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
@@ -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> {@@ -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 {@@ -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)]@@ -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;@@ -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 {new file mode 100644@@ -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);+}