spark_runtime/
prefix_cache.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Prefix caching trait for KV block reuse (SDD).
4//!
5//! When multiple requests share a common prompt prefix, previously-computed
6//! KV cache blocks can be reused instead of re-running prefill. The cache
7//! is indexed by token sequences at block granularity via a radix tree.
8//!
9//! Two implementations:
10//! - `NoPrefixCaching`: no-ops (zero overhead when disabled)
11//! - `RadixTree` (see `crate::radix_tree`): full radix tree with LRU eviction
12
13use std::sync::atomic::Ordering;
14
15mod no_caching;
16mod tier_evict;
17pub use no_caching::NoPrefixCaching;
18pub use tier_evict::TierEvict;
19
20// The three counters that lived here are fields of the single run mailbox,
21// `crate::run_metrics::RunMetrics` — see that module for why one static and
22// not none, and why it is cleared at run start.
23
24pub fn record_cache_hit(matched_tokens: usize) {
25    let m = crate::run_metrics::metrics();
26    m.cache_hits.fetch_add(1, Ordering::Relaxed);
27    m.cache_hit_tokens
28        .fetch_add(matched_tokens as u64, Ordering::Relaxed);
29}
30
31pub fn record_cache_miss() {
32    crate::run_metrics::metrics()
33        .cache_misses
34        .fetch_add(1, Ordering::Relaxed);
35}
36
37pub fn cache_hit_count() -> u64 {
38    crate::run_metrics::metrics()
39        .cache_hits
40        .load(Ordering::Relaxed)
41}
42pub fn cache_miss_count() -> u64 {
43    crate::run_metrics::metrics()
44        .cache_misses
45        .load(Ordering::Relaxed)
46}
47pub fn cache_hit_tokens_total() -> u64 {
48    crate::run_metrics::metrics()
49        .cache_hit_tokens
50        .load(Ordering::Relaxed)
51}
52
53/// Result of evicting LRU cached blocks (Phase 6.1.e).
54#[derive(Debug, Clone, Default)]
55pub struct EvictedBlocks {
56    /// Physical block indices freed (caller calls `PagedKvCache::free_block`).
57    pub physical: Vec<u32>,
58    /// Parallel disk-block IDs to release (caller calls
59    /// `HighSpeedSwap::dec_disk_ref`). Empty when HSS isn't in use.
60    pub disk_block_ids: Vec<u32>,
61}
62
63/// What an `insert` newly took ownership of, so the caller can take the
64/// matching references.
65///
66/// Ownership rule: the cache holds exactly ONE reference on the physical block
67/// stored in each radix node, taken when that node is CREATED and returned when
68/// the node is evicted (`return_evicted_block`). The reference therefore has to
69/// follow the block the NODE holds — not the block the inserting sequence had at
70/// that position. Those two diverge whenever a node already exists for a token
71/// chunk: `insert` keeps the node's original `block_idx`, while the sequence has
72/// a different block there (it did not get that block from the cache — e.g. it
73/// never matched, or it was restored from a swap file). Referencing the
74/// sequence's block instead left the node's block with no reference at all, so
75/// evicting that node decremented a ref belonging to a LIVE sequence: the block
76/// went back on the free list while still in use and was handed out again, with
77/// the second owner's teardown underflowing. Reporting the blocks here keeps
78/// node lifetime and reference lifetime identical by construction.
79#[derive(Debug, Clone, Default)]
80pub struct InsertAcquired {
81    /// Disk-block IDs the cache newly references (caller `inc_disk_ref`s each).
82    pub disk_block_ids: Vec<u32>,
83    /// Physical KV blocks stored in radix nodes CREATED by this insert; the
84    /// caller `inc_ref`s each exactly once.
85    pub blocks: Vec<u32>,
86    /// Physical KV blocks this insert stopped storing — a `partial_suffix` slot
87    /// that was overwritten or dropped. The caller `dec_ref`s each exactly once,
88    /// releasing the reference taken when that slot was first filled.
89    pub released_blocks: Vec<u32>,
90}
91
92impl EvictedBlocks {
93    pub fn is_empty(&self) -> bool {
94        self.physical.is_empty()
95    }
96
97    pub fn len(&self) -> usize {
98        self.physical.len()
99    }
100}
101
102/// Result of looking up a token sequence in the prefix cache.
103#[derive(Debug, Clone)]
104pub struct PrefixMatch {
105    /// Physical KV cache block indices to reuse (in order).
106    pub matched_blocks: Vec<u32>,
107    /// `--high-speed-swap` disk-block IDs parallel to `matched_blocks`
108    /// (Phase 6.1.e). Empty when HSS is not in use. Same length as
109    /// `matched_blocks` when populated. Caller must `inc_disk_ref` each
110    /// before treating them as live (the cache itself does not
111    /// retain disk-side refs — see `RadixTree::lookup` for the bump).
112    pub matched_disk_block_ids: Vec<u32>,
113    /// Number of tokens matched (always block-aligned).
114    pub matched_tokens: usize,
115    /// SSM state snapshot ID at the deepest matched node (Marconi caching).
116    /// When `Some`, the caller can restore SSM h_state + conv_state from
117    /// this snapshot and skip SSM computation for the matched prefix.
118    pub ssm_snapshot: Option<usize>,
119    /// Number of tokens covered by `ssm_snapshot` (Marconi intermediate checkpoints).
120    /// With leaf-only snapshots this equals `matched_tokens`. With intermediate
121    /// checkpoints it may be less — the caller must recompute SSM state for
122    /// tokens between `ssm_snapshot_tokens` and `matched_tokens`.
123    pub ssm_snapshot_tokens: usize,
124    /// Phase 1b spill tier: when the deepest anchor for this prefix is SPILLED
125    /// (not resident in HBM), `ssm_snapshot` is `None` and this holds the tier
126    /// key (prefix hash). The caller faults the bytes into a fresh snapshot slot
127    /// (`SsmSnapshotPool::fault_in_slot`), `promote_snapshot`s the entry, then
128    /// restores. `None` whenever nothing is tiered (i.e. `ATLAS_SSM_TIER` off) —
129    /// so this field is inert on the default path.
130    pub ssm_snapshot_tier_key: Option<u64>,
131    /// Token depth covered by `ssm_snapshot_tier_key` (analogue of
132    /// `ssm_snapshot_tokens` for a tiered anchor).
133    pub ssm_snapshot_tier_tokens: usize,
134    /// Whether the matched SSM snapshot is a TAIL (bleeds past the exact
135    /// prefix). The restore site session-gates only tails; exact and
136    /// is_tail_sibling snapshots are content-addressed (safe cross-session).
137    pub ssm_snapshot_is_tail: bool,
138}
139
140impl PrefixMatch {
141    /// Empty match (no cached prefix found).
142    pub fn empty() -> Self {
143        Self {
144            matched_blocks: Vec::new(),
145            matched_disk_block_ids: Vec::new(),
146            matched_tokens: 0,
147            ssm_snapshot: None,
148            ssm_snapshot_tokens: 0,
149            ssm_snapshot_tier_key: None,
150            ssm_snapshot_tier_tokens: 0,
151            ssm_snapshot_is_tail: false,
152        }
153    }
154
155    /// Whether any prefix was matched.
156    pub fn is_empty(&self) -> bool {
157        self.matched_tokens == 0
158    }
159}
160
161/// Trait for prefix caching strategies.
162///
163/// All methods take `&self` — implementations use interior mutability
164/// (e.g., `Mutex`) for thread safety. This allows the prefix cache to be
165/// shared between the model (prefill) and scheduler (free_sequence) without
166/// requiring `&mut self`.
167pub trait PrefixCache: Send + Sync {
168    /// Whether this implementation is active (i.e., a real cache that
169    /// actually inserts/holds refs). `NoPrefixCaching` returns false;
170    /// `RadixTree` returns true. Callers use this to skip ref-bookkeeping
171    /// that's only meaningful when the cache holds refs (e.g., the manual
172    /// `kv_cache.inc_ref` in `cache_sequence` that pairs with eviction's
173    /// `return_evicted_block`).
174    fn is_active(&self) -> bool {
175        true
176    }
177
178    /// Look up a token sequence and return cached KV blocks for the
179    /// longest matching prefix (block-aligned).
180    ///
181    /// Increments ref_count on matched nodes so they survive eviction
182    /// while the sequence is active. `session_hash` is used for SSM
183    /// snapshot isolation (0 = legacy/no session tracking).
184    ///
185    /// Task #24: `adapter_id` keys the KV/prefix + SSM-snapshot cache so a
186    /// request reuses ONLY blocks computed under the same adapter. `0` = base /
187    /// no adapter, which keys byte-identically to the pre-LoRA token-only cache.
188    fn lookup(
189        &self,
190        tokens: &[u32],
191        block_size: usize,
192        session_hash: u64,
193        adapter_id: u64,
194    ) -> PrefixMatch;
195
196    /// Read-only longest-prefix probe: number of tokens (block-aligned)
197    /// `lookup` would match, WITHOUT taking refs, touching LRU state, or
198    /// counting a hit/miss. Used by the prefill tail-checkpoint split to
199    /// detect conversation reuse before deciding to pay the extra pass.
200    /// Task #24: keyed by `adapter_id` so a cross-adapter peek reports a miss.
201    fn peek_matched_tokens(&self, _tokens: &[u32], _block_size: usize, _adapter_id: u64) -> usize {
202        0
203    }
204
205    /// Insert a completed prefill's blocks into the cache.
206    ///
207    /// `block_table[i]` is the physical block for tokens
208    /// `[i*block_size .. (i+1)*block_size]`.
209    ///
210    /// `disk_block_ids` parallels `block_table` for `--high-speed-swap`
211    /// (Phase 6.1.e). Empty when HSS is not in use; same length as
212    /// `block_table` when populated. The cache stores these alongside the
213    /// physical block IDs and returns them in `EvictedBlocks` so the
214    /// caller can `dec_disk_ref` the orchestrator's per-block refcount.
215    ///
216    /// **Disk-ref obligation (Issue #17 fix):** the returned vec lists every
217    /// disk_block_id on which this insert call newly took an ownership ref
218    /// (a node was created OR an existing node had its `disk_block_id`
219    /// populated for the first time). The caller MUST `inc_disk_ref` each
220    /// returned ID so the swap allocator's refcount matches the cache's
221    /// reachability. Already-cached portions (matched-prefix entries, or
222    /// blocks a prior intermediate insert already covered) are NOT in the
223    /// returned vec — re-incing them would leak the cache's refcount.
224    ///
225    /// `matched_tokens` is the number of tokens the inserting sequence
226    /// already acquired via `lookup()`'s `inc_refs` (0 for a cache-miss
227    /// request). Tokens past this offset are "seq-owned" — the inserting
228    /// sequence's eventual `release()` will decrement them — so `insert`
229    /// must bump their ref_count to keep the cache's own reference alive
230    /// after the release. See the release/lookup dance at the top of
231    /// `radix_tree.rs`.
232    fn insert(
233        &self,
234        tokens: &[u32],
235        block_table: &[u32],
236        disk_block_ids: &[u32],
237        block_size: usize,
238        matched_tokens: usize,
239        adapter_id: u64,
240    ) -> InsertAcquired;
241
242    /// Insert blocks with an SSM state snapshot registered in the snapshot index.
243    ///
244    /// The snapshot ID references a slot in an external `SsmSnapshotPool`.
245    /// On future lookups matching this prefix, the snapshot ID is returned
246    /// in `PrefixMatch::ssm_snapshot` so the caller can restore SSM state.
247    /// `session_hash` tags the snapshot for session-scoped isolation.
248    /// `matched_tokens` has the same semantics as in `insert`.
249    /// Returns `(displaced_snapshot_id, newly_acquired_disk_ids)`. The
250    /// disk-ref obligation matches `insert`: caller `inc_disk_ref`s each
251    /// returned ID.
252    #[allow(clippy::too_many_arguments)]
253    fn insert_with_snapshot(
254        &self,
255        tokens: &[u32],
256        block_table: &[u32],
257        disk_block_ids: &[u32],
258        block_size: usize,
259        snapshot_id: usize,
260        session_hash: u64,
261        matched_tokens: usize,
262        adapter_id: u64,
263    ) -> (Option<usize>, InsertAcquired);
264
265    /// Insert an SSM snapshot at an intermediate token boundary.
266    ///
267    /// `tokens` is the token sequence up to and including the snapshot point.
268    /// `block_table` contains the physical block indices for those tokens.
269    /// `session_hash` tags the snapshot for session-scoped isolation.
270    /// `matched_tokens` has the same semantics as in `insert`.
271    /// Returns the displaced snapshot ID if an existing entry was overwritten.
272    #[allow(clippy::too_many_arguments)]
273    fn insert_intermediate_snapshot(
274        &self,
275        tokens: &[u32],
276        block_table: &[u32],
277        disk_block_ids: &[u32],
278        block_size: usize,
279        snapshot_id: usize,
280        session_hash: u64,
281        matched_tokens: usize,
282        adapter_id: u64,
283    ) -> Option<usize>;
284
285    /// Register the per-session TAIL snapshot in the index WITHOUT touching the
286    /// radix tree (the final chunk's `insert` covers those blocks). Supersedes
287    /// this session's previous tail; returns displaced snapshot ids to free.
288    fn insert_tail_snapshot(
289        &self,
290        tokens: &[u32],
291        snapshot_id: usize,
292        session_hash: u64,
293        adapter_id: u64,
294    ) -> Vec<usize>;
295
296    /// Register the tail's EARLY sibling (`tb - bs`) in the index. Must be
297    /// called after `insert_tail_snapshot` in the same finalize (the tail
298    /// insert sweeps the session's previous tail + sibling). Returns a
299    /// displaced snapshot id to free, if the prefix was already registered.
300    fn insert_tail_sibling_snapshot(
301        &self,
302        tokens: &[u32],
303        snapshot_id: usize,
304        session_hash: u64,
305        adapter_id: u64,
306    ) -> Option<usize>;
307
308    /// Release ref_counts on blocks that were acquired via `lookup`.
309    ///
310    /// Called when a sequence finishes. Decrements ref_count on cache
311    /// nodes matching the token prefix, making them eligible for eviction.
312    /// Task #24: `adapter_id` must match the one used at `lookup`/`insert`.
313    fn release(&self, tokens: &[u32], block_size: usize, adapter_id: u64);
314
315    /// Release exactly the block-aligned prefix acquired by one `lookup`.
316    ///
317    /// Batched-prefill admission may acquire several candidates, then reject
318    /// the batch before any sequence owns their blocks. Releasing the full
319    /// token slice in that case would be racy: another request could insert a
320    /// longer matching suffix between acquisition and rollback. Implementors
321    /// must therefore decrement no more than `matched_tokens`.
322    fn release_matched(
323        &self,
324        tokens: &[u32],
325        block_size: usize,
326        matched_tokens: usize,
327        adapter_id: u64,
328    );
329
330    /// Evict up to `num_blocks` cached blocks, returning their physical
331    /// indices and parallel disk-block IDs (Phase 6.1.e).
332    ///
333    /// Picks LRU zero-ref leaf nodes. Returns fewer than requested if not
334    /// enough evictable blocks exist. The caller is responsible for
335    /// `dec_disk_ref`-ing every entry in `disk_block_ids` (releasing the
336    /// cache's HSS-side refcount). When HSS isn't in use the `disk_block_ids`
337    /// vec is empty.
338    fn evict(&self, num_blocks: usize) -> EvictedBlocks;
339
340    /// Evict the least-recently-used SSM snapshot from the snapshot index.
341    /// Returns the snapshot ID so the caller can free it in `SsmSnapshotPool`.
342    fn evict_snapshot_lru(&self) -> Option<usize>;
343
344    /// Phase 1b spill tier: pick a spill victim (same policy as
345    /// `evict_snapshot_lru`, HBM-resident only) and decide whether it is worth
346    /// spilling — see [`TierEvict`]. A victim shallower than `min_tokens`
347    /// cannot repay the spill's fixed cost, so its entry is dropped outright
348    /// rather than left findable-but-empty. `min_tokens == 0` disables the
349    /// gate. `None` when nothing resident remains; default `None` (no tier).
350    fn evict_snapshot_to_tier(&self, min_tokens: usize) -> Option<TierEvict> {
351        let _ = min_tokens;
352        None
353    }
354
355    /// Phase 1b spill tier: after the caller faulted a spilled snapshot's bytes
356    /// into `new_slot`, re-home its index entry to HBM. Returns `false` if the
357    /// key is unknown. Default: `false`.
358    fn promote_snapshot(&self, key: u64, new_slot: usize) -> bool {
359        let _ = (key, new_slot);
360        false
361    }
362
363    /// Phase 1b spill tier: the FAILED-fault-in twin of [`Self::promote_snapshot`].
364    /// The caller's `store.get(key)` MISSED, so this entry is findable by
365    /// `lookup_tiered` with no bytes behind it. Left in place, every warm turn
366    /// on this prefix repeats the whole doomed cycle — spill a LIVE 66 MB
367    /// victim D2H to free a slot, fault in, miss, free the slot — and then
368    /// recomputes anyway; under `ATLAS_SSM_TIER_DISK_GB` that doomed spill
369    /// evicts one MORE tier record, so the cap's own pressure re-amplifies
370    /// itself. Dropping the entry degrades the prefix to a plain recompute
371    /// ONCE.
372    ///
373    /// Only removes an entry that is still `tiered`: a resident entry's
374    /// `snapshot_id` is a LIVE pool slot that only its owner may free, so a
375    /// by-key remove of one would leak it. Returns whether an entry was
376    /// dropped. Default: `false` (no tier).
377    fn forget_snapshot_tier_key(&self, key: u64) -> bool {
378        let _ = key;
379        false
380    }
381
382    /// Number of SSM snapshots currently stored in the snapshot index.
383    fn snapshot_count(&self) -> usize;
384
385    /// (entries, cached_blocks) for logging.
386    fn stats(&self) -> (usize, usize);
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_prefix_match_empty() {
395        let m = PrefixMatch::empty();
396        assert!(m.is_empty());
397        assert_eq!(m.matched_tokens, 0);
398        assert!(m.matched_blocks.is_empty());
399    }
400
401    #[test]
402    fn test_no_prefix_caching_is_noop() {
403        let cache = NoPrefixCaching;
404        let tokens = vec![1, 2, 3, 4, 5, 6, 7, 8];
405        let block_table = vec![0, 1];
406        let disk_block_ids: Vec<u32> = vec![];
407
408        let m = cache.lookup(&tokens, 4, 0, 0);
409        assert!(m.is_empty());
410
411        // These should not panic
412        let new_acq = cache.insert(&tokens, &block_table, &disk_block_ids, 4, 0, 0);
413        assert!(new_acq.disk_block_ids.is_empty());
414        assert!(new_acq.blocks.is_empty());
415        cache.release(&tokens, 4, 0);
416
417        let evicted = cache.evict(10);
418        assert!(evicted.is_empty());
419
420        assert_eq!(cache.evict_snapshot_lru(), None);
421        assert_eq!(cache.snapshot_count(), 0);
422
423        assert_eq!(cache.stats(), (0, 0));
424    }
425}