metrale_grammar/compiler/
rule_cache.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//
3// RuleLevelCache — port of `class RuleLevelCache` from
4// `cpp/grammar_functor.cc` (upstream commit `bfb2a79`, "feat: support
5// crossing-grammar cache", PR #526).
6//
7// CROSS-GRAMMAR / SUB-STRUCTURE MASK CACHE
8// ----------------------------------------
9// Two grammars compiled in different requests very often share
10// structurally identical rules — every JSON-schema tool definition
11// reuses the same `basic_string`, `basic_number`, whitespace and
12// punctuation sub-rules. The expensive part of compiling a rule is the
13// per-state `AdaptiveTokenMask` computation (a full sorted-vocab scan
14// through an `EarleyParser`). That result depends ONLY on the rule's
15// FSM structure and the tokenizer — not on which grammar the rule
16// belongs to.
17//
18// `RuleLevelCache` keys a computed mask by a *structural* tuple:
19//   (fsm_hash, fsm_new_node_id, state_cnt, edge_cnt)
20// where `fsm_hash` is the `GrammarFsmHasher` per-rule structural hash,
21// `fsm_new_node_id` is the canonical (renumbered) state id within that
22// rule's FSM, and `state_cnt`/`edge_cnt` are the rule FSM's node/edge
23// counts (a cheap collision guard, exactly as upstream). A rule seen in
24// a previous request — even from an entirely different grammar — reuses
25// its masks instead of recomputing them. Upstream measures 3-7x on
26// multi-request tool-calling workloads.
27//
28// LRU EVICTION
29// ------------
30// Upstream bounds the cache by an approximate memory budget with an LRU
31// list. This port mirrors that: `max_cache_memory_size` bounds the sum
32// of `AdaptiveTokenMask::memory_size()` of cached entries; on insert,
33// least-recently-used entries are evicted until the new entry fits.
34// `kUnlimitedSize` (`usize::MAX`) disables the bound — entries are then
35// kept until `clear`.
36//
37// THREAD SAFETY
38// -------------
39// The `GrammarCompiler` is shared across requests and its grammar-level
40// cache is `dashmap`-based; this rule-level cache matches that posture
41// with an inner `Mutex` guarding the map + LRU list. Lookups and
42// inserts are short (a hash-map probe and an `Arc` clone / LRU splice),
43// so a single `Mutex` is cheaper than `DashMap`'s sharding here and
44// keeps the LRU list — which is inherently shared — trivially correct.
45
46use std::collections::HashMap;
47use std::sync::atomic::{AtomicU64, Ordering};
48use std::sync::{Arc, Mutex};
49
50use super::mask::AdaptiveTokenMask;
51
52/// Sentinel `max_cache_memory_size` meaning "no memory bound" — port of
53/// `RuleLevelCache::kUnlimitedSize` (`static_cast<size_t>(-1)`).
54pub const UNLIMITED_SIZE: usize = usize::MAX;
55
56/// The structural cache key — port of `RuleLevelCache::Impl::NodeKey`.
57///
58/// Faithful to upstream's `std::tuple<uint64_t, int32_t, int32_t,
59/// int32_t>`: the rule FSM's structural hash, the renumbered (canonical)
60/// node id, and the rule FSM's node / edge counts. The two counts make
61/// a hash collision between structurally different rules astronomically
62/// unlikely without storing the full FSM in the key.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub struct RuleMaskKey {
65    /// Per-rule FSM structural hash from [`crate::grammar::functor::
66    /// GrammarFsmHasher`]. For lookahead-bearing / root rules this is
67    /// the hash already combined with the lookahead hash + flags by the
68    /// caller (see `compile.rs`), so the key stays a single `u64`.
69    pub fsm_hash: u64,
70    /// The canonical (renumbered) FSM node id of the parser state — from
71    /// `per_rule_fsm_new_state_ids`.
72    pub fsm_new_node_id: i32,
73    /// The rule FSM's node count (`CompactFsmWithStartEnd::num_states`).
74    pub state_cnt: i32,
75    /// The rule FSM's edge count (`CompactFsmWithStartEnd::num_edges`).
76    pub edge_cnt: i32,
77}
78
79/// An entry in the LRU intrusive list — a key plus its cached mask. The
80/// list is kept newest-at-back, matching upstream's `cache_list_`.
81struct Entry {
82    key: RuleMaskKey,
83    mask: Arc<AdaptiveTokenMask>,
84}
85
86/// The mutable interior, guarded by one `Mutex`.
87struct Inner {
88    /// `key -> index into `lru``. Port of `RuleLevelCache::Impl::cache_`.
89    index: HashMap<RuleMaskKey, usize>,
90    /// LRU order — `lru[0]` is least-recently-used, `lru.last()` is
91    /// most-recently-used. A `Vec` splice is O(n) but the cache is
92    /// small (bounded by the memory budget) and lookups dominate; this
93    /// keeps the structure `unsafe`-free, which the crate forbids.
94    lru: Vec<Entry>,
95    /// Sum of `memory_size()` over every cached mask.
96    current_size: usize,
97}
98
99/// Cross-grammar adaptive-token-mask cache. Cheap to clone — the inner
100/// state is shared via [`Arc`], matching the C++ pimpl `shared_ptr`.
101#[derive(Clone)]
102pub struct RuleLevelCache {
103    max_size: usize,
104    inner: Arc<Mutex<Inner>>,
105    /// Lifetime lookup outcomes, for the #918 warm-up logging and for
106    /// tests that need a DETERMINISTIC hit/miss oracle rather than a
107    /// wall-clock ratio. `(hits, misses)`.
108    hits: Arc<AtomicU64>,
109    misses: Arc<AtomicU64>,
110}
111
112impl std::fmt::Debug for RuleLevelCache {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        let inner = self.inner.lock().expect("rule cache mutex poisoned");
115        f.debug_struct("RuleLevelCache")
116            .field("max_size", &self.max_size)
117            .field("entries", &inner.lru.len())
118            .field("current_size", &inner.current_size)
119            .finish()
120    }
121}
122
123impl RuleLevelCache {
124    /// Construct a cache bounded by `max_cache_memory_size` bytes.
125    /// Pass [`UNLIMITED_SIZE`] for no bound. Port of the C++
126    /// `RuleLevelCache(size_t)` constructor.
127    pub fn new(max_cache_memory_size: usize) -> Self {
128        Self {
129            max_size: max_cache_memory_size,
130            inner: Arc::new(Mutex::new(Inner {
131                index: HashMap::new(),
132                lru: Vec::new(),
133                current_size: 0,
134            })),
135            hits: Arc::new(AtomicU64::new(0)),
136            misses: Arc::new(AtomicU64::new(0)),
137        }
138    }
139
140    /// Look up the cached mask for `key`, marking it most-recently-used
141    /// on a hit. Port of `RuleLevelCache::GetCache`.
142    pub fn get(&self, key: &RuleMaskKey) -> Option<Arc<AdaptiveTokenMask>> {
143        let mut inner = self.inner.lock().expect("rule cache mutex poisoned");
144        let Some(&idx) = inner.index.get(key) else {
145            self.misses.fetch_add(1, Ordering::Relaxed);
146            return None;
147        };
148        self.hits.fetch_add(1, Ordering::Relaxed);
149        Self::touch(&mut inner, idx);
150        // After `touch`, the entry is at the back.
151        Some(Arc::clone(&inner.lru[inner.lru.len() - 1].mask))
152    }
153
154    /// Insert `mask` under `key`. Returns `true` if it was stored,
155    /// `false` if rejected (already present, or larger than the whole
156    /// budget). Port of `RuleLevelCache::AddCache`.
157    pub fn add(&self, key: RuleMaskKey, mask: Arc<AdaptiveTokenMask>) -> bool {
158        let item_size = mask.memory_size();
159        let mut inner = self.inner.lock().expect("rule cache mutex poisoned");
160
161        // A mask larger than the entire budget can never be cached.
162        if self.max_size != UNLIMITED_SIZE && item_size > self.max_size {
163            return false;
164        }
165        // Already cached — upstream returns false and does not refresh.
166        if inner.index.contains_key(&key) {
167            return false;
168        }
169
170        // Evict least-recently-used entries until the new item fits.
171        if self.max_size != UNLIMITED_SIZE {
172            let budget = self.max_size.saturating_sub(item_size);
173            while inner.current_size > budget && !inner.lru.is_empty() {
174                let evicted = inner.lru.remove(0);
175                inner.current_size -= evicted.mask.memory_size();
176                inner.index.remove(&evicted.key);
177                // Removing index 0 shifts every later index down by one.
178                for v in inner.index.values_mut() {
179                    *v -= 1;
180                }
181            }
182        }
183
184        inner.current_size += item_size;
185        inner.lru.push(Entry { key, mask });
186        let last = inner.lru.len() - 1;
187        inner.index.insert(key, last);
188        true
189    }
190
191    /// Get-or-compute: returns the cached mask if present, otherwise
192    /// runs `compute`, inserts the result, and returns it. This is the
193    /// single call site `compile.rs` uses — it keeps the "hash on
194    /// compile; on hit reuse, on miss compute + insert" logic in one
195    /// place. The computed value is always returned even if the insert
196    /// is rejected (e.g. the mask exceeds the whole budget).
197    pub fn get_or_compute<F>(&self, key: RuleMaskKey, compute: F) -> Arc<AdaptiveTokenMask>
198    where
199        F: FnOnce() -> AdaptiveTokenMask,
200    {
201        if let Some(hit) = self.get(&key) {
202            return hit;
203        }
204        let computed = Arc::new(compute());
205        self.add(key, Arc::clone(&computed));
206        computed
207    }
208
209    /// Snapshot every cached entry in LRU order (least-recently-used
210    /// first), for cross-process persistence — see #918 and
211    /// [`super::mask_snapshot`].
212    ///
213    /// Returns clones of the `Arc`s, so the caller can encode the
214    /// masks without holding the cache lock.
215    pub fn entries(&self) -> Vec<(RuleMaskKey, Arc<AdaptiveTokenMask>)> {
216        let inner = self.inner.lock().expect("rule cache mutex poisoned");
217        inner
218            .lru
219            .iter()
220            .map(|e| (e.key, Arc::clone(&e.mask)))
221            .collect()
222    }
223
224    /// Drop every cached entry. Port of `RuleLevelCache::ClearCache`.
225    pub fn clear(&self) {
226        let mut inner = self.inner.lock().expect("rule cache mutex poisoned");
227        inner.index.clear();
228        inner.lru.clear();
229        inner.current_size = 0;
230    }
231
232    /// The configured memory bound in bytes ([`UNLIMITED_SIZE`] when
233    /// unbounded). Port of `RuleLevelCache::GetMaxSize`.
234    pub fn max_size(&self) -> usize {
235        self.max_size
236    }
237
238    /// Approximate bytes held by the cache — the sum of every cached
239    /// mask's `memory_size()`. Port of `MemorySize(const RuleLevelCache&)`.
240    pub fn memory_size(&self) -> usize {
241        self.inner
242            .lock()
243            .expect("rule cache mutex poisoned")
244            .current_size
245    }
246
247    /// Lifetime `(hits, misses)` over [`Self::get`] — the deterministic
248    /// oracle behind the #918 snapshot hit/miss tests. Not reset by
249    /// [`Self::clear`]: they count lookups, not residency.
250    pub fn hit_miss(&self) -> (u64, u64) {
251        (
252            self.hits.load(Ordering::Relaxed),
253            self.misses.load(Ordering::Relaxed),
254        )
255    }
256
257    /// Number of cached entries — for tests / introspection.
258    pub fn len(&self) -> usize {
259        self.inner
260            .lock()
261            .expect("rule cache mutex poisoned")
262            .lru
263            .len()
264    }
265
266    /// True if the cache holds no entries.
267    pub fn is_empty(&self) -> bool {
268        self.len() == 0
269    }
270
271    /// Move the entry at `idx` to the back of the LRU list (most-
272    /// recently-used), fixing every shifted index. Port of the C++
273    /// `List::MoveBack`.
274    fn touch(inner: &mut Inner, idx: usize) {
275        let last = inner.lru.len() - 1;
276        if idx == last {
277            return;
278        }
279        let entry = inner.lru.remove(idx);
280        let moved_key = entry.key;
281        inner.lru.push(entry);
282        // Everything that was after `idx` shifted down by one; the moved
283        // entry is now at the back.
284        for v in inner.index.values_mut() {
285            if *v > idx {
286                *v -= 1;
287            }
288        }
289        inner.index.insert(moved_key, inner.lru.len() - 1);
290    }
291}
292
293#[cfg(test)]
294#[path = "rule_cache_tests.rs"]
295mod tests;