@@ -5,6 +5,7 @@ use crate::tokenizer::{Model, Result, Token};
55use crate :: utils:: byte_level:: { self } ;
66use crate :: utils:: cache:: { DEFAULT_CACHE_CAPACITY , MAX_LENGTH } ;
77use crate :: utils:: iter:: ResultShunt ;
8+ use crate :: utils:: word_cache:: { Lookup , WordCache } ;
89use crate :: vocab:: bucket_vocab_store:: BucketVocabStore ;
910use crate :: vocab_store:: VocabStore ;
1011use ahash:: AHashMap ;
@@ -680,6 +681,8 @@ pub struct PipelineBPE {
680681 vocab : BucketVocabStore ,
681682 merges : MergeMap ,
682683 ignore_merges : bool ,
684+ /// `None` when the tokenizer asked for no cache.
685+ cache_capacity : Option < usize > ,
683686}
684687
685688enum Atoms {
@@ -760,6 +763,7 @@ impl PipelineBPE {
760763 ignore_merges,
761764 merges,
762765 vocab,
766+ cache_capacity : model. cache . map ( |c| c. capacity ) . filter ( |& c| c > 0 ) ,
763767 } )
764768 }
765769
@@ -827,23 +831,39 @@ impl pipeline::Model for PipelineBPE {
827831 return Ok ( ( ) ) ;
828832 }
829833
830- if self . ignore_merges
831- && let Some ( id) = self . vocab . get_bytes ( sequence. as_bytes ( ) )
832- {
833- output. push ( PipelineToken { id } ) ;
834- return Ok ( ( ) ) ;
835- }
836-
837- // TODO: persistent cache mapping &str -> &[u32]
838-
839834 let BpeScratch {
840835 merge_queue,
841836 skip,
842837 word,
838+ word_cache,
843839 } = scratch;
844840
845- self . merge_word ( sequence, merge_queue, skip, word) ;
846- output. extend ( word. get_chars_iter ( ) . map ( |id| PipelineToken { id } ) ) ;
841+ let mut placement = None ;
842+ if let Some ( cache) = word_cache. as_mut ( ) {
843+ match cache. lookup ( sequence. as_bytes ( ) ) {
844+ Lookup :: Hit ( ids) => {
845+ output. extend ( ids. iter ( ) . map ( |& id| PipelineToken { id } ) ) ;
846+ return Ok ( ( ) ) ;
847+ }
848+ Lookup :: Miss ( at) => placement = at,
849+ }
850+ }
851+ let start = output. len ( ) ;
852+ if self . ignore_merges
853+ && let Some ( id) = self . vocab . get_bytes ( sequence. as_bytes ( ) )
854+ {
855+ output. push ( PipelineToken { id } ) ;
856+ } else {
857+ self . merge_word ( sequence, merge_queue, skip, word) ;
858+ output. extend ( word. get_chars_iter ( ) . map ( |id| PipelineToken { id } ) ) ;
859+ }
860+ // The ids come back out of `output` because that is the only place both
861+ // branches above leave them: `ignore_merges` never touches `word`.
862+ if let Some ( cache) = word_cache. as_mut ( )
863+ && let Some ( at) = placement
864+ {
865+ cache. insert ( at, output[ start..] . iter ( ) . map ( |token| token. id ) ) ;
866+ }
847867
848868 Ok ( ( ) )
849869 }
@@ -853,6 +873,7 @@ impl pipeline::Model for PipelineBPE {
853873 merge_queue : QuaternaryHeap :: with_capacity ( 64 ) ,
854874 word : Word :: with_capacity ( 64 ) ,
855875 skip : Vec :: new ( ) ,
876+ word_cache : self . cache_capacity . map ( WordCache :: new) ,
856877 }
857878 }
858879}
@@ -861,6 +882,8 @@ pub struct BpeScratch {
861882 pub ( crate ) merge_queue : QuaternaryHeap < Merge > ,
862883 pub ( crate ) skip : Vec < Merge > ,
863884 pub ( crate ) word : Word ,
885+ /// Outlives the encode call that fills it, or it would never see a word twice.
886+ pub ( crate ) word_cache : Option < WordCache > ,
864887}
865888impl ModelScratch for BpeScratch { }
866889
@@ -1438,13 +1461,61 @@ mod tests {
14381461 assert ! ( pipeline_ids( & pipeline, "" ) . is_empty( ) ) ;
14391462 }
14401463
1464+ // The pool hands the SAME scratch to successive encodes. State left behind by one
1465+ // call (an undrained merge queue, a stale word buffer) would corrupt every call
1466+ // after it. Drive several inputs through one scratch and check each still matches
1467+ // the reference model.
1468+ #[ test]
1469+ fn reused_scratch_matches_fresh ( ) {
1470+ let bpe = hello_builder ( ) . build ( ) . unwrap ( ) ;
1471+ let reference = bpe. clone ( ) ;
1472+ let model = PipelineBPE :: from_bpe ( bpe, false ) . unwrap ( ) ;
1473+ let mut scratch = model. init_scratch ( ) ;
1474+ for input in [ "hello" , "hell" , "helo" , "oleh" , "hello" , "" , "hxe" ] {
1475+ let mut out = Vec :: new ( ) ;
1476+ pipeline:: Model :: tokenize_pipeline ( & model, input, & mut scratch, & mut out) . unwrap ( ) ;
1477+ let got: Vec < u32 > = out. iter ( ) . map ( |t| t. id ) . collect ( ) ;
1478+ assert_eq ! ( got, reference_ids( & reference, input) , "{input:?}" ) ;
1479+ }
1480+ }
1481+
1482+ // A cache may forget a word, but it must never change one. Run every word twice
1483+ // through one scratch, the second time answered from the cache, against a model
1484+ // built with no cache at all.
1485+ #[ test]
1486+ fn cached_ids_match_uncached ( ) {
1487+ let cached = PipelineBPE :: from_bpe ( hello_builder ( ) . build ( ) . unwrap ( ) , false ) . unwrap ( ) ;
1488+ let uncached =
1489+ PipelineBPE :: from_bpe ( hello_builder ( ) . cache_capacity ( 0 ) . build ( ) . unwrap ( ) , false )
1490+ . unwrap ( ) ;
1491+
1492+ let mut scratch = cached. init_scratch ( ) ;
1493+ assert ! ( scratch. word_cache. is_some( ) , "nothing is being cached" ) ;
1494+ for _ in 0 ..2 {
1495+ for word in [
1496+ "hello" ,
1497+ "hell" ,
1498+ "o" ,
1499+ "hellohello" ,
1500+ "hello-a-word-past-fifteen-bytes" ,
1501+ "hxe" ,
1502+ ] {
1503+ let mut out = Vec :: new ( ) ;
1504+ pipeline:: Model :: tokenize_pipeline ( & cached, word, & mut scratch, & mut out)
1505+ . unwrap ( ) ;
1506+ let got: Vec < u32 > = out. iter ( ) . map ( |t| t. id ) . collect ( ) ;
1507+ assert_eq ! ( got, pipeline_ids( & uncached, word) , "{word:?}" ) ;
1508+ }
1509+ }
1510+ }
1511+
14411512 #[ test]
14421513 fn unknown_char_without_unk_is_dropped ( ) {
14431514 let bpe = hello_builder ( ) . build ( ) . unwrap ( ) ;
14441515 let reference = bpe. clone ( ) ;
14451516 let pipeline = PipelineBPE :: from_bpe ( bpe, false ) . unwrap ( ) ;
14461517 // 'x' vanishes, making 'h' and 'e' adjacent, so the (h,e) merge
1447- // applies — mirrors the reference model.
1518+ // applies, mirroring the reference model.
14481519 assert_eq ! ( pipeline_ids( & pipeline, "hxe" ) , vec![ 4 ] ) ;
14491520 assert_eq ! (
14501521 pipeline_ids( & pipeline, "hxe" ) ,
@@ -1615,7 +1686,7 @@ mod tests {
16151686
16161687 /// A gpt2-shaped miniature: the 256 projected single-byte tokens
16171688 /// (id == byte value) plus `extra` tokens and merges, given in raw
1618- /// space and projected here — like a real byte-level tokenizer.json,
1689+ /// space and projected here, like a real byte-level tokenizer.json,
16191690 /// whose vocab is stored in the projected alphabet.
16201691 fn byte_level_bpe (
16211692 extra : & [ ( & str , u32 ) ] ,
@@ -1672,6 +1743,22 @@ mod tests {
16721743 ) ;
16731744 }
16741745
1746+ /// A whole-word vocab hit skips the merge loop but not the vocab lookup,
1747+ /// and in a byte-level vocab the words that take that path are the
1748+ /// commonest ones in the text. Storing the id it found turns the next
1749+ /// occurrence into a cache hit.
1750+ #[ test]
1751+ fn ignore_merges_stores_the_whole_word_id ( ) {
1752+ let bpe = byte_level_bpe ( & [ ( " hello" , 300 ) ] , & [ ] , true ) ;
1753+ let model = PipelineBPE :: from_bpe ( bpe, true ) . unwrap ( ) ;
1754+ let mut scratch = model. init_scratch ( ) ;
1755+ let mut out = Vec :: new ( ) ;
1756+ PipelineModel :: tokenize_pipeline ( & model, " hello" , & mut scratch, & mut out) . unwrap ( ) ;
1757+
1758+ let cache = scratch. word_cache . as_mut ( ) . unwrap ( ) ;
1759+ assert_eq ! ( cache. lookup( b" hello" ) . hit( ) , Some ( & [ 300u32 ] [ ..] ) ) ;
1760+ }
1761+
16751762 #[ test]
16761763 fn byte_level_requires_full_byte_coverage ( ) {
16771764 // An ASCII-only vocab covers no control/high bytes: building the
0 commit comments