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
+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);