Preview: frame lanes are four threads wide, and cache_bench measures the cadence

This commit is contained in:
nazar-gavrylyk committed 2026-11-10 15:03:10 +01:00
1 parent 893d9a0b04
commit 3709cccedf
4 files changed
+91 -20

No files matched your search

+52 -8
View File
@@ -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<String> {
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<usize> {
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::<u32, u64>::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::<f64>::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<f64> = 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<f64> = 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<String> = 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(", "));
+3 -6
View File
@@ -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()
}
}
+32 -2
View File
@@ -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<rayon::ThreadPool> = (0..threads)
let width = width.clamp(1, (threads / 2).max(1));
#[cfg(not(target_arch = "wasm32"))]
let lanes: Vec<rayon::ThreadPool> = (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
+4 -4
View File
@@ -215,7 +215,7 @@ fn preview_with_audio_holds_the_first_frame_and_sounds_from_it() {
let gate = GatedFrames::default();
let renderer: Arc<dyn RemoteFrames> = 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<i64> = (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;