From 42dd44e78a37ca6e59b1d67322574c68b038cc7d Mon Sep 17 00:00:00 2001 From: Willy Foo Date: Fri, 9 Oct 2026 08:26:28 +0800 Subject: [PATCH] perf(algo): specialize photo blur and resample channel loops --- contributors/people.toml | 5 ++ crates/algo/examples/photo_workflow_bench.rs | 38 +++++++++ crates/algo/src/blur.rs | 64 ++++++++++++++-- crates/algo/src/resample.rs | 81 ++++++++++++++++---- 4 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 crates/algo/examples/photo_workflow_bench.rs diff --git a/contributors/people.toml b/contributors/people.toml index 5f294b1491..0497f2dba8 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -33,3 +33,8 @@ sync_github_name = true [people.ArthurViniciusBA1] real_name = "Arthur Vinicius" + +[people.GamerWil26] +real_name = "Willy Foo" +display_name = "Willy Foo" +sync_github_name = false diff --git a/crates/algo/examples/photo_workflow_bench.rs b/crates/algo/examples/photo_workflow_bench.rs new file mode 100644 index 0000000000..54f4f74e5e --- /dev/null +++ b/crates/algo/examples/photo_workflow_bench.rs @@ -0,0 +1,38 @@ +//! Deterministic 24 MP blur/resize benchmark. Run with --release; no private photos needed. +use photocraft_algo::{ + FilterParams, apply_in, + resample::{Resample, resize_surface_in_canvas}, +}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use std::time::Instant; +fn main() { + let bounds = Rect::new(0, 0, 6000, 4000); + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + for y in (0..4000).step_by(128) { + let r = Rect::new(0, y, 6000, (y + 128).min(4000)); + let mut data = Vec::new(); + for yy in r.y0..r.y1 { + for x in r.x0..r.x1 { + data.extend_from_slice(&[(x % 251) as f32 / 250.0, (yy % 241) as f32 / 240.0, ((x + yy) % 239) as f32 / 238.0, 1.0]); + } + } + src.write_region(r, &data); + } + for op in ["blur3", "bicubic1791"] { + for rep in 0..4 { + let t = Instant::now(); + let out = if op == "blur3" { + apply_in(&src, &FilterParams::GaussianBlur { radius: 3.0 }, bounds, bounds, None, bounds) + } else { + resize_surface_in_canvas(&src, 1791.0 / 6000.0, 1194.0 / 4000.0, Resample::Bicubic, bounds) + }; + let elapsed = t.elapsed().as_secs_f64() * 1000.0; + let sample = out.read_region(Rect::new(100, 100, 140, 130)); + let checksum = sample.iter().fold(0u64, |s, v| s.wrapping_mul(31).wrapping_add(v.to_bits() as u64)); + println!("{op} rep={rep} ms={elapsed:.3} checksum={checksum}"); + std::hint::black_box(out); + } + } +} diff --git a/crates/algo/src/blur.rs b/crates/algo/src/blur.rs index adb8984be9..66926f475d 100644 --- a/crates/algo/src/blur.rs +++ b/crates/algo/src/blur.rs @@ -43,12 +43,21 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo for ty in 0..th { let row = &p[ty * ww * n..(ty + 1) * ww * n]; let dst = &mut tmp[ty * ow * n..(ty + 1) * ow * n]; - for ox in 0..ow { - let d = &mut dst[ox * n..(ox + 1) * n]; - for (i, kv) in kx.iter().enumerate() { - let s = &row[(ox + i) * n..(ox + i + 1) * n]; - for c in 0..n { - d[c] += s[c] * kv; + match n { + 1 => conv_row::<1>(row, dst, kx), + 2 => conv_row::<2>(row, dst, kx), + 3 => conv_row::<3>(row, dst, kx), + 4 => conv_row::<4>(row, dst, kx), + 5 => conv_row::<5>(row, dst, kx), + _ => { + for ox in 0..ow { + let d = &mut dst[ox * n..(ox + 1) * n]; + for (i, kv) in kx.iter().enumerate() { + let s = &row[(ox + i) * n..(ox + i + 1) * n]; + for c in 0..n { + d[c] += s[c] * kv; + } + } } } } @@ -68,6 +77,21 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo res } +// Fixed channel counts let LLVM keep one pixel's accumulators in registers and vectorize +// channels. The tap order is unchanged; unusual multichannel images keep the generic path. +fn conv_row(src: &[f32], dst: &mut [f32], kernel: &[f32]) { + let pixels = src.as_chunks::().0; + for (x, out) in dst.as_chunks_mut::().0.iter_mut().enumerate() { + let mut acc = [0.0f32; N]; + for (pixel, weight) in pixels.get(x..).unwrap_or(&[]).iter().zip(kernel) { + for c in 0..N { + acc[c] += pixel[c] * weight; + } + } + *out = acc; + } +} + pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec { // Exact kernel for small radii; beyond that three box passes approximate the Gaussian with // cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010). @@ -660,6 +684,34 @@ mod tests { assert!(coarse < expected * 0.1, "former 64-interval sampling unexpectedly resolved the patch: {coarse} vs {expected}"); } + #[test] + fn fixed_channel_convolution_matches_scalar_tap_order() { + fn check() { + for width in [1, 7, 256] { + for radius in [0.0, 0.5, 3.0, 4.0] { + let k = gaussian_kernel(radius); + let src: Vec = (0..(width + k.len() - 1) * N).map(|i| ((i * 73) % 251) as f32 / 250.0).collect(); + let mut expected = vec![0.0f32; width * N]; + for x in 0..width { + for (i, w) in k.iter().enumerate() { + for c in 0..N { + expected[x * N + c] += src[(x + i) * N + c] * w; + } + } + } + let mut actual = vec![0.0f32; width * N]; + conv_row::(&src, &mut actual, &k); + assert_eq!(actual, expected, "channels={N} width={width} radius={radius}"); + } + } + } + check::<1>(); + check::<2>(); + check::<3>(); + check::<4>(); + check::<5>(); + } + #[test] fn box_gaussian_matches_exact_kernel() { // A hard vertical edge (premultiplied RGBA-ish, 2 channels) blurred with σ = 12. diff --git a/crates/algo/src/resample.rs b/crates/algo/src/resample.rs index 57b3627315..6ac1c3b35e 100644 --- a/crates/algo/src/resample.rs +++ b/crates/algo/src/resample.rs @@ -148,6 +148,41 @@ pub fn resize_surface_in_canvas(s: &Surface, sx: f64, sy: f64, filter: Resample, /// Samples one source read may hold (64 MB of `f32`); larger windows are read in row chunks. const READ_BUDGET: usize = 1 << 24; +// Preserve tap order and edge weights while exposing the common channel counts to LLVM. +fn horizontal_fixed(row: &[f32], out: &mut [f32], taps: &[Taps], x_lo: i32, def: &[f32]) { + let pixels = row.as_chunks::().0; + for (t, d) in taps.iter().zip(out.as_chunks_mut::().0) { + let mut acc = [0.0f32; N]; + for (k, w) in t.weights.iter().enumerate() { + if let Some(sp) = pixels.get((t.start + k as i32 - x_lo) as usize) { + for c in 0..N { + acc[c] += sp[c] * w; + } + } + } + for (c, v) in acc.iter_mut().enumerate() { + *v += def.get(c).copied().unwrap_or(0.0) * t.outside; + } + *d = acc; + } +} + +fn horizontal_dynamic(row: &[f32], out: &mut [f32], taps: &[Taps], x_lo: i32, def: &[f32], n: usize) { + for (t, d) in taps.iter().zip(out.chunks_exact_mut(n)) { + for (k, w) in t.weights.iter().enumerate() { + let sx_ = (t.start + k as i32 - x_lo) as usize; + if let Some(sp) = row.get(sx_ * n..(sx_ + 1) * n) { + for c in 0..n { + d[c] += sp[c] * w; + } + } + } + for c in 0..n { + d[c] += def[c] * t.outside; + } + } +} + fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Option) -> Surface { resize_with_budget(s, sx, sy, filter, edge, READ_BUDGET) } @@ -174,20 +209,13 @@ fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Opt const BAND: usize = 64; let bands: Vec<(usize, usize)> = (0..vy.len()).step_by(BAND).map(|b| (b, (b + BAND).min(vy.len()))).collect(); // Horizontal pass of one source row (`row` spans `x_lo..x_hi`) into `dw` samples. - let horizontal = |row: &[f32], out: &mut [f32]| { - for (t, d) in hx.iter().zip(out.chunks_exact_mut(n)) { - for (k, w) in t.weights.iter().enumerate() { - let sx_ = (t.start + k as i32 - x_lo) as usize; - if let Some(sp) = row.get(sx_ * n..(sx_ + 1) * n) { - for c in 0..n { - d[c] += sp[c] * w; - } - } - } - for c in 0..n { - d[c] += def[c] * t.outside; - } - } + let horizontal = |row: &[f32], out: &mut [f32]| match n { + 1 => horizontal_fixed::<1>(row, out, &hx, x_lo, &def), + 2 => horizontal_fixed::<2>(row, out, &hx, x_lo, &def), + 3 => horizontal_fixed::<3>(row, out, &hx, x_lo, &def), + 4 => horizontal_fixed::<4>(row, out, &hx, x_lo, &def), + 5 => horizontal_fixed::<5>(row, out, &hx, x_lo, &def), + _ => horizontal_dynamic(row, out, &hx, x_lo, &def, n), }; // A source row of nothing but the default pixel, after the horizontal pass. let def_row = { @@ -313,6 +341,31 @@ mod tests { s } + #[test] + fn fixed_channel_rows_match_dynamic_resampling() { + fn check() { + for scale in [0.12, 0.5, 1.0, 2.3] { + for filter in [Resample::Nearest, Resample::Bilinear, Resample::Bicubic, Resample::Lanczos] { + for clamp in [None, Some((-3, 31))] { + let ts = taps(-5, 41, scale, filter, clamp, (-3, 31)); + let row: Vec = (0..35 * N).map(|i| ((i * 73) % 251) as f32 / 250.0).collect(); + let def: Vec = (0..N).map(|i| i as f32 / 7.0).collect(); + let mut expected = vec![0.0; ts.len() * N]; + let mut actual = expected.clone(); + horizontal_dynamic(&row, &mut expected, &ts, -3, &def, N); + horizontal_fixed::(&row, &mut actual, &ts, -3, &def); + assert_eq!(actual, expected, "N={N} scale={scale} filter={filter:?} clamp={clamp:?}"); + } + } + } + } + check::<1>(); + check::<2>(); + check::<3>(); + check::<4>(); + check::<5>(); + } + #[test] fn identity_scale_is_lossless() { for f in [Resample::Nearest, Resample::Bilinear, Resample::Bicubic, Resample::Lanczos] {