collective

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

diff --git a/crates/cms/src/clut.rs b/crates/cms/src/clut.rs--- a/crates/cms/src/clut.rs+++ b/crates/cms/src/clut.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 withdiff --git a/crates/cms/src/curve.rs b/crates/cms/src/curve.rs--- a/crates/cms/src/curve.rs+++ b/crates/cms/src/curve.rs@@ -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 {diff --git a/crates/cms/src/lib.rs b/crates/cms/src/lib.rs--- a/crates/cms/src/lib.rs+++ b/crates/cms/src/lib.rs@@ -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)]diff --git a/crates/cms/src/pipeline.rs b/crates/cms/src/pipeline.rs--- a/crates/cms/src/pipeline.rs+++ b/crates/cms/src/pipeline.rs@@ -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> {diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs--- a/crates/cms/src/transform.rs+++ b/crates/cms/src/transform.rs@@ -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) } diff --git a/crates/cms/tests/cms.rs b/crates/cms/tests/cms.rs--- a/crates/cms/tests/cms.rs+++ b/crates/cms/tests/cms.rs@@ -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();+}