collective

Browse / github.com/storytold/craft-libs / patch

craft-cms: bound transform cache by size and evict least recently used (closes #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
zachshallbetter (imported from pull request #89, open)
Base
ae2f796b673f29cf3161f36eb6a8933f0a3fe028
Size
+508 −18 in 6 files
Review
pending
Shared
2026-10-28
Id
25474293f20b33d8018ceead

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/cache.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@@ -80,6 +80,11 @@ impl Clut {         self.data.len() / self.outputs     } +    /// Returns the approximate memory footprint in bytes of the lookup table data and grid metadata.+    pub fn table_bytes(&self) -> usize {+        self.data.len() * std::mem::size_of::<f32>() + (self.grid.len() + self.strides.len()) * std::mem::size_of::<usize>()+    }+     /// Builds a table by sampling `f` at every node (inputs normalised to `[0, 1]`); the     /// dimensions are checked as in [`Clut::new`].     pub fn sample(grid: Vec<usize>, outputs: usize, mut f: impl FnMut(&[f32], &mut [f32])) -> Result<Self, CmsError> {diff --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@@ -50,6 +50,14 @@ impl Curve {         }     } +    /// Approximate memory footprint in bytes of any sampled lookup table.+    pub fn table_bytes(&self) -> usize {+        match self {+            Curve::Table(t) => t.len() * std::mem::size_of::<f32>(),+            _ => 0,+        }+    }+     /// Evaluates the curve at `x` (f64 reference path).     pub fn eval64(&self, x: f64) -> f64 {         match self {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@@ -13,7 +13,7 @@ //! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas. //! //! Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications,-//! CIE 15, and Adobe's published black point compensation paper.+//! CIE 15, and the ISO 18619 black point compensation standard. #![forbid(unsafe_code)]  pub mod builtin;@@ -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::{+    CacheLimits, CacheStats, SampleKind, Transform, TransformCache, TransformOptions, black_point, cache_limits, cache_stats, cached, clear_cache,+    set_cache_limits,+};  /// 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@@ -77,6 +77,16 @@ impl Stage {         Stage::Matrix { rows: 3, cols: 3, m: m.iter().flatten().copied().collect(), offset: vec![0.0; 3] }     } +    /// Returns the approximate memory footprint in bytes of lookup tables and buffers owned by this stage.+    pub fn table_bytes(&self) -> usize {+        match self {+            Stage::Curves(cs) | Stage::InvCurves(cs) => cs.iter().map(Curve::table_bytes).sum(),+            Stage::Matrix { m, offset, .. } => (m.len() + offset.len()) * std::mem::size_of::<f64>(),+            Stage::Clut(c) => c.table_bytes(),+            _ => 0,+        }+    }+     #[inline]     fn run(&self, v: &mut [f32; MAX], n: usize) -> usize {         match self {@@ -198,6 +208,11 @@ impl Pipeline {         &self.stages     } +    /// Returns the approximate memory footprint in bytes of lookup tables and buffers in the pipeline stages.+    pub fn table_bytes(&self) -> usize {+        self.stages.iter().map(Stage::table_bytes).sum()+    }+     /// Appends stages, checked as in [`Pipeline::new`]; on error the pipeline is unchanged.     pub fn extend(&mut self, stages: impl IntoIterator<Item = Stage>) -> Result<(), CmsError> {         let mut n = self.outputs;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@@ -1,7 +1,7 @@ //! Colour transforms between profiles through the PCS, with rendering intents, black point //! compensation, soft-proofing chains, and fast lookup-table evaluation of pixel buffers. -use std::collections::HashMap;+use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex, OnceLock};  use crate::clut::{Clut, MAX_CHANNELS};@@ -270,6 +270,38 @@ impl Transform {     pub fn outputs(&self) -> usize {         self.outputs     }+    /// Returns the approximate memory footprint in bytes of the precomputed lookup tables, grids, and exact pipeline.+    pub fn table_bytes(&self) -> usize {+        let mut bytes = self.pipeline.table_bytes();+        match &self.core {+            Core::Shaper { curves8, curves, matrix } => {+                bytes += curves8.len() * 256 * std::mem::size_of::<f32>();+                for c in curves {+                    bytes += c.len() * std::mem::size_of::<f32>();+                }+                if let Some((_, _, m, b)) = matrix {+                    bytes += (m.len() + b.len()) * std::mem::size_of::<f32>();+                }+            }+            Core::Grid(clut) => {+                bytes += clut.table_bytes();+            }+            Core::Exact => {}+        }+        if let Some(post) = &self.post {+            for t in &post.tables {+                bytes += t.len() * std::mem::size_of::<f32>();+            }+        }+        bytes += self.grid8.len() * 256 * std::mem::size_of::<(u32, f32)>();+        if let Some(white) = &self.white {+            bytes += (white.src.len() + white.dst.len()) * std::mem::size_of::<f32>();+            if let Some(src8) = &white.src8 {+                bytes += src8.len();+            }+        }+        bytes+    }     pub fn options(&self) -> TransformOptions {         self.opts     }@@ -734,7 +766,7 @@ fn connection(src: &Profile, dst: &Profile, intent: Intent, bpc: bool) -> Result     if (bs - bd).abs() < 1e-6 {         return Ok(None);     }-    // Neutral black points (luminance only, as in Adobe's BPC), mapped linearly in XYZ so that+    // Neutral black points (luminance only, as in Adobe BPC), mapped linearly in XYZ so that     // the source black lands on the destination black and the white stays fixed.     let w = math::D50;     let mut scale = [1.0; 3];@@ -750,7 +782,7 @@ fn connection(src: &Profile, dst: &Profile, intent: Intent, bpc: bool) -> Result  /// Luminance (Y, D50-relative) of the profile's black point for `intent`. ///-/// Follows the structure of Adobe's published BPC algorithm: matrix/TRC profiles use device+/// Follows the structure of the Adobe BPC algorithm: matrix/TRC profiles use device /// zero; v4 LUT profiles in perceptual/saturation use the perceptual reference black; other LUT /// profiles use the darkest colour the device reaches (Lab 0 round-tripped through BToA/AToB, /// or device black for additive spaces). The destination-side curve fitting of the paper is@@ -802,23 +834,212 @@ pub fn black_point(p: &Profile, intent: Intent, _input: bool) -> Result<f64, Cms  // ------------------------------------------------------------ cache +/// Limits for a transform cache.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub struct CacheLimits {+    /// Maximum number of transform entries in the cache.+    pub max_entries: usize,+    /// Maximum total lookup table bytes allowed across all cached transforms.+    pub max_bytes: usize,+}++impl Default for CacheLimits {+    fn default() -> Self {+        Self { max_entries: 64, max_bytes: 64 * 1024 * 1024 }+    }+}++/// Statistics for a transform cache.+///+/// Note: `total_bytes` reflects the memory of lookup tables currently retained by entries+/// resident inside the cache. It does not measure memory retained by callers holding+/// `Arc<Transform>` handles after eviction.+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]+pub struct CacheStats {+    /// Current number of transform entries in the cache.+    pub entries: usize,+    /// Current total lookup table memory in bytes retained across all cached entries.+    pub total_bytes: usize,+}+ type Key = (u64, u64, TransformOptions); +struct CacheEntry {+    transform: Arc<Transform>,+    bytes: usize,+}++struct CacheState {+    entries: HashMap<Key, CacheEntry>,+    order: VecDeque<Key>,+    total_bytes: usize,+    limits: CacheLimits,+}++impl CacheState {+    fn new(limits: CacheLimits) -> Self {+        Self { entries: HashMap::new(), order: VecDeque::new(), total_bytes: 0, limits }+    }++    fn touch(&mut self, key: &Key) {+        if let Some(pos) = self.order.iter().position(|k| k == key) {+            self.order.remove(pos);+            self.order.push_back(*key);+        }+    }++    fn insert(&mut self, key: Key, transform: Arc<Transform>) {+        let bytes = transform.table_bytes();+        if bytes > self.limits.max_bytes {+            return;+        }+        if let Some(old) = self.entries.insert(key, CacheEntry { transform, bytes }) {+            self.total_bytes = self.total_bytes.saturating_sub(old.bytes);+            if let Some(pos) = self.order.iter().position(|k| k == &key) {+                self.order.remove(pos);+            }+        }+        self.total_bytes = self.total_bytes.saturating_add(bytes);+        self.order.push_back(key);+        self.evict();+    }++    fn evict(&mut self) {+        while self.entries.len() > self.limits.max_entries || (self.total_bytes > self.limits.max_bytes && !self.entries.is_empty()) {+            if let Some(oldest) = self.order.pop_front() {+                if let Some(removed) = self.entries.remove(&oldest) {+                    self.total_bytes = self.total_bytes.saturating_sub(removed.bytes);+                }+            } else {+                break;+            }+        }+    }++    fn set_limits(&mut self, limits: CacheLimits) {+        self.limits = limits;+        self.evict();+    }++    fn clear(&mut self) {+        self.entries.clear();+        self.order.clear();+        self.total_bytes = 0;+    }++    fn stats(&self) -> CacheStats {+        CacheStats { entries: self.entries.len(), total_bytes: self.total_bytes }+    }+}++/// A bounded LRU cache of colour transforms.+///+/// May be instantiated locally by applications to isolate specific workloads (e.g. background+/// exports or tile pipelines), or accessed through the shared process-wide instance via+/// [`TransformCache::global`] or [`cached`].+pub struct TransformCache {+    state: Mutex<CacheState>,+}++impl Default for TransformCache {+    fn default() -> Self {+        Self::new()+    }+}++impl TransformCache {+    /// Creates a new transform cache with default limits (64 entries, 64 MiB).+    pub fn new() -> Self {+        Self::with_limits(CacheLimits::default())+    }++    /// Creates a new transform cache with custom entry and byte limits.+    pub fn with_limits(limits: CacheLimits) -> Self {+        Self { state: Mutex::new(CacheState::new(limits)) }+    }++    /// Accesses the process-wide global transform cache.+    pub fn global() -> &'static Self {+        static GLOBAL: OnceLock<TransformCache> = OnceLock::new();+        GLOBAL.get_or_init(Self::new)+    }++    /// Retrieves an existing transform or computes and caches a new one.+    ///+    /// Transform compilation happens outside the cache lock to avoid blocking concurrent lookups.+    /// If multiple threads concurrently request the same uncached transform, each may compile it;+    /// the first completion is cached, subsequent completions return the cached transform, and all callers+    /// receive valid transforms.+    pub fn get(&self, src: &Profile, dst: &Profile, opts: TransformOptions) -> Result<Arc<Transform>, CmsError> {+        let key = (src.content_hash(), dst.content_hash(), opts);+        {+            let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+            if let Some(t) = state.entries.get(&key).map(|e| e.transform.clone()) {+                state.touch(&key);+                return Ok(t);+            }+        }+        let t = Arc::new(Transform::with_options(src, dst, opts)?);+        let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+        if let Some(t) = state.entries.get(&key).map(|e| e.transform.clone()) {+            state.touch(&key);+            return Ok(t);+        }+        state.insert(key, t.clone());+        Ok(t)+    }++    /// Sets entry and byte limits, evicting least recently used entries if needed.+    pub fn set_limits(&self, limits: CacheLimits) {+        let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+        state.set_limits(limits);+    }++    /// Returns the current cache limits.+    pub fn limits(&self) -> CacheLimits {+        let state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+        state.limits+    }++    /// Returns the current entry count and total lookup table memory in bytes.+    pub fn stats(&self) -> CacheStats {+        let state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+        state.stats()+    }++    /// Clears all entries from the cache.+    pub fn clear(&self) {+        let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+        state.clear();+    }+}++/// Sets the limits for the process-wide transform cache and evicts least recently used entries if needed.+pub fn set_cache_limits(limits: CacheLimits) {+    TransformCache::global().set_limits(limits);+}++/// Returns the current limits of the process-wide transform cache.+pub fn cache_limits() -> CacheLimits {+    TransformCache::global().limits()+}++/// Returns current statistics of the process-wide transform cache.+pub fn cache_stats() -> CacheStats {+    TransformCache::global().stats()+}++/// Clears all entries from the process-wide transform cache.+pub fn clear_cache() {+    TransformCache::global().clear();+}+ /// Process-wide cache of transforms keyed by profile content and options.+///+/// Retrieves an existing transform or precomputes and caches a new one, evicting+/// least recently used entries when limits are exceeded. 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 t = Arc::new(Transform::with_options(src, dst, opts)?);-    let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());-    if c.len() > 64 {-        c.clear();-    }-    c.insert(key, t.clone());-    Ok(t)+    TransformCache::global().get(src, dst, opts) }  impl Clut {diff --git a/crates/cms/tests/cache.rs b/crates/cms/tests/cache.rsnew file mode 100644--- /dev/null+++ b/crates/cms/tests/cache.rs@@ -0,0 +1,238 @@+use craft_cms::{+    Builtin, CacheLimits, Transform, TransformCache, TransformOptions, cache_limits, cache_stats, cached, clear_cache, set_cache_limits,+};++#[test]+fn test_table_bytes() {+    let srgb = Builtin::Srgb.profile();+    let p3 = Builtin::DisplayP3.profile();+    let opts = TransformOptions::default();++    let t = Transform::with_options(srgb, p3, opts).expect("transform");+    assert!(t.table_bytes() > 0);+}++#[test]+fn test_global_lru_cache() {+    clear_cache();+    let initial_limits = cache_limits();+    assert_eq!(cache_stats().entries, 0);+    assert_eq!(cache_stats().total_bytes, 0);++    let srgb = Builtin::Srgb.profile();+    let p3 = Builtin::DisplayP3.profile();+    let adobe = Builtin::AdobeRgbCompat.profile();+    let rec2020 = Builtin::Rec2020.profile();++    let opts = TransformOptions::default();++    // Set cache limit to 2 entries+    set_cache_limits(CacheLimits { max_entries: 2, max_bytes: 64 * 1024 * 1024 });++    // Insert entry 1: sRGB -> P3+    let _ = cached(srgb, p3, opts).expect("cached 1");+    assert_eq!(cache_stats().entries, 1);+    assert!(cache_stats().total_bytes > 0);++    // Insert entry 2: sRGB -> Adobe+    let _ = cached(srgb, adobe, opts).expect("cached 2");+    assert_eq!(cache_stats().entries, 2);++    // Touch entry 1 by querying it again (makes entry 2 the LRU)+    let _ = cached(srgb, p3, opts).expect("cached 1 hit");+    assert_eq!(cache_stats().entries, 2);++    // Insert entry 3: sRGB -> Rec2020 (should evict entry 2)+    let _ = cached(srgb, rec2020, opts).expect("cached 3");+    assert_eq!(cache_stats().entries, 2);++    // Reinsert entry 2 so cache has 3 resident transforms before applying byte limit+    set_cache_limits(CacheLimits { max_entries: 10, max_bytes: 64 * 1024 * 1024 });+    let _ = cached(srgb, adobe, opts).expect("cached 2 again");+    assert_eq!(cache_stats().entries, 3);++    // Byte budget eviction test: set limit so only 1 transform fits (evicts 2 entries)+    let single_bytes = cache_stats().total_bytes / 3;+    set_cache_limits(CacheLimits { max_entries: 10, max_bytes: single_bytes + 10 });+    assert_eq!(cache_stats().entries, 1);+    assert!(cache_stats().total_bytes <= single_bytes + 10);++    // Clear cache+    clear_cache();+    assert_eq!(cache_stats().entries, 0);+    assert_eq!(cache_stats().total_bytes, 0);++    // Restore initial limits+    set_cache_limits(initial_limits);+}++#[test]+fn test_isolated_transform_cache_instance() {+    let srgb = Builtin::Srgb.profile();+    let p3 = Builtin::DisplayP3.profile();+    let adobe = Builtin::AdobeRgbCompat.profile();+    let rec2020 = Builtin::Rec2020.profile();+    let opts = TransformOptions::default();++    // Isolated cache with 2-entry capacity+    let local_cache = TransformCache::with_limits(CacheLimits { max_entries: 2, max_bytes: 32 * 1024 * 1024 });+    assert_eq!(local_cache.stats().entries, 0);++    let _ = local_cache.get(srgb, p3, opts).expect("local 1");+    let _ = local_cache.get(srgb, adobe, opts).expect("local 2");+    assert_eq!(local_cache.stats().entries, 2);++    // Touch entry 1+    let _ = local_cache.get(srgb, p3, opts).expect("local 1 hit");++    // Insert entry 3 -> evicts entry 2+    let _ = local_cache.get(srgb, rec2020, opts).expect("local 3");+    assert_eq!(local_cache.stats().entries, 2);++    local_cache.clear();+    assert_eq!(local_cache.stats().entries, 0);+}++#[cfg(not(target_arch = "wasm32"))]+#[test]+fn test_concurrent_misses_and_contention() {+    use std::sync::Arc;+    use std::thread;++    let cache = Arc::new(TransformCache::with_limits(CacheLimits { max_entries: 4, max_bytes: 64 * 1024 * 1024 }));++    let srgb = Builtin::Srgb.profile();+    let p3 = Builtin::DisplayP3.profile();+    let adobe = Builtin::AdobeRgbCompat.profile();+    let rec2020 = Builtin::Rec2020.profile();+    let opts = TransformOptions::default();++    // 1. Concurrent misses on the same uncached transform+    let mut handles = Vec::new();+    for _ in 0..8 {+        let c = Arc::clone(&cache);+        handles.push(thread::spawn(move || {+            let t = c.get(srgb, p3, opts).expect("get transform concurrently");+            let mut out = [0.0f32; 3];+            t.eval(&[0.5, 0.5, 0.5], &mut out).expect("eval");+            assert!(out[0] > 0.0);+        }));+    }+    for h in handles {+        h.join().expect("thread join");+    }++    // Cache should contain exactly 1 entry despite concurrent misses+    assert_eq!(cache.stats().entries, 1);+    assert!(cache.stats().total_bytes > 0);++    // 2. High contention concurrent queries across multiple transforms with strict entry and byte bounds+    let tight_cache = Arc::new(TransformCache::with_limits(CacheLimits { max_entries: 2, max_bytes: 2 * 1024 * 1024 }));++    let mut contention_handles = Vec::new();+    let profiles = [srgb, p3, adobe, rec2020];++    for id in 0..16 {+        let c = Arc::clone(&tight_cache);+        let src = profiles[id % profiles.len()];+        let dst = profiles[(id + 1) % profiles.len()];+        contention_handles.push(thread::spawn(move || {+            for _ in 0..10 {+                let t = c.get(src, dst, opts).expect("contention get");+                let mut out = [0.0f32; 3];+                t.eval(&[0.2, 0.4, 0.6], &mut out).expect("eval under contention");+            }+        }));+    }++    for h in contention_handles {+        h.join().expect("contention thread join");+    }++    let stats = tight_cache.stats();+    assert!(stats.entries <= 2, "entries ({}) exceeded max_entries (2)", stats.entries);+    assert!(stats.total_bytes <= 2 * 1024 * 1024, "total_bytes ({}) exceeded max_bytes", stats.total_bytes);+}++fn make_lut16_profile_65() -> craft_cms::Profile {+    let mut lut = b"mft2\0\0\0\0".to_vec();+    lut.extend_from_slice(&[3, 3, 65, 0]);+    for k in 0..9 {+        let v: i32 = if k % 4 == 0 { 0x10000 } else { 0 };+        lut.extend_from_slice(&v.to_be_bytes());+    }+    lut.extend_from_slice(&[0, 2, 0, 2]);+    for _ in 0..3 {+        lut.extend_from_slice(&[0, 0, 0xff, 0xff]);+    }+    let clut_vals = 65usize * 65 * 65 * 3;+    lut.resize(lut.len() + clut_vals * 2, 0x80);+    for _ in 0..3 {+        lut.extend_from_slice(&[0, 0, 0xff, 0xff]);+    }++    let mut icc = vec![0u8; 128];+    icc[8] = 4;+    icc[12..16].copy_from_slice(b"mntr");+    icc[16..20].copy_from_slice(b"RGB ");+    icc[20..24].copy_from_slice(b"XYZ ");+    icc[36..40].copy_from_slice(b"acsp");+    icc.extend_from_slice(&1u32.to_be_bytes());+    icc.extend_from_slice(b"A2B1");+    icc.extend_from_slice(&144u32.to_be_bytes());+    icc.extend_from_slice(&(lut.len() as u32).to_be_bytes());+    icc.extend_from_slice(&lut);+    let size = icc.len() as u32;+    icc[..4].copy_from_slice(&size.to_be_bytes());++    craft_cms::Profile::parse(&icc).expect("valid profile with 65x65x65 lut16")+}++#[test]+fn test_retained_pipeline_clut_memory_accounting_and_eviction() {+    let src = make_lut16_profile_65();+    let dst = Builtin::Srgb.profile();+    let opts = TransformOptions::default();++    let transform = Transform::with_options(&src, dst, opts).expect("transform");+    let table_bytes = transform.table_bytes();++    // Verify accounting includes the retained 65^3 * 3 * 4 = 3,295,500 bytes CLUT from the pipeline+    assert!(table_bytes >= 3_295_500, "table_bytes ({table_bytes}) must account for retained pipeline CLUT data (at least 3,295,500 bytes)");++    // Cache with configured limit of 1 MiB (1,048,576 bytes) as reported by @euisuh+    let limit_bytes = 1_048_576;+    let cache = TransformCache::with_limits(CacheLimits { max_entries: 10, max_bytes: limit_bytes });++    // Insert a smaller transform first to verify oversized entries don't evict it+    let p3 = Builtin::DisplayP3.profile();+    let small_t = cache.get(dst, p3, opts).expect("small transform");+    let small_bytes = small_t.table_bytes();+    assert_eq!(cache.stats().entries, 1);+    assert_eq!(cache.stats().total_bytes, small_bytes);++    // The transform exceeds the 1 MiB budget (it is ~3.7 MiB)+    let t = cache.get(&src, dst, opts).expect("get transform");+    assert!(t.table_bytes() >= 3_295_500);++    // The oversized transform must not be cached, and the small resident entry must survive+    let stats = cache.stats();+    assert_eq!(stats.entries, 1, "oversized transform must not remain resident and must not evict smaller entries");+    assert_eq!(stats.total_bytes, small_bytes);++    // When cache byte budget is large enough to accommodate the transform (e.g. 8 MiB)+    cache.set_limits(CacheLimits { max_entries: 10, max_bytes: 8 * 1024 * 1024 });+    let t2 = cache.get(&src, dst, opts).expect("get transform with sufficient budget");+    let stats2 = cache.stats();+    assert_eq!(stats2.entries, 2);+    assert_eq!(stats2.total_bytes, small_bytes + t2.table_bytes());++    // Touch small_t so t2 is the LRU entry+    let _ = cache.get(dst, p3, opts).expect("touch small transform");++    // Reducing the limit back to 1 MiB should evict the oversized transform while keeping the small entry+    cache.set_limits(CacheLimits { max_entries: 10, max_bytes: limit_bytes });+    let stats3 = cache.stats();+    assert_eq!(stats3.entries, 1);+    assert_eq!(stats3.total_bytes, small_bytes);+}