Skip to main content

llama_cpp_4/
ngram.rs

1//! Speculative decoding with no draft model.
2//!
3//! [`Eagle3Session`](crate::eagle::Eagle3Session) and
4//! [`MtpSession`](crate::mtp::MtpSession) draft with a second model. These
5//! draft by *looking up what came before*: find where the recent token history
6//! repeats, and propose whatever followed it last time. That costs one hash
7//! lookup per step instead of a forward pass, needs no extra weights and no
8//! extra VRAM, and wins big on the workloads where text repeats — code
9//! editing, RAG over a quoted document, JSON with recurring keys, chat that
10//! restates the question.
11//!
12//! Three strategies, in increasing order of memory and payoff:
13//!
14//! | | Learns from | State |
15//! |---|---|---|
16//! | [`ngram_simple_draft`] | the current context only | none |
17//! | [`NgramMap`] | the current context, adapting to how its drafts land | in memory |
18//! | [`NgramCache`] | a corpus and/or past generations | in memory, saveable |
19//!
20//! All three return a *draft*: candidate tokens to verify against the target
21//! model, typically via
22//! [`CommonSampler::sample_and_accept_n`](crate::common_sampler::CommonSampler::sample_and_accept_n).
23//! A wrong draft costs only the tokens it wasted.
24
25use std::ffi::CString;
26use std::ptr::NonNull;
27
28use llama_cpp_sys_4 as sys;
29
30use crate::shim::{check_status, last_error, read_tokens, ShimError};
31use crate::token::LlamaToken;
32
33/// Errors from the n-gram drafters.
34pub type NgramError = ShimError;
35
36type Result<T> = std::result::Result<T, NgramError>;
37
38/// Draft by finding the most recent repeat of the trailing n-gram.
39///
40/// Looks for the last `size_ngram` tokens elsewhere in `tokens` and proposes
41/// the `size_mgram` tokens that followed it. Entirely stateless — the whole
42/// strategy is "this text repeated once, it may repeat again".
43///
44/// `tokens` is the history **excluding** `sampled`: upstream builds its search
45/// pattern from the tail of `tokens` plus `sampled`, so passing a history that
46/// already ends in `sampled` searches for the wrong thing.
47///
48/// Returns an empty draft when nothing matches, which is the common case for
49/// prose and the reason this is nearly free. Also empty when the history is
50/// shorter than `size_ngram + size_mgram + 1`, or when the only match sits at
51/// position 0 — upstream treats index 0 as "no match".
52///
53/// # Errors
54///
55/// Returns [`NgramError::Failed`] if llama.cpp throws.
56// The two parameter names mirror `common_ngram_simple_config`; renaming either
57// to satisfy `similar_names` would obscure which upstream field it sets.
58#[allow(clippy::similar_names)]
59pub fn ngram_simple_draft(
60    size_ngram: u16,
61    size_mgram: u16,
62    tokens: &[LlamaToken],
63    sampled: LlamaToken,
64) -> Result<Vec<LlamaToken>> {
65    let raw: Vec<i32> = tokens.iter().map(|t| t.0).collect();
66    read_tokens(|out, cap, len| unsafe {
67        sys::common_shim_ngram_simple_draft(
68            size_ngram,
69            size_mgram,
70            raw.as_ptr(),
71            raw.len(),
72            sampled.0,
73            out,
74            cap,
75            len,
76        )
77    })
78}
79
80/// A statistical n-gram cache: which tokens tend to follow which n-grams.
81///
82/// Where [`ngram_simple_draft`] takes the single most recent repeat,
83/// this accumulates a distribution over many observations and drafts the most
84/// likely continuation. It can be persisted, so a cache built once from a
85/// corpus — or grown across a user's sessions — keeps paying off.
86///
87/// llama.cpp consults up to three caches at once, in priority order:
88///
89/// * **context** — built from the current conversation, most specific;
90/// * **dynamic** — built from this user's past generations;
91/// * **static** — built offline from a large corpus, used to validate.
92///
93/// Wraps `common_ngram_cache`.
94pub struct NgramCache {
95    raw: NonNull<sys::common_shim_ngram_cache>,
96}
97
98impl std::fmt::Debug for NgramCache {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.debug_struct("NgramCache")
101            .field("len", &self.len())
102            .finish()
103    }
104}
105
106// SAFETY: the handle owns an `unordered_map` with no shared state; mutating
107// methods take `&mut self`.
108unsafe impl Send for NgramCache {}
109
110impl Drop for NgramCache {
111    fn drop(&mut self) {
112        unsafe { sys::common_shim_ngram_cache_free(self.raw.as_ptr()) }
113    }
114}
115
116impl Default for NgramCache {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122impl NgramCache {
123    /// An empty cache.
124    ///
125    /// # Panics
126    ///
127    /// Panics if the allocation fails.
128    #[must_use]
129    pub fn new() -> Self {
130        let raw = unsafe { sys::common_shim_ngram_cache_init() };
131        Self {
132            raw: NonNull::new(raw).expect("common_shim_ngram_cache_init returned null"),
133        }
134    }
135
136    /// Load a cache written by [`Self::save`].
137    ///
138    /// # Errors
139    ///
140    /// Returns [`NgramError::Failed`] if the file is missing or malformed, or
141    /// [`NgramError::Nul`] for an interior NUL in `path`.
142    pub fn load(path: &str) -> Result<Self> {
143        let c_path = CString::new(path)?;
144        let raw = unsafe { sys::common_shim_ngram_cache_load(c_path.as_ptr()) };
145        NonNull::new(raw)
146            .map(|raw| Self { raw })
147            .ok_or_else(|| NgramError::Failed(last_error()))
148    }
149
150    /// Write this cache to disk.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`NgramError::Failed`] if the file cannot be written, or
155    /// [`NgramError::Nul`] for an interior NUL in `path`.
156    pub fn save(&mut self, path: &str) -> Result<()> {
157        let c_path = CString::new(path)?;
158        let status =
159            unsafe { sys::common_shim_ngram_cache_save(self.raw.as_ptr(), c_path.as_ptr()) };
160        check_status(status)
161    }
162
163    /// Fold another cache's counts into this one.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`NgramError::Failed`] if llama.cpp throws.
168    pub fn merge(&mut self, other: &mut NgramCache) -> Result<()> {
169        let status =
170            unsafe { sys::common_shim_ngram_cache_merge(self.raw.as_ptr(), other.raw.as_ptr()) };
171        check_status(status)
172    }
173
174    /// Number of distinct n-grams recorded.
175    #[must_use]
176    pub fn len(&self) -> usize {
177        unsafe { sys::common_shim_ngram_cache_size(self.raw.as_ptr()) }
178    }
179
180    /// Whether the cache has learned anything yet.
181    #[must_use]
182    pub fn is_empty(&self) -> bool {
183        self.len() == 0
184    }
185
186    /// Learn from a token sequence.
187    ///
188    /// `nnew` is how many tokens were appended since the last call, so a live
189    /// conversation only pays for its new tokens. Upstream requires `tokens` to
190    /// be **append-only**: editing the middle invalidates the statistics and
191    /// needs a rebuild from scratch.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`NgramError::Failed`] if llama.cpp throws.
196    pub fn update(
197        &mut self,
198        ngram_min: i32,
199        ngram_max: i32,
200        tokens: &[LlamaToken],
201        nnew: i32,
202        print_progress: bool,
203    ) -> Result<()> {
204        let raw: Vec<i32> = tokens.iter().map(|t| t.0).collect();
205        let status = unsafe {
206            sys::common_shim_ngram_cache_update(
207                self.raw.as_ptr(),
208                ngram_min,
209                ngram_max,
210                raw.as_ptr(),
211                raw.len(),
212                nnew,
213                print_progress,
214            )
215        };
216        check_status(status)
217    }
218}
219
220/// Draft a continuation from up to three caches.
221///
222/// Any cache may be `None`. `tokens` must be non-empty — the last token seeds
223/// the lookup — and the returned draft excludes it.
224///
225/// # Errors
226///
227/// Returns [`NgramError::InvalidArg`] for empty `tokens`, or
228/// [`NgramError::Failed`] if llama.cpp throws.
229pub fn ngram_cache_draft(
230    tokens: &[LlamaToken],
231    n_draft: i32,
232    ngram_min: i32,
233    ngram_max: i32,
234    context: Option<&mut NgramCache>,
235    dynamic: Option<&mut NgramCache>,
236    statik: Option<&mut NgramCache>,
237) -> Result<Vec<LlamaToken>> {
238    if tokens.is_empty() {
239        return Err(NgramError::InvalidArg);
240    }
241    let raw: Vec<i32> = tokens.iter().map(|t| t.0).collect();
242    let ctx_ptr = context.map_or(std::ptr::null_mut(), |c| c.raw.as_ptr());
243    let dyn_ptr = dynamic.map_or(std::ptr::null_mut(), |c| c.raw.as_ptr());
244    let sta_ptr = statik.map_or(std::ptr::null_mut(), |c| c.raw.as_ptr());
245
246    read_tokens(|out, cap, len| unsafe {
247        sys::common_shim_ngram_cache_draft(
248            raw.as_ptr(),
249            raw.len(),
250            n_draft,
251            ngram_min,
252            ngram_max,
253            ctx_ptr,
254            dyn_ptr,
255            sta_ptr,
256            out,
257            cap,
258            len,
259        )
260    })
261}
262
263/// An adaptive in-context n-gram drafter.
264///
265/// Like [`NgramCache`] it indexes repeated n-grams, but it also records how
266/// many tokens each of its own drafts got accepted and uses that to decide
267/// whether to draft again — so a context where lookup is not paying off stops
268/// costing anything. Feed results back with [`Self::accept`].
269///
270/// State lives only in memory and only for this generation; call
271/// [`Self::begin`] when starting a new one.
272///
273/// Wraps `common_ngram_map`.
274pub struct NgramMap {
275    raw: NonNull<sys::common_shim_ngram_map>,
276}
277
278impl std::fmt::Debug for NgramMap {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        f.debug_struct("NgramMap").finish_non_exhaustive()
281    }
282}
283
284// SAFETY: the handle owns a `common_ngram_map` with no shared state.
285unsafe impl Send for NgramMap {}
286
287impl Drop for NgramMap {
288    fn drop(&mut self) {
289        unsafe { sys::common_shim_ngram_map_free(self.raw.as_ptr()) }
290    }
291}
292
293impl NgramMap {
294    /// Build a map.
295    ///
296    /// * `size_key` — length of the n-grams used as lookup keys.
297    /// * `size_value` — length of the continuations drafted.
298    /// * `key_only` — index keys without tracking continuations, which is
299    ///   cheaper but drafts nothing on its own.
300    /// * `min_hits` — how many times a key must recur before it is trusted.
301    ///
302    /// # Errors
303    ///
304    /// Returns [`NgramError::Failed`] if llama.cpp cannot allocate the map —
305    /// it reserves a 2^18-entry hash table up front.
306    pub fn new(size_key: u16, size_value: u16, key_only: bool, min_hits: u16) -> Result<Self> {
307        let raw =
308            unsafe { sys::common_shim_ngram_map_init(size_key, size_value, key_only, min_hits) };
309        NonNull::new(raw)
310            .map(|raw| Self { raw })
311            .ok_or_else(|| NgramError::Failed(last_error()))
312    }
313
314    /// Start a generation over `tokens` (the prompt).
315    ///
316    /// # Errors
317    ///
318    /// Returns [`NgramError::Failed`] if llama.cpp throws.
319    pub fn begin(&mut self, tokens: &[LlamaToken]) -> Result<()> {
320        let raw: Vec<i32> = tokens.iter().map(|t| t.0).collect();
321        let status =
322            unsafe { sys::common_shim_ngram_map_begin(self.raw.as_ptr(), raw.as_ptr(), raw.len()) };
323        check_status(status)
324    }
325
326    /// Draft a continuation, given everything generated so far and the token
327    /// just sampled.
328    ///
329    /// Returns an empty draft when the map decides lookup is not worth it here.
330    ///
331    /// # Errors
332    ///
333    /// Returns [`NgramError::Failed`] if llama.cpp throws.
334    pub fn draft(&mut self, tokens: &[LlamaToken], sampled: LlamaToken) -> Result<Vec<LlamaToken>> {
335        let raw: Vec<i32> = tokens.iter().map(|t| t.0).collect();
336        read_tokens(|out, cap, len| unsafe {
337            sys::common_shim_ngram_map_draft(
338                self.raw.as_ptr(),
339                raw.as_ptr(),
340                raw.len(),
341                sampled.0,
342                out,
343                cap,
344                len,
345            )
346        })
347    }
348
349    /// Report how many of the last draft's tokens the target model accepted.
350    ///
351    /// This is what makes the map adaptive; skipping it leaves it drafting
352    /// blind.
353    pub fn accept(&mut self, n_accepted: u16) {
354        unsafe { sys::common_shim_ngram_map_accept(self.raw.as_ptr(), n_accepted) }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    fn toks(v: &[i32]) -> Vec<LlamaToken> {
363        v.iter().copied().map(LlamaToken).collect()
364    }
365
366    /// The whole premise: text that repeats should be drafted from its earlier
367    /// occurrence.
368    ///
369    /// `sampled` is deliberately *not* in `history` — upstream appends it to
370    /// build the search pattern. The leading 9 keeps the earlier `1 2` off
371    /// index 0, which upstream treats as "no match".
372    #[test]
373    fn simple_draft_predicts_a_repeat() {
374        let history = toks(&[9, 1, 2, 3, 4, 5, 1]);
375        let draft = ngram_simple_draft(2, 2, &history, LlamaToken(2)).unwrap();
376        assert_eq!(
377            draft,
378            toks(&[3, 4]),
379            "expected the continuation of the earlier `1 2`"
380        );
381    }
382
383    /// Passing a history that already ends in `sampled` searches for
384    /// `[sampled, sampled]`, which is the mistake the doc warns about.
385    #[test]
386    fn simple_draft_with_sampled_already_in_history_finds_nothing() {
387        let history = toks(&[9, 1, 2, 3, 4, 5, 1, 2]);
388        let draft = ngram_simple_draft(2, 2, &history, LlamaToken(2)).unwrap();
389        assert!(draft.is_empty(), "got {draft:?}");
390    }
391
392    /// Upstream needs more than `size_ngram + size_mgram + 1` tokens before it
393    /// will look at all.
394    #[test]
395    fn simple_draft_is_empty_below_the_length_floor() {
396        let history = toks(&[1, 2, 1, 2, 1]);
397        assert!(ngram_simple_draft(2, 2, &history, LlamaToken(2))
398            .unwrap()
399            .is_empty());
400    }
401
402    /// Non-repeating history must draft nothing rather than guess — a wrong
403    /// draft costs a verification pass.
404    #[test]
405    fn simple_draft_is_empty_without_a_repeat() {
406        let history = toks(&[1, 2, 3, 4, 5]);
407        let draft = ngram_simple_draft(2, 2, &history, LlamaToken(5)).unwrap();
408        assert!(draft.is_empty(), "expected no draft, got {draft:?}");
409    }
410
411    #[test]
412    fn simple_draft_handles_empty_history() {
413        assert!(ngram_simple_draft(2, 2, &[], LlamaToken(1))
414            .unwrap()
415            .is_empty());
416    }
417
418    #[test]
419    fn cache_starts_empty_and_learns() {
420        let mut cache = NgramCache::new();
421        assert!(cache.is_empty());
422
423        let tokens = toks(&[1, 2, 3, 1, 2, 3, 1, 2, 3]);
424        cache
425            .update(1, 4, &tokens, i32::try_from(tokens.len()).unwrap(), false)
426            .unwrap();
427        assert!(!cache.is_empty(), "update recorded nothing");
428    }
429
430    /// A cache must survive a save/load round trip, since the point of it is
431    /// being reusable across sessions.
432    #[test]
433    fn cache_round_trips_through_disk() {
434        let dir = std::env::temp_dir();
435        let path = dir.join("llama_cpp_rs_ngram_test.bin");
436        let path_str = path.to_str().unwrap();
437
438        let mut cache = NgramCache::new();
439        let tokens = toks(&[7, 8, 9, 7, 8, 9, 7, 8, 9]);
440        cache
441            .update(1, 4, &tokens, i32::try_from(tokens.len()).unwrap(), false)
442            .unwrap();
443        let saved_len = cache.len();
444        cache.save(path_str).unwrap();
445
446        let loaded = NgramCache::load(path_str).unwrap();
447        assert_eq!(loaded.len(), saved_len, "cache changed size across disk");
448
449        let _ = std::fs::remove_file(&path);
450    }
451
452    #[test]
453    fn cache_load_rejects_a_missing_file() {
454        assert!(NgramCache::load("/definitely/not/a/cache.bin").is_err());
455    }
456
457    #[test]
458    fn cache_rejects_interior_nul_in_path() {
459        let mut cache = NgramCache::new();
460        assert!(matches!(cache.save("a\0b"), Err(NgramError::Nul(_))));
461        assert!(matches!(NgramCache::load("a\0b"), Err(NgramError::Nul(_))));
462    }
463
464    /// Merging must be additive, not replacing — two caches over the same text
465    /// should not shrink the result.
466    #[test]
467    fn cache_merge_is_additive() {
468        let tokens = toks(&[4, 5, 6, 4, 5, 6, 4, 5, 6]);
469        let mut a = NgramCache::new();
470        a.update(1, 4, &tokens, i32::try_from(tokens.len()).unwrap(), false)
471            .unwrap();
472        let before = a.len();
473
474        let mut b = NgramCache::new();
475        b.update(1, 4, &tokens, i32::try_from(tokens.len()).unwrap(), false)
476            .unwrap();
477
478        a.merge(&mut b).unwrap();
479        assert!(a.len() >= before, "merge lost entries");
480    }
481
482    #[test]
483    fn cache_draft_requires_tokens() {
484        assert!(matches!(
485            ngram_cache_draft(&[], 4, 1, 4, None, None, None),
486            Err(NgramError::InvalidArg)
487        ));
488    }
489
490    /// With no caches supplied there is nothing to draft from; it must return
491    /// empty rather than dereference a null cache.
492    #[test]
493    fn cache_draft_with_no_caches_is_empty() {
494        let tokens = toks(&[1, 2, 3]);
495        let draft = ngram_cache_draft(&tokens, 4, 1, 4, None, None, None).unwrap();
496        assert!(draft.is_empty(), "got {draft:?}");
497    }
498
499    #[test]
500    fn cache_draft_predicts_a_learned_repeat() {
501        let tokens = toks(&[1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2]);
502        let mut cache = NgramCache::new();
503        cache
504            .update(1, 4, &tokens, i32::try_from(tokens.len()).unwrap(), false)
505            .unwrap();
506
507        let draft = ngram_cache_draft(&tokens, 4, 1, 4, Some(&mut cache), None, None).unwrap();
508        assert!(
509            draft.contains(&LlamaToken(3)),
510            "expected 3 after 1,2; got {draft:?}"
511        );
512    }
513
514    #[test]
515    fn map_builds_and_drafts() {
516        let mut map = NgramMap::new(2, 2, false, 1).expect("map");
517        let tokens = toks(&[1, 2, 3, 4, 1, 2]);
518        map.begin(&tokens).unwrap();
519        // Whether it drafts depends on its heuristics; the contract under test
520        // is that the call succeeds and reports acceptance without panicking.
521        let _ = map.draft(&tokens, LlamaToken(2)).unwrap();
522        map.accept(0);
523    }
524
525    #[test]
526    fn map_begin_accepts_an_empty_prompt() {
527        let mut map = NgramMap::new(2, 2, false, 1).expect("map");
528        map.begin(&[]).unwrap();
529    }
530}