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}