perf(studio): preview scale selector and prefetch worker pool#99
Conversation
Heavy scenarios (glass, camera, parallax) cost ~150ms CPU per frame at full resolution — 4.5x the 33ms budget of 30fps playback — and the single prefetch thread rendered at scale 1.0, so the cache drained and playback stalled while the exported MP4 (parallel, offline) was smooth. - render preview frames at a UI-selected scale (100/75/50/25%, default 50%); the scale is part of the frame cache key and a scale switch purges stale-scale entries on first insert - replace the single prefetch thread with a worker pool (cores-2, clamped to [2,6]); workers spread over the window through a shared claim set so no frame is rendered twice concurrently - the transport bar gains a preview-quality select; export still renders at 100%
Investigation of an app freeze + OOM while playing dynamic-glass with the worker pool. Isolated soaks exonerate the native pipeline: 6 workers at 0.5/1.0 scale hold a flat RSS (124/368 MB over 360 heavy frames), and a 60s full-pipeline soak (moving 30fps playhead, simulated asset handler, canvas hit pass) stays bounded at 122-482 MB with 3 cache misses in 1200 served frames. The identified failure mode is a panic cascade: the global cosmic-text mutexes were locked with .unwrap(), so one Skia panic on any render thread (caught by the panic fences) poisoned FONT_SYSTEM/SWASH_CACHE and turned every subsequent text shape on every thread into a panic — preview permanently stuck, six workers re-rendering panicking frames in a tight loop. - cosmic: poison-tolerant font/swash locks + regression test that deliberately poisons the mutex and shapes through it - studio: FailLedger — a frame whose render panics gets MAX_RENDER_ATTEMPTS tries (workers and asset handler), then is left alone instead of looping - keep the ignored stress/soak diagnostics for future perf work
|
Follow-up commit Evidence
Identified failure mode (matches the symptom exactly)The global cosmic-text mutexes were locked with Hardening
If the OOM ever recurs, check Activity Monitor: app process vs |
Root cause of the preview freeze on dynamic-glass, found by sampling the frozen process: the main thread sat at 100% in a Dioxus re-render loop, its stack deep in Skia SaveLayerRec / internalDrawDeviceWithFilter. The Inspect hit-map is computed by render_scene_hits, which runs a FULL paint pass — backdrop-filters, blur, noise, grain included — on a full-resolution raster surface, on the MAIN thread, once per frame. On a glass-heavy scene that is ~150ms/frame; at 30fps playback the main thread never keeps up and the UI wedges (deterministically, around the scene-1→2 transition where two glass cards raise the cost). Preview scale never helped because the hit paint ignores it. The hit-map only matters at rest (hover/click to inspect); recomputing it 30x/s during playback is pure waste. Gate it on !playing via the pure should_compute_hits(show_hits, side_a, playing); it returns instantly on pause. Fixes the freeze independently of the worker pool.
|
Root cause of the freeze — found by sampling the frozen process (commit Sampled PID while frozen at 149/449: the main thread sits at 100% in a Dioxus re-render loop, stack deep in Skia Source: the Inspect hit-map. This is a pre-existing issue (not the worker pool) that dynamic-glass was simply the first scene heavy enough to trip. Fix: the hit-map only matters at rest (hover/click to inspect). Gate it on This commit is the actual freeze fix; |
Problem
Playing
examples/dynamic-glass.json(PR #98) in the studio stutters while its exported MP4 is smooth. Measured root cause: the scenario costs ~150 ms CPU per frame at full resolution (67.9 s CPU / 450 frames on export) — 4.5× the 33 ms budget of 30 fps playback. The preview pipeline rendered every frame at scale 1.0 (prefetch.rs) on a single prefetch thread, so frame production peaked at ~6 fps, the cache drained, and playback stalled. The export is smooth because it renders frames in parallel offline, then plays at a fixed rate.Changes
Preview render scale, selectable from the UI
AtomicU16(readable from the asset handler and render threads without a UI runtime) mirrored by a signal for the<img>cache-bust.FrameKeygainsscale_pct: a frame rendered at 50 % is never served for 100 %, and a scale switch purges all stale-scale entries on the next insert.Prefetch worker pool
ensure_prefetchernow spawnsworker_count(cores)threads (cores − 2, clamped to [2, 6]) instead of one.ClaimSet— no frame is rendered twice concurrently; a claim is released even when the render panics (panic fence unchanged).Expected effect on dynamic-glass: ~150 ms → ~40 ms/frame at 50 % scale, across up to 6 workers → production comfortably above 30 fps.
Verification
cargo test --workspacegreen (studio crate: 114 tests, incl. new scale-eviction, claim-set, worker-count, and half-scale JPEG dimension tests)cargo fmt --checkclean; clippy gate ≤ 1 pre-existing warning