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}