metrale_speculative/shared_lookup.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-10-04: Cross-request token cache for prompt lookup. A bounded index
4//! over recent requests' prompt and output tokens, split into scopes keyed by
5//! (model, tokenizer, LoRA adapter, tenant). A sequence whose own history has
6//! no match may copy the continuation of the same n-gram from what earlier
7//! requests of its scope held. Host memory only. The scheduler thread owns the
8//! cache, so there is no lock.
9//!
10//! Each scope is a token ring plus a slot table, both sized once when the scope
11//! is created. A document (one request's tokens) is appended to the ring and
12//! each of its n-grams is written to the slot its hash selects, holding the
13//! ring position of the token that followed it. A later write to the same slot
14//! replaces the entry, and the ring overwrites its oldest tokens: both are the
15//! eviction. A stale or colliding entry is harmless, because every hit is
16//! re-checked token by token against the ring before it is used.
17//!
18//! Runs a document repeats are not stored again: while an n-gram's existing
19//! entry already predicts the document's next token, nothing is written. A
20//! re-sent conversation prefix therefore costs no ring space.
21//!
22//! Owner: speculative.
23//! Invariants:
24//! - A proposal under scope `S` is a slice of tokens inserted under `S`: the
25//! `key_len` ring tokens before it equal the history's last `key_len`
26//! tokens, it is non-empty, and it stops at its document's end.
27//! - No call under one scope reads, writes or evicts another scope's tokens,
28//! except that creating a scope when `max_scopes` exist drops the least
29//! recently used scope whole.
30//! - Memory is fixed per scope at creation: at most `max_scopes` scopes of
31//! [`SharedLookupConfig::scope_bytes`] each, within the configured budget.
32//! - Deterministic: proposals depend only on the order of `insert` and
33//! `propose` calls and their arguments.
34
35use std::collections::HashMap;
36
37use crate::prompt_lookup::ngram_key;
38
39/// 2026-10-04: Ring marker after each stored run; never a token id, so a
40/// history never matches it and a copy stops at it.
41const SEP: u32 = u32::MAX;
42
43/// 2026-10-04: Duplicate n-grams in a row, while storing, after which the
44/// rest of a repeated run is skipped. Shorter repeats are stored, so a copy is
45/// not cut into many short pieces by brief coincidences.
46pub const DEDUP_RUN: usize = 32;
47
48/// 2026-10-04: Fewest ring tokens a scope may have.
49pub const MIN_SCOPE_TOKENS: usize = 1024;
50
51/// 2026-10-04: What partitions the cache. Two sequences share drafts only when
52/// every field is equal.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub struct ScopeKey {
55 pub model: u64,
56 pub tokenizer: u64,
57 pub adapter: u64,
58 pub tenant: u64,
59}
60
61/// 2026-10-04: Cache settings (`--prompt-lookup-shared-cache-*`).
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct SharedLookupConfig {
64 /// 2026-10-04: Host bytes for all scopes together.
65 pub budget_bytes: usize,
66 /// 2026-10-04: Most scopes held at once; each gets `budget_bytes / max_scopes`.
67 pub max_scopes: usize,
68 /// 2026-10-04: Tokens a match must span (the prompt-lookup n-gram length
69 /// or minimum match, whichever is longer).
70 pub key_len: usize,
71}
72
73impl SharedLookupConfig {
74 /// 2026-10-04: Slots and ring tokens of one scope: the largest power-of-two
75 /// slot table within half the scope's bytes, and the ring in the rest.
76 /// `Err` when the ring would hold fewer than [`MIN_SCOPE_TOKENS`].
77 pub fn scope_geometry(&self) -> Result<(usize, usize), String> {
78 if self.max_scopes == 0 || self.key_len == 0 {
79 return Err("shared prompt-lookup cache needs max_scopes >= 1 and key_len >= 1".into());
80 }
81 let per_scope = self.budget_bytes / self.max_scopes;
82 let slot_bytes = std::mem::size_of::<u64>();
83 let half_slots = per_scope / 2 / slot_bytes;
84 let slots = if half_slots == 0 {
85 0
86 } else {
87 1usize << half_slots.ilog2()
88 };
89 let ring = (per_scope - slots * slot_bytes) / std::mem::size_of::<u32>();
90 if slots == 0 || ring < MIN_SCOPE_TOKENS {
91 return Err(format!(
92 "shared prompt-lookup cache: {per_scope} bytes per scope holds fewer than \
93 {MIN_SCOPE_TOKENS} tokens; raise the budget or lower the scope count"
94 ));
95 }
96 Ok((slots, ring))
97 }
98
99 /// 2026-10-04: Bytes one scope allocates.
100 pub fn scope_bytes(&self) -> Result<usize, String> {
101 let (slots, ring) = self.scope_geometry()?;
102 Ok(slots * std::mem::size_of::<u64>() + ring * std::mem::size_of::<u32>())
103 }
104}
105
106/// 2026-10-04: One scope: a token ring addressed by logical position (never
107/// reused; position 0 is never written, so a 0 slot is empty) and a slot table
108/// from n-gram hash to the position of the token after that n-gram.
109#[derive(Debug)]
110struct Scope {
111 ring: Vec<u32>,
112 slots: Vec<u64>,
113 /// 2026-10-04: Next logical position to write.
114 head: u64,
115 /// 2026-10-04: Cache clock at this scope's last insert or proposal.
116 last_used: u64,
117}
118
119impl Scope {
120 fn new(slots: usize, ring: usize) -> Self {
121 Self {
122 ring: vec![0; ring],
123 slots: vec![0; slots],
124 head: 1,
125 last_used: 0,
126 }
127 }
128
129 fn slot_of(&self, key: &[u32]) -> usize {
130 let bits = self.slots.len().trailing_zeros();
131 if bits == 0 {
132 return 0;
133 }
134 (ngram_key(key).wrapping_mul(0x9e37_79b9_7f4a_7c15) >> (64 - bits)) as usize
135 }
136
137 /// 2026-10-04: Oldest logical position still in the ring.
138 fn oldest(&self) -> u64 {
139 self.head.saturating_sub(self.ring.len() as u64).max(1)
140 }
141
142 fn token(&self, pos: u64) -> u32 {
143 self.ring[(pos % self.ring.len() as u64) as usize]
144 }
145
146 /// 2026-10-04: The token at `pos` when it is still in the ring and not a
147 /// run end.
148 fn live_token(&self, pos: u64) -> Option<u32> {
149 (pos >= self.oldest() && pos < self.head)
150 .then(|| self.token(pos))
151 .filter(|&t| t != SEP)
152 }
153
154 fn push(&mut self, tok: u32) -> u64 {
155 let pos = self.head;
156 let len = self.ring.len() as u64;
157 self.ring[(pos % len) as usize] = tok;
158 self.head += 1;
159 pos
160 }
161
162 /// 2026-10-04: Position after the stored occurrence `key`'s slot points at,
163 /// when that occurrence is still in the ring and equals `key`.
164 fn find(&self, key: &[u32]) -> Option<u64> {
165 let pos = self.slots[self.slot_of(key)];
166 let k = key.len() as u64;
167 if pos == 0 || pos >= self.head || pos < self.oldest() + k {
168 return None;
169 }
170 key.iter()
171 .enumerate()
172 .all(|(i, &t)| self.token(pos - k + i as u64) == t)
173 .then_some(pos)
174 }
175
176 /// 2026-10-04: Stores `doc` with `k`-token keys, skipping runs the scope
177 /// already predicts (see [`DEDUP_RUN`]).
178 fn insert(&mut self, doc: &[u32], k: usize) {
179 // 2026-10-04: The tail that fits, with room for the run marker.
180 let doc = &doc[doc.len().saturating_sub(self.ring.len() - 1)..];
181 let mut writing = false;
182 let mut dup_run = 0usize;
183 for e in k..doc.len() {
184 let key = &doc[e - k..e];
185 let dup = self
186 .find(key)
187 .is_some_and(|p| self.live_token(p) == Some(doc[e]));
188 dup_run = if dup { dup_run + 1 } else { 0 };
189 if writing && dup_run >= DEDUP_RUN {
190 self.push(SEP);
191 writing = false;
192 }
193 if !writing && dup {
194 continue;
195 }
196 if !writing {
197 // 2026-10-04: A new run starts with its key as context.
198 for &t in key {
199 self.push(t);
200 }
201 writing = true;
202 }
203 let pos = self.push(doc[e]);
204 let slot = self.slot_of(key);
205 self.slots[slot] = pos;
206 }
207 if writing {
208 self.push(SEP);
209 }
210 }
211
212 fn propose(&self, history: &[u32], k: usize, max_len: usize) -> Option<Vec<u32>> {
213 if max_len == 0 || history.len() < k {
214 return None;
215 }
216 let start = self.find(&history[history.len() - k..])?;
217 let copy: Vec<u32> = (start..)
218 .take(max_len)
219 .map_while(|p| self.live_token(p))
220 .collect();
221 (!copy.is_empty()).then_some(copy)
222 }
223}
224
225/// 2026-10-04: The cross-request cache: scopes created on first insert, at
226/// most `max_scopes`, each of fixed size.
227#[derive(Debug)]
228pub struct SharedTokenCache {
229 key_len: usize,
230 max_scopes: usize,
231 slots: usize,
232 ring: usize,
233 scopes: HashMap<ScopeKey, Scope>,
234 clock: u64,
235}
236
237impl SharedTokenCache {
238 /// 2026-10-04: An empty cache; `Err` when a scope would be too small
239 /// ([`SharedLookupConfig::scope_geometry`]).
240 pub fn new(cfg: SharedLookupConfig) -> Result<Self, String> {
241 let (slots, ring) = cfg.scope_geometry()?;
242 Ok(Self {
243 key_len: cfg.key_len,
244 max_scopes: cfg.max_scopes,
245 slots,
246 ring,
247 scopes: HashMap::new(),
248 clock: 0,
249 })
250 }
251
252 /// 2026-10-04: Tokens a match must span.
253 pub fn key_len(&self) -> usize {
254 self.key_len
255 }
256
257 /// 2026-10-04: Scopes now held.
258 pub fn scope_count(&self) -> usize {
259 self.scopes.len()
260 }
261
262 /// 2026-10-04: Ring tokens written under `scope` so far (0 when absent):
263 /// what a document's stored, not skipped, tokens cost.
264 pub fn stored_tokens(&self, scope: &ScopeKey) -> u64 {
265 self.scopes.get(scope).map_or(0, |s| s.head - 1)
266 }
267
268 fn tick(&mut self) -> u64 {
269 self.clock += 1;
270 self.clock
271 }
272
273 /// 2026-10-04: Adds one request's tokens (prompt then output) under
274 /// `scope`, creating the scope if needed. When `max_scopes` already exist
275 /// the least recently used one is dropped first.
276 pub fn insert(&mut self, scope: ScopeKey, doc: &[u32]) {
277 if doc.len() <= self.key_len {
278 return;
279 }
280 let now = self.tick();
281 if !self.scopes.contains_key(&scope) && self.scopes.len() >= self.max_scopes {
282 // 2026-10-04: Clock values are unique, so the victim is too.
283 if let Some(victim) = self
284 .scopes
285 .iter()
286 .min_by_key(|(_, s)| s.last_used)
287 .map(|(k, _)| *k)
288 {
289 self.scopes.remove(&victim);
290 }
291 }
292 let (slots, ring, k) = (self.slots, self.ring, self.key_len);
293 let s = self
294 .scopes
295 .entry(scope)
296 .or_insert_with(|| Scope::new(slots, ring));
297 s.last_used = now;
298 s.insert(doc, k);
299 }
300
301 /// 2026-10-04: The continuation, at most `max_len` tokens, of the latest
302 /// stored occurrence under `scope` of `history`'s last `key_len` tokens.
303 /// `None` when the scope does not exist or holds no such occurrence.
304 pub fn propose(
305 &mut self,
306 scope: &ScopeKey,
307 history: &[u32],
308 max_len: usize,
309 ) -> Option<Vec<u32>> {
310 let now = self.tick();
311 let s = self.scopes.get_mut(scope)?;
312 s.last_used = now;
313 s.propose(history, self.key_len, max_len)
314 }
315}
316
317#[cfg(test)]
318#[path = "shared_lookup_tests.rs"]
319mod tests;