diff --git a/crates/algo/examples/bench_multiband.rs b/crates/algo/examples/bench_multiband.rs index 9d58b81a54..d3dcdc2f0b 100644 --- a/crates/algo/examples/bench_multiband.rs +++ b/crates/algo/examples/bench_multiband.rs @@ -1,42 +1,171 @@ -//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels -//! (3:2), each overlapping the next by half its width. -//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP). -//! Peak memory is the growth of the process's high-water mark during the blend (Linux only). +//! Time and peak memory of Photomerge's multi-band blend on overlapping RGB sources. +//! Backwards compatible: `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]`. +//! `cargo xtask perf --bench bench_multiband --quick` also collects repeatable JSON timings. +//! The peak is the entire process's resident-set high-water mark, not an estimate of allocations +//! by the blend alone. Available on Linux and Windows; other platforms report no peak value. +use std::path::PathBuf; use std::time::Instant; use photocraft_algo::panorama::{self, RoiImage}; +use serde_json::json; -/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it. -fn peak_mib() -> Option { - let s = std::fs::read_to_string("/proc/self/status").ok()?; - let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?; - Some(kb / 1024.0) +const ROW: &str = "Photomerge multiband blend"; + +#[derive(Debug, PartialEq)] +struct Options { + n: usize, + mp: f64, + reps: usize, + json: Option, } -fn main() { - let mut args = std::env::args().skip(1); - let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1); - let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0); - let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2); +fn options(args: impl IntoIterator) -> Result { + let mut out = Options { n: 4, mp: 24.0, reps: 1, json: None }; + let mut positional = Vec::new(); + let mut it = args.into_iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--json" => out.json = Some(PathBuf::from(it.next().ok_or("--json needs a file path")?)), + "--reps" => { + out.reps = it.next().ok_or("--reps needs an integer")?.parse().map_err(|_| "--reps needs an integer")?; + } + _ if !arg.starts_with('-') => positional.push(arg), + _ => return Err(format!("unknown option: {arg}")), + } + } + if positional.len() > 2 { + return Err("expected at most [n] [mp]".into()); + } + if let Some(n) = positional.first() { + out.n = n.parse().map_err(|_| "n must be an integer")?; + } + if let Some(mp) = positional.get(1) { + out.mp = mp.parse().map_err(|_| "mp must be a number")?; + } + if !(1..=16).contains(&out.n) || !(1..=25).contains(&out.reps) || !out.mp.is_finite() || !(0.01..=24.0).contains(&out.mp) { + return Err("expected 1..=16 images, 0.01..=24 MP per image and 1..=25 repetitions".into()); + } + Ok(out) +} + +/// Peak resident set size in bytes (Linux: VmHWM; Windows: PeakWorkingSet64). +/// Called outside the timed code. This metric includes input buffers and output buffers. +fn peak_rss_bytes() -> Option { + #[cfg(target_os = "linux")] + { + let s = std::fs::read_to_string("/proc/self/status").ok()?; + let kb: u64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?; + return kb.checked_mul(1024); + } + #[cfg(target_os = "windows")] + { + // A standard Windows system utility; no unsafe FFI or new crate is needed in this + // performance example. Its process-launch cost is outside the measurement window. + let cmd = format!("(Get-Process -Id {}).PeakWorkingSet64", std::process::id()); + let out = std::process::Command::new("powershell.exe").args(["-NoProfile", "-NonInteractive", "-Command", &cmd]).output().ok()?; + return out.status.success().then_some(out.stdout).and_then(|v| String::from_utf8(v).ok())?.trim().parse().ok(); + } + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + None +} + +fn run() -> Result<(), String> { + let o = options(std::env::args().skip(1))?; + let sh = ((o.mp * 1e6 / 1.5).sqrt().round() as usize).max(2); let sw = sh * 3 / 2; let step = sw / 2; - let (cw, ch) = (step * (n - 1) + sw, sh); - let rois: Vec = (0..n) + let (cw, ch) = (step * (o.n - 1) + sw, sh); + let rois: Vec = (0..o.n) .map(|k| { let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect(); RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] } }) .collect(); - // Each canvas column belongs to the image whose centre is nearest, as a seam would split it. - let wts: Vec> = (0..n) - .map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect()) + let wts: Vec> = (0..o.n) + .map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(o.n - 1) == k { 1.0 } else { 0.0 }).collect()) .collect(); let levels = panorama::blend_levels(cw, ch).min(7); - let before = peak_mib(); - let t0 = Instant::now(); - let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels); - let ms = t0.elapsed().as_secs_f64() * 1000.0; - let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b)); - println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend"); - std::hint::black_box((px, cov)); + let before = peak_rss_bytes(); + let mut samples_ms = Vec::with_capacity(o.reps); + for _ in 0..o.reps { + let t0 = Instant::now(); + let output = panorama::multiband(cw, ch, &rois, &wts, levels); + samples_ms.push(t0.elapsed().as_secs_f64() * 1000.0); + std::hint::black_box(&output); + } + let peak = peak_rss_bytes(); + let delta = before.zip(peak).map(|(a, b)| b.saturating_sub(a) as f64 / (1024.0 * 1024.0)); + let med = { + let mut sorted = samples_ms.clone(); + sorted.sort_by(f64::total_cmp); + sorted[sorted.len() / 2] + }; + println!( + "{} × {}×{} ({:.2} MP) RGB, canvas {}×{}, {} levels: {:.1} ms median ({} runs), peak +{}", + o.n, + sw, + sh, + o.mp, + cw, + ch, + levels, + med, + o.reps, + delta.map_or("n/a".to_string(), |m| format!("{m:.0} MiB")) + ); + if let Some(path) = o.json { + let report = json!({ + "context": {"sources": o.n, "mp_per_source": o.mp, "canvas_width": cw, "canvas_height": ch, "levels": levels}, + "process_peak_rss_bytes": peak, + "rows": [{"name": ROW, "samples_ms": samples_ms, "peak_rss_bytes": peak}] + }); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + } + std::fs::write(&path, serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| format!("write {}: {e}", path.display()))?; + } + Ok(()) +} + +fn main() { + if let Err(e) = run() { + eprintln!("bench_multiband: {e}"); + std::process::exit(2); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Result { + options(args.iter().map(|s| s.to_string())) + } + + #[test] + fn parses_old_positional_args_and_perf_runner_args() { + assert_eq!(parse(&[]).unwrap().n, 4); + assert_eq!( + parse(&["2", "0.25", "--reps", "3", "--json", "out.json"]).unwrap(), + Options { n: 2, mp: 0.25, reps: 3, json: Some(PathBuf::from("out.json")) } + ); + } + + #[test] + fn rejects_bad_benchmark_args() { + for args in [ + vec!["0"], + vec!["17"], + vec!["2", "0"], + vec!["2", "NaN"], + vec!["2", "inf"], + vec!["--reps", "0"], + vec!["--reps", "26"], + vec!["--json"], + vec!["--reps"], + vec!["--unknown"], + ] { + assert!(parse(&args).is_err(), "{args:?}"); + } + } } diff --git a/docs/scorecard.md b/docs/scorecard.md index c4b53a20f8..b9902151c8 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -6,7 +6,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| -| [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | +| [Performance](#performance) | 3 | 22 | 25 | 59 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 20 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 169 (target 0) | @@ -24,6 +24,7 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr | Id | Scenario | Target | p50 ms | p95 ms | max ms | Peak RSS | GPU | Status | Issue | |---|---|---|---:|---:|---:|---:|---:|---|---| +| P62 | Photomerge multi-band blend, synthetic | track timing and peak RSS; no calibrated performance budget | – | – | – | – | – | not run | [#7758](https://github.com/storytold/photocraft/issues/7758) | | P1 | Move a layer with layer styles (per drag update) | ≤ 25 ms p95 | 321 | 535 | 535 | 825 MB | 409 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) | | P2 | Change layer opacity | ≤ 20 ms p95 | 276 | 443 | 443 | 639 MB | 409 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) | | P3 | Change layer fill | ≤ 20 ms p95 | 279 | 467 | 467 | 506 MB | 409 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) | diff --git a/perf/budgets.toml b/perf/budgets.toml index da47aa0503..86ab5a7b4f 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -69,6 +69,25 @@ requires = "crates/ui-egui/examples/layout_bench.rs" quick_args = [] note = "layout PSDs with many styled layers (#125/#128)" +# Photomerge (#7758): the bounded-band algorithm must remain fast and its process-level +# resident-set high-water mark is tracked (on Linux and Windows). No uncalibrated budget is +# enforced. Full mode uses 4 x 2 MP so ordinary CI machines do not allocate multi-GB blends; +# use the stand-alone example for the 4 x 24 MP stress test. +[[bench]] +name = "bench_multiband" +package = "photocraft-algo" +args = ["4", "2", "--reps", "5"] +quick_args = ["2", "0.25", "--reps", "3"] +note = "synthetic Photomerge multi-band blend; resident-set peak includes input/output buffers" + +[[scenario]] +id = "P62" +title = "Photomerge multi-band blend, synthetic" +issue = 7758 +bench = "bench_multiband" +row = "Photomerge multiband blend" +target = "track timing and peak RSS; no calibrated performance budget" + # ---- #209: interactive edits on layered documents (20 MP, styled layers) --------------------- [[scenario]]