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;