1use ferrox_core::cache::KvCache;
23
24#[derive(Clone)]
30struct StoredPrefix {
31 tokens: Vec<usize>,
32 kv_caches: Vec<KvCache>,
33 pending_logits: Vec<f32>,
34}
35
36pub 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
46pub struct PrefixMatch {
48 pub matched_len: usize,
50 pub kv_caches: Option<Vec<KvCache>>,
53 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 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 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 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 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 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]); 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 #[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 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 let decoder_b = Decoder::new_random_small(cfg.clone(), 2, vocab); 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 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 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 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}