Skip to content

Commit bb360a4

Browse files
committed
perf(cache): serve repeated words from a shared WordCache (#2262)
A set-associative cache keyed on the word bytes, plus a scratch pile reused across encode calls, so BPE, WordPiece and Unigram all stop re-tokenizing words they have already seen. Squashed from feat/bpe-cache-min, which also carries #2261 (perf(scratch): reuse Scratch buffers across encode() calls).
1 parent 0bcf291 commit bb360a4

8 files changed

Lines changed: 1667 additions & 60 deletions

File tree

tokenizers/tk-encode/src/models/bpe/model.rs

Lines changed: 100 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::tokenizer::{Model, Result, Token};
55
use crate::utils::byte_level::{self};
66
use crate::utils::cache::{DEFAULT_CACHE_CAPACITY, MAX_LENGTH};
77
use crate::utils::iter::ResultShunt;
8+
use crate::utils::word_cache::{Lookup, WordCache};
89
use crate::vocab::bucket_vocab_store::BucketVocabStore;
910
use crate::vocab_store::VocabStore;
1011
use 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

685688
enum 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
}
865888
impl 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

tokenizers/tk-encode/src/models/bpe/word.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ impl Word {
276276
self.get_chars_iter().collect()
277277
}
278278

279-
pub fn get_chars_iter(&self) -> impl Iterator<Item = u32> + '_ {
279+
pub fn get_chars_iter(&self) -> impl ExactSizeIterator<Item = u32> + '_ {
280280
self.symbols.iter().map(|s| s.c)
281281
}
282282

0 commit comments

Comments
 (0)