Skip to main content

dynamo_tokenizers/cache/
mod.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3//
4// SPDX-FileCopyrightText: Copyright (c) 2024 Simo Lin, Chang Su, Keyang Ru (llm-tokenizer authors)
5//
6// Portions adapted from sgl-project/llm-tokenizer v1.3.2 (Apache-2.0).
7// Upstream: https://github.com/lightseekorg/smg
8// Modifications: removed L0 layer, removed `add_special_tokens` plumbing (Dynamo's
9// `Encoder::encode` has no such flag), dropped fingerprinting, retargeted onto
10// `crate::traits::Tokenizer`.
11
12//! Tokenizer caching layer (L1: prefix matching at special-token boundaries).
13//!
14//! Wraps a cache-compatible [`Tokenizer`] in a cache that records prefix
15//! tokenizations at every special-token boundary. On a hit, the cached prefix
16//! tokens are merged with a fresh encode of the trailing suffix only — turning
17//! O(N) tokenization work into O(suffix_len) when prompts share a system prefix.
18//!
19//! # Correctness
20//!
21//! Boundaries are taken **only** at positions immediately following a registered
22//! special token (e.g. `<|im_start|>`, `<|im_end|>`, `<s>`, `</s>`). Special tokens
23//! are atomic in BPE (`special: true, normalized: false`), so splitting there
24//! preserves the invariant `tokenize(prefix) + tokenize(suffix) == tokenize(prefix + suffix)`.
25//! No fallback to whitespace or punctuation — better to miss than to corrupt.
26//!
27//! # Storage normalization
28//!
29//! When L1 is enabled, **every** `encode` returns [`Encoding::Sp`] (token-ids only) —
30//! hits merge cached prefix ids with a fresh suffix encode, and misses assemble the ids
31//! from the per-boundary segment encodes (see [`L1Cache::populate_and_encode`]) — even
32//! when the inner tokenizer would have produced [`Encoding::Hf`] (rich offsets/attention/
33//! etc). All current downstream consumers in Dynamo only call [`Encoding::token_ids`], so
34//! this lossy normalization is safe; revisit if a caller starts reading offsets or
35//! attention masks from encodings produced through the cache.
36//!
37//! # Configuration
38//!
39//! - `special_tokens: Vec<String>` — must be supplied at construction (the
40//!   [`Tokenizer`] trait is intentionally minimal and does not expose them).
41//!   An empty list disables L1: `encode`/`encode_batch` short-circuit straight
42//!   to the inner tokenizer with no lookup, no miss-counter bump, and no
43//!   insert attempt.
44//! - `encode_segments` always passes through to the inner tokenizer without
45//!   caching. Flattening segments for L1 would discard their special-token
46//!   trust boundaries.
47//! - `max_memory_bytes` — L1 byte budget; entries evicted via approximate LRU.
48//!
49//! # Provenance
50//!
51//! Adapted from `llm-tokenizer` v1.3.2 (`cache/l1.rs`, `cache/mod.rs`). L0 and
52//! fingerprinting were dropped; L1 alone covers the headline multi-turn-chat
53//! workload, and the in-memory cache lifetime is bound to a single tokenizer
54//! instance so fingerprint-based invalidation is unnecessary.
55
56mod l1;
57
58use std::sync::Arc;
59
60pub use l1::{CacheEventFn, L1Cache, L1CacheStats};
61
62use crate::{
63    EncodeSegment, Encoding, Result, TokenIdType,
64    traits::{DecodeResult, Decoder, Encoder, Tokenizer},
65};
66
67/// Token-level cache usage for one successful encode.
68///
69/// A partial cache hit reports both cached prefix tokens and uncached suffix tokens.
70/// Their sum always equals the number of tokens returned by the encode operation.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct CacheTokenUsage {
73    /// Tokens returned from the cached prefix.
74    pub cached_tokens: usize,
75    /// Tokens freshly encoded from the uncached suffix.
76    pub uncached_tokens: usize,
77}
78
79/// Optional observer for token-level cache usage.
80pub type CacheTokenUsageFn = Arc<dyn Fn(CacheTokenUsage) + Send + Sync>;
81
82/// Caching wrapper around an inner tokenizer.
83///
84/// Implements [`Encoder`], [`Decoder`], and [`Tokenizer`]; decode calls pass
85/// through to the inner tokenizer (decoding is fast and rarely repeated).
86pub struct CachedTokenizer {
87    inner: Arc<dyn Tokenizer>,
88    l1: L1Cache,
89    /// Whether L1 is active. False when the special-token set is empty (e.g. the tiktoken
90    /// wrapping path): `encode`/`encode_batch` then bypass the cache entirely. The special
91    /// tokens themselves live in the `L1Cache` (its boundary automaton).
92    l1_enabled: bool,
93    /// When true, cache the newly-tokenized suffix on a partial hit so the next turn
94    /// of a growing conversation hits deeper (see [`L1Cache::extend_after_match`]).
95    extend_on_hit: bool,
96    /// Called once after every successful encode while L1 is active.
97    token_observer: Option<CacheTokenUsageFn>,
98}
99
100impl CachedTokenizer {
101    /// Construct a cached tokenizer.
102    ///
103    /// `special_tokens` is the list of atomic special-token strings the inner
104    /// tokenizer recognizes (typically extracted via the HuggingFace tokenizer's
105    /// `get_added_tokens_decoder()` filtering by `special == true`). An empty list
106    /// disables L1 — `encode`/`encode_batch` short-circuit to the inner tokenizer
107    /// without touching the cache or its counters.
108    ///
109    /// `max_memory_bytes` is the L1 cache byte budget.
110    ///
111    /// # Errors
112    ///
113    /// Returns the inner tokenizer's compatibility error when it cannot be
114    /// safely wrapped in the prefix cache.
115    pub fn new(
116        inner: Arc<dyn Tokenizer>,
117        special_tokens: Vec<String>,
118        max_memory_bytes: usize,
119    ) -> Result<Self> {
120        inner.validate_prefix_cache()?;
121
122        let l1_enabled = !special_tokens.is_empty();
123        Ok(Self {
124            inner,
125            l1: L1Cache::new(max_memory_bytes, special_tokens),
126            l1_enabled,
127            extend_on_hit: false,
128            token_observer: None,
129        })
130    }
131
132    /// Enable partial-hit extension. When on, a partial cache hit also caches the
133    /// freshly-tokenized suffix at its deepest special-token boundary, so each turn of
134    /// a growing multi-turn conversation hits deeper than the last and per-turn
135    /// tokenization cost stops growing with conversation length. Default off.
136    pub fn with_extend(mut self, enabled: bool) -> Self {
137        self.extend_on_hit = enabled;
138        self
139    }
140
141    /// Install hit/miss callbacks so each L1 lookup pushes an event into the
142    /// supplied closures (e.g. `Prometheus::Counter::inc`). Replaces any
143    /// previously-set observer.
144    pub fn with_observer(mut self, on_hit: CacheEventFn, on_miss: CacheEventFn) -> Self {
145        self.l1.set_observer(on_hit, on_miss);
146        self
147    }
148
149    /// Install a callback that receives exact cached and uncached token counts after each
150    /// successful encode while L1 is active. A partial hit reports both categories, which
151    /// lets consumers maintain token-level cache totals and derive a reuse ratio. Replaces
152    /// any previously-set token observer.
153    ///
154    /// This observer is not called when the special-token set is empty (and L1 is therefore
155    /// disabled) or when encoding returns an error.
156    pub fn with_token_observer(mut self, observer: CacheTokenUsageFn) -> Self {
157        self.token_observer = Some(observer);
158        self
159    }
160
161    fn observe_token_usage(&self, cached_tokens: usize, total_tokens: usize) {
162        if let Some(observer) = &self.token_observer {
163            let uncached_tokens = total_tokens
164                .checked_sub(cached_tokens)
165                .expect("cached token count cannot exceed total token count");
166            observer(CacheTokenUsage {
167                cached_tokens,
168                uncached_tokens,
169            });
170        }
171    }
172
173    /// Snapshot of L1 cache statistics (cumulative hits/misses/entries/memory).
174    pub fn cache_stats(&self) -> L1CacheStats {
175        self.l1.stats()
176    }
177
178    /// Clear all cached entries and reset counters.
179    pub fn clear_cache(&self) {
180        self.l1.clear();
181    }
182
183    /// Access the underlying tokenizer (e.g. for downcasting to a concrete type).
184    pub fn inner(&self) -> &Arc<dyn Tokenizer> {
185        &self.inner
186    }
187}
188
189impl Encoder for CachedTokenizer {
190    fn encode(&self, input: &str) -> Result<Encoding> {
191        // No specials => no boundaries are ever produced. Skip the lookup, miss-counter
192        // bump, and insert attempt entirely — otherwise the tiktoken wrapping path (which
193        // deliberately passes an empty list) pays the cost on every call with no chance
194        // of a hit.
195        if !self.l1_enabled {
196            return self.inner.encode(input);
197        }
198
199        if let Some((prefix_tokens, prefix_len, deepest_boundary)) =
200            self.l1.longest_prefix_match(input)
201        {
202            let cached_tokens = prefix_tokens.len();
203            let suffix = &input[prefix_len..];
204            let encoding = if suffix.is_empty() {
205                Encoding::Sp(prefix_tokens.to_vec())
206            } else if self.extend_on_hit {
207                // Cache the new suffix at its deepest boundary so the next turn hits
208                // deeper, then return the full merged tokens. The deepest boundary was
209                // already found by `longest_prefix_match`, so no rescan is needed here.
210                Encoding::Sp(self.l1.extend_after_match(
211                    input,
212                    prefix_tokens,
213                    prefix_len,
214                    deepest_boundary,
215                    self.inner.as_ref(),
216                )?)
217            } else {
218                let suffix_enc = self.inner.encode(suffix)?;
219                // Reserve exact capacity so appending the suffix doesn't grow-realloc and
220                // re-copy the (large) cached prefix.
221                let mut merged: Vec<TokenIdType> =
222                    Vec::with_capacity(prefix_tokens.len() + suffix_enc.token_ids().len());
223                merged.extend_from_slice(&prefix_tokens);
224                merged.extend_from_slice(suffix_enc.token_ids());
225                Encoding::Sp(merged)
226            };
227            self.observe_token_usage(cached_tokens, encoding.token_ids().len());
228            return Ok(encoding);
229        }
230
231        // Miss path: tokenize once, caching the cumulative prefix at every boundary as we
232        // go. The returned ids equal an uncached encode (special tokens are atomic), so we
233        // avoid the redundant second tokenization a separate full-encode + insert would
234        // cost. Returns Encoding::Sp — consistent with the hit path (see the storage-
235        // normalization note in the module docs).
236        let encoding = Encoding::Sp(self.l1.populate_and_encode(input, self.inner.as_ref())?);
237        self.observe_token_usage(0, encoding.token_ids().len());
238        Ok(encoding)
239    }
240
241    fn encode_batch(&self, inputs: &[&str]) -> Result<Vec<Encoding>> {
242        // True passthrough when L1 is disabled — delegate to the inner's native
243        // batch path (which may be rayon-parallel for HF) instead of falling
244        // through per-item.
245        if !self.l1_enabled {
246            return self.inner.encode_batch(inputs);
247        }
248
249        // Per-item cache lookup — do NOT delegate to inner.encode_batch, which would
250        // bypass the cache. Sequential iteration is fine; if rayon is added later it
251        // belongs here, not inside `encode`.
252        inputs.iter().map(|&i| self.encode(i)).collect()
253    }
254
255    fn encode_segments(&self, segments: &[EncodeSegment<'_>]) -> Result<Encoding> {
256        // L1 indexes flattened string offsets and cannot preserve each
257        // segment's allow_special boundary. Keep the operation correct by
258        // delegating without populating or consulting the cache.
259        let encoding = self.inner.encode_segments(segments)?;
260        if self.l1_enabled {
261            self.observe_token_usage(0, encoding.token_ids().len());
262        }
263        Ok(encoding)
264    }
265}
266
267impl Decoder for CachedTokenizer {
268    fn decode(&self, token_ids: &[TokenIdType], skip_special_tokens: bool) -> Result<DecodeResult> {
269        // Decode is not cached — passthrough to inner.
270        self.inner.decode(token_ids, skip_special_tokens)
271    }
272}
273
274impl Tokenizer for CachedTokenizer {}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::HuggingFaceTokenizer;
280    use std::sync::{Mutex, atomic::AtomicU64, atomic::Ordering};
281
282    struct FailingTokenizer;
283
284    struct SegmentTokenizer;
285
286    impl Encoder for SegmentTokenizer {
287        fn encode(&self, input: &str) -> Result<Encoding> {
288            Ok(Encoding::Sp(vec![input.len() as u32]))
289        }
290
291        fn encode_batch(&self, inputs: &[&str]) -> Result<Vec<Encoding>> {
292            inputs.iter().map(|input| self.encode(input)).collect()
293        }
294
295        fn encode_segments(&self, segments: &[EncodeSegment<'_>]) -> Result<Encoding> {
296            let ids = segments
297                .iter()
298                .flat_map(|segment| [segment.allow_special as u32, segment.text.len() as u32])
299                .collect();
300            Ok(Encoding::Sp(ids))
301        }
302    }
303
304    impl Decoder for SegmentTokenizer {
305        fn decode(
306            &self,
307            _token_ids: &[TokenIdType],
308            _skip_special_tokens: bool,
309        ) -> Result<DecodeResult> {
310            Ok(DecodeResult::Complete(String::new()))
311        }
312    }
313
314    impl Tokenizer for SegmentTokenizer {
315        fn validate_prefix_cache(&self) -> Result<()> {
316            Ok(())
317        }
318    }
319
320    impl Encoder for FailingTokenizer {
321        fn encode(&self, _input: &str) -> Result<Encoding> {
322            Err(anyhow::anyhow!("intentional encode failure"))
323        }
324
325        fn encode_batch(&self, _inputs: &[&str]) -> Result<Vec<Encoding>> {
326            Err(anyhow::anyhow!("intentional encode failure"))
327        }
328    }
329
330    impl Decoder for FailingTokenizer {
331        fn decode(
332            &self,
333            _token_ids: &[TokenIdType],
334            _skip_special_tokens: bool,
335        ) -> Result<DecodeResult> {
336            Err(anyhow::anyhow!("intentional decode failure"))
337        }
338    }
339
340    impl Tokenizer for FailingTokenizer {
341        fn validate_prefix_cache(&self) -> Result<()> {
342            Ok(())
343        }
344    }
345
346    const TINYLLAMA_PATH: &str = concat!(
347        env!("CARGO_MANIFEST_DIR"),
348        "/tests/data/sample-models/TinyLlama_v1.1/tokenizer.json"
349    );
350
351    fn inner() -> Arc<dyn Tokenizer> {
352        Arc::new(HuggingFaceTokenizer::from_file(TINYLLAMA_PATH).expect("load TinyLlama"))
353    }
354
355    fn specials() -> Vec<String> {
356        vec!["<s>".into(), "</s>".into()]
357    }
358
359    fn collect_token_usage(
360        tokenizer: CachedTokenizer,
361    ) -> (CachedTokenizer, Arc<Mutex<Vec<CacheTokenUsage>>>) {
362        let events = Arc::new(Mutex::new(Vec::new()));
363        let observed = events.clone();
364        let tokenizer = tokenizer.with_token_observer(Arc::new(move |usage| {
365            observed.lock().unwrap().push(usage);
366        }));
367        (tokenizer, events)
368    }
369
370    #[test]
371    fn rejects_hf_tokenizer_that_adds_special_tokens() {
372        let tokenizer: Arc<dyn Tokenizer> = Arc::new(
373            HuggingFaceTokenizer::from_file(TINYLLAMA_PATH)
374                .expect("load TinyLlama")
375                .with_options(crate::TokenizerOptions {
376                    add_special_tokens: true,
377                }),
378        );
379
380        let result = CachedTokenizer::new(tokenizer, specials(), 4096);
381        let Err(error) = result else {
382            panic!("add_special_tokens=true must be rejected");
383        };
384        assert_eq!(
385            error.to_string(),
386            "HuggingFace tokenizers configured with add_special_tokens=true must remain uncached"
387        );
388    }
389
390    #[test]
391    fn empty_specials_passes_through_correctly() {
392        // L1 disabled by empty specials list — encode must produce correct ids
393        // AND short-circuit to the inner tokenizer (no miss-counter bump, no
394        // insert attempt). Otherwise the tiktoken integration would log a
395        // miss per request with zero hits forever.
396        let tok = inner();
397        let (cached, events) = collect_token_usage(
398            CachedTokenizer::new(tok.clone(), Vec::new(), 4096)
399                .expect("TinyLlama must support prefix caching"),
400        );
401        let s = "<s>hello world</s>";
402        let a = cached.encode(s).unwrap();
403        let b = tok.encode(s).unwrap();
404        assert_eq!(a.token_ids(), b.token_ids());
405        let stats = cached.cache_stats();
406        assert_eq!(stats.entries, 0);
407        assert_eq!(stats.misses, 0, "empty specials must not increment misses");
408        assert_eq!(stats.hits, 0);
409        assert!(
410            events.lock().unwrap().is_empty(),
411            "empty specials must not emit token usage"
412        );
413    }
414
415    #[test]
416    fn segmented_encoding_passes_through_without_caching() {
417        let inner: Arc<dyn Tokenizer> = Arc::new(SegmentTokenizer);
418        let segments = [
419            EncodeSegment::new("<ctl>", true),
420            EncodeSegment::new("user content", false),
421        ];
422        let expected = inner.encode_segments(&segments).unwrap();
423
424        for special_tokens in [Vec::new(), vec!["<ctl>".to_string()]] {
425            let l1_enabled = !special_tokens.is_empty();
426            let (cached, events) = collect_token_usage(
427                CachedTokenizer::new(inner.clone(), special_tokens, 4096)
428                    .expect("test tokenizer supports prefix caching"),
429            );
430            let actual = cached.encode_segments(&segments).unwrap();
431
432            assert_eq!(actual.token_ids(), expected.token_ids());
433            let stats = cached.cache_stats();
434            assert_eq!(stats.entries, 0);
435            assert_eq!(stats.hits, 0);
436            assert_eq!(stats.misses, 0);
437            let events = events.lock().unwrap();
438            if l1_enabled {
439                assert_eq!(
440                    events.as_slice(),
441                    &[CacheTokenUsage {
442                        cached_tokens: 0,
443                        uncached_tokens: expected.token_ids().len(),
444                    }]
445                );
446            } else {
447                assert!(events.is_empty());
448            }
449        }
450    }
451
452    #[test]
453    fn token_observer_reports_full_miss_and_partial_hit_with_and_without_extension() {
454        for extend_on_hit in [false, true] {
455            let tok = inner();
456            let hits = Arc::new(AtomicU64::new(0));
457            let misses = Arc::new(AtomicU64::new(0));
458            let hit_counter = hits.clone();
459            let miss_counter = misses.clone();
460            let cached = CachedTokenizer::new(tok, specials(), 64 * 1024)
461                .expect("TinyLlama must support prefix caching")
462                .with_extend(extend_on_hit)
463                .with_observer(
464                    Arc::new(move || {
465                        hit_counter.fetch_add(1, Ordering::Relaxed);
466                    }),
467                    Arc::new(move || {
468                        miss_counter.fetch_add(1, Ordering::Relaxed);
469                    }),
470                );
471            let (cached, events) = collect_token_usage(cached);
472
473            let shared = "<s>system\nYou are helpful.</s><s>user\n";
474            let first = format!("{shared}First question?</s>");
475            let second = format!("{shared}Second different prompt entirely.</s>");
476
477            let first_encoding = cached.encode(&first).unwrap();
478            let second_encoding = cached.encode(&second).unwrap();
479
480            let events = events.lock().unwrap();
481            assert_eq!(events.len(), 2);
482            assert_eq!(
483                events[0],
484                CacheTokenUsage {
485                    cached_tokens: 0,
486                    uncached_tokens: first_encoding.token_ids().len(),
487                }
488            );
489            assert!(events[1].cached_tokens > 0);
490            assert!(events[1].uncached_tokens > 0);
491            assert_eq!(
492                events[1].cached_tokens + events[1].uncached_tokens,
493                second_encoding.token_ids().len()
494            );
495            assert_eq!(hits.load(Ordering::Relaxed), 1);
496            assert_eq!(misses.load(Ordering::Relaxed), 1);
497        }
498    }
499
500    #[test]
501    fn token_observer_does_not_report_failed_encodes() {
502        let tokenizer: Arc<dyn Tokenizer> = Arc::new(FailingTokenizer);
503        let (cached, events) = collect_token_usage(
504            CachedTokenizer::new(tokenizer, specials(), 4096)
505                .expect("test tokenizer explicitly supports prefix caching"),
506        );
507
508        assert!(cached.encode("<s>this fails</s>").is_err());
509        assert!(events.lock().unwrap().is_empty());
510    }
511
512    #[test]
513    fn two_turn_chat_correctness_and_hit() {
514        let tok = inner();
515        let cached = CachedTokenizer::new(tok.clone(), specials(), 64 * 1024)
516            .expect("TinyLlama must support prefix caching");
517
518        let template = "<s>system\nYou are helpful.</s><s>user\n";
519        let first = format!("{template}First question?</s>");
520        let second = format!("{template}Second different prompt entirely.</s>");
521
522        // Warm the cache.
523        let _ = cached.encode(&first).unwrap();
524
525        // Second request: shared prefix → L1 hit, suffix-only fresh encode.
526        let cached_second = cached.encode(&second).unwrap();
527        let plain_second = tok.encode(&second).unwrap();
528        assert_eq!(
529            cached_second.token_ids(),
530            plain_second.token_ids(),
531            "cached encode must equal plain encode for second turn"
532        );
533
534        let stats = cached.cache_stats();
535        assert!(stats.hits >= 1, "expected L1 hit on second request");
536    }
537
538    #[test]
539    fn decode_passes_through() {
540        let tok = inner();
541        let cached = CachedTokenizer::new(tok.clone(), specials(), 4096)
542            .expect("TinyLlama must support prefix caching");
543        let enc = cached.encode("<s>hello</s>").unwrap();
544        let direct = tok.decode(enc.token_ids(), false).unwrap();
545        let through = cached.decode(enc.token_ids(), false).unwrap();
546        assert_eq!(direct, through);
547    }
548
549    #[test]
550    fn encode_batch_uses_cache() {
551        let tok = inner();
552        let (cached, events) = collect_token_usage(
553            CachedTokenizer::new(tok.clone(), specials(), 64 * 1024)
554                .expect("TinyLlama must support prefix caching"),
555        );
556        let shared = "<s>system\nShared persona.</s><s>user\n";
557        let inputs = [
558            format!("{shared}q1</s>"),
559            format!("{shared}q2</s>"),
560            format!("{shared}q3</s>"),
561        ];
562        let refs: Vec<&str> = inputs.iter().map(String::as_str).collect();
563        let outs = cached.encode_batch(&refs).unwrap();
564        assert_eq!(outs.len(), 3);
565        let events = events.lock().unwrap();
566        assert_eq!(events.len(), outs.len());
567        for (event, output) in events.iter().zip(&outs) {
568            assert_eq!(
569                event.cached_tokens + event.uncached_tokens,
570                output.token_ids().len()
571            );
572        }
573        assert_eq!(events[0].cached_tokens, 0);
574        assert!(events[1..].iter().all(|event| event.cached_tokens > 0));
575        // First call populates, second/third hit.
576        assert!(cached.cache_stats().hits >= 2, "expected hits on q2 and q3");
577    }
578}