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