From 4776d20b7a281ca2eab4162af96dacb1f32ea861 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:31:58 +0200 Subject: [PATCH] perf(compression): Use zlib-rs with runtime detection Isolate the workspace backend choice and verify PNG, PSD and complete native-format decoding. Record standalone timing and compressed-size tradeoffs. --- Cargo.lock | 1 + Cargo.toml | 2 +- crates/io/Cargo.toml | 1 + crates/io/tests/deflate_compatibility.rs | 123 +++++++++++++++++++++++ docs/deflate-zlib-rs-performance.md | 57 +++++++++++ 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 crates/io/tests/deflate_compatibility.rs create mode 100644 docs/deflate-zlib-rs-performance.md diff --git a/Cargo.lock b/Cargo.lock index aff38ac01b..d1c7213be2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4895,6 +4895,7 @@ dependencies = [ "craft-docpath", "crc32fast", "flate2", + "miniz_oxide 0.9.1", "photocraft-affinity", "photocraft-algo", "photocraft-cms", diff --git a/Cargo.toml b/Cargo.toml index 3f8dbb0a72..834062aeef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" arc-swap = "1" bytemuck = { version = "1", features = ["derive"] } -flate2 = { version = "1", default-features = false, features = ["rust_backend"] } +flate2 = { version = "1", default-features = false, features = ["zlib-rs", "runtime_detection"] } image = { version = "0.25", default-features = false } # Baseline-JPEG decode of lossy DNG tiles (compression 34892), shared with zune-jpeg. zune-core = "0.5" diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index eb8c0b00d7..c458441386 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -49,6 +49,7 @@ resvg = { version = "0.45", default-features = false, features = ["text", "syste resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } [dev-dependencies] +miniz_oxide = "0.9" crc32fast = "1.5" photocraft-affinity = { workspace = true, features = ["synth"] } flate2 = { workspace = true } diff --git a/crates/io/tests/deflate_compatibility.rs b/crates/io/tests/deflate_compatibility.rs new file mode 100644 index 0000000000..b9e6365833 --- /dev/null +++ b/crates/io/tests/deflate_compatibility.rs @@ -0,0 +1,123 @@ +//! Backend compatibility, including native-format tile data rather than ZIP assembly alone. +mod common; + +use std::{hint::black_box, io::Read, time::Instant}; + +use photocraft_codecs::{ChannelLayout, Format, Image, SampleType as CodecSample}; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_format::{SaveOptions, load_from_bytes, read_thumbnail, save_to_bytes}; +use photocraft_geom::Size; +use photocraft_io::{PsdExportOptions, document_to_psd_with, import}; +use photocraft_psd::Compression; +use photocraft_raster::Rgba8Image; + +fn document(depth: SampleType, size: Size) -> Document { + let mut doc = Document::new("compatibility", size, ColorMode::Rgb, depth); + doc.layers.push(Layer::new("pixels", LayerContent::Raster(common::pattern(doc.pixel_format(), doc.bounds(), 3, false)))); + doc +} + +fn old_and_new_inflate(bytes: &[u8]) { + let old = miniz_oxide::inflate::decompress_to_vec_zlib(bytes) + .unwrap_or_else(|e| panic!("old decoder: {:?}, header {:?}", e.status, &bytes[..bytes.len().min(8)])); + let mut new = Vec::new(); + flate2::read::ZlibDecoder::new(bytes).read_to_end(&mut new).unwrap(); + assert_eq!(new, old); +} + +fn check_png(bytes: &[u8], image: &Image) { + assert_eq!(photocraft_codecs::decode(bytes).unwrap().data(), image.data()); + let mut idat = Vec::new(); + let mut offset = 8; + while offset < bytes.len() { + let len = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize; + if &bytes[offset + 4..offset + 8] == b"IDAT" { + idat.extend_from_slice(&bytes[offset + 8..offset + 8 + len]); + } + offset += 12 + len; + } + old_and_new_inflate(&idat); +} + +#[test] +fn png_psd_and_pcraft_decode_identically() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let doc = document(depth, Size::new(257, 131)); + let pixels = doc.layers[0].surface().unwrap().read_region(doc.bounds()); + if depth != SampleType::F32 { + let sample = if depth == SampleType::U8 { CodecSample::U8 } else { CodecSample::U16 }; + let image = Image::from_normalized(257, 131, ChannelLayout::Rgba, sample, &pixels).unwrap(); + check_png(&photocraft_codecs::encode(&image, Format::Png, &Default::default()).unwrap(), &image); + } + for compression in [Compression::Zip, Compression::ZipPrediction] { + let opts = PsdExportOptions { merged_compression: compression, merged_matte: false, ..Default::default() }; + let mut file = document_to_psd_with(&doc, &opts).0; + // The IO exporter normally chooses Raw for float composites; also cover ZIP here. + if depth == SampleType::F32 { + file.image_data = photocraft_psd::ImageData::encode(compression, &file.image_data.decode(&file.header).unwrap(), &file.header).unwrap(); + } + let bytes = file.to_bytes().unwrap(); + let parsed = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + assert_eq!(parsed.image_data.compression, compression); + old_and_new_inflate(&parsed.image_data.data); + let back = import("compatibility.psd", &bytes).unwrap().document; + assert_eq!(back.layers[0].surface().unwrap().read_region(doc.bounds()), pixels); + } + let mut thumbnail = Rgba8Image::new(7, 3); + thumbnail.pixels = (0..84).map(|i| (i * 37) as u8).collect(); + let opts = SaveOptions { thumbnail: Some(thumbnail.clone()), ..Default::default() }; + let bytes = save_to_bytes(&doc, &opts).unwrap(); + assert!(load_from_bytes(&bytes).unwrap() == doc, "all tile bytes, {depth:?}"); + let image = Image::from_u8(7, 3, ChannelLayout::Rgba, thumbnail.pixels).unwrap(); + check_png(&read_thumbnail(&bytes).unwrap().unwrap(), &image); + } +} + +#[test] +#[ignore = "manual 24 MP release benchmark; run on main and the backend branch"] +fn benchmark_deflate_formats() { + for pattern in ["ramp", "noise"] { + let mut doc = document(SampleType::U8, Size::new(6000, 4000)); + if pattern == "noise" { + let mut seed = 0x9e3779b9u32; + let raw: Vec = (0..96_000_000usize) + .map(|i| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + if i % 4 == 3 { 255 } else { (seed >> 24) as u8 } + }) + .collect(); + doc.layers[0].content = LayerContent::Raster(photocraft_raster::Surface::from_interleaved(doc.pixel_format(), doc.bounds(), &raw)); + } + let pixels = doc.layers[0].surface().unwrap().read_region(doc.bounds()); + let image = Image::from_normalized(6000, 4000, ChannelLayout::Rgba, CodecSample::U8, &pixels).unwrap(); + drop(pixels); + let psd_opts = PsdExportOptions { merged_compression: Compression::Zip, merged_matte: false, ..Default::default() }; + let save_opts = SaveOptions { thumbnail: Some(Rgba8Image::new(256, 170)), ..Default::default() }; + let png = photocraft_codecs::encode(&image, Format::Png, &Default::default()).unwrap(); + let psd = document_to_psd_with(&doc, &psd_opts).0.to_bytes().unwrap(); + let pcraft = save_to_bytes(&doc, &save_opts).unwrap(); + for trial in 0..6 { + for op in ["png-encode", "png-decode", "psd-encode", "psd-decode", "pcraft-save", "pcraft-load"] { + let start = Instant::now(); + let size = match op { + "png-encode" => black_box(photocraft_codecs::encode(black_box(&image), Format::Png, &Default::default()).unwrap()).len(), + "png-decode" => black_box(photocraft_codecs::decode(black_box(&png)).unwrap()).data().len(), + "psd-encode" => black_box(document_to_psd_with(black_box(&doc), &psd_opts).0.to_bytes().unwrap()).len(), + "psd-decode" => { + black_box(import("bench.psd", black_box(&psd)).unwrap()); + psd.len() + } + "pcraft-save" => black_box(save_to_bytes(black_box(&doc), &save_opts).unwrap()).len(), + _ => { + black_box(load_from_bytes(black_box(&pcraft)).unwrap()); + pcraft.len() + } + }; + println!("deflate,{pattern}-{op},{trial},{:.3},{size}", start.elapsed().as_secs_f64() * 1000.0); + } + } + } +} diff --git a/docs/deflate-zlib-rs-performance.md b/docs/deflate-zlib-rs-performance.md new file mode 100644 index 0000000000..82e685fd91 --- /dev/null +++ b/docs/deflate-zlib-rs-performance.md @@ -0,0 +1,57 @@ +# Deflate backend comparison + +Only the workspace flate2 backend changes from miniz_oxide to zlib-rs with runtime CPU detection. The 24 MP (6000×4000) RGBA8 cases are a repeating ramp and deterministic noise; inputs are prepared outside the timers. + +Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max +(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that +base; none of the other performance splits is applied. Release uses optimization +level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. +Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal +priority with no concurrent builds. Timings are medians of five samples after one +warm-up, except the compositor's ten Criterion samples. These synthetic workloads +are reproducible checks of this change, not a representative image corpus. + +| Workload | Main ms | This change ms | Time change | +|---|---:|---:|---:| +| ramp-png-encode | 42.114 | 43.989 | +4.5% | +| ramp-png-decode | 47.100 | 8.697 | -81.5% | +| ramp-psd-encode | 200.218 | 162.012 | -19.1% | +| ramp-psd-decode | 45.092 | 31.839 | -29.4% | +| ramp-pcraft-save | 82.126 | 83.149 | +1.2% | +| ramp-pcraft-load | 1.424 | 1.474 | +3.5% | +| noise-png-encode | 206.339 | 152.328 | -26.2% | +| noise-png-decode | 187.408 | 183.032 | -2.3% | +| noise-psd-encode | 1191.777 | 1002.312 | -15.9% | +| noise-psd-decode | 30.907 | 30.394 | -1.7% | +| noise-pcraft-save | 177.713 | 175.379 | -1.3% | +| noise-pcraft-load | 103.026 | 101.085 | -1.9% | + +| Encoded file | Main bytes | This change bytes | +|---|---:|---:| +| ramp-png-encode | 107,506 | 478,751 | +| ramp-psd-encode | 73,268,469 | 73,503,868 | +| ramp-pcraft-save | 88,188 | 88,214 | +| noise-png-encode | 82,731,323 | 82,272,418 | +| noise-psd-encode | 144,983,563 | 144,994,193 | +| noise-pcraft-save | 96,670,862 | 96,670,888 | + +Compressed bytes are not identical across backends. The ramp PNG gets larger; the noise case and decode timings must be considered alongside encode time. The native format stores Zstandard tiles and STORE ZIP entries, so this switch only changes its PNG previews; the repeating ramp deduplicates many tiles. No tile-compression speedup is attributed to deflate. + +The committed compatibility test compares old miniz_oxide and flate2 inflation of PNG IDAT and PSD ZIP/ZIP-prediction streams, checks exact decoded PNG/PSD pixels at supported depths, and compares complete .pcraft documents at U8/U16/F32 (tiles included) plus their PNG thumbnail. Float PSD composites are explicitly ZIP-encoded in the test because the IO exporter normally chooses Raw. The same test passed on the base and this branch. + +Reproduce with the same release profile environment: + +```sh +CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-io --test deflate_compatibility --no-run +# Run the printed executable at normal priority: + benchmark_deflate_formats --ignored --nocapture --test-threads=1 +``` + +## Local validation + +- `cargo test -p photocraft-codecs -p photocraft-psd -p photocraft-format -p photocraft-io --locked`: passed (1956 passed, 9 ignored). +- `cargo clippy -p photocraft-codecs -p photocraft-psd -p photocraft-format -p photocraft-io --locked --all-targets -- -D warnings`: passed. +- `cargo xtask layers`: passed. +- `cargo xtask wasm`: passed. +- `cargo xtask test-corpus -p psd -p codecs -p io`: passed (1853 passed, 9 ignored). +- The compatibility test also passed with the main backend before measurements. To reproduce the main comparison, copy only this test and its miniz_oxide dev-dependency into a clean main worktree.