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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nicolas Falesy
2026-10-13 12:47:55 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b007b1d355
commit 4b76f10072
4 changed files with 166 additions and 18 deletions
+15 -3
View File
@@ -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.
+27 -9
View File
@@ -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<D: Send, S: Sync>(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;
+58
View File
@@ -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:?}");
}
}
+66 -6
View File
@@ -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() {