Skip to main content

cranpose_render_common/
bounded_lru_cache.rs

1use cranpose_core::collections::map::HashMap;
2use std::hash::Hash;
3use std::num::NonZeroUsize;
4
5/// One cached entry, and its neighbours in recency order.
6///
7/// The links are indices into `slots` rather than references, so the structure
8/// stays safe Rust and a slot never cares where it lives in memory.
9struct CacheSlot<K, V> {
10    key: K,
11    value: V,
12    newer: Option<usize>,
13    older: Option<usize>,
14}
15
16/// Small bounded LRU cache used by renderer hot-path caches.
17///
18/// Hits update recency in place, so the common path is a single hash lookup.
19/// Eviction unlinks the oldest entry, which costs the same whether the cache
20/// holds ten entries or ten thousand.
21///
22/// The recency order is a linked list rather than a timestamp per entry
23/// because a timestamp makes eviction a scan for the minimum. These caches are
24/// large -- thousands of glyph masks -- and the workloads that need them most
25/// are the ones that miss steadily: text whose size animates re-rasterises
26/// every glyph of every frame, and every one of those inserts was walking the
27/// whole table to decide what to drop.
28///
29/// A key is held twice, once in the index and once in its slot, so an eviction
30/// can find the index entry to remove without searching for it. The keys these
31/// caches use are small `Copy` structs, and the duplicate is what keeps the
32/// links free of raw pointers.
33pub struct BoundedLruCache<K, V> {
34    index: HashMap<K, usize>,
35    slots: Vec<Option<CacheSlot<K, V>>>,
36    free: Vec<usize>,
37    /// Most recently used: the end entries are added to.
38    newest: Option<usize>,
39    /// Least recently used: the next entry to be evicted.
40    oldest: Option<usize>,
41    cap: NonZeroUsize,
42}
43
44impl<K, V> BoundedLruCache<K, V>
45where
46    K: Clone + Eq + Hash,
47{
48    pub fn new(cap: NonZeroUsize) -> Self {
49        Self {
50            index: HashMap::with_capacity(cap.get()),
51            slots: Vec::with_capacity(cap.get()),
52            free: Vec::new(),
53            newest: None,
54            oldest: None,
55            cap,
56        }
57    }
58
59    pub fn with_capacity_at_least_one(cap: usize) -> Self {
60        let cap = NonZeroUsize::new(cap).unwrap_or(NonZeroUsize::MIN);
61        Self::new(cap)
62    }
63
64    pub fn len(&self) -> usize {
65        self.index.len()
66    }
67
68    pub fn is_empty(&self) -> bool {
69        self.index.is_empty()
70    }
71
72    pub fn cap(&self) -> NonZeroUsize {
73        self.cap
74    }
75
76    pub fn contains(&self, key: &K) -> bool {
77        self.index.contains_key(key)
78    }
79
80    pub fn get(&mut self, key: &K) -> Option<&V> {
81        let slot = *self.index.get(key)?;
82        self.promote(slot);
83        Some(&self.slot(slot).value)
84    }
85
86    pub fn peek(&self, key: &K) -> Option<&V> {
87        let slot = *self.index.get(key)?;
88        Some(&self.slot(slot).value)
89    }
90
91    pub fn get_mut(&mut self, key: &K) -> Option<&mut V> {
92        let slot = *self.index.get(key)?;
93        self.promote(slot);
94        Some(&mut self.slot_mut(slot).value)
95    }
96
97    pub fn push(&mut self, key: K, value: V) -> Option<(K, V)> {
98        if let Some(&slot) = self.index.get(&key) {
99            self.promote(slot);
100            let old_value = std::mem::replace(&mut self.slot_mut(slot).value, value);
101            return Some((key, old_value));
102        }
103
104        let evicted = if self.index.len() == self.cap.get() {
105            self.pop_lru()
106        } else {
107            None
108        };
109
110        let slot = self.claim_slot(key.clone(), value);
111        self.index.insert(key, slot);
112        self.link_newest(slot);
113        evicted
114    }
115
116    pub fn put(&mut self, key: K, value: V) -> Option<V> {
117        self.push(key, value).map(|(_, value)| value)
118    }
119
120    pub fn pop_lru(&mut self) -> Option<(K, V)> {
121        let slot = self.oldest?;
122        self.unlink(slot);
123        let entry = self.release_slot(slot);
124        self.index.remove(&entry.key);
125        Some((entry.key, entry.value))
126    }
127
128    pub fn pop(&mut self, key: &K) -> Option<V> {
129        let slot = self.index.remove(key)?;
130        self.unlink(slot);
131        Some(self.release_slot(slot).value)
132    }
133
134    /// Entries most recently used first.
135    pub fn iter(&self) -> impl Iterator<Item = (&K, &V)> {
136        let mut next = self.newest;
137        std::iter::from_fn(move || {
138            let entry = self.slot(next?);
139            next = entry.older;
140            Some((&entry.key, &entry.value))
141        })
142    }
143
144    /// An index recorded in `index` or in a link always names a live slot.
145    fn slot(&self, slot: usize) -> &CacheSlot<K, V> {
146        self.slots[slot]
147            .as_ref()
148            .expect("a linked cache slot is always occupied")
149    }
150
151    fn slot_mut(&mut self, slot: usize) -> &mut CacheSlot<K, V> {
152        self.slots[slot]
153            .as_mut()
154            .expect("a linked cache slot is always occupied")
155    }
156
157    /// Move a slot to the newest end. A slot already there costs nothing.
158    fn promote(&mut self, slot: usize) {
159        if self.newest == Some(slot) {
160            return;
161        }
162        self.unlink(slot);
163        self.link_newest(slot);
164    }
165
166    fn link_newest(&mut self, slot: usize) {
167        let previous_newest = self.newest;
168        {
169            let entry = self.slot_mut(slot);
170            entry.newer = None;
171            entry.older = previous_newest;
172        }
173        if let Some(previous) = previous_newest {
174            self.slot_mut(previous).newer = Some(slot);
175        }
176        self.newest = Some(slot);
177        if self.oldest.is_none() {
178            self.oldest = Some(slot);
179        }
180    }
181
182    fn unlink(&mut self, slot: usize) {
183        let (newer, older) = {
184            let entry = self.slot_mut(slot);
185            (entry.newer.take(), entry.older.take())
186        };
187        match newer {
188            Some(newer) => self.slot_mut(newer).older = older,
189            None => self.newest = older,
190        }
191        match older {
192            Some(older) => self.slot_mut(older).newer = newer,
193            None => self.oldest = newer,
194        }
195    }
196
197    /// A slot for a new entry, reusing one an eviction left behind.
198    ///
199    /// Slots are vacated rather than removed, so every index already handed out
200    /// stays valid for as long as its entry lives.
201    fn claim_slot(&mut self, key: K, value: V) -> usize {
202        let entry = CacheSlot {
203            key,
204            value,
205            newer: None,
206            older: None,
207        };
208        match self.free.pop() {
209            Some(slot) => {
210                self.slots[slot] = Some(entry);
211                slot
212            }
213            None => {
214                self.slots.push(Some(entry));
215                self.slots.len() - 1
216            }
217        }
218    }
219
220    fn release_slot(&mut self, slot: usize) -> CacheSlot<K, V> {
221        let entry = self.slots[slot]
222            .take()
223            .expect("a slot being released is always occupied");
224        self.free.push(slot);
225        entry
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::BoundedLruCache;
232
233    fn cache<K, V>(cap: usize) -> BoundedLruCache<K, V>
234    where
235        K: Clone + Eq + std::hash::Hash,
236    {
237        BoundedLruCache::with_capacity_at_least_one(cap)
238    }
239
240    #[test]
241    fn clamped_constructor_uses_minimum_nonzero_capacity() {
242        let mut cache = BoundedLruCache::with_capacity_at_least_one(0);
243        assert_eq!(cache.cap().get(), 1);
244        assert_eq!(cache.push("a", 1), None);
245        assert_eq!(cache.push("b", 2), Some(("a", 1)));
246        assert_eq!(cache.get(&"b"), Some(&2));
247    }
248
249    #[test]
250    fn get_promotes_entry_and_push_evicts_lru() {
251        let mut cache = cache(2);
252        assert_eq!(cache.push("a", 1), None);
253        assert_eq!(cache.push("b", 2), None);
254
255        assert_eq!(cache.get(&"a"), Some(&1));
256        assert_eq!(cache.push("c", 3), Some(("b", 2)));
257
258        assert!(cache.contains(&"a"));
259        assert!(cache.contains(&"c"));
260        assert!(!cache.contains(&"b"));
261    }
262
263    #[test]
264    fn push_existing_replaces_value_and_keeps_capacity() {
265        let mut cache = cache(2);
266        cache.push("a", 1);
267        cache.push("b", 2);
268
269        assert_eq!(cache.push("a", 3), Some(("a", 1)));
270        assert_eq!(cache.len(), 2);
271        assert_eq!(cache.get(&"a"), Some(&3));
272    }
273
274    #[test]
275    fn pop_removes_requested_entry_and_preserves_lru_order() {
276        let mut cache = cache(3);
277        cache.push("a", 1);
278        cache.push("b", 2);
279        cache.push("c", 3);
280
281        assert_eq!(cache.pop(&"b"), Some(2));
282        assert_eq!(cache.get(&"a"), Some(&1));
283        assert_eq!(cache.pop_lru(), Some(("c", 3)));
284        assert_eq!(cache.len(), 1);
285    }
286
287    #[test]
288    fn a_full_cache_that_only_misses_keeps_evicting_in_order() {
289        // The shape a scaling list produces: every insert is new, so every
290        // insert evicts. What must hold is that the entry dropped is always the
291        // oldest, however many times the slots have been recycled.
292        let mut cache = cache(4);
293        for step in 0..4 {
294            assert_eq!(cache.push(step, step * 10), None);
295        }
296
297        for step in 4..64 {
298            let evicted = cache.push(step, step * 10);
299            assert_eq!(
300                evicted,
301                Some((step - 4, (step - 4) * 10)),
302                "insert {step} must evict the oldest entry"
303            );
304            assert_eq!(cache.len(), 4);
305        }
306
307        let live: Vec<_> = cache.iter().map(|(key, value)| (*key, *value)).collect();
308        assert_eq!(live, vec![(63, 630), (62, 620), (61, 610), (60, 600)]);
309    }
310
311    #[test]
312    fn reused_slots_do_not_resurrect_the_entries_that_vacated_them() {
313        let mut cache = cache(3);
314        cache.push("a", 1);
315        cache.push("b", 2);
316        cache.push("c", 3);
317
318        assert_eq!(cache.pop(&"b"), Some(2));
319        // "d" lands in the slot "b" vacated.
320        assert_eq!(cache.push("d", 4), None);
321
322        assert!(!cache.contains(&"b"));
323        assert_eq!(cache.peek(&"d"), Some(&4));
324        assert_eq!(cache.len(), 3);
325        // Recency survived the reuse: "a" is still the oldest.
326        assert_eq!(cache.pop_lru(), Some(("a", 1)));
327        assert_eq!(cache.pop_lru(), Some(("c", 3)));
328        assert_eq!(cache.pop_lru(), Some(("d", 4)));
329        assert_eq!(cache.pop_lru(), None);
330        assert!(cache.is_empty());
331    }
332
333    #[test]
334    fn a_promoted_entry_survives_the_next_eviction() {
335        let mut cache = cache(3);
336        cache.push("a", 1);
337        cache.push("b", 2);
338        cache.push("c", 3);
339
340        assert_eq!(cache.get(&"a"), Some(&1));
341        assert_eq!(cache.get_mut(&"b").map(|value| *value), Some(2));
342
343        // "c" is now the oldest despite having been inserted last.
344        assert_eq!(cache.push("d", 4), Some(("c", 3)));
345        assert!(cache.contains(&"a"));
346        assert!(cache.contains(&"b"));
347    }
348
349    #[test]
350    fn peek_reads_without_promoting_entry() {
351        let mut cache = cache(2);
352        cache.push("a", 1);
353        cache.push("b", 2);
354
355        assert_eq!(cache.peek(&"a"), Some(&1));
356        assert_eq!(cache.push("c", 3), Some(("a", 1)));
357    }
358
359    #[test]
360    fn iter_reports_mru_to_lru_entries() {
361        let mut cache = cache(3);
362        cache.push("a", 1);
363        cache.push("b", 2);
364        cache.get(&"a");
365
366        let entries: Vec<_> = cache.iter().map(|(key, value)| (*key, *value)).collect();
367        assert_eq!(entries, vec![("a", 1), ("b", 2)]);
368    }
369}