From 819bd47bf1c6f8e9cebf57dbe2d0c623451b8741 Mon Sep 17 00:00:00 2001 From: Sir Starch Date: Sat, 10 Oct 2026 01:39:45 +0200 Subject: [PATCH] perf: bevel_bench, the cost of changing a chisel bevel's size (#2006 follow-up) (#2118) #2006 made a bevel size change dearer on purpose (+10-75% on the GPU canvas, 2x for CPU text bevels) and asked for a bench of it under perf/. `bevel_bench` renders a 12 MP document with one Chisel Hard inner bevel on an anti-aliased ellipse (a smooth outline) or on a text paragraph (many thin edges). Each case warms the caches one pixel below the size, then times refreshes with the size growing by one pixel each, so the GPU canvas rebuilds its distance fields every time (it keeps them while the size shrinks) and the CPU compositor misses its map cache. Sizes 8, 50 and 250, GPU and CPU, with `--scene`, `--size`, `--reps`, `--no-cpu` and `--json`. Registered in perf/budgets.toml as P52-P59 with no budgets (track only, like P45 and P46): GPU for ellipse and text at 8, 50 and 250, CPU at 8. docs/scorecard.md is regenerated; the rows read "not run" until the next baseline. RTX 4080 SUPER, release, 9 reps, p50 ms, before #2006 (3bc49f5e) -> after: ellipse GPU 88 -> 105 (8), 169 -> 199 (50), 305 -> 460 (250); text GPU 119 -> 140, 141 -> 252, 238 -> 557; CPU text at 8 402 -> 797. Runs vary by about 20% on this machine. Co-authored-by: Brandon Thomas --- crates/ui-egui/examples/bevel_bench.rs | 207 +++++++++++++++++++++++++ docs/development.md | 4 +- docs/scorecard.md | 10 +- perf/budgets.toml | 55 +++++++ 4 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/examples/bevel_bench.rs diff --git a/crates/ui-egui/examples/bevel_bench.rs b/crates/ui-egui/examples/bevel_bench.rs new file mode 100644 index 00000000..5d2d09dc --- /dev/null +++ b/crates/ui-egui/examples/bevel_bench.rs @@ -0,0 +1,207 @@ +//! Chisel bevel benchmark: the cost of changing the bevel size, the way a dragged slider does. +//! +//! ```sh +//! cargo run --release -p photocraft-ui-egui --example bevel_bench -- [--size 4000x3000] [--reps 5] [--scene ellipse|text] [--no-cpu] [--json out.json] +//! ``` +//! +//! Two synthetic scenes on a document of the given size: one big anti-aliased ellipse (a smooth +//! outline, few edge pixels) and a paragraph of text (many thin, anti-aliased edges). The layer +//! carries a Chisel Hard inner bevel; every case first renders it one pixel smaller (untimed, +//! warm caches), then times `--reps` refreshes of the whole document, each with the size one +//! pixel larger than the last, so the GPU canvas rebuilds its distance fields every time (it +//! keeps them while the size shrinks) and the CPU compositor misses its map cache. Sizes 8, 50 +//! and 250 px. Rows are named `bevel size change to N, (GPU|CPU)`; `cargo xtask perf` +//! reads them through `perf/budgets.toml`. + +use std::time::Instant; + +use eframe::wgpu; +use photocraft_color::{BlendMode, Color}; +use photocraft_doc::{Bevel, BevelStyle, BevelTechnique, Contour, Document, Effect, FxCommon, LayerContent}; +use photocraft_geom::Rect; +use serde_json::json; + +const SIZES: [u32; 3] = [8, 50, 250]; + +fn arg(args: &[String], name: &str) -> Option { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned()) +} + +fn block_on(f: F) -> F::Output { + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut f = std::pin::pin!(f); + loop { + if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) { + return v; + } + std::thread::yield_now(); + } +} + +/// A Chisel Hard inner bevel of the given size (the light comes from the effect's own angle). +fn bevel(size: f32) -> Effect { + Effect::BevelEmboss(Bevel { + enabled: true, + style: BevelStyle::InnerBevel, + technique: BevelTechnique::ChiselHard, + depth: 1.5, + up: true, + size, + soften: 0.0, + angle: 120.0, + altitude: 32.0, + use_global_light: false, + gloss_contour: Contour::Linear, + highlight: FxCommon::new(BlendMode::Screen, 0.75), + highlight_color: Color::WHITE, + shadow: FxCommon::new(BlendMode::Multiply, 0.75), + shadow_color: Color::BLACK, + contour: None, + texture: None, + }) +} + +/// The scene's document through the engine, so the shape and the glyphs are the real thing. +fn scene(kind: &str, w: u32, h: u32) -> Result { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "background": "#1E1813"})).map_err(|e| format!("file.new: {e}"))?; + let (w, h) = (w as i64, h as i64); + match kind { + "ellipse" => { + s.execute("shape.create", json!({"kind": "ellipse", "rect": [w / 16, h / 12, w * 7 / 8, h * 5 / 6], "fill": "#C9A45C"})) + .map_err(|e| format!("shape.create: {e}"))?; + } + _ => { + let text = "Chisel bevels follow every curve of a letter, ".repeat(14); + s.execute( + "type.create", + json!({"x": w / 25, "y": h / 10, "box": [w / 25, h / 25, w * 23 / 25, h * 23 / 25], "text": text, "size": (h / 20).max(12), "color": "#E8D9B0"}), + ) + .map_err(|e| format!("type.create: {e}"))?; + } + } + s.active().map(|a| (*a.doc).clone()).ok_or_else(|| "no active document".to_string()) +} + +/// Sets the bevel on the scene's one effect layer. +fn set_bevel(doc: &mut Document, size: f32) { + for l in &mut doc.layers { + if matches!(l.content, LayerContent::Shape(_) | LayerContent::Text(_)) { + l.effects.items = vec![bevel(size)]; + } + } +} + +struct Gpu { + device: wgpu::Device, + queue: wgpu::Queue, + comp: photocraft_gpu::Compositor, + adapter: String, +} + +fn gpu() -> Option { + let instance = wgpu::Instance::default(); + let adapter = + block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })) + .ok()?; + eprintln!("adapter: {:?}", adapter.get_info().name); + // The limits the app requests (see `gpu_canvas::use_adapter_limits`). + let limits = photocraft_ui_egui::gpu_canvas::device_limits(&adapter); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_limits: limits, ..Default::default() })).ok()?; + let comp = photocraft_gpu::Compositor::new(&device); + Some(Gpu { device, queue, comp, adapter: adapter.get_info().name }) +} + +fn gpu_time(g: &mut Gpu, doc: &Document, region: Rect) -> Result { + let t = Instant::now(); + g.comp.render(&g.device, &g.queue, doc, region, |_, _| {}).map_err(|e| e.to_string())?; + let _ = g.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + Ok(t.elapsed().as_secs_f64() * 1000.0) +} + +fn cpu_time(doc: &Document, region: Rect) -> f64 { + let t = Instant::now(); + let b = photocraft_compose::render(doc, region); + std::hint::black_box(&b); + t.elapsed().as_secs_f64() * 1000.0 +} + +fn median(v: &mut [f64]) -> f64 { + v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + v.get(v.len() / 2).copied().unwrap_or(0.0) +} + +fn main() { + let args: Vec = std::env::args().collect(); + let (w, h) = arg(&args, "--size").and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?)))).unwrap_or((4000u32, 3000u32)); + let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(5); + let no_cpu = args.iter().any(|a| a == "--no-cpu"); + let only = arg(&args, "--scene"); + let mut g = gpu(); + let adapter = g.as_ref().map(|g| g.adapter.clone()); + println!("document {w}x{h} ({:.1} MP), {reps} reps per case, adapter {}", f64::from(w) * f64::from(h) / 1e6, adapter.as_deref().unwrap_or("none")); + println!("{:<44} {:>18} {:>18} (median ms)", "case", "GPU", "CPU"); + + // `--json out.json` (`cargo xtask perf`): one row per case and compositor, with samples and the peak RSS. + let rss = photocraft_testkit::perf::RssSampler::start(std::time::Duration::from_millis(2)); + let mut json_rows: Vec = Vec::new(); + let full = Rect::new(0, 0, w as i32, h as i32); + for kind in ["ellipse", "text"] { + if only.as_deref().is_some_and(|o| o != kind) { + continue; + } + let mut doc = match scene(kind, w, h) { + Ok(d) => d, + Err(e) => { + eprintln!("{kind}: {e}"); + std::process::exit(1); + } + }; + for size in SIZES { + let what = format!("bevel size change to {size}, {kind}"); + // Warm up one pixel smaller, untimed, so only the size change is measured. + set_bevel(&mut doc, (size - 1) as f32); + if let Some(g) = g.as_mut() { + let _ = gpu_time(g, &doc, full); + } + if !no_cpu { + let _ = cpu_time(&doc, full); + } + rss.reset(); + let (mut gs, mut cs, mut gerr) = (Vec::new(), Vec::new(), None); + for i in 0..reps { + set_bevel(&mut doc, (size + i as u32) as f32); + if let Some(g) = g.as_mut() { + match gpu_time(g, &doc, full) { + Ok(v) => gs.push(v), + Err(e) => gerr = Some(e), + } + } + if !no_cpu { + cs.push(cpu_time(&doc, full)); + } + } + let peak = rss.peak(); + if !gs.is_empty() { + json_rows.push(photocraft_testkit::perf::row(&format!("{what} (GPU)"), &gs, peak, None)); + } + if !cs.is_empty() { + json_rows.push(photocraft_testkit::perf::row(&format!("{what} (CPU)"), &cs, peak, None)); + } + let show = |v: &mut Vec, err: Option<&String>| match (v.is_empty(), err) { + (false, _) => format!("{:.1}", median(v)), + (true, Some(e)) => format!("unsupported ({e})"), + (true, None) => "-".to_string(), + }; + println!("{what:<44} {:>18} {:>18}", show(&mut gs, gerr.as_ref()), show(&mut cs, None)); + } + } + if let Some(out) = arg(&args, "--json") { + let context = json!({"width": w, "height": h, "gpu_adapter": adapter}); + let report = photocraft_testkit::perf::report("bevel_bench", context, json_rows, Some(&rss)); + if let Err(e) = photocraft_testkit::perf::write_report(&out, &report) { + eprintln!("{e}"); + std::process::exit(1); + } + } +} diff --git a/docs/development.md b/docs/development.md index 7b9a90df..d0044436 100644 --- a/docs/development.md +++ b/docs/development.md @@ -256,7 +256,7 @@ translations do not affect the denominator. Unregistered locale TSV files are ig ## Performance notes - The canvas is presented by a custom WGSL shader (`ui-egui/src/gpu_canvas.rs`): mip-mapped/nearest sampling, procedural checkerboard, pixel grid, tiling. Brush strokes upload only their damage rect. -- **The canvas composites on the GPU** (`photocraft-gpu`, driven from `gpu_canvas.rs`), layer effects included. What the planner can't express returns `Unsupported` and the canvas falls back to the CPU compositor (`photocraft-compose`, also the reference for export and tests): Multichannel documents, and documents or effect regions over the texture limit. Pieces the GPU can't derive itself are rasterised once on the CPU and cached (`compose::masks` for vector masks, `compose::shape_split` for stroked shapes with clipped layers, effect distance fields). Timings of the interactive paths: `cargo run --release -p photocraft-ui-egui --example interactive_bench`; effects: `--example fx_bench` (`--compare files…` for GPU vs CPU); large documents (open, refresh, thumbnails, a filter, a stroke, saves; one operation per run so `/usr/bin/time -l` gives its peak memory): `--example large_image_bench -- --size 14000x14000 --op psd`. The app requests the adapter's own texture limit (egui's default is 8192 px; see `gpu_canvas::use_adapter_limits`); beyond it the CPU fallback composites and uploads in bands (`compose::render_bands`), and exports, thumbnails and flattening stream bands too, so no full-size float composite is ever held. Rendering fidelity: `cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd` (the whole PSD oracle table in seconds). `ui.inspect` → `perf.timings.gpuFallback` names the reason (`null` on the GPU path). +- **The canvas composites on the GPU** (`photocraft-gpu`, driven from `gpu_canvas.rs`), layer effects included. What the planner can't express returns `Unsupported` and the canvas falls back to the CPU compositor (`photocraft-compose`, also the reference for export and tests): Multichannel documents, and documents or effect regions over the texture limit. Pieces the GPU can't derive itself are rasterised once on the CPU and cached (`compose::masks` for vector masks, `compose::shape_split` for stroked shapes with clipped layers, effect distance fields). Timings of the interactive paths: `cargo run --release -p photocraft-ui-egui --example interactive_bench`; effects: `--example fx_bench` (`--compare files…` for GPU vs CPU); the cost of changing a chisel bevel's size (a dragged slider, GPU and CPU, ellipse and text): `--example bevel_bench`; large documents (open, refresh, thumbnails, a filter, a stroke, saves; one operation per run so `/usr/bin/time -l` gives its peak memory): `--example large_image_bench -- --size 14000x14000 --op psd`. The app requests the adapter's own texture limit (egui's default is 8192 px; see `gpu_canvas::use_adapter_limits`); beyond it the CPU fallback composites and uploads in bands (`compose::render_bands`), and exports, thumbnails and flattening stream bands too, so no full-size float composite is ever held. Rendering fidelity: `cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd` (the whole PSD oracle table in seconds). `ui.inspect` → `perf.timings.gpuFallback` names the reason (`null` on the GPU path). - **Layer effects on the GPU** (`gpu/src/fx.rs`, kernels in `gpu/src/compose.wgsl`). Every enabled effect becomes a *map program* over the layer's effect region (shift, dilate, Gaussian blur, glow ramp, bevel height and shading, contour, stroke band), mirroring `compose::effects` step by step; the chunked composite then paints through the maps, clipped to that region, and copies the result back into the backdrop in place, so a small text layer costs only its own pixels. The layer's shape (`compose::layer_shape`) and its distance fields (`compose::effects::distance_field`: a sequential transform whose tie-breaking a parallel GPU pass can't reproduce bit for bit) come from compose on the CPU, computed in parallel bands. Everything is cached per layer state: an unrelated edit, or an effect's colour or opacity, rebuilds nothing; a brush dab recomputes the touched 256² tiles grown by the effect reach; changing one effect's geometry rebuilds that effect only. Cache budget `gpu::FX_BUDGET` (1.5 GB, least recently drawn layers evicted first). `PHOTOCRAFT_FX_TRACE=1` prints the CPU time of each rebuild. - The canvas grows a stroke's damage rect by the effect reach of the layers around it (`canvas::effect_reach`), so effects beyond the dab refresh too (on both paths). - **Numbers** (7360 × 4912, 8 text layers + one painted layer with drop shadow + stroke + bevel, Hue/Saturation on top; M4 Pro; `cargo run --release -p photocraft-ui-egui --example fx_bench`), CPU fallback → GPU: full refresh with warm effect maps 5.7 s → 47 ms; Hue/Saturation tweak above the effects 4.7 s → 31 ms; brush dab on a plain layer 32 → 1.7 ms; brush dab on the effect layer (its maps rebuilt around the dab) 659 → 3.7 ms; first refresh (all maps built) 6.4 s → 0.39 s. With the CPU ~14× oversubscribed by parallel builds (min of 7 runs): 8.1 s → 0.33 s, 9.9 s → 0.28 s, 28 → 2.2 ms, 1.7 s → 89 ms; moving a text layer 7 px 336 → 13 ms. `fx_bench --compare corpus/psd/…/*.psd` reports the GPU vs CPU difference on real files (30 of the 31 corpus files with effects render on the GPU, worst 0.12/255). @@ -297,7 +297,7 @@ cargo xtask perf --update-baseline # also write perf/baseline.json from this r from the tree (never-crash attribute coverage, `docs/parity.md`). **Perf runs.** `xtask perf` builds the benches in release (`perf_scenarios`, `interactive_bench`, -`fx_bench`, `type_bench`, `large_image_bench`, and `layout_bench` once it exists), runs each with +`fx_bench`, `bevel_bench`, `type_bench`, `large_image_bench`, and `layout_bench` once it exists), runs each with `--json`, and merges the reports into `target/perf/results.json` keyed by scenario id, with p50, p95 and max (nearest rank over the samples), peak RSS measured in-process (`photocraft-testkit`'s `perf::RssSampler`), GPU bytes held by the canvas, and the load average before and after each diff --git a/docs/scorecard.md b/docs/scorecard.md index 9f8dae6d..29931baf 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -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 | 14 | 48 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 7 not run, 7 not measurable yet (baseline 2026-10-05) | +| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 15 not run, 7 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 4 | 12 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 17 | 14 | 8 | | | [Workspace and UI](#workspace-and-ui) | 5 | 7 | 11 | settings that do nothing: 48 of 141 (target 0) | @@ -65,6 +65,14 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr | P46 | Brush dab on a layer with effects, 36 MP (GPU) | track only | 30.6 | 83.3 | 83.3 | 863 MB | – | tracked | | | P47 | Font-size drag step, 50 type layers | track only | 23.3 | 60.5 | 71.2 | 600 MB | – | tracked | | | P48 | Full refresh of a 14000×14000 document | track only | 8715 | 9897 | 9897 | – | – | tracked | | +| P52 | Chisel bevel size change, 12 MP ellipse, size 8 (GPU) | track only | – | – | – | – | – | not run | | +| P53 | Chisel bevel size change, 12 MP ellipse, size 50 (GPU) | track only | – | – | – | – | – | not run | | +| P54 | Chisel bevel size change, 12 MP ellipse, size 250 (GPU) | track only | – | – | – | – | – | not run | | +| P55 | Chisel bevel size change, 12 MP text paragraph, size 8 (GPU) | track only | – | – | – | – | – | not run | | +| P56 | Chisel bevel size change, 12 MP text paragraph, size 50 (GPU) | track only | – | – | – | – | – | not run | | +| P57 | Chisel bevel size change, 12 MP text paragraph, size 250 (GPU) | track only | – | – | – | – | – | not run | | +| P58 | Chisel bevel size change, 12 MP ellipse, size 8 (CPU) | track only | – | – | – | – | – | not run | | +| P59 | Chisel bevel size change, 12 MP text paragraph, size 8 (CPU) | track only | – | – | – | – | – | not run | | **Not measurable yet** (the code can't do it yet; no number is recorded): diff --git a/perf/budgets.toml b/perf/budgets.toml index 05a86878..95ca421c 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -35,6 +35,13 @@ package = "photocraft-ui-egui" args = ["--no-cpu", "--reps", "5"] note = "36 MP document with layer effects, GPU compositor" +[[bench]] +name = "bevel_bench" +package = "photocraft-ui-egui" +args = ["--reps", "9"] +quick_args = ["--size", "1200x800", "--reps", "3"] +note = "chisel bevel size change (a dragged slider) on a 12 MP ellipse and a text paragraph, GPU canvas and CPU" + [[bench]] name = "camera_raw_bench" package = "photocraft-ui-egui" @@ -457,3 +464,51 @@ id = "P48" title = "Full refresh of a 14000×14000 document" bench = "large_image_bench" row = "full refresh" + +[[scenario]] +id = "P52" +title = "Chisel bevel size change, 12 MP ellipse, size 8 (GPU)" +bench = "bevel_bench" +row = "bevel size change to 8, ellipse (GPU)" + +[[scenario]] +id = "P53" +title = "Chisel bevel size change, 12 MP ellipse, size 50 (GPU)" +bench = "bevel_bench" +row = "bevel size change to 50, ellipse (GPU)" + +[[scenario]] +id = "P54" +title = "Chisel bevel size change, 12 MP ellipse, size 250 (GPU)" +bench = "bevel_bench" +row = "bevel size change to 250, ellipse (GPU)" + +[[scenario]] +id = "P55" +title = "Chisel bevel size change, 12 MP text paragraph, size 8 (GPU)" +bench = "bevel_bench" +row = "bevel size change to 8, text (GPU)" + +[[scenario]] +id = "P56" +title = "Chisel bevel size change, 12 MP text paragraph, size 50 (GPU)" +bench = "bevel_bench" +row = "bevel size change to 50, text (GPU)" + +[[scenario]] +id = "P57" +title = "Chisel bevel size change, 12 MP text paragraph, size 250 (GPU)" +bench = "bevel_bench" +row = "bevel size change to 250, text (GPU)" + +[[scenario]] +id = "P58" +title = "Chisel bevel size change, 12 MP ellipse, size 8 (CPU)" +bench = "bevel_bench" +row = "bevel size change to 8, ellipse (CPU)" + +[[scenario]] +id = "P59" +title = "Chisel bevel size change, 12 MP text paragraph, size 8 (CPU)" +bench = "bevel_bench" +row = "bevel size change to 8, text (CPU)"