mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
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.
This commit is contained in:
Generated
+1
@@ -4895,6 +4895,7 @@ dependencies = [
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"miniz_oxide 0.9.1",
|
||||
"photocraft-affinity",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<u8> = (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
<printed-executable> 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.
|
||||
Reference in New Issue
Block a user