Files
Oxicloud/benches/BASELINE.md
T
DioCrafts fd5808c157 perf(thumbnails): shrink-on-load JPEG decode (1.8-2× faster, 5-15× less RAM)
Decode JPEGs at the smallest DCT scale (1/8·1/4·1/2·1/1) whose long axis is
still ≥ the largest needed thumbnail (800px), via jpeg-decoder, instead of a
full-resolution decode through the image crate. The full-res bitmap — the
dominant time and RAM cost — is never materialised. PNG/GIF/WebP and unusual
JPEG colour spaces (CMYK / 16-bit grey) fall back to a full decode.

Extracts the shared decode + EXIF-orientation logic into decode_oriented(),
removing the duplication that existed between render_thumbnail_from_data and
render_all_thumbnails_from_data.

Measured on 14 cores (see benches/BASELINE.md):
- render_all 1.8-2.0× faster (12MP 111->61ms, 48MP 398->203ms)
- peak heap 5.5-14.8× lower, now decoupled from source MP (~18-25MB regardless)
- saturated throughput 3-3.6× (parallel efficiency 4.9×->8.5×)
- quality SSIM 0.987-0.999 (>=0.98 gate), PSNR 47-55dB

Also adds the Phase 0 benchmark harness (gated behind the `bench` feature, zero
prod impact): deterministic image corpus (src/bench_support.rs), criterion
latency bench (benches/thumbnails.rs), and a peak-RAM/throughput/SSIM harness
(examples/bench_thumbnails_mem.rs). Baseline + before/after in benches/BASELINE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:13:03 +02:00

7.5 KiB
Raw Blame History

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

# 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.