Skip to main content

wsi_rs/core/
cache.rs

1use lru::LruCache;
2use std::borrow::Borrow;
3use std::hash::Hash;
4use std::num::NonZeroUsize;
5use std::sync::{Arc, Mutex};
6
7use crate::core::types::{CpuTile, DatasetId};
8
9// ── TileCache (axis-aware) ────────────────────────────────────────
10
11/// Default shared decoded tile cache.
12///
13/// Standard Aperio SVS JPEG tiles are commonly 240x240 RGB, or about 170 KiB
14/// per decoded tile. A 64 MiB budget keeps a few hundred such source tiles
15/// resident, which is enough for normal viewport overlap during quick zooms
16/// without forcing users to tune cache options before the viewer is usable.
17pub(crate) const DEFAULT_TILE_CACHE_SIZE: u64 = 64 * 1024 * 1024;
18const TILE_CACHE_BYTES_ENV: &str = "WSI_RS_TILE_CACHE_BYTES";
19/// Default display-tile cache.
20///
21/// Display-tile reads on regular tiled slides cache the decoded source tiles
22/// used for composition. Keep enough room for at least a dense viewport plus
23/// adjacent zoom/pan overlap; 1 MiB only held a handful of SVS tiles and caused
24/// immediate churn during zoom-out bursts.
25pub(crate) const DEFAULT_DISPLAY_TILE_CACHE_SIZE: u64 = 32 * 1024 * 1024;
26const DISPLAY_TILE_CACHE_BYTES_ENV: &str = "WSI_RS_DISPLAY_TILE_CACHE_BYTES";
27// Count-bounded private LRUs still allocate per-entry bookkeeping. Include a
28// conservative floor so tiny source tiles cannot turn a byte budget into an
29// enormous hash-table capacity at open time.
30const PRIVATE_CACHE_ENTRY_ACCOUNTING_FLOOR_BYTES: u64 = 256;
31// Private per-format caches supplement the public shared tile cache. Bound
32// their aggregate entry capacity to one quarter of the configured shared
33// cache so opening a slide cannot multiply the caller's byte policy.
34const PRIVATE_CACHE_BUDGET_DIVISOR: u64 = 4;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[non_exhaustive]
38pub struct CacheConfig {
39    pub shared_tile_bytes: Option<u64>,
40    pub display_tile_bytes: Option<u64>,
41}
42
43impl CacheConfig {
44    pub const fn deterministic() -> Self {
45        Self {
46            shared_tile_bytes: None,
47            display_tile_bytes: None,
48        }
49    }
50
51    pub const fn with_shared_tile_bytes(mut self, bytes: u64) -> Self {
52        self.shared_tile_bytes = Some(bytes);
53        self
54    }
55
56    pub const fn with_display_tile_bytes(mut self, bytes: u64) -> Self {
57        self.display_tile_bytes = Some(bytes);
58        self
59    }
60
61    pub(crate) fn shared_tile_budget(self, source_hint: Option<u64>) -> u64 {
62        self.shared_tile_bytes
63            .or(source_hint)
64            .unwrap_or(DEFAULT_TILE_CACHE_SIZE)
65    }
66
67    pub(crate) fn display_tile_budget(self) -> u64 {
68        self.display_tile_bytes
69            .unwrap_or(DEFAULT_DISPLAY_TILE_CACHE_SIZE)
70    }
71
72    pub(crate) fn private_cache_budget(self, cache_count: usize) -> PrivateCacheBudget {
73        PrivateCacheBudget {
74            remaining_bytes: self.private_cache_budget_bytes(),
75            remaining_caches: cache_count,
76        }
77    }
78
79    pub(crate) fn private_cache_budget_bytes(self) -> u64 {
80        self.shared_tile_budget(None) / PRIVATE_CACHE_BUDGET_DIVISOR
81    }
82}
83
84impl Default for CacheConfig {
85    fn default() -> Self {
86        Self::deterministic()
87    }
88}
89
90/// One slide's aggregate budget for count-bounded format-private caches.
91///
92/// Allocations are made against estimated retained bytes (including a floor
93/// for LRU bookkeeping). A cache receives zero entries when the remaining
94/// budget cannot account for one entry, avoiding eager hash-table allocation.
95pub(crate) struct PrivateCacheBudget {
96    remaining_bytes: u64,
97    remaining_caches: usize,
98}
99
100impl PrivateCacheBudget {
101    pub(crate) fn allocate(&mut self, estimated_entry_bytes: u64) -> PrivateCacheCapacity {
102        if self.remaining_caches == 0 {
103            return PrivateCacheCapacity::default();
104        }
105
106        let cache_count = self.remaining_caches as u64;
107        self.remaining_caches -= 1;
108        let accounted_entry_bytes =
109            estimated_entry_bytes.max(PRIVATE_CACHE_ENTRY_ACCOUNTING_FLOOR_BYTES);
110        let fair_share = self.remaining_bytes / cache_count;
111        let mut entries = fair_share / accounted_entry_bytes;
112        if entries == 0 && self.remaining_bytes >= accounted_entry_bytes {
113            entries = 1;
114        }
115        entries = entries.min(usize::MAX as u64);
116        let accounted_bytes = entries
117            .checked_mul(accounted_entry_bytes)
118            .unwrap_or(self.remaining_bytes)
119            .min(self.remaining_bytes);
120        self.remaining_bytes -= accounted_bytes;
121
122        PrivateCacheCapacity {
123            entries: entries as usize,
124            accounted_bytes,
125        }
126    }
127}
128
129#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
130pub(crate) struct PrivateCacheCapacity {
131    entries: usize,
132    accounted_bytes: u64,
133}
134
135/// A count-bounded private LRU that can be completely disabled.
136///
137/// `lru::LruCache::new` preallocates for its entry capacity and requires a
138/// non-zero value. Keeping the disabled state as `None` makes a zero-budget
139/// cache allocation-free and causes inserts to be ignored.
140#[derive(Debug)]
141pub(crate) struct PrivateCache<K: Hash + Eq, V> {
142    lru: Option<LruCache<K, V>>,
143    capacity: PrivateCacheCapacity,
144}
145
146impl<K: Hash + Eq, V> PrivateCache<K, V> {
147    pub(crate) fn new(capacity: PrivateCacheCapacity) -> Self {
148        let lru = NonZeroUsize::new(capacity.entries).map(LruCache::new);
149        Self { lru, capacity }
150    }
151
152    pub(crate) fn get<'a, Q>(&'a mut self, key: &Q) -> Option<&'a V>
153    where
154        K: Borrow<Q>,
155        Q: Hash + Eq + ?Sized,
156    {
157        self.lru.as_mut()?.get(key)
158    }
159
160    pub(crate) fn put(&mut self, key: K, value: V) {
161        if let Some(lru) = &mut self.lru {
162            lru.put(key, value);
163        }
164    }
165
166    pub(crate) fn capacity_entries(&self) -> usize {
167        self.capacity.entries
168    }
169
170    #[cfg(test)]
171    pub(crate) fn accounted_capacity_bytes(&self) -> u64 {
172        self.capacity.accounted_bytes
173    }
174
175    #[cfg(test)]
176    pub(crate) fn len(&self) -> usize {
177        self.lru.as_ref().map_or(0, LruCache::len)
178    }
179}
180
181#[derive(Hash, Eq, PartialEq, Clone, Debug)]
182/// Note: scene/series are u32 here (not usize) to keep CacheKey compact and
183/// Hash-friendly. TileRequest/RegionRequest use usize for ergonomic indexing.
184/// Slide converts usize → u32 via `as u32` when constructing cache keys.
185/// Overflow is not a practical concern (>4B scenes/series is impossible).
186pub struct CacheKey {
187    pub(crate) dataset_id: DatasetId,
188    pub(crate) scene: u32,
189    pub(crate) series: u32,
190    pub(crate) level: u32,
191    pub(crate) z: u32,
192    pub(crate) c: u32,
193    pub(crate) t: u32,
194    pub(crate) tile_col: i64,
195    pub(crate) tile_row: i64,
196}
197
198/// Thread-safe, byte-bounded decoded tile cache that can be shared by slides.
199pub struct TileCache {
200    inner: Mutex<TileCacheState>,
201}
202
203/// Snapshot of byte-sized decoded tile cache activity.
204#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
205pub struct TileCacheStats {
206    /// Successful lookups.
207    pub hits: u64,
208    /// Unsuccessful lookups.
209    pub misses: u64,
210    /// Entries admitted to the cache.
211    pub puts: u64,
212    /// Entries removed to remain within the byte capacity.
213    pub evictions: u64,
214    /// Entries rejected because one value exceeded the whole capacity.
215    pub rejected_oversize: u64,
216    /// Configured byte capacity.
217    pub capacity_bytes: u64,
218    /// Bytes currently retained by cached entries.
219    pub current_bytes: u64,
220    /// Number of entries currently retained.
221    pub entries: usize,
222}
223
224impl std::fmt::Debug for TileCache {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        let state = self.inner.lock().unwrap_or_else(|e| e.into_inner());
227        f.debug_struct("TileCache")
228            .field("capacity_bytes", &state.capacity_bytes)
229            .field("current_bytes", &state.current_bytes)
230            .field("entries", &state.lru.len())
231            .field("hits", &state.hits)
232            .field("misses", &state.misses)
233            .finish()
234    }
235}
236
237struct TileCacheState {
238    lru: LruCache<CacheKey, CachedTile>,
239    capacity_bytes: u64,
240    current_bytes: u64,
241    hits: u64,
242    misses: u64,
243    puts: u64,
244    evictions: u64,
245    rejected_oversize: u64,
246}
247
248struct CachedTile {
249    data: Arc<CpuTile>,
250    byte_size: u64,
251}
252
253impl TileCache {
254    /// Create a thread-safe decoded tile cache with a byte capacity.
255    pub fn new(capacity_bytes: u64) -> Self {
256        Self {
257            inner: Mutex::new(TileCacheState {
258                // The cache is byte-budgeted only. The backing LRU stays unbounded
259                // and eviction is driven by `capacity_bytes`.
260                lru: LruCache::unbounded(),
261                capacity_bytes,
262                current_bytes: 0,
263                hits: 0,
264                misses: 0,
265                puts: 0,
266                evictions: 0,
267                rejected_oversize: 0,
268            }),
269        }
270    }
271
272    pub(crate) fn put(&self, key: CacheKey, data: Arc<CpuTile>) {
273        let byte_size = data.data.byte_size() as u64;
274        let mut state = self.inner.lock().unwrap_or_else(|e| e.into_inner());
275
276        if byte_size > state.capacity_bytes {
277            state.rejected_oversize += 1;
278            return;
279        }
280
281        // Remove existing entry if present
282        if let Some((_, existing)) = state.lru.pop_entry(&key) {
283            state.current_bytes -= existing.byte_size;
284        }
285
286        // Evict LRU entries until there's room
287        while state.current_bytes + byte_size > state.capacity_bytes {
288            if let Some((_, evicted)) = state.lru.pop_lru() {
289                state.current_bytes -= evicted.byte_size;
290                state.evictions += 1;
291            } else {
292                break;
293            }
294        }
295
296        state.lru.put(key, CachedTile { data, byte_size });
297        state.current_bytes += byte_size;
298        state.puts += 1;
299    }
300
301    pub(crate) fn get(&self, key: &CacheKey) -> Option<Arc<CpuTile>> {
302        let mut state = self.inner.lock().unwrap_or_else(|e| e.into_inner());
303        let cached = state.lru.get(key).map(|entry| entry.data.clone());
304        if cached.is_some() {
305            state.hits += 1;
306        } else {
307            state.misses += 1;
308        }
309        cached
310    }
311
312    /// Return an atomic snapshot of cache capacity and activity counters.
313    pub fn stats(&self) -> TileCacheStats {
314        let state = self.inner.lock().unwrap_or_else(|e| e.into_inner());
315        TileCacheStats {
316            hits: state.hits,
317            misses: state.misses,
318            puts: state.puts,
319            evictions: state.evictions,
320            rejected_oversize: state.rejected_oversize,
321            capacity_bytes: state.capacity_bytes,
322            current_bytes: state.current_bytes,
323            entries: state.lru.len(),
324        }
325    }
326
327    pub(crate) fn display_default() -> Self {
328        Self::new(capacity_from_env(
329            DISPLAY_TILE_CACHE_BYTES_ENV,
330            DEFAULT_DISPLAY_TILE_CACHE_SIZE,
331        ))
332    }
333
334    pub(crate) fn display_with_config(config: CacheConfig) -> Self {
335        Self::new(config.display_tile_budget())
336    }
337
338    pub(crate) fn shared_default_with_hint(default_bytes: u64) -> Self {
339        Self::new(capacity_from_env(TILE_CACHE_BYTES_ENV, default_bytes))
340    }
341
342    pub(crate) fn shared_with_config(config: CacheConfig, source_hint: Option<u64>) -> Self {
343        Self::new(config.shared_tile_budget(source_hint))
344    }
345}
346
347impl Default for TileCache {
348    fn default() -> Self {
349        Self::shared_default_with_hint(DEFAULT_TILE_CACHE_SIZE)
350    }
351}
352
353fn capacity_from_env(env_name: &str, default_bytes: u64) -> u64 {
354    std::env::var(env_name)
355        .ok()
356        .and_then(|value| value.parse::<u64>().ok())
357        .filter(|bytes| *bytes > 0)
358        .unwrap_or(default_bytes)
359}
360
361#[cfg(test)]
362mod tile_cache_tests {
363    use super::*;
364    use crate::core::types::*;
365
366    const SVS_RGB_240_TILE_BYTES: usize = 240 * 240 * 3;
367    const COMMON_ZOOM_VIEWPORT_TILE_COUNT: i64 = 96;
368
369    fn make_sample_buffer(size: usize) -> CpuTile {
370        CpuTile {
371            width: 256,
372            height: 256,
373            channels: 3,
374            color_space: ColorSpace::Rgb,
375            layout: CpuTileLayout::Interleaved,
376            data: CpuTileData::u8(vec![0u8; size]),
377        }
378    }
379
380    fn make_key(dataset_id: u128, level: u32, col: i64, row: i64) -> CacheKey {
381        CacheKey {
382            dataset_id: DatasetId::new(dataset_id),
383            scene: 0,
384            series: 0,
385            level,
386            z: 0,
387            c: 0,
388            t: 0,
389            tile_col: col,
390            tile_row: row,
391        }
392    }
393
394    #[test]
395    fn private_cache_budget_bounds_aggregate_capacity_and_disables_excess_caches() {
396        let config = CacheConfig::deterministic().with_shared_tile_bytes(8 * 1024);
397        let mut budget = config.private_cache_budget(8);
398        let mut caches = (0..8)
399            .map(|_| PrivateCache::<u32, u32>::new(budget.allocate(1024)))
400            .collect::<Vec<_>>();
401
402        assert!(
403            caches
404                .iter()
405                .map(PrivateCache::accounted_capacity_bytes)
406                .sum::<u64>()
407                <= config.private_cache_budget_bytes()
408        );
409        assert_eq!(
410            caches
411                .iter()
412                .map(PrivateCache::capacity_entries)
413                .sum::<usize>(),
414            2
415        );
416        assert_eq!(
417            caches
418                .iter()
419                .filter(|cache| cache.capacity_entries() == 0)
420                .count(),
421            6
422        );
423
424        let disabled = caches
425            .iter_mut()
426            .find(|cache| cache.capacity_entries() == 0)
427            .expect("small aggregate budget disables at least one cache");
428        disabled.put(1, 2);
429        assert_eq!(disabled.len(), 0, "disabled caches retain no entries");
430    }
431
432    #[test]
433    fn put_and_get() {
434        let cache = TileCache::new(1024 * 1024);
435        let buf = Arc::new(make_sample_buffer(100));
436        let key = make_key(1, 0, 0, 0);
437        cache.put(key.clone(), buf.clone());
438        let result = cache.get(&key).unwrap();
439        assert_eq!(result.width, 256);
440    }
441
442    #[test]
443    fn miss_returns_none() {
444        let cache = TileCache::new(1024);
445        let key = make_key(1, 0, 0, 0);
446        assert!(cache.get(&key).is_none());
447    }
448
449    #[test]
450    fn eviction_by_byte_size() {
451        let cache = TileCache::new(250);
452        cache.put(make_key(1, 0, 0, 0), Arc::new(make_sample_buffer(100)));
453        cache.put(make_key(1, 0, 1, 0), Arc::new(make_sample_buffer(100)));
454        // Both fit: 200 bytes
455        assert!(cache.get(&make_key(1, 0, 0, 0)).is_some());
456        assert!(cache.get(&make_key(1, 0, 1, 0)).is_some());
457
458        // Third pushes over 250
459        cache.put(make_key(1, 0, 2, 0), Arc::new(make_sample_buffer(100)));
460        assert!(cache.get(&make_key(1, 0, 0, 0)).is_none()); // evicted
461        assert!(cache.get(&make_key(1, 0, 1, 0)).is_some());
462        assert!(cache.get(&make_key(1, 0, 2, 0)).is_some());
463    }
464
465    #[test]
466    fn different_datasets_are_independent() {
467        let cache = TileCache::new(1024);
468        cache.put(make_key(1, 0, 0, 0), Arc::new(make_sample_buffer(10)));
469        cache.put(make_key(2, 0, 0, 0), Arc::new(make_sample_buffer(10)));
470        assert!(cache.get(&make_key(1, 0, 0, 0)).is_some());
471        assert!(cache.get(&make_key(2, 0, 0, 0)).is_some());
472    }
473
474    #[test]
475    fn axis_aware_keys() {
476        let cache = TileCache::new(1024);
477        let mut key_z0 = make_key(1, 0, 0, 0);
478        key_z0.z = 0;
479        let mut key_z1 = make_key(1, 0, 0, 0);
480        key_z1.z = 1;
481        cache.put(key_z0.clone(), Arc::new(make_sample_buffer(10)));
482        cache.put(key_z1.clone(), Arc::new(make_sample_buffer(10)));
483        assert!(cache.get(&key_z0).is_some());
484        assert!(cache.get(&key_z1).is_some());
485    }
486
487    #[test]
488    fn oversize_entry_rejected() {
489        let cache = TileCache::new(50);
490        cache.put(make_key(1, 0, 0, 0), Arc::new(make_sample_buffer(100)));
491        assert!(cache.get(&make_key(1, 0, 0, 0)).is_none());
492    }
493
494    #[test]
495    fn shared_across_threads() {
496        let cache = Arc::new(TileCache::new(4096));
497        let cache_clone = cache.clone();
498        let handle = std::thread::spawn(move || {
499            cache_clone.put(make_key(1, 0, 5, 5), Arc::new(make_sample_buffer(10)));
500        });
501        handle.join().unwrap();
502        assert!(cache.get(&make_key(1, 0, 5, 5)).is_some());
503    }
504
505    #[test]
506    fn display_default_holds_common_svs_zoom_viewport_working_set() {
507        let cache = TileCache::new(DEFAULT_DISPLAY_TILE_CACHE_SIZE);
508        for col in 0..COMMON_ZOOM_VIEWPORT_TILE_COUNT {
509            cache.put(
510                make_key(1, 0, col, 0),
511                Arc::new(make_sample_buffer(SVS_RGB_240_TILE_BYTES)),
512            );
513        }
514
515        let stats = cache.stats();
516        assert_eq!(stats.entries, COMMON_ZOOM_VIEWPORT_TILE_COUNT as usize);
517        assert_eq!(stats.evictions, 0);
518        assert_eq!(stats.rejected_oversize, 0);
519    }
520
521    #[test]
522    fn stats_count_hits_misses_puts_evictions_and_oversize_rejections() {
523        let cache = TileCache::new(150);
524        let missing = make_key(1, 0, 9, 9);
525        assert!(cache.get(&missing).is_none());
526
527        cache.put(make_key(1, 0, 0, 0), Arc::new(make_sample_buffer(100)));
528        assert!(cache.get(&make_key(1, 0, 0, 0)).is_some());
529
530        cache.put(make_key(1, 0, 1, 0), Arc::new(make_sample_buffer(100)));
531        cache.put(make_key(1, 0, 2, 0), Arc::new(make_sample_buffer(200)));
532
533        let stats = cache.stats();
534        assert_eq!(stats.hits, 1);
535        assert_eq!(stats.misses, 1);
536        assert_eq!(stats.puts, 2);
537        assert_eq!(stats.evictions, 1);
538        assert_eq!(stats.rejected_oversize, 1);
539        assert_eq!(stats.capacity_bytes, 150);
540        assert_eq!(stats.current_bytes, 100);
541        assert_eq!(stats.entries, 1);
542    }
543}