Files
photocraft/crates/gpu/examples/bench_stack_reuse.rs
a8ad82dd46 GPU composite: reuse unchanged lower-stack chunks for property edits (#1169)
* perf(gpu): reuse unchanged lower-stack composite chunks

* test(gpu): verify half-float composite checkpoint reuse

* docs: record measured GPU composite reuse gains and limits

* perf(gpu): admit whole grid chunks, serve partial damage as sub-rect copies

Cache entries were keyed by the exact damage rectangle, so brush strokes
(a new partial rect every frame) filled the budget with entries that
never hit again. Entries are now whole chunk-aligned, chunk-sized grid
cells: a capture merges into the cell it overlaps at the rectangle's
offset, a read is served as a sub-rect copy of the covered part, and
coverage is tracked per cell so a resume only happens where every pixel
of the requested rectangle is cached. After a full render, any in-bounds
partial rectangle hits.

Regression test: two series of disjoint strokes inside the same chunks
leave composite_cache_bytes unchanged (no entry-per-rectangle growth),
a full refresh still finds its chunks and a top-layer edit afterwards
reuses them. The old partial-damage test now expects the covered-chunk
hit and checks outside-the-document renders admit nothing.

* docs: record the GPU reuse measurement in target-app-parity.md

The honest assessment moved from roadmap.md to target-app-parity.md on
main; keep main's roadmap and add only the dated measurement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:07:48 -04:00

69 lines
3.6 KiB
Rust

//! Dense 24-MP stack, synchronized GPU rendering with/without lower-stack reuse.
//! `cargo run --release -p photocraft-gpu --example bench_stack_reuse [-- --quick]`
use std::time::Instant;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, LayerContent};
use photocraft_geom::Size;
use photocraft_gpu::{Compositor, Stats};
use photocraft_raster::Surface;
fn render(comp: &mut Compositor, device: &wgpu::Device, queue: &wgpu::Queue, doc: &Document) -> Result<Stats, Box<dyn std::error::Error>> {
let stats = comp.render(device, queue, doc, doc.bounds(), |_, _| {})?;
device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None })?;
Ok(stats)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let quick = std::env::args().any(|a| a == "--quick");
let size = if quick { Size::new(1024, 768) } else { Size::new(6000, 4000) };
let adapter = pollster::block_on(wgpu::Instance::default().request_adapter(&Default::default()))?;
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
let format = Compositor::preferred_acc_format(&adapter);
println!("adapter={:?}, accumulation={format:?}, size={}x{}", adapter.get_info(), size.width, size.height);
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut doc = Document::new("stack", size, ColorMode::Rgb, depth);
let mut surface = Surface::new(doc.pixel_format());
surface.fill_rect(doc.bounds(), &[0.6, 0.2, 0.4, 0.5]);
for i in 0..12 {
// The immutable dense starting tiles are shared on the CPU, but each layer has
// its own resident GPU pages and composite passes. No raster-generation timing.
let mut layer = Layer::new(format!("layer {i}"), LayerContent::Raster(surface.clone()));
layer.opacity = 0.8;
doc.layers.push(layer);
}
for target in [11, 5] {
for budget in [0, 512 << 20] {
let mut comp = Compositor::try_new_with_format(&device, format)?;
comp.set_composite_cache_budget(budget);
let cold = Instant::now();
render(&mut comp, &device, &queue, &doc)?;
let cold_ms = cold.elapsed().as_secs_f64() * 1000.0;
doc.layers.get_mut(target).ok_or("missing target layer")?.opacity = 0.45;
let adapt = Instant::now();
render(&mut comp, &device, &queue, &doc)?;
let adapt_ms = adapt.elapsed().as_secs_f64() * 1000.0;
let mut samples = Vec::new();
let mut stats = Stats::default();
for i in 0..11 {
doc.layers.get_mut(target).ok_or("missing target layer")?.opacity = if i % 2 == 0 { 0.7 } else { 0.4 };
let start = Instant::now();
stats = render(&mut comp, &device, &queue, &doc)?;
samples.push(start.elapsed().as_secs_f64() * 1000.0);
}
samples.sort_by(f64::total_cmp);
println!(
"{depth:?}, target={target}, reuse={}, cold={cold_ms:.3} ms adapt={adapt_ms:.3} ms p50={:.3} ms p95={:.3} ms cache={} bytes hits={} skipped={}",
budget != 0,
samples.get(5).copied().unwrap_or_default(),
samples.get(10).copied().unwrap_or_default(),
comp.composite_cache_bytes(),
stats.prefix_hits,
stats.prefix_passes_skipped,
);
}
}
}
Ok(())
}