mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-31 11:42:13 +01:00
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:
co-authored by
Claude Opus 5.5
parent
b007b1d355
commit
4b76f10072
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user