spark_runtime/
radix_tree.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Radix tree prefix cache for KV block reuse.
4//!
5//! Token sequences are chunked at `block_size` granularity. Each node in
6//! the tree corresponds to one KV cache block. Lookup walks the tree
7//! matching block-aligned chunks, returning cached physical block indices.
8//!
9//! Thread-safe via `Mutex<RadixTreeInner>`.
10
11use parking_lot::Mutex;
12
13use crate::prefix_cache::{EvictedBlocks, PrefixCache, PrefixMatch};
14
15mod inner;
16mod snapshot;
17mod snapshot_insert;
18mod snapshot_stats;
19mod snapshot_tier;
20
21#[cfg(test)]
22mod tests;
23
24use inner::RadixTreeInner;
25use snapshot::SsmSnapshotIndex;
26
27/// FNV-1a-ish stable hash for the first `count` tokens — used to key SSM
28/// snapshots independently of the radix tree (allows the same prefix hash to be
29/// reproduced across requests).
30///
31/// Task #24 (adapter-correct KV): `adapter_id` is folded in so two adapters that
32/// share a token prefix key to DIFFERENT snapshot hashes (no cross-adapter SSM
33/// restore). The fold is a strict no-op when `adapter_id == 0` (the base / no-
34/// adapter sentinel), so base keying is BYTE-IDENTICAL to the pre-LoRA hash and
35/// existing prefix-cache/snapshot hit rates are unchanged.
36pub(crate) fn hash_token_prefix(tokens: &[u32], count: usize, adapter_id: u64) -> u64 {
37    let mut h: u64 = 0xcbf29ce484222325; // FNV-1a basis
38    if adapter_id != 0 {
39        h ^= adapter_id;
40        h = h.wrapping_mul(0x100000001b3);
41    }
42    for &t in &tokens[..count] {
43        h ^= t as u64;
44        h = h.wrapping_mul(0x100000001b3);
45    }
46    h
47}
48
49/// Thread-safe radix tree prefix cache.
50///
51/// SSM snapshots are stored in a separate `SsmSnapshotIndex`, decoupled from
52/// tree node lifetime. This ensures snapshots survive KV cache eviction.
53/// Lock ordering: acquire `inner` first (then release), then `snapshot_index`.
54pub struct RadixTree {
55    inner: Mutex<RadixTreeInner>,
56    snapshot_index: Mutex<SsmSnapshotIndex>,
57}
58
59impl Default for RadixTree {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl RadixTree {
66    pub fn new() -> Self {
67        Self {
68            inner: Mutex::new(RadixTreeInner::new()),
69            snapshot_index: Mutex::new(SsmSnapshotIndex::new()),
70        }
71    }
72}
73
74impl PrefixCache for RadixTree {
75    fn lookup(
76        &self,
77        tokens: &[u32],
78        block_size: usize,
79        session_hash: u64,
80        adapter_id: u64,
81    ) -> PrefixMatch {
82        // Phase 1: walk tree (lock inner, then release)
83        let (matched_blocks, matched_disk_block_ids, matched_tokens) = {
84            let mut inner = self.inner.lock();
85            let (blocks, disk, matched) = inner.walk(tokens, block_size, adapter_id);
86            if matched > 0 {
87                inner.inc_refs(tokens, block_size, matched, adapter_id);
88                crate::prefix_cache::record_cache_hit(matched);
89            } else {
90                crate::prefix_cache::record_cache_miss();
91            }
92            (blocks, disk, matched)
93        };
94        // Phase 2: snapshot lookup (lock snapshot_index, inner NOT held).
95        // Tier-aware: `lookup_tiered` returns the deepest anchor across resident
96        // AND spilled entries. A resident hit populates `ssm_snapshot` (restore
97        // directly); a spilled hit populates `ssm_snapshot_tier_key` (caller
98        // faults it in). When nothing is spilled (ATLAS_SSM_TIER off) this is
99        // byte-identical to the old resident-only lookup.
100        let mut ssm_snapshot = None;
101        let mut ssm_snapshot_tokens = 0;
102        let mut ssm_snapshot_tier_key = None;
103        let mut ssm_snapshot_tier_tokens = 0;
104        let mut ssm_snapshot_is_tail = false;
105        if matched_tokens > 0 {
106            let mut idx = self.snapshot_index.lock();
107            if let Some(m) = idx.lookup_tiered(tokens, matched_tokens, session_hash, adapter_id) {
108                ssm_snapshot_is_tail = m.is_tail;
109                match m.loc {
110                    snapshot::SnapLoc::Hbm(slot) => {
111                        ssm_snapshot = Some(slot);
112                        ssm_snapshot_tokens = m.token_count;
113                    }
114                    snapshot::SnapLoc::Tier(key) => {
115                        ssm_snapshot_tier_key = Some(key);
116                        ssm_snapshot_tier_tokens = m.token_count;
117                    }
118                }
119            }
120        }
121        // Filter disk_block_ids to MAX-free entries when HSS isn't in use, so
122        // the caller can check `!matched_disk_block_ids.is_empty()` as the
123        // HSS-engaged signal. When HSS *is* in use every entry should be a
124        // valid disk_id (not MAX).
125        let matched_disk_block_ids = if matched_disk_block_ids.iter().all(|&id| id == u32::MAX) {
126            Vec::new()
127        } else {
128            matched_disk_block_ids
129        };
130        PrefixMatch {
131            matched_blocks,
132            matched_disk_block_ids,
133            matched_tokens,
134            ssm_snapshot,
135            ssm_snapshot_tokens,
136            ssm_snapshot_tier_key,
137            ssm_snapshot_tier_tokens,
138            ssm_snapshot_is_tail,
139        }
140    }
141
142    fn peek_matched_tokens(&self, tokens: &[u32], block_size: usize, adapter_id: u64) -> usize {
143        self.inner.lock().walk(tokens, block_size, adapter_id).2
144    }
145
146    fn insert(
147        &self,
148        tokens: &[u32],
149        block_table: &[u32],
150        disk_block_ids: &[u32],
151        block_size: usize,
152        matched_tokens: usize,
153        adapter_id: u64,
154    ) -> crate::prefix_cache::InsertAcquired {
155        self.inner.lock().insert(
156            tokens,
157            block_table,
158            disk_block_ids,
159            block_size,
160            matched_tokens,
161            adapter_id,
162        )
163    }
164
165    fn insert_with_snapshot(
166        &self,
167        tokens: &[u32],
168        block_table: &[u32],
169        disk_block_ids: &[u32],
170        block_size: usize,
171        snapshot_id: usize,
172        session_hash: u64,
173        matched_tokens: usize,
174        adapter_id: u64,
175    ) -> (Option<usize>, crate::prefix_cache::InsertAcquired) {
176        // Phase 1: insert tree nodes (lock inner, then release)
177        let newly_acquired = self.inner.lock().insert(
178            tokens,
179            block_table,
180            disk_block_ids,
181            block_size,
182            matched_tokens,
183            adapter_id,
184        );
185        // Phase 2: register snapshot in index (lock snapshot_index, inner NOT held)
186        let prefix_hash = hash_token_prefix(tokens, tokens.len(), adapter_id);
187        let mut idx = self.snapshot_index.lock();
188        let displaced = idx.insert(prefix_hash, snapshot_id, session_hash, tokens.len());
189        (displaced, newly_acquired)
190    }
191
192    fn insert_tail_snapshot(
193        &self,
194        tokens: &[u32],
195        snapshot_id: usize,
196        session_hash: u64,
197        adapter_id: u64,
198    ) -> Vec<usize> {
199        // Index only. The tree nodes for [0, tokens.len()) are inserted by the
200        // final chunk's `insert` (finalize_last); re-inserting the whole prefix
201        // here cost ~0.9 s/turn for zero benefit.
202        let prefix_hash = hash_token_prefix(tokens, tokens.len(), adapter_id);
203        self.snapshot_index
204            .lock()
205            .insert_tail(prefix_hash, snapshot_id, session_hash, tokens.len())
206    }
207
208    fn insert_tail_sibling_snapshot(
209        &self,
210        tokens: &[u32],
211        snapshot_id: usize,
212        session_hash: u64,
213        adapter_id: u64,
214    ) -> Option<usize> {
215        // Index only, like the tail (finalize_last's insert lays the tree nodes).
216        let prefix_hash = hash_token_prefix(tokens, tokens.len(), adapter_id);
217        self.snapshot_index.lock().insert_tail_sibling(
218            prefix_hash,
219            snapshot_id,
220            session_hash,
221            tokens.len(),
222        )
223    }
224
225    fn insert_intermediate_snapshot(
226        &self,
227        tokens: &[u32],
228        _block_table: &[u32],
229        _disk_block_ids: &[u32],
230        _block_size: usize,
231        snapshot_id: usize,
232        session_hash: u64,
233        _matched_tokens: usize,
234        adapter_id: u64,
235    ) -> Option<usize> {
236        // Intermediate snapshots go directly into the index with the correct
237        // token boundary (tokens.len()). Tree nodes are already inserted by
238        // a prior `insert()` call, which handled the ref_count bookkeeping.
239        let prefix_hash = hash_token_prefix(tokens, tokens.len(), adapter_id);
240        let mut idx = self.snapshot_index.lock();
241        idx.insert(prefix_hash, snapshot_id, session_hash, tokens.len())
242    }
243
244    fn release(&self, tokens: &[u32], block_size: usize, adapter_id: u64) {
245        self.inner
246            .lock()
247            .dec_refs(tokens, block_size, tokens.len(), adapter_id);
248    }
249
250    fn release_matched(
251        &self,
252        tokens: &[u32],
253        block_size: usize,
254        matched_tokens: usize,
255        adapter_id: u64,
256    ) {
257        self.inner
258            .lock()
259            .dec_refs(tokens, block_size, matched_tokens, adapter_id);
260    }
261
262    fn evict(&self, num_blocks: usize) -> EvictedBlocks {
263        let (physical, disk) = self.inner.lock().evict(num_blocks);
264        // Filter MAX sentinels out — the caller only needs disk_block_ids to
265        // dec_disk_ref on, and MAX entries don't correspond to a live HSS ref.
266        let disk_block_ids: Vec<u32> = disk.into_iter().filter(|&id| id != u32::MAX).collect();
267        EvictedBlocks {
268            physical,
269            disk_block_ids,
270        }
271    }
272
273    fn evict_snapshot_lru(&self) -> Option<usize> {
274        self.snapshot_index.lock().evict_lru()
275    }
276
277    fn evict_snapshot_to_tier(&self, min_tokens: usize) -> Option<crate::prefix_cache::TierEvict> {
278        self.snapshot_index.lock().evict_to_tier(min_tokens)
279    }
280
281    fn promote_snapshot(&self, key: u64, new_slot: usize) -> bool {
282        self.snapshot_index.lock().promote(key, new_slot)
283    }
284
285    fn forget_snapshot_tier_key(&self, key: u64) -> bool {
286        self.snapshot_index.lock().forget_tiered(key)
287    }
288
289    fn snapshot_count(&self) -> usize {
290        self.snapshot_index.lock().len()
291    }
292
293    fn stats(&self) -> (usize, usize) {
294        let inner = self.inner.lock();
295        let entries = inner.num_entries();
296        (entries, entries)
297    }
298}