From 99b89cd9863d6cc9eeb5ba7303ebb68ff50d996b Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 16:52:12 -0500 Subject: [PATCH] perf(gpu): #1145 keep mask alphas in GpuStages The GPU stage cache mirrors the CPU's: per-mask alpha buffers keyed by masks::alpha_key, copied into the combined alpha buffer on a hit (no readback for CPU-evaluated shapes). GpuStages::bytes counts them and the chromaticity buffer it left out. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/render.rs | 38 ++++++++++++++++---- crates/gpu/tests/equivalence.rs | 61 +++++++++++++++++++++++++++++++++ docs/gpu-pipeline.md | 8 +++++ 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 44458350f..f4b7e9fad 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -9,6 +9,7 @@ use lightcraft_geom::Orientation; use lightcraft_pipeline::finish::{FinishParams, MASK_TERMS, mask_terms}; use lightcraft_pipeline::geometry::SampleMode; use lightcraft_pipeline::local::plane_at; +use lightcraft_pipeline::masks::{AlphaCache, AlphaKeys, alpha_key}; use lightcraft_pipeline::{Plan, RenderRequest, Rendered, SourceInfo, local}; use lightcraft_raster::resample::Filter; use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8}; @@ -34,6 +35,9 @@ struct Entry { sampled: Arc, lin: Option<(u64, Arc)>, planes: Planes, + /// Alphas of the last render's masks, as [`lightcraft_pipeline::masks::AlphaCache`] keeps them + /// on the CPU (same keys). + masks: AlphaCache, } /// Spatial planes of one linear image (`key` = its `lin_key`), each tagged with its radius. @@ -106,6 +110,8 @@ impl GpuStages { p.clarity.iter().for_each(|(_, b)| add(b)); p.texture.iter().for_each(|(_, b)| add(b)); p.dark.iter().for_each(|(_, b, _)| add(b)); + p.chroma.iter().for_each(|(_, b)| add(b)); + e.masks.planes().for_each(&mut add); } if let Some((_, b)) = &*self.source.lock().unwrap_or_else(|e| e.into_inner()) { add(b); @@ -644,6 +650,7 @@ pub fn render( lap("wb/nr", &mut t, &mut cx); // 3. spatial planes + let mut alphas = cached.as_ref().map(|e| e.masks.clone()).unwrap_or_default(); let mut planes = match cached.map(|e| e.planes) { Some(p) if p.key == plan.lin_key => p, _ => Planes { key: plan.lin_key, ..Default::default() }, @@ -654,13 +661,13 @@ pub fn render( prep.air = a; } lap("planes", &mut t, &mut cx); - if let Some(c) = stages { - c.put(Entry { src: src.clone(), geo: plan.geo, sampled, lin: Some((plan.lin_key, lin.clone())), planes }); - } // 4. masks - let (masks, terms) = masks(&mut cx, &lin, &prep, &plan, &mut host); + let (masks, terms) = masks(&mut cx, &lin, &prep, &plan, &mut host, &mut alphas); lap("masks", &mut t, &mut cx); + if let Some(c) = stages { + c.put(Entry { src: src.clone(), geo: plan.geo, sampled, lin: Some((plan.lin_key, lin.clone())), planes, masks: alphas }); + } // 5. per-pixel stage let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); @@ -924,14 +931,25 @@ fn guided_cross_max(cx: &mut Cx<'_>, guide: &Buf, p: &Buf, w: usize, h: usize, s /// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated, /// followed by the blurred chromaticity when local Moiré / Noise need it) and their adjustment -/// terms. Shapes without a kernel (Sky, Subject, …) run on the CPU. -fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Host) -> (Option, Vec<[f32; MASK_TERMS]>) { +/// terms. Shapes without a kernel (Sky, Subject, …) run on the CPU. A mask whose alpha `alphas` +/// holds (same [`alpha_key`]) is copied from there; `alphas` keeps this render's. +fn masks( + cx: &mut Cx<'_>, + lin: &Buf, + prep: &Prep, + plan: &Plan<'_>, + host: &mut Host, + alphas: &mut AlphaCache, +) -> (Option, Vec<[f32; MASK_TERMS]>) { use lightcraft_develop::{MaskOp, MaskShape}; let s = &*plan.settings; let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect(); + let old = alphas.take(plan.mattes.as_ref()); if list.is_empty() { return (None, Vec::new()); } + let keys = AlphaKeys { geo: plan.geo, image: plan.lin_key }; + let mut used = Vec::with_capacity(list.len()); let (w, h) = (plan.w, plan.h); let n = w * h; let frame = &plan.frame; @@ -946,6 +964,12 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho } let comp_plane = cx.gpu.buffer(n); for (mi, m) in list.iter().enumerate() { + let key = alpha_key(m, keys, ev); + if let Some((_, b)) = old.iter().find(|(k, _)| *k == key) { + cx.copy_into(b, &alpha, mi * n); + used.push((key, b.clone())); + continue; + } let mut first = true; for comp in &m.components { let op = match comp.op { @@ -1055,7 +1079,9 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho &[None, None, None, Some(&comp_plane), Some(&alpha)], groups1(n), ); + used.push((key, Arc::new(cx.slice(&alpha, mi * n, n)))); } + alphas.keep(plan.mattes.as_ref(), used); let terms = list.iter().map(|m| mask_terms(&m.adjust)).collect(); (Some(alpha), terms) } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 96771a971..ed0c6fb97 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -553,6 +553,67 @@ fn cached_renders_match_uncached() { } } +/// Mask alphas kept on the device across a slider drag: a warm render equals a fresh one whatever +/// the edit (one each mask reads, and ones none of them read), the CPU's result within the bounds, +/// and the alphas count in the stages' device bytes. +#[test] +fn cached_renders_with_masks_match_uncached() { + if !gpu() { + return; + } + let src = scene(1, 900, 600); + let info = SourceInfo { raw: true, ..Default::default() }; + let cache = StageCache::default(); + let req = RenderRequest::fit(640, 640); + let comp = |shape: MaskShape| MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }; + let mask = |id: u32, shape: MaskShape| Mask { id, components: vec![comp(shape)], ..Default::default() }; + let mut s = DevelopSettings::default(); + typical(&mut s); + let stroke = BrushStroke { points: (0..10).map(|k| Point::new(0.1 + 0.07 * k as f64, 0.6)).collect(), size: 0.04, ..Default::default() }; + s.masks = vec![ + mask(1, MaskShape::Radial { center: Point::new(0.4, 0.5), rx: 0.3, ry: 0.2, angle: 0.0, feather: 50.0, invert: false }), + mask(2, MaskShape::Brush { strokes: vec![stroke] }), + mask(3, MaskShape::LuminanceRange { lo: 0.3, hi: 0.7, lo_feather: 0.1, hi_feather: 0.1 }), + // no kernel: evaluated on the CPU and uploaded + mask(4, MaskShape::Subject), + ]; + for (i, m) in s.masks.iter_mut().enumerate() { + m.adjust.exposure = 0.3 * i as f64 - 0.4; + m.adjust.clarity = 20.0; + } + let steps: Vec> = vec![ + Box::new(|_| {}), + Box::new(|s| s.light.exposure = 0.8), + Box::new(|s| s.masks[0].adjust.exposure = 1.0), + Box::new(|s| s.masks[3].adjust.amount = 50.0), + Box::new(|s| { + s.masks[0].components[0].shape = + MaskShape::Radial { center: Point::new(0.6, 0.4), rx: 0.2, ry: 0.2, angle: 0.0, feather: 30.0, invert: false } + }), + Box::new(|s| s.masks.swap(1, 2)), + Box::new(|s| { + s.wb.mode = WbMode::Custom; + s.wb.temp = 4300.0; + }), + Box::new(|s| s.masks[1].visible = false), + ]; + for (i, step) in steps.iter().enumerate() { + step(&mut s); + let warm = lightcraft_gpu::render(&src, &info, &s, &req, Some(&cache)).expect("gpu"); + let fresh = lightcraft_gpu::render(&src, &info, &s, &req, None).expect("gpu"); + assert_eq!(warm.image, fresh.image, "step {i}"); + } + check("masks after a cached drag", &src, &info, &s, &req); + // three visible masks of 4 B/px held on the device besides the stages + let out = lightcraft_gpu::render(&src, &info, &s, &req, Some(&cache)).expect("gpu").image; + let n = out.width * out.height; + let with = lightcraft_gpu::stage_bytes(&cache); + s.masks.clear(); + let fresh = StageCache::default(); + lightcraft_gpu::render(&src, &info, &s, &req, Some(&fresh)).expect("gpu"); + assert!(with >= lightcraft_gpu::stage_bytes(&fresh) + 3 * 4 * n, "{with}"); +} + /// Issue #632: the Highlights / Shadows base of each process (V1: the edge-aware filter of log /// luminance; V2: of its fine median, a radius that grows with the render) matches the CPU's, and /// a warm cache never hands one process the other's base. diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 883c3a0bf..689c97c76 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -150,6 +150,14 @@ walks the drive. - Stage cache: `GpuStages` mirrors `StageCache` (same keys: source identity + `geo`, `lin_key`, per-plane radii) and lives in it as an extension, so the UI needs no change. The uploaded source is kept per view. A tone / colour / exposure drag re-runs one dispatch of `finish` plus the readback. + Mask alphas are cached too, on both sides (`masks::AlphaCache`, one plane per visible mask of the + last render), each keyed by `masks::alpha_key`: the mask's components, Refine Edges, Invert and + Amount, `geo`, plus `lin_key` when it reads the image (range masks, Sky / Subject / Background + heuristics, Auto Mask brushes, Refine Edges) and the exposure when it reads that (luminance and + colour range, Sky). An Exposure drag over gradient and brush masks, or a drag of a mask's own + sliders, evaluates no mask; a cached alpha is the buffer a fresh evaluation produced, so warm and + fresh renders stay bit-identical. Both caches' `bytes()` count the alphas and the blurred + chromaticity. - One command encoder per render; the only mid-render readback is the airlight samples (dehaze, when the dark channel is recomputed). Dropped buffers are recycled (exact size, ≤ 2 GB pool) once the thread that dropped them has submitted — allocating and zero-filling fresh 100–300 MB buffers