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>
This commit is contained in:
DioCrafts
2026-06-21 15:13:03 +02:00
parent 08b36cf0d4
commit fd5808c157
9 changed files with 1415 additions and 39 deletions
+154
View File
@@ -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.
+108
View File
@@ -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);