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