filmcraft/crates/ui-egui/examples/bench/sections.rs

504 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The benchmark sections. Each returns one JSON row per measured case.
use std::sync::Arc;
use std::time::{Duration, Instant};
use filmcraft_engine::Session;
use filmcraft_project::{ItemId, Project, SequenceSettings, TrackKind};
use filmcraft_time::{FrameRate, Tick, TimeRange};
use filmcraft_ui_egui::frames::{FrameServer, process_cpu_time};
use serde_json::{Value, json};
use crate::Opts;
use crate::fixtures::fixture;
use crate::playback::{self, Bench, Display, load_avg, ms, pct};
fn median(v: &[f64]) -> f64 {
pct(v, 0.5)
}
fn cpu_now() -> Duration {
process_cpu_time().unwrap_or_default()
}
// ------------------------------------------------------------------------------------ decode
/// (fixture, codec, size)
const DECODE: &[(&str, &str, &str)] = &[
("dec_h264_1080.mp4", "H.264", "1080p"),
("dec_h264_2160.mp4", "H.264", "2160p"),
("dec_hevc_1080.mp4", "HEVC", "1080p"),
("dec_hevc_2160.mp4", "HEVC", "2160p"),
("dec_vp9_1080.webm", "VP9", "1080p"),
("dec_vp9_2160.webm", "VP9", "2160p"),
("dec_av1_1080.mp4", "AV1", "1080p"),
("dec_av1_2160.mp4", "AV1", "2160p"),
("dec_prores_1080.mov", "ProRes 422 HQ", "1080p"),
("dec_prores_2160.mov", "ProRes 422 HQ", "2160p"),
];
/// Sequential decode of every frame through the media stack (container source, GOP cache,
/// decoder, frame conversion), as playback reads a clip. A fresh source per repeat.
pub fn decode(o: &Opts) -> Vec<Value> {
let mut rows = Vec::new();
for &(name, codec, size) in DECODE.iter().filter(|d| o.wants(d.0)) {
let Some(path) = fixture(name) else {
eprintln!("decode {name}: skipped (fixture unavailable)");
continue;
};
let bytes: Arc<[u8]> = std::fs::read(&path).expect("read fixture").into();
let mut fps = Vec::new();
let mut cpu = Vec::new();
let mut first = Vec::new();
let mut frames = 0;
let mut rate = FrameRate::FPS_23_976;
for _ in 0..o.repeat {
let src = match filmcraft_codecs::open_bytes(name, bytes.clone()) {
Ok(s) => s,
Err(e) => {
eprintln!("decode {name}: {e}");
break;
}
};
rate = src.info().frame_rate();
let n = rate.frame_at(src.info().duration).max(1);
let n = if o.quick { n.min(48) } else { n };
frames = n;
let (t0, c0) = (Instant::now(), cpu_now());
for f in 0..n {
if let Err(e) = src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(f))) {
eprintln!("decode {name} frame {f}: {e}");
break;
}
if f == 0 {
first.push(ms(t0.elapsed()));
}
}
let dt = t0.elapsed().as_secs_f64();
fps.push(n as f64 / dt);
cpu.push(ms(cpu_now() - c0) / n as f64);
}
if fps.is_empty() {
continue;
}
let best = fps.iter().cloned().fold(0.0, f64::max);
let row = json!({
"fixture": name, "codec": codec, "size": size, "frames": frames,
"fps": best, "fps_min": fps.iter().cloned().fold(f64::MAX, f64::min), "fps_runs": fps,
"cpu_ms_per_frame": median(&cpu), "first_frame_ms": median(&first), "realtime": best / rate.as_f64(),
"decode_threads": rayon::current_num_threads(), "load": load_avg(),
});
eprintln!("decode {name}: {best:.1} fps");
rows.push(row);
}
rows
}
// ------------------------------------------------------------------------------------ seek
/// Cold seeks through the media stack, the three catch-up modes interleaved per target so they
/// see the same machine load: `full` (decode every picture from the keyframe), `keep 2 s` (what a
/// scrub request does: skip non-reference pictures more than 2 s before the target) and `late`
/// (playback catching up: skip every non-reference picture before the target). A fresh source per
/// seek (cold GOP cache). CPU time and samples decoded do not depend on load; wall time does.
pub fn seek(o: &Opts) -> Vec<Value> {
let seeks = if o.quick { 4 } else { 12 };
let modes: [(&str, Option<Tick>); 3] = [("full", None), ("keep 2 s", Some(Tick::from_seconds_f64(2.0))), ("late", Some(Tick::ZERO))];
let mut rows = Vec::new();
for name in ["a1080.mp4", "a2160.mp4", "hevc2160.mp4"].into_iter().filter(|n| o.wants(n)) {
let Some(path) = fixture(name) else {
eprintln!("seek {name}: skipped (fixture unavailable)");
continue;
};
let bytes: Arc<[u8]> = std::fs::read(&path).expect("read fixture").into();
let mut rng = 0x2545_f491_4f6c_dd1du64;
let (mut wall, mut cpu, mut dec, mut skip) = ([vec![], vec![], vec![]], [vec![], vec![], vec![]], [0u64; 3], [0u64; 3]);
for _ in 0..seeks {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
for (m, (_, margin)) in modes.iter().enumerate() {
let src = filmcraft_codecs::open_bytes(name, bytes.clone()).expect("open");
let rate = src.info().frame_rate();
let n = rate.frame_at(src.info().duration).max(1);
let t = rate.tick_of((rng % n as u64) as i64);
let (g0, t0, c0) = (filmcraft_codecs::gop_stats(), Instant::now(), cpu_now());
filmcraft_media::cancel::with_catch_up(*margin, || src.video_frame(filmcraft_media::FrameRequest::full(t))).expect("frame");
wall[m].push(ms(t0.elapsed()));
cpu[m].push(ms(cpu_now() - c0));
let g = filmcraft_codecs::gop_stats() - g0;
dec[m] += g.decoded;
skip[m] += g.skipped;
}
}
for (m, (mode, _)) in modes.iter().enumerate() {
let row = json!({
"fixture": name, "mode": mode, "seeks": seeks,
"p50_ms": pct(&wall[m], 0.5), "p95_ms": pct(&wall[m], 0.95), "cpu_ms_mean": cpu[m].iter().sum::<f64>() / seeks as f64,
"decoded_per_seek": dec[m] as f64 / seeks as f64, "skipped_per_seek": skip[m] as f64 / seeks as f64, "load": load_avg(),
});
eprintln!("seek {name} {mode}: p50 {:.0} ms, CPU {:.0} ms", pct(&wall[m], 0.5), cpu[m].iter().sum::<f64>() / seeks as f64);
rows.push(row);
}
}
rows
}
// ------------------------------------------------------------------------------------ playback
fn new_bench(s: &Session, seconds: f64) -> Bench {
let server = FrameServer::new(s.media.clone(), s.services.clone(), s.previews.clone(), FrameServer::default_workers());
server.set_profiling(true);
Bench { server, refresh_hz: 60.0, seconds, draft: false }
}
/// Program-monitor playback through the real scheduler (see `playback.rs`), frames shown vs
/// dropped over N seconds.
pub fn playback(o: &Opts) -> Vec<Value> {
let seconds = if o.quick { 4.0 } else { 8.0 };
// (scenario, resolution, scale, draft decoding)
let cases: &[(&str, &str, f32, bool)] = &[
("h264-1080", "full", 1.0, false),
("stack3", "full", 1.0, false),
("h264-2160", "full", 1.0, false),
("h264-2160", "half", 0.5, false),
("h264-2160", "half draft", 0.5, true),
("h264-2160", "quarter", 0.25, false),
("h264-2160", "quarter draft", 0.25, true),
("hevc-2160", "full", 1.0, false),
("hevc-2160", "half", 0.5, false),
];
let mut rows = Vec::new();
for &(scenario, res, scale, draft) in cases.iter().filter(|c| o.wants(&format!("{} {}", c.0, c.1))) {
let Some((s, seq)) = playback::scenario_session(scenario) else {
eprintln!("playback {scenario}: skipped (fixtures unavailable)");
continue;
};
let fps = s.project.sequence(seq).map(|q| q.settings.frame_rate.as_f64()).unwrap_or(24.0);
for rep in 0..o.repeat {
// A fresh frame server per run: caches start cold, as after opening a project.
let mut bench = new_bench(&s, seconds);
bench.draft = draft;
let mut display = Display::new(o.gpu);
let path = if display.gpu_available() { "gpu" } else { "cpu" };
let r = bench.play(&s, seq, &mut display, scale, Tick::ZERO, &format!("{scenario} {res} #{}", rep + 1));
let mut v = playback::to_json(&r, scenario, res, path);
let frames = (r.shown + r.dropped).max(1) as f64;
v["drop_pct"] = json!(100.0 * r.dropped as f64 / frames);
v["decode_ms"] = json!(r.src_wall.iter().sum::<f64>() / r.jobs.max(1) as f64);
v["ui_p95_ms"] = json!(pct(&r.present, 0.95));
v["seeks"] = json!(r.gop.seeks);
v["skipped"] = json!(r.gop.skipped);
v["draft_frames"] = json!(r.gop.draft);
v["load"] = json!(r.load);
v["cores_needed"] = json!(r.process_cpu / frames * fps / 1000.0);
eprintln!("playback {scenario} {res}: {}/{} shown/dropped (load {})", r.shown, r.dropped, r.load);
rows.push(v);
}
}
rows
}
// ------------------------------------------------------------------------------------ scrub
/// Scrubbing: random seeks and playhead drags at full resolution; time until the exact frame
/// under the playhead is on screen.
pub fn scrub(o: &Opts) -> Vec<Value> {
let (seeks, drags) = if o.quick { (8, 3) } else { (24, 8) };
let mut rows = Vec::new();
for scenario in ["h264-1080", "h264-2160", "hevc-2160"].into_iter().filter(|s| o.wants(s)) {
let Some((s, seq)) = playback::scenario_session(scenario) else {
eprintln!("scrub {scenario}: skipped (fixtures unavailable)");
continue;
};
for _ in 0..o.repeat {
let bench = new_bench(&s, 0.0);
let mut display = Display::new(o.gpu);
let r = bench.scrub(&s, seq, &mut display, 1.0, seeks, drags, 24, Duration::from_secs(8));
let positions = (seeks + drags) as f64;
let row = json!({
"scenario": scenario,
"seek_p50_ms": pct(&r.seek_ms, 0.5), "seek_p95_ms": pct(&r.seek_ms, 0.95), "seek_max_ms": pct(&r.seek_ms, 1.0),
"settle_p50_ms": pct(&r.settle_ms, 0.5), "settle_p95_ms": pct(&r.settle_ms, 0.95),
"drag_shown_pct": 100.0 * r.drag_shown as f64 / r.drag_steps.max(1) as f64,
"timeouts": r.timeouts, "gop_seeks": r.gop.seeks, "decoded": r.gop.decoded, "skipped": r.gop.skipped,
"decoded_per_seek": r.gop.decoded as f64 / positions, "process_cpu_ms": r.process_cpu_ms, "load": r.load,
});
eprintln!("scrub {scenario}: seek p50 {:.0} ms p95 {:.0} ms (load {})", pct(&r.seek_ms, 0.5), pct(&r.seek_ms, 0.95), r.load);
rows.push(row);
}
}
rows
}
// ------------------------------------------------------------------------------------ big projects
/// A sequence of 20 tracks (12 video, 8 audio) × 50 clips = 1000 clips of the small clip fixture,
/// 1–3 s each with small gaps, varied source in-points. Returns the sequence and its clip count.
pub fn big_sequence(s: &mut Session, item: ItemId, name: &str, seed: u64) -> (ItemId, usize) {
let rate = FrameRate::FPS_23_976;
let src_frames = rate.frame_at(Tick::from_seconds_f64(19.0));
let mut rng = seed | 1;
let mut next = move || {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
rng
};
s.edit("bench sequence", |p: &mut Project, st| {
let settings = SequenceSettings { width: 1920, height: 1080, frame_rate: rate, ..SequenceSettings::default() };
let seq = p.new_sequence(name, settings, 12, 8, None);
let mut clips = 0;
for t in 0..20 {
let kind = if t < 12 { TrackKind::Video } else { TrackKind::Audio };
let mut at = (next() % 24) as i64;
let mut items = Vec::new();
for _ in 0..50 {
let len = 24 + (next() % 48) as i64;
let src_in = (next() % (src_frames - len) as u64) as i64;
let mut it =
p.make_track_item(item, kind, rate.tick_of(at), TimeRange::new(rate.tick_of(src_in), rate.tick_of(len)), rate).expect("track item");
// "auto" (NaN) Motion points resolve against the frame, as the edit commands do
for e in &mut it.effects {
filmcraft_project::resolve_auto_points(e, (1920, 1080), (640, 360));
}
items.push(it);
at += len + (next() % 12) as i64;
clips += 1;
}
let q = p.sequence_mut(seq).expect("seq");
if t < 12 {
q.video_tracks[t].items = items;
} else {
q.audio_tracks[t - 12].items = items;
}
}
st.active_sequence = Some(seq);
st.open_sequences = vec![seq];
st.playheads.insert(seq, Tick::ZERO);
Ok((seq, clips))
})
.expect("big sequence")
}
// ------------------------------------------------------------------------------------ timeline UI
struct Ui {
harness: egui_kittest::Harness<'static, filmcraft_ui_egui::FilmcraftApp>,
tx: std::sync::mpsc::Sender<filmcraft_ui_egui::control::ControlRequest>,
}
impl Ui {
fn frame(&mut self) {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
fn call(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = filmcraft_ui_egui::control::ControlRequest::new(method, params.clone());
self.tx.send(req).expect("control");
for _ in 0..600 {
self.frame();
if let Ok(v) = reply.try_recv() {
return v;
}
}
panic!("no reply to {method} {params}");
}
}
/// Timeline UI frame time with a 1000-clip / 20-track sequence: the real app under
/// `egui_kittest` (no window), per frame: app update (layout + painting), tessellation, and the
/// wgpu render of the frame when a GPU adapter exists.
pub fn timeline(o: &Opts) -> Vec<Value> {
let Some(path) = fixture("clip360.mp4") else {
eprintln!("timeline: skipped (fixture unavailable)");
return Vec::new();
};
let gpu = o.gpu && Display::new(true).gpu_available();
let frames = if o.quick { 40 } else { 120 };
let mut rows = Vec::new();
for _ in 0..o.repeat {
let mut s = Session::default();
let item = playback::import(&mut s, &path);
let (_, clips) = big_sequence(&mut s, item, "Timeline 1000", 7);
let (tx, rx) = std::sync::mpsc::channel();
let app = filmcraft_ui_egui::FilmcraftApp::new(s).with_control(rx);
let mut b = egui_kittest::Harness::builder().with_size(egui::vec2(1920.0, 1080.0)).with_max_steps(100_000);
if gpu {
b = b.wgpu();
}
let harness = b.build_eframe(move |_cc| app);
let mut ui = Ui { harness, tx };
for _ in 0..8 {
ui.frame();
}
ui.call("ui.set", json!({"timeline": {"videoTrackHeight": 34, "audioTrackHeight": 30}}));
for (view, setup) in
[("fit: all 1000 clips", json!({"timeline": {"fit": true}})), ("zoomed, scrolling", json!({"timeline": {"pps": 120.0, "scroll": 0.0}}))]
{
ui.call("ui.set", setup);
// settle the zoom animation, let thumbnails and waveforms arrive
let t_settle = Instant::now();
while t_settle.elapsed() < Duration::from_millis(if o.quick { 800 } else { 2500 }) {
ui.frame();
std::thread::sleep(Duration::from_millis(5));
}
let (mut upd, mut tess, mut rend, mut total) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
let mut vertices = 0usize;
let mut shapes = 0usize;
let scrolling = view.starts_with("zoomed");
for i in 0..frames {
// Pointer moving over the timeline (hover feedback), and a scroll step when zoomed.
let x = 300.0 + (i as f32 * 13.0) % 1500.0;
ui.harness.hover_at(egui::pos2(x, 820.0));
if scrolling {
let tl = &mut ui.harness.state_mut().ui.timeline;
tl.target_scroll += 0.2;
tl.scroll = tl.target_scroll;
}
let t0 = Instant::now();
ui.frame();
let u = ms(t0.elapsed());
let out = ui.harness.output();
shapes = out.shapes.len();
let t1 = Instant::now();
let prims = ui.harness.ctx.tessellate(out.shapes.clone(), out.pixels_per_point);
let te = ms(t1.elapsed());
vertices = prims
.iter()
.map(|p| match &p.primitive {
egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
_ => 0,
})
.sum();
let r = if gpu {
let t2 = Instant::now();
let _ = ui.harness.render();
Some(ms(t2.elapsed()))
} else {
None
};
upd.push(u);
tess.push(te);
total.push(u + r.unwrap_or(te));
if let Some(r) = r {
rend.push(r);
}
}
eprintln!("timeline {view}: update p50 {:.2} ms, p95 {:.2} ms", pct(&upd, 0.5), pct(&upd, 0.95));
rows.push(json!({
"view": view, "clips": clips, "frames": frames, "gpu": gpu,
"update_p50_ms": pct(&upd, 0.5), "update_p95_ms": pct(&upd, 0.95),
"tess_p50_ms": pct(&tess, 0.5), "tess_p95_ms": pct(&tess, 0.95),
"render_p50_ms": pct(&rend, 0.5), "render_p95_ms": pct(&rend, 0.95),
"total_p50_ms": pct(&total, 0.5), "total_p95_ms": pct(&total, 0.95),
"shapes": shapes, "vertices": vertices, "load": load_avg(),
}));
}
}
rows
}
// ------------------------------------------------------------------------------------ export
/// Export throughput of a 1080p H.264 sequence (clip with AAC audio) to H.264 + AAC and ProRes.
pub fn export(o: &Opts) -> Vec<Value> {
let Some(path) = fixture("a1080.mp4") else {
eprintln!("export: skipped (fixture unavailable)");
return Vec::new();
};
let secs = if o.quick { 3.0 } else { 8.0 };
let dir = std::env::temp_dir().join(format!("filmcraft-bench-export-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("tmp dir");
let mut rows = Vec::new();
for (format, label, ext) in [("h264", "H.264 + AAC (MP4)", "mp4"), ("prores", "ProRes 422 (MOV)", "mov")].into_iter().filter(|f| o.wants(f.0)) {
let mut runs = Vec::new();
let mut cpu = Vec::new();
let mut bytes = 0u64;
let mut frames = 0i64;
for _ in 0..o.repeat {
let mut s = Session::default();
let a = playback::import(&mut s, &path);
let seq = playback::build_sequence(&mut s, 1920, 1080, &[(a, 100.0, None, 0.0, 100.0)], &[], secs);
// build_sequence adds video only: add the clip's audio on A1 for the AAC encode
s.edit("audio", |p, _| {
let rate = FrameRate::FPS_23_976;
let it = p
.make_track_item(a, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.snap(Tick::from_seconds_f64(secs))), rate)
.expect("audio item");
p.sequence_mut(seq).expect("seq").audio_tracks[0].items.push(it);
Ok(())
})
.expect("audio");
frames = s.project.sequence(seq).map(|q| q.settings.frame_rate.frame_at(q.duration())).unwrap_or(0);
let out = dir.join(format!("out.{ext}"));
let (t0, c0) = (Instant::now(), cpu_now());
let r = s.execute("file.exportMedia", json!({"path": out.to_string_lossy(), "format": format, "wait": true}));
let dt = t0.elapsed().as_secs_f64();
if let Err(e) = r {
eprintln!("export {format}: {e}");
break;
}
if let Some(e) = s.jobs.last().and_then(|j| j.progress.error.lock().ok().and_then(|g| g.clone())) {
eprintln!("export {format}: {e}");
break;
}
runs.push(dt);
cpu.push(ms(cpu_now() - c0) / frames.max(1) as f64);
bytes = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
}
if runs.is_empty() {
continue;
}
let best = runs.iter().cloned().fold(f64::MAX, f64::min);
eprintln!("export {format}: {frames} frames in {best:.1} s");
rows.push(json!({
"format": label, "frames": frames, "seconds": best, "fps": frames as f64 / best,
"realtime": frames as f64 / best / FrameRate::FPS_23_976.as_f64(), "cpu_ms_per_frame": median(&cpu),
"mbytes": bytes as f64 / 1e6, "runs_s": runs, "load": load_avg(),
}));
}
let _ = std::fs::remove_dir_all(&dir);
rows
}
// ------------------------------------------------------------------------------------ project
/// Save and open a large project: 5 sequences × 1000 clips (20 tracks each).
pub fn project(o: &Opts) -> Vec<Value> {
let Some(path) = fixture("clip360.mp4") else {
eprintln!("project: skipped (fixture unavailable)");
return Vec::new();
};
let mut s = Session::default();
let item = playback::import(&mut s, &path);
let mut clips = 0;
for k in 0..5 {
clips += big_sequence(&mut s, item, &format!("Sequence {}", k + 1), 11 + k as u64).1;
}
let dir = std::env::temp_dir().join(format!("filmcraft-bench-project-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("tmp dir");
let file = dir.join("big.fcproj");
let n = o.repeat.max(3);
let (mut save, mut open) = (Vec::new(), Vec::new());
for _ in 0..n {
let t0 = Instant::now();
s.execute("file.saveAs", json!({"path": file.to_string_lossy()})).expect("save");
save.push(ms(t0.elapsed()));
let mut s2 = Session::default();
let t1 = Instant::now();
s2.execute("file.open", json!({"path": file.to_string_lossy()})).expect("open");
open.push(ms(t1.elapsed()));
assert_eq!(s2.project.items.len(), s.project.items.len());
}
let mbytes = std::fs::metadata(&file).map(|m| m.len()).unwrap_or(0) as f64 / 1e6;
let _ = std::fs::remove_dir_all(&dir);
eprintln!("project: save {:.0} ms, open {:.0} ms ({clips} clips, {mbytes:.1} MB)", median(&save), median(&open));
let row = |what: &str, v: &[f64]| json!({"what": what, "clips": clips, "mbytes": mbytes, "p50_ms": median(v), "min_ms": v.iter().cloned().fold(f64::MAX, f64::min), "runs_ms": v, "load": load_avg()});
vec![row("save (file.saveAs)", &save), row("open (file.open)", &open)]
}