From 8b069bf87eaaf1d7189a0a2d77979f43f3a3261d Mon Sep 17 00:00:00 2001 From: Ted Mao Date: Thu, 15 Oct 2026 20:26:21 -0700 Subject: [PATCH 1/2] Reduce CPU guided-filter coefficient allocations Generated by an AI coding agent. --- Cargo.lock | 1 + contributors/people.toml | 5 + crates/pipeline/Cargo.toml | 1 + crates/pipeline/examples/guided_memory.rs | 84 +++++++++++++++++ crates/pipeline/src/local.rs | 106 +++++++++++++++++++++- docs/gpu-pipeline.md | 4 + docs/guided-filter-memory.md | 53 +++++++++++ 7 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 crates/pipeline/examples/guided_memory.rs create mode 100644 docs/guided-filter-memory.md diff --git a/Cargo.lock b/Cargo.lock index 4b6d54289..6767a876c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3383,6 +3383,7 @@ dependencies = [ name = "lightcraft-pipeline" version = "0.8.0" dependencies = [ + "dhat", "image", "lightcraft-color", "lightcraft-develop", diff --git a/contributors/people.toml b/contributors/people.toml index bec67597c..c95b1298c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -45,3 +45,8 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.ternarybits] +real_name = "Ted Mao" +display_name = "Ted Mao (@ternarybits)" +sync_github_name = false diff --git a/crates/pipeline/Cargo.toml b/crates/pipeline/Cargo.toml index 197020e45..b62588b6d 100644 --- a/crates/pipeline/Cargo.toml +++ b/crates/pipeline/Cargo.toml @@ -19,6 +19,7 @@ rayon = { workspace = true } serde = { workspace = true } [dev-dependencies] +dhat = { workspace = true } lightcraft-scenes = { workspace = true } proptest = { workspace = true } image = { workspace = true } diff --git a/crates/pipeline/examples/guided_memory.rs b/crates/pipeline/examples/guided_memory.rs new file mode 100644 index 000000000..9f6beaa2a --- /dev/null +++ b/crates/pipeline/examples/guided_memory.rs @@ -0,0 +1,84 @@ +//! Isolated fast-guided-filter benchmark (procedural log luminance; no decoded RGB or tone median). +//! `RAYON_NUM_THREADS=1 cargo run --release -p lightcraft-pipeline --example guided_memory -- new` +//! Replace `new` with `old` for the materialized-coefficient reference. Optional positional arguments: +//! width height sigma iterations (defaults: 6000 4000 120 5). Append `--heap` for one dhat-counted run. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use lightcraft_pipeline::local::{BASE_EPS, guided, guided_fast, guided_fast_step}; +use lightcraft_raster::blur::gaussian; +use lightcraft_raster::resample::{Filter, resize}; +use lightcraft_raster::{Plane, par_join, par_rows}; +use std::hint::black_box; +use std::time::Instant; + +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +/// Previous algorithm; kept here only to make before/after measurements reproducible. +fn materialized(p: &Plane, sigma: f32) -> Plane { + let s = guided_fast_step(sigma); + if s <= 1 { + return guided(p, sigma, BASE_EPS); + } + let lo = resize(p, p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1), Filter::Box); + let (a, b) = coefficients(&lo, sigma / s as f32); + let (a, b) = (resize(&a, p.width, p.height, Filter::Bilinear), resize(&b, p.width, p.height, Filter::Bilinear)); + let mut q = Plane::new(p.width, p.height); + par_rows(&mut q.data, p.width, |y, row| { + for ((v, pv), (av, bv)) in row.iter_mut().zip(p.row(y)).zip(a.row(y).iter().zip(b.row(y))) { + *v = av * pv + bv; + } + }); + q +} + +fn coefficients(lo: &Plane, sg: f32) -> (Plane, Plane) { + let (mean, corr) = par_join(|| gaussian(lo, sg), || gaussian(&lo.map(|v| v * v), sg)); + let a = corr.zip_map(&mean, |c, m| { + let v = (c - m * m).max(0.0); + v / (v + BASE_EPS) + }); + let b = mean.zip_map(&a, |m, a| m - a * m); + par_join(|| gaussian(&a, sg), || gaussian(&b, sg)) +} + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().skip(1).collect(); + let mode = args.first().map(String::as_str).unwrap_or("new"); + if !matches!(mode, "old" | "new") { + return Err("mode must be old or new".into()); + } + let parse = |i: usize, default: usize| -> Result> { + Ok(args.get(i).filter(|v| !v.starts_with("--")).map(|v| v.parse()).transpose()?.unwrap_or(default)) + }; + let (w, h, sigma, n) = (parse(1, 6000)?, parse(2, 4000)?, parse(3, 120)? as f32, parse(4, 5)?.max(1)); + if w == 0 || h == 0 || w.checked_mul(h).is_none_or(|pixels| pixels > 100_000_000) { + return Err("dimensions must be positive and at most 100 MP".into()); + } + let heap = args.iter().any(|a| a == "--heap"); + let p = Plane::from_fn(w, h, |x, y| ((x * 7919 + y * 104729) % 97) as f32 / 97.0 - 4.0 + if x > w / 2 { 2.0 } else { 0.0 }); + let filter = || if mode == "old" { materialized(&p, sigma) } else { guided_fast(&p, sigma, BASE_EPS) }; + // Initialize Rayon and warm code paths before counting. The source is excluded from heap stats. + black_box(filter()); + let profiler = heap.then(|| dhat::Profiler::builder().testing().build()); + let mut best = f64::MAX; + for _ in 0..if heap { 1 } else { n } { + let t = Instant::now(); + let q = black_box(filter()); + best = best.min(t.elapsed().as_secs_f64() * 1000.0); + if heap { + let stats = dhat::HeapStats::get(); + println!( + "peak_bytes={} total_bytes={} total_blocks={} retained_output_bytes={}", + stats.max_bytes, + stats.total_bytes, + stats.total_blocks, + q.data.len() * size_of::() + ); + } + } + drop(profiler); + println!("{mode} {w}x{h} sigma={sigma} step={} heap={heap} min_wall_ms={best:.3}", guided_fast_step(sigma)); + Ok(()) +} diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index 679acabfe..6b46ee994 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -178,10 +178,55 @@ fn guided_coeffs(p: &Plane, sigma: f32, eps: f32) -> (Plane, Plane) { par_join(|| gaussian(&a, sigma), || gaussian(&b, sigma)) } -/// `q = a·p + b` per pixel. +/// `q = a·p + b` per pixel. Reduced coefficients are sampled with the same separable +/// weights and accumulation order as a bilinear resize, without full-size coefficient planes. fn guided_apply(p: &Plane, a: &Plane, b: &Plane) -> Plane { let mut q = Plane::new(p.width, p.height); let w = p.width; + if (a.width, a.height) != (p.width, p.height) { + use lightcraft_raster::resample::{Filter, weights}; + let wx = weights(a.width, w, Filter::Bilinear); + let wy = weights(a.height, p.height, Filter::Bilinear); + // Bilinear upsampling has at most four taps (including zero-weight boundary taps). + // Keep only those horizontally sampled rows per band, reusing them for neighbouring + // output rows. Scratch scales with width and active bands, not image area. + const BAND: usize = 32; + for_rows(&mut q.data, w.saturating_mul(BAND), |band, rows| { + let mut cache: [(usize, Vec, Vec); 4] = std::array::from_fn(|_| (usize::MAX, vec![0.0; w], vec![0.0; w])); + let mut bias = vec![0.0; w]; + for (dy, row) in rows.chunks_mut(w).enumerate() { + let y = band * BAND + dy; + let (top, ys) = &wy[y]; + bias.fill(0.0); + for (dy, yw) in ys.iter().enumerate() { + let cy = top + dy; + let (cached_y, aa, bb) = &mut cache[cy % 4]; + if *cached_y != cy { + let (ar, br) = (a.row(cy), b.row(cy)); + for ((av, bv), (left, xs)) in aa.iter_mut().zip(bb.iter_mut()).zip(&wx) { + *av = 0.0; + *bv = 0.0; + for (dx, xw) in xs.iter().enumerate() { + *av += ar[left + dx] * xw; + *bv += br[left + dx] * xw; + } + } + *cached_y = cy; + } + for (av, ax) in row.iter_mut().zip(aa) { + *av += *ax * yw; + } + for (bv, bx) in bias.iter_mut().zip(bb) { + *bv += *bx * yw; + } + } + for ((v, pv), bv) in row.iter_mut().zip(p.row(y)).zip(&bias) { + *v = *v * pv + bv; + } + } + }); + return q; + } for_rows(&mut q.data, w, |y, row| { for ((v, pv), (av, bv)) in row.iter_mut().zip(p.row(y)).zip(a.row(y).iter().zip(b.row(y))) { *v = av * pv + bv; @@ -191,7 +236,7 @@ fn guided_apply(p: &Plane, a: &Plane, b: &Plane) -> Plane { } /// Fast guided filter (He & Sun 2015): coefficients computed on a `s×` subsampled plane and -/// bilinearly upsampled; the output keeps full-resolution edges. Equivalent to [`guided`] for +/// sampled bilinearly at full resolution; the output keeps full-resolution edges. Equivalent to [`guided`] for /// large windows at a fraction of the cost. pub fn guided_fast(p: &Plane, sigma: f32, eps: f32) -> Plane { use lightcraft_raster::resample::{Filter, resize}; @@ -202,8 +247,7 @@ pub fn guided_fast(p: &Plane, sigma: f32, eps: f32) -> Plane { let (lw, lh) = (p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1)); let lo = resize(p, lw, lh, Filter::Box); let (ma, mb) = guided_coeffs(&lo, sigma / s as f32, eps); - let up = |c: &Plane| resize(c, p.width, p.height, Filter::Bilinear); - guided_apply(p, &up(&ma), &up(&mb)) + guided_apply(p, &ma, &mb) } /// Separable median (`2r+1` px along each row, then each column; clamped edges). It keeps the @@ -703,6 +747,60 @@ mod tests { mod fast_tests { use super::*; + /// The previous fast path, including its two materialized coefficient resizes. + fn resized_reference(p: &Plane, sigma: f32, eps: f32) -> Plane { + use lightcraft_raster::resample::{Filter, resize}; + let s = guided_fast_step(sigma); + if s <= 1 { + return guided(p, sigma, eps); + } + let lo = resize(p, p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1), Filter::Box); + let (a, b) = guided_coeffs(&lo, sigma / s as f32, eps); + let (a, b) = (resize(&a, p.width, p.height, Filter::Bilinear), resize(&b, p.width, p.height, Filter::Bilinear)); + Plane::from_fn(p.width, p.height, |x, y| a.get(x, y) * p.get(x, y) + b.get(x, y)) + } + + #[test] + fn sampled_coefficients_match_materialized_resizes() { + for (w, h) in [(0, 0), (1, 1), (1, 19), (23, 1), (2, 3), (17, 13), (64, 48), (161, 119)] { + let planes = [ + Plane::filled(w, h, -3.25), + Plane::from_fn(w, h, |x, y| ((x * 7919 + y * 104729) % 97) as f32 / 7.0 - 8.0), + Plane::from_fn(w, h, |x, y| { + if x == w / 2 && y == h / 2 { + 9.0 + } else if x < w / 2 { + -5.0 + } else { + 2.0 + } + }), + ]; + // Step 1 fallback, every small step, Draft's cap, and the maximum step (16). + for sigma in [0.0, 2.99, 5.99, 6.0, 9.0, 12.0, 24.0, 48.0, 200.0] { + for eps in [BASE_EPS, CLARITY_EPS, 0.0001] { + for p in &planes { + let expected = resized_reference(p, sigma, eps); + let actual = guided_fast(p, sigma, eps); + assert_eq!((actual.width, actual.height), (w, h)); + assert_eq!(actual.data, expected.data, "{w}x{h}, sigma {sigma}, eps {eps}"); + } + } + } + } + } + + #[test] + fn sampled_tone_base_keeps_the_median_before_the_guided_filter() { + let p = Plane::from_fn(73, 51, |x, y| if x % 9 == 0 { 3.0 } else { -4.0 + ((x + y) % 7) as f32 * 0.03 }); + for sigma in [6.0, 24.0, 120.0] { + for detail in [0, 1, 3, BASE_DETAIL_MAX] { + let median = median_hv(&p, detail); + assert_eq!(tone_base(&p, sigma, detail).data, resized_reference(&median, sigma, BASE_EPS).data, "{sigma}, {detail}"); + } + } + } + #[test] fn fast_guided_close_to_reference() { let p = Plane::from_fn(160, 120, |x, y| ((x as f32 * 0.13).sin() + (y as f32 * 0.07).cos()) * 2.0 + if x > 80 { 1.5 } else { 0.0 }); diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index b74af85a2..43df8b75a 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -4,6 +4,10 @@ The CPU pipeline (`crates/pipeline`) is the reference ("oracle"). `lightcraft-gp evaluates the same stages with wgpu compute shaders (WGSL) on Metal / Vulkan / DX12. Everything is pure Rust (wgpu, naga); the drivers are the system's. No GL backend is compiled in. +CPU fast guided-filter allocation measurements and reproduction commands are documented in +[guided-filter-memory.md](guided-filter-memory.md). That optimization retains the full-size base +and does not change the GPU's coefficient buffers. + Warped geometry uses a CPU-computed coverage bit mask for the source boundary. The CPU reference evaluates that boundary in double precision; GPU float rounding otherwise can turn a blank edge pixel into a photo pixel. The mask diff --git a/docs/guided-filter-memory.md b/docs/guided-filter-memory.md new file mode 100644 index 000000000..de0fe1b62 --- /dev/null +++ b/docs/guided-filter-memory.md @@ -0,0 +1,53 @@ +# CPU fast guided-filter memory + +`local::guided_fast` computes blurred coefficients on the same reduced grid as before, then +samples them while applying `a * p + b`. It keeps the raster resizer's weights, boundary +normalization and horizontal-then-vertical accumulation order. Odd dimensions therefore use +their actual reduced/full dimension ratios. Four coefficient row pairs and one bias row are cached +per active 32-row band; adjacent output rows reuse their horizontal samples. + +The two full-resolution coefficient planes and their resize intermediates are gone. The returned +full-resolution base remains: downstream CPU tone mapping and the spatial-plane cache need it. +The step-1 filter, Box reduction, Gaussian coefficients, V2's median input, process selection and +Draft sigma cap are unchanged. The GPU implementation is unchanged; this reduces CPU memory only. + +## Reproduce + +The dev-only example includes the previous materialized algorithm and uses the workspace's +existing `dhat` allocator. No allocator or profiling dependency is added to product builds. + +```sh +cargo build --release -p lightcraft-pipeline --example guided_memory +RAYON_NUM_THREADS=1 target/release/examples/guided_memory old 6000 4000 120 15 +RAYON_NUM_THREADS=1 target/release/examples/guided_memory new 6000 4000 120 15 +RAYON_NUM_THREADS=1 target/release/examples/guided_memory old 6000 4000 120 1 --heap +RAYON_NUM_THREADS=1 target/release/examples/guided_memory new 6000 4000 120 1 --heap +``` + +If `CARGO_TARGET_DIR` is set, use that directory instead of `target` for the executable. +Arguments are mode, width, height, integer sigma and timing iterations. The example warms up +before measurement; `--heap` counts one filter call with the source allocated beforehand. +Time the runs without `--heap`: allocation profiling significantly changes timings. It reports +the minimum wall time; on a shared machine repeat when other builds/renders have stopped. +Use `LIGHTCRAFT_PROFILE=1` and `cargo xtask bench` for broader pipeline measurements. + +## Observed allocation and time + +Measured 2026-10-16 UTC, macOS aarch64, Rust 1.96.0, release build, one Rayon thread. Bytes are +requested heap bytes counted by dhat, not process RSS. Timings are minimums of 15 unprofiled +calls on a shared machine with concurrent builds; they are indicative, not a speed guarantee. + +| Dimensions / sigma / step | Old peak bytes | New peak bytes | Old total allocated bytes | New total allocated bytes | Old / new wall ms | +|---|---:|---:|---:|---:|---:| +| 6000 × 4000 / 120 / 16 | 289,125,000 | 97,780,856 | 313,083,488 | 135,643,856 | 42.135 / 45.131 | +| 1601 × 1067 / 24 / 8 | 20,822,412 | 7,331,172 | 25,102,604 | 11,562,560 | 3.359 / 3.121 | + +The 24 MP result saves about 191 MB of peak allocation (66%). Its **96,000,000-byte output is +included**; its **96,000,000-byte input is excluded**. Application RGB images, log luminance, +V2 median input and its construction temporaries, other spatial planes and caches also remain +outside this isolated measurement. More Rayon threads can hold more band scratch concurrently. +This is not a bound on total pipeline memory. + +Regression tests compare the sampled result exactly against materialized bilinear resizes for +empty/singleton/one-dimensional, odd/even and multiple-band images; flat fields, signed texture, +edges and impulses; small/full and reduced filters; and median tone bases. From 8677e5fe3e82779d6cf9336b0aad2859780f601c Mon Sep 17 00:00:00 2001 From: Ted Mao Date: Thu, 15 Oct 2026 21:26:36 -0700 Subject: [PATCH 2/2] Use GitHub nickname for contributor display name Generated by an AI coding agent. --- contributors/people.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contributors/people.toml b/contributors/people.toml index c95b1298c..ffb444273 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -48,5 +48,5 @@ sync_github_name = false [people.ternarybits] real_name = "Ted Mao" -display_name = "Ted Mao (@ternarybits)" +display_name = "ternarybits" sync_github_name = false