b505a974b9
Benchmark refuted the rayon-oversubscription hypothesis: throughput stayed flat at the real operating point (14 permits) and PNG single-image latency regressed 66% when the 3-size resize was made sequential. The code was reverted; this records the negative result in benches/BASELINE.md so it isn't retried (like the dropped defer-Large task). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
257 lines
13 KiB
Markdown
257 lines
13 KiB
Markdown
# 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. ✅ done below.
|
||
- 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.
|
||
|
||
---
|
||
|
||
# Phase 1.5 results — raise decode-concurrency cap (`cpus/2` → `cpus`)
|
||
|
||
`max_concurrent_decodes()` now defaults to all cores (was half), overridable via
|
||
`OXICLOUD_THUMBNAIL_DECODE_CONCURRENCY`. Safe only because Phase 1.1 decoupled
|
||
peak heap from source resolution.
|
||
|
||
Measured with a harness that mirrors the **real service path** (Table D:
|
||
`tokio::Semaphore(permits)` + `spawn_blocking`, many concurrent requests),
|
||
14 cores, 3 s window:
|
||
|
||
| case | 7 permits (`cpus/2`, old) | 14 permits (`cpus`, new) | 28 (`cpus*2`) |
|
||
|-----------|--------------------------:|-------------------------:|--------------:|
|
||
| jpeg_12mp | 92.7 | **133.7 (1.44×)** | 133.3 (—) |
|
||
| jpeg_24mp | 49.5 | **69.9 (1.41×)** | 71.1 (+1.7%) |
|
||
|
||
- **~1.4× throughput** on the real path, for free; peak heap unchanged (17–25 MB).
|
||
- `cpus*2` yields nothing → `cpus` is the right ceiling for CPU-bound work.
|
||
- It's 1.4× not 2× because `render_all` fans its 3 sizes onto rayon, so 7 permits
|
||
already partly fill all cores — the remaining headroom is **Task 1.7**
|
||
(rayon oversubscription).
|
||
|
||
---
|
||
|
||
# Phase 1.2 results — SIMD resize (`fast_image_resize`, Lanczos3)
|
||
|
||
Replaced the `image` crate's scalar resampler with `fast_image_resize`
|
||
(AVX2/SSE4.1/NEON) in the shared `encode_thumbnail` helper; `render_all` now
|
||
converts to RGB8 once and SIMD-resizes the shared buffer per size. Lanczos3 for
|
||
downscaling, CatmullRom when upscaling (avoids Lanczos ringing). Also folded the
|
||
duplicated path-variant `generate_all_sizes_background` into the shared render
|
||
path, so it too gets shrink-on-load + SIMD. "before" = post-1.5 state.
|
||
|
||
### Single-thread latency `render_all` (ms) and peak heap (MB)
|
||
|
||
| case | ms before | ms after | speedup | heap before | heap after |
|
||
|-----------|----------:|---------:|--------:|------------:|-----------:|
|
||
| jpeg_12mp | 60.89 | 56.46 | 1.08× | 17.6 | **7.1** |
|
||
| jpeg_24mp | 113.98 | 106.76 | 1.07× | 24.9 | **10.1** |
|
||
| jpeg_48mp | 203.29 | 198.75 | 1.02× | 17.6 | **7.1** |
|
||
| **png_large** | 33.63 | **12.92** | **2.60×** | 58.4 | **27.1** |
|
||
| gif_large | 12.93 | 7.99 | 1.62× | 15.4 | 5.9 |
|
||
| webp_large| 21.15 | 16.89 | 1.25× | 20.7 | 8.3 |
|
||
| small_300 | 10.64 | 6.37 | 1.67× | 8.0 | 3.9 |
|
||
|
||
- **JPEG: only ~6–8 %** — shrink-on-load already shrank the decoded bitmap, so
|
||
the resize was a small slice of the time. But **peak heap fell another ~2.5×**
|
||
(7 MB): fir works on tight RGB buffers with no intermediate `DynamicImage`,
|
||
and RGB conversion now happens once instead of per size.
|
||
- **PNG: 2.6×** (and GIF/WebP 1.25–1.6×) — exactly as predicted: these decode at
|
||
full resolution (no DCT shrink), so the SIMD resize dominates the win.
|
||
|
||
### Throughput (≈ +10–15 %, run-to-run noisy)
|
||
|
||
Saturated 12 MP ≈ 142→157 photos/s; semaphore-bounded 12 MP @ 14 permits
|
||
≈ 134→148. Directionally up; treat as noise-bounded.
|
||
|
||
### Quality gate (vs full-decode CatmullRom at identical dims)
|
||
|
||
| case | SSIM | PSNR dB |
|
||
|-----------|-------:|--------:|
|
||
| jpeg_12mp | 0.9865 | 47.21 |
|
||
| jpeg_24mp | 0.9923 | 48.73 |
|
||
| jpeg_48mp | 0.9938 | 49.33 |
|
||
| small_300 | 0.9921 | 42.59 |
|
||
|
||
All **≥ 0.98**. (The upscale case `small_300` needed the Lanczos3→CatmullRom
|
||
upscale rule — Lanczos rings when enlarging; it was 0.954 before that fix.)
|
||
|
||
### Note
|
||
Output thumbnails are now exactly `max_dim` on the long side (e.g. 400×266),
|
||
vs the old `image::resize` fit-within which produced 399×266 — a ≤1 px change,
|
||
invisible under the frontend's `object-fit: cover`.
|
||
|
||
---
|
||
|
||
# Phase 1.7 — TESTED AND REVERTED (rayon oversubscription)
|
||
|
||
Hypothesis: `render_all`'s internal `par_iter` over the 3 sizes oversubscribes
|
||
the global rayon pool under load (cpus×3 tasks), capping burst throughput.
|
||
Tested by resizing the 3 sizes **sequentially** (parallelism across images
|
||
only). Measured before/after on 14 cores, two runs each:
|
||
|
||
| metric | before (par_iter) | after (sequential) | verdict |
|
||
|------------------------------|------------------:|-------------------:|---------|
|
||
| **PNG single-image latency** | 12.9 ms | **21.3 ms** | **−66 % WORSE** |
|
||
| JPEG 12 MP single-image | 57.8 ms | 58.5 ms | neutral |
|
||
| Saturated 12 MP (photos/s) | 155.8 | 170 / 158 | flat (noise) |
|
||
| Semaphore @14 12 MP | 157.7 | 164 / 157 | flat |
|
||
| Semaphore @28 12 MP | 160.4 | 162 / 159 | flat |
|
||
|
||
**Verdict: reverted.** Throughput at the real operating point (14 permits) is
|
||
flat — rayon oversubscription was **not** the bottleneck. The "1.4× not 2×" of
|
||
Phase 1.5 is the single-threaded JPEG decode (which dominates post-shrink) plus
|
||
memory bandwidth, not rayon scheduling; even 28 concurrent renders (84 rayon
|
||
tasks) show no thrash. Meanwhile full-decode formats (PNG) regressed 66 % on
|
||
single-image latency because their resize-from-full-resolution genuinely
|
||
benefits from the per-image parallelism. Net negative → kept `par_iter`.
|
||
|
||
(Another "measure before believing" result, like the dropped Task 2.1.)
|
||
|