Skip to main content

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}