Skip to main content

otf_pixels_core/
cache.rs

1//! The byte-budgeted tile cache.
2//!
3//! Intermediates are keyed by `(node, region)` (ARCHITECTURE §Layer 4) so that
4//! a tile computed for one consumer can be reused by another: graph branches
5//! sharing a prefix, and spatial ops whose input regions overlap. Without it,
6//! a diamond-shaped graph recomputes its shared prefix once per branch.
7//!
8//! # Eviction bounds retention, not liveness
9//!
10//! Entries are `Arc<TileBuf>`. Evicting one drops *the cache's* reference; a
11//! caller already holding the `Arc` keeps its tile alive and valid. That makes
12//! eviction free of correctness hazards — there is nothing to pin, and a task
13//! can never have its input yanked mid-computation.
14//!
15//! The consequence is that [`TileCache::budget`] bounds what the cache
16//! **retains**, not total live tile memory. Callers holding many tiles at once
17//! exceed it by construction; bounding *that* is the scheduler's job, by
18//! limiting how much work is in flight.
19
20use crate::{NodeId, Region, TileBuf};
21use std::collections::{BTreeMap, HashMap};
22use std::sync::Mutex;
23
24/// Identifies one cached intermediate: a region of one node's output.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub struct TileKey {
27    /// The graph node that produced the tile.
28    pub node: NodeId,
29    /// The region of that node's output the tile covers.
30    pub region: Region,
31}
32
33impl TileKey {
34    /// A key for `region` of `node`'s output.
35    #[must_use]
36    pub const fn new(node: NodeId, region: Region) -> Self {
37        Self { node, region }
38    }
39}
40
41/// Counters describing cache behaviour, for tests and diagnostics.
42///
43/// These are observations, not API guarantees: eviction policy details are
44/// explicitly internal (ARCHITECTURE §Layer 4).
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46#[non_exhaustive]
47pub struct CacheStats {
48    /// Lookups that found a retained tile.
49    pub hits: u64,
50    /// Lookups that found nothing.
51    pub misses: u64,
52    /// Tiles dropped to stay within budget.
53    pub evictions: u64,
54    /// Tiles offered to the cache.
55    pub insertions: u64,
56    /// Tiles not retained because one alone exceeded the whole budget.
57    pub rejections: u64,
58}
59
60impl CacheStats {
61    /// Hit rate over all lookups, or [`None`] if there were none.
62    #[must_use]
63    pub fn hit_rate(&self) -> Option<f64> {
64        let total = self.hits + self.misses;
65        (total > 0).then(|| self.hits as f64 / total as f64)
66    }
67}
68
69/// One retained tile and its recency tick.
70#[derive(Debug)]
71struct Entry {
72    tile: std::sync::Arc<TileBuf>,
73    bytes: usize,
74    tick: u64,
75}
76
77/// The cache's mutable state, behind one lock.
78#[derive(Debug)]
79struct Inner {
80    entries: HashMap<TileKey, Entry>,
81    /// Recency index: tick → key, ordered oldest-first. Kept in step with
82    /// `entries`, so the least-recently-used key is always the first entry.
83    recency: BTreeMap<u64, TileKey>,
84    bytes: usize,
85    next_tick: u64,
86    stats: CacheStats,
87}
88
89/// A byte-budgeted LRU cache of graph intermediates.
90///
91/// Shared across scheduler threads; all methods take `&self`.
92#[derive(Debug)]
93pub struct TileCache {
94    inner: Mutex<Inner>,
95    budget: usize,
96}
97
98impl TileCache {
99    /// The default retention budget: 64 MiB.
100    pub const DEFAULT_BUDGET: usize = 64 * 1024 * 1024;
101
102    /// A cache retaining at most `budget` bytes of tiles.
103    ///
104    /// A budget of zero disables retention entirely: every lookup misses,
105    /// which is a useful way to test that a pipeline is correct without any
106    /// caching at all.
107    #[must_use]
108    pub fn new(budget: usize) -> Self {
109        Self {
110            inner: Mutex::new(Inner {
111                entries: HashMap::new(),
112                recency: BTreeMap::new(),
113                bytes: 0,
114                next_tick: 0,
115                stats: CacheStats::default(),
116            }),
117            budget,
118        }
119    }
120
121    /// The retention budget in bytes.
122    #[must_use]
123    pub const fn budget(&self) -> usize {
124        self.budget
125    }
126
127    /// Look up a tile, marking it most-recently-used on a hit.
128    ///
129    /// The returned [`Arc`] keeps the tile alive regardless of later eviction.
130    ///
131    /// [`Arc`]: std::sync::Arc
132    #[must_use]
133    pub fn get(&self, key: &TileKey) -> Option<std::sync::Arc<TileBuf>> {
134        let mut inner = self.lock();
135        let tick = inner.next_tick;
136        let Some(entry) = inner.entries.get_mut(key) else {
137            inner.stats.misses += 1;
138            return None;
139        };
140        let previous = entry.tick;
141        entry.tick = tick;
142        let tile = std::sync::Arc::clone(&entry.tile);
143        inner.next_tick += 1;
144        inner.recency.remove(&previous);
145        inner.recency.insert(tick, *key);
146        inner.stats.hits += 1;
147        Some(tile)
148    }
149
150    /// Offer a tile to the cache, evicting as needed to stay within budget.
151    ///
152    /// Returns the tile, so a caller can insert and use it in one step. A tile
153    /// larger than the entire budget is returned without being retained rather
154    /// than evicting everything to make room for something that cannot fit.
155    pub fn insert(&self, key: TileKey, tile: std::sync::Arc<TileBuf>) -> std::sync::Arc<TileBuf> {
156        let bytes = tile.bytes().len();
157        let mut inner = self.lock();
158        inner.stats.insertions += 1;
159
160        if bytes > self.budget {
161            inner.stats.rejections += 1;
162            return tile;
163        }
164        // Replacing an existing entry releases its bytes first.
165        if let Some(previous) = inner.entries.remove(&key) {
166            inner.bytes -= previous.bytes;
167            inner.recency.remove(&previous.tick);
168        }
169        let tick = inner.next_tick;
170        inner.next_tick += 1;
171        inner.bytes += bytes;
172        inner.entries.insert(
173            key,
174            Entry {
175                tile: std::sync::Arc::clone(&tile),
176                bytes,
177                tick,
178            },
179        );
180        inner.recency.insert(tick, key);
181        inner.evict_to_fit(self.budget);
182        tile
183    }
184
185    /// Bytes currently retained.
186    #[must_use]
187    pub fn bytes_used(&self) -> usize {
188        self.lock().bytes
189    }
190
191    /// Number of tiles currently retained.
192    #[must_use]
193    pub fn len(&self) -> usize {
194        self.lock().entries.len()
195    }
196
197    /// Whether the cache retains nothing.
198    #[must_use]
199    pub fn is_empty(&self) -> bool {
200        self.len() == 0
201    }
202
203    /// A snapshot of the cache counters.
204    #[must_use]
205    pub fn stats(&self) -> CacheStats {
206        self.lock().stats
207    }
208
209    /// Drop every retained tile, keeping the accumulated statistics.
210    pub fn clear(&self) {
211        let mut inner = self.lock();
212        inner.entries.clear();
213        inner.recency.clear();
214        inner.bytes = 0;
215    }
216
217    /// Lock the inner state, recovering from a poisoned mutex.
218    ///
219    /// A cache is pure derived data: if a thread panicked holding the lock the
220    /// worst case is a torn *statistic*, never a torn pixel, since tiles are
221    /// immutable `Arc`s. Propagating poisoning here would turn an unrelated
222    /// panic into a permanently unusable engine, so the guard is taken either
223    /// way.
224    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
225        self.inner
226            .lock()
227            .unwrap_or_else(std::sync::PoisonError::into_inner)
228    }
229}
230
231impl Default for TileCache {
232    fn default() -> Self {
233        Self::new(Self::DEFAULT_BUDGET)
234    }
235}
236
237impl Inner {
238    /// Drop least-recently-used entries until within `budget`.
239    fn evict_to_fit(&mut self, budget: usize) {
240        while self.bytes > budget {
241            // `recency` is ordered by tick, so the first key is the LRU one.
242            let Some((&tick, &key)) = self.recency.iter().next() else {
243                // No entries left to evict; nothing more can be done.
244                break;
245            };
246            self.recency.remove(&tick);
247            if let Some(entry) = self.entries.remove(&key) {
248                self.bytes -= entry.bytes;
249                self.stats.evictions += 1;
250            }
251        }
252    }
253}
254
255#[cfg(test)]
256#[allow(
257    clippy::unwrap_used,
258    clippy::indexing_slicing,
259    clippy::panic,
260    reason = "tests operate on known-good values and assert shapes directly"
261)]
262mod tests {
263    use super::*;
264    use crate::PixelFormat;
265    use std::sync::Arc;
266
267    /// A `size` × 1 Gray8 tile, i.e. exactly `size` bytes.
268    fn tile(size: u32) -> Arc<TileBuf> {
269        Arc::new(TileBuf::zeroed(Region::from_size(size, 1), PixelFormat::Gray8).unwrap())
270    }
271
272    /// A key for `region` on a freshly minted node, i.e. distinct from every
273    /// other key this helper has returned.
274    fn key(region: Region) -> TileKey {
275        TileKey::new(fresh_node_id(), region)
276    }
277
278    /// Mint a distinct `NodeId` without building a whole graph.
279    fn fresh_node_id() -> NodeId {
280        use crate::testing::CountingProducer;
281        use crate::{Format, Image, ImageDescriptor};
282        let descriptor = ImageDescriptor::new(1, 1, PixelFormat::Gray8).unwrap();
283        let image = Image::from_producer(Arc::new(CountingProducer::new(descriptor)), Format::Raw);
284        image.node().id()
285    }
286
287    #[test]
288    fn a_tile_round_trips_through_the_cache() {
289        let cache = TileCache::new(1024);
290        let k = key(Region::from_size(4, 1));
291        assert!(cache.get(&k).is_none(), "empty cache misses");
292        cache.insert(k, tile(4));
293        let found = cache.get(&k).unwrap();
294        assert_eq!(found.bytes().len(), 4);
295        assert_eq!(cache.len(), 1);
296        assert_eq!(cache.bytes_used(), 4);
297    }
298
299    #[test]
300    fn the_budget_is_never_exceeded_by_retained_bytes() {
301        let cache = TileCache::new(100);
302        for _ in 0..50 {
303            cache.insert(key(Region::from_size(10, 1)), tile(10));
304            assert!(
305                cache.bytes_used() <= 100,
306                "retained {} bytes over a 100 byte budget",
307                cache.bytes_used()
308            );
309        }
310        assert!(cache.stats().evictions > 0, "nothing was ever evicted");
311    }
312
313    #[test]
314    fn eviction_removes_the_least_recently_used_entry() {
315        let cache = TileCache::new(30);
316        let (a, b, c) = (
317            key(Region::from_size(1, 1)),
318            key(Region::from_size(2, 1)),
319            key(Region::from_size(3, 1)),
320        );
321        cache.insert(a, tile(10));
322        cache.insert(b, tile(10));
323        // Touch `a` so `b` becomes the least recently used.
324        assert!(cache.get(&a).is_some());
325        cache.insert(c, tile(10));
326        // Budget is exactly 30, so nothing has been evicted yet.
327        assert_eq!(cache.len(), 3);
328
329        // One more forces eviction of `b`, the LRU entry.
330        let d = key(Region::from_size(4, 1));
331        cache.insert(d, tile(10));
332        assert!(cache.get(&b).is_none(), "LRU entry survived");
333        assert!(cache.get(&a).is_some(), "recently used entry was evicted");
334        assert!(cache.get(&c).is_some());
335        assert!(cache.get(&d).is_some());
336    }
337
338    #[test]
339    fn an_evicted_tile_stays_valid_for_whoever_holds_it() {
340        // The property that makes pinning unnecessary: eviction drops the
341        // cache's reference, never the caller's.
342        let cache = TileCache::new(10);
343        let k = key(Region::from_size(10, 1));
344        let held = cache.insert(k, tile(10));
345        // Force the entry out.
346        cache.insert(key(Region::from_size(9, 1)), tile(10));
347        assert!(cache.get(&k).is_none(), "expected eviction");
348        // The held Arc is still perfectly usable.
349        assert_eq!(held.bytes().len(), 10);
350        assert!(held.as_tile().is_ok());
351    }
352
353    #[test]
354    fn a_tile_larger_than_the_budget_is_returned_but_not_retained() {
355        let cache = TileCache::new(10);
356        let k = key(Region::from_size(50, 1));
357        let returned = cache.insert(k, tile(50));
358        assert_eq!(returned.bytes().len(), 50, "the tile is still usable");
359        assert!(cache.get(&k).is_none());
360        assert_eq!(cache.len(), 0);
361        assert_eq!(cache.stats().rejections, 1);
362        // Crucially it did not evict everything else trying to fit.
363        assert_eq!(cache.stats().evictions, 0);
364    }
365
366    #[test]
367    fn a_zero_budget_disables_retention() {
368        let cache = TileCache::new(0);
369        let k = key(Region::from_size(4, 1));
370        let returned = cache.insert(k, tile(4));
371        assert_eq!(returned.bytes().len(), 4, "the tile is still returned");
372        assert!(cache.get(&k).is_none());
373        assert_eq!(cache.bytes_used(), 0);
374    }
375
376    #[test]
377    fn reinserting_a_key_replaces_it_without_double_counting() {
378        let cache = TileCache::new(1000);
379        let k = key(Region::from_size(4, 1));
380        cache.insert(k, tile(10));
381        cache.insert(k, tile(20));
382        assert_eq!(cache.len(), 1);
383        assert_eq!(cache.bytes_used(), 20, "old bytes were not released");
384        assert_eq!(cache.get(&k).unwrap().bytes().len(), 20);
385    }
386
387    #[test]
388    fn keys_distinguish_node_and_region() {
389        let cache = TileCache::new(1000);
390        let node = fresh_node_id();
391        let a = TileKey::new(node, Region::from_size(4, 1));
392        let b = TileKey::new(node, Region::new(4, 0, 4, 1));
393        cache.insert(a, tile(4));
394        assert!(
395            cache.get(&b).is_none(),
396            "different regions must not collide"
397        );
398
399        let other = TileKey::new(fresh_node_id(), Region::from_size(4, 1));
400        assert!(
401            cache.get(&other).is_none(),
402            "different nodes must not collide"
403        );
404    }
405
406    #[test]
407    fn statistics_track_lookups_and_evictions() {
408        let cache = TileCache::new(10);
409        let k = key(Region::from_size(4, 1));
410        assert!(cache.get(&k).is_none());
411        cache.insert(k, tile(4));
412        assert!(cache.get(&k).is_some());
413        let stats = cache.stats();
414        assert_eq!(stats.hits, 1);
415        assert_eq!(stats.misses, 1);
416        assert_eq!(stats.insertions, 1);
417        assert_eq!(stats.hit_rate(), Some(0.5));
418        assert_eq!(TileCache::new(1).stats().hit_rate(), None, "no lookups yet");
419    }
420
421    #[test]
422    fn clear_drops_entries_but_keeps_statistics() {
423        let cache = TileCache::new(1000);
424        cache.insert(key(Region::from_size(4, 1)), tile(4));
425        assert!(!cache.is_empty());
426        cache.clear();
427        assert!(cache.is_empty());
428        assert_eq!(cache.bytes_used(), 0);
429        assert_eq!(cache.stats().insertions, 1, "statistics are cumulative");
430    }
431
432    #[test]
433    fn the_cache_is_usable_from_many_threads() {
434        let cache = Arc::new(TileCache::new(4096));
435        let keys: Vec<TileKey> = (0..8).map(|i| key(Region::from_size(i + 1, 1))).collect();
436        std::thread::scope(|scope| {
437            for _ in 0..8 {
438                let cache = Arc::clone(&cache);
439                let keys = keys.clone();
440                scope.spawn(move || {
441                    for _ in 0..200 {
442                        for (i, k) in keys.iter().enumerate() {
443                            cache.insert(*k, tile(i as u32 + 1));
444                            let _ = cache.get(k);
445                        }
446                    }
447                });
448            }
449        });
450        // The invariant that matters under concurrency: accounting stayed
451        // consistent with what is actually retained.
452        let inner = cache.lock();
453        let actual: usize = inner.entries.values().map(|e| e.bytes).sum();
454        assert_eq!(inner.bytes, actual, "byte accounting drifted");
455        assert_eq!(
456            inner.recency.len(),
457            inner.entries.len(),
458            "recency index drifted"
459        );
460        assert!(inner.bytes <= 4096);
461    }
462
463    #[test]
464    fn a_poisoned_lock_does_not_disable_the_cache() {
465        // An unrelated panic must not permanently break the engine.
466        let cache = Arc::new(TileCache::new(1000));
467        let k = key(Region::from_size(4, 1));
468        cache.insert(k, tile(4));
469        let poisoner = Arc::clone(&cache);
470        let handle = std::thread::spawn(move || {
471            let _guard = poisoner.lock();
472            panic!("poison the mutex");
473        });
474        assert!(handle.join().is_err(), "the thread was supposed to panic");
475        // The cache still works.
476        assert!(cache.get(&k).is_some());
477        cache.insert(key(Region::from_size(8, 1)), tile(8));
478    }
479}