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//!
22//! "LRU-bounded" is now true. It was not: eviction dropped the oldest
23//! ARRIVAL while nothing on the hit path recorded that an entry had
24//! been used, so the policy was first-in-first-out under an LRU name.
25//! That inverted the cache's purpose, because the entry a prefix cache
26//! exists for -- the system prompt every request shares -- is the
27//! oldest one precisely because it is the most reused.
28//!
29//! What is still true of the scope: each entry CLONES its
30//! `Vec<KvCache>`, so N conversations off one system prompt hold N
31//! copies of its KV rather than sharing the pages. Fixing that is the
32//! radix cache's job (`crate::policy::radix`), which shares nodes and
33//! reference-counts pages, and which needs a serving path that reads
34//! paged KV before it can be wired in.
35
36use ferrox_core::cache::KvCache;
37
38/// A stored snapshot: the tokens processed so far, the resulting
39/// per-layer KV cache state, and the logits that predict the token
40/// immediately after `tokens` (needed so a request that matches this
41/// prefix *exactly* -- no new tokens at all -- doesn't need any
42/// computation to know what to generate next).
43#[derive(Clone)]
44struct StoredPrefix {
45    tokens: Vec<usize>,
46    kv_caches: Vec<KvCache>,
47    pending_logits: Vec<f32>,
48    /// Monotonic recency stamp; smallest = least recently used.
49    ///
50    /// A stamp per touch rather than reshuffling a dedicated LRU list,
51    /// the same shape `ferrox_core::expert_store` uses and for the same
52    /// reason: eviction pays an O(n) scan, which costs nothing here
53    /// because the lookup that precedes it is already O(n) over the
54    /// same vector.
55    last_used: u64,
56}
57
58/// LRU-bounded store of `StoredPrefix` snapshots, searched for the
59/// longest common prefix with an incoming token sequence.
60pub struct PrefixCache {
61    entries: Vec<StoredPrefix>,
62    max_entries: usize,
63    hits_positions_reused: u64,
64    hits_count: u64,
65    misses_count: u64,
66    /// Ticks on every hit and every store, so `last_used` orders
67    /// entries by when they were last USEFUL rather than by when they
68    /// arrived.
69    clock: u64,
70}
71
72/// What was found (or not) for an incoming token sequence.
73pub struct PrefixMatch {
74    /// How many leading tokens matched a stored prefix (0 if none).
75    pub matched_len: usize,
76    /// Restored KV cache state covering exactly `matched_len`
77    /// positions, ready to continue from. `None` if `matched_len == 0`.
78    pub kv_caches: Option<Vec<KvCache>>,
79    /// Logits predicting the token at position `matched_len`, valid
80    /// only when `matched_len > 0`.
81    pub pending_logits: Option<Vec<f32>>,
82}
83
84impl PrefixCache {
85    pub fn new(max_entries: usize) -> Self {
86        PrefixCache {
87            entries: Vec::new(),
88            max_entries,
89            hits_positions_reused: 0,
90            hits_count: 0,
91            misses_count: 0,
92            clock: 0,
93        }
94    }
95
96    /// Drops every stored prefix, keeping the capacity and the
97    /// lifetime hit/miss counters.
98    ///
99    /// For a KV-side cache rebuild. A stored prefix names positions in
100    /// an allocation that is about to stop existing, so handing one back
101    /// afterwards would restore another request's state into this one --
102    /// silently, since a KV cache carries no identity of its own. The
103    /// counters survive because they describe what this process has
104    /// served, which a re-split does not undo.
105    pub fn clear(&mut self) {
106        self.entries.clear();
107    }
108
109    /// Finds the stored prefix with the longest common leading
110    /// subsequence with `tokens`, and returns a ready-to-use clone of
111    /// its KV state truncated to exactly that common length (a stored
112    /// prefix may itself be longer than the common part, if a later,
113    /// different continuation was stored under it -- the KV cache is
114    /// truncated to the matching length before being handed back, so
115    /// the caller never sees state from a divergent continuation).
116    pub fn find_longest_prefix(&mut self, tokens: &[usize]) -> PrefixMatch {
117        let mut best: Option<(usize, usize)> = None; // (matched_len, index)
118        for (i, entry) in self.entries.iter().enumerate() {
119            let common = common_prefix_len(&entry.tokens, tokens);
120            if common > 0 && best.map(|(len, _)| common > len).unwrap_or(true) {
121                best = Some((common, i));
122            }
123        }
124
125        match best {
126            Some((matched_len, index)) => {
127                self.hits_count += 1;
128                self.hits_positions_reused += matched_len as u64;
129                // A HIT is what makes an entry worth keeping, so this
130                // is where recency has to be recorded. Without it the
131                // policy degenerates to first-in-first-out, and the one
132                // entry a prefix cache exists for -- a shared system
133                // prompt every request starts with -- is evicted as
134                // soon as `max_entries` newer prompts arrive, however
135                // often it is being reused.
136                self.clock += 1;
137                self.entries[index].last_used = self.clock;
138                let entry = &self.entries[index];
139
140                let mut kv_caches = entry.kv_caches.clone();
141                for cache in kv_caches.iter_mut() {
142                    cache.truncate(matched_len);
143                }
144
145                // The stored pending_logits predict the token
146                // immediately after entry.tokens' FULL length. They're
147                // only valid to hand back if the match covers that
148                // entire stored sequence (matched_len ==
149                // entry.tokens.len()); a partial match into the middle
150                // of a longer stored sequence means the caller is
151                // asking about position `matched_len`, not
152                // `entry.tokens.len()`, and reusing the stored logits
153                // there would silently answer the wrong question.
154                let pending_logits = if matched_len == entry.tokens.len() {
155                    Some(entry.pending_logits.clone())
156                } else {
157                    None
158                };
159
160                PrefixMatch {
161                    matched_len,
162                    kv_caches: Some(kv_caches),
163                    pending_logits,
164                }
165            }
166            None => {
167                self.misses_count += 1;
168                PrefixMatch {
169                    matched_len: 0,
170                    kv_caches: None,
171                    pending_logits: None,
172                }
173            }
174        }
175    }
176
177    /// Stores a snapshot for `tokens` (all tokens processed so far,
178    /// prompt plus any generated continuation) with the given KV cache
179    /// state and next-token logits, evicting the least recently USED
180    /// entry if already at capacity.
181    ///
182    /// Used, not stored. This used to drop `entries[0]` -- the oldest
183    /// arrival -- while the type documented itself as LRU-bounded. The
184    /// difference is the whole value of the cache: a system prompt that
185    /// every request shares is the oldest entry precisely BECAUSE it is
186    /// the most reused, so FIFO evicted the one entry worth keeping as
187    /// soon as `max_entries` newer prompts arrived, and the next
188    /// request off that system prompt recomputed all of it.
189    /// A cache that has evicted rows behind a sliding window (#61,
190    /// `FERROX_KV_WINDOW`) is REFUSED rather than stored. A stored
191    /// prefix is handed back truncated to an arbitrary common length,
192    /// and a windowed cache no longer holds the rows an arbitrary
193    /// truncation names -- so storing one would trade a cheap recompute
194    /// for a request that stops. Refusing loses the prefix cache for
195    /// the run, which is what the switch's documentation says it costs.
196    pub fn store(&mut self, tokens: Vec<usize>, kv_caches: Vec<KvCache>, pending_logits: Vec<f32>) {
197        if kv_caches.iter().any(|c| c.window().is_some()) {
198            return;
199        }
200        if self.entries.len() >= self.max_entries {
201            // An O(n) scan, over the same vector the lookup above
202            // already scans linearly -- so this costs nothing the
203            // design was not already paying.
204            if let Some(coldest) = self
205                .entries
206                .iter()
207                .enumerate()
208                .min_by_key(|(i, e)| (e.last_used, *i))
209                .map(|(i, _)| i)
210            {
211                self.entries.remove(coldest);
212            }
213        }
214        self.clock += 1;
215        self.entries.push(StoredPrefix {
216            last_used: self.clock,
217            tokens,
218            kv_caches,
219            pending_logits,
220        });
221    }
222
223    pub fn stats(&self) -> PrefixCacheStats {
224        PrefixCacheStats {
225            hits: self.hits_count,
226            misses: self.misses_count,
227            entries: self.entries.len(),
228            total_positions_reused: self.hits_positions_reused,
229        }
230    }
231}
232
233#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
234pub struct PrefixCacheStats {
235    pub hits: u64,
236    pub misses: u64,
237    pub entries: usize,
238    pub total_positions_reused: u64,
239}
240
241fn common_prefix_len(a: &[usize], b: &[usize]) -> usize {
242    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    fn dummy_cache(seq_len: usize) -> KvCache {
250        let mut cache = KvCache::new(1, 1);
251        for i in 0..seq_len {
252            cache.push(&[i as f32], &[i as f32 * 10.0]).unwrap();
253        }
254        cache
255    }
256
257    /// The bug this type was named after and did not have.
258    ///
259    /// A shared system prompt is the entry a prefix cache exists for,
260    /// and under FIFO it was the FIRST thing evicted -- it is the
261    /// oldest arrival precisely because it is the most reused. Here it
262    /// is kept hot by hits while `max_entries` newer prompts arrive,
263    /// and it must survive.
264    #[test]
265    fn a_prefix_that_keeps_being_hit_survives_newer_arrivals() {
266        let system: Vec<usize> = (0..8).collect();
267        let mut cache = PrefixCache::new(3);
268        cache.store(system.clone(), vec![dummy_cache(system.len())], vec![0.5]);
269
270        // Three unrelated prompts arrive, which is capacity twice over.
271        // Between each, the system prompt is used again.
272        for n in 0..3usize {
273            let hit = cache.find_longest_prefix(&system);
274            assert_eq!(
275                hit.matched_len,
276                system.len(),
277                "the system prompt must still be here before arrival {n}"
278            );
279            let other: Vec<usize> = (100 + n * 10..100 + n * 10 + 4).collect();
280            cache.store(other.clone(), vec![dummy_cache(other.len())], vec![0.5]);
281        }
282
283        let hit = cache.find_longest_prefix(&system);
284        assert_eq!(
285            hit.matched_len,
286            system.len(),
287            "a hot prefix was evicted while cold newer ones were kept"
288        );
289    }
290
291    /// And the converse: the entry nobody has touched is the one that
292    /// goes. Without this the first test could pass by never evicting
293    /// anything at all.
294    #[test]
295    fn the_least_recently_used_prefix_is_the_one_evicted() {
296        let mut cache = PrefixCache::new(2);
297        let cold: Vec<usize> = vec![1, 2, 3, 4];
298        let warm: Vec<usize> = vec![5, 6, 7, 8];
299        cache.store(cold.clone(), vec![dummy_cache(cold.len())], vec![0.5]);
300        cache.store(warm.clone(), vec![dummy_cache(warm.len())], vec![0.5]);
301
302        // Touch `warm` only, then push past capacity.
303        assert_eq!(cache.find_longest_prefix(&warm).matched_len, warm.len());
304        let fresh: Vec<usize> = vec![9, 10, 11, 12];
305        cache.store(fresh.clone(), vec![dummy_cache(fresh.len())], vec![0.5]);
306
307        assert_eq!(
308            cache.find_longest_prefix(&cold).matched_len,
309            0,
310            "the untouched entry should have been evicted"
311        );
312        assert_eq!(cache.find_longest_prefix(&warm).matched_len, warm.len());
313        assert_eq!(cache.find_longest_prefix(&fresh).matched_len, fresh.len());
314    }
315
316    /// With nothing ever hit, eviction still has to make progress and
317    /// has to be deterministic: equal stamps break toward the lower
318    /// index, so the oldest arrival goes, which is the FIFO behaviour
319    /// as a degenerate case rather than as the policy.
320    #[test]
321    fn untouched_entries_evict_oldest_first_and_capacity_is_never_exceeded() {
322        let mut cache = PrefixCache::new(2);
323        for n in 0..5usize {
324            let p: Vec<usize> = (n * 10..n * 10 + 4).collect();
325            cache.store(p.clone(), vec![dummy_cache(p.len())], vec![0.5]);
326        }
327        assert_eq!(cache.entries.len(), 2, "capacity must hold");
328        // The two most recent survive.
329        for n in [3usize, 4] {
330            let p: Vec<usize> = (n * 10..n * 10 + 4).collect();
331            assert_eq!(cache.find_longest_prefix(&p).matched_len, 4, "prompt {n}");
332        }
333        for n in [0usize, 1, 2] {
334            let p: Vec<usize> = (n * 10..n * 10 + 4).collect();
335            assert_eq!(cache.find_longest_prefix(&p).matched_len, 0, "prompt {n}");
336        }
337    }
338
339    #[test]
340    fn empty_cache_always_misses() {
341        let mut cache = PrefixCache::new(4);
342        let m = cache.find_longest_prefix(&[1, 2, 3]);
343        assert_eq!(m.matched_len, 0);
344        assert!(m.kv_caches.is_none());
345        assert_eq!(cache.stats().misses, 1);
346    }
347
348    #[test]
349    fn exact_prefix_match_returns_full_length_and_pending_logits() {
350        let mut cache = PrefixCache::new(4);
351        cache.store(vec![1, 2, 3], vec![dummy_cache(3)], vec![0.1, 0.2]);
352
353        let m = cache.find_longest_prefix(&[1, 2, 3]);
354        assert_eq!(m.matched_len, 3);
355        assert!(m.kv_caches.is_some());
356        assert_eq!(m.pending_logits, Some(vec![0.1, 0.2]));
357        assert_eq!(cache.stats().hits, 1);
358    }
359
360    #[test]
361    fn extended_request_matches_the_shared_prefix_length() {
362        let mut cache = PrefixCache::new(4);
363        cache.store(vec![1, 2, 3, 4, 5], vec![dummy_cache(5)], vec![9.9]);
364
365        // New request extends the stored one with two more tokens.
366        let m = cache.find_longest_prefix(&[1, 2, 3, 4, 5, 6, 7]);
367        assert_eq!(
368            m.matched_len, 5,
369            "must match the full stored prefix, not just a partial one"
370        );
371        assert_eq!(m.pending_logits, Some(vec![9.9]));
372    }
373
374    #[test]
375    fn partial_divergent_match_returns_only_the_common_length_and_no_stale_logits() {
376        let mut cache = PrefixCache::new(4);
377        cache.store(vec![1, 2, 3, 4, 5], vec![dummy_cache(5)], vec![9.9]);
378
379        // Diverges after the first 3 tokens.
380        let m = cache.find_longest_prefix(&[1, 2, 3, 9, 9]);
381        assert_eq!(m.matched_len, 3);
382        assert!(
383            m.kv_caches.is_some(),
384            "a real KV-state saving still exists for the matched prefix"
385        );
386        assert!(
387            m.pending_logits.is_none(),
388            "stored pending_logits predicted the token after the FULL stored sequence, not after the partial match point -- must not be reused here"
389        );
390    }
391
392    #[test]
393    fn no_common_prefix_at_all_is_a_clean_miss() {
394        let mut cache = PrefixCache::new(4);
395        cache.store(vec![1, 2, 3], vec![dummy_cache(3)], vec![1.0]);
396        let m = cache.find_longest_prefix(&[9, 8, 7]);
397        assert_eq!(m.matched_len, 0);
398    }
399
400    #[test]
401    fn picks_the_longest_match_among_several_stored_entries() {
402        let mut cache = PrefixCache::new(4);
403        cache.store(vec![1, 2], vec![dummy_cache(2)], vec![0.0]);
404        cache.store(vec![1, 2, 3, 4], vec![dummy_cache(4)], vec![0.0]);
405        cache.store(vec![1, 2, 3], vec![dummy_cache(3)], vec![0.0]);
406
407        let m = cache.find_longest_prefix(&[1, 2, 3, 4, 5]);
408        assert_eq!(
409            m.matched_len, 4,
410            "the longest stored prefix that's actually a prefix of the query must win"
411        );
412    }
413
414    #[test]
415    fn evicts_oldest_entry_when_at_capacity() {
416        let mut cache = PrefixCache::new(2);
417        cache.store(vec![1, 1], vec![dummy_cache(2)], vec![0.0]);
418        cache.store(vec![2, 2], vec![dummy_cache(2)], vec![0.0]);
419        cache.store(vec![3, 3], vec![dummy_cache(2)], vec![0.0]); // evicts [1,1]
420
421        assert_eq!(
422            cache.find_longest_prefix(&[1, 1]).matched_len,
423            0,
424            "oldest entry must have been evicted"
425        );
426        assert_eq!(cache.find_longest_prefix(&[2, 2]).matched_len, 2);
427        assert_eq!(cache.find_longest_prefix(&[3, 3]).matched_len, 2);
428    }
429
430    #[test]
431    fn stats_track_positions_reused_not_just_hit_count() {
432        let mut cache = PrefixCache::new(4);
433        cache.store(
434            vec![1, 2, 3, 4, 5, 6, 7, 8],
435            vec![dummy_cache(8)],
436            vec![0.0],
437        );
438        cache.find_longest_prefix(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
439        assert_eq!(
440            cache.stats().total_positions_reused,
441            8,
442            "should report exactly how many positions were reused, not just that a hit occurred"
443        );
444    }
445
446    /// The end-to-end property that matters most: using a prefix
447    /// cache's restored KV state to continue a real decoder must
448    /// produce EXACTLY the same output as processing the full token
449    /// sequence from scratch. If this fails, prefix caching is not a
450    /// safe optimization -- it would silently change model output
451    /// depending on cache state, which is far worse than no caching at
452    /// all.
453    #[test]
454    fn prefix_cached_continuation_matches_from_scratch_decode_exactly() {
455        use crate::config::glm_5_2;
456        use crate::decoder::Decoder;
457        use ferrox_core::cache::KvCache as RealKvCache;
458
459        let mut cfg = glm_5_2();
460        cfg.hidden_dim = 16;
461        cfg.n_heads = 4;
462        cfg.n_kv_heads = 2;
463        cfg.head_dim = 4;
464        cfg.moe.hidden_dim = 16;
465        cfg.moe.n_experts = 6;
466        cfg.moe.n_experts_active = 2;
467        cfg.moe.n_shared_experts = 1;
468        cfg.moe.expert_ffn_dim = 8;
469        let vocab = 16;
470
471        let shared_prefix = vec![1usize, 2, 3, 4, 5];
472        let full_sequence = vec![1usize, 2, 3, 4, 5, 6, 7];
473
474        // "Conversation A": process the shared prefix once, store it.
475        let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
476        let mut caches_a: Vec<RealKvCache> = (0..2)
477            .map(|_| RealKvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
478            .collect();
479        let prefix_logits = decoder_a.forward_batch(&shared_prefix, 0, &mut caches_a);
480        let mut prefix_cache = PrefixCache::new(4);
481        prefix_cache.store(
482            shared_prefix.clone(),
483            caches_a,
484            prefix_logits.last().unwrap().clone(),
485        );
486
487        // "Conversation B": extends the shared prefix. Using the
488        // prefix cache, only the new suffix tokens should need
489        // computing.
490        let decoder_b = Decoder::new_random_small(cfg.clone(), 2, vocab); // same seed => identical weights
491        let m = prefix_cache.find_longest_prefix(&full_sequence);
492        assert_eq!(m.matched_len, 5);
493        let mut restored_caches = m.kv_caches.unwrap();
494        let suffix = &full_sequence[m.matched_len..];
495        let via_prefix_cache_logits =
496            decoder_b.forward_batch(suffix, m.matched_len, &mut restored_caches);
497
498        // Ground truth: process the ENTIRE sequence from scratch on an
499        // identically-seeded decoder with a fresh empty cache.
500        let decoder_c = Decoder::new_random_small(cfg, 2, vocab);
501        let mut fresh_caches: Vec<RealKvCache> = (0..2)
502            .map(|_| RealKvCache::new(decoder_c.config.n_kv_heads, decoder_c.config.head_dim))
503            .collect();
504        let from_scratch_logits = decoder_c.forward_batch(&full_sequence, 0, &mut fresh_caches);
505
506        // The prefix-cache path's logits for the suffix positions must
507        // match the from-scratch path's logits for those same
508        // positions exactly.
509        let from_scratch_suffix = &from_scratch_logits[m.matched_len..];
510        assert_eq!(via_prefix_cache_logits.len(), from_scratch_suffix.len());
511        for (pos, (a, b)) in via_prefix_cache_logits
512            .iter()
513            .zip(from_scratch_suffix.iter())
514            .enumerate()
515        {
516            for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() {
517                assert!(
518                    (x - y).abs() < 1e-3,
519                    "suffix position {pos}, logit {i}: via_prefix_cache={x} from_scratch={y}"
520                );
521            }
522        }
523
524        // And the KV cache state itself must match too, not just the
525        // final logits (in case a later request extends even further).
526        for (restored, fresh) in restored_caches.iter().zip(fresh_caches.iter()) {
527            assert_eq!(restored.positions(), fresh.positions());
528            for (a, b) in restored.k.iter().zip(fresh.k.iter()) {
529                assert!((a - b).abs() < 1e-3);
530            }
531        }
532    }
533}