Browse / github.com/storytold/craft-libs / patch
cms: bound the transform cache by bytes and evict least recently used (#63)
craft-cms: bound the transform cache by size and evict least recently used `cms::cached` keeps transforms in a process-wide `HashMap` and clears all of them once more than 64 are cached (`crates/cms/src/transform.rs`). Two problems: - No byte limit. A transform's tables grow with the profiles (CMYK and LUT-based links are much larger than matrix/TRC ones), so 64 entries can hold far more memory than 64 small ones. - Clearing everything drops the transforms in use as well, so the next frames rebuild them all at once. Proposal: evict least recently used entries against an entry count and a byte budget, with the budget settable by the app. A `Transform::table_bytes()`
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Hamza0101 (imported from pull request #218, open)
- Base
1f805378d454dac48b5c214029d254055fdf961b- Size
- +214 −8 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
ec072f15ec722cadcd913775
How it went
No other operator has reported using it yet.
Diff
raw · crates/cms/src/clut.rs crates/cms/src/curve.rs crates/cms/src/lib.rs crates/cms/src/pipeline.rs crates/cms/src/transform.rs crates/cms/tests/cms.rs
@@ -23,6 +23,11 @@ pub struct Clut { } impl Clut {+ /// Heap bytes this table holds: the nodes and the index arithmetic beside them.+ pub fn table_bytes(&self) -> usize {+ self.data.capacity() * std::mem::size_of::<f32>() + (self.grid.capacity() + self.strides.capacity()) * std::mem::size_of::<usize>()+ }+ /// Builds a table from node data: `grid` holds the points per input dimension (1 to /// [`MAX_CHANNELS`] dimensions, each ≥ 2, or 1 for a degenerate dimension), `data` holds /// `outputs` values per node. Refuses inconsistent dimensions with@@ -26,6 +26,14 @@ pub fn srgb_trc() -> Curve { } impl Curve {+ /// Heap bytes this curve holds: the samples of a table, nothing for a function.+ pub fn table_bytes(&self) -> usize {+ match self {+ Curve::Table(t) => t.capacity() * std::mem::size_of::<f32>(),+ _ => 0,+ }+ }+ /// Number of parameters stored for a parametric function type. pub fn param_count(kind: u16) -> usize { match kind {@@ -35,7 +35,10 @@ pub use curve::Curve; pub use gamut::GamutCheck; pub use lut3d::Lut3d; pub use profile::{ColorSpace, Pcs, Profile, ProfileClass};-pub use transform::{SampleKind, Transform, TransformOptions, black_point, cached};+pub use transform::{+ DEFAULT_CACHE_BUDGET, MAX_CACHED, SampleKind, Transform, TransformOptions, black_point, cache_budget, cache_usage, cached, clear_cache,+ set_cache_budget,+}; /// ICC rendering intent. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]@@ -176,7 +176,25 @@ pub struct Pipeline { stages: Vec<Stage>, } +impl Stage {+ /// Heap bytes this stage holds: curve samples, matrix coefficients and table nodes.+ pub fn table_bytes(&self) -> usize {+ let curves = |cs: &Vec<Curve>| cs.capacity() * std::mem::size_of::<Curve>() + cs.iter().map(Curve::table_bytes).sum::<usize>();+ match self {+ Stage::Curves(cs) | Stage::InvCurves(cs) => curves(cs),+ Stage::Matrix { m, offset, .. } => (m.capacity() + offset.capacity()) * std::mem::size_of::<f64>(),+ Stage::Clut(c) => c.table_bytes(),+ _ => 0,+ }+ }+}+ impl Pipeline {+ /// Heap bytes the stages hold ([`Stage::table_bytes`]).+ pub fn table_bytes(&self) -> usize {+ self.stages.capacity() * std::mem::size_of::<Stage>() + self.stages.iter().map(Stage::table_bytes).sum::<usize>()+ }+ /// A pipeline over `inputs` channels (1 to [`MAX`]). Refuses stages that cannot run on the /// channels reaching them with [`CmsError::InvalidArgument`]. pub fn new(inputs: usize, stages: Vec<Stage>) -> Result<Self, CmsError> {@@ -2,6 +2,7 @@ //! compensation, soft-proofing chains, and fast lookup-table evaluation of pixel buffers. use std::collections::HashMap;+use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use crate::clut::{Clut, MAX_CHANNELS};@@ -106,6 +107,30 @@ struct White { } impl Transform {+ /// Heap bytes this transform's tables hold: the exact pipeline, the acceleration tables built+ /// from it (input curves or a device-link grid, the output inverse tables, the 8-bit grid+ /// coordinates) and the white it keeps. What the cache accounts for, since a CMYK or+ /// LUT-based link holds megabytes where a matrix/TRC one holds kilobytes.+ pub fn table_bytes(&self) -> usize {+ let f32s = |n: usize| n * std::mem::size_of::<f32>();+ let core = match &self.core {+ Core::Shaper { curves8, curves, matrix } => {+ curves8.capacity() * std::mem::size_of::<[f32; 256]>()+ + curves.capacity() * std::mem::size_of::<Vec<f32>>()+ + curves.iter().map(|c| f32s(c.capacity())).sum::<usize>()+ + matrix.as_ref().map_or(0, |(_, _, m, o)| f32s(m.capacity() + o.capacity()))+ }+ Core::Grid(c) => c.table_bytes(),+ Core::Exact => 0,+ };+ let post = self+ .post+ .as_ref()+ .map_or(0, |p| p.tables.capacity() * std::mem::size_of::<Vec<f32>>() + p.tables.iter().map(|t| f32s(t.capacity())).sum::<usize>());+ let white = self.white.as_ref().map_or(0, |w| f32s(w.src.capacity() + w.dst.capacity()) + w.src8.as_ref().map_or(0, Vec::capacity));+ self.pipeline.table_bytes() + core + post + self.grid8.capacity() * std::mem::size_of::<[(u32, f32); 256]>() + white+ }+ /// Transform from `src` to `dst` with `intent`, optionally with black point compensation. pub fn new(src: &Profile, dst: &Profile, intent: Intent, bpc: bool) -> Result<Transform, CmsError> { Self::with_options(src, dst, TransformOptions { intent, bpc, ..Default::default() })@@ -804,20 +829,109 @@ pub fn black_point(p: &Profile, intent: Intent, _input: bool) -> Result<f64, Cms type Key = (u64, u64, TransformOptions); +/// Transforms the cache keeps at most, whatever their size.+pub const MAX_CACHED: usize = 64;++/// Bytes of transform tables the cache keeps by default ([`set_cache_budget`]). A matrix/TRC link+/// holds a few hundred kilobytes and a CMYK or LUT-based one a few megabytes, so a count alone+/// bounds the cache by a factor of ten or more; this bounds the memory instead.+pub const DEFAULT_CACHE_BUDGET: usize = 64 << 20;++static BUDGET: AtomicUsize = AtomicUsize::new(DEFAULT_CACHE_BUDGET);++/// What the cache may hold, in bytes of transform tables ([`Transform::table_bytes`]). An app with+/// its own memory budget can lower this; 0 keeps only the transform last built.+pub fn set_cache_budget(bytes: usize) {+ BUDGET.store(bytes, Ordering::Relaxed);+ let cache = cache();+ let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());+ c.evict(bytes);+}++/// The byte budget in force.+pub fn cache_budget() -> usize {+ BUDGET.load(Ordering::Relaxed)+}++/// What the cache holds now: transforms, and bytes of their tables.+pub fn cache_usage() -> (usize, usize) {+ let cache = cache();+ let c = cache.lock().unwrap_or_else(|e| e.into_inner());+ (c.entries.len(), c.bytes)+}++/// Drop every cached transform (the memory goes as their last users do).+pub fn clear_cache() {+ let cache = cache();+ let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());+ c.entries.clear();+ c.bytes = 0;+}++struct Entry {+ transform: Arc<Transform>,+ bytes: usize,+ /// Value of the cache's counter when this transform was last asked for.+ used: u64,+}++#[derive(Default)]+struct Cache {+ entries: HashMap<Key, Entry>,+ bytes: usize,+ tick: u64,+}++impl Cache {+ /// Drop least recently used transforms until the cache is inside `budget` and+ /// [`MAX_CACHED`]. The transform just asked for is the most recently used, so it stays -- one+ /// is kept whatever the budget, since the caller holds it anyway and rebuilding it for the+ /// next frame would cost more than the bytes it holds.+ fn evict(&mut self, budget: usize) {+ while self.entries.len() > 1 && (self.bytes > budget || self.entries.len() > MAX_CACHED) {+ let Some(oldest) = self.entries.iter().min_by_key(|(_, e)| e.used).map(|(k, _)| *k) else { break };+ if let Some(e) = self.entries.remove(&oldest) {+ self.bytes = self.bytes.saturating_sub(e.bytes);+ }+ }+ }+}++fn cache() -> &'static Mutex<Cache> {+ static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();+ CACHE.get_or_init(Default::default)+}+ /// Process-wide cache of transforms keyed by profile content and options.+///+/// Least recently used transforms are dropped when the cache passes [`MAX_CACHED`] or its byte+/// budget ([`set_cache_budget`]). It used to clear itself whole, which dropped the transforms the+/// next frames were about to use along with the rest, and counted entries rather than bytes, so a+/// CMYK document's cache was an order of magnitude larger than an RGB one's (#63). pub fn cached(src: &Profile, dst: &Profile, opts: TransformOptions) -> Result<Arc<Transform>, CmsError> {- static CACHE: OnceLock<Mutex<HashMap<Key, Arc<Transform>>>> = OnceLock::new(); let key = (src.content_hash(), dst.content_hash(), opts);- let cache = CACHE.get_or_init(Default::default);- if let Some(t) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {- return Ok(t.clone());+ let cache = cache();+ {+ let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());+ c.tick += 1;+ let tick = c.tick;+ if let Some(e) = c.entries.get_mut(&key) {+ e.used = tick;+ return Ok(e.transform.clone());+ } }+ // Built without the lock held: another thread may want a different transform meanwhile, and+ // building one takes far longer than a lookup. let t = Arc::new(Transform::with_options(src, dst, opts)?);+ let bytes = t.table_bytes(); let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());- if c.len() > 64 {- c.clear();+ c.tick += 1;+ let used = c.tick;+ if let Some(old) = c.entries.insert(key, Entry { transform: t.clone(), bytes, used }) {+ c.bytes = c.bytes.saturating_sub(old.bytes); }- c.insert(key, t.clone());+ c.bytes += bytes;+ c.evict(BUDGET.load(Ordering::Relaxed)); Ok(t) } @@ -991,3 +991,61 @@ fn white_is_exact_on_every_path() { assert!(o[2] < 0.99, "{cmyk:?}: absolute paper {o:?}"); } }++/// #63: the transform cache counted entries and cleared itself whole. A CMYK or LUT-based link+/// holds megabytes where a matrix/TRC one holds kilobytes, so a count bounds the memory only to+/// within an order of magnitude, and clearing everything dropped the transforms the next frames+/// were about to use. It evicts the least recently used against a byte budget now.+#[test]+fn the_transform_cache_evicts_by_size_and_keeps_what_is_in_use() {+ use craft_cms::{cache_budget, cache_usage, cached, clear_cache, set_cache_budget};++ // What the issue is about: the two kinds of link are nothing like the same size.+ let rgb = Transform::new(srgb(), lab(), Intent::RelativeColorimetric, true).unwrap().table_bytes();+ let link = Transform::new(cmyk(), srgb(), Intent::Perceptual, true).unwrap().table_bytes();+ assert!(rgb > 0 && link > rgb * 4, "a CMYK link is far larger than a matrix/TRC one: {link} vs {rgb}");++ let budget = cache_budget();+ clear_cache();+ assert_eq!(cache_usage(), (0, 0));++ // A budget that holds one of these transforms and not two.+ set_cache_budget(link + rgb / 2);+ let opts = |intent| TransformOptions { intent, ..Default::default() };+ let first = cached(cmyk(), srgb(), opts(Intent::Perceptual)).unwrap();+ let (entries, bytes) = cache_usage();+ assert_eq!(entries, 1);+ assert_eq!(bytes, first.table_bytes());++ // The second does not fit beside the first, so the first goes -- and the caller's copy of it+ // is still usable, which is why eviction is safe.+ let second = cached(srgb(), lab(), opts(Intent::RelativeColorimetric)).unwrap();+ let (entries, bytes) = cache_usage();+ assert_eq!(entries, 1, "the older transform was evicted, not kept beside the new one");+ assert_eq!(bytes, second.table_bytes());+ let mut out = [0.0f32; 3];+ first.eval(&[0.1, 0.2, 0.3, 0.4], &mut out).unwrap();++ // Least recently used, not first inserted: asking for the older one again keeps it while the+ // other goes.+ set_cache_budget(rgb * 2 + link * 2);+ clear_cache();+ let a = cached(srgb(), lab(), opts(Intent::RelativeColorimetric)).unwrap();+ let b = cached(srgb(), lab(), opts(Intent::Saturation)).unwrap();+ let _ = (&a, &b);+ assert_eq!(cache_usage().0, 2);+ let _again = cached(srgb(), lab(), opts(Intent::RelativeColorimetric)).unwrap(); // a is now the newer+ set_cache_budget(cache_usage().1 - 1); // room for one+ assert_eq!(cache_usage().0, 1, "one was evicted");+ let kept = cached(srgb(), lab(), opts(Intent::RelativeColorimetric)).unwrap();+ assert_eq!(cache_usage().0, 1, "and it was the one asked for last, so this was a hit");+ assert!(std::sync::Arc::ptr_eq(&kept, &_again), "the same transform, not a rebuilt one");++ // A budget of nothing still keeps the transform last built: the caller holds it anyway.+ set_cache_budget(0);+ let only = cached(cmyk(), srgb(), opts(Intent::Perceptual)).unwrap();+ assert_eq!(cache_usage(), (1, only.table_bytes()));++ set_cache_budget(budget);+ clear_cache();+}