Files
photocraft/crates/algo/examples/bench_multiband.rs
T
Khawar Zamman WaniandFredApps 316e00a97e Bound Photomerge memory and safely reject out-of-budget jobs (#7851) (#10463)
* Reduce Photomerge input and fill memory

* Bounded working-memory budgeting and safe allocation for Photomerge

* Photomerge memory: estimate warped sizes, no RAM ceiling, shared RAM detection

- Budget is half of physical RAM (at least 1 GiB) with no 8 GiB ceiling.
- The pre-flight estimate uses each image's warped extent and the band
  grid's exact bound (band_bytes_bound), at the levels seam_blend uses.
- Auto-Blend keeps its old batching with no single-band cap.
- multiband returns a Result instead of a blank image on failure.
- Physical-memory detection moves to photocraft_engine::memory; the GPU
  budgets reuse it, so PHOTOCRAFT_RAM_MB means the same in both.
- Document memoryBudget; restore comments; tests for the band cap and the
  budget precedence.

* Photomerge: lower the blend's peak and estimate it by phase

- The band build makes its binary coverage straight from the alpha and
  writes each Laplacian level in place, so a band holds two fewer planes.
- The weight sums are freed before the collapse, which adds each level
  in place and frees it as it goes.
- The estimate is the largest phase (warp, ROIs, bands, collapse, output,
  fill) rather than the sum of all of them, and the bands get what is
  left of the budget after the ROIs and accumulators.

Every output pixel is unchanged: each change drops a plane or does the
same elementwise operation in place.

* Fix Photomerge band budget validation and fallible pyramid allocation

* Keep reference pyramid test helpers infallible

* Photomerge: bound source loading with the memory budget

Resolve the budget before any source is read, and admit each flattened
source before its surface is allocated. The accounting charges the
retained surface, the same-canvas temporary the flatten writes while the
source document is alive, and a file import's encoded bytes, with
saturating math so an overflow cannot wrap under a finite budget. Merge
to HDR Pro passes no budget and keeps its previous behaviour.

Tests cover the exact retained-plus-temporary charge, a refused source,
the within/over-budget command paths, the post-load estimate path, and
the unbudgeted HDR loader.

* Photomerge: bound source reads and imports by the remaining budget

Read each source file with what is left of the budget as its limit, lower the importer's own PSD/TIFF preflight or the flat decoders' pixel allowance to the remainder after the retained sources and encoded bytes, and refuse formats with no pre-decode estimate. Charge the decoded document's actual pixels (every layer) plus the flatten's working and output surfaces before flattening. Merge to HDR Pro keeps its unbudgeted read-and-import path.

* Remove redundant TIFF decode options update

* Clean up Photomerge budget Clippy findings

* Accept early decoder admission in source budget regression

* Photomerge: report unreadable sources as file errors; never reserve past a read limit

- A budgeted Photomerge reported a missing or unreadable source file as
  'not enough memory'. Only a file over the remaining budget, or one memory
  cannot hold, is a memory error now; others are file errors, as without a budget.
- read_all_with reserved a whole read chunk before checking the caller's limit
  for a file that grew after it was statted. It now refuses first and caps the
  reservation at the limit.

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 06:23:56 +01:00

162 lines
6.5 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.
//! 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 (`photocraft_testkit::perf`: the kernel's mark on Linux, sampled elsewhere).
use std::path::PathBuf;
use std::time::Instant;
use photocraft_algo::panorama::{self, RoiImage};
use photocraft_testkit::perf::{self, RssSampler, Summary};
use serde_json::json;
const ROW: &str = "Photomerge multiband blend";
#[derive(Debug, PartialEq)]
struct Options {
n: usize,
mp: f64,
reps: usize,
json: Option<PathBuf>,
}
/// Parses legacy `[n] [mp]` arguments and the perf runner's `--reps`/`--json` flags.
/// Rejects missing, out-of-range, or non-finite values before allocating images.
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" => {
let path = it.next().filter(|p| !p.starts_with('-')).ok_or("--json needs a file path")?;
out.json = Some(PathBuf::from(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..=36.0).contains(&out.mp) {
return Err("expected 1..=16 images, 0.01..=36 MP per image and 1..=25 repetitions".into());
}
Ok(out)
}
/// Builds deterministic overlapping RGB inputs and times each multi-band blend.
/// Writes individual samples and whole-process peak RSS to JSON when requested.
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 * (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();
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 = perf::process_peak_rss_bytes().or_else(perf::current_rss_bytes);
let sampler = RssSampler::start(std::time::Duration::from_millis(2));
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).map_err(|e| format!("multiband: {e}"))?;
samples_ms.push(t0.elapsed().as_secs_f64() * 1000.0);
std::hint::black_box(&output);
}
let peak = perf::process_peak_rss_bytes().or(sampler.peak());
let delta = before.zip(peak).map(|(a, b)| b.saturating_sub(a) as f64 / (1024.0 * 1024.0));
// The same nearest-rank p50 that `cargo xtask perf` reports for these samples.
let p50 = Summary::of(&samples_ms).map_or(f64::NAN, |s| s.p50);
println!(
"{} × {}×{} ({:.2} MP) RGB, canvas {}×{}, {} levels: {:.1} ms p50 ({} runs), peak +{}",
o.n,
sw,
sh,
o.mp,
cw,
ch,
levels,
p50,
o.reps,
delta.map_or("n/a".to_string(), |m| format!("{m:.0} MiB"))
);
if let Some(path) = o.json {
let context = json!({"sources": o.n, "mp_per_source": o.mp, "canvas_width": cw, "canvas_height": ch, "levels": levels});
let report = perf::report("bench_multiband", context, vec![perf::row(ROW, &samples_ms, peak, None)], Some(&sampler));
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(())
}
/// Runs the benchmark, reporting argument or output errors with a failing exit status.
fn main() {
if let Err(e) = run() {
eprintln!("bench_multiband: {e}");
std::process::exit(2);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Converts borrowed argument slices for the owned-string command-line parser.
fn parse(args: &[&str]) -> Result<Options, String> {
options(args.iter().map(|s| s.to_string()))
}
/// Confirms backwards compatibility with the positional and perf-runner forms.
#[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")) }
);
// The 24-36 MP range AGENTS.md benchmarks at, as before the flags were added.
assert_eq!(parse(&["4", "36"]).unwrap().mp, 36.0);
}
/// Checks invalid counts, non-finite sizes, repetitions, and missing flag values.
#[test]
fn rejects_bad_benchmark_args() {
for args in [
vec!["0"],
vec!["17"],
vec!["2", "0"],
vec!["2", "NaN"],
vec!["2", "inf"],
vec!["2", "37"],
vec!["--reps", "0"],
vec!["--reps", "26"],
vec!["--json"],
vec!["--json", "--reps", "5"],
vec!["--reps"],
vec!["--unknown"],
] {
assert!(parse(&args).is_err(), "{args:?}");
}
}
}