perf(thumbnails): SIMD resize via fast_image_resize (PNG 2.6x, RAM 2.5x lower)

Replace the image crate's scalar resampler with fast_image_resize
(AVX2/SSE4.1/NEON) in a 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 (Lanczos rings on enlargement).

Also folds the duplicated path-variant generate_all_sizes_background into the
shared render path -- it had missed BOTH shrink-on-load and SIMD resizing -- so
every thumbnail path now goes through one optimised routine (no duplication).

Measured on 14 cores vs the post-1.5 state (benches/BASELINE.md):
- PNG 2.60x faster (33.6->12.9ms), GIF/WebP 1.25-1.6x: full-resolution decode
  paths where the resize dominates, so SIMD helps most
- JPEG only ~7% (shrink-on-load already shrank the bitmap) but peak heap fell
  another ~2.5x (17.6->7.1MB): tight RGB buffers, RGB conversion once
- quality SSIM 0.986-0.994 at identical dims (>=0.98 gate)

Thumbnails are now exactly max_dim on the long side (e.g. 400x266) vs the old
fit-within 399x266 -- a <=1px change, invisible under object-fit: cover.

Bench example gains an exact-dims quality reference + semaphore throughput table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DioCrafts
2026-06-21 15:53:28 +02:00
parent 51713b218d
commit b7e092ad05
5 changed files with 177 additions and 125 deletions
+78 -118
View File
@@ -665,74 +665,93 @@ impl ThumbnailService {
apply_orientation(img, orientation)
}
/// Aspect-ratio-preserving target dimensions so the longest side equals
/// `max_dim` (clamped to ≥1 to keep the SIMD resizer happy on extreme ratios).
fn fit_dims(src_w: u32, src_h: u32, max_dim: u32) -> (u32, u32) {
if src_w > src_h {
let ratio = max_dim as f32 / src_w as f32;
(max_dim, ((src_h as f32 * ratio) as u32).max(1))
} else {
let ratio = max_dim as f32 / src_h as f32;
(((src_w as f32 * ratio) as u32).max(1), max_dim)
}
}
/// Resampling filter per size: Bilinear for the tiny icon (cheap, output is
/// 150 px), Lanczos3 for preview/large (highest quality, SIMD-fast here).
fn filter_for(size: ThumbnailSize) -> fast_image_resize::FilterType {
use fast_image_resize::FilterType;
match size {
ThumbnailSize::Icon => FilterType::Bilinear,
ThumbnailSize::Preview | ThumbnailSize::Large => FilterType::Lanczos3,
}
}
/// SIMD-resize an RGB8 source to `dst_w×dst_h` (via `fast_image_resize`,
/// AVX2/SSE4.1/NEON) and encode the result as a q80 JPEG.
fn encode_thumbnail(
src_rgb: &[u8],
src_w: u32,
src_h: u32,
dst_w: u32,
dst_h: u32,
filter: fast_image_resize::FilterType,
) -> Result<Vec<u8>, ThumbnailError> {
use fast_image_resize::images::{Image, ImageRef};
use fast_image_resize::{FilterType, PixelType, ResizeAlg, ResizeOptions, Resizer};
// Lanczos3 is ideal for downscaling but rings on edges when upscaling;
// fall back to a smooth bicubic (CatmullRom) whenever the target is
// larger than the source on either axis.
let filter = if dst_w > src_w || dst_h > src_h {
FilterType::CatmullRom
} else {
filter
};
let src = ImageRef::new(src_w, src_h, src_rgb, PixelType::U8x3)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
let mut dst = Image::new(dst_w, dst_h, PixelType::U8x3);
let opts = ResizeOptions::new().resize_alg(ResizeAlg::Convolution(filter));
Resizer::new()
.resize(&src, &mut dst, &opts)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
let rgb = image::RgbImage::from_raw(dst_w, dst_h, dst.into_vec())
.ok_or_else(|| ThumbnailError::ImageError("resize buffer size mismatch".into()))?;
let mut buffer = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut buffer, 80);
rgb.write_with_encoder(encoder)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
Ok(buffer)
}
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 {
let ratio = max_dim as f32 / orig_width as f32;
(max_dim, (orig_height as f32 * ratio) as u32)
} else {
let ratio = max_dim as f32 / orig_height as f32;
((orig_width as f32 * ratio) as u32, max_dim)
};
let filter = match size {
ThumbnailSize::Icon => FilterType::Triangle,
ThumbnailSize::Preview => FilterType::CatmullRom,
ThumbnailSize::Large => FilterType::CatmullRom,
};
let thumbnail = img.resize(new_width, new_height, filter);
let rgb = thumbnail.to_rgb8();
let mut buffer = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut buffer, 80);
rgb.write_with_encoder(encoder)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
Ok(buffer)
let rgb = Self::decode_oriented(data, max_dim)?.into_rgb8();
let (sw, sh) = (rgb.width(), rgb.height());
let (nw, nh) = Self::fit_dims(sw, sh, max_dim);
Self::encode_thumbnail(rgb.as_raw(), sw, sh, nw, nh, Self::filter_for(size))
}
fn render_all_thumbnails_from_data(
data: &[u8],
) -> Result<Vec<(ThumbnailSize, Bytes)>, ThumbnailError> {
// 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());
// Decode once, shrunk-on-load for the largest size (800 px), and convert
// to RGB8 once; all three sizes are then SIMD-resampled from this single
// shared bitmap (the RGB conversion is no longer repeated per size).
let rgb = Self::decode_oriented(data, ThumbnailSize::Large.max_dimension())?.into_rgb8();
let (sw, sh) = (rgb.width(), rgb.height());
let src = rgb.as_raw().as_slice();
ThumbnailSize::all()
.par_iter()
.map(|&size| {
let max_dim = size.max_dimension();
let (new_w, new_h) = if orig_w > orig_h {
let ratio = max_dim as f32 / orig_w as f32;
(max_dim, (orig_h as f32 * ratio) as u32)
} else {
let ratio = max_dim as f32 / orig_h as f32;
((orig_w as f32 * ratio) as u32, max_dim)
};
let filter = match size {
ThumbnailSize::Icon => FilterType::Triangle,
ThumbnailSize::Preview => FilterType::CatmullRom,
ThumbnailSize::Large => FilterType::CatmullRom,
};
let thumb = img.resize(new_w, new_h, filter);
let rgb = thumb.to_rgb8();
let mut buf = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut buf, 80);
rgb.write_with_encoder(encoder)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
let (nw, nh) = Self::fit_dims(sw, sh, size.max_dimension());
let buf = Self::encode_thumbnail(src, sw, sh, nw, nh, Self::filter_for(size))?;
Ok((size, Bytes::from(buf)))
})
.collect::<Result<Vec<_>, ThumbnailError>>()
@@ -875,73 +894,14 @@ impl ThumbnailService {
let path = original_path.clone();
// Single spawn_blocking: 1 read + 1 decode + 3 resize + 3 encode
// Single spawn_blocking: read the file once, then run the shared
// render path (shrink-on-load decode + SIMD resize) — identical to
// the blob variant, so this path gets both optimisations and there
// is no duplicated decode/resize logic.
let results = tokio::task::spawn_blocking(move || {
// Single read: load file once into memory
let data =
std::fs::read(&path).map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
// Safety check: read dimensions from in-memory buffer (no 2nd I/O)
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
)));
}
// Full decode from the same in-memory buffer (no 2nd disk read)
let img = image::load_from_memory(&data)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
// Apply EXIF orientation so thumbnails display correctly
let img = {
use crate::infrastructure::services::exif_service::{
ExifService, apply_orientation,
};
let orientation = ExifService::extract(&data)
.and_then(|m| m.orientation)
.unwrap_or(1);
// Free the encoded image data now that image is decoded and EXIF extracted
drop(data);
apply_orientation(img, orientation)
};
let (orig_w, orig_h) = (img.width(), img.height());
ThumbnailSize::all()
.par_iter()
.map(|&size| {
let max_dim = size.max_dimension();
let (new_w, new_h) = if orig_w > orig_h {
let ratio = max_dim as f32 / orig_w as f32;
(max_dim, (orig_h as f32 * ratio) as u32)
} else {
let ratio = max_dim as f32 / orig_h as f32;
((orig_w as f32 * ratio) as u32, max_dim)
};
let filter = match size {
ThumbnailSize::Icon => FilterType::Triangle,
ThumbnailSize::Preview => FilterType::CatmullRom,
ThumbnailSize::Large => FilterType::CatmullRom,
};
let thumb = img.resize(new_w, new_h, filter);
let rgb = thumb.to_rgb8();
let mut buf = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut buf, 80);
rgb.write_with_encoder(encoder)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
Ok((size, Bytes::from(buf)))
})
.collect::<Result<Vec<_>, ThumbnailError>>()
Self::render_all_thumbnails_from_data(&data)
})
.await;