rlx_runtime/spec_decode.rs
1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Speculative decoding scheduling pattern (plan #34).
6//!
7//! Borrowed from MAX's serving scheduler structure
8//! (`one_shot_scheduler.py`, decode/prefill split). The classic
9//! Leviathan-et-al "Fast Inference from Transformers via
10//! Speculative Decoding" algorithm — a small draft model proposes
11//! `n` tokens; the larger target model verifies all `n` in one
12//! forward pass; tokens are accepted up to the first rejection,
13//! then one extra "corrected" token is sampled from the residual
14//! distribution.
15//!
16//! Expected speedup on decode-heavy workloads: 2-3×.
17//!
18//! Layout:
19//! - [`Speculator`] — trait an autoregressive model implements.
20//! Two methods: `propose` (draft) and `verify` (target).
21//! - [`DraftProposal`] / [`VerifyResult`] / [`AcceptDecision`]
22//! — wire-format data shapes.
23//! - [`speculative_accept`] — pure function that runs the
24//! acceptance algorithm. Testable without a real model.
25//! - [`SpecDecoder`] — orchestrator that calls a draft + target
26//! and returns the next batch of accepted tokens.
27
28use rlx_ir::Philox4x32;
29
30/// One round of draft proposals.
31#[derive(Debug, Clone)]
32pub struct DraftProposal {
33 /// `n` proposed tokens (draft sampled greedily or stochastically).
34 pub tokens: Vec<u32>,
35 /// `[n, vocab]` row-major — the draft's probability for each
36 /// token at that position. `probs[i][tokens[i]]` is the
37 /// probability the draft assigned to its own choice.
38 pub probs: Vec<Vec<f32>>,
39}
40
41/// Target model's verification of the draft's proposals.
42#[derive(Debug, Clone)]
43pub struct VerifyResult {
44 /// `[n, vocab]` row-major — target's probability at each
45 /// position, conditioned on the prefix and all preceding
46 /// draft tokens.
47 pub probs: Vec<Vec<f32>>,
48}
49
50/// Outcome of one speculative-decoding round.
51#[derive(Debug, Clone)]
52pub struct AcceptDecision {
53 /// Tokens accepted. Length is `0..=n`.
54 pub accepted: Vec<u32>,
55 /// One extra token sampled from the target's distribution
56 /// after rejection — `None` only when all `n` are accepted.
57 /// Either way the round produces `accepted.len() + 1` real
58 /// tokens (the +1 is `corrected` *or* a final target sample).
59 pub corrected: Option<u32>,
60}
61
62impl AcceptDecision {
63 /// Total real tokens this round produced.
64 pub fn total_tokens(&self) -> usize {
65 self.accepted.len() + if self.corrected.is_some() { 1 } else { 0 }
66 }
67}
68
69/// Streaming speculator interface — one method to draft, one to
70/// verify. Real implementations bind to a `CompiledGraph` per
71/// model; testable implementations can return canned probability
72/// tables.
73pub trait Speculator {
74 /// Propose `n` tokens given the current `context`. Returns the
75 /// proposed tokens + the draft's probability tables.
76 fn propose(&mut self, context: &[u32], n: usize) -> DraftProposal;
77
78 /// Verify a batch of `proposed` tokens in one forward pass:
79 /// for each position `i ∈ 0..n`, return the *target* model's
80 /// probability distribution conditioned on
81 /// `context ++ proposed[..i]`.
82 fn verify(&mut self, context: &[u32], proposed: &[u32]) -> VerifyResult;
83
84 /// Commit `accepted` tokens into persistent decode state after a
85 /// speculative round. Default no-op; MTP draft overrides so GDN
86 /// recurrent state only advances for accepted tokens.
87 fn commit(&mut self, context: &[u32], accepted: &[u32]) {
88 let _ = (context, accepted);
89 }
90}
91
92/// Pure speculative-acceptance algorithm. Given the draft's
93/// proposal and the target's verification, runs the
94/// per-position accept/reject test and returns the final
95/// decision. No model state, no I/O — easy to unit-test against
96/// hand-built distributions.
97///
98/// Algorithm (Leviathan et al. 2022, Algorithm 1):
99/// for i in 0..n:
100/// r ~ Uniform(0,1)
101/// if r < min(1, q_target(x_i) / p_draft(x_i)):
102/// accept x_i
103/// else:
104/// sample x' from norm(max(0, q - p))
105/// return (accepted[..i], Some(x'))
106/// return (all n accepted, None)
107pub fn speculative_accept(
108 proposal: &DraftProposal,
109 verify: &VerifyResult,
110 rng: &mut Philox4x32,
111) -> AcceptDecision {
112 assert_eq!(
113 proposal.tokens.len(),
114 proposal.probs.len(),
115 "DraftProposal: tokens and probs must agree"
116 );
117 assert_eq!(
118 proposal.probs.len(),
119 verify.probs.len(),
120 "DraftProposal and VerifyResult must propose the same n"
121 );
122 let n = proposal.tokens.len();
123 let mut accepted: Vec<u32> = Vec::with_capacity(n);
124 for i in 0..n {
125 let token = proposal.tokens[i];
126 let p = proposal.probs[i][token as usize].max(f32::MIN_POSITIVE);
127 let q = verify.probs[i][token as usize];
128 let accept_ratio = (q / p).min(1.0);
129 let r = rng.next_f32();
130 if r < accept_ratio {
131 accepted.push(token);
132 } else {
133 let corrected = sample_corrected_residual(&proposal.probs[i], &verify.probs[i], rng);
134 return AcceptDecision {
135 accepted,
136 corrected: Some(corrected),
137 };
138 }
139 }
140 AcceptDecision {
141 accepted,
142 corrected: None,
143 }
144}
145
146/// Sample from the *residual* distribution `norm(max(0, q - p))`.
147/// This is the "what the target prefers but the draft missed"
148/// distribution, used after a rejection so the round still emits
149/// a valid sample from the target.
150fn sample_corrected_residual(p: &[f32], q: &[f32], rng: &mut Philox4x32) -> u32 {
151 let mut adj: Vec<f32> = q.iter().zip(p).map(|(qi, pi)| (qi - pi).max(0.0)).collect();
152 let sum: f32 = adj.iter().sum();
153 if sum <= f32::MIN_POSITIVE {
154 // q ≤ p elementwise (extreme edge case): fall back to
155 // sampling from q directly.
156 return sample_from(q, rng);
157 }
158 let inv = 1.0 / sum;
159 for v in adj.iter_mut() {
160 *v *= inv;
161 }
162 sample_from(&adj, rng)
163}
164
165fn sample_from(probs: &[f32], rng: &mut Philox4x32) -> u32 {
166 let r = rng.next_f32();
167 let mut acc = 0f32;
168 for (i, &p) in probs.iter().enumerate() {
169 acc += p;
170 if r <= acc {
171 return i as u32;
172 }
173 }
174 (probs.len() - 1) as u32
175}
176
177/// A distribution supported on a small candidate set, `ids[j] ->
178/// probs[j]`. Everything outside `ids` has probability zero.
179///
180/// Block drafters (DFlash2's candidate selector, Medusa-style heads)
181/// only ever expose a top-k slate, and a target sampler with top-k /
182/// top-p applied is likewise sparse. Carrying `[n, vocab]` dense rows
183/// for those is ~150k floats per position of pure waste, so the
184/// sparse path exists alongside [`speculative_accept`] rather than
185/// forcing callers to densify.
186///
187/// Duplicate ids are summed on lookup, so a caller may push the same
188/// id twice without corrupting the mass.
189#[derive(Debug, Clone, Default)]
190pub struct SparseDist {
191 pub ids: Vec<u32>,
192 pub probs: Vec<f32>,
193}
194
195impl SparseDist {
196 /// Probability this distribution assigns to `id` (0.0 if absent).
197 pub fn prob_of(&self, id: u32) -> f32 {
198 self.ids
199 .iter()
200 .zip(&self.probs)
201 .filter(|&(&i, _)| i == id)
202 .map(|(_, &p)| p)
203 .sum()
204 }
205}
206
207/// Sparse speculative acceptance — maximal coupling against a target
208/// distribution that is itself only known on a candidate set.
209///
210/// Same accept/reject law as [`speculative_accept`]: keep `draft[i]`
211/// with probability `min(1, p_target / q_draft)`, otherwise stop and
212/// emit one token from the residual `norm(max(0, p - q))`. Restricting
213/// the residual to the target's candidate set is what keeps the
214/// distribution exact *for that sampler*: a token the target's top-k
215/// already excluded must not reappear through the residual.
216///
217/// **Contract differs from [`speculative_accept`] on purpose.**
218/// `target` carries `draft.len() + 1` rows — one per drafted position
219/// plus the bonus slot the target verified for free — and `corrected`
220/// is therefore always `Some`. A round always yields
221/// `accepted.len() + 1` real tokens, so a fully-rejected block still
222/// advances by one. The dense function leaves that bonus to the
223/// caller; here it would just force every caller to re-derive it.
224///
225/// # Panics
226/// If `dists.len() != draft.len()` or `target.len() != draft.len() + 1`.
227pub fn speculative_accept_sparse(
228 draft: &[u32],
229 dists: &[SparseDist],
230 target: &[SparseDist],
231 rng: &mut Philox4x32,
232) -> AcceptDecision {
233 assert_eq!(
234 draft.len(),
235 dists.len(),
236 "speculative_accept_sparse: one proposal distribution per drafted token"
237 );
238 assert_eq!(
239 target.len(),
240 draft.len() + 1,
241 "speculative_accept_sparse: target must cover every drafted position plus the bonus slot"
242 );
243
244 let mut accepted: Vec<u32> = Vec::with_capacity(draft.len());
245 for i in 0..draft.len() {
246 let token = draft[i];
247 let q = dists[i].prob_of(token);
248 let p = target[i].prob_of(token);
249
250 // `u * q <= p` is `u <= p/q` without dividing by a q that the
251 // drafter may have left at zero.
252 if q > 0.0 && rng.next_f32() * q <= p {
253 accepted.push(token);
254 continue;
255 }
256
257 let corrected = sample_residual_sparse(&dists[i], &target[i], rng);
258 return AcceptDecision {
259 accepted,
260 corrected: Some(corrected),
261 };
262 }
263
264 // Whole block accepted: the bonus slot is a plain target sample.
265 let bonus = &target[draft.len()];
266 let corrected = sample_sparse(bonus, rng);
267 AcceptDecision {
268 accepted,
269 corrected: Some(corrected),
270 }
271}
272
273/// Sample `norm(max(0, p_target - q_draft))` over the target's support.
274fn sample_residual_sparse(draft: &SparseDist, target: &SparseDist, rng: &mut Philox4x32) -> u32 {
275 let residual: Vec<f32> = target
276 .ids
277 .iter()
278 .zip(&target.probs)
279 .map(|(&id, &p)| (p - draft.prob_of(id)).max(0.0))
280 .collect();
281 let sum: f32 = residual.iter().sum();
282 if sum <= f32::MIN_POSITIVE {
283 // Target mass is fully covered by the draft (possible when the
284 // two slates coincide and rounding eats the difference): fall
285 // back to the target itself rather than emitting nothing.
286 return sample_sparse(target, rng);
287 }
288 let inv = 1.0 / sum;
289 let idx = sample_from(&residual.iter().map(|v| v * inv).collect::<Vec<_>>(), rng) as usize;
290 target.ids[idx]
291}
292
293fn sample_sparse(dist: &SparseDist, rng: &mut Philox4x32) -> u32 {
294 assert!(
295 !dist.ids.is_empty(),
296 "speculative_accept_sparse: empty target candidate set"
297 );
298 let sum: f32 = dist.probs.iter().sum();
299 let idx = if sum > f32::MIN_POSITIVE {
300 let inv = 1.0 / sum;
301 sample_from(&dist.probs.iter().map(|v| v * inv).collect::<Vec<_>>(), rng) as usize
302 } else {
303 0
304 };
305 dist.ids[idx]
306}
307
308/// Top-level orchestrator. Holds a draft + target speculator and
309/// the lookahead window `n`. `step()` runs one full round and
310/// returns the tokens to append to the running context.
311pub struct SpecDecoder<D: Speculator, T: Speculator> {
312 pub draft: D,
313 pub target: T,
314 pub n: usize,
315 rng: Philox4x32,
316}
317
318impl<D: Speculator, T: Speculator> SpecDecoder<D, T> {
319 pub fn new(draft: D, target: T, n: usize, seed: u64) -> Self {
320 Self {
321 draft,
322 target,
323 n,
324 rng: Philox4x32::new(seed),
325 }
326 }
327
328 /// One speculative-decoding round. Returns the tokens that
329 /// should be appended to `context`.
330 pub fn step(&mut self, context: &[u32]) -> Vec<u32> {
331 let proposal = self.draft.propose(context, self.n);
332 let verify = self.target.verify(context, &proposal.tokens);
333 let decision = speculative_accept(&proposal, &verify, &mut self.rng);
334 let mut out = decision.accepted;
335 if let Some(c) = decision.corrected {
336 out.push(c);
337 }
338 self.draft.commit(context, &out);
339 self.target.commit(context, &out);
340 out
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 /// When draft and target agree perfectly (same probs), every
349 /// proposed token must be accepted (accept_ratio = 1.0).
350 #[test]
351 fn identical_distributions_accept_all() {
352 let n = 4;
353 let vocab = 8;
354 // Draft proposed token = argmax of a peaked distribution.
355 // Target's distribution is identical → q/p = 1.0 → always
356 // accept.
357 let mut probs = Vec::with_capacity(n);
358 let mut tokens = Vec::with_capacity(n);
359 for i in 0..n {
360 let mut row = vec![0.01f32; vocab];
361 let pick = (i * 2) % vocab;
362 row[pick] = 1.0 - 0.01 * (vocab - 1) as f32;
363 probs.push(row);
364 tokens.push(pick as u32);
365 }
366 let proposal = DraftProposal {
367 tokens: tokens.clone(),
368 probs: probs.clone(),
369 };
370 let verify = VerifyResult { probs };
371
372 // 100 trials with different seeds; all should accept all 4.
373 for seed in 0..100u64 {
374 let mut rng = Philox4x32::new(seed + 1);
375 let d = speculative_accept(&proposal, &verify, &mut rng);
376 assert_eq!(d.accepted, tokens, "seed {seed}: should accept all");
377 assert!(d.corrected.is_none());
378 }
379 }
380
381 /// When the draft places mass on tokens the target rejects
382 /// (q ≪ p on those tokens), at least some rejections happen.
383 #[test]
384 fn divergent_distributions_reject_sometimes() {
385 let n = 4;
386 let _vocab = 4;
387 // Draft ALWAYS picks token 0; target wants token 3.
388 let draft_row = vec![0.97f32, 0.01, 0.01, 0.01];
389 let target_row = vec![0.01f32, 0.01, 0.01, 0.97];
390 let proposal = DraftProposal {
391 tokens: vec![0u32; n],
392 probs: vec![draft_row.clone(); n],
393 };
394 let verify = VerifyResult {
395 probs: vec![target_row.clone(); n],
396 };
397
398 let mut total_accepted = 0usize;
399 let trials = 200;
400 for seed in 0..trials {
401 let mut rng = Philox4x32::new(seed + 1);
402 let d = speculative_accept(&proposal, &verify, &mut rng);
403 total_accepted += d.accepted.len();
404 // After rejection, corrected must be present.
405 if d.accepted.len() < n {
406 assert!(
407 d.corrected.is_some(),
408 "rejection at seed {seed} should yield a corrected token"
409 );
410 // Corrected token should be drawn from
411 // norm(max(0, q-p)) which strongly favours token 3.
412 }
413 }
414 // q/p = 0.01/0.97 ≈ 0.0103 per token → expected acceptance
415 // length per round is geometric, mean ≈ 0.01. Across 200
416 // trials × 4 positions = 800 chances, accept rate ~1%.
417 assert!(
418 total_accepted < 80,
419 "divergent distributions should accept rarely; got {total_accepted}/800"
420 );
421 }
422
423 fn sparse(ids: &[u32], probs: &[f32]) -> SparseDist {
424 SparseDist {
425 ids: ids.to_vec(),
426 probs: probs.to_vec(),
427 }
428 }
429
430 /// Matching slates → every draft token survives, plus the bonus.
431 #[test]
432 fn sparse_identical_slates_accept_all_plus_bonus() {
433 let slate = sparse(&[7, 8, 9], &[0.8, 0.15, 0.05]);
434 let draft = vec![7u32, 7, 7];
435 let dists = vec![slate.clone(); 3];
436 let target = vec![slate.clone(); 4];
437
438 for seed in 0..100u64 {
439 let mut rng = Philox4x32::new(seed + 1);
440 let d = speculative_accept_sparse(&draft, &dists, &target, &mut rng);
441 assert_eq!(d.accepted, draft, "seed {seed}");
442 // Contract: always n+1 tokens, unlike the dense path.
443 assert_eq!(d.total_tokens(), 4);
444 assert!(target[3].ids.contains(&d.corrected.unwrap()));
445 }
446 }
447
448 /// A rejection may only emit tokens the TARGET still allows —
449 /// letting the residual reach outside the target's slate would
450 /// resurrect tokens its top-k already discarded.
451 #[test]
452 fn sparse_correction_stays_inside_target_support() {
453 // Draft is certain of 100; target never proposes it at all.
454 let draft = vec![100u32];
455 let dists = vec![sparse(&[100], &[1.0])];
456 let target = vec![sparse(&[1, 2], &[0.5, 0.5]), sparse(&[1, 2], &[0.5, 0.5])];
457
458 for seed in 0..200u64 {
459 let mut rng = Philox4x32::new(seed + 1);
460 let d = speculative_accept_sparse(&draft, &dists, &target, &mut rng);
461 // p_target(100) = 0 → the accept test can never pass.
462 assert!(d.accepted.is_empty(), "seed {seed}: 100 must be rejected");
463 let c = d.corrected.unwrap();
464 assert!(c == 1 || c == 2, "seed {seed}: leaked token {c}");
465 }
466 }
467
468 /// The property that makes speculation *free*: the first emitted
469 /// token is distributed exactly as the target would have sampled
470 /// it, however wrong the drafter is.
471 #[test]
472 fn sparse_first_token_matches_target_distribution() {
473 let target_row = sparse(&[10, 11, 12], &[0.6, 0.3, 0.1]);
474 // Drafter is badly miscalibrated and always proposes 12.
475 let draft = vec![12u32];
476 let dists = vec![sparse(&[10, 11, 12], &[0.05, 0.05, 0.9])];
477 let target = vec![target_row.clone(), target_row.clone()];
478
479 let trials = 40_000;
480 let mut counts = [0usize; 3];
481 for seed in 0..trials {
482 let mut rng = Philox4x32::new(seed as u64 + 1);
483 let d = speculative_accept_sparse(&draft, &dists, &target, &mut rng);
484 let first = *d.accepted.first().unwrap_or(&d.corrected.unwrap());
485 counts[(first - 10) as usize] += 1;
486 }
487 for (i, &want) in target_row.probs.iter().enumerate() {
488 let got = counts[i] as f32 / trials as f32;
489 assert!(
490 (got - want).abs() < 0.02,
491 "token {}: target p={want}, sampled {got} over {trials} trials",
492 10 + i
493 );
494 }
495 }
496
497 /// Mock speculators for end-to-end SpecDecoder basic test.
498 /// Both return canned probability tables.
499 struct CannedSpeculator {
500 next_token: u32,
501 peaked_prob: f32,
502 }
503
504 impl Speculator for CannedSpeculator {
505 fn propose(&mut self, _ctx: &[u32], n: usize) -> DraftProposal {
506 let vocab = 8;
507 let mut probs = Vec::with_capacity(n);
508 for _ in 0..n {
509 let mut row = vec![(1.0 - self.peaked_prob) / (vocab - 1) as f32; vocab];
510 row[self.next_token as usize] = self.peaked_prob;
511 probs.push(row);
512 }
513 DraftProposal {
514 tokens: vec![self.next_token; n],
515 probs,
516 }
517 }
518 fn verify(&mut self, _ctx: &[u32], proposed: &[u32]) -> VerifyResult {
519 // Canned target: identical distribution to its own
520 // "next_token" choice.
521 let n = proposed.len();
522 let vocab = 8;
523 let mut probs = Vec::with_capacity(n);
524 for _ in 0..n {
525 let mut row = vec![(1.0 - self.peaked_prob) / (vocab - 1) as f32; vocab];
526 row[self.next_token as usize] = self.peaked_prob;
527 probs.push(row);
528 }
529 VerifyResult { probs }
530 }
531 }
532
533 #[test]
534 fn spec_decoder_step_emits_n_plus_1_tokens_when_aligned() {
535 let draft = CannedSpeculator {
536 next_token: 5,
537 peaked_prob: 0.95,
538 };
539 let target = CannedSpeculator {
540 next_token: 5,
541 peaked_prob: 0.95,
542 };
543 let mut dec = SpecDecoder::new(draft, target, 4, 1);
544 let context = vec![0u32, 1, 2];
545 let out = dec.step(&context);
546 // Aligned distributions → all 4 accepted, no corrected; total = 4.
547 assert_eq!(
548 out.len(),
549 4,
550 "aligned step should emit n tokens (no rejection)"
551 );
552 assert!(out.iter().all(|&t| t == 5));
553 }
554}