1use std::{
27 hash::BuildHasherDefault,
28 mem::size_of_val,
29 sync::{
30 Arc,
31 atomic::{AtomicU64, Ordering},
32 },
33};
34
35use aho_corasick::AhoCorasick;
36use moka::sync::Cache;
37use rustc_hash::FxHasher;
38
39use crate::{TokenIdType, traits::Encoder};
40
41type Blake3Hash = [u8; 32];
43
44type PrefixHasher = BuildHasherDefault<FxHasher>;
47
48type PrefixCache = Cache<Blake3Hash, Arc<[TokenIdType]>, PrefixHasher>;
50
51fn boundaries_with(text: &str, matcher: &AhoCorasick) -> Vec<usize> {
62 let mut boundaries: Vec<usize> = matcher
63 .find_overlapping_iter(text)
64 .map(|m| m.end())
65 .filter(|&end| end < text.len())
66 .collect();
67 boundaries.sort_unstable();
68 boundaries.dedup();
69 boundaries
70}
71
72#[cfg(test)]
75fn find_special_token_boundaries(text: &str, special_tokens: &[&str]) -> Vec<usize> {
76 if special_tokens.is_empty() {
77 return Vec::new();
78 }
79 let matcher = AhoCorasick::new(special_tokens)
80 .expect("special tokens form a valid Aho-Corasick automaton");
81 boundaries_with(text, &matcher)
82}
83
84pub type CacheEventFn = Arc<dyn Fn() + Send + Sync>;
88
89pub struct L1Cache {
93 cache: PrefixCache,
95 matcher: Option<AhoCorasick>,
98 hits: AtomicU64,
99 misses: AtomicU64,
100 on_hit: Option<CacheEventFn>,
101 on_miss: Option<CacheEventFn>,
102}
103
104impl L1Cache {
105 pub fn new(max_memory: usize, special_tokens: Vec<String>) -> Self {
108 let cache = Cache::builder()
112 .max_capacity(max_memory as u64)
113 .weigher(|_k: &Blake3Hash, tokens: &Arc<[TokenIdType]>| -> u32 {
114 size_of_val(tokens.as_ref()).min(u32::MAX as usize) as u32
115 })
116 .build_with_hasher(PrefixHasher::default());
117
118 let matcher = (!special_tokens.is_empty()).then(|| {
120 AhoCorasick::new(&special_tokens)
121 .expect("special tokens form a valid Aho-Corasick automaton")
122 });
123
124 Self {
125 cache,
126 matcher,
127 hits: AtomicU64::new(0),
128 misses: AtomicU64::new(0),
129 on_hit: None,
130 on_miss: None,
131 }
132 }
133
134 pub fn set_observer(&mut self, on_hit: CacheEventFn, on_miss: CacheEventFn) {
136 self.on_hit = Some(on_hit);
137 self.on_miss = Some(on_miss);
138 }
139
140 fn boundaries(&self, text: &str) -> Vec<usize> {
144 match &self.matcher {
145 Some(matcher) => boundaries_with(text, matcher),
146 None => Vec::new(),
147 }
148 }
149
150 pub fn longest_prefix_match(&self, input: &str) -> Option<(Vec<TokenIdType>, usize, usize)> {
157 let boundaries = self.boundaries(input);
158
159 if boundaries.is_empty() {
160 self.misses.fetch_add(1, Ordering::Relaxed);
161 if let Some(cb) = &self.on_miss {
162 cb();
163 }
164 return None;
165 }
166
167 let deepest_boundary = *boundaries.last().expect("boundaries is non-empty here");
170
171 let mut hasher = blake3::Hasher::new();
173 let mut prefix_hashes = Vec::with_capacity(boundaries.len());
174 let mut last_pos = 0;
175 let bytes = input.as_bytes();
176 for &boundary_pos in &boundaries {
177 hasher.update(&bytes[last_pos..boundary_pos]);
178 prefix_hashes.push((boundary_pos, *hasher.finalize().as_bytes()));
180 last_pos = boundary_pos;
181 }
182
183 for (boundary_pos, hash_bytes) in prefix_hashes.into_iter().rev() {
186 if let Some(tokens) = self.cache.get(&hash_bytes) {
187 self.hits.fetch_add(1, Ordering::Relaxed);
188 if let Some(cb) = &self.on_hit {
189 cb();
190 }
191 return Some((tokens.to_vec(), boundary_pos, deepest_boundary));
192 }
193 }
194
195 self.misses.fetch_add(1, Ordering::Relaxed);
196 if let Some(cb) = &self.on_miss {
197 cb();
198 }
199 None
200 }
201
202 pub fn insert_at_boundaries<E: Encoder + ?Sized>(
210 &self,
211 input: &str,
212 tokenizer: &E,
213 ) -> anyhow::Result<()> {
214 let boundaries = self.boundaries(input);
215 if boundaries.is_empty() {
216 return Ok(());
217 }
218 self.populate_boundaries(input, &boundaries, tokenizer)?;
219 Ok(())
220 }
221
222 pub fn populate_and_encode<E: Encoder + ?Sized>(
231 &self,
232 input: &str,
233 tokenizer: &E,
234 ) -> anyhow::Result<Vec<TokenIdType>> {
235 let boundaries = self.boundaries(input);
236 if boundaries.is_empty() {
237 return Ok(tokenizer.encode(input)?.token_ids().to_vec());
239 }
240
241 let mut running = self.populate_boundaries(input, &boundaries, tokenizer)?;
243
244 let tail_start = *boundaries.last().expect("boundaries is non-empty here");
247 let tail = tokenizer.encode(&input[tail_start..])?;
248 running.extend_from_slice(tail.token_ids());
249 Ok(running)
250 }
251
252 fn populate_boundaries<E: Encoder + ?Sized>(
256 &self,
257 input: &str,
258 boundaries: &[usize],
259 tokenizer: &E,
260 ) -> anyhow::Result<Vec<TokenIdType>> {
261 let mut hasher = blake3::Hasher::new();
262 let mut running_tokens: Vec<TokenIdType> = Vec::new();
263 let mut last_pos = 0;
264 let bytes = input.as_bytes();
265
266 for &boundary_pos in boundaries {
267 hasher.update(&bytes[last_pos..boundary_pos]);
269 let hash_bytes: Blake3Hash = *hasher.finalize().as_bytes();
270
271 let seg = tokenizer.encode(&input[last_pos..boundary_pos])?;
275 running_tokens.extend_from_slice(seg.token_ids());
276
277 let prefix_tokens: Arc<[TokenIdType]> = running_tokens.as_slice().into();
280 self.cache.insert(hash_bytes, prefix_tokens);
281
282 last_pos = boundary_pos;
283 }
284
285 Ok(running_tokens)
286 }
287
288 pub fn extend_after_match<E: Encoder + ?Sized>(
305 &self,
306 input: &str,
307 prefix_tokens: Vec<TokenIdType>,
308 prefix_len: usize,
309 deepest_boundary: usize,
310 tokenizer: &E,
311 ) -> anyhow::Result<Vec<TokenIdType>> {
312 let deepest = (deepest_boundary > prefix_len).then_some(deepest_boundary);
318
319 let Some(deepest) = deepest else {
320 let suffix_enc = tokenizer.encode(&input[prefix_len..])?;
323 let mut merged = prefix_tokens;
324 merged.extend_from_slice(suffix_enc.token_ids());
325 return Ok(merged);
326 };
327
328 let seg_a = tokenizer.encode(&input[prefix_len..deepest])?;
332 let mut cumulative = prefix_tokens;
333 cumulative.extend_from_slice(seg_a.token_ids());
334
335 let mut hasher = blake3::Hasher::new();
339 hasher.update(&input.as_bytes()[..deepest]);
340 let hash_bytes: Blake3Hash = *hasher.finalize().as_bytes();
341
342 let tokens: Arc<[TokenIdType]> = cumulative.as_slice().into();
343 self.cache.insert(hash_bytes, tokens);
344
345 let seg_b = tokenizer.encode(&input[deepest..])?;
347 let mut merged = cumulative;
348 merged.extend_from_slice(seg_b.token_ids());
349 Ok(merged)
350 }
351
352 pub fn len(&self) -> usize {
355 self.cache.run_pending_tasks();
356 self.cache.entry_count() as usize
357 }
358
359 pub fn is_empty(&self) -> bool {
360 self.len() == 0
361 }
362
363 pub fn stats(&self) -> L1CacheStats {
364 self.cache.run_pending_tasks();
366 let hits = self.hits.load(Ordering::Relaxed);
367 let misses = self.misses.load(Ordering::Relaxed);
368 let total_requests = hits + misses;
369
370 L1CacheStats {
371 hits,
372 misses,
373 entries: self.cache.entry_count() as usize,
374 memory_bytes: self.cache.weighted_size() as usize,
375 hit_rate: if total_requests > 0 {
376 hits as f64 / total_requests as f64
377 } else {
378 0.0
379 },
380 }
381 }
382
383 pub fn clear(&self) {
384 self.cache.invalidate_all();
385 self.cache.run_pending_tasks();
386 self.hits.store(0, Ordering::Relaxed);
387 self.misses.store(0, Ordering::Relaxed);
388 }
389}
390
391#[derive(Debug, Clone)]
392pub struct L1CacheStats {
393 pub hits: u64,
394 pub misses: u64,
395 pub entries: usize,
396 pub memory_bytes: usize,
397 pub hit_rate: f64,
398}
399
400#[cfg(test)]
401mod tests {
402 use std::sync::Arc;
403
404 use super::*;
405 use crate::{HuggingFaceTokenizer, traits::Tokenizer};
406
407 const TINYLLAMA_PATH: &str = concat!(
410 env!("CARGO_MANIFEST_DIR"),
411 "/../llm/tests/data/sample-models/TinyLlama_v1.1/tokenizer.json"
412 );
413
414 const SPECIALS: &[&str] = &["<s>", "</s>"];
415
416 fn load_tokenizer() -> Arc<dyn Tokenizer> {
417 Arc::new(HuggingFaceTokenizer::from_file(TINYLLAMA_PATH).expect("load TinyLlama"))
418 }
419
420 fn test_cache(max_memory: usize) -> L1Cache {
422 L1Cache::new(
423 max_memory,
424 SPECIALS.iter().map(|s| (*s).to_string()).collect(),
425 )
426 }
427
428 #[test]
429 fn boundaries_are_after_each_special_token_occurrence() {
430 let input = "<s>system\nHi</s><s>user\nHello</s>";
431 let bounds = find_special_token_boundaries(input, SPECIALS);
432 assert_eq!(bounds.len(), 3);
434 for w in bounds.windows(2) {
435 assert!(w[0] < w[1], "boundaries must be strictly increasing");
436 }
437 assert!(bounds.iter().all(|&b| b < input.len()));
438 }
439
440 #[test]
441 fn no_special_tokens_yields_no_boundaries() {
442 assert!(find_special_token_boundaries("plain text", &[]).is_empty());
443 }
444
445 #[test]
446 fn insert_then_lookup_finds_shared_prefix() {
447 let cache = test_cache(1024 * 1024);
448 let tokenizer = load_tokenizer();
449
450 let warm = "<s>system\nYou are helpful.</s><s>user\nHi</s>";
451 cache
452 .insert_at_boundaries(warm, tokenizer.as_ref())
453 .unwrap();
454 assert!(!cache.is_empty());
455
456 let target = "<s>system\nYou are helpful.</s><s>user\nDifferent question</s>";
457 let (tokens, offset, _deepest) = cache
458 .longest_prefix_match(target)
459 .expect("shared prefix should match");
460 assert!(offset > 0);
461 assert!(!tokens.is_empty());
462 }
463
464 #[test]
465 fn miss_increments_misses_counter() {
466 let cache = test_cache(1024 * 1024);
467 assert!(
468 cache
469 .longest_prefix_match("plain text no specials")
470 .is_none()
471 );
472 assert_eq!(cache.stats().misses, 1);
473 }
474
475 #[test]
476 fn hit_increments_hits_counter() {
477 let cache = test_cache(1024 * 1024);
478 let tokenizer = load_tokenizer();
479 let warm = "<s>system\nA.</s><s>user\nB</s>";
480 cache
481 .insert_at_boundaries(warm, tokenizer.as_ref())
482 .unwrap();
483 let _ = cache.longest_prefix_match(warm);
484 assert!(cache.stats().hits >= 1);
485 }
486
487 #[test]
488 fn merge_invariant_holds_against_uncached_encode() {
489 let cache = test_cache(1024 * 1024);
493 let tokenizer = load_tokenizer();
494
495 let template = "<s>system\nYou are helpful.</s><s>user\n";
496 let warm = format!("{template}First.</s>");
497 cache
498 .insert_at_boundaries(&warm, tokenizer.as_ref())
499 .unwrap();
500
501 let target = format!("{template}A completely different second question.</s>");
502 let (prefix_tokens, prefix_len, _deepest) = cache
503 .longest_prefix_match(&target)
504 .expect("should find prefix");
505
506 let suffix = &target[prefix_len..];
507 let suffix_enc = tokenizer.encode(suffix).unwrap();
508 let mut merged = prefix_tokens.clone();
509 merged.extend_from_slice(suffix_enc.token_ids());
510
511 let plain = tokenizer.encode(&target).unwrap();
512 assert_eq!(
513 merged,
514 plain.token_ids(),
515 "merged tokens must equal plain encode"
516 );
517 }
518
519 #[test]
520 fn eviction_respects_memory_budget() {
521 let cache = test_cache(4 * 1024);
523 let tokenizer = load_tokenizer();
524 for i in 0..50 {
525 let input =
526 format!("<s>system\nPersona {i} chatty.</s><s>user\nTurn {i} content here.</s>");
527 cache
528 .insert_at_boundaries(&input, tokenizer.as_ref())
529 .unwrap();
530 }
531 let stats = cache.stats();
532 assert!(
533 stats.memory_bytes <= 4 * 1024,
534 "memory_bytes={} exceeds budget",
535 stats.memory_bytes
536 );
537 }
538
539 #[test]
540 fn concurrent_inserts_and_lookups_do_not_corrupt() {
541 use std::thread;
542
543 let cache = Arc::new(test_cache(1024 * 1024));
544 let tokenizer = load_tokenizer();
545
546 let mut handles = vec![];
547 for i in 0..10 {
548 let cache_c = cache.clone();
549 let tok = tokenizer.clone();
550 handles.push(thread::spawn(move || {
551 let input = format!("<s>system\nThread {i}.</s><s>user\nThread {i} body.</s>");
552 cache_c.insert_at_boundaries(&input, tok.as_ref()).unwrap();
553 let r = cache_c.longest_prefix_match(&input);
554 assert!(r.is_some(), "thread {i} expected match after insert");
555 }));
556 }
557 for h in handles {
558 h.join().unwrap();
559 }
560 assert!(cache.stats().memory_bytes > 0);
561 assert!(cache.stats().hits >= 10);
562 }
563
564 fn growing_chat_turns(n: usize) -> Vec<String> {
570 let mut convo = String::from("<s>system\nYou are a helpful assistant.</s>");
571 let mut turns = Vec::with_capacity(n);
572 for i in 0..n {
573 convo.push_str(&format!(
574 "<s>user\nQuestion {i} please answer it.</s><s>assistant\nDetailed answer {i} follows here.</s>"
575 ));
576 turns.push(format!("{convo}<s>user\nFollow-up {i}"));
577 }
578 turns
579 }
580
581 #[test]
582 fn extend_on_hit_advances_match_depth_each_turn() {
583 let tok = load_tokenizer();
587 let turns = growing_chat_turns(5);
588
589 let off = test_cache(8 * 1024 * 1024);
591 off.insert_at_boundaries(&turns[0], tok.as_ref()).unwrap();
592 let pinned = off.longest_prefix_match(&turns[1]).expect("hit").1;
593 for t in &turns[1..] {
594 let (_toks, offset, _deepest) = off.longest_prefix_match(t).expect("hit");
595 assert_eq!(
596 offset, pinned,
597 "extend-off offset must stay pinned at turn-1 depth"
598 );
599 }
600
601 let on = test_cache(8 * 1024 * 1024);
603 on.insert_at_boundaries(&turns[0], tok.as_ref()).unwrap();
604 let mut prev = 0usize;
605 for (i, t) in turns.iter().enumerate().skip(1) {
606 let (prefix_tokens, offset, deepest) = on.longest_prefix_match(t).expect("hit");
607 assert!(
608 offset > prev,
609 "turn {i}: extend-on offset {offset} must exceed previous {prev}"
610 );
611 prev = offset;
612
613 let merged = on
615 .extend_after_match(t, prefix_tokens, offset, deepest, tok.as_ref())
616 .unwrap();
617 let plain = tok.encode(t).unwrap();
618 assert_eq!(
619 merged,
620 plain.token_ids(),
621 "turn {i}: extend merge must equal plain encode"
622 );
623 }
624
625 assert!(
626 prev > pinned,
627 "extend-on frontier ({prev}) must reach deeper than pinned extend-off depth ({pinned})"
628 );
629 }
630
631 #[test]
632 fn extend_on_hit_respects_budget_and_stays_correct() {
633 let tok = load_tokenizer();
636 let cache = test_cache(4 * 1024);
637 let turns = growing_chat_turns(20);
638 cache.insert_at_boundaries(&turns[0], tok.as_ref()).unwrap();
639
640 for t in &turns[1..] {
641 let merged = match cache.longest_prefix_match(t) {
642 Some((prefix_tokens, offset, deepest)) => cache
643 .extend_after_match(t, prefix_tokens, offset, deepest, tok.as_ref())
644 .unwrap(),
645 None => {
646 let enc = tok.encode(t).unwrap();
648 cache.insert_at_boundaries(t, tok.as_ref()).unwrap();
649 enc.token_ids().to_vec()
650 }
651 };
652 let plain = tok.encode(t).unwrap();
653 assert_eq!(
654 merged,
655 plain.token_ids(),
656 "encode must stay correct under eviction pressure"
657 );
658 assert!(
659 cache.stats().memory_bytes <= 4 * 1024,
660 "memory_bytes={} exceeds budget",
661 cache.stats().memory_bytes
662 );
663 }
664 }
665
666 #[test]
667 fn concurrent_extend_on_hit_does_not_corrupt() {
668 use std::thread;
669
670 let tok = load_tokenizer();
671 let cache = Arc::new(test_cache(8 * 1024 * 1024));
672 let turns = growing_chat_turns(8);
673 cache.insert_at_boundaries(&turns[0], tok.as_ref()).unwrap();
675
676 let mut handles = vec![];
677 for _ in 0..8 {
678 let cache_c = cache.clone();
679 let tok_c = tok.clone();
680 let turns_c = turns.clone();
681 handles.push(thread::spawn(move || {
682 for t in &turns_c[1..] {
683 if let Some((prefix_tokens, offset, deepest)) = cache_c.longest_prefix_match(t)
684 {
685 let merged = cache_c
686 .extend_after_match(t, prefix_tokens, offset, deepest, tok_c.as_ref())
687 .unwrap();
688 let plain = tok_c.encode(t).unwrap();
689 assert_eq!(
690 merged,
691 plain.token_ids(),
692 "concurrent extend must stay correct"
693 );
694 }
695 }
696 }));
697 }
698 for h in handles {
699 h.join().unwrap();
700 }
701 assert!(cache.stats().memory_bytes > 0);
702 }
703
704 #[test]
705 fn extend_after_match_persists_correct_deepest_entry() {
706 let tok = load_tokenizer();
712 let turns = growing_chat_turns(3);
713
714 let cache = test_cache(8 * 1024 * 1024);
715 cache.insert_at_boundaries(&turns[0], tok.as_ref()).unwrap();
716
717 let (prefix_tokens, prefix_len, deepest_boundary) = cache
718 .longest_prefix_match(&turns[1])
719 .expect("partial hit on turns[1]");
720 let entries_before = cache.stats().entries;
721
722 let _merged = cache
723 .extend_after_match(
724 &turns[1],
725 prefix_tokens,
726 prefix_len,
727 deepest_boundary,
728 tok.as_ref(),
729 )
730 .unwrap();
731
732 assert_eq!(
733 cache.stats().entries,
734 entries_before + 1,
735 "extend must persist exactly one (deepest) entry"
736 );
737
738 let deepest = find_special_token_boundaries(&turns[1], SPECIALS)
741 .into_iter()
742 .rev()
743 .find(|&b| b > prefix_len)
744 .expect("a deeper boundary must exist in the appended turn");
745 assert_eq!(
746 deepest_boundary, deepest,
747 "longest_prefix_match must return the deepest boundary used by extend"
748 );
749
750 let (saved_tokens, saved_offset, _deepest) = cache
753 .longest_prefix_match(&turns[1])
754 .expect("hit after extend");
755 assert_eq!(
756 saved_offset, deepest,
757 "lookup must now hit at the just-saved deepest boundary"
758 );
759 let expected = tok.encode(&turns[1][..deepest]).unwrap();
760 assert_eq!(
761 saved_tokens,
762 expected.token_ids(),
763 "persisted entry tokens must equal the uncached encode of the cached prefix"
764 );
765 }
766
767 #[test]
768 fn boundaries_detected_for_multibyte_deepseek_tool_tokens() {
769 let specials = &["<|tool▁calls▁begin|>", "<|tool▁call▁end|>"];
774 let text = "<|tool▁calls▁begin|>payload<|tool▁call▁end|>tail";
775 let bounds = find_special_token_boundaries(text, specials);
776
777 let after_begin = "<|tool▁calls▁begin|>".len();
778 let after_end = text.find("<|tool▁call▁end|>").unwrap() + "<|tool▁call▁end|>".len();
779 assert_eq!(bounds, vec![after_begin, after_end]);
780 for &b in &bounds {
781 assert!(
782 text.is_char_boundary(b),
783 "boundary {b} is not a char boundary"
784 );
785 let _ = &text[..b]; }
787 }
788
789 #[test]
790 fn populate_and_encode_matches_uncached_and_seeds_cache() {
791 let tok = load_tokenizer();
794 let cache = test_cache(8 * 1024 * 1024);
795 let input = "<s>system\nYou are helpful.</s><s>user\nHello there, friend.</s>";
796
797 let got = cache.populate_and_encode(input, tok.as_ref()).unwrap();
798 let plain = tok.encode(input).unwrap();
799 assert_eq!(
800 got,
801 plain.token_ids(),
802 "fused miss encode must equal uncached encode"
803 );
804
805 assert!(
807 !cache.is_empty(),
808 "miss path must populate boundary entries"
809 );
810 let (_t, offset, _d) = cache
811 .longest_prefix_match(input)
812 .expect("hit after populate");
813 assert!(offset > 0, "follow-up lookup should hit a cached boundary");
814 }
815
816 #[test]
817 fn populate_and_encode_handles_inputs_without_special_tokens() {
818 let tok = load_tokenizer();
821 let cache = test_cache(8 * 1024 * 1024);
822 let input = "plain text with no special tokens at all";
823
824 let got = cache.populate_and_encode(input, tok.as_ref()).unwrap();
825 let plain = tok.encode(input).unwrap();
826 assert_eq!(got, plain.token_ids());
827 assert!(cache.is_empty(), "nothing cacheable without boundaries");
828 }
829
830 #[test]
831 fn populate_and_encode_handles_trailing_special_token() {
832 let tok = load_tokenizer();
836 let cache = test_cache(8 * 1024 * 1024);
837 let input = "<s>system\nDone.</s>";
838
839 let got = cache.populate_and_encode(input, tok.as_ref()).unwrap();
840 let plain = tok.encode(input).unwrap();
841 assert_eq!(
842 got,
843 plain.token_ids(),
844 "tail-segment assembly must be exact"
845 );
846 }
847}