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//! - `max_memory_bytes` — L1 byte budget; entries evicted via approximate LRU.
45//!
46//! # Provenance
47//!
48//! Adapted from `llm-tokenizer` v1.3.2 (`cache/l1.rs`, `cache/mod.rs`). L0 and
49//! fingerprinting were dropped; L1 alone covers the headline multi-turn-chat
50//! workload, and the in-memory cache lifetime is bound to a single tokenizer
51//! instance so fingerprint-based invalidation is unnecessary.
52
53mod l1;
54
55use std::sync::Arc;
56
57pub use l1::{CacheEventFn, L1Cache, L1CacheStats};
58
59use crate::{
60    Encoding, Result, TokenIdType,
61    traits::{DecodeResult, Decoder, Encoder, Tokenizer},
62};
63
64/// Token-level cache usage for one successful encode.
65///
66/// A partial cache hit reports both cached prefix tokens and uncached suffix tokens.
67/// Their sum always equals the number of tokens returned by the encode operation.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct CacheTokenUsage {
70    /// Tokens returned from the cached prefix.
71    pub cached_tokens: usize,
72    /// Tokens freshly encoded from the uncached suffix.
73    pub uncached_tokens: usize,
74}
75
76/// Optional observer for token-level cache usage.
77pub type CacheTokenUsageFn = Arc<dyn Fn(CacheTokenUsage) + Send + Sync>;
78
79/// Caching wrapper around an inner tokenizer.
80///
81/// Implements [`Encoder`], [`Decoder`], and [`Tokenizer`]; decode calls pass
82/// through to the inner tokenizer (decoding is fast and rarely repeated).
83pub struct CachedTokenizer {
84    inner: Arc<dyn Tokenizer>,
85    l1: L1Cache,
86    /// Whether L1 is active. False when the special-token set is empty (e.g. the tiktoken
87    /// wrapping path): `encode`/`encode_batch` then bypass the cache entirely. The special
88    /// tokens themselves live in the `L1Cache` (its boundary automaton).
89    l1_enabled: bool,
90    /// When true, cache the newly-tokenized suffix on a partial hit so the next turn
91    /// of a growing conversation hits deeper (see [`L1Cache::extend_after_match`]).
92    extend_on_hit: bool,
93    /// Called once after every successful encode while L1 is active.
94    token_observer: Option<CacheTokenUsageFn>,
95}
96
97impl CachedTokenizer {
98    /// Construct a cached tokenizer.
99    ///
100    /// `special_tokens` is the list of atomic special-token strings the inner
101    /// tokenizer recognizes (typically extracted via the HuggingFace tokenizer's
102    /// `get_added_tokens_decoder()` filtering by `special == true`). An empty list
103    /// disables L1 — `encode`/`encode_batch` short-circuit to the inner tokenizer
104    /// without touching the cache or its counters.
105    ///
106    /// `max_memory_bytes` is the L1 cache byte budget.
107    ///
108    /// # Errors
109    ///
110    /// Returns the inner tokenizer's compatibility error when it cannot be
111    /// safely wrapped in the prefix cache.
112    pub fn new(
113        inner: Arc<dyn Tokenizer>,
114        special_tokens: Vec<String>,
115        max_memory_bytes: usize,
116    ) -> Result<Self> {
117        inner.validate_prefix_cache()?;
118
119        let l1_enabled = !special_tokens.is_empty();
120        Ok(Self {
121            inner,
122            l1: L1Cache::new(max_memory_bytes, special_tokens),
123            l1_enabled,
124            extend_on_hit: false,
125            token_observer: None,
126        })
127    }
128
129    /// Enable partial-hit extension. When on, a partial cache hit also caches the
130    /// freshly-tokenized suffix at its deepest special-token boundary, so each turn of
131    /// a growing multi-turn conversation hits deeper than the last and per-turn
132    /// tokenization cost stops growing with conversation length. Default off.
133    pub fn with_extend(mut self, enabled: bool) -> Self {
134        self.extend_on_hit = enabled;
135        self
136    }
137
138    /// Install hit/miss callbacks so each L1 lookup pushes an event into the
139    /// supplied closures (e.g. `Prometheus::Counter::inc`). Replaces any
140    /// previously-set observer.
141    pub fn with_observer(mut self, on_hit: CacheEventFn, on_miss: CacheEventFn) -> Self {
142        self.l1.set_observer(on_hit, on_miss);
143        self
144    }
145
146    /// Install a callback that receives exact cached and uncached token counts after each
147    /// successful encode while L1 is active. A partial hit reports both categories, which
148    /// lets consumers maintain token-level cache totals and derive a reuse ratio. Replaces
149    /// any previously-set token observer.
150    ///
151    /// This observer is not called when the special-token set is empty (and L1 is therefore
152    /// disabled) or when encoding returns an error.
153    pub fn with_token_observer(mut self, observer: CacheTokenUsageFn) -> Self {
154        self.token_observer = Some(observer);
155        self
156    }
157
158    fn observe_token_usage(&self, cached_tokens: usize, total_tokens: usize) {
159        if let Some(observer) = &self.token_observer {
160            let uncached_tokens = total_tokens
161                .checked_sub(cached_tokens)
162                .expect("cached token count cannot exceed total token count");
163            observer(CacheTokenUsage {
164                cached_tokens,
165                uncached_tokens,
166            });
167        }
168    }
169
170    /// Snapshot of L1 cache statistics (cumulative hits/misses/entries/memory).
171    pub fn cache_stats(&self) -> L1CacheStats {
172        self.l1.stats()
173    }
174
175    /// Clear all cached entries and reset counters.
176    pub fn clear_cache(&self) {
177        self.l1.clear();
178    }
179
180    /// Access the underlying tokenizer (e.g. for downcasting to a concrete type).
181    pub fn inner(&self) -> &Arc<dyn Tokenizer> {
182        &self.inner
183    }
184}
185
186impl Encoder for CachedTokenizer {
187    fn encode(&self, input: &str) -> Result<Encoding> {
188        // No specials => no boundaries are ever produced. Skip the lookup, miss-counter
189        // bump, and insert attempt entirely — otherwise the tiktoken wrapping path (which
190        // deliberately passes an empty list) pays the cost on every call with no chance
191        // of a hit.
192        if !self.l1_enabled {
193            return self.inner.encode(input);
194        }
195
196        if let Some((prefix_tokens, prefix_len, deepest_boundary)) =
197            self.l1.longest_prefix_match(input)
198        {
199            let cached_tokens = prefix_tokens.len();
200            let suffix = &input[prefix_len..];
201            let encoding = if suffix.is_empty() {
202                Encoding::Sp(prefix_tokens.to_vec())
203            } else if self.extend_on_hit {
204                // Cache the new suffix at its deepest boundary so the next turn hits
205                // deeper, then return the full merged tokens. The deepest boundary was
206                // already found by `longest_prefix_match`, so no rescan is needed here.
207                Encoding::Sp(self.l1.extend_after_match(
208                    input,
209                    prefix_tokens,
210                    prefix_len,
211                    deepest_boundary,
212                    self.inner.as_ref(),
213                )?)
214            } else {
215                let suffix_enc = self.inner.encode(suffix)?;
216                // Reserve exact capacity so appending the suffix doesn't grow-realloc and
217                // re-copy the (large) cached prefix.
218                let mut merged: Vec<TokenIdType> =
219                    Vec::with_capacity(prefix_tokens.len() + suffix_enc.token_ids().len());
220                merged.extend_from_slice(&prefix_tokens);
221                merged.extend_from_slice(suffix_enc.token_ids());
222                Encoding::Sp(merged)
223            };
224            self.observe_token_usage(cached_tokens, encoding.token_ids().len());
225            return Ok(encoding);
226        }
227
228        // Miss path: tokenize once, caching the cumulative prefix at every boundary as we
229        // go. The returned ids equal an uncached encode (special tokens are atomic), so we
230        // avoid the redundant second tokenization a separate full-encode + insert would
231        // cost. Returns Encoding::Sp — consistent with the hit path (see the storage-
232        // normalization note in the module docs).
233        let encoding = Encoding::Sp(self.l1.populate_and_encode(input, self.inner.as_ref())?);
234        self.observe_token_usage(0, encoding.token_ids().len());
235        Ok(encoding)
236    }
237
238    fn encode_batch(&self, inputs: &[&str]) -> Result<Vec<Encoding>> {
239        // True passthrough when L1 is disabled — delegate to the inner's native
240        // batch path (which may be rayon-parallel for HF) instead of falling
241        // through per-item.
242        if !self.l1_enabled {
243            return self.inner.encode_batch(inputs);
244        }
245
246        // Per-item cache lookup — do NOT delegate to inner.encode_batch, which would
247        // bypass the cache. Sequential iteration is fine; if rayon is added later it
248        // belongs here, not inside `encode`.
249        inputs.iter().map(|&i| self.encode(i)).collect()
250    }
251}
252
253impl Decoder for CachedTokenizer {
254    fn decode(&self, token_ids: &[TokenIdType], skip_special_tokens: bool) -> Result<DecodeResult> {
255        // Decode is not cached — passthrough to inner.
256        self.inner.decode(token_ids, skip_special_tokens)
257    }
258}
259
260impl Tokenizer for CachedTokenizer {}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::HuggingFaceTokenizer;
266    use std::sync::{Mutex, atomic::AtomicU64, atomic::Ordering};
267
268    struct FailingTokenizer;
269
270    impl Encoder for FailingTokenizer {
271        fn encode(&self, _input: &str) -> Result<Encoding> {
272            Err(anyhow::anyhow!("intentional encode failure"))
273        }
274
275        fn encode_batch(&self, _inputs: &[&str]) -> Result<Vec<Encoding>> {
276            Err(anyhow::anyhow!("intentional encode failure"))
277        }
278    }
279
280    impl Decoder for FailingTokenizer {
281        fn decode(
282            &self,
283            _token_ids: &[TokenIdType],
284            _skip_special_tokens: bool,
285        ) -> Result<DecodeResult> {
286            Err(anyhow::anyhow!("intentional decode failure"))
287        }
288    }
289
290    impl Tokenizer for FailingTokenizer {
291        fn validate_prefix_cache(&self) -> Result<()> {
292            Ok(())
293        }
294    }
295
296    const TINYLLAMA_PATH: &str = concat!(
297        env!("CARGO_MANIFEST_DIR"),
298        "/tests/data/sample-models/TinyLlama_v1.1/tokenizer.json"
299    );
300
301    fn inner() -> Arc<dyn Tokenizer> {
302        Arc::new(HuggingFaceTokenizer::from_file(TINYLLAMA_PATH).expect("load TinyLlama"))
303    }
304
305    fn specials() -> Vec<String> {
306        vec!["<s>".into(), "</s>".into()]
307    }
308
309    fn collect_token_usage(
310        tokenizer: CachedTokenizer,
311    ) -> (CachedTokenizer, Arc<Mutex<Vec<CacheTokenUsage>>>) {
312        let events = Arc::new(Mutex::new(Vec::new()));
313        let observed = events.clone();
314        let tokenizer = tokenizer.with_token_observer(Arc::new(move |usage| {
315            observed.lock().unwrap().push(usage);
316        }));
317        (tokenizer, events)
318    }
319
320    #[test]
321    fn rejects_hf_tokenizer_that_adds_special_tokens() {
322        let tokenizer: Arc<dyn Tokenizer> = Arc::new(
323            HuggingFaceTokenizer::from_file(TINYLLAMA_PATH)
324                .expect("load TinyLlama")
325                .with_options(crate::TokenizerOptions {
326                    add_special_tokens: true,
327                }),
328        );
329
330        let result = CachedTokenizer::new(tokenizer, specials(), 4096);
331        let Err(error) = result else {
332            panic!("add_special_tokens=true must be rejected");
333        };
334        assert_eq!(
335            error.to_string(),
336            "HuggingFace tokenizers configured with add_special_tokens=true must remain uncached"
337        );
338    }
339
340    #[test]
341    fn empty_specials_passes_through_correctly() {
342        // L1 disabled by empty specials list — encode must produce correct ids
343        // AND short-circuit to the inner tokenizer (no miss-counter bump, no
344        // insert attempt). Otherwise the tiktoken integration would log a
345        // miss per request with zero hits forever.
346        let tok = inner();
347        let (cached, events) = collect_token_usage(
348            CachedTokenizer::new(tok.clone(), Vec::new(), 4096)
349                .expect("TinyLlama must support prefix caching"),
350        );
351        let s = "<s>hello world</s>";
352        let a = cached.encode(s).unwrap();
353        let b = tok.encode(s).unwrap();
354        assert_eq!(a.token_ids(), b.token_ids());
355        let stats = cached.cache_stats();
356        assert_eq!(stats.entries, 0);
357        assert_eq!(stats.misses, 0, "empty specials must not increment misses");
358        assert_eq!(stats.hits, 0);
359        assert!(
360            events.lock().unwrap().is_empty(),
361            "empty specials must not emit token usage"
362        );
363    }
364
365    #[test]
366    fn token_observer_reports_full_miss_and_partial_hit_with_and_without_extension() {
367        for extend_on_hit in [false, true] {
368            let tok = inner();
369            let hits = Arc::new(AtomicU64::new(0));
370            let misses = Arc::new(AtomicU64::new(0));
371            let hit_counter = hits.clone();
372            let miss_counter = misses.clone();
373            let cached = CachedTokenizer::new(tok, specials(), 64 * 1024)
374                .expect("TinyLlama must support prefix caching")
375                .with_extend(extend_on_hit)
376                .with_observer(
377                    Arc::new(move || {
378                        hit_counter.fetch_add(1, Ordering::Relaxed);
379                    }),
380                    Arc::new(move || {
381                        miss_counter.fetch_add(1, Ordering::Relaxed);
382                    }),
383                );
384            let (cached, events) = collect_token_usage(cached);
385
386            let shared = "<s>system\nYou are helpful.</s><s>user\n";
387            let first = format!("{shared}First question?</s>");
388            let second = format!("{shared}Second different prompt entirely.</s>");
389
390            let first_encoding = cached.encode(&first).unwrap();
391            let second_encoding = cached.encode(&second).unwrap();
392
393            let events = events.lock().unwrap();
394            assert_eq!(events.len(), 2);
395            assert_eq!(
396                events[0],
397                CacheTokenUsage {
398                    cached_tokens: 0,
399                    uncached_tokens: first_encoding.token_ids().len(),
400                }
401            );
402            assert!(events[1].cached_tokens > 0);
403            assert!(events[1].uncached_tokens > 0);
404            assert_eq!(
405                events[1].cached_tokens + events[1].uncached_tokens,
406                second_encoding.token_ids().len()
407            );
408            assert_eq!(hits.load(Ordering::Relaxed), 1);
409            assert_eq!(misses.load(Ordering::Relaxed), 1);
410        }
411    }
412
413    #[test]
414    fn token_observer_does_not_report_failed_encodes() {
415        let tokenizer: Arc<dyn Tokenizer> = Arc::new(FailingTokenizer);
416        let (cached, events) = collect_token_usage(
417            CachedTokenizer::new(tokenizer, specials(), 4096)
418                .expect("test tokenizer explicitly supports prefix caching"),
419        );
420
421        assert!(cached.encode("<s>this fails</s>").is_err());
422        assert!(events.lock().unwrap().is_empty());
423    }
424
425    #[test]
426    fn two_turn_chat_correctness_and_hit() {
427        let tok = inner();
428        let cached = CachedTokenizer::new(tok.clone(), specials(), 64 * 1024)
429            .expect("TinyLlama must support prefix caching");
430
431        let template = "<s>system\nYou are helpful.</s><s>user\n";
432        let first = format!("{template}First question?</s>");
433        let second = format!("{template}Second different prompt entirely.</s>");
434
435        // Warm the cache.
436        let _ = cached.encode(&first).unwrap();
437
438        // Second request: shared prefix → L1 hit, suffix-only fresh encode.
439        let cached_second = cached.encode(&second).unwrap();
440        let plain_second = tok.encode(&second).unwrap();
441        assert_eq!(
442            cached_second.token_ids(),
443            plain_second.token_ids(),
444            "cached encode must equal plain encode for second turn"
445        );
446
447        let stats = cached.cache_stats();
448        assert!(stats.hits >= 1, "expected L1 hit on second request");
449    }
450
451    #[test]
452    fn decode_passes_through() {
453        let tok = inner();
454        let cached = CachedTokenizer::new(tok.clone(), specials(), 4096)
455            .expect("TinyLlama must support prefix caching");
456        let enc = cached.encode("<s>hello</s>").unwrap();
457        let direct = tok.decode(enc.token_ids(), false).unwrap();
458        let through = cached.decode(enc.token_ids(), false).unwrap();
459        assert_eq!(direct, through);
460    }
461
462    #[test]
463    fn encode_batch_uses_cache() {
464        let tok = inner();
465        let (cached, events) = collect_token_usage(
466            CachedTokenizer::new(tok.clone(), specials(), 64 * 1024)
467                .expect("TinyLlama must support prefix caching"),
468        );
469        let shared = "<s>system\nShared persona.</s><s>user\n";
470        let inputs = [
471            format!("{shared}q1</s>"),
472            format!("{shared}q2</s>"),
473            format!("{shared}q3</s>"),
474        ];
475        let refs: Vec<&str> = inputs.iter().map(String::as_str).collect();
476        let outs = cached.encode_batch(&refs).unwrap();
477        assert_eq!(outs.len(), 3);
478        let events = events.lock().unwrap();
479        assert_eq!(events.len(), outs.len());
480        for (event, output) in events.iter().zip(&outs) {
481            assert_eq!(
482                event.cached_tokens + event.uncached_tokens,
483                output.token_ids().len()
484            );
485        }
486        assert_eq!(events[0].cached_tokens, 0);
487        assert!(events[1..].iter().all(|event| event.cached_tokens > 0));
488        // First call populates, second/third hit.
489        assert!(cached.cache_stats().hits >= 2, "expected hits on q2 and q3");
490    }
491}