spark_storage/
cascade_policy.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//
3// Placement + eviction bookkeeping for the KV cache tier cascade (T1 = local
4// pinned LPDDR write-back cache in front of the peer/SSD backing). Pure logic,
5// no I/O and no CUDA, so it unit-tests on the metal/skip build with no hardware.
6//
7// LRU by default: the `StorageBackend` trait passes no predictor score to the
8// backend, so a predictor-scored T1 eviction would need an out-of-trait side
9// channel (deferred). Groups are keyed by `GroupKey` — write_from_host / read
10// are per-group and each group is independently addressed.
11
12use std::collections::{HashMap, VecDeque};
13
14use crate::group::GroupKey;
15
16/// Result of planning a T1 write: the slot to write `key` into, and — if a
17/// resident group had to be evicted to make room — the victim to flush DOWN to
18/// the backing tier first (its bytes still occupy `slot` until overwritten).
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct WritePlan {
21    pub slot: u32,
22    pub flush_victim: Option<(GroupKey, u32)>,
23}
24
25/// A fixed-capacity LRU set of resident groups over `cap_slots` byte slots.
26pub struct SlotCache {
27    cap_slots: u32,
28    lookup: HashMap<GroupKey, u32>,
29    slot_key: Vec<Option<GroupKey>>,
30    free: VecDeque<u32>,
31    /// LRU order: front = least-recently-used (eviction target), back = MRU.
32    lru: VecDeque<u32>,
33}
34
35impl SlotCache {
36    pub fn new(cap_slots: u32) -> Self {
37        assert!(cap_slots > 0, "SlotCache needs at least one slot");
38        Self {
39            cap_slots,
40            lookup: HashMap::new(),
41            slot_key: vec![None; cap_slots as usize],
42            free: (0..cap_slots).collect(),
43            lru: VecDeque::with_capacity(cap_slots as usize),
44        }
45    }
46
47    pub fn capacity(&self) -> u32 {
48        self.cap_slots
49    }
50
51    fn move_to_back(&mut self, slot: u32) {
52        if let Some(pos) = self.lru.iter().position(|&s| s == slot) {
53            self.lru.remove(pos);
54        }
55        self.lru.push_back(slot);
56    }
57
58    /// Bump `slot` to most-recently-used (call on every hit).
59    pub fn touch(&mut self, slot: u32) {
60        self.move_to_back(slot);
61    }
62
63    /// Plan a write of `key`. Overwrite-in-place if resident (no victim); else a
64    /// free slot; else evict the LRU slot and report its group to flush down.
65    pub fn plan_write(&mut self, key: GroupKey) -> WritePlan {
66        if let Some(&slot) = self.lookup.get(&key) {
67            self.move_to_back(slot);
68            return WritePlan {
69                slot,
70                flush_victim: None,
71            };
72        }
73        if let Some(slot) = self.free.pop_front() {
74            self.install(key, slot);
75            return WritePlan {
76                slot,
77                flush_victim: None,
78            };
79        }
80        // Full → evict the LRU tail's group (front of the deque).
81        let victim_slot = self.lru.pop_front().expect("full cache has an LRU entry");
82        let victim_key = self.slot_key[victim_slot as usize]
83            .take()
84            .expect("occupied slot has a key");
85        self.lookup.remove(&victim_key);
86        self.install(key, victim_slot);
87        WritePlan {
88            slot: victim_slot,
89            flush_victim: Some((victim_key, victim_slot)),
90        }
91    }
92
93    fn install(&mut self, key: GroupKey, slot: u32) {
94        self.lookup.insert(key, slot);
95        self.slot_key[slot as usize] = Some(key);
96        self.lru.push_back(slot);
97    }
98
99    /// Partition request keys into T1 hits `(request_index, slot)` and misses
100    /// `(request_index)`. Read-only — the caller `touch`es the hits after the
101    /// copy so a failed copy doesn't perturb LRU order.
102    pub fn plan_read(&self, keys: &[GroupKey]) -> (Vec<(usize, u32)>, Vec<usize>) {
103        let mut hits = Vec::new();
104        let mut misses = Vec::new();
105        for (i, k) in keys.iter().enumerate() {
106            match self.lookup.get(k) {
107                Some(&slot) => hits.push((i, slot)),
108                None => misses.push(i),
109            }
110        }
111        (hits, misses)
112    }
113
114    /// Every resident `(key, slot)` — used to flush the whole cache on drop.
115    pub fn residents(&self) -> Vec<(GroupKey, u32)> {
116        self.lookup.iter().map(|(&k, &s)| (k, s)).collect()
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::group::KvKind;
124
125    fn k(block: u32) -> GroupKey {
126        GroupKey::new(0, block, 0, KvKind::K)
127    }
128
129    #[test]
130    fn free_slots_then_overwrite_in_place() {
131        let mut c = SlotCache::new(3);
132        let p0 = c.plan_write(k(0));
133        assert_eq!(p0.flush_victim, None);
134        let p1 = c.plan_write(k(1));
135        assert_ne!(p1.slot, p0.slot);
136        // Re-write k(0): same slot, no victim.
137        let p0b = c.plan_write(k(0));
138        assert_eq!(p0b.slot, p0.slot);
139        assert_eq!(p0b.flush_victim, None);
140    }
141
142    #[test]
143    fn fill_then_evict_lru_tail() {
144        let mut c = SlotCache::new(2);
145        let s0 = c.plan_write(k(0)).slot;
146        let _s1 = c.plan_write(k(1)).slot;
147        // Touch k(0) so k(1) becomes LRU.
148        let (hits, _) = c.plan_read(&[k(0)]);
149        c.touch(hits[0].1);
150        // Write k(2) → evicts k(1) (LRU), reusing its slot.
151        let p2 = c.plan_write(k(2));
152        assert_eq!(p2.flush_victim.map(|(vk, _)| vk), Some(k(1)));
153        // k(2) reuses k(1)'s (evicted) slot, not k(0)'s still-resident slot.
154        assert_ne!(p2.slot, s0);
155    }
156
157    #[test]
158    fn hit_miss_partition() {
159        let mut c = SlotCache::new(4);
160        c.plan_write(k(0));
161        c.plan_write(k(2));
162        let (hits, misses) = c.plan_read(&[k(0), k(1), k(2), k(3)]);
163        let hit_idx: Vec<usize> = hits.iter().map(|(i, _)| *i).collect();
164        assert_eq!(hit_idx, vec![0, 2]);
165        assert_eq!(misses, vec![1, 3]);
166    }
167
168    #[test]
169    fn residents_lists_all_live_groups() {
170        let mut c = SlotCache::new(3);
171        c.plan_write(k(0));
172        c.plan_write(k(1));
173        let mut r: Vec<GroupKey> = c.residents().into_iter().map(|(k, _)| k).collect();
174        r.sort_by_key(|g| g.block);
175        assert_eq!(r, vec![k(0), k(1)]);
176    }
177
178    #[test]
179    fn evicted_group_is_no_longer_a_hit() {
180        let mut c = SlotCache::new(1);
181        c.plan_write(k(0));
182        let p = c.plan_write(k(1)); // evicts k(0)
183        assert_eq!(p.flush_victim.map(|(vk, _)| vk), Some(k(0)));
184        let (hits, misses) = c.plan_read(&[k(0), k(1)]);
185        assert_eq!(hits.iter().map(|(i, _)| *i).collect::<Vec<_>>(), vec![1]);
186        assert_eq!(misses, vec![0]);
187    }
188}