diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 65341aeab..040069373 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -139,6 +139,23 @@ pub(crate) fn is_bw(s: &DevelopSettings) -> bool { } /// Parallel map over output rows with index. +/// `LIGHTCRAFT_PROFILE` is set: print per-stage timings to stderr. +pub(crate) fn profiling() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("LIGHTCRAFT_PROFILE").is_some()) +} + +/// Run `f`, printing its duration under `LIGHTCRAFT_PROFILE`. +pub(crate) fn timed(what: &str, f: impl FnOnce() -> R) -> R { + if !profiling() { + return f(); + } + let t = std::time::Instant::now(); + let r = f(); + eprintln!(" {what}: {:.1} ms", t.elapsed().as_secs_f64() * 1e3); + r +} + pub(crate) fn for_rows(data: &mut [T], w: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) { par_rows(data, w, f) } diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index bcb8b0ea8..b84a048ed 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -8,7 +8,7 @@ use lightcraft_raster::blur::gaussian; use lightcraft_raster::{Plane, Rgb32f}; use crate::geometry::Frame; -use crate::{Prepared, Quality, SourceInfo, for_rows, masks}; +use crate::{Prepared, Quality, SourceInfo, for_rows, masks, timed}; /// White balance (relative to the source's as-shot white) and exposure, in place. pub fn scene_linear_pre(img: &mut Rgb32f, info: &SourceInfo, s: &DevelopSettings) { @@ -120,6 +120,7 @@ pub fn denoise(img: &mut Rgb32f, s: &DevelopSettings, src_long: usize, out_long: let scale = (out_long as f32 / src_long.max(1) as f32).clamp(0.05, 1.0); let w = img.width; if lum > 0.0 { + let _t = crate::profiling().then(std::time::Instant::now); let l = img.map(log_lum); let detail = (s.detail.nr_detail / 100.0) as f32; let eps = 0.002 + lum * lum * 0.25 * (1.0 - 0.7 * detail); @@ -133,8 +134,12 @@ pub fn denoise(img: &mut Rgb32f, s: &DevelopSettings, src_long: usize, out_long: *p = p.map(|v| v * g); } }); + if let Some(t) = _t { + eprintln!(" nr luminance: {:.1} ms", t.elapsed().as_secs_f64() * 1e3); + } } if col > 0.0 { + let _t = crate::profiling().then(std::time::Instant::now); let chroma = img.map(|c| { let y = luminance_2020(c).max(1e-6); [c[0] / y, c[1] / y, c[2] / y] @@ -152,6 +157,9 @@ pub fn denoise(img: &mut Rgb32f, s: &DevelopSettings, src_long: usize, out_long: *p = [0, 1, 2].map(|k| ((c0[k] + (cb[k] - c0[k]) * t) * yl).max(0.0)); } }); + if let Some(t) = _t { + eprintln!(" nr colour: {:.1} ms", t.elapsed().as_secs_f64() * 1e3); + } } } @@ -160,7 +168,7 @@ pub fn log_lum(c: [f32; 3]) -> f32 { } pub fn prepare(img: Rgb32f, s: &DevelopSettings, frame: &Frame, px_per_long: f64, q: Quality) -> Prepared { - let log_l = img.map(log_lum); + let log_l = timed("log_l", || img.map(log_lum)); let ppl = px_per_long as f32; let tone_active = s.light.highlights != 0.0 || s.light.shadows != 0.0 || s.masks.iter().any(|m| m.adjust.highlights != 0.0 || m.adjust.shadows != 0.0); @@ -168,19 +176,25 @@ pub fn prepare(img: Rgb32f, s: &DevelopSettings, frame: &Frame, px_per_long: f64 // Edge-aware base at ~1.5% of the long edge (EV² epsilon: edges of > ~0.6 EV are preserved). let sigma = (0.015 * ppl).max(1.0); let sigma = if q == Quality::Draft { sigma.min(24.0) } else { sigma }; - guided_fast(&log_l, sigma, 0.35) + timed("base", || guided_fast(&log_l, sigma, 0.35)) } else { log_l.clone() }; let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().any(|m| f(&m.adjust) != 0.0); - let clarity_blur = (s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| guided_fast(&log_l, (0.012 * ppl).max(1.0), 0.8)); + let clarity_blur = + (s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| timed("clarity", || guided_fast(&log_l, (0.012 * ppl).max(1.0), 0.8))); let texture_blur = (s.effects.texture != 0.0 || s.detail.sharpen_amount != 0.0 || local_any(|a| a.texture) || local_any(|a| a.sharpness)) - .then(|| gaussian(&log_l, (0.0018 * ppl).max(0.6))); + .then(|| timed("texture", || gaussian(&log_l, (0.0018 * ppl).max(0.6)))); let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| { + let _t = crate::profiling().then(std::time::Instant::now); let min_c = img.map(|c| c[0].min(c[1]).min(c[2])); - gaussian(&min_c, (0.02 * ppl).max(1.0)) + let d = gaussian(&min_c, (0.02 * ppl).max(1.0)); + if let Some(t) = _t { + eprintln!(" dehaze: {:.1} ms", t.elapsed().as_secs_f64() * 1e3); + } + d }); - let masks = masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l); + let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l)); Prepared { img, log_l, base, clarity_blur, texture_blur, dark, masks, px_per_long } } diff --git a/crates/raster/examples/blur_bench.rs b/crates/raster/examples/blur_bench.rs new file mode 100644 index 000000000..0a222a336 --- /dev/null +++ b/crates/raster/examples/blur_bench.rs @@ -0,0 +1,24 @@ +//! Micro-benchmark for the blur/map primitives (min of N runs, robust to a loaded machine): +//! `RAYON_NUM_THREADS=1 cargo run --release -p lightcraft-raster --example blur_bench`. +use lightcraft_raster::{Plane, Rgb32f, blur::gaussian}; +use std::time::Instant; + +fn best(n: usize, mut f: impl FnMut()) -> f64 { + (0..n) + .map(|_| { + let t = Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1e3 + }) + .fold(f64::MAX, f64::min) +} + +fn main() { + let (w, h) = (3000, 2400); + let p = Plane::from_fn(w, h, |x, y| ((x * 7 + y * 13) % 255) as f32 / 255.0); + let c = Rgb32f::from_fn(w, h, |x, y| [x as f32 / w as f32, y as f32 / h as f32, 0.5]); + for sigma in [1.0f32, 4.0, 24.0] { + println!("plane gaussian σ={sigma}: {:.1} ms", best(9, || drop(gaussian(&p, sigma)))); + } + println!("rgb gaussian σ=4: {:.1} ms", best(9, || drop(gaussian(&c, 4.0)))); +} diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs index 13f9c43a3..ac8eeda6c 100644 --- a/crates/raster/src/blur.rs +++ b/crates/raster/src/blur.rs @@ -17,37 +17,58 @@ fn box_radii(sigma: f32) -> [usize; 3] { std::array::from_fn(|i| (((if (i as i32) < m { wl } else { wu }) - 1) / 2).max(0) as usize) } -fn box_h(src: &Image, r: usize) -> Image { - let w = src.width; - let mut out = Image::::new(w, src.height); - if r == 0 { - return src.clone(); +/// One horizontal box pass over `row` (clamped edges), using `tmp` as scratch. +#[inline] +fn box_row(row: &mut [T], tmp: &mut Vec, r: usize) { + let w = row.len(); + if r == 0 || w == 0 { + return; } + tmp.clear(); + tmp.extend_from_slice(row); + let s = &tmp[..]; + let last = w - 1; let inv = 1.0 / (2 * r + 1) as f32; - par_rows(&mut out.data, w, |y, row| { - let s = src.row(y); - let at = |i: isize| s[i.clamp(0, w as isize - 1) as usize]; - let mut acc = T::zero(); - for i in -(r as isize)..=(r as isize) { - acc = acc.madd(at(i), 1.0); - } - for x in 0..w { - row[x] = T::zero().madd(acc, inv); - acc = acc.madd(at(x as isize + r as isize + 1), 1.0).madd(at(x as isize - r as isize), -1.0); - } - }); - out + let mut acc = T::zero(); + for i in 0..=2 * r { + acc = acc.madd(s[i.saturating_sub(r).min(last)], 1.0); + } + for x in 0..w { + row[x] = T::zero().madd(acc, inv); + acc = acc.madd(s[(x + r + 1).min(last)], 1.0).madd(s[x.saturating_sub(r)], -1.0); + } } -fn transpose(src: &Image) -> Image { +/// Rows per parallel band in the vertical pass (each band re-primes its running sums). +const BAND: usize = 32; + +/// One vertical box pass `src → dst` (clamped edges): a running sum per column, vectorized across +/// the row, parallel over horizontal bands. Cache-friendly — no transposes. +fn box_v(src: &Image, dst: &mut Image, r: usize) { let (w, h) = (src.width, src.height); - let mut out = Image::::new(h, w); - par_rows(&mut out.data, h, |y, row| { - for (x, o) in row.iter_mut().enumerate() { - *o = src.data[x * w + y]; + let inv = 1.0 / (2 * r + 1) as f32; + let last = h - 1; + let row = |y: usize| &src.data[y * w..(y + 1) * w]; + par_rows(&mut dst.data, w * BAND, |band, out| { + let y0 = band * BAND; + let mut acc = vec![T::zero(); w]; + for i in 0..=2 * r { + let yy = (y0 + i).saturating_sub(r).min(last); + for (a, v) in acc.iter_mut().zip(row(yy)) { + *a = a.madd(*v, 1.0); + } + } + for (k, o) in out.chunks_mut(w).enumerate() { + let y = y0 + k; + for (d, a) in o.iter_mut().zip(&acc) { + *d = T::zero().madd(*a, inv); + } + let (add, sub) = (row((y + r + 1).min(last)), row(y.saturating_sub(r))); + for ((a, p), m) in acc.iter_mut().zip(add).zip(sub) { + *a = a.madd(*p, 1.0).madd(*m, -1.0); + } } }); - out } /// Blur with Gaussian `sigma` (pixels). `sigma <= 0.3` returns a copy. @@ -57,14 +78,21 @@ pub fn gaussian(img: &Image, sigma: f32) -> Image { } let radii = box_radii(sigma); let mut a = img.clone(); + par_rows(&mut a.data, img.width, |_, row| { + let mut tmp = Vec::with_capacity(row.len()); + for r in radii { + box_row(row, &mut tmp, r); + } + }); + let mut b = Image::::new(img.width, img.height); for r in radii { - a = box_h(&a, r); + if r == 0 { + continue; + } + box_v(&a, &mut b, r); + std::mem::swap(&mut a, &mut b); } - let mut t = transpose(&a); - for r in radii { - t = box_h(&t, r); - } - transpose(&t) + a } #[cfg(test)]