perf(photomerge): track multiband timing and peak memory

This commit is contained in:
ersoynarin-code committed 2026-10-20 01:54:27 +03:00
1 parent 3a896a5efc
commit 7bf4cc0739
3 files changed
+176 -27

No files matched your search

+155 -26
View File
@@ -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<f64> {
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<PathBuf>,
}
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<Item = String>) -> Result<Options, String> {
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<u64> {
#[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<RoiImage> = (0..n)
let (cw, ch) = (step * (o.n - 1) + sw, sh);
let rois: Vec<RoiImage> = (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<Vec<f32>> = (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<Vec<f32>> = (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, String> {
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:?}");
}
}
}
+2 -1
View File
@@ -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) |
+19
View File
@@ -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]]