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/// 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}