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
+118 -34
View File
@@ -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<Vec<u8>, ThumbnailError> {
let max_dim = size.max_dimension();
target_long: u32,
) -> Result<image::DynamicImage, ThumbnailError> {
// 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<Option<image::DynamicImage>, 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<Vec<u8>, 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<Vec<(ThumbnailSize, Bytes)>, 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<Vec<u8>, 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<Vec<(ThumbnailSize, usize)>, 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 {