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