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;