From 3709cccedf9e63cf23570ba3f27dee5d459fd18a Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Sun, 8 Nov 2026 17:57:50 +0100 Subject: [PATCH] Preview: frame lanes are four threads wide, and cache_bench measures the cadence --- crates/ui-egui/examples/cache_bench.rs | 60 ++++++++++++++++++++--- crates/ui-egui/src/app/prefetch.rs | 9 ++-- crates/ui-egui/src/frames.rs | 34 ++++++++++++- crates/ui-egui/tests/it/ui_audio_scrub.rs | 8 +-- 4 files changed, 91 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/examples/cache_bench.rs b/crates/ui-egui/examples/cache_bench.rs index 9cfd5b26b..43e937c92 100644 --- a/crates/ui-egui/examples/cache_bench.rs +++ b/crates/ui-egui/examples/cache_bench.rs @@ -10,8 +10,8 @@ //! //! Options: `--project P --comp C` (a saved project's comp instead), `--scale S` (render scale, //! default 0.5: a viewer at half size), `--frames N` (frames to cache, default the whole comp), -//! `--slots N` (frames in flight, at least 1, default one per frame thread as the app does when -//! idle), +//! `--slots N` (frames in flight, at least 1, default one per lane as the app does when idle), +//! `--lane-width W` (threads per lane; the app's default when absent; `--play` takes it too), //! `--disk` (with a fresh disk cache in `target/cache-bench/disk`), `--no-adjustment`, //! `--gpu` (composite on a GPU of its own, as the desktop app does with Mercury GPU Acceleration: //! the GPU or the CPU, whichever Auto measures faster per comp, read back to the CPU; the viewer's @@ -37,6 +37,11 @@ fn arg(name: &str) -> Option { a.iter().position(|x| x == name).and_then(|i| a.get(i + 1).cloned()) } +/// `--lane-width W`: threads per frame lane (the app's default when absent). +fn lane_width() -> Option { + arg("--lane-width").and_then(|v| v.parse().ok()) +} + fn run(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } @@ -130,9 +135,9 @@ fn main() { let n = arg("--frames").and_then(|v| v.parse().ok()).unwrap_or(total).min(total); let scale: f64 = arg("--scale").and_then(|v| v.parse().ok()).unwrap_or(0.5); - let frames = Frames::default(); - // (as Cache Frames When Idle: one frame per frame thread, see `app/prefetch.rs`) - let slots: usize = arg("--slots").and_then(|v| v.parse().ok()).map(|n: usize| n.max(1)).unwrap_or(frames.parallelism().max(1)); + let frames = lane_width().map_or_else(Frames::default, Frames::with_lane_width); + // (as Cache Frames When Idle: one frame per lane, see `app/prefetch.rs`) + let slots: usize = arg("--slots").and_then(|v| v.parse().ok()).map(|n: usize| n.max(1)).unwrap_or(frames.lanes()); // Room for every frame: this measures speed, not eviction. frames.set_budget(64 << 30); use effectcraft_engine::render::Backend; @@ -168,9 +173,10 @@ fn main() { }; eprintln!( - "caching {n} frames of {}×{} at scale {scale} with {slots} in flight{}…", + "caching {n} frames of {}×{} at scale {scale} with {slots} in flight on {} lanes{}…", comp.width, comp.height, + frames.lanes(), if src.gpu.is_some() { " on the GPU" } else { "" } ); let (gop0, t0) = (effectcraft_media::gop_stats(), Instant::now()); @@ -224,6 +230,9 @@ fn play(session: effectcraft_engine::Session, cid: ItemId, secs: f64, gpu: bool) let mut h = builder.build_eframe(move |_| EffectcraftApp::new(session).with_control(rx)); { let app = h.state_mut(); + if let Some(w) = lane_width() { + app.frames = effectcraft_ui_egui::frames::Frames::with_lane_width(w); + } let _ = app.session.execute("comp.open", json!({"comp": cid.0})); app.ui.viewer.zoom = Some(1.0); app.ui.cache_when_idle = false; @@ -242,6 +251,10 @@ fn play(session: effectcraft_engine::Session, cid: ItemId, secs: f64, gpu: bool) let _ = tx.send(ControlRequest { method: "ui.playback".into(), params: json!({"action": "play"}), reply }); let t0 = Instant::now(); let (mut started, mut stalled, mut last, mut scales) = (None, 0.0, t0, std::collections::BTreeMap::::new()); + // When each new frame went on screen (seconds since play was pressed): the cadence. The + // current time, not `Playback::shown`: frames shown while the clock holds don't count there. + let fr = h.state().session.project.comp(cid).map(|c| c.frame_rate); + let (mut shown_at, mut last_frame) = (Vec::::new(), None); while t0.elapsed().as_secs_f64() < secs { h.step(); let now = Instant::now(); @@ -252,20 +265,51 @@ fn play(session: effectcraft_engine::Session, cid: ItemId, secs: f64, gpu: bool) stalled += (now - last).as_secs_f64(); } } + let frame = fr.map(|fr| fr.frame_at(app.session.time())); + if app.playback.playing && frame != last_frame { + last_frame = frame; + shown_at.push((now - t0).as_secs_f64()); + } *scales.entry(app.shown_series(cid).scale).or_default() += 1; last = now; std::thread::sleep(Duration::from_millis(2)); } let app = h.state(); let played = started.map_or(0.0, |t| t.elapsed().as_secs_f64()); - let shown = app.playback.shown; - let fr = app.session.project.comp(cid).map(|c| c.frame_rate); + let shown = shown_at.len(); let frame = fr.map_or(0, |fr| fr.frame_at(app.session.time())); println!( "played {shown} frames shown in {played:.2} s: {:.1} frames/s, stalled {stalled:.2} s, reached frame {frame}{}", shown as f64 / played.max(1e-9), if gpu { " (GPU)" } else { "" } ); + // Cadence: how evenly the frames went on screen (the intervals between consecutive shown + // frames after the first), and how long the first two took. + let mut gaps: Vec = shown_at.windows(2).map(|w| w[1] - w[0]).collect(); + gaps.sort_by(f64::total_cmp); + let at = |q: f64| gaps.get(((gaps.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0) * 1000.0; + let holds = gaps.iter().filter(|g| **g > 0.2).count(); + println!( + "cadence first frame {:.2} s, second {:.2} s; gaps median {:.0} ms, p95 {:.0} ms, max {:.0} ms, {holds} holds > 200 ms", + shown_at.first().copied().unwrap_or(0.0), + shown_at.get(1).copied().unwrap_or(0.0), + at(0.5), + at(0.95), + at(1.0) + ); + // The opening (the first 3 s, before an adaptive step down can have taken effect): how + // evenly the uncached frames at the starting resolution went on screen. + let mut early: Vec = shown_at.windows(2).filter(|w| w[1] <= 3.0).map(|w| w[1] - w[0]).collect(); + early.sort_by(f64::total_cmp); + let at = |q: f64| early.get(((early.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0) * 1000.0; + println!( + "opening (3 s) {} frames; gaps median {:.0} ms, p95 {:.0} ms, max {:.0} ms, {} holds > 200 ms", + early.len() + usize::from(!early.is_empty()), + at(0.5), + at(0.95), + at(1.0), + early.iter().filter(|g| **g > 0.2).count() + ); let passes: u64 = scales.values().sum(); let s: Vec = scales.iter().map(|(k, v)| format!("{:.3}: {:.0} %", *k as f64 / 1000.0, 100.0 * *v as f64 / passes.max(1) as f64)).collect(); println!("viewer scale {}", s.join(", ")); diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index 3da62a17c..f31df7094 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -549,13 +549,10 @@ impl EffectcraftApp { side(comp.width) * side(comp.height) * frames::frame_pixel_bytes(&self.session.project.settings) as f64 } + /// Frames rendered at once by a preview or idle caching: the lanes (in the browser the frame + /// workers; without them frames render one at a time on the UI thread, `Frames::pump`). fn frames_parallelism(&self) -> usize { - if cfg!(target_arch = "wasm32") { - // frame workers render one frame each; without them frames render one at a time - // on the UI thread (`Frames::pump`) - return self.frames.remote_slots().max(1); - } - self.frames.parallelism() + self.frames.lanes() } } diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 800306385..2d51068b0 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -707,15 +707,36 @@ struct DiskCarry { #[cfg(not(target_arch = "wasm32"))] const MAX_DISK_SERIES: usize = 8; +/// Threads per lane (see [`Queue::lanes`]). A frame's passes are memory-bound and stop scaling +/// at about 4 threads, so a core per lane (12 frames at once, each on one thread) and 4 cores +/// per lane cache frames at the same rate (within 3 %, widths 1–4 measured on 12 cores); but the +/// 12 single-threaded frames start and finish together, so a preview that can't keep up shows +/// them in bursts, and its second frame waits for the whole batch (0.3–1.3 s). Three lanes of +/// four threads finish frames 3× as often and the second frame shows in 0.13–0.45 s. +/// Never fewer than two lanes: one would finish frames in a single file. +const LANE_WIDTH: usize = 4; + impl Default for Frames { fn default() -> Self { + Self::with_lane_width(LANE_WIDTH) + } +} + +impl Frames { + /// A frame cache whose lanes are `width` threads each (see [`LANE_WIDTH`]); `Default` uses + /// [`LANE_WIDTH`], benchmarks compare widths. + pub fn with_lane_width(width: usize) -> Self { + #[cfg(target_arch = "wasm32")] + let _ = width; #[cfg(not(target_arch = "wasm32"))] let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16); #[cfg(not(target_arch = "wasm32"))] - let lanes: Vec = (0..threads) + let width = width.clamp(1, (threads / 2).max(1)); + #[cfg(not(target_arch = "wasm32"))] + let lanes: Vec = (0..(threads / width).max(1)) .map_while(|i| { rayon::ThreadPoolBuilder::new() - .num_threads(1) + .num_threads(width) .thread_name(move |_| format!("ec-preview-{i}")) .panic_handler(|_| log::error!("a frame render panicked; that frame was skipped")) .build() @@ -765,6 +786,15 @@ impl Default for Frames { disk_series: Arc::default(), } } + + /// How many frames render at once on the lanes (a playing preview's prefetch, Cache Frames + /// When Idle); in the browser, the frame workers. + pub fn lanes(&self) -> usize { + #[cfg(target_arch = "wasm32")] + return self.remote_slots().max(1); + #[cfg(not(target_arch = "wasm32"))] + self.queue.lock().map_or(1, |q| q.lanes.max(1)) + } } /// Whether viewer frames of a project with `settings` are half floats ([`F16Frame`], rendered diff --git a/crates/ui-egui/tests/it/ui_audio_scrub.rs b/crates/ui-egui/tests/it/ui_audio_scrub.rs index ae724c9ad..d177d5654 100644 --- a/crates/ui-egui/tests/it/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/it/ui_audio_scrub.rs @@ -215,7 +215,7 @@ fn preview_with_audio_holds_the_first_frame_and_sounds_from_it() { let gate = GatedFrames::default(); let renderer: Arc = Arc::new(gate.clone()); app.frames.set_remote(Some(renderer)); - let ahead = (app.frames.parallelism() * 2).max(4) as i64; + let ahead = (app.frames.lanes() * 2).max(4) as i64; app.session.execute("comp.settings", json!({"comp": cid.0, "duration": (4 * ahead) as f64 / 30.0})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); h.state_mut().use_manual_clock(); @@ -258,7 +258,7 @@ fn preview_with_audio_sounds_without_caching_the_work_area() { h.state_mut().use_manual_clock(); h.run_steps(2); gate.discard(&mut h); - let ahead = (h.state().frames.parallelism() * 2).max(4) as i64; + let ahead = (h.state().frames.lanes() * 2).max(4) as i64; let now = h.state().now(); h.state_mut().play(now); let frames: Vec = (0..ahead).collect(); @@ -439,7 +439,7 @@ fn until_started(h: &mut Harness<'_, EffectcraftApp>) -> bool { fn startup_finishes_at_any_ram_preview_budget() { let probe = Fake::default(); let (app, _) = setup(true, &probe); - let ahead = (app.frames.parallelism() * 2).max(4) as i64; + let ahead = (app.frames.lanes() * 2).max(4) as i64; // A 64×36 frame is 9216 bytes; `fit` frames fit in (fit + ½) frames' worth / 0.9. let mut fits = vec![2, 3, 4, ahead - 1, ahead, ahead + 1]; fits.sort(); @@ -505,7 +505,7 @@ fn extended_viewer(fake: &Fake) -> (Harness<'static, EffectcraftApp>, ItemId, [u fn startup_finishes_when_extended_viewer_frames_are_larger() { let fake = Fake::default(); let (mut h, cid, size) = extended_viewer(&fake); - let ahead = (h.state().frames.parallelism() * 2).max(4); + let ahead = (h.state().frames.lanes() * 2).max(4); let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); // Room for `ahead` comp-sized frames (and a half), which is fewer real ones. let budget = ((ahead as f64 + 0.5) * comp_bytes as f64 / 0.9) as usize;