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/// Top-level orchestrator. Holds a draft + target speculator and
178/// the lookahead window `n`. `step()` runs one full round and
179/// returns the tokens to append to the running context.
180pub struct SpecDecoder<D: Speculator, T: Speculator> {
181 pub draft: D,
182 pub target: T,
183 pub n: usize,
184 rng: Philox4x32,
185}
186
187impl<D: Speculator, T: Speculator> SpecDecoder<D, T> {
188 pub fn new(draft: D, target: T, n: usize, seed: u64) -> Self {
189 Self {
190 draft,
191 target,
192 n,
193 rng: Philox4x32::new(seed),
194 }
195 }
196
197 /// One speculative-decoding round. Returns the tokens that
198 /// should be appended to `context`.
199 pub fn step(&mut self, context: &[u32]) -> Vec<u32> {
200 let proposal = self.draft.propose(context, self.n);
201 let verify = self.target.verify(context, &proposal.tokens);
202 let decision = speculative_accept(&proposal, &verify, &mut self.rng);
203 let mut out = decision.accepted;
204 if let Some(c) = decision.corrected {
205 out.push(c);
206 }
207 self.draft.commit(context, &out);
208 self.target.commit(context, &out);
209 out
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 /// When draft and target agree perfectly (same probs), every
218 /// proposed token must be accepted (accept_ratio = 1.0).
219 #[test]
220 fn identical_distributions_accept_all() {
221 let n = 4;
222 let vocab = 8;
223 // Draft proposed token = argmax of a peaked distribution.
224 // Target's distribution is identical → q/p = 1.0 → always
225 // accept.
226 let mut probs = Vec::with_capacity(n);
227 let mut tokens = Vec::with_capacity(n);
228 for i in 0..n {
229 let mut row = vec![0.01f32; vocab];
230 let pick = (i * 2) % vocab;
231 row[pick] = 1.0 - 0.01 * (vocab - 1) as f32;
232 probs.push(row);
233 tokens.push(pick as u32);
234 }
235 let proposal = DraftProposal {
236 tokens: tokens.clone(),
237 probs: probs.clone(),
238 };
239 let verify = VerifyResult { probs };
240
241 // 100 trials with different seeds; all should accept all 4.
242 for seed in 0..100u64 {
243 let mut rng = Philox4x32::new(seed + 1);
244 let d = speculative_accept(&proposal, &verify, &mut rng);
245 assert_eq!(d.accepted, tokens, "seed {seed}: should accept all");
246 assert!(d.corrected.is_none());
247 }
248 }
249
250 /// When the draft places mass on tokens the target rejects
251 /// (q ≪ p on those tokens), at least some rejections happen.
252 #[test]
253 fn divergent_distributions_reject_sometimes() {
254 let n = 4;
255 let _vocab = 4;
256 // Draft ALWAYS picks token 0; target wants token 3.
257 let draft_row = vec![0.97f32, 0.01, 0.01, 0.01];
258 let target_row = vec![0.01f32, 0.01, 0.01, 0.97];
259 let proposal = DraftProposal {
260 tokens: vec![0u32; n],
261 probs: vec![draft_row.clone(); n],
262 };
263 let verify = VerifyResult {
264 probs: vec![target_row.clone(); n],
265 };
266
267 let mut total_accepted = 0usize;
268 let trials = 200;
269 for seed in 0..trials {
270 let mut rng = Philox4x32::new(seed + 1);
271 let d = speculative_accept(&proposal, &verify, &mut rng);
272 total_accepted += d.accepted.len();
273 // After rejection, corrected must be present.
274 if d.accepted.len() < n {
275 assert!(
276 d.corrected.is_some(),
277 "rejection at seed {seed} should yield a corrected token"
278 );
279 // Corrected token should be drawn from
280 // norm(max(0, q-p)) which strongly favours token 3.
281 }
282 }
283 // q/p = 0.01/0.97 ≈ 0.0103 per token → expected acceptance
284 // length per round is geometric, mean ≈ 0.01. Across 200
285 // trials × 4 positions = 800 chances, accept rate ~1%.
286 assert!(
287 total_accepted < 80,
288 "divergent distributions should accept rarely; got {total_accepted}/800"
289 );
290 }
291
292 /// Mock speculators for end-to-end SpecDecoder basic test.
293 /// Both return canned probability tables.
294 struct CannedSpeculator {
295 next_token: u32,
296 peaked_prob: f32,
297 }
298
299 impl Speculator for CannedSpeculator {
300 fn propose(&mut self, _ctx: &[u32], n: usize) -> DraftProposal {
301 let vocab = 8;
302 let mut probs = Vec::with_capacity(n);
303 for _ in 0..n {
304 let mut row = vec![(1.0 - self.peaked_prob) / (vocab - 1) as f32; vocab];
305 row[self.next_token as usize] = self.peaked_prob;
306 probs.push(row);
307 }
308 DraftProposal {
309 tokens: vec![self.next_token; n],
310 probs,
311 }
312 }
313 fn verify(&mut self, _ctx: &[u32], proposed: &[u32]) -> VerifyResult {
314 // Canned target: identical distribution to its own
315 // "next_token" choice.
316 let n = proposed.len();
317 let vocab = 8;
318 let mut probs = Vec::with_capacity(n);
319 for _ in 0..n {
320 let mut row = vec![(1.0 - self.peaked_prob) / (vocab - 1) as f32; vocab];
321 row[self.next_token as usize] = self.peaked_prob;
322 probs.push(row);
323 }
324 VerifyResult { probs }
325 }
326 }
327
328 #[test]
329 fn spec_decoder_step_emits_n_plus_1_tokens_when_aligned() {
330 let draft = CannedSpeculator {
331 next_token: 5,
332 peaked_prob: 0.95,
333 };
334 let target = CannedSpeculator {
335 next_token: 5,
336 peaked_prob: 0.95,
337 };
338 let mut dec = SpecDecoder::new(draft, target, 4, 1);
339 let context = vec![0u32, 1, 2];
340 let out = dec.step(&context);
341 // Aligned distributions → all 4 accepted, no corrected; total = 4.
342 assert_eq!(
343 out.len(),
344 4,
345 "aligned step should emit n tokens (no rejection)"
346 );
347 assert!(out.iter().all(|&t| t == 5));
348 }
349}