From 4b76f100725a2736862effcaa90192f5fbcee6ee Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:47:55 -0400 Subject: [PATCH] perf: encode and quantize canvas pixels in parallel (display_color, Buffer::to_rgba8) (#4768) * perf(display): encode linear documents for the screen in parallel CanvasDisplay::texture_buffer (every CPU-path canvas upload of a linear document, which every 32-bit document is since #3792) and to_rgba8's sRGB encode ran one pixel at a time on one thread. Both now split the pixels into 16K-pixel pieces across rayon off wasm; results are bit-identical. 24 MP linear document, median of 5 (22-thread laptop, dev profile): - texture_buffer: 348.5 ms -> 29.6 ms - to_rgba8: 107.8 ms -> 10.6 ms Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J * perf(compose): quantize Buffer::to_rgba8 in parallel The plain 8-bit quantize behind the CPU canvas, previews, thumbnails and 8-bit exports ran on one thread. Split it into 16K-pixel pieces across rayon off wasm; results are bit-identical. 24 MP buffer, median of 5 (22-thread laptop, dev profile): 122.3 ms -> 11.9 ms Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J * perf(canvas): build canvas images in parallel, fused with the quantize display_image (CPU canvas updates, filter previews, the navigator, the crop pasteboard) quantized to RGBA8 and then ran egui's single-threaded ColorImage::from_rgba_unmultiplied over every pixel. The identity path now quantizes straight to Color32 in one parallel pass; the colour-managed path converts in parallel. Results are identical to egui's conversion. 24 MP buffer, median of 5 (22-thread laptop, dev profile): 50.9 ms -> 12.7 ms Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J --------- Co-authored-by: Claude Opus 5.5 --- crates/compose/src/lib.rs | 18 +++++- crates/engine/src/display_color.rs | 36 +++++++++--- crates/engine/src/display_color_tests.rs | 58 +++++++++++++++++++ crates/ui-egui/src/canvas.rs | 72 ++++++++++++++++++++++-- 4 files changed, 166 insertions(+), 18 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 8e88675fde..8813b122d9 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -58,11 +58,23 @@ impl Buffer { } pub fn to_rgba8(&self) -> Rgba8Image { let mut img = Rgba8Image::new(self.rect.width(), self.rect.height()); - for (o, p) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(&self.px) { - for (dst, v) in o.iter_mut().zip(p) { - *dst = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + let quantize = |out: &mut [[u8; 4]], src: &[[f32; 4]]| { + for (o, p) in out.iter_mut().zip(src) { + for (dst, v) in o.iter_mut().zip(p) { + *dst = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } } + }; + let out = img.pixels.as_chunks_mut::<4>().0; + // In parallel off wasm: a 24 MP composite took 120 ms on one core (the CPU canvas, + // previews, thumbnails and 8-bit exports all come through here). + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(16 * 1024).zip(self.px.par_chunks(16 * 1024)).for_each(|(o, p)| quantize(o, p)); } + #[cfg(target_arch = "wasm32")] + quantize(out, &self.px); img } /// Flatten over an opaque background colour. diff --git a/crates/engine/src/display_color.rs b/crates/engine/src/display_color.rs index 06490dbade..1e4aafc909 100644 --- a/crates/engine/src/display_color.rs +++ b/crates/engine/src/display_color.rs @@ -76,14 +76,15 @@ impl CanvasDisplay { if !self.encode_srgb { return Cow::Borrowed(buf); } - let mut b = buf.clone(); - for p in &mut b.px { - for v in &mut p[..3] { + let mut px = vec![[0.0f32; 4]; buf.px.len()]; + par_pixels(&mut px, &buf.px, |out, src| { + for (o, p) in out.iter_mut().zip(src) { // Capped at the largest half float; NaN is dropped by the texel conversion. - *v = photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 65504.0)); + let enc = |v: f32| photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 65504.0)); + *o = [enc(p[0]), enc(p[1]), enc(p[2]), p[3]]; } - } - Cow::Owned(b) + }); + Cow::Owned(Buffer { rect: buf.rect, px }) } } @@ -97,12 +98,29 @@ fn encode_rgba8(buf: &Buffer) -> Rgba8Image { let t = encode_table(); let mut img = Rgba8Image::new(buf.rect.width(), buf.rect.height()); let code = |v: f32| t.get((v.clamp(0.0, 1.0) * 65535.0 + 0.5) as usize).copied().unwrap_or(255); - for (o, p) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(&buf.px) { - *o = [code(p[0]), code(p[1]), code(p[2]), (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8]; - } + par_pixels(img.pixels.as_chunks_mut::<4>().0, &buf.px, |out, src| { + for (o, p) in out.iter_mut().zip(src) { + *o = [code(p[0]), code(p[1]), code(p[2]), (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8]; + } + }); img } +/// Pixels per piece [`par_pixels`] hands to one thread. +const PAR_PIXELS: usize = 16 * 1024; + +/// `f` over matching pieces of `dst` and `src`, in parallel off wasm. The display encodes run on +/// every canvas upload and preview, and a 24 MP linear document took 350 ms on one core. +fn par_pixels(dst: &mut [D], src: &[S], f: impl Fn(&mut [D], &[S]) + Sync) { + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + dst.par_chunks_mut(PAR_PIXELS).zip(src.par_chunks(PAR_PIXELS)).for_each(|(d, s)| f(d, s)); + } + #[cfg(target_arch = "wasm32")] + f(dst, src); +} + /// In-place 8-bit RGBA transform (alpha kept), in blocks so the scratch copy stays small. fn apply_u8(t: &Transform, px: &mut [u8]) { const BLOCK: usize = 1 << 20; diff --git a/crates/engine/src/display_color_tests.rs b/crates/engine/src/display_color_tests.rs index e40dca9bf6..1270a2418f 100644 --- a/crates/engine/src/display_color_tests.rs +++ b/crates/engine/src/display_color_tests.rs @@ -397,3 +397,61 @@ fn proof_colors_still_apply_on_top() { s.execute("view.gamutWarning", json!({"on": true})).unwrap(); assert_ne!(sig, s.color.display_signature(&d)); } + +#[test] +fn parallel_linear_encodes_match_the_scalar_curve() { + // Several parallel pieces, a ragged last one, and HDR, negative and NaN values. + let n: usize = 3 * 16 * 1024 + 77; + let px: Vec<[f32; 4]> = (0..n) + .map(|i| { + let v = match i % 9 { + 0 => f32::NAN, + 1 => -0.5, + 2 => 3.25, + _ => (i % 1001) as f32 / 1000.0, + }; + [v, (i % 257) as f32 / 256.0, 1.0 - (i % 113) as f32 / 112.0, (i % 5) as f32 / 4.0] + }) + .collect(); + let buf = photocraft_compose::Buffer { rect: Rect::new(-4, 2, -4 + n as i32, 3), px }; + let mut doc = Document::new("t", Size::new(1, 1), ColorMode::Rgb, SampleType::F32); + doc.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes()); + let mut color = crate::color_cmds::ColorState::default(); + color.settings.monitor_profile = "srgb".into(); + let d = color.canvas_display(&doc).unwrap(); + assert!(d.encode_srgb); + let enc = |v: f32| photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 65504.0)); + let tex = d.texture_buffer(&buf); + assert_eq!(tex.rect, buf.rect); + for (t, p) in tex.px.iter().zip(&buf.px) { + let want = [enc(p[0]), enc(p[1]), enc(p[2]), p[3]]; + assert!(t.iter().zip(want).all(|(a, b)| a.to_bits() == b.to_bits()), "{p:?} -> {t:?}, want {want:?}"); + } + let code = |v: f32| (photocraft_color::convert::linear_to_srgb(((v.clamp(0.0, 1.0) * 65535.0 + 0.5) as usize) as f32 / 65535.0) * 255.0 + 0.5) as u8; + let img = d.to_rgba8(&buf); + for (o, p) in img.pixels.as_chunks::<4>().0.iter().zip(&buf.px) { + assert_eq!(*o, [code(p[0]), code(p[1]), code(p[2]), (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8], "{p:?}"); + } +} + +#[test] +fn parallel_plain_quantize_matches_the_scalar_rounding() { + let n: usize = 3 * 16 * 1024 + 77; + let px: Vec<[f32; 4]> = (0..n) + .map(|i| { + let v = match i % 9 { + 0 => f32::NAN, + 1 => -0.5, + 2 => 3.25, + _ => (i % 1001) as f32 / 1000.0, + }; + [v, (i % 257) as f32 / 256.0, 1.0 - (i % 113) as f32 / 112.0, (i % 5) as f32 / 4.0] + }) + .collect(); + let buf = photocraft_compose::Buffer { rect: Rect::new(-4, 2, -4 + n as i32, 3), px }; + let img = buf.to_rgba8(); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + for (o, p) in img.pixels.as_chunks::<4>().0.iter().zip(&buf.px) { + assert_eq!(*o, p.map(q), "{p:?}"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0ed6960642..c84c2f1f97 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -990,18 +990,51 @@ fn checker(app: &mut PhotocraftApp, ctx: &egui::Context) -> egui::TextureId { } fn buffer_to_image(buf: &photocraft_compose::Buffer) -> egui::ColorImage { - let img = buf.to_rgba8(); - egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels) + // `buf.to_rgba8()` then `color_image`, fused: one parallel pass, no intermediate image. + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + let mut pixels = vec![egui::Color32::TRANSPARENT; buf.px.len()]; + let convert = |out: &mut [egui::Color32], src: &[[f32; 4]]| { + for (o, p) in out.iter_mut().zip(src) { + *o = egui::Color32::from_rgba_unmultiplied(q(p[0]), q(p[1]), q(p[2]), q(p[3])); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + pixels.par_chunks_mut(16 * 1024).zip(buf.px.par_chunks(16 * 1024)).for_each(|(o, s)| convert(o, s)); + } + #[cfg(target_arch = "wasm32")] + convert(&mut pixels, &buf.px); + egui::ColorImage::new([buf.rect.width() as usize, buf.rect.height() as usize], pixels) +} + +/// `img` as an egui image: `ColorImage::from_rgba_unmultiplied`, in parallel off wasm. egui's own +/// conversion runs on one thread and took 37 ms of a 24 MP canvas or preview update, three times +/// the quantize before it. +fn color_image(img: &photocraft_raster::Rgba8Image) -> egui::ColorImage { + let size = [img.width as usize, img.height as usize]; + let src = img.pixels.as_chunks::<4>().0; + let mut pixels = vec![egui::Color32::TRANSPARENT; src.len()]; + let convert = |out: &mut [egui::Color32], src: &[[u8; 4]]| { + for (o, &[r, g, b, a]) in out.iter_mut().zip(src) { + *o = egui::Color32::from_rgba_unmultiplied(r, g, b, a); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + pixels.par_chunks_mut(16 * 1024).zip(src.par_chunks(16 * 1024)).for_each(|(o, s)| convert(o, s)); + } + #[cfg(target_arch = "wasm32")] + convert(&mut pixels, src); + egui::ColorImage::new(size, pixels) } /// A composite as monitor pixels: document profile → monitor profile (nothing to do in the /// common sRGB-on-sRGB case). pub(crate) fn display_image(display: Option<&photocraft_engine::display_color::CanvasDisplay>, buf: &photocraft_compose::Buffer) -> egui::ColorImage { match display { - Some(d) if !d.is_identity() => { - let img = d.to_rgba8(buf); - egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels) - } + Some(d) if !d.is_identity() => color_image(&d.to_rgba8(buf)), _ => buffer_to_image(buf), } } @@ -5718,6 +5751,33 @@ mod tabs_tests; #[cfg(test)] mod tests { + #[test] + fn display_image_matches_egui_unmultiplied_conversion() { + // Several parallel pieces and a ragged one; translucent, out-of-range and NaN values. + let n: usize = 3 * 16 * 1024 + 77; + let px: Vec<[f32; 4]> = (0..n) + .map(|i| { + let v = match i % 9 { + 0 => f32::NAN, + 1 => -0.5, + 2 => 3.25, + _ => (i % 1001) as f32 / 1000.0, + }; + [v, (i % 257) as f32 / 256.0, 1.0 - (i % 113) as f32 / 112.0, (i % 6) as f32 / 5.0] + }) + .collect(); + let buf = photocraft_compose::Buffer { rect: photocraft_geom::Rect::new(0, 0, n as i32, 1), px }; + let reference = |img: photocraft_raster::Rgba8Image| egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels); + assert_eq!(super::display_image(None, &buf).pixels, reference(buf.to_rgba8()).pixels); + let mut doc = photocraft_doc::Document::new("t", photocraft_geom::Size::new(1, 1), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8); + doc.icc_profile = Some(photocraft_cms::Builtin::AdobeRgbCompat.profile().to_bytes()); + let mut color = photocraft_engine::color_cmds::ColorState::default(); + color.settings.monitor_profile = "srgb".into(); + let d = color.canvas_display(&doc).unwrap(); + assert!(!d.is_identity()); + assert_eq!(super::display_image(Some(&d), &buf).pixels, reference(d.to_rgba8(&buf)).pixels); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn every_filter_previews_off_the_ui_thread_and_a_newer_value_cancels_the_old() {