Skip to main content

ferrox_models/
prefix_cache.rs

1//! KV-prefix caching: when a new request's tokens share a leading
2//! subsequence with a previously processed request, skip recomputing
3//! the KV state for that shared prefix entirely, restoring it from a
4//! stored snapshot instead of running `forward_batch` over tokens
5//! that were already processed.
6//!
7//! This is the harder sibling of `ferrox-server::cache::ResponseCache`
8//! (which only helps *exact*-repeat requests): prefix caching helps
9//! any request that *starts with* something seen before, which is the
10//! common case for multi-turn chat (each turn's full prompt is the
11//! previous turn's prompt plus a little more) even when no single
12//! request repeats exactly.
13//!
14//! Deliberately scoped: this does a linear scan over a small,
15//! LRU-bounded set of stored prefixes to find the longest common
16//! prefix, not a trie/radix-tree structure (vLLM's and SGLang's
17//! RadixAttention do this properly at production scale). For the
18//! small number of concurrent conversations a demo server actually
19//! handles, a linear scan is simpler and correctness is easier to
20//! verify.
21
22use ferrox_core::cache::KvCache;
23
24/// A stored snapshot: the tokens processed so far, the resulting
25/// per-layer KV cache state, and the logits that predict the token
26/// immediately after `tokens` (needed so a request that matches this
27/// prefix *exactly* -- no new tokens at all -- doesn't need any
28/// computation to know what to generate next).
29#[derive(Clone)]
30struct StoredPrefix {
31    tokens: Vec<usize>,
32    kv_caches: Vec<KvCache>,
33    pending_logits: Vec<f32>,
34}
35
36/// LRU-bounded store of `StoredPrefix` snapshots, searched for the
37/// longest common prefix with an incoming token sequence.
38pub struct PrefixCache {
39    entries: Vec<StoredPrefix>,
40    max_entries: usize,
41    hits_positions_reused: u64,
42    hits_count: u64,
43    misses_count: u64,
44}
45
46/// What was found (or not) for an incoming token sequence.
47pub struct PrefixMatch {
48    /// How many leading tokens matched a stored prefix (0 if none).
49    pub matched_len: usize,
50    /// Restored KV cache state covering exactly `matched_len`
51    /// positions, ready to continue from. `None` if `matched_len == 0`.
52    pub kv_caches: Option<Vec<KvCache>>,
53    /// Logits predicting the token at position `matched_len`, valid
54    /// only when `matched_len > 0`.
55    pub pending_logits: Option<Vec<f32>>,
56}
57
58impl PrefixCache {
59    pub fn new(max_entries: usize) -> Self {
60        PrefixCache {
61            entries: Vec::new(),
62            max_entries,
63            hits_positions_reused: 0,
64            hits_count: 0,
65            misses_count: 0,
66        }
67    }
68
69    /// Finds the stored prefix with the longest common leading
70    /// subsequence with `tokens`, and returns a ready-to-use clone of
71    /// its KV state truncated to exactly that common length (a stored
72    /// prefix may itself be longer than the common part, if a later,
73    /// different continuation was stored under it -- the KV cache is
74    /// truncated to the matching length before being handed back, so
75    /// the caller never sees state from a divergent continuation).
76    pub fn find_longest_prefix(&mut self, tokens: &[usize]) -> PrefixMatch {
77        let mut best: Option<(usize, &StoredPrefix)> = None;
78        for entry in &self.entries {
79            let common = common_prefix_len(&entry.tokens, tokens);
80            if common > 0 && best.map(|(len, _)| common > len).unwrap_or(true) {
81                best = Some((common, entry));
82            }
83        }
84
85        match best {
86            Some((matched_len, entry)) => {
87                self.hits_count += 1;
88                self.hits_positions_reused += matched_len as u64;
89
90                let mut kv_caches = entry.kv_caches.clone();
91                for cache in kv_caches.iter_mut() {
92                    cache.truncate(matched_len);
93                }
94
95                // The stored pending_logits predict the token
96                // immediately after entry.tokens' FULL length. They're
97                // only valid to hand back if the match covers that
98                // entire stored sequence (matched_len ==
99                // entry.tokens.len()); a partial match into the middle
100                // of a longer stored sequence means the caller is
101                // asking about position `matched_len`, not
102                // `entry.tokens.len()`, and reusing the stored logits
103                // there would silently answer the wrong question.
104                let pending_logits = if matched_len == entry.tokens.len() {
105                    Some(entry.pending_logits.clone())
106                } else {
107                    None
108                };
109
110                PrefixMatch {
111                    matched_len,
112                    kv_caches: Some(kv_caches),
113                    pending_logits,
114                }
115            }
116            None => {
117                self.misses_count += 1;
118                PrefixMatch {
119                    matched_len: 0,
120                    kv_caches: None,
121                    pending_logits: None,
122                }
123            }
124        }
125    }
126
127    /// Stores a snapshot for `tokens` (all tokens processed so far,
128    /// prompt plus any generated continuation) with the given KV cache
129    /// state and next-token logits, evicting the least-recently-stored
130    /// entry if already at capacity.
131    pub fn store(&mut self, tokens: Vec<usize>, kv_caches: Vec<KvCache>, pending_logits: Vec<f32>) {
132        if self.entries.len() >= self.max_entries {
133            self.entries.remove(0);
134        }
135        self.entries.push(StoredPrefix {
136            tokens,
137            kv_caches,
138            pending_logits,
139        });
140    }
141
142    pub fn stats(&self) -> PrefixCacheStats {
143        PrefixCacheStats {
144            hits: self.hits_count,
145            misses: self.misses_count,
146            entries: self.entries.len(),
147            total_positions_reused: self.hits_positions_reused,
148        }
149    }
150}
151
152#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
153pub struct PrefixCacheStats {
154    pub hits: u64,
155    pub misses: u64,
156    pub entries: usize,
157    pub total_positions_reused: u64,
158}
159
160fn common_prefix_len(a: &[usize], b: &[usize]) -> usize {
161    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn dummy_cache(seq_len: usize) -> KvCache {
169        let mut cache = KvCache::new(1, 1);
170        for i in 0..seq_len {
171            cache.push(&[i as f32], &[i as f32 * 10.0]).unwrap();
172        }
173        cache
174    }
175
176    #[test]
177    fn empty_cache_always_misses() {
178        let mut cache = PrefixCache::new(4);
179        let m = cache.find_longest_prefix(&[1, 2, 3]);
180        assert_eq!(m.matched_len, 0);
181        assert!(m.kv_caches.is_none());
182        assert_eq!(cache.stats().misses, 1);
183    }
184
185    #[test]
186    fn exact_prefix_match_returns_full_length_and_pending_logits() {
187        let mut cache = PrefixCache::new(4);
188        cache.store(vec![1, 2, 3], vec![dummy_cache(3)], vec![0.1, 0.2]);
189
190        let m = cache.find_longest_prefix(&[1, 2, 3]);
191        assert_eq!(m.matched_len, 3);
192        assert!(m.kv_caches.is_some());
193        assert_eq!(m.pending_logits, Some(vec![0.1, 0.2]));
194        assert_eq!(cache.stats().hits, 1);
195    }
196
197    #[test]
198    fn extended_request_matches_the_shared_prefix_length() {
199        let mut cache = PrefixCache::new(4);
200        cache.store(vec![1, 2, 3, 4, 5], vec![dummy_cache(5)], vec![9.9]);
201
202        // New request extends the stored one with two more tokens.
203        let m = cache.find_longest_prefix(&[1, 2, 3, 4, 5, 6, 7]);
204        assert_eq!(
205            m.matched_len, 5,
206            "must match the full stored prefix, not just a partial one"
207        );
208        assert_eq!(m.pending_logits, Some(vec![9.9]));
209    }
210
211    #[test]
212    fn partial_divergent_match_returns_only_the_common_length_and_no_stale_logits() {
213        let mut cache = PrefixCache::new(4);
214        cache.store(vec![1, 2, 3, 4, 5], vec![dummy_cache(5)], vec![9.9]);
215
216        // Diverges after the first 3 tokens.
217        let m = cache.find_longest_prefix(&[1, 2, 3, 9, 9]);
218        assert_eq!(m.matched_len, 3);
219        assert!(
220            m.kv_caches.is_some(),
221            "a real KV-state saving still exists for the matched prefix"
222        );
223        assert!(
224            m.pending_logits.is_none(),
225            "stored pending_logits predicted the token after the FULL stored sequence, not after the partial match point -- must not be reused here"
226        );
227    }
228
229    #[test]
230    fn no_common_prefix_at_all_is_a_clean_miss() {
231        let mut cache = PrefixCache::new(4);
232        cache.store(vec![1, 2, 3], vec![dummy_cache(3)], vec![1.0]);
233        let m = cache.find_longest_prefix(&[9, 8, 7]);
234        assert_eq!(m.matched_len, 0);
235    }
236
237    #[test]
238    fn picks_the_longest_match_among_several_stored_entries() {
239        let mut cache = PrefixCache::new(4);
240        cache.store(vec![1, 2], vec![dummy_cache(2)], vec![0.0]);
241        cache.store(vec![1, 2, 3, 4], vec![dummy_cache(4)], vec![0.0]);
242        cache.store(vec![1, 2, 3], vec![dummy_cache(3)], vec![0.0]);
243
244        let m = cache.find_longest_prefix(&[1, 2, 3, 4, 5]);
245        assert_eq!(
246            m.matched_len, 4,
247            "the longest stored prefix that's actually a prefix of the query must win"
248        );
249    }
250
251    #[test]
252    fn evicts_oldest_entry_when_at_capacity() {
253        let mut cache = PrefixCache::new(2);
254        cache.store(vec![1, 1], vec![dummy_cache(2)], vec![0.0]);
255        cache.store(vec![2, 2], vec![dummy_cache(2)], vec![0.0]);
256        cache.store(vec![3, 3], vec![dummy_cache(2)], vec![0.0]); // evicts [1,1]
257
258        assert_eq!(
259            cache.find_longest_prefix(&[1, 1]).matched_len,
260            0,
261            "oldest entry must have been evicted"
262        );
263        assert_eq!(cache.find_longest_prefix(&[2, 2]).matched_len, 2);
264        assert_eq!(cache.find_longest_prefix(&[3, 3]).matched_len, 2);
265    }
266
267    #[test]
268    fn stats_track_positions_reused_not_just_hit_count() {
269        let mut cache = PrefixCache::new(4);
270        cache.store(
271            vec![1, 2, 3, 4, 5, 6, 7, 8],
272            vec![dummy_cache(8)],
273            vec![0.0],
274        );
275        cache.find_longest_prefix(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
276        assert_eq!(
277            cache.stats().total_positions_reused,
278            8,
279            "should report exactly how many positions were reused, not just that a hit occurred"
280        );
281    }
282
283    /// The end-to-end property that matters most: using a prefix
284    /// cache's restored KV state to continue a real decoder must
285    /// produce EXACTLY the same output as processing the full token
286    /// sequence from scratch. If this fails, prefix caching is not a
287    /// safe optimization -- it would silently change model output
288    /// depending on cache state, which is far worse than no caching at
289    /// all.
290    #[test]
291    fn prefix_cached_continuation_matches_from_scratch_decode_exactly() {
292        use crate::config::glm_5_2;
293        use crate::decoder::Decoder;
294        use ferrox_core::cache::KvCache as RealKvCache;
295
296        let mut cfg = glm_5_2();
297        cfg.hidden_dim = 16;
298        cfg.n_heads = 4;
299        cfg.n_kv_heads = 2;
300        cfg.head_dim = 4;
301        cfg.moe.hidden_dim = 16;
302        cfg.moe.n_experts = 6;
303        cfg.moe.n_experts_active = 2;
304        cfg.moe.n_shared_experts = 1;
305        cfg.moe.expert_ffn_dim = 8;
306        let vocab = 16;
307
308        let shared_prefix = vec![1usize, 2, 3, 4, 5];
309        let full_sequence = vec![1usize, 2, 3, 4, 5, 6, 7];
310
311        // "Conversation A": process the shared prefix once, store it.
312        let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
313        let mut caches_a: Vec<RealKvCache> = (0..2)
314            .map(|_| RealKvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
315            .collect();
316        let prefix_logits = decoder_a.forward_batch(&shared_prefix, 0, &mut caches_a);
317        let mut prefix_cache = PrefixCache::new(4);
318        prefix_cache.store(
319            shared_prefix.clone(),
320            caches_a,
321            prefix_logits.last().unwrap().clone(),
322        );
323
324        // "Conversation B": extends the shared prefix. Using the
325        // prefix cache, only the new suffix tokens should need
326        // computing.
327        let decoder_b = Decoder::new_random_small(cfg.clone(), 2, vocab); // same seed => identical weights
328        let m = prefix_cache.find_longest_prefix(&full_sequence);
329        assert_eq!(m.matched_len, 5);
330        let mut restored_caches = m.kv_caches.unwrap();
331        let suffix = &full_sequence[m.matched_len..];
332        let via_prefix_cache_logits =
333            decoder_b.forward_batch(suffix, m.matched_len, &mut restored_caches);
334
335        // Ground truth: process the ENTIRE sequence from scratch on an
336        // identically-seeded decoder with a fresh empty cache.
337        let decoder_c = Decoder::new_random_small(cfg, 2, vocab);
338        let mut fresh_caches: Vec<RealKvCache> = (0..2)
339            .map(|_| RealKvCache::new(decoder_c.config.n_kv_heads, decoder_c.config.head_dim))
340            .collect();
341        let from_scratch_logits = decoder_c.forward_batch(&full_sequence, 0, &mut fresh_caches);
342
343        // The prefix-cache path's logits for the suffix positions must
344        // match the from-scratch path's logits for those same
345        // positions exactly.
346        let from_scratch_suffix = &from_scratch_logits[m.matched_len..];
347        assert_eq!(via_prefix_cache_logits.len(), from_scratch_suffix.len());
348        for (pos, (a, b)) in via_prefix_cache_logits
349            .iter()
350            .zip(from_scratch_suffix.iter())
351            .enumerate()
352        {
353            for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() {
354                assert!(
355                    (x - y).abs() < 1e-3,
356                    "suffix position {pos}, logit {i}: via_prefix_cache={x} from_scratch={y}"
357                );
358            }
359        }
360
361        // And the KV cache state itself must match too, not just the
362        // final logits (in case a later request extends even further).
363        for (restored, fresh) in restored_caches.iter().zip(fresh_caches.iter()) {
364            assert_eq!(restored.seq_len, fresh.seq_len);
365            for (a, b) in restored.k.iter().zip(fresh.k.iter()) {
366                assert!((a - b).abs() < 1e-3);
367            }
368        }
369    }
370}