diff --git a/.gitignore b/.gitignore index 71532900..e42a72a4 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,8 @@ wasm/oxicloud-hash/target/ # K6 load-test raw run outputs and per-run storage (baseline is committed) tests/load/results/*.json tests/load/storage/ + +# Phase 0 perf-bench corpus — generated deterministically on first +# `cargo bench --features bench`; drop real photos here (same filenames) to +# benchmark against real data. (target/ already ignored, covers the JSON dump.) +benches/corpus/ diff --git a/Cargo.lock b/Cargo.lock index 7f5b989a..bb01444f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstyle" version = "1.0.14" @@ -1252,6 +1258,12 @@ dependencies = [ "winx", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cbc" version = "0.1.2" @@ -1340,6 +1352,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1350,6 +1389,31 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.58" @@ -1646,6 +1710,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -2683,6 +2783,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -2734,6 +2845,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -3254,6 +3371,17 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "iso6709parse" version = "0.1.2" @@ -3264,6 +3392,15 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -3309,6 +3446,15 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] + [[package]] name = "js-sys" version = "0.3.97" @@ -3970,6 +4116,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -4048,6 +4200,7 @@ dependencies = [ "blake3", "bytes", "chrono", + "criterion", "dashmap", "dotenvy", "extism", @@ -4064,6 +4217,7 @@ dependencies = [ "idna", "image", "infer 0.19.0", + "jpeg-decoder", "jsonwebtoken", "kamadak-exif", "lettre", @@ -4294,6 +4448,34 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.18.1" @@ -4473,7 +4655,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn", @@ -5090,6 +5272,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -5748,7 +5939,7 @@ dependencies = [ "fnv", "fs4", "htmlescape", - "itertools", + "itertools 0.14.0", "levenshtein_automata", "log", "lru", @@ -5797,7 +5988,7 @@ checksum = "c57166f5bcfd478f370ab8445afb4678dce44801fa5ce5c451aaf8595583c5dc" dependencies = [ "downcast-rs", "fastdivide", - "itertools", + "itertools 0.14.0", "serde", "tantivy-bitpacker", "tantivy-common", @@ -5849,7 +6040,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a2cfc3ac5164cbadc28965ffb145a8f47582a60ae5897859ad8d4316596c606" dependencies = [ "futures-util", - "itertools", + "itertools 0.14.0", "tantivy-bitpacker", "tantivy-common", "tantivy-fst", @@ -6000,6 +6191,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -6546,6 +6747,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -6945,7 +7156,7 @@ dependencies = [ "cranelift-frontend", "cranelift-native", "gimli", - "itertools", + "itertools 0.14.0", "log", "object", "pulley-interpreter", diff --git a/Cargo.toml b/Cargo.toml index f88df1c2..5b4508b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,10 @@ dotenvy = "0.15.7" moka = { version = "0.12.15", features = ["future", "sync"] } http-range-header = "0.4" image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +# Shrink-on-load JPEG decode (DCT 1/2·1/4·1/8 scaling) for thumbnails — pure +# Rust, no C toolchain. The `image` crate's zune-jpeg backend can't scale during +# decode; this can, cutting decode time/RAM ~order-of-magnitude on large photos. +jpeg-decoder = "0.3" id3 = "1.17" mp3-duration = "0.1" kamadak-exif = "0.6.1" @@ -103,6 +107,14 @@ load_seed_bin = [] # requires OXICLOUD_ENABLE_FACES=true *and* operator-provided ONNX models; without # this feature the People pipeline falls back to the inert NoopFaceAnalyzer. faces-onnx = ["dep:ort", "dep:ndarray"] +# Performance benchmark harness (Phase 0). Exposes `bench_support` + thin public +# wrappers over the private thumbnail render functions so `benches/` and +# `examples/` can measure them. Off by default — adds nothing to prod builds. +# Run with: `cargo bench --features bench` / `cargo run --release --features bench --example bench_thumbnails_mem`. +bench = [] + +[dev-dependencies] +criterion = "0.5" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] } @@ -123,6 +135,19 @@ path = "src/bin/load-seed.rs" # and load-nightly.yml build it explicitly with --features load_seed_bin. required-features = ["load_seed_bin"] +# Phase 0 perf harness — Task 0.2 (criterion latency + output-size bench). +[[bench]] +name = "thumbnails" +path = "benches/thumbnails.rs" +harness = false +required-features = ["bench"] + +# Phase 0 perf harness — Task 0.3 (peak-RAM + saturated-throughput baseline). +[[example]] +name = "bench_thumbnails_mem" +path = "examples/bench_thumbnails_mem.rs" +required-features = ["bench"] + [profile.release] lto = "thin" codegen-units = 1 diff --git a/benches/BASELINE.md b/benches/BASELINE.md new file mode 100644 index 00000000..d42695e1 --- /dev/null +++ b/benches/BASELINE.md @@ -0,0 +1,154 @@ +# Thumbnail performance — Phase 0 baseline + +> **Phase 1.1 (shrink-on-load) is now merged — see "Phase 1.1 results" at the +> bottom for the before/after.** The tables below remain the Phase 0 baseline +> (the "before"). + +The "before" numbers every later phase must beat. Captured on **14 cores** with +the current `image` 0.25 pipeline (`render_thumbnail_from_data` / +`render_all_thumbnails_from_data` in +`src/infrastructure/services/thumbnail_service.rs`). + +> Heap = logical allocation high-water mark (counting allocator), not RSS. +> The synthetic corpus is high-entropy (gradient + noise), so JPEG sizes and +> decode work are realistic-to-slightly-pessimistic. Drop real photos into +> `benches/corpus/` (same filenames) to re-baseline on real data. + +## Reproduce + +```bash +# Peak RAM + saturated throughput (Task 0.3) → target/bench-baseline-fase0.json +cargo run --release --features bench --example bench_thumbnails_mem + +# Per-size latency + output bytes (Task 0.2) → target/criterion/report/index.html +cargo bench --features bench # do NOT pipe through `tail` — it truncates the log; + # results are saved under target/criterion/ regardless +``` + +## A. Per-image — peak heap, single-thread latency, output size + +| case | fmt | source | MP | render_all ms | peak heap MB | out KB (3 sizes) | +|------------------|------|-----------|-----:|--------------:|-------------:|-----------------:| +| jpeg_12mp | jpeg | 4000×3000 | 12.0 | 111.30 | 96.1 | 57 | +| jpeg_24mp | jpeg | 6000×4000 | 24.0 | 207.23 | 151.0 | 41 | +| jpeg_48mp | jpeg | 8000×6000 | 48.0 | 397.67 | 260.9 | 38 | +| jpeg_exif_orient | jpeg | 4000×3000 | 12.0 | 121.82 | 107.6 | 59 | +| png_large | png | 3000×2000 | 6.0 | 34.36 | 58.4 | 63 | +| webp_large | webp | 1280×853 | 1.1 | 21.05 | 20.7 | 113 | +| gif_large | gif | 600×600 | 0.4 | 12.86 | 15.4 | 232 | +| small_300 | jpeg | 300×300 | 0.1 | 9.94 | 8.0 | 184 | + +## B. Saturated throughput (14 threads, 3 s window) + +| case | source | MP | photos/sec | eff ms/photo | +|-----------|-----------|-----:|-----------:|-------------:| +| jpeg_12mp | 4000×3000 | 12.0 | 44.4 | 22.52 | +| jpeg_24mp | 6000×4000 | 24.0 | 25.3 | 39.51 | +| jpeg_48mp | 8000×6000 | 48.0 | 12.4 | 80.49 | + +Scaling is sub-linear (14 threads ≈ 4.9× single-thread): memory-bandwidth bound +(moving 96–261 MB per decode) + rayon oversubscription (each caller thread fans +3 sizes onto the shared rayon pool). + +## C. Per-size latency — criterion median ms (one size in isolation vs all-three) + +| case | Icon ms | Preview ms | Large ms | all-3 ms | Large/all | +|------------------|--------:|-----------:|---------:|---------:|----------:| +| jpeg_12mp | 75.42 | 97.17 | 106.30 | 107.11 | 99.3% | +| jpeg_24mp | 148.65 | 190.05 | 200.70 | 203.44 | 98.7% | +| jpeg_48mp | 303.31 | 375.33 | 386.04 | 391.24 | 98.7% | +| jpeg_exif_orient | 90.68 | 122.57 | 119.27 | 119.33 | 100.0% | +| png_large | 13.32 | 25.87 | 32.26 | 32.59 | 99.0% | +| webp_large | 15.83 | 19.51 | 24.47 | 24.45 | 100.1% | +| gif_large | 2.28 | 5.15 | 12.94 | 12.92 | 100.2% | +| small_300 | 0.85 | 3.22 | 9.94 | 9.92 | 100.2% | + +## Key findings (these steer Phase 1) + +1. **Decode dominates: 70–99 % of total time.** For jpeg_12mp, rendering all + three sizes (107 ms) costs barely more than rendering Icon alone (75 ms) — + the full-resolution decode is the shared cost; per-size resize+encode is + cheap on top. ⇒ **Shrink-on-load (Task 1.1) is the single biggest lever**, + bigger than first estimated. + +2. **Peak heap scales linearly with megapixels** (~2× the RGBA bitmap): + 12 MP→96 MB, 48 MP→261 MB. With the real `cpus/2` semaphore that is up to + 7×261 MB ≈ 1.8 GB on a 48 MP burst — the OOM ceiling that caps concurrency. + Shrink-on-load collapses this ~16× and unlocks Task 1.5 (raise the semaphore). + +3. **Task 2.1 "defer Large" is now DROPPED — the benchmark refutes it.** + Because all three sizes share one decode (Large/all ≈ 99 %), deferring Large + saves ~9 ms eager but forces a *second full decode* (~106 ms) when the + lightbox opens — it roughly **doubles** total decode work. Keep generating + all sizes in one pass. + +4. **No-upscale (Task 1.4) confirmed minor:** small_300's Large (9.9 ms) + upscales 300→800; clamping recovers a few ms and avoids artefacts. + +5. **PNG/GIF/WebP get no DCT shrink-on-load** — only `fast_image_resize` + (Task 1.2) speeds their resize portion. + +--- + +# Phase 1.1 results — shrink-on-load (DCT scale-on-decode for JPEG) + +Implemented via `jpeg-decoder` in `decode_oriented` / `decode_jpeg_scaled` +(`src/infrastructure/services/thumbnail_service.rs`). The JPEG decoder now emits +the image at the smallest DCT scale (1/8·1/4·1/2·1/1) whose long axis is still ≥ +the largest needed thumbnail (800 px), so the full-resolution bitmap is never +materialised. Non-JPEG and unusual JPEG colour spaces fall back to a full decode. +Same machine (14 cores), same corpus. + +### Latency — `render_all`, single thread (ms) + +| case | before | after | speedup | +|-----------|-------:|-------:|--------:| +| jpeg_12mp | 111.30 | 60.64 | 1.84× | +| jpeg_24mp | 207.23 | 113.71 | 1.82× | +| jpeg_48mp | 397.67 | 202.88 | 1.96× | +| jpeg_exif | 121.82 | 60.12 | 2.03× | +| png_large | 34.36 | 33.67 | ~1× (no DCT, expected) | + +### Peak heap per decode (MB) — the headline win + +| case | before | after | reduction | +|-----------|-------:|------:|----------:| +| jpeg_12mp | 96.1 | 17.6 | 5.5× | +| jpeg_24mp | 151.0 | 24.9 | 6.1× | +| jpeg_48mp | 260.9 | 17.6 | 14.8× | +| jpeg_exif | 107.6 | 18.9 | 5.7× | + +Peak heap is now **decoupled from source resolution** (~18–25 MB regardless of +MP — bounded by the 800 px decode, not the original). 48 MP now uses *less* than +24 MP because it hits the 1/8 scale (1000×750) vs 24 MP's 1/4 (1500×1000). + +### Saturated throughput (14 threads, photos/sec) + +| case | before | after | speedup | +|-----------|-------:|------:|--------:| +| jpeg_12mp | 44.4 | 140.8 | 3.17× | +| jpeg_24mp | 25.3 | 74.7 | 2.95× | +| jpeg_48mp | 12.4 | 45.3 | 3.65× | + +Throughput improved **more** than single-thread latency (3.2× vs 1.8× at 12 MP): +parallel efficiency rose from ~4.9× to ~8.5× across 14 threads because the 16× +smaller decode buffers relieve the memory-bandwidth ceiling. + +### Quality gate — shrink-on-load vs full decode (Preview 400 px) + +| case | SSIM | PSNR dB | +|-----------|-------:|--------:| +| jpeg_12mp | 0.9875 | 47.42 | +| jpeg_24mp | 0.9927 | 48.91 | +| jpeg_48mp | 0.9939 | 49.37 | +| small_300 | 0.9995 | 55.17 | + +All **SSIM ≥ 0.98** (acceptance criterion met) and PSNR 47–55 dB (>40 dB = +visually indistinguishable). Output bytes unchanged (e.g. 12 MP: 57→58 KB). + +### Follow-ups this unlocked +- **Task 1.5** (raise `cpus/2` → `cpus`): peak heap no longer scales with MP, so + the OOM ceiling that justified halving concurrency is largely gone. +- The `MAX_DECODE_PIXELS` 50 MP reject could be relaxed — huge JPEGs now decode + cheaply at 1/8 — but that is a behaviour change, deferred. + diff --git a/benches/thumbnails.rs b/benches/thumbnails.rs new file mode 100644 index 00000000..182b5e5f --- /dev/null +++ b/benches/thumbnails.rs @@ -0,0 +1,108 @@ +//! Phase 0 — Task 0.2: thumbnail render latency + output-size baseline. +//! +//! Measures the CPU-bound render path (decode → EXIF orientation → resize → +//! JPEG encode) per size and for the all-sizes upload path, across the size/ +//! format corpus. `Throughput::Elements(1)` makes criterion report images/sec +//! alongside ms/image. Output byte sizes (bandwidth/disk proxy) are printed once +//! as a table before the timed runs. +//! +//! Run: `cargo bench --features bench` +//! HTML report: `target/criterion/report/index.html` + +use std::time::Duration; + +use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; +use oxicloud::bench_support::{self, CorpusCase}; +use oxicloud::infrastructure::services::thumbnail_service::{ThumbnailService, ThumbnailSize}; + +const SIZES: [ThumbnailSize; 3] = [ + ThumbnailSize::Icon, + ThumbnailSize::Preview, + ThumbnailSize::Large, +]; + +/// Print the output-size table (per-size encoded JPEG bytes) once. This is the +/// bandwidth/disk half of the Task 0.2 deliverable. +fn print_output_sizes(corpus: &[CorpusCase]) { + println!("\n=== Output size baseline (encoded JPEG bytes per thumbnail) ==="); + println!( + "| {:<17} | {:<5} | {:>11} | {:>8} | {:>7} | {:>8} | {:>8} |", + "case", "fmt", "source", "input KB", "icon B", "preview B", "large B" + ); + println!( + "|{:-<19}|{:-<7}|{:-<13}|{:-<10}|{:-<9}|{:-<10}|{:-<10}|", + "", "", "", "", "", "", "" + ); + for case in corpus { + let sizes = ThumbnailService::bench_render_all(&case.bytes).unwrap_or_default(); + let get = |want: ThumbnailSize| { + sizes + .iter() + .find(|(s, _)| *s == want) + .map(|(_, n)| *n) + .unwrap_or(0) + }; + println!( + "| {:<17} | {:<5} | {:>5}×{:<5} | {:>8} | {:>6} | {:>8} | {:>8} |", + case.name, + case.format, + case.width, + case.height, + case.bytes.len() / 1024, + get(ThumbnailSize::Icon), + get(ThumbnailSize::Preview), + get(ThumbnailSize::Large), + ); + } + println!(); +} + +fn configure(group: &mut criterion::BenchmarkGroup<'_, criterion::measurement::WallTime>) { + // Big images (48 MP) are slow per-iter; keep the suite bounded but stable. + group + .sample_size(10) + .warm_up_time(Duration::from_secs(1)) + .measurement_time(Duration::from_secs(6)); +} + +fn bench_thumbnails(c: &mut Criterion) { + let corpus = bench_support::load_or_generate(); + assert!(!corpus.is_empty(), "corpus is empty — generation failed"); + print_output_sizes(&corpus); + + // Per-size single-thumbnail latency (the lazy request path). + for size in SIZES { + let mut group = c.benchmark_group(format!("render_thumbnail/{size:?}")); + configure(&mut group); + for case in &corpus { + group.throughput(Throughput::Elements(1)); + group.bench_with_input(BenchmarkId::from_parameter(case.name), case, |b, case| { + b.iter(|| { + let out = + ThumbnailService::bench_render_thumbnail(black_box(&case.bytes), size) + .expect("render_thumbnail"); + black_box(out.len()) + }); + }); + } + group.finish(); + } + + // All-sizes-in-one-decode latency (the eager upload path). + let mut group = c.benchmark_group("render_all"); + configure(&mut group); + for case in &corpus { + group.throughput(Throughput::Elements(1)); + group.bench_with_input(BenchmarkId::from_parameter(case.name), case, |b, case| { + b.iter(|| { + let out = + ThumbnailService::bench_render_all(black_box(&case.bytes)).expect("render_all"); + black_box(out.len()) + }); + }); + } + group.finish(); +} + +criterion_group!(benches, bench_thumbnails); +criterion_main!(benches); diff --git a/examples/bench_thumbnails_mem.rs b/examples/bench_thumbnails_mem.rs new file mode 100644 index 00000000..f49d3b9b --- /dev/null +++ b/examples/bench_thumbnails_mem.rs @@ -0,0 +1,466 @@ +//! Phase 0 — Task 0.3: peak-RAM and saturated-throughput baseline. +//! +//! Two measurements criterion does not give us: +//! 1. **Peak heap per decode** — via a counting global allocator wrapping the +//! system allocator. We snapshot the high-water mark of bytes allocated +//! around a single `render_all` call, i.e. the transient decode/resize/ +//! encode footprint. This is the number the `Semaphore` (`cpus/2`) exists +//! to bound, and the one shrink-on-load (Task 1.1) should collapse. +//! 2. **Throughput under saturation** — N = cores threads hammering the same +//! image for a fixed window → photos/sec and effective ms/photo, mirroring +//! a burst of hundreds of uploads. +//! +//! Heap bytes here are *logical allocation* (what the program requested), not +//! RSS. For a Max-RSS cross-check on macOS run the binary under: +//! `/usr/bin/time -l ./target/release/examples/bench_thumbnails_mem` +//! +//! Run: `cargo run --release --features bench --example bench_thumbnails_mem` + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::hint::black_box; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use oxicloud::bench_support::{self, CorpusCase}; +use oxicloud::infrastructure::services::thumbnail_service::{ThumbnailService, ThumbnailSize}; + +// --------------------------------------------------------------------------- +// Counting allocator +// --------------------------------------------------------------------------- + +struct TrackingAlloc; + +static CURRENT: AtomicUsize = AtomicUsize::new(0); +static PEAK: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for TrackingAlloc { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let p = unsafe { System.alloc(layout) }; + if !p.is_null() { + let now = CURRENT.fetch_add(layout.size(), Ordering::Relaxed) + layout.size(); + PEAK.fetch_max(now, Ordering::Relaxed); + } + p + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + let p = unsafe { System.alloc_zeroed(layout) }; + if !p.is_null() { + let now = CURRENT.fetch_add(layout.size(), Ordering::Relaxed) + layout.size(); + PEAK.fetch_max(now, Ordering::Relaxed); + } + p + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) }; + CURRENT.fetch_sub(layout.size(), Ordering::Relaxed); + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + let p = unsafe { System.realloc(ptr, layout, new_size) }; + if !p.is_null() { + if new_size >= layout.size() { + let delta = new_size - layout.size(); + let now = CURRENT.fetch_add(delta, Ordering::Relaxed) + delta; + PEAK.fetch_max(now, Ordering::Relaxed); + } else { + CURRENT.fetch_sub(layout.size() - new_size, Ordering::Relaxed); + } + } + p + } +} + +#[global_allocator] +static GLOBAL: TrackingAlloc = TrackingAlloc; + +fn current() -> usize { + CURRENT.load(Ordering::Relaxed) +} +fn peak() -> usize { + PEAK.load(Ordering::Relaxed) +} +fn reset_peak_to_current() { + PEAK.store(CURRENT.load(Ordering::Relaxed), Ordering::Relaxed); +} + +const MB: f64 = 1024.0 * 1024.0; + +// --------------------------------------------------------------------------- +// Measurements +// --------------------------------------------------------------------------- + +struct PeakRow { + name: &'static str, + format: &'static str, + width: u32, + height: u32, + megapixels: f64, + input_bytes: usize, + output_bytes: usize, + single_ms: f64, + peak_heap_bytes: usize, +} + +/// Single-thread: peak transient heap + latency for one `render_all`. +fn measure_peak(case: &CorpusCase) -> PeakRow { + // Warm up (let any one-time lazy allocations settle) and discard. + let _ = ThumbnailService::bench_render_all(&case.bytes).expect("render_all warmup"); + + let mut best_peak = 0usize; + let mut best_ms = f64::INFINITY; + let mut output_bytes = 0usize; + for _ in 0..3 { + reset_peak_to_current(); + let base = current(); + let t = Instant::now(); + let out = ThumbnailService::bench_render_all(&case.bytes).expect("render_all"); + let ms = t.elapsed().as_secs_f64() * 1000.0; + let pk = peak().saturating_sub(base); + output_bytes = out.iter().map(|(_, n)| *n).sum(); + black_box(&out); + best_peak = best_peak.max(pk); + best_ms = best_ms.min(ms); + } + + PeakRow { + name: case.name, + format: case.format, + width: case.width, + height: case.height, + megapixels: case.megapixels(), + input_bytes: case.bytes.len(), + output_bytes, + single_ms: best_ms, + peak_heap_bytes: best_peak, + } +} + +struct ThroughputRow { + name: &'static str, + width: u32, + height: u32, + megapixels: f64, + threads: usize, + seconds: f64, + photos: u64, + photos_per_sec: f64, + eff_ms_per_photo: f64, +} + +/// N=threads workers render the same image until the window elapses. +fn measure_throughput(case: &CorpusCase, threads: usize, window: Duration) -> ThroughputRow { + let counter = AtomicU64::new(0); + let start = Instant::now(); + let deadline = start + window; + + thread::scope(|s| { + for _ in 0..threads { + let counter = &counter; + let bytes = &case.bytes; + s.spawn(move || { + while Instant::now() < deadline { + let out = ThumbnailService::bench_render_all(bytes).expect("render_all"); + black_box(out.len()); + counter.fetch_add(1, Ordering::Relaxed); + } + }); + } + }); + + let elapsed = start.elapsed().as_secs_f64(); + let photos = counter.load(Ordering::Relaxed); + let pps = photos as f64 / elapsed; + ThroughputRow { + name: case.name, + width: case.width, + height: case.height, + megapixels: case.megapixels(), + threads, + seconds: elapsed, + photos, + photos_per_sec: pps, + eff_ms_per_photo: if pps > 0.0 { 1000.0 / pps } else { 0.0 }, + } +} + +// --------------------------------------------------------------------------- +// Reporting +// --------------------------------------------------------------------------- + +fn main() { + let threads = thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); + let corpus = bench_support::load_or_generate(); + assert!(!corpus.is_empty(), "corpus is empty — generation failed"); + + println!("\n###########################################################"); + println!("# Phase 0 baseline — thumbnail render (current `image` crate)"); + println!("# cores (available_parallelism): {threads}"); + println!("# corpus dir: {}", bench_support::corpus_dir().display()); + println!("# heap = logical allocation high-water mark (not RSS)"); + println!("###########################################################\n"); + + // --- Table A: peak RAM + single-thread latency + output size --- + println!("== A. Per-image: peak heap, single-thread latency, output size =="); + println!( + "| {:<17} | {:<5} | {:>11} | {:>6} | {:>8} | {:>9} | {:>13} | {:>11} |", + "case", "fmt", "source", "MP", "input KB", "out KB", "render_all ms", "peak heap MB" + ); + println!( + "|{:-<19}|{:-<7}|{:-<13}|{:-<8}|{:-<10}|{:-<11}|{:-<15}|{:-<13}|", + "", "", "", "", "", "", "", "" + ); + let mut peak_rows = Vec::new(); + for case in &corpus { + let row = measure_peak(case); + println!( + "| {:<17} | {:<5} | {:>5}×{:<5} | {:>6.1} | {:>8} | {:>9} | {:>13.2} | {:>11.1} |", + row.name, + row.format, + row.width, + row.height, + row.megapixels, + row.input_bytes / 1024, + row.output_bytes / 1024, + row.single_ms, + row.peak_heap_bytes as f64 / MB, + ); + peak_rows.push(row); + } + + // --- Table B: saturated throughput (JPEG photo sizes only) --- + println!("\n== B. Saturated throughput ({threads} threads, 3s window) =="); + println!( + "| {:<17} | {:>11} | {:>6} | {:>7} | {:>8} | {:>11} | {:>14} |", + "case", "source", "MP", "threads", "photos", "photos/sec", "eff ms/photo" + ); + println!( + "|{:-<19}|{:-<13}|{:-<8}|{:-<9}|{:-<10}|{:-<13}|{:-<16}|", + "", "", "", "", "", "", "" + ); + let mut tp_rows = Vec::new(); + for case in corpus.iter().filter(|c| is_throughput_case(c.name)) { + let row = measure_throughput(case, threads, Duration::from_secs(3)); + println!( + "| {:<17} | {:>5}×{:<5} | {:>6.1} | {:>7} | {:>8} | {:>11.1} | {:>14.2} |", + row.name, + row.width, + row.height, + row.megapixels, + row.threads, + row.photos, + row.photos_per_sec, + row.eff_ms_per_photo, + ); + tp_rows.push(row); + } + + // --- Table C: quality — shrink-on-load vs full decode (Task 1.1 gate) --- + println!("\n== C. Quality: shrink-on-load vs full-decode, Preview 400px =="); + println!( + "| {:<17} | {:>11} | {:>7} | {:>9} |", + "case", "thumb dims", "SSIM", "PSNR dB" + ); + println!("|{:-<19}|{:-<13}|{:-<9}|{:-<11}|", "", "", "", ""); + for case in corpus.iter().filter(|c| { + matches!( + c.name, + "jpeg_12mp" | "jpeg_24mp" | "jpeg_48mp" | "small_300" + ) + }) { + let new_jpeg = + ThumbnailService::bench_render_thumbnail(&case.bytes, ThumbnailSize::Preview) + .expect("shrink-on-load render"); + let ref_jpeg = reference_render_full_decode(&case.bytes, 400); + let (a, aw, ah) = decode_to_luma(&new_jpeg); + let (b, bw, bh) = decode_to_luma(&ref_jpeg); + if (aw, ah) != (bw, bh) { + println!( + "| {:<17} | {:>4}×{:<4} ⚠ ref {}×{} (dim mismatch) |", + case.name, aw, ah, bw, bh + ); + continue; + } + let ssim = block_ssim(&a, &b, aw, ah); + let psnr = psnr(&a, &b); + let flag = if ssim >= 0.98 { "" } else { " ⚠ below 0.98" }; + println!( + "| {:<17} | {:>4}×{:<4} | {:>7.4} | {:>9.2} |{}", + case.name, aw, ah, ssim, psnr, flag + ); + } + + write_json(threads, &peak_rows, &tp_rows); + + println!( + "\nWrote machine-readable baseline → {}", + json_path().display() + ); + println!( + "For Max RSS / CPU time cross-check, re-run under:\n /usr/bin/time -l ./target/release/examples/bench_thumbnails_mem\n" + ); +} + +/// Throughput is only meaningful on the realistic upload load — the JPEG photo +/// sizes. (Tiny / GIF / WebP cases stay in the per-image table.) +fn is_throughput_case(name: &str) -> bool { + matches!(name, "jpeg_12mp" | "jpeg_24mp" | "jpeg_48mp") +} + +fn json_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("bench-baseline-fase0.json") +} + +fn write_json(threads: usize, peak_rows: &[PeakRow], tp_rows: &[ThroughputRow]) { + let per_image: Vec<_> = peak_rows + .iter() + .map(|r| { + serde_json::json!({ + "case": r.name, + "format": r.format, + "width": r.width, + "height": r.height, + "megapixels": r.megapixels, + "input_bytes": r.input_bytes, + "output_bytes": r.output_bytes, + "single_ms": r.single_ms, + "peak_heap_bytes": r.peak_heap_bytes, + }) + }) + .collect(); + + let throughput: Vec<_> = tp_rows + .iter() + .map(|r| { + serde_json::json!({ + "case": r.name, + "width": r.width, + "height": r.height, + "megapixels": r.megapixels, + "threads": r.threads, + "seconds": r.seconds, + "photos": r.photos, + "photos_per_sec": r.photos_per_sec, + "eff_ms_per_photo": r.eff_ms_per_photo, + }) + }) + .collect(); + + let doc = serde_json::json!({ + "phase": 0, + "label": "baseline-image-crate", + "cores": threads, + "per_image": per_image, + "throughput": throughput, + }); + + if let Err(e) = std::fs::write( + json_path(), + serde_json::to_string_pretty(&doc).unwrap_or_default(), + ) { + eprintln!("could not write {}: {e}", json_path().display()); + } +} + +// --------------------------------------------------------------------------- +// Quality verification helpers (Table C) +// --------------------------------------------------------------------------- + +/// Reference thumbnail: identical resample + q80 JPEG encode as production, but +/// forced through a **full decode** (no shrink-on-load). Comparing against this +/// isolates exactly the quality impact of DCT scale-on-decode. EXIF orientation +/// is not applied here, so only run it on orientation=1 corpus cases. +fn reference_render_full_decode(bytes: &[u8], max_dim: u32) -> Vec { + let img = image::load_from_memory(bytes).expect("ref full decode"); + let (ow, oh) = (img.width(), img.height()); + let (nw, nh) = if ow > oh { + (max_dim, (oh as f32 * (max_dim as f32 / ow as f32)) as u32) + } else { + ((ow as f32 * (max_dim as f32 / oh as f32)) as u32, max_dim) + }; + let rgb = img + .resize(nw, nh, image::imageops::FilterType::CatmullRom) + .to_rgb8(); + let mut buf = Vec::new(); + let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 80); + rgb.write_with_encoder(enc).expect("ref encode"); + buf +} + +/// Decode a JPEG thumbnail back to an 8-bit luma plane for comparison. +fn decode_to_luma(jpeg: &[u8]) -> (Vec, u32, u32) { + let img = image::load_from_memory(jpeg).expect("decode thumbnail"); + let luma = img.to_luma8(); + let (w, h) = (luma.width(), luma.height()); + (luma.into_raw(), w, h) +} + +/// Mean SSIM over non-overlapping 8×8 blocks (luma). 1.0 = identical. +fn block_ssim(a: &[u8], b: &[u8], w: u32, h: u32) -> f64 { + const C1: f64 = (0.01 * 255.0) * (0.01 * 255.0); + const C2: f64 = (0.03 * 255.0) * (0.03 * 255.0); + let (w, h) = (w as usize, h as usize); + let bs = 8usize; + let mut acc = 0.0; + let mut blocks = 0.0; + let mut by = 0; + while by < h { + let mut bx = 0; + while bx < w { + let (mut sa, mut sb, mut saa, mut sbb, mut sab, mut n) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + for y in by..(by + bs).min(h) { + for x in bx..(bx + bs).min(w) { + let ia = a[y * w + x] as f64; + let ib = b[y * w + x] as f64; + sa += ia; + sb += ib; + saa += ia * ia; + sbb += ib * ib; + sab += ia * ib; + n += 1.0; + } + } + let (ma, mb) = (sa / n, sb / n); + let va = (saa / n - ma * ma).max(0.0); + let vb = (sbb / n - mb * mb).max(0.0); + let cov = sab / n - ma * mb; + let s = ((2.0 * ma * mb + C1) * (2.0 * cov + C2)) + / ((ma * ma + mb * mb + C1) * (va + vb + C2)); + acc += s; + blocks += 1.0; + bx += bs; + } + by += bs; + } + if blocks > 0.0 { acc / blocks } else { 1.0 } +} + +/// Peak signal-to-noise ratio (luma). ∞ for identical inputs. +fn psnr(a: &[u8], b: &[u8]) -> f64 { + let n = a.len().min(b.len()); + if n == 0 { + return f64::INFINITY; + } + let mse: f64 = a + .iter() + .zip(b.iter()) + .take(n) + .map(|(&x, &y)| { + let d = x as f64 - y as f64; + d * d + }) + .sum::() + / n as f64; + if mse == 0.0 { + f64::INFINITY + } else { + 10.0 * (255.0 * 255.0 / mse).log10() + } +} diff --git a/src/bench_support.rs b/src/bench_support.rs new file mode 100644 index 00000000..f9396ceb --- /dev/null +++ b/src/bench_support.rs @@ -0,0 +1,317 @@ +//! Phase 0 perf-benchmark support — deterministic image corpus. +//! +//! Shared by `benches/thumbnails.rs` (Task 0.2, criterion latency + output +//! size) and `examples/bench_thumbnails_mem.rs` (Task 0.3, peak RAM + +//! throughput). Gated behind the `bench` feature so it never touches normal +//! builds. +//! +//! The corpus is **generated deterministically** (a low-frequency gradient plus +//! seeded high-frequency xorshift noise) so it is reproducible, license-free and +//! gives the decoder/resizer realistic work without committing large binaries to +//! git. Files are written to `benches/corpus/` (git-ignored) on first run and +//! reused afterwards. +//! +//! Files already present on disk are **always preferred** over generation — so +//! you can drop your own real photos into `benches/corpus/` using the documented +//! filenames (see [`CASE_SPECS`]) to benchmark against real-world data. + +use std::io::Cursor; +use std::path::PathBuf; + +use image::codecs::jpeg::JpegEncoder; +use image::{DynamicImage, ImageFormat, Rgb, RgbImage}; + +/// One corpus entry: the encoded file bytes plus its probed dimensions. +pub struct CorpusCase { + /// Stable identifier (e.g. `"jpeg_12mp"`), used as the bench label. + pub name: &'static str, + /// Container format (`"jpeg"`, `"png"`, `"gif"`, `"webp"`). + pub format: &'static str, + /// Actual decoded width (probed from the bytes). + pub width: u32, + /// Actual decoded height (probed from the bytes). + pub height: u32, + /// Encoded file bytes (what the thumbnail pipeline receives as `&[u8]`). + pub bytes: Vec, +} + +impl CorpusCase { + /// Megapixels of the source image (decoded resolution). + pub fn megapixels(&self) -> f64 { + (self.width as f64 * self.height as f64) / 1_000_000.0 + } +} + +/// Declarative description of a synthetic corpus image. +struct Spec { + name: &'static str, + filename: &'static str, + format: &'static str, + width: u32, + height: u32, + /// JPEG quality (ignored for non-JPEG formats). + quality: u8, + /// When set, injects an EXIF Orientation tag into the JPEG (e.g. 6 = rotate + /// 90° CW) to exercise the orientation-correction path. + exif_orientation: Option, +} + +/// The corpus matrix: a spread of sizes and formats so we never trust an +/// average. Drop a real photo at `benches/corpus/` to override any +/// entry with real-world data. +const CASE_SPECS: &[Spec] = &[ + // JPEG photo-like sources at the three sizes that matter for "hundreds of + // phone/camera photos". These dominate the real upload load. + Spec { + name: "jpeg_12mp", + filename: "jpeg_12mp.jpg", + format: "jpeg", + width: 4000, + height: 3000, + quality: 90, + exif_orientation: None, + }, + Spec { + name: "jpeg_24mp", + filename: "jpeg_24mp.jpg", + format: "jpeg", + width: 6000, + height: 4000, + quality: 90, + exif_orientation: None, + }, + // 8000×6000 = 48 MP, just under the 50 MP MAX_DECODE_PIXELS guard. + Spec { + name: "jpeg_48mp", + filename: "jpeg_48mp.jpg", + format: "jpeg", + width: 8000, + height: 6000, + quality: 90, + exif_orientation: None, + }, + // Non-JPEG decode paths (no DCT shrink-on-load possible — useful contrast). + Spec { + name: "png_large", + filename: "png_large.png", + format: "png", + width: 3000, + height: 2000, + quality: 0, + exif_orientation: None, + }, + Spec { + name: "gif_large", + filename: "gif_large.gif", + format: "gif", + width: 600, + height: 600, + quality: 0, + exif_orientation: None, + }, + Spec { + name: "webp_large", + filename: "webp_large.webp", + format: "webp", + width: 1280, + height: 853, + quality: 0, + exif_orientation: None, + }, + // Small source: exercises the (future) no-upscale clamp — Large=800 target + // is bigger than the 300 px source. + Spec { + name: "small_300", + filename: "small_300.jpg", + format: "jpeg", + width: 300, + height: 300, + quality: 90, + exif_orientation: None, + }, + // EXIF orientation ≠ 1: exercises the rotate/flip correction path. + Spec { + name: "jpeg_exif_orient", + filename: "jpeg_exif_orient.jpg", + format: "jpeg", + width: 4000, + height: 3000, + quality: 90, + exif_orientation: Some(6), + }, +]; + +/// Absolute path to `benches/corpus/` next to this crate's `Cargo.toml`. +pub fn corpus_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("benches") + .join("corpus") +} + +/// Load the corpus, generating any missing files on disk first. +/// +/// Existing files win, so user-provided real photos are used as-is. A case that +/// fails to generate (e.g. an unavailable encoder) is logged and skipped rather +/// than aborting the whole baseline. +pub fn load_or_generate() -> Vec { + let dir = corpus_dir(); + if let Err(e) = std::fs::create_dir_all(&dir) { + panic!("bench_support: cannot create {}: {e}", dir.display()); + } + + let mut out = Vec::new(); + for spec in CASE_SPECS { + let path = dir.join(spec.filename); + let bytes = if path.exists() { + match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + eprintln!("bench_support: skipping {} (read failed: {e})", spec.name); + continue; + } + } + } else { + match generate(spec) { + Ok(b) => { + if let Err(e) = std::fs::write(&path, &b) { + eprintln!("bench_support: could not cache {} ({e})", path.display()); + } + b + } + Err(e) => { + eprintln!( + "bench_support: skipping {} (generate failed: {e})", + spec.name + ); + continue; + } + } + }; + + let (width, height) = probe_dimensions(&bytes).unwrap_or((spec.width, spec.height)); + out.push(CorpusCase { + name: spec.name, + format: spec.format, + width, + height, + bytes, + }); + } + out +} + +/// Probe the decoded dimensions of encoded image bytes without a full decode. +fn probe_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + image::ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .ok()? + .into_dimensions() + .ok() +} + +/// Render and encode one spec into file bytes. +fn generate(spec: &Spec) -> Result, String> { + let img = synthesize(spec.width, spec.height, seed_for(spec.name)); + + match spec.format { + "jpeg" => { + let mut buf = Vec::new(); + let encoder = JpegEncoder::new_with_quality(&mut buf, spec.quality); + img.write_with_encoder(encoder) + .map_err(|e| format!("jpeg encode: {e}"))?; + match spec.exif_orientation { + Some(o) => inject_exif_orientation(&buf, o), + None => Ok(buf), + } + } + other => { + let fmt = match other { + "png" => ImageFormat::Png, + "gif" => ImageFormat::Gif, + "webp" => ImageFormat::WebP, + _ => return Err(format!("unknown format {other}")), + }; + let mut buf = Vec::new(); + DynamicImage::ImageRgb8(img) + .write_to(&mut Cursor::new(&mut buf), fmt) + .map_err(|e| format!("{other} encode: {e}"))?; + Ok(buf) + } + } +} + +/// Build a photo-like RGB image: a smooth diagonal gradient (low frequency) +/// plus seeded ±32 white noise (high frequency). Deterministic for a given +/// seed, so corpus bytes are byte-stable across runs and machines. +fn synthesize(width: u32, height: u32, seed: u64) -> RgbImage { + let mut img = RgbImage::new(width, height); + let mut state = seed | 1; // xorshift requires a non-zero state + let (w, h) = (width.max(1), height.max(1)); + for y in 0..height { + let gy = (y as i32 * 255 / h as i32).clamp(0, 255); + for x in 0..width { + let gx = (x as i32 * 255 / w as i32).clamp(0, 255); + let noise = (xorshift(&mut state) & 0x3F) as i32 - 32; // -32..=31 + let r = (gx + noise).clamp(0, 255) as u8; + let g = (gy + noise).clamp(0, 255) as u8; + let b = (((gx + gy) / 2) + noise).clamp(0, 255) as u8; + img.put_pixel(x, y, Rgb([r, g, b])); + } + } + img +} + +/// Tiny xorshift64 PRNG — fast, deterministic, no dependency. +fn xorshift(state: &mut u64) -> u64 { + let mut x = *state; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + *state = x; + x +} + +/// Per-case fixed seed so each image has distinct noise but stays reproducible. +fn seed_for(name: &str) -> u64 { + // FNV-1a over the name → splitmix-ish spread. + let mut hash: u64 = 0xcbf29ce484222325; + for b in name.bytes() { + hash ^= b as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + hash.wrapping_mul(0x9E3779B97F4A7C15) | 1 +} + +/// Splice a minimal, standard EXIF APP1 segment carrying a single Orientation +/// tag into a baseline JPEG, right after the SOI marker. Little-endian TIFF. +fn inject_exif_orientation(jpeg: &[u8], orientation: u16) -> Result, String> { + if jpeg.len() < 2 || jpeg[0] != 0xFF || jpeg[1] != 0xD8 { + return Err("not a JPEG (missing SOI)".into()); + } + + // TIFF body (little-endian "II"). + let mut tiff = Vec::new(); + tiff.extend_from_slice(b"II"); + tiff.extend_from_slice(&0x2Au16.to_le_bytes()); // magic 42 + tiff.extend_from_slice(&8u32.to_le_bytes()); // IFD0 offset + tiff.extend_from_slice(&1u16.to_le_bytes()); // 1 directory entry + tiff.extend_from_slice(&0x0112u16.to_le_bytes()); // tag: Orientation + tiff.extend_from_slice(&3u16.to_le_bytes()); // type: SHORT + tiff.extend_from_slice(&1u32.to_le_bytes()); // count + tiff.extend_from_slice(&(orientation as u32).to_le_bytes()); // value (SHORT in low bytes) + tiff.extend_from_slice(&0u32.to_le_bytes()); // next IFD = none + + let mut payload = Vec::with_capacity(6 + tiff.len()); + payload.extend_from_slice(b"Exif\0\0"); + payload.extend_from_slice(&tiff); + + let seg_len = u16::try_from(2 + payload.len()).map_err(|_| "EXIF segment too large")?; + + let mut out = Vec::with_capacity(jpeg.len() + 4 + payload.len()); + out.extend_from_slice(&jpeg[0..2]); // SOI + out.extend_from_slice(&[0xFF, 0xE1]); // APP1 marker + out.extend_from_slice(&seg_len.to_be_bytes()); // APP1 length (big-endian) + out.extend_from_slice(&payload); + out.extend_from_slice(&jpeg[2..]); // rest of the original JPEG + Ok(out) +} diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index afe9702a..dd50db65 100644 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -551,11 +551,31 @@ impl ThumbnailService { Ok(bytes) } - fn render_thumbnail_from_data( + /// Cheap magic-byte check for a JPEG container (SOI + first marker). + fn is_jpeg(data: &[u8]) -> bool { + data.len() >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF + } + + /// Decode `data` into a `DynamicImage` sized for a thumbnail whose longest + /// side is `target_long`, with EXIF orientation already applied. + /// + /// For JPEG this is **shrink-on-load**: the decoder emits the image at the + /// smallest DCT scale (1/8·1/4·1/2·1/1) whose longest axis is still ≥ + /// `target_long`, so a 12 MP photo decodes ~16× fewer pixels for an 800 px + /// thumbnail — and the full-resolution bitmap (the dominant time/RAM cost) + /// is never materialised. PNG/GIF/WebP (no DCT scaling) and unusual JPEG + /// colour spaces (CMYK / 16-bit grey) fall back to a full decode. + fn decode_oriented( data: &[u8], - size: ThumbnailSize, - ) -> Result, ThumbnailError> { - let max_dim = size.max_dimension(); + target_long: u32, + ) -> Result { + // JPEG fast path: shrink-on-load. A non-JPEG, or a JPEG colour space we + // don't map (CMYK / 16-bit grey → `None`), falls through to a full decode. + if Self::is_jpeg(data) + && let Some(img) = Self::decode_jpeg_scaled(data, target_long)? + { + return Ok(Self::apply_exif_orientation(data, img)); + } let (w, h) = image::ImageReader::new(std::io::Cursor::new(data)) .with_guessed_format() @@ -568,17 +588,74 @@ impl ThumbnailService { w as u64 * h as u64 / 1_000_000 ))); } - let img = image::load_from_memory(data).map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + Ok(Self::apply_exif_orientation(data, img)) + } - let img = { - use crate::infrastructure::services::exif_service::{ExifService, apply_orientation}; - let orientation = ExifService::extract(data) - .and_then(|m| m.orientation) - .unwrap_or(1); - apply_orientation(img, orientation) + /// JPEG shrink-on-load. Returns `Ok(None)` for colour spaces we don't map + /// (CMYK / 16-bit grey), signalling the caller to fall back to a full decode. + fn decode_jpeg_scaled( + data: &[u8], + target_long: u32, + ) -> Result, ThumbnailError> { + let mut decoder = jpeg_decoder::Decoder::new(std::io::Cursor::new(data)); + // read_info() first so the dimension guard sees the *original* size. + decoder + .read_info() + .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + let info = decoder + .info() + .ok_or_else(|| ThumbnailError::ImageError("missing JPEG metadata".into()))?; + let (orig_w, orig_h) = (info.width as u64, info.height as u64); + if orig_w * orig_h > MAX_DECODE_PIXELS { + return Err(ThumbnailError::ImageError(format!( + "Image too large for thumbnail: {orig_w}×{orig_h} ({} MP, max {MAX_DECODE_PIXELS})", + orig_w * orig_h / 1_000_000 + ))); + } + + // Request a `target_long` square box: jpeg-decoder picks the smallest + // scale whose longest axis is still ≥ target_long (its "≥ in at least + // one axis" rule reduces to the long axis since it dominates), so the + // later resample step only ever downscales — never upscales/blurs. + let req = target_long.min(u16::MAX as u32) as u16; + let (sw, sh) = decoder + .scale(req, req) + .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + let pixels = decoder + .decode() + .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + + let (sw, sh) = (sw as u32, sh as u32); + let img = match decoder.info().map(|i| i.pixel_format) { + Some(jpeg_decoder::PixelFormat::RGB24) => { + image::RgbImage::from_raw(sw, sh, pixels).map(image::DynamicImage::ImageRgb8) + } + Some(jpeg_decoder::PixelFormat::L8) => { + image::GrayImage::from_raw(sw, sh, pixels).map(image::DynamicImage::ImageLuma8) + } + _ => None, }; + Ok(img) + } + + /// Read EXIF orientation from the original bytes and rotate/flip the image. + /// (Applied after shrink-on-load, so the rotation works on the small bitmap.) + fn apply_exif_orientation(data: &[u8], img: image::DynamicImage) -> image::DynamicImage { + use crate::infrastructure::services::exif_service::{ExifService, apply_orientation}; + let orientation = ExifService::extract(data) + .and_then(|m| m.orientation) + .unwrap_or(1); + apply_orientation(img, orientation) + } + + fn render_thumbnail_from_data( + data: &[u8], + size: ThumbnailSize, + ) -> Result, ThumbnailError> { + let max_dim = size.max_dimension(); + let img = Self::decode_oriented(data, max_dim)?; let (orig_width, orig_height) = (img.width(), img.height()); let (new_width, new_height) = if orig_width > orig_height { @@ -608,29 +685,11 @@ impl ThumbnailService { fn render_all_thumbnails_from_data( data: &[u8], ) -> Result, ThumbnailError> { - let (w, h) = image::ImageReader::new(std::io::Cursor::new(data)) - .with_guessed_format() - .map_err(|e| ThumbnailError::ImageError(e.to_string()))? - .into_dimensions() - .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; - if (w as u64) * (h as u64) > MAX_DECODE_PIXELS { - return Err(ThumbnailError::ImageError(format!( - "Image too large for thumbnail: {w}×{h} ({} MP, max {MAX_DECODE_PIXELS})", - w as u64 * h as u64 / 1_000_000 - ))); - } - - let img = - image::load_from_memory(data).map_err(|e| ThumbnailError::ImageError(e.to_string()))?; - - let img = { - use crate::infrastructure::services::exif_service::{ExifService, apply_orientation}; - let orientation = ExifService::extract(data) - .and_then(|m| m.orientation) - .unwrap_or(1); - apply_orientation(img, orientation) - }; - + // Decode once, shrunk-on-load for the largest size (800 px); all three + // sizes are then resampled from this single shared bitmap. Sizing the + // shared decode to Large keeps quality for every size while paying the + // (now much smaller) decode cost only once. + let img = Self::decode_oriented(data, ThumbnailSize::Large.max_dimension())?; let (orig_w, orig_h) = (img.width(), img.height()); ThumbnailSize::all() @@ -1271,6 +1330,31 @@ impl ThumbnailPort for ThumbnailService { } } +/// Benchmark-only public surface (Phase 0 perf harness). +/// +/// The real render functions are private (`render_thumbnail_from_data` / +/// `render_all_thumbnails_from_data`). Benches and examples are separate crate +/// targets and can only see `pub` items, so these thin wrappers — gated behind +/// the `bench` feature so they never exist in production builds — expose the +/// exact CPU-bound work (decode → orientation → resize → JPEG encode) for +/// before/after measurement. Errors are flattened to `String` to avoid leaking +/// `ThumbnailError` into the public API. +#[cfg(feature = "bench")] +impl ThumbnailService { + /// Render a single thumbnail size, returning the encoded JPEG bytes. + pub fn bench_render_thumbnail(data: &[u8], size: ThumbnailSize) -> Result, String> { + Self::render_thumbnail_from_data(data, size).map_err(|e| e.to_string()) + } + + /// Render all sizes in one decode (the upload-time path), returning each + /// size paired with its encoded byte length (output-size baseline). + pub fn bench_render_all(data: &[u8]) -> Result, String> { + Self::render_all_thumbnails_from_data(data) + .map(|v| v.into_iter().map(|(s, b)| (s, b.len())).collect()) + .map_err(|e| e.to_string()) + } +} + /// Thumbnail service errors #[derive(Debug, thiserror::Error)] pub enum ThumbnailError { diff --git a/src/lib.rs b/src/lib.rs index 301ea930..ef629325 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,12 @@ pub mod interfaces; #[cfg(integration_tests)] pub mod integration_test_support; +// Phase 0 perf-benchmark support: deterministic image corpus generation/loading +// shared by `benches/thumbnails.rs` and `examples/bench_thumbnails_mem.rs`. +// Gated behind the `bench` feature so it adds nothing to normal builds. +#[cfg(feature = "bench")] +pub mod bench_support; + // Common public re-exports pub use application::services::folder_service::FolderService; pub use application::services::i18n_application_service::I18nApplicationService;