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 any [`Tokenizer`] in a cache that records prefix tokenizations at every
15//! special-token boundary. On a hit, the cached prefix tokens are merged with a
16//! fresh encode of the trailing suffix only — turning O(N) tokenization work
17//! 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/// Caching wrapper around an inner tokenizer.
65///
66/// Implements [`Encoder`], [`Decoder`], and [`Tokenizer`]; decode calls pass
67/// through to the inner tokenizer (decoding is fast and rarely repeated).
68pub struct CachedTokenizer {
69    inner: Arc<dyn Tokenizer>,
70    l1: L1Cache,
71    /// Whether L1 is active. False when the special-token set is empty (e.g. the tiktoken
72    /// wrapping path): `encode`/`encode_batch` then bypass the cache entirely. The special
73    /// tokens themselves live in the `L1Cache` (its boundary automaton).
74    l1_enabled: bool,
75    /// When true, cache the newly-tokenized suffix on a partial hit so the next turn
76    /// of a growing conversation hits deeper (see [`L1Cache::extend_after_match`]).
77    extend_on_hit: bool,
78}
79
80impl CachedTokenizer {
81    /// Construct a cached tokenizer.
82    ///
83    /// `special_tokens` is the list of atomic special-token strings the inner
84    /// tokenizer recognizes (typically extracted via the HuggingFace tokenizer's
85    /// `get_added_tokens_decoder()` filtering by `special == true`). An empty list
86    /// disables L1 — `encode`/`encode_batch` short-circuit to the inner tokenizer
87    /// without touching the cache or its counters.
88    ///
89    /// `max_memory_bytes` is the L1 cache byte budget.
90    pub fn new(
91        inner: Arc<dyn Tokenizer>,
92        special_tokens: Vec<String>,
93        max_memory_bytes: usize,
94    ) -> Self {
95        let l1_enabled = !special_tokens.is_empty();
96        Self {
97            inner,
98            l1: L1Cache::new(max_memory_bytes, special_tokens),
99            l1_enabled,
100            extend_on_hit: false,
101        }
102    }
103
104    /// Enable partial-hit extension. When on, a partial cache hit also caches the
105    /// freshly-tokenized suffix at its deepest special-token boundary, so each turn of
106    /// a growing multi-turn conversation hits deeper than the last and per-turn
107    /// tokenization cost stops growing with conversation length. Default off.
108    pub fn with_extend(mut self, enabled: bool) -> Self {
109        self.extend_on_hit = enabled;
110        self
111    }
112
113    /// Install hit/miss callbacks so each L1 lookup pushes an event into the
114    /// supplied closures (e.g. `Prometheus::Counter::inc`). Replaces any
115    /// previously-set observer.
116    pub fn with_observer(mut self, on_hit: CacheEventFn, on_miss: CacheEventFn) -> Self {
117        self.l1.set_observer(on_hit, on_miss);
118        self
119    }
120
121    /// Snapshot of L1 cache statistics (cumulative hits/misses/entries/memory).
122    pub fn cache_stats(&self) -> L1CacheStats {
123        self.l1.stats()
124    }
125
126    /// Clear all cached entries and reset counters.
127    pub fn clear_cache(&self) {
128        self.l1.clear();
129    }
130
131    /// Access the underlying tokenizer (e.g. for downcasting to a concrete type).
132    pub fn inner(&self) -> &Arc<dyn Tokenizer> {
133        &self.inner
134    }
135}
136
137impl Encoder for CachedTokenizer {
138    fn encode(&self, input: &str) -> Result<Encoding> {
139        // No specials => no boundaries are ever produced. Skip the lookup, miss-counter
140        // bump, and insert attempt entirely — otherwise the tiktoken wrapping path (which
141        // deliberately passes an empty list) pays the cost on every call with no chance
142        // of a hit.
143        if !self.l1_enabled {
144            return self.inner.encode(input);
145        }
146
147        if let Some((prefix_tokens, prefix_len, deepest_boundary)) =
148            self.l1.longest_prefix_match(input)
149        {
150            let suffix = &input[prefix_len..];
151            if suffix.is_empty() {
152                return Ok(Encoding::Sp(prefix_tokens.to_vec()));
153            }
154            if self.extend_on_hit {
155                // Cache the new suffix at its deepest boundary so the next turn hits
156                // deeper, then return the full merged tokens. The deepest boundary was
157                // already found by `longest_prefix_match`, so no rescan is needed here.
158                return Ok(Encoding::Sp(self.l1.extend_after_match(
159                    input,
160                    prefix_tokens,
161                    prefix_len,
162                    deepest_boundary,
163                    self.inner.as_ref(),
164                )?));
165            }
166            let suffix_enc = self.inner.encode(suffix)?;
167            // Reserve exact capacity so appending the suffix doesn't grow-realloc and
168            // re-copy the (large) cached prefix.
169            let mut merged: Vec<TokenIdType> =
170                Vec::with_capacity(prefix_tokens.len() + suffix_enc.token_ids().len());
171            merged.extend_from_slice(&prefix_tokens);
172            merged.extend_from_slice(suffix_enc.token_ids());
173            return Ok(Encoding::Sp(merged));
174        }
175
176        // Miss path: tokenize once, caching the cumulative prefix at every boundary as we
177        // go. The returned ids equal an uncached encode (special tokens are atomic), so we
178        // avoid the redundant second tokenization a separate full-encode + insert would
179        // cost. Returns Encoding::Sp — consistent with the hit path (see the storage-
180        // normalization note in the module docs).
181        Ok(Encoding::Sp(
182            self.l1.populate_and_encode(input, self.inner.as_ref())?,
183        ))
184    }
185
186    fn encode_batch(&self, inputs: &[&str]) -> Result<Vec<Encoding>> {
187        // True passthrough when L1 is disabled — delegate to the inner's native
188        // batch path (which may be rayon-parallel for HF) instead of falling
189        // through per-item.
190        if !self.l1_enabled {
191            return self.inner.encode_batch(inputs);
192        }
193
194        // Per-item cache lookup — do NOT delegate to inner.encode_batch, which would
195        // bypass the cache. Sequential iteration is fine; if rayon is added later it
196        // belongs here, not inside `encode`.
197        inputs.iter().map(|&i| self.encode(i)).collect()
198    }
199}
200
201impl Decoder for CachedTokenizer {
202    fn decode(&self, token_ids: &[TokenIdType], skip_special_tokens: bool) -> Result<DecodeResult> {
203        // Decode is not cached — passthrough to inner.
204        self.inner.decode(token_ids, skip_special_tokens)
205    }
206}
207
208impl Tokenizer for CachedTokenizer {}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::HuggingFaceTokenizer;
214
215    const TINYLLAMA_PATH: &str = concat!(
216        env!("CARGO_MANIFEST_DIR"),
217        "/../llm/tests/data/sample-models/TinyLlama_v1.1/tokenizer.json"
218    );
219
220    fn inner() -> Arc<dyn Tokenizer> {
221        Arc::new(HuggingFaceTokenizer::from_file(TINYLLAMA_PATH).expect("load TinyLlama"))
222    }
223
224    fn specials() -> Vec<String> {
225        vec!["<s>".into(), "</s>".into()]
226    }
227
228    #[test]
229    fn empty_specials_passes_through_correctly() {
230        // L1 disabled by empty specials list — encode must produce correct ids
231        // AND short-circuit to the inner tokenizer (no miss-counter bump, no
232        // insert attempt). Otherwise the tiktoken integration would log a
233        // miss per request with zero hits forever.
234        let tok = inner();
235        let cached = CachedTokenizer::new(tok.clone(), Vec::new(), 4096);
236        let s = "<s>hello world</s>";
237        let a = cached.encode(s).unwrap();
238        let b = tok.encode(s).unwrap();
239        assert_eq!(a.token_ids(), b.token_ids());
240        let stats = cached.cache_stats();
241        assert_eq!(stats.entries, 0);
242        assert_eq!(stats.misses, 0, "empty specials must not increment misses");
243        assert_eq!(stats.hits, 0);
244    }
245
246    #[test]
247    fn two_turn_chat_correctness_and_hit() {
248        let tok = inner();
249        let cached = CachedTokenizer::new(tok.clone(), specials(), 64 * 1024);
250
251        let template = "<s>system\nYou are helpful.</s><s>user\n";
252        let first = format!("{template}First question?</s>");
253        let second = format!("{template}Second different prompt entirely.</s>");
254
255        // Warm the cache.
256        let _ = cached.encode(&first).unwrap();
257
258        // Second request: shared prefix → L1 hit, suffix-only fresh encode.
259        let cached_second = cached.encode(&second).unwrap();
260        let plain_second = tok.encode(&second).unwrap();
261        assert_eq!(
262            cached_second.token_ids(),
263            plain_second.token_ids(),
264            "cached encode must equal plain encode for second turn"
265        );
266
267        let stats = cached.cache_stats();
268        assert!(stats.hits >= 1, "expected L1 hit on second request");
269    }
270
271    #[test]
272    fn decode_passes_through() {
273        let tok = inner();
274        let cached = CachedTokenizer::new(tok.clone(), specials(), 4096);
275        let enc = cached.encode("<s>hello</s>").unwrap();
276        let direct = tok.decode(enc.token_ids(), false).unwrap();
277        let through = cached.decode(enc.token_ids(), false).unwrap();
278        assert_eq!(direct, through);
279    }
280
281    #[test]
282    fn encode_batch_uses_cache() {
283        let tok = inner();
284        let cached = CachedTokenizer::new(tok.clone(), specials(), 64 * 1024);
285        let shared = "<s>system\nShared persona.</s><s>user\n";
286        let inputs = [
287            format!("{shared}q1</s>"),
288            format!("{shared}q2</s>"),
289            format!("{shared}q3</s>"),
290        ];
291        let refs: Vec<&str> = inputs.iter().map(String::as_str).collect();
292        let outs = cached.encode_batch(&refs).unwrap();
293        assert_eq!(outs.len(), 3);
294        // First call populates, second/third hit.
295        assert!(cached.cache_stats().hits >= 2, "expected hits on q2 and q3");
296    }
297}