Images: count a decode's buffers at their peak before decoding in parallel

The gate that limits parallel decodes counted each image at its cached
size (4 bytes a pixel and a third more for the smaller levels). A decode
holds more than that while it converts the pixels: an 8-bit image keeps
its RGBA copy beside the decoded or the premultiplied pixels, 8 bytes a
pixel. Two 5,500 pixel square images passed the gate together and could
hold more than half the cache budget at once.

- decode_sizes returns the cached size and the peak size from one size
  read. The peak counts the samples as stored beside their converted
  copy (designcraft_images::decode_bytes_per_pixel): 8 bytes a pixel for
  8-bit color, 10 for 16-bit RGB, 12 for 16-bit CMYK TIFFs and 16-bit
  RGBA, 10 for 8-bit CMYKA TIFFs, 15 for 16-bit CMYKA TIFFs, 9 for
  PSDs. An EPS counts as its TIFF preview. PDF pages, SVGs and EPS
  files without a preview count 8.
- warm budgets the cache with the cached size and gates decodes with
  the peak size.
- Every warm call in the process shares one gate, so the canvas
  worker, thumbnails and exports together stay within half the budget
  while they decode. warm returns the most its own call had decoding.
- An EPS without a preview is sized at the larger of its drawn
  PostScript and its placeholder. The placeholder is clamped to 4000
  pixels on each side, so a 10,000 by 4,000 point EPS that is not drawn
  takes 4000 by 4000 pixels, not the 4000 by 1600 counted before.
This commit is contained in:
dotCooCoo committed 2026-11-12 13:53:38 -08:00
1 parent b9894b47c5
commit 2b6fb6fa06
6 files changed
+197 -33

No files matched your search

+1 -1
View File
@@ -98,7 +98,7 @@ pub fn eps_size(bytes: &[u8]) -> Option<(f64, f64)> {
((0.001..=20_000.0).contains(&w) && (0.001..=20_000.0).contains(&h)).then_some((w, h))
}
fn preview(bytes: &[u8]) -> Option<(&[u8], (u32, u32))> {
pub(crate) fn preview(bytes: &[u8]) -> Option<(&[u8], (u32, u32))> {
let (_, tiff) = sections(bytes);
let tiff = tiff?;
let (w, h) = crate::pixel_size(tiff)?;
+33
View File
@@ -442,6 +442,39 @@ pub fn decode_rgba(bytes: &[u8]) -> Option<image::RgbaImage> {
to_display_rgba(px, Some(&icc), &DisplayColour::active())
}
/// About how many bytes a pixel [`decode_rgba`] of `bytes` holds at once, with a premultiplied
/// copy of its result: the samples as stored beside the copy they are converted to, and at least
/// 8 (the RGBA result and its premultiplied copy). 16-bit CMYK TIFF samples are narrowed into an
/// 8-bit copy and CMYK alpha is split off into one, so a 16-bit CMYKA TIFF counts 15. An EPS
/// counts as its TIFF preview, or 8 for its placeholder or drawn PostScript.
pub fn decode_bytes_per_pixel(bytes: &[u8]) -> usize {
if is_eps(bytes) {
return eps::preview(bytes).map_or(8, |(tiff, _)| if is_eps(tiff) { 8 } else { decode_bytes_per_pixel(tiff) });
}
if is_psd(bytes) {
// RGBA-sized channels, an alpha plane, and the RGBA copy of CMYK inks.
return 9;
}
if let Some(mut d) = tiff_decoder(bytes) {
let cmyk = match d.colortype() {
Ok(tiff::ColorType::CMYK(bits)) => Some((4, bits)),
Ok(tiff::ColorType::CMYKA(bits)) => Some((5, bits)),
_ => None,
};
if let Some((samples, bits)) = cmyk {
let stored = samples * usize::from(bits).div_ceil(8);
let copy = if bits > 8 || samples == 5 { samples } else { 0 };
return (stored + copy).max(samples + 4).max(8);
}
}
let sample = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()
.and_then(|r| r.into_decoder().ok())
.map_or(4, |d| usize::from(image::ImageDecoder::color_type(&d).bytes_per_pixel()));
(sample + 4).max(8)
}
/// False when `bytes` is a WebP whose `ICCP` chunk declares more bytes than the file holds or than
/// [`colour::MAX_ICC_BYTES`]. The decoder sizes its buffer from the declared length before it reads
/// anything, so such a file must not reach `icc_profile()`; its pixels decode without it.
+26
View File
@@ -372,6 +372,32 @@ fn cmyk_tiff_keeps_inks() {
assert_eq!(decode_cmyk_tiff(&b[..b.len() / 2]), None, "truncated");
}
/// A decode holds the samples as stored beside the copy they are converted to.
#[test]
fn decode_bytes_per_pixel_counts_the_stored_samples_and_their_copy() {
use tiff::encoder::colortype::{CMYK8, CMYK16, CMYKA8, RGB16};
assert_eq!(decode_bytes_per_pixel(&tiff_of::<CMYK8>(1, 1, &[0; 4], false)), 8);
assert_eq!(decode_bytes_per_pixel(&tiff_of::<CMYKA8>(1, 1, &[0; 5], true)), 10);
assert_eq!(decode_bytes_per_pixel(&tiff_of::<CMYK16>(1, 1, &[0; 4], false)), 12);
assert_eq!(decode_bytes_per_pixel(&tiff_of::<RGB16>(1, 1, &[0; 3], false)), 10);
let mut psd = b"8BPS".to_vec();
psd.resize(26, 0);
assert_eq!(decode_bytes_per_pixel(&psd), 9);
assert_eq!(decode_bytes_per_pixel(b"not an image"), 8);
// An EPS counts as its TIFF preview, or 8 without one.
let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\nnewpath\n%%EOF\n";
assert_eq!(decode_bytes_per_pixel(ps), 8);
let preview = tiff_of::<CMYK16>(1, 1, &[0; 4], false);
let mut dos = vec![0xC5, 0xD0, 0xD3, 0xC6];
for v in [30, ps.len(), 0, 0, 30 + ps.len(), preview.len()] {
dos.extend((v as u32).to_le_bytes());
}
dos.extend([0xFF, 0xFF]);
dos.extend_from_slice(ps);
dos.extend_from_slice(&preview);
assert_eq!(decode_bytes_per_pixel(&dos), 12);
}
/// Indexed colour (PhotometricInterpretation 3), which the `tiff` encoder has no type for.
struct Palette<const BITS: u16>;
impl<const BITS: u16> tiff::encoder::colortype::ColorType for Palette<BITS> {
+26 -6
View File
@@ -12,18 +12,39 @@ const VECTOR_SIDE: u32 = 3000;
/// About how many bytes [`decode_pixmap_page`] of `bytes` takes, with its smaller levels (a third
/// more): PDF pages and SVGs at [`VECTOR_SIDE`] pixels on the longer side, an EPS without a
/// preview at up to 8 pixels per point and 4000 pixels on the longer side (as its PostScript is
/// drawn), other images and previews at their pixel size. 0 when the size can't be read.
/// drawn) or at its placeholder's size when that is larger, other images and previews at their
/// pixel size. 0 when the size can't be read.
#[cfg(test)]
pub(crate) fn decoded_bytes(bytes: &[u8], page: u32) -> usize {
decode_sizes(bytes, page).0
}
/// [`decoded_bytes`] of `bytes`, and about how many bytes [`decode_pixmap_page`] of it holds at
/// once while it runs: a raster as decoded and its RGBA copy, or that copy and the premultiplied
/// pixels, whichever is more ([`designcraft_images::decode_bytes_per_pixel`]: 8 bytes a pixel
/// for 8-bit color, 12 for 16-bit RGBA, an EPS as its TIFF preview). PDF pages, SVGs and EPS
/// files without a preview count 8 bytes a pixel. Both are 0 when the size can't be read.
pub(crate) fn decode_sizes(bytes: &[u8], page: u32) -> (usize, usize) {
let Some((w, h)) = drawn_size(bytes, page) else { return (0, 0) };
let raster = !is_pdf(bytes) && !designcraft_images::is_svg(bytes);
let per_pixel = if raster { designcraft_images::decode_bytes_per_pixel(bytes) } else { 8 } as f64;
((w * h * 16.0 / 3.0) as usize, (w * h * per_pixel) as usize)
}
/// The pixel size [`decode_pixmap_page`] of `bytes` draws at, as [`decoded_bytes`] describes it.
fn drawn_size(bytes: &[u8], page: u32) -> Option<(f64, f64)> {
let drawn = |(w, h): (f64, f64), side: f64, most: f64| {
let k = (side / w.max(h).max(1e-3)).min(most);
((w * k).round().max(1.0), (h * k).round().max(1.0))
};
let size = if designcraft_images::is_eps(bytes) {
if designcraft_images::is_eps(bytes) {
image_size(bytes).zip(designcraft_images::eps_size(bytes)).map(|((pw, ph), (w, h))| {
// Without a preview, the pixel size is the 2 pixels per point placeholder's.
// Without a preview, the pixel size is the 2 pixels per point placeholder's. The
// PostScript may be drawn instead, at another size: count the larger of the two.
let placeholder = ((w * 2.0).round().clamp(2.0, 4000.0), (h * 2.0).round().clamp(2.0, 4000.0));
let px = (pw as f64, ph as f64);
if px == placeholder { drawn((w, h), 4000.0, 8.0) } else { px }
let rendered = drawn((w, h), 4000.0, 8.0);
if px == placeholder && rendered.0 * rendered.1 > px.0 * px.1 { rendered } else { px }
})
} else if is_pdf(bytes) {
// As render_pdf_page scales: at most 8 times the page size.
@@ -32,8 +53,7 @@ pub(crate) fn decoded_bytes(bytes: &[u8], page: u32) -> usize {
designcraft_images::natural_size(bytes).map(|s| drawn(s, VECTOR_SIDE as f64, f64::INFINITY))
} else {
image_size(bytes).map(|(w, h)| (w as f64, h as f64))
};
size.map_or(0, |(w, h)| (w * h * 16.0 / 3.0) as usize)
}
}
/// [`decode_pixmap`] for page `page` of a PDF (other formats have one page).
+103 -26
View File
@@ -11,7 +11,7 @@ use std::sync::{Arc, Condvar, Mutex};
use vello_cpu::Pixmap;
use crate::decode::decoded_bytes;
use crate::decode::decode_sizes;
use crate::{decode_pixmap_page, halve};
/// Decoded pixels kept (bytes).
@@ -190,18 +190,20 @@ fn admits(in_flight: usize, size: usize) -> bool {
in_flight == 0 || in_flight.saturating_add(size) <= BUDGET / 2
}
/// Bytes decoding at once and the most there were, with the signal that some finished.
type Gate = (Mutex<(usize, usize)>, Condvar);
/// Bytes [`warm`] is decoding at once in every render in the process (canvas worker, thumbnails,
/// exports), with the signal that some finished.
static GATE: (Mutex<usize>, Condvar) = (Mutex::new(0), Condvar::new());
/// Takes a finished decode's bytes off its [`Gate`] when dropped.
struct Release<'a>(&'a Gate, usize);
/// Takes a finished decode's bytes off [`GATE`] and off its own call's count when dropped.
struct Release<'a>(&'a AtomicUsize, usize);
impl Drop for Release<'_> {
fn drop(&mut self) {
let mut g = self.0.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
g.0 = g.0.saturating_sub(self.1);
self.0.fetch_sub(self.1, Ordering::Relaxed);
let mut g = GATE.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
*g = g.saturating_sub(self.1);
drop(g);
self.0.1.notify_all();
GATE.1.notify_all();
}
}
@@ -210,8 +212,8 @@ impl Drop for Release<'_> {
/// of decoding them one after another. Does nothing when fewer than two images need decoding.
/// The images in view that are cached count against the cache budget first; a new image that
/// would take the total past what the cache keeps (by its pixel size) is left to the render.
/// Decodes wait while those in flight together would take more than half the budget. Returns
/// the most bytes (estimated) that were decoding at once.
/// Decodes wait while those in flight in every render together would take more than half the
/// budget. Returns the most bytes (estimated) this call had decoding at once.
pub(crate) fn warm(wants: Vec<Want>) -> usize {
let workers = workers();
if workers < 2 {
@@ -263,7 +265,7 @@ pub(crate) fn warm(wants: Vec<Want>) -> usize {
if held.contains_key(&key) {
continue;
}
let size = wanted.first().map_or(0, |w| decoded_bytes(&w.bytes, w.page));
let (size, decoding) = wanted.first().map_or((0, 0), |w| decode_sizes(&w.bytes, w.page));
// A store past the budget evicts down to three quarters of it: the images in view must
// fit in that, unless they and the rest fit in the budget.
let total = pixels.saturating_add(size);
@@ -271,29 +273,34 @@ pub(crate) fn warm(wants: Vec<Want>) -> usize {
continue;
}
pixels = total;
jobs.push((size, wanted));
jobs.push((decoding, wanted));
}
if jobs.len() < 2 {
return 0;
}
// While it converts the pixels, a decode holds buffers of about its size besides the cache:
// a decode waits while those in flight would take it past half the budget (one always runs).
let gate: Gate = (Mutex::new((0, 0)), Condvar::new());
let lock = || gate.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// While it converts the pixels, a decode holds the buffers `decode_sizes` counts: a
// decode waits while those in flight in any render would take it past half the budget (one
// always runs).
let lock = || GATE.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let next = AtomicUsize::new(0);
// This call's bytes decoding, and the most there were.
let (mine, peak) = (AtomicUsize::new(0), AtomicUsize::new(0));
std::thread::scope(|s| {
for _ in 0..workers.min(jobs.len()) {
let work = || {
while let Some((size, job)) = jobs.get(next.fetch_add(1, Ordering::Relaxed)) {
let mut g = lock();
while !admits(g.0, *size) {
g = gate.1.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner);
while !admits(*g, *size) {
#[cfg(test)]
waited(job);
g = GATE.1.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner);
}
g.0 += size;
g.1 = g.1.max(g.0);
*g = g.saturating_add(*size);
let now = mine.fetch_add(*size, Ordering::Relaxed).saturating_add(*size);
peak.fetch_max(now, Ordering::Relaxed);
drop(g);
// Released even if the decode panics, so no other decode waits forever.
let _release = Release(&gate, *size);
let _release = Release(&mine, *size);
for w in job {
let _ = mip(&w.bytes, w.page, w.width_pt, w.on_screen);
}
@@ -305,7 +312,19 @@ pub(crate) fn warm(wants: Vec<Want>) -> usize {
}
}
});
gate.0.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner).1
peak.into_inner()
}
/// The images whose decode waited at [`GATE`] (the address of the encoded bytes), with the
/// signal that one did.
#[cfg(test)]
static WAITED: (Mutex<Vec<usize>>, Condvar) = (Mutex::new(Vec::new()), Condvar::new());
#[cfg(test)]
fn waited(job: &[Want]) {
let id = job.first().map_or(0, |w| Arc::as_ptr(&w.bytes) as usize);
WAITED.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(id);
WAITED.1.notify_all();
}
/// Drop every decoded image.
@@ -320,6 +339,11 @@ pub fn clear() {
#[cfg(test)]
mod tests {
use super::*;
use crate::decode::decoded_bytes;
fn decode_peak_bytes(bytes: &[u8], page: u32) -> usize {
decode_sizes(bytes, page).1
}
#[test]
fn failed_assets_are_capped_and_oldest_is_dropped() {
@@ -403,21 +427,74 @@ mod tests {
#[test]
fn warm_decodes_large_images_one_at_a_time() {
// Two 7,000 pixel square images fit the budget together, but decoding both at once would
// Two 6,500 pixel square images fit the budget together, but decoding both at once would
// take more than half of it; the small image still decodes beside them.
let (d, e, small) = (psd(7000), psd(7000), png(8, 15));
let (d, e, small) = (psd(6500), psd(6500), png(8, 15));
let since = mark();
let large = decoded_bytes(&d, 0);
assert!(large > BUDGET / 4 && 2 * large <= BUDGET * 3 / 4);
assert!(admits(0, large) && !admits(large, large) && admits(large, decoded_bytes(&small, 0)));
let decoding = decode_peak_bytes(&d, 0);
assert!(admits(0, decoding) && !admits(decoding, decoding) && admits(decoding, decode_peak_bytes(&small, 0)));
assert!(admits(0, usize::MAX), "an image over half the budget still decodes alone");
let peak = warm(vec![want(&d), want(&e), want(&small)]);
assert!(peak <= BUDGET / 2, "{peak} bytes decoding at once");
assert!(peak >= large, "the large images were decoded ahead");
assert!(peak >= decoding, "the large images were decoded ahead");
let me = std::thread::current().id();
for img in [&d, &e, &small] {
let on = decoders(img, since);
assert!(on.len() == 1 && on[0] != me);
}
}
#[test]
fn warm_counts_a_decode_at_its_peak() {
// A decode holds the RGBA copy beside the decoded or the premultiplied pixels: two 5,500
// pixel square images would take less than half the budget cached, but more than half of
// it while both decode, so they decode one at a time.
let (f, g) = (psd(5500), psd(5500));
let decoding = decode_peak_bytes(&f, 0);
assert_eq!(decoding, 5500 * 5500 * 9, "9 bytes a pixel for a PSD");
assert!(2 * decoded_bytes(&f, 0) <= BUDGET / 2 && !admits(decoding, decode_peak_bytes(&g, 0)));
assert_eq!(warm(vec![want(&f), want(&g)]), decoding, "one decode at a time");
// 16-bit RGBA decodes at 8 bytes a pixel before its RGBA copy.
let mut deep = Vec::new();
let img = image::ImageBuffer::<image::Rgba<u16>, Vec<u16>>::from_pixel(4, 4, image::Rgba([9, 0, 0, 65535]));
img.write_to(&mut std::io::Cursor::new(&mut deep), image::ImageFormat::Png).unwrap();
assert_eq!(decode_peak_bytes(&deep, 0), 16 * 12);
assert_eq!(decode_peak_bytes(&png(8, 1), 0), 64 * 8);
}
#[test]
fn warm_waits_for_decodes_in_other_renders() {
// Another render's decodes take half the budget: this render's decodes wait for them.
let (a, b) = (png(8, 17), png(8, 19));
let ids = [Arc::as_ptr(&a) as usize, Arc::as_ptr(&b) as usize];
let (since, decoded_since) = (WAITED.0.lock().unwrap().len(), mark());
let other = BUDGET / 2;
*GATE.0.lock().unwrap() += other;
let done = std::sync::atomic::AtomicBool::new(false);
let (waited, peak) = std::thread::scope(|s| {
let render = s.spawn(|| {
let peak = warm(vec![want(&a), want(&b)]);
let w = WAITED.0.lock().unwrap();
done.store(true, Ordering::SeqCst);
drop(w);
WAITED.1.notify_all();
peak
});
let seen = |w: &Vec<usize>| w.iter().skip(since).any(|id| ids.contains(id));
let mut w = WAITED.0.lock().unwrap();
while !seen(&w) && !done.load(Ordering::SeqCst) {
w = WAITED.1.wait(w).unwrap();
}
let waited = seen(&w);
drop(w);
*GATE.0.lock().unwrap() -= other;
GATE.1.notify_all();
(waited, render.join().unwrap())
});
assert!(waited, "decoded beside the other render's decodes");
assert_eq!((decoders(&a, decoded_since).len(), decoders(&b, decoded_since).len()), (1, 1));
assert!(peak > 0 && peak <= BUDGET / 2, "{peak} bytes decoding at once");
}
}
+8
View File
@@ -792,6 +792,14 @@ fn decode_size_estimates_match_the_decodes() {
let drawn = pm.width() as usize * pm.height() as usize * 16 / 3;
assert!(drawn > 200 * 100 * 16 / 3, "drawn above 2 pixels per point: {}×{}", pm.width(), pm.height());
assert!(decode::decoded_bytes(eps, 0) >= drawn);
// A wide EPS whose PostScript isn't drawn keeps its placeholder, clamped to 4000 pixels on
// each side: it is budgeted at no less than that.
let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10000 4000\n%%EndComments\n0 0 moveto 10 10 lineto stroke\n%%EOF\n";
let pm = decode_pixmap_page(eps, 0).unwrap();
assert_eq!((pm.width(), pm.height()), (4000, 4000));
let px = pm.width() as usize * pm.height() as usize;
assert!(decode::decoded_bytes(eps, 0) >= px * 16 / 3);
assert!(decode::decode_sizes(eps, 0).1 >= px * 8);
}
#[test]