Skip to content

perf(studio): preview scale selector and prefetch worker pool#99

Merged
LeadcodeDev merged 3 commits into
mainfrom
feat/studio-preview-scale-workers
Jul 20, 2026
Merged

perf(studio): preview scale selector and prefetch worker pool#99
LeadcodeDev merged 3 commits into
mainfrom
feat/studio-preview-scale-workers

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

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

  • New quality select in the transport bar: 100 / 75 / 50 / 25 % (default 50 %). Export always renders at 100 %.
  • The scale lives in an AtomicU16 (readable from the asset handler and render threads without a UI runtime) mirrored by a signal for the <img> cache-bust.
  • FrameKey gains scale_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_prefetcher now spawns worker_count(cores) threads (cores − 2, clamped to [2, 6]) instead of one.
  • Workers spread over the nearest-first window through a shared ClaimSet — no frame is rendered twice concurrently; a claim is released even when the render panics (panic fence unchanged).
  • The abort fingerprint now includes the scale, so a scale change retargets in-flight passes immediately.

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 --workspace green (studio crate: 114 tests, incl. new scale-eviction, claim-set, worker-count, and half-scale JPEG dimension tests)
  • cargo fmt --check clean; clippy gate ≤ 1 pre-existing warning
  • Release build compiles

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
@LeadcodeDev

Copy link
Copy Markdown
Owner Author

Follow-up commit 67f073a — investigation of the freeze + OOM reported while playing dynamic-glass with this branch.

Evidence

  • Isolated worker stress (6 threads × 360 heavy frames): RSS flat at 124 MB (0.5×) / 368 MB (1.0×), ~90 fps throughput — no leak in the render+JPEG path.
  • 60 s full-pipeline soak (real worker pool, moving 30 fps playhead, simulated asset handler + canvas hit pass): RSS oscillates 122–482 MB with no growth trend; 3 cache misses in 1200 served frames.
  • No macOS jetsam/crash report for the studio; the native pipeline is exonerated.

Identified failure mode (matches the symptom exactly)

The global cosmic-text mutexes were locked with .unwrap(). One Skia panic on any render thread — the panic fences prove those happen — poisons FONT_SYSTEM/SWASH_CACHE, after which every text shape on every thread panics forever: the preview freezes on the last served frame, and the six workers re-render panicking frames in a tight loop (hundreds of attempts/s of alloc/unwind churn) until the app is killed.

Hardening

  • cosmic.rs: poison-tolerant locks (unwrap_or_else(|e| e.into_inner())) + a regression test that deliberately poisons the mutex and shapes through it.
  • FailLedger in the studio: a frame whose render panics gets 2 attempts (workers and asset handler), then is skipped — a panicking frame degrades to a stale image instead of a render-panic loop.
  • The ignored stress_rss/soak_full diagnostics are kept for future perf work.

If the OOM ever recurs, check Activity Monitor: app process vs WebKit.WebContent growth now distinguishes a native issue from webview image churn.

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.
@LeadcodeDev

Copy link
Copy Markdown
Owner Author

Root cause of the freeze — found by sampling the frozen process (commit 8cb3b58).

Sampled PID while frozen at 149/449: the main thread sits at 100% in a Dioxus re-render loop, stack deep in Skia SaveLayerRec / internalDrawDeviceWithFilter — i.e. it is painting backdrop-filters. Not a deadlock, not a lock: the main thread is CPU-bound.

Source: the Inspect hit-map. Canvas computes it via render_scene_hits, which runs a full paint pass (backdrop-filter blur + noise + grain) on a full-resolution 1920×1080 raster surface, on the main thread, once per frame. On dynamic-glass that is ~150 ms/frame; at 30 fps the main thread never catches up and the UI wedges. It's deterministic around the scene-1→2 transition where two glass cards double the cost — hence the repeatable 149. Preview scale didn't help because render_scene_hits renders at full config.width/height, ignoring the scale.

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 !playing via a pure should_compute_hits(show_hits, side_a, playing) (+3 tests). During playback the overlay is skipped entirely — the main thread stays free, playback is smooth — and it returns the instant you pause.

This commit is the actual freeze fix; 67f073a (panic-poison hardening) and the worker pool / preview scale remain valuable but were treating symptoms. Please cargo run -p rustmotion-studio --release and retry — playback should be smooth now, at any preview scale.

@LeadcodeDev
LeadcodeDev merged commit 0d3bb96 into main Jul 20, 2026
3 checks passed
@LeadcodeDev
LeadcodeDev deleted the feat/studio-preview-scale-workers branch July 20, 2026 08:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant