Skip to main content

ferrox_models/
speculative.rs

1//! Prompt-lookup speculative decoding: propose several candidate next
2//! tokens by finding a repeat of the current context elsewhere in the
3//! token history (no separate draft model needed, unlike classic
4//! speculative decoding), then verify all candidates in a single
5//! `Decoder::forward_batch` call instead of one `forward_token` call
6//! per candidate.
7//!
8//! This is the CPU-only, no-draft-model variant of speculative
9//! decoding (the same idea as vLLM's "prompt lookup decoding"), chosen
10//! specifically because it needs no GPU and no second model to be
11//! useful -- unlike tree-based speculative decoding with a real draft
12//! model, which needs real hardware to actually pay off.
13//!
14//! # Quality-neutrality is the property that matters most here
15//!
16//! Speculative decoding is only worth having if it produces *exactly*
17//! the same output as plain greedy decode, just potentially faster.
18//! `speculative_decode`'s accept/reject protocol is designed so that
19//! every accepted token is one `forward_batch` would have produced
20//! anyway on its own path: a candidate is only kept if the model's own
21//! argmax at that position agrees with it. This is checked directly by
22//! `speculative_decode_matches_greedy_token_for_token`, which runs both
23//! this module's decode and a plain sequential `forward_token` loop
24//! against the same decoder and asserts the exact same token sequence
25//! comes out either way.
26
27use crate::decoder::Decoder;
28use ferrox_core::cache::KvCache;
29
30/// Proposes candidate continuation tokens by looking for the longest
31/// available match of the most recent `ngram_size` tokens earlier in
32/// `history`, and returning up to `max_draft_len` tokens that followed
33/// that earlier occurrence. Returns an empty vector if no match is
34/// found or `history` is too short to contain one.
35///
36/// This is deliberately simple (last-match-wins, not best-match or a
37/// frequency-weighted choice): the whole point of prompt-lookup
38/// decoding is that it's nearly free to compute, since a wrong guess
39/// costs nothing but a rejected batch position, not a correctness bug.
40#[derive(Debug, Clone, Copy)]
41pub struct PromptLookupSpeculator {
42    pub ngram_size: usize,
43    pub max_draft_len: usize,
44}
45
46impl PromptLookupSpeculator {
47    pub fn new(ngram_size: usize, max_draft_len: usize) -> Self {
48        assert!(ngram_size >= 1, "ngram_size must be at least 1");
49        assert!(max_draft_len >= 1, "max_draft_len must be at least 1");
50        PromptLookupSpeculator {
51            ngram_size,
52            max_draft_len,
53        }
54    }
55
56    /// Looks for the most recent earlier occurrence of `history`'s
57    /// last `ngram_size` tokens, scanning from the end backwards so
58    /// the *most recent* match wins (most likely to reflect current
59    /// context, e.g. a loop the model is currently in). Returns the
60    /// tokens that followed that occurrence, truncated to
61    /// `max_draft_len`.
62    pub fn propose(&self, history: &[usize]) -> Vec<usize> {
63        if history.len() < self.ngram_size + 1 {
64            return Vec::new();
65        }
66        let needle = &history[history.len() - self.ngram_size..];
67
68        // Search every earlier start position, latest first. The last
69        // possible start that still leaves room for the needle without
70        // overlapping into the needle itself is history.len() -
71        // ngram_size - 1 (exclusive of the needle's own occurrence).
72        let last_possible_start = history.len() - self.ngram_size - 1;
73        for start in (0..=last_possible_start).rev() {
74            if &history[start..start + self.ngram_size] == needle {
75                let continuation_start = start + self.ngram_size;
76                let available = history.len() - continuation_start;
77                let take = available.min(self.max_draft_len);
78                return history[continuation_start..continuation_start + take].to_vec();
79            }
80        }
81        Vec::new()
82    }
83}
84
85/// Result of a speculative decode run, with the counters that make its
86/// actual savings observable rather than just assumed.
87#[derive(Debug, Clone)]
88pub struct SpeculativeDecodeResult {
89    pub generated_tokens: Vec<usize>,
90    /// Number of `Decoder::forward_batch` calls made (prefill counts as
91    /// one call, each subsequent accept/reject round counts as one
92    /// more, regardless of how many tokens that round produced).
93    pub forward_calls: usize,
94    /// Total tokens produced across all rounds -- always equal to
95    /// `generated_tokens.len()`, kept as a separate field so the ratio
96    /// `tokens_generated / forward_calls` (the actual speedup metric)
97    /// is easy to read directly off this struct.
98    pub tokens_generated: usize,
99}
100
101impl SpeculativeDecodeResult {
102    /// Average tokens produced per `forward_batch` call. 1.0 means
103    /// speculation never helped (every round produced exactly the
104    /// anchor token); higher means draft tokens were accepted.
105    pub fn tokens_per_call(&self) -> f64 {
106        if self.forward_calls == 0 {
107            0.0
108        } else {
109            self.tokens_generated as f64 / self.forward_calls as f64
110        }
111    }
112}
113
114/// Runs greedy decoding for `max_new_tokens` steps, using
115/// `speculator` to propose candidate continuations and verifying them
116/// in batches. `prompt_tokens` is processed as a single prefill batch
117/// (one `forward_batch` call for the whole prompt, not one per
118/// prompt token -- itself a real saving independent of speculation).
119///
120/// `kv_caches` must be freshly initialized (empty) for this call;
121/// continuing an already-populated cache across multiple calls isn't
122/// supported by this function today.
123pub fn speculative_decode(
124    decoder: &Decoder,
125    prompt_tokens: &[usize],
126    max_new_tokens: usize,
127    kv_caches: &mut [KvCache],
128    speculator: &PromptLookupSpeculator,
129) -> SpeculativeDecodeResult {
130    assert!(!prompt_tokens.is_empty(), "prompt must not be empty");
131
132    let mut history: Vec<usize> = prompt_tokens.to_vec();
133    let mut generated = Vec::with_capacity(max_new_tokens);
134    let mut forward_calls = 0usize;
135
136    // Prefill: one batched call over the whole prompt instead of one
137    // forward_token call per prompt token.
138    let prefill_logits = decoder.forward_batch(prompt_tokens, 0, kv_caches);
139    forward_calls += 1;
140    let mut pending_logits = prefill_logits
141        .last()
142        .expect("prompt_tokens is non-empty, so forward_batch returns at least one logits vector")
143        .clone();
144    let mut pos = prompt_tokens.len();
145
146    while generated.len() < max_new_tokens {
147        let real_tok = argmax(&pending_logits);
148
149        let remaining_budget = max_new_tokens - generated.len() - 1; // -1 for real_tok itself
150        let mut guesses = speculator.propose(&history);
151        guesses.truncate(remaining_budget);
152
153        if guesses.is_empty() {
154            // No draft: verify just the anchor token, batch size 1.
155            let logits = decoder.forward_batch(&[real_tok], pos, kv_caches);
156            forward_calls += 1;
157            generated.push(real_tok);
158            history.push(real_tok);
159            pending_logits = logits.into_iter().next().unwrap();
160            pos += 1;
161            continue;
162        }
163
164        let mut batch = Vec::with_capacity(1 + guesses.len());
165        batch.push(real_tok);
166        batch.extend_from_slice(&guesses);
167
168        let batch_logits = decoder.forward_batch(&batch, pos, kv_caches);
169        forward_calls += 1;
170
171        // accepted_count = length of the longest prefix of `guesses`
172        // whose predicted-by-the-real-path argmax matches.
173        let mut accepted_count = 0usize;
174        for (i, &guess) in guesses.iter().enumerate() {
175            if argmax(&batch_logits[i]) == guess {
176                accepted_count += 1;
177            } else {
178                break;
179            }
180        }
181
182        if accepted_count < guesses.len() {
183            // Some guess was wrong: roll the cache back to keep only
184            // the anchor token plus the accepted guesses.
185            let committed_len = pos + 1 + accepted_count;
186            for cache in kv_caches.iter_mut() {
187                cache.truncate(committed_len);
188            }
189        }
190
191        generated.push(real_tok);
192        generated.extend_from_slice(&guesses[..accepted_count]);
193        history.push(real_tok);
194        history.extend_from_slice(&guesses[..accepted_count]);
195
196        // batch_logits[accepted_count] was computed from a token that
197        // is definitely correct (either the anchor itself, if
198        // accepted_count == 0, or the last accepted guess), so it
199        // correctly predicts the next not-yet-filled position
200        // regardless of whether every guess was accepted.
201        pending_logits = batch_logits[accepted_count].clone();
202        pos += 1 + accepted_count;
203    }
204
205    generated.truncate(max_new_tokens);
206    SpeculativeDecodeResult {
207        tokens_generated: generated.len(),
208        generated_tokens: generated,
209        forward_calls,
210    }
211}
212
213fn argmax(logits: &[f32]) -> usize {
214    logits
215        .iter()
216        .enumerate()
217        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
218        .map(|(i, _)| i)
219        .unwrap_or(0)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::config::glm_5_2;
226    use crate::ModelConfig;
227
228    fn tiny_test_config() -> ModelConfig {
229        let mut cfg = glm_5_2();
230        cfg.hidden_dim = 16;
231        cfg.n_heads = 4;
232        cfg.n_kv_heads = 2;
233        cfg.head_dim = 4;
234        cfg.moe.hidden_dim = 16;
235        cfg.moe.n_experts = 6;
236        cfg.moe.n_experts_active = 2;
237        cfg.moe.n_shared_experts = 1;
238        cfg.moe.expert_ffn_dim = 8;
239        cfg
240    }
241
242    // ---- PromptLookupSpeculator tests ----
243
244    #[test]
245    fn proposes_the_continuation_after_a_real_repeat() {
246        let spec = PromptLookupSpeculator::new(2, 4);
247        // "...1 2 3 4 5 9 9 9 1 2" -> earlier "1 2" occurs at the very
248        // start (indices 0-1); the 4 tokens that followed it are
249        // "3 4 5 9" (capped at max_draft_len=4).
250        let history = vec![1, 2, 3, 4, 5, 9, 9, 9, 1, 2];
251        assert_eq!(spec.propose(&history), vec![3, 4, 5, 9]);
252    }
253
254    #[test]
255    fn respects_max_draft_len() {
256        let spec = PromptLookupSpeculator::new(2, 2);
257        let history = vec![1, 2, 3, 4, 5, 6, 7, 1, 2];
258        assert_eq!(spec.propose(&history), vec![3, 4]);
259    }
260
261    #[test]
262    fn returns_empty_when_no_earlier_match_exists() {
263        let spec = PromptLookupSpeculator::new(2, 4);
264        let history = vec![1, 2, 3, 4, 5];
265        assert_eq!(spec.propose(&history), Vec::<usize>::new());
266    }
267
268    #[test]
269    fn returns_empty_when_history_too_short() {
270        let spec = PromptLookupSpeculator::new(3, 4);
271        let history = vec![1, 2, 3];
272        assert_eq!(spec.propose(&history), Vec::<usize>::new());
273    }
274
275    #[test]
276    fn finds_the_most_recent_match_when_several_exist() {
277        let spec = PromptLookupSpeculator::new(1, 3);
278        // needle = [9]. Earlier occurrences at index 0 (-> [8,7,6]) and
279        // index 4 (-> [5,4,9]); most recent (index 4) should win.
280        let history = vec![9, 8, 7, 6, 9, 5, 4, 9];
281        assert_eq!(spec.propose(&history), vec![5, 4, 9]);
282    }
283
284    // ---- speculative_decode correctness tests ----
285
286    #[test]
287    fn speculative_decode_matches_greedy_token_for_token() {
288        // The property that matters most: speculative decoding must be
289        // quality-neutral. Build a prompt with a real repeated pattern
290        // so the speculator actually proposes something non-trivial,
291        // then check token-for-token identity against plain greedy
292        // forward_token decoding on a separately constructed but
293        // identically-seeded decoder.
294        let cfg = tiny_test_config();
295        let vocab = 8;
296        let prompt = vec![1usize, 2, 3, 4, 1, 2];
297        let max_new = 6;
298
299        let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
300        let mut caches_a: Vec<KvCache> = (0..2)
301            .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
302            .collect();
303        let speculator = PromptLookupSpeculator::new(2, 3);
304        let result = speculative_decode(&decoder_a, &prompt, max_new, &mut caches_a, &speculator);
305
306        let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
307        let mut caches_b: Vec<KvCache> = (0..2)
308            .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
309            .collect();
310        let mut pending = decoder_b
311            .forward_batch(&prompt, 0, &mut caches_b)
312            .pop()
313            .unwrap();
314        let mut greedy = Vec::with_capacity(max_new);
315        for pos in (prompt.len()..).take(max_new) {
316            let tok = argmax(&pending);
317            greedy.push(tok);
318            pending = decoder_b.forward_token(tok, pos, &mut caches_b);
319        }
320
321        assert_eq!(
322            result.generated_tokens, greedy,
323            "speculative decode must produce exactly the same tokens as plain greedy decode"
324        );
325    }
326
327    #[test]
328    fn speculative_decode_saves_real_calls_when_drafts_hit() {
329        // Craft a scenario where the FIRST round's draft is guaranteed
330        // to be checked against a real repeat (prompt = "A B A B",
331        // ngram_size=2 will find "A B" repeating and propose whatever
332        // came before, if anything did). This test doesn't assert the
333        // draft is *accepted* (that depends on the random weights'
334        // actual predictions, which this test doesn't control) --
335        // it asserts the WEAKER but still meaningful property that
336        // forward_calls is never more than max_new_tokens (i.e.
337        // speculation is never worse than plain sequential decode) and
338        // reports tokens_per_call for visibility.
339        let cfg = tiny_test_config();
340        let vocab = 8;
341        let prompt = vec![1usize, 2, 3, 1, 2];
342        let max_new = 8;
343
344        let decoder = Decoder::new_random_small(cfg, 2, vocab);
345        let mut caches: Vec<KvCache> = (0..2)
346            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
347            .collect();
348        let speculator = PromptLookupSpeculator::new(2, 4);
349        let result = speculative_decode(&decoder, &prompt, max_new, &mut caches, &speculator);
350
351        assert_eq!(result.tokens_generated, max_new);
352        assert!(
353            result.forward_calls <= max_new,
354            "speculative decode must never need MORE forward_batch calls than plain sequential decode would (calls={}, tokens={})",
355            result.forward_calls,
356            max_new
357        );
358    }
359
360    #[test]
361    fn speculative_decode_with_no_repeats_falls_back_to_one_token_per_call() {
362        // A prompt with no internal repeats at all (each token
363        // distinct, ngram_size=3 means nothing can ever match) must
364        // still work correctly, just without any speedup: forward_calls
365        // should equal tokens_generated exactly (batch size 1 every
366        // round), matching plain sequential decode's call count.
367        let cfg = tiny_test_config();
368        let vocab = 8;
369        let prompt = vec![1usize, 2, 3];
370        let max_new = 5;
371
372        let decoder = Decoder::new_random_small(cfg, 2, vocab);
373        let mut caches: Vec<KvCache> = (0..2)
374            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
375            .collect();
376        let speculator = PromptLookupSpeculator::new(10, 4); // ngram far longer than any possible history
377        let result = speculative_decode(&decoder, &prompt, max_new, &mut caches, &speculator);
378
379        assert_eq!(result.tokens_generated, max_new);
380        assert_eq!(
381            result.forward_calls,
382            1 + max_new,
383            "prefill (1 call) + one call per token when nothing ever matches"
384        );
385    }
386}