Files
photocraft/apps/photocraft-cli/examples/gpu_bench.rs
T
Khawar Zamman WaniandFredApps 13b481f892 Expose optional GPU composition and export in photocraft-cli (#10912)
* Add headless GPU backend foundation for composition and flat export (issue #8062 PR 1)

* Fix headless GPU readback bounds, API compliance, and error preservation (#8062)

* Fix GPU readback bounds, context thread spawn error, and test parity (#8062)

* Fix BandRenderer Send bound, PNG reborrows, and clippy lints (#8062)

* Fix effect shadow test construction and remove dead code wrappers (#8062)

* Use short reborrows for Headless renderer without lifetime escaping (#8062)

* Fix clippy collapsible_if and ensure multi-band geometry in flat export tests (#8062)

* Fix automation Rect import, add renderer diagnostics, and harden flat export retries (#8062)

* Expose optional GPU rendering in photocraft-cli (#8062)

* Fix GPU feature test APIs and native target gates

* Fix CLI locked metadata, bounded benchmark params, and protocol diagnostics (#8062)

* Verify unsupported GPU effects before testing band seams

* Fix CLI renderer borrows and GPU-enabled test compilation

* Use compositor buffer ownership API after upstream memory accounting change

* Fix GPU CLI lint failures and validate run environment policy

* Update export renderer test helper for owned compositor buffers

* Fix needless benchmark layer name borrow

* Fix headless GPU backend review findings (#10842)

- release a closed document's GPU caches through a BandRenderer hook
- bound the monitored readback wait and report a stalled GPU as device loss
- reject 32-bit float documents before band readback so exports fall back to CPU
- keep previews at 8-bit RGBA and report the palette/Duotone renderer bypass
- retain renderer diagnostics when encoding fails
- strengthen renderer, save-path and GPU diagnostics tests

* Use valid batch arguments and inspect method in GPU CLI tests

* Add GPU close-hook and 8-bit preview regression tests (#10842)

* Fix GPU CLI review findings (#10912)

- guard the benchmark's modulo divisors for one-pixel dimensions
- count a cold export as GPU only when the renderer context was used
- report every batch file's export warnings, deduplicating only GPU diagnostics
- drain renderer diagnostics before propagating convert and run save errors
- cover renderer use in the GPU precedence, bridge and serve tests

* fix(io): correct renderer binding mutability

* test(cli): verify narrow benchmark inputs and GPU diagnostics

* Fix remaining headless GPU review findings (#10842)

- release a document's GPU caches when file.close, file.closeAll or
  file.closeOthers removes it through a command
- honor cancellation while band readback waits, polling in 100 ms slices
- fault the device after a readback timeout so later exports skip it
- report single-layer, CMYK ink-mixing, ORA and PDF CPU paths to a
  requested renderer
- keep fallback warnings in their export instead of leaking them into the
  next one
- requeue renderer diagnostics when a renderer-aware save's final write
  fails
- write MCP/serve diagnostics without panicking on a broken stderr
- document the actual colour-space contract and drop F32 from the GPU
  depth list

* Fix remaining headless GPU review findings (#10842)

- release a document's GPU caches when file.close, file.closeAll or
  file.closeOthers removes it through a command
- honor cancellation while band readback waits, polling in 100 ms slices
- fault the device after a readback timeout so later exports skip it
- report single-layer, CMYK ink-mixing, ORA and PDF CPU paths to a
  requested renderer
- keep fallback warnings in their export instead of leaking them into the
  next one
- requeue renderer diagnostics when a renderer-aware save's final write
  fails
- write MCP/serve diagnostics without panicking on a broken stderr
- document the actual colour-space contract and drop F32 from the GPU
  depth list

* Fix remaining GPU CLI review findings (#10912)

- reject non-UTF-8 PHOTOCRAFT_GPU values instead of silently using the CPU
- release a batch item's document caches before taking the renderer to the
  next item, and report fallbacks once
- count warm benchmark runs that fell back to CPU in the summary
- write serve diagnostics without panicking on a broken stderr
- assert that the batch test's --gpu policy reaches the renderer

* Use question mark for cancellable readback errors

* Use question mark for cancellable readback errors

* Keep GPU export diagnostics with their attempt and stop bands on cancel

Encode native .pcraft bytes before draining renderer diagnostics, and
requeue export warnings when a workspace write fails, so callers still
report the adapter or fallback reason with the error.

Move a failed GPU attempt's queued diagnostics into its CPU retry result
instead of leaving them for a later successful export to report.

Stop GPU band readback at the next band after cancellation, and check the
interrupt before each CpuRenderer sink delivery.

Band readback uploads half-precision textures, so reject U16 alongside
F32: their samples cannot survive the upload. Tighten the 8-bit CPU/GPU
parity checks to 1/255.

* Keep GPU export diagnostics with their attempt and stop bands on cancel

Encode native .pcraft bytes before draining renderer diagnostics, and
requeue export warnings when a workspace write fails, so callers still
report the adapter or fallback reason with the error.

Move a failed GPU attempt's queued diagnostics into its CPU retry result
instead of leaving them for a later successful export to report.

Stop GPU band readback at the next band after cancellation, and check the
interrupt before each CpuRenderer sink delivery.

Band readback uploads half-precision textures, so reject U16 alongside
F32: their samples cannot survive the upload. Tighten the 8-bit CPU/GPU
parity checks to 1/255.

* Report partial warm fallbacks and CPU fallback on stderr

The averaged GPU Warm row now says how many warm runs fell back to the
CPU and were excluded from the average, through the existing helper.

The single-layer convert diagnostic test checks that the CPU fallback is
reported on stderr, in GPU-enabled and CPU-only builds alike.

* Keep headless export warnings when a workspace write fails (#10842)

A capability-scoped save encodes the export, then writes it; when the write
fails, the export warnings (folded together with renderer diagnostics) were
pushed back through `BandRenderer::push_diagnostic`, whose default is a no-op.
A custom renderer that overrides only `drain_diagnostics` therefore lost the
adapter or fallback reason that belonged with the error.

Keep them in `Headless` instead and return them from the next
`drain_diagnostics`, alongside the renderer's own queue. The regression uses a
renderer without a `push_diagnostic` override and checks the warnings survive
the failed write and drain exactly once.

Also name the actual source of the depth limit in the headless GPU matrix:
layer content is uploaded as half-precision textures, not band readback.

* Stop CPU bands and CPU retries on cancellation (#10912)

The CPU retry after a retryable GPU error composes through
`FlatSource::bands`, whose shared `emit` sink did not poll the export
interrupt: a cancel was only observed after the whole composite finished.
Check it once per band, as `CpuRenderer` does, for both RGB and CMYK ink
bands. The flat and streamed-PNG retry branches also check before starting
the full CPU composite again, so a cancelled export returns
`IoError::Cancelled` instead of re-rendering the document.

Tests: band-sink cancellation for RGB and CMYK inks, a renderer call counter
proving a second export reaches the renderer, and a pending `wait_slices`
poll observing cancellation set mid-wait.

* Merge origin/main into GPU foundation (#10842)

* Keep GPU-path diagnostics on every failure path, honour cancel in native copy

- save: export warnings of a failed local write stay in Headless pending diagnostics
- render: preview fallback warnings are returned, drained, and added to the doc.render reply
- io: renderer-neutral fallback wording; native single-layer copy checks cancellation per band
- gpu: gate wait_slices tests off wasm32 (#10842)

* Address CodeRabbit review: keep fallback reasons on failed retries, report native-copy and quick-export CPU use, guard diagnostics drain (#10912)

* Accept render diagnostics from the doc.render reply in the serve GPU test (#10912)

---------

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

222 lines
9.5 KiB
Rust

//! Benchmark example measuring headless CPU vs GPU rendering and export performance.
//!
//! Usage:
//! ```sh
//! cargo run --release -p photocraft-cli --example gpu_bench -- [options]
//! ```
use std::path::PathBuf;
use std::time::Instant;
use clap::Parser;
use photocraft_automation::files;
use photocraft_automation::gpu::GpuRenderer;
use photocraft_doc::{BlendMode, Color, ColorMode, Document, Layer, LayerContent, Rect, SampleType, Size, Surface};
use photocraft_io::ExportOptions;
#[derive(Parser, Debug)]
#[command(name = "gpu_bench", about = "Benchmark headless CPU vs GPU rendering and export performance")]
struct BenchArgs {
/// Input image or PSD file (optional; defaults to synthetic mockup)
#[arg(short, long)]
input: Option<PathBuf>,
/// Synthetic document width in pixels (1..=16384)
#[arg(short, long, default_value_t = 2048, value_parser = clap::value_parser!(u32).range(1..=16384))]
width: u32,
/// Synthetic document height in pixels (1..=16384)
#[arg(short = 'H', long, default_value_t = 2048, value_parser = clap::value_parser!(u32).range(1..=16384))]
height: u32,
/// Synthetic layer count (1..=128)
#[arg(short, long, default_value_t = 8, value_parser = clap::value_parser!(u32).range(1..=128))]
layers: u32,
/// Number of warm GPU runs (0..=50)
#[arg(short = 'r', long = "warm-runs", default_value_t = 3, value_parser = clap::value_parser!(u32).range(0..=50))]
warm_runs: u32,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = BenchArgs::parse();
let layers = args.layers as usize;
let warm_runs = args.warm_runs as usize;
println!("# PhotoCraft CLI Headless Rendering Benchmark");
println!();
const MAX_SYNTHETIC_PIXELS: u64 = 256 * 1024 * 1024;
let doc = if let Some(path) = &args.input {
println!("- **Input Source**: file `{}`", path.display());
let open_res = files::open(path)?;
open_res.document
} else {
let total_pixels = u64::from(args.width) * u64::from(args.height) * u64::from(args.layers);
if total_pixels > MAX_SYNTHETIC_PIXELS {
return Err(format!(
"requested synthetic benchmark volume ({}x{} x {} layers = {} total pixels) exceeds budget of {} pixels (~{} MB); please lower width, height, or layer count",
args.width,
args.height,
args.layers,
total_pixels,
MAX_SYNTHETIC_PIXELS,
(MAX_SYNTHETIC_PIXELS * 4) / (1024 * 1024)
)
.into());
}
println!("- **Input Source**: synthetic mockup ({}x{}, {} layers)", args.width, args.height, args.layers);
sample_doc(args.width, args.height, layers)
};
println!("- **Dimensions**: {}x{}", doc.size.width, doc.size.height);
println!("- **Layer Count**: {}", doc.layers.len());
println!();
let tmp_dir = std::env::temp_dir().join(format!("pc-bench-{}", std::process::id()));
let _ = std::fs::create_dir_all(&tmp_dir);
let cpu_out = tmp_dir.join("cpu_out.png");
let gpu_cold_out = tmp_dir.join("gpu_cold_out.png");
let gpu_warm_out = tmp_dir.join("gpu_warm_out.png");
let opts = ExportOptions::default();
// 1. CPU Run
print!("Benchmarking CPU export... ");
let t0 = Instant::now();
files::save_with_renderer(&doc, &cpu_out, Some("png"), &opts, None, None)?;
let cpu_dur = t0.elapsed();
println!("{:.2} ms", cpu_dur.as_secs_f64() * 1000.0);
// 2. GPU Cold Run
print!("Benchmarking GPU cold export... ");
let mut gpu_renderer = GpuRenderer::lazy();
let t0 = Instant::now();
let gpu_cold_res = files::save_with_renderer(&doc, &gpu_cold_out, Some("png"), &opts, None, Some(&mut gpu_renderer));
let gpu_cold_dur = t0.elapsed();
let (gpu_succeeded, is_fallback) = match gpu_cold_res {
Ok(warnings) => {
let fallback = warnings.iter().any(|w| w.contains("falling back to CPU"));
if fallback {
println!("{:.2} ms (FALLBACK TO CPU: {:?})", gpu_cold_dur.as_secs_f64() * 1000.0, warnings);
(true, true)
} else if gpu_renderer.context().is_none() {
// The export never reached the renderer (e.g. a single-layer input takes the
// native path), so no GPU work ran: report it as CPU work, not a GPU result.
println!("{:.2} ms (renderer not used)", gpu_cold_dur.as_secs_f64() * 1000.0);
(false, true)
} else {
println!("{:.2} ms", gpu_cold_dur.as_secs_f64() * 1000.0);
let adapter = gpu_renderer.adapter_name().unwrap_or("unknown");
let is_soft = if gpu_renderer.is_software() { " (software rasterizer)" } else { " (hardware)" };
println!("- **GPU Adapter**: `{adapter}`{is_soft}");
(true, false)
}
}
Err(e) => {
println!("FAILED ({e})");
let _ = std::fs::remove_dir_all(&tmp_dir);
return Ok(());
}
};
// 3. GPU Warm Runs
let mut warm_durations = Vec::with_capacity(warm_runs);
let mut warm_fallbacks = 0usize;
if gpu_succeeded && !is_fallback && warm_runs > 0 {
for run in 1..=warm_runs {
print!("Benchmarking GPU warm export (run {run}/{warm_runs})... ");
let t0 = Instant::now();
let warm_res = files::save_with_renderer(&doc, &gpu_warm_out, Some("png"), &opts, None, Some(&mut gpu_renderer))?;
let d = t0.elapsed();
if warm_res.iter().any(|w| w.contains("falling back to CPU")) {
warm_fallbacks += 1;
println!("{:.2} ms (FALLBACK TO CPU)", d.as_secs_f64() * 1000.0);
} else {
println!("{:.2} ms", d.as_secs_f64() * 1000.0);
warm_durations.push(d);
}
}
}
println!();
println!("### Summary Results");
println!("| Mode | Duration | Relative to CPU | Note |");
println!("|---|---|---|---|");
println!("| CPU Baseline | {:.2} ms | 1.00x | Reference CPU compositor |", cpu_dur.as_secs_f64() * 1000.0);
if is_fallback {
println!("| GPU Cold | {:.2} ms | N/A | GPU not used (CPU fallback or a CPU-only export path); no GPU speedup |", gpu_cold_dur.as_secs_f64() * 1000.0);
println!("| GPU Warm | N/A | N/A | Not measured (GPU not used) |");
} else {
let is_software = gpu_renderer.is_software();
let adapter_note = if is_software { "Software rasterizer (e.g. lavapipe/llvmpipe)" } else { "Hardware GPU" };
println!(
"| GPU Cold | {:.2} ms | {:.2}x | Cold adapter initialization + pipeline build ({adapter_note}) |",
gpu_cold_dur.as_secs_f64() * 1000.0,
cpu_dur.as_secs_f64() / gpu_cold_dur.as_secs_f64()
);
if !warm_durations.is_empty() {
let avg_warm: f64 = warm_durations.iter().map(|d| d.as_secs_f64()).sum::<f64>() / (warm_durations.len() as f64);
// Runs that fell back to the CPU are excluded from the average: say how many.
let note = match warm_fallback_note(warm_runs, warm_fallbacks) {
Some(fallbacks) => format!("Reused device context and pipeline caches ({adapter_note}); {fallbacks} (excluded from the average)"),
None => format!("Reused device context and pipeline caches ({adapter_note})"),
};
println!("| GPU Warm (avg) | {:.2} ms | {:.2}x | {note} |", avg_warm * 1000.0, cpu_dur.as_secs_f64() / avg_warm);
} else if let Some(note) = warm_fallback_note(warm_runs, warm_fallbacks) {
println!("| GPU Warm | N/A | N/A | {note} |");
} else {
println!("| GPU Warm | N/A | N/A | Not measured (0 warm runs requested) |");
}
}
let _ = std::fs::remove_dir_all(&tmp_dir);
Ok(())
}
/// The warm-run summary note when every measured warm run fell back to the CPU.
fn warm_fallback_note(warm_runs: usize, warm_fallbacks: usize) -> Option<String> {
(warm_fallbacks > 0).then(|| format!("{warm_fallbacks} of {warm_runs} warm runs fell back to CPU"))
}
fn sample_doc(w: u32, h: u32, layer_count: usize) -> Document {
let mut doc = Document::with_background("bench", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let blend_modes = [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Overlay, BlendMode::Darken, BlendMode::Lighten];
for idx in 0..layer_count {
let mut s = Surface::new(doc.pixel_format());
let x0 = (idx as i32 * 20) % (w as i32 / 2).max(1);
let y0 = (idx as i32 * 20) % (h as i32 / 2).max(1);
let rw = (w as i32 - x0).max(1);
let rh = (h as i32 - y0).max(1);
s.fill_rect(Rect::new(x0, y0, x0 + rw, y0 + rh), &[0.3, 0.5, 0.7, 0.8]);
let mut layer = Layer::new(format!("Layer {}", idx + 1), LayerContent::Raster(s));
layer.opacity = 0.85;
layer.blend = blend_modes[idx % blend_modes.len()];
doc.layers.push(layer);
}
doc
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn synthetic_documents_allow_one_pixel_dimensions() {
for (width, height) in [(1, 1), (1, 16), (16, 1)] {
let doc = sample_doc(width, height, 2);
assert_eq!(doc.size, Size::new(width, height));
}
}
#[test]
fn warm_summary_reports_fallbacks_instead_of_zero_runs() {
assert_eq!(warm_fallback_note(3, 3).as_deref(), Some("3 of 3 warm runs fell back to CPU"));
assert_eq!(warm_fallback_note(3, 1).as_deref(), Some("1 of 3 warm runs fell back to CPU"));
assert_eq!(warm_fallback_note(0, 0), None);
}
}