M5.1: cache-friendly Gaussian blur (no transposes; in-row horizontal passes, banded running-sum vertical passes) + per-stage pipeline profiling

3–10x faster blurs on a 3000x2400 plane (min-of-9; machine under load, see blur_bench example).
LIGHTCRAFT_PROFILE=1 now also times log-lum, base, clarity, texture, dehaze, masks and NR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 23:59:10 -04:00
parent 17057f7bbc
commit 3cc97c58d1
4 changed files with 120 additions and 37 deletions

View File

@ -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<bool> = 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<R>(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<T: Send>(data: &mut [T], w: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
par_rows(data, w, f)
}

View File

@ -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 }
}

View File

@ -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))));
}

View File

@ -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<T: Pixel>(src: &Image<T>, r: usize) -> Image<T> {
let w = src.width;
let mut out = Image::<T>::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<T: Pixel>(row: &mut [T], tmp: &mut Vec<T>, 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<T: Pixel>(src: &Image<T>) -> Image<T> {
/// 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<T: Pixel>(src: &Image<T>, dst: &mut Image<T>, r: usize) {
let (w, h) = (src.width, src.height);
let mut out = Image::<T>::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<T: Pixel>(img: &Image<T>, sigma: f32) -> Image<T> {
}
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::<T>::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)]