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