Skip to main content

forge_dictate/normalize/
lookup.rs

1//! Prompt-lookup speculative decoding.
2//!
3//! A normalized transcript is nearly its input, so the input is already a
4//! good draft of the output. Guess the next `K` tokens by finding where the
5//! text just emitted last appeared in the input, validate the whole guess
6//! in one batched decode, keep the longest prefix the model agrees with,
7//! and drop the KV entries behind the rest.
8//!
9//! Reading tokens is near-free next to generating them, so checking `K`
10//! guesses costs about what generating one does and every correct guess is
11//! free. Acceptance FALLS as `K` rises while wall-clock keeps improving,
12//! because the longer accepted runs outweigh the wasted drafts: tune this
13//! on wall-clock, never on the acceptance rate.
14
15use llama_cpp_2::model::LlamaModel;
16use llama_cpp_2::sampling::LlamaSampler;
17use llama_cpp_2::token::LlamaToken;
18
19use super::{NormalizeError, Session, decode_piece};
20
21/// Match width. Two beats one decisively; three gains nothing.
22pub const NGRAM: usize = 2;
23
24/// Draft length, chosen on wall-clock. Acceptance FALLS as this rises while
25/// wall-clock keeps improving, so it is not tuned on the acceptance rate.
26pub const K: usize = 64;
27
28/// The tokens following the most recent occurrence of `generated`'s last
29/// `ngram` tokens within `source`, capped at `k`.
30///
31/// Most recent rather than first: the nearest match is the better predictor
32/// when a phrase repeats.
33pub fn draft<'a>(
34    source: &'a [LlamaToken],
35    generated: &[LlamaToken],
36    ngram: usize,
37    k: usize,
38) -> &'a [LlamaToken] {
39    if ngram == 0 || generated.len() < ngram || source.len() <= ngram {
40        return &[];
41    }
42    let tail = &generated[generated.len() - ngram..];
43    for start in (0..=source.len() - ngram).rev() {
44        if &source[start..start + ngram] == tail {
45            let from = start + ngram;
46            let to = (from + k).min(source.len());
47            if from < to {
48                return &source[from..to];
49            }
50        }
51    }
52    &[]
53}
54
55/// Greedy generation, speculating from `source`.
56///
57/// `ctx` must already hold the decoded prompt, and `start` is the position
58/// the next token occupies.
59pub fn generate(
60    model: &LlamaModel,
61    session: &mut Session<'_>,
62    source: &[LlamaToken],
63    ngram: usize,
64    k: usize,
65) -> Result<String, NormalizeError> {
66    let Session { ctx, batch, start, budget } = session;
67    let (start, budget) = (*start, *budget);
68    let mut sampler = LlamaSampler::greedy();
69    let vocab = model.vocab();
70    let mut decoder = encoding_rs::UTF_8.new_decoder();
71    let mut out = String::new();
72    let mut emitted: Vec<LlamaToken> = Vec::new();
73    let mut pos = start;
74    let mut current = sampler.sample(ctx, batch.n_tokens() - 1);
75
76    while emitted.len() < budget {
77        if vocab.is_eog(current) {
78            break;
79        }
80        sampler.accept(current);
81        out.push_str(&decode_piece(&mut decoder, &vocab.token_to_piece(current, false, None)));
82        emitted.push(current);
83
84        let guess = draft(source, &emitted, ngram, k);
85
86        // The confirmed token plus the entire draft in one decode. Every
87        // position asks for logits, so one pass validates every guess.
88        batch.clear();
89        batch.add(current, pos, &[0], true)?;
90        for (i, token) in guess.iter().enumerate() {
91            let offset = i32::try_from(i).unwrap_or(i32::MAX);
92            batch.add(*token, pos + 1 + offset, &[0], true)?;
93        }
94        ctx.decode(batch)?;
95
96        // Sampling at index i gives the token following position pos + i.
97        let mut taken = 0usize;
98        let mut next = sampler.sample(ctx, 0);
99        // Tokenizing parses special tokens, so a transcript containing a
100        // literal end-of-turn marker puts a real EOG token in the draft.
101        // Accepting one decodes to no bytes and keeps the loop running, so
102        // whatever the model emits next lands in the output.
103        while taken < guess.len()
104            && next == guess[taken]
105            && !vocab.is_eog(next)
106            && emitted.len() < budget
107        {
108            sampler.accept(next);
109            out.push_str(&decode_piece(&mut decoder, &vocab.token_to_piece(next, false, None)));
110            emitted.push(next);
111            taken += 1;
112            next = sampler.sample(ctx, i32::try_from(taken).unwrap_or(i32::MAX));
113        }
114
115        // Positions from pos+1+taken onwards hold drafts the model refused.
116        // Leaving them attends over tokens that were never emitted, which
117        // is the one error that makes this diverge from plain greedy.
118        if taken < guess.len() {
119            let first_stale = u32::try_from(pos + 1)
120                .unwrap_or(u32::MAX)
121                .saturating_add(u32::try_from(taken).unwrap_or(u32::MAX));
122            ctx.kv_cache_seq_rm(0, Some(first_stale), None)?;
123        }
124
125        pos += 1 + i32::try_from(taken).unwrap_or(i32::MAX);
126        current = next;
127    }
128
129    // Stopping here rather than on an end-of-generation token means the text
130    // is cut off mid-sentence, and nothing downstream can tell.
131    if emitted.len() >= budget {
132        tracing::debug!(
133            event_name = "normalization_token_budget_reached",
134            budget,
135            "normalization hit its token ceiling; output is truncated"
136        );
137    }
138
139    Ok(out)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    fn tokens(ids: &[i32]) -> Vec<LlamaToken> {
147        ids.iter().copied().map(LlamaToken).collect()
148    }
149
150    /// A forward search would return the continuation of the FIRST match,
151    /// which is the wrong one and the cheaper thing to write by accident.
152    #[test]
153    fn drafts_from_the_most_recent_match() {
154        let source = tokens(&[1, 2, 99, 1, 2, 42, 43]);
155        let generated = tokens(&[7, 1, 2]);
156        assert_eq!(
157            draft(&source, &generated, 2, 4),
158            tokens(&[42, 43]).as_slice(),
159            "draft did not continue from the last occurrence of the tail"
160        );
161    }
162
163    #[test]
164    fn draft_is_capped_at_k() {
165        let source = tokens(&[1, 2, 3, 4, 5, 6]);
166        let generated = tokens(&[1, 2]);
167        assert_eq!(
168            draft(&source, &generated, 2, 2),
169            tokens(&[3, 4]).as_slice(),
170            "draft exceeded k tokens"
171        );
172    }
173
174    /// Nothing to speculate from is the common case on the first token, and
175    /// it has to be empty rather than a panic on an underflowed range.
176    #[test]
177    fn no_draft_without_a_match() {
178        let source = tokens(&[1, 2, 3]);
179        assert!(
180            draft(&source, &tokens(&[8, 9]), 2, 4).is_empty(),
181            "drafted from a tail that does not occur in the source"
182        );
183        assert!(
184            draft(&source, &tokens(&[1]), 2, 4).is_empty(),
185            "drafted from fewer generated tokens than the ngram width"
186        );
187    }
188
189    /// Both are documented as public ways to turn speculation off, so both
190    /// need to actually return nothing.
191    ///
192    /// `ngram = 0` does not fail loudly without its guard: the tail becomes
193    /// an empty slice, an empty slice matches at every position, and the
194    /// function then drafts the source's last token on every step forever.
195    /// That surfaces as wasted work and nonsense drafts, which reads as a
196    /// model-quality problem rather than an off-by-one.
197    #[test]
198    fn zero_turns_speculation_off() {
199        let source = tokens(&[1, 2, 3, 4, 5]);
200        let generated = tokens(&[1, 2]);
201        let by_ngram = draft(&source, &generated, 0, 4);
202        assert!(by_ngram.is_empty(), "ngram = 0 drafted {by_ngram:?}");
203        let by_k = draft(&source, &generated, 2, 0);
204        assert!(by_k.is_empty(), "k = 0 drafted {by_k:?}");
205    }
206}