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}