From 48fb3064ec18d44d2f1a3a1fe510fc42ce7e4086 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:36:58 -0400 Subject: [PATCH 1/2] perf(camera-raw): scopes under budget, histogram and overlays in parallel (P36) Every new Camera Raw preview ran three per-pixel passes over the 447k-pixel proxy on one thread: the RGB histogram (2.7 ms), the clipping warning overlay (1.5-2 ms) and the selection mask blend (0.7 ms). They now run with rayon (plain loops on wasm32). Histogram blocks add with saturating integer sums, so the counts equal one pass. camera_raw_bench, Core Ultra X7 358H: interactive scope update (P36) p95 11.0 -> 8.1 ms (budget 10), exposure proxy update (P31) p50 6.1 -> 4.1 ms. Part of #211. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CD2ipSyvx1tYwnHJZyDs59 --- crates/algo/src/histogram.rs | 55 ++++++++++++++++++++ crates/ui-egui/src/camera_raw_scope_ui.rs | 61 ++++++++++++----------- crates/ui-egui/src/camera_raw_ui.rs | 14 +++++- 3 files changed, 101 insertions(+), 29 deletions(-) diff --git a/crates/algo/src/histogram.rs b/crates/algo/src/histogram.rs index 4a4fb45e20..2d3df42dc9 100644 --- a/crates/algo/src/histogram.rs +++ b/crates/algo/src/histogram.rs @@ -28,6 +28,18 @@ impl RgbHistogram { /// Each visible pixel counts once, including partially transparent pixels. Non-finite /// pixels are rejected as a whole; transparent RGB cannot contribute phantom peaks. pub fn from_rgba(pixels: &[[f32; 4]]) -> Self { + // Camera Raw counts its whole proxy (~450k pixels) on every slider step, so the count + // runs in blocks across cores (#211, P36). Integer sums don't depend on the order the + // blocks finish in, so the result is the same as one pass over all pixels. + #[cfg(not(target_arch = "wasm32"))] + if pixels.len() >= PARALLEL_PIXELS { + use rayon::prelude::*; + return pixels.par_chunks(PARALLEL_PIXELS / 2).map(Self::count).reduce(Self::default, Self::merge); + } + Self::count(pixels) + } + + fn count(pixels: &[[f32; 4]]) -> Self { let mut h = Self::default(); for pixel in pixels { if !pixel[3].is_finite() { @@ -68,8 +80,29 @@ impl RgbHistogram { } h } + + /// Sum of two block counts. Saturating like the counters themselves. + #[cfg(not(target_arch = "wasm32"))] + fn merge(mut self, other: Self) -> Self { + let add = |a: &mut [u64], b: &[u64]| a.iter_mut().zip(b).for_each(|(a, b)| *a = a.saturating_add(*b)); + for (a, b) in self.channels.iter_mut().zip(&other.channels) { + add(a, b); + } + add(&mut self.underflow, &other.underflow); + add(&mut self.overflow, &other.overflow); + add(&mut self.shadows, &other.shadows); + add(&mut self.highlights, &other.highlights); + self.samples = self.samples.saturating_add(other.samples); + self.transparent = self.transparent.saturating_add(other.transparent); + self.invalid = self.invalid.saturating_add(other.invalid); + self + } } +/// Below this many pixels one pass is faster than splitting the count across cores. +#[cfg(not(target_arch = "wasm32"))] +const PARALLEL_PIXELS: usize = 64 * 1024; + /// Clipped channels of one visible straight RGBA pixel as bit masks (bit 0=R, 1=G, 2=B): /// `(shadows, highlights)` at exact endpoints ≤0 / ≥1, not rounded edge bins. /// Transparent and non-finite pixels clip nothing. @@ -94,6 +127,28 @@ pub fn clipping(p: [f32; 4]) -> (u8, u8) { mod tests { use super::*; + #[test] + fn matches_the_single_pass_on_every_domain_and_across_block_edges() { + // Every special value at every channel, plus a dense ramp through each bin's rounding + // edge; lengths around the parallel threshold and its block size. + let special = [0.0, -0.0, 1.0, -1e-7, 1.0 + 1e-7, 0.5 / 255.0, 254.5 / 255.0, -3.0, 4.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MIN_POSITIVE]; + let mut pool: Vec<[f32; 4]> = Vec::new(); + for (i, &v) in special.iter().enumerate() { + for alpha in [1.0, 0.5, 0.0, -1.0, f32::NAN] { + pool.push([v, special[(i + 3) % special.len()], 0.25, alpha]); + pool.push([0.75, v, special[(i + 5) % special.len()], alpha]); + } + } + pool.extend((0..4096).map(|i| { + let v = i as f32 / 4095.0 * 1.2 - 0.1; + [v, 1.0 - v, (v * 7.0).fract(), 1.0] + })); + for len in [0, 1, PARALLEL_PIXELS / 2 + 1, PARALLEL_PIXELS - 1, PARALLEL_PIXELS, 3 * PARALLEL_PIXELS + 17] { + let pixels: Vec<_> = (0..len).map(|i| pool[(i * 7919) % pool.len()]).collect(); + assert_eq!(RgbHistogram::from_rgba(&pixels), RgbHistogram::count(&pixels), "{len} pixels"); + } + } + #[test] fn exact_bins_and_channel_totals() { let pixels: Vec<_> = (0..BINS).map(|i| [i as f32 / 255.0, (255 - i) as f32 / 255.0, 0.5, 0.25]).collect(); diff --git a/crates/ui-egui/src/camera_raw_scope_ui.rs b/crates/ui-egui/src/camera_raw_scope_ui.rs index 9cb5233d00..4e93b72df9 100644 --- a/crates/ui-egui/src/camera_raw_scope_ui.rs +++ b/crates/ui-egui/src/camera_raw_scope_ui.rs @@ -619,36 +619,41 @@ pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut Cam } else if mode != ClippingMode::None { if d.scope.overlay_key != Some(key) || d.scope.overlay_detail { d.scope.overlay_detail = false; - let colors = d - .pixels() - .iter() - .map(|p| { - let (lo, hi) = clipping(*p); - if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) { - return Color32::TRANSPARENT; - } - match mode { - ClippingMode::ShadowChannels => t.histogram_color(7 ^ lo), - ClippingMode::HighlightChannels => { - if hi == 0 { - t.histogram_background() - } else { - t.histogram_color(hi) - } + let color = |p: &[f32; 4]| { + let (lo, hi) = clipping(*p); + if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) { + return Color32::TRANSPARENT; + } + match mode { + ClippingMode::ShadowChannels => t.histogram_color(7 ^ lo), + ClippingMode::HighlightChannels => { + if hi == 0 { + t.histogram_background() + } else { + t.histogram_color(hi) } - ClippingMode::Warnings(warnings) => { - if warnings & 2 != 0 && hi != 0 { - t.histogram_color(1) - } else if warnings & 1 != 0 && lo != 0 { - t.histogram_color(4) - } else { - Color32::TRANSPARENT - } - } - ClippingMode::None => Color32::TRANSPARENT, } - }) - .collect(); + ClippingMode::Warnings(warnings) => { + if warnings & 2 != 0 && hi != 0 { + t.histogram_color(1) + } else if warnings & 1 != 0 && lo != 0 { + t.histogram_color(4) + } else { + Color32::TRANSPARENT + } + } + ClippingMode::None => Color32::TRANSPARENT, + } + }; + // Rebuilt for every new preview while a warning is on: one colour per proxy pixel, + // each independent, so it runs across cores (#211, P36). + #[cfg(not(target_arch = "wasm32"))] + let colors = { + use rayon::prelude::*; + d.pixels().par_iter().with_min_len(crate::camera_raw_ui::PARALLEL_MIN_PIXELS).map(color).collect() + }; + #[cfg(target_arch = "wasm32")] + let colors = d.pixels().iter().map(color).collect(); let image = egui::ColorImage::new([d.pw, d.ph], colors); match &mut d.scope.overlay_tex { Some(t) => t.set(image, egui::TextureOptions::NEAREST), diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index 090c0a0b3c..8641dc4024 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -26,6 +26,10 @@ use crate::theme::Tokens; use crate::widgets; const PROXY_SIDE: usize = 900; +/// Smallest run of proxy pixels one rayon task takes in the per-pixel passes: below it the +/// split costs more than the work. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) const PARALLEL_MIN_PIXELS: usize = 16 * 1024; const PANEL_W: f32 = 330.0; const BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"]; @@ -278,11 +282,19 @@ impl ProxyJob { } photocraft_algo::camera_raw::develop(&mut px, pw, ph, &p, self.float); if let Some(mask) = &self.selection { - for ((out, original), coverage) in px.iter_mut().zip(self.proxy.iter()).zip(mask.iter()) { + let blend = |(out, (original, coverage)): (&mut [f32; 4], (&[f32; 4], &f32))| { for (out, original) in out.iter_mut().zip(original) { *out = *original + (*out - *original) * coverage; } + }; + // Each pixel is independent, so bands of the proxy blend in parallel (#211, P36). + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + px.par_iter_mut().zip(self.proxy.par_iter().zip(mask.par_iter())).with_min_len(PARALLEL_MIN_PIXELS).for_each(blend); } + #[cfg(target_arch = "wasm32")] + px.iter_mut().zip(self.proxy.iter().zip(mask.iter())).for_each(blend); } let histogram_start = crate::gpu_canvas::now_ms(); let histogram = RgbHistogram::from_rgba(&px); From 9451ed7557409b766d930559c1d2a6631a8f4142 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:49:01 -0400 Subject: [PATCH 2/2] test(histogram): build the parallel-path test only off wasm32 (CodeRabbit) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CD2ipSyvx1tYwnHJZyDs59 --- crates/algo/src/histogram.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/algo/src/histogram.rs b/crates/algo/src/histogram.rs index 2d3df42dc9..849b222c2a 100644 --- a/crates/algo/src/histogram.rs +++ b/crates/algo/src/histogram.rs @@ -127,6 +127,8 @@ pub fn clipping(p: [f32; 4]) -> (u8, u8) { mod tests { use super::*; + // The parallel path, and its threshold, exist only off wasm32. + #[cfg(not(target_arch = "wasm32"))] #[test] fn matches_the_single_pass_on_every_domain_and_across_block_edges() { // Every special value at every channel, plus a dense ramp through each bin's rounding