mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 20:32:31 +00:00
perf(photomerge): track multiband timing and peak memory
This commit is contained in:
1 parent
3a896a5efc
commit
7bf4cc0739
3 files changed
+176
-27
No files matched your search
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Reference in new issue
Block a user