Skip to main content

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