Skip to content

Speculative decoding in the engine

ModuleL10.8 · build · Rust · Pass 7 · 6 to 9 h
You buildrust/crates/tl-engine/src/spec.rs: prompt-lookup and n-gram drafts over the request’s own context, the verifier (greedy acceptance, and M07.6’s accept-or-resample at temperature > 0), KV rollback on rejection, and a generation loop over a Target
Contractthe Python specification you port: py/tinyllm/infer/spec.pyi (L8.6) · sampling order and draws: spec/sampling.md, spec/pcg32.md · configuration [engine].speculative: config/runtime.schema.json · the gauge tl.engine.spec_accept_rate: otel/metrics.yaml
Testscourse/tests/rust/l10_8.rs, 14 tests (what they check: section 4)
NeedsL8.6 your Python speculative decoding, the specification this port is held to (chapter) · L10.1 the sampler (apply_penalties, distribution, sample, stream) and KvPool (chapter) · L10.1 the Rust pool behind the rollback (chapter) · reading: L10.2, L10.4 the scheduler and block manager it plugs into, M07.6 rejection and residuals (chapter) · or --ref-deps
Used byL10.5’s runner-backed serving path ([engine].speculative); MS-L10 checks greedy output is unchanged with prompt lookup on
MilestoneMS-L10
Optional depthLeviathan et al., Fast Inference from Transformers via Speculative Decoding (free); Chen et al., Accelerating LLM Decoding with Speculative Sampling (free); Prompt Lookup Decoding (free)
  • Prompt lookup copies what followed the most recent earlier occurrence of the context’s suffix, trying long suffixes first; it costs no model and shines on repetitive text (hand_example_prompt_lookup, prompt_lookup_matches_python_l8_6).
  • Greedy verification keeps drafts while they equal the target’s argmax, so greedy output with speculation is token for token greedy output without it (hand_example_greedy_verify, greedy_spec_equals_greedy_without_spec).
  • With sampling, accept-or-resample keeps the target’s distribution exactly, whatever the draft proposes (hand_example_sampled_step, sampled_spec_keeps_the_target_distribution).
  • The port draws exactly what L8.6 draws, in the same order (u_accept, then u_resample, both always; one draw for the bonus token), so its tokens equal your Python’s on the same logits and seed (verify_matches_python_l8_6).
  • A rejected draft’s KV is given back: after every round the sequence holds exactly the blocks its cached positions need (rollback_hand_example, blocks_conserved_across_rollbacks).
Terminal window
ol start L10.8 # stubs spec.rs
ol tests L10.8
ol check L10.8 # exit code is the verdict
ol check L10.8 --ref-deps # only if L8.6, L10.1, or L10.1 is not passing yet
ol diff L10.8

Declare pub mod spec; in tl-engine/src/lib.rs. Your engine turns [engine].speculative = { draft = "prompt_lookup", k = 4 } into a SpecConfig with SpecConfig::parse, and its step loop runs one round per sequence: propose, one forward over the pending token plus the draft, verify_draft, then rollback of the sequence’s block table to the kept length.


Decoding one token costs one forward pass over the whole model, and at batch sizes your laptop can afford that pass is limited by reading the weights, not by arithmetic: checking five tokens costs about as much as producing one. If something cheap can guess the next few tokens, one target pass can verify all of them. In Python (L8.6) you proved the rule that makes this exact. Your Rust engine (L10.5) still decodes one token per pass, and the workloads it serves are full of repetition: code edits, retrieval answers quoting the context, JSON tool arguments that echo the prompt. This module ports the verifier to the engine, adds two drafts that need no second model (prompt lookup and an n-gram table over the request’s own tokens), and gives back the KV of rejected drafts so speculation never leaks blocks.

SymbolMeaningType
x1…xtx_1 \ldots x_tthe context: prompt plus emitted tokensu32 ids
d1…dmd_1 \ldots d_mthe draft, m≤km \le ku32 ids
pip_ithe target’s sampling distribution after context +d1…di−1+ d_1 \ldots d_{i-1}f64[V]
qiq_ithe draft’s distribution for did_i (one-hot for a deterministic draft)f64[V]
ua,uru_a, u_rthe two uniforms drawn per verified positionf64 in [0,1)[0, 1)
BBtokens per KV blockusize

Prompt lookup: for nn from max_ngram down to min_ngram, take the last nn tokens and find their most recent earlier occurrence (a start i<t−ni < t - n); the draft is the next kk tokens after it, which may run into the suffix itself. The first nn with a continuation wins. N-gram: for each draft token, count what followed the last mm tokens anywhere earlier in the context (drafted tokens included), for mm = max_ngram - 1 down to 1; take the most frequent, ties to the lowest id; stop when nothing matches. Both are deterministic, so qiq_i is one-hot.

The target extends its cache with the pending token (the previous round’s last emitted token, or the prompt in the first round) followed by d1…dmd_1 \ldots d_m, in one forward pass, giving m+1m + 1 rows of logits: row ii scores position ii of the draft, row mm the token after the whole draft. The verifier walks the draft:

  • Greedy (T=0T = 0): accept did_i while it equals the argmax of row ii (penalties applied, seeing the tokens accepted so far); the first mismatch emits the argmax instead and stops; after mm acceptances emit the argmax of row mm. No draws.
  • Sampled (T>0T > 0): draw uau_a then uru_r (both always, so the number of draws does not depend on the outcome); accept did_i when ua<pi(di)/qi(di)u_a < p_i(d_i)/q_i(d_i); otherwise emit a draw from the residual r=normalize(max⁡(0,pi−qi))r = \mathrm{normalize}(\max(0, p_i - q_i)) with uru_r and stop. After mm acceptances emit L10.1’s sample of row mm (one draw).

Every round emits between 1 and m+1m + 1 tokens, never more than the request has left: the draft is shortened to max_new - emitted - 1.

For a proposed x∼qx \sim q: P(emit x)=q(x)min⁡(1,p(x)/q(x))+(1−∑ymin⁡(p(y),q(y))) r(x)P(\text{emit } x) = q(x)\min(1, p(x)/q(x)) + (1 - \sum_y \min(p(y), q(y)))\,r(x). Since min⁡(q,p)+max⁡(0,p−q)=p\min(q, p) + \max(0, p - q) = p pointwise and the rejected mass is exactly the residual’s normalizer, the sum is p(x)p(x). For a one-hot draft this is: keep dd with probability p(d)p(d), else sample from pp with dd removed and renormalized.

The forward wrote KV for all mm draft positions. After keeping nn of them, the cache must hold the base length plus the pending tokens plus nn: the blocks past ⌈keep/B⌉\lceil \text{keep}/B \rceil are released (one reference each: a block shared through the prefix cache survives for its other owner) and the last kept block’s fill is set to what it still holds. A block a rejected draft filled must never be registered in the prefix index.

Prompt lookup (test hand_example_prompt_lookup). Context [5 6 7 8 5 6], k=3k = 3, nn from 3 down to 1. The 3-suffix [8 5 6] never occurred earlier. The 2-suffix [5 6] occurred at position 0, so the draft is what followed it: [7 8 5].

A greedy round (test hand_example_greedy_verify). Four ids, draft [2 1], rows:

RowLogitsArgmax
0[0 1 3 0.5]2 = d1d_1: accept
1[0 1 0.5 2]3 ≠d2=1\ne d_2 = 1: emit 3, stop
2[5 0 0 0](not reached)

Emitted [2 3], one draft accepted, no draws. With draft [2 3] both are accepted and row 2’s argmax 0 is the bonus: [2 3 0]. The logprob of the first emitted 2 is 3−ln⁡(e0+e1+e3+e0.5)3 - \ln(e^0 + e^1 + e^3 + e^{0.5}).

A sampled step (test hand_example_sampled_step). p=[0.2,0.5,0.3]p = [0.2, 0.5, 0.3], the draft proposed x=0x = 0 from q=[0.6,0.2,0.2]q = [0.6, 0.2, 0.2]; accept with probability 0.2/0.6=1/30.2/0.6 = 1/3. With ua=0.3u_a = 0.3: accept. With ua=0.5u_a = 0.5: reject; the residual is max⁡(0,p−q)=[0,0.3,0.1]\max(0, p - q) = [0, 0.3, 0.1], normalized [0,0.75,0.25][0, 0.75, 0.25]; ur=0.8u_r = 0.8 walks the running sum 0,0.75,1.00, 0.75, 1.0 and lands on id 2.

A rollback (test rollback_hand_example). B=4B = 4; 10 cached positions fill three blocks (4, 4, 2). Keeping 5 positions needs ⌈5/4⌉=2\lceil 5/4 \rceil = 2 blocks: block 3 is released, block 2’s fill becomes 1.

rust/crates/tl-engine/src/spec.rs
pub enum Draft { None, Ngram, PromptLookup }
pub struct SpecConfig { pub draft: Draft, pub k: usize, pub max_ngram: usize, pub min_ngram: usize }
impl SpecConfig { pub fn parse(draft: &str, k: i64) -> Result<SpecConfig, SpecError>; } // "none" | "ngram" | "prompt_lookup", 1 <= k <= 16
pub fn prompt_lookup(ctx: &[u32], k: usize, max_ngram: usize, min_ngram: usize) -> Vec<u32>;
pub fn ngram_draft(ctx: &[u32], k: usize, n: usize) -> Vec<u32>;
pub fn propose(cfg: &SpecConfig, ctx: &[u32], k: usize) -> Vec<u32>;
pub fn residual(p: &[f64], q: &[f64]) -> Vec<f64>;
pub fn sample_dense(p: &[f64], u: f64) -> usize;
pub fn speculative_step(p: &[f64], q: &[f64], x: usize, u_accept: f64, u_resample: f64) -> Result<(usize, bool), SpecError>;
pub fn dense_distribution(logits: &[f32], p: &SamplingParams, prompt: &[u32], output: &[u32]) -> Vec<f64>;
pub struct Verified { pub tokens: Vec<u32>, pub logprobs: Vec<f64>, pub n_accepted: usize }
pub fn verify_draft(rows: &[Vec<f32>], draft: &[u32], draft_probs: Option<&[Vec<f64>]>, p: &SamplingParams,
prompt: &[u32], output: &[u32], rng: &mut Pcg32) -> Result<Verified, SpecError>;
pub fn blocks_for(tokens: usize, block_tokens: usize) -> usize;
pub fn rollback(pool: &mut KvPool, table: &mut Vec<u32>, keep: usize) -> Result<usize, SpecError>;
pub trait Target { fn cached(&self) -> usize; fn extend(&mut self, ids: &[u32]) -> Result<Vec<Vec<f32>>, SpecError>; fn truncate(&mut self, len: usize) -> Result<(), SpecError>; }
pub struct SpecStats { pub drafted: usize, pub accepted: usize, pub target_calls: usize } // accept_rate()
pub fn generate(target: &mut dyn Target, cfg: &SpecConfig, prompt: &[u32], p: &SamplingParams,
seed: u64, max_new: usize, eos: &[u32]) -> Result<Generated, SpecError>; // given: generate_stream, never stopped
pub fn generate_stream(target: &mut dyn Target, cfg: &SpecConfig, prompt: &[u32], p: &SamplingParams,
seed: u64, max_new: usize, eos: &[u32],
on_token: &mut dyn FnMut(u32, f64) -> bool) -> Result<Generated, SpecError>;

You write generate_stream: generate’s loop, handing each emitted token and its logprob to on_token as soon as its round is verified, and returning at once with what it has when on_token answers false. The serve loop streams through it and stops a request whose client left. RunnerTarget (given) is the production Target: the runner returns one logits row per sequence, so it feeds every id as its own one-token sequence over the request’s blocks, at its own position, in one forward.

course/fixtures/L10.8/spec_golden.json records what the Python reference of L8.6 answers on 120 verification cases (exact f32 logits, drafts that agree for a while, optional draft distributions, every sampling knob, 64-bit seeds) and 150 prompt-lookup contexts (course/oracle/L10.8/spec_golden.py). The generation tests run a fake target whose logits are a fixed function of the context.

TestKINDChecksWhy it matters downstream
hand_example_prompt_lookupunitsection 3’s draft, a continuation into the suffix, no draftthe worked example
prompt_lookup_matches_python_l8_6differential150 contexts equal to PromptLookupDraftmost recent occurrence, longest suffix first
ngram_draft_hand_exampleunitcounts, ties to the lowest id, longest context first, back-off, stopthe second draft
config_parses_runtime_valuesboundarythe three drafts; k 0 and 17 and a typo refuseda bad runtime.toml fails at startup
hand_example_greedy_verifyunitsection 3’s round, no draws, logprobs, the bonus tokenthe greedy rule
hand_example_sampled_stepunitaccept, reject, residual, a q that never proposed xM07.6 by hand
verify_matches_python_l8_6differential120 cases: emitted tokens, n_accepted, and the generator’s next u32the port draws exactly what Python draws
verify_rejects_bad_shapesboundaryrows, draft distributions, out-of-vocabulary draftsan error, not a panic, in the step loop
greedy_spec_equals_greedy_without_specdifferentialevery draft kind, three prompts: the greedy tokens, cached positions, fewer passes with prompt lookupspeculation changes speed only
eos_and_max_tokens_end_generationboundaryexactly max_new tokens; EOS inside an accepted run stops thererequests end where they should
sampled_spec_keeps_the_target_distributionstatistical4000 seeds: the first token’s chi-square against the target softmax at p > 1e-3sampled output is unchanged in law
rollback_hand_exampleunitsection 3’s rollback, a full block, keep 0, blocks_forthe worked example
rollback_drops_one_reference_onlyfaulta block shared with another sequence survivesthe prefix cache is never corrupted
blocks_conserved_across_rollbacksproperty200 seeded requests with many rejections on a real pool: blocks match positions, nothing leaksweeks of speculation do not shrink the pool
generate_stream_hands_over_each_token_and_stops_when_toldconformanceevery emitted token reaches the listener in order; a listener that says stop ends generation therethe engine streams a speculative answer and frees it when the client leaves
runner_target_scores_every_fed_positiondifferentialon L7.9’s tiny llama: one row per fed id, each equal to a fresh run of its prefix; prompt lookup emits plain greedy’s tokensspeculation works on a real model, not only the fake
PitfallSymptomCaught by
Copying after the FIRST earlier occurrencestale drafts on long repetitive contexts; acceptance dropsprompt_lookup_matches_python_l8_6 (mutant s01)
Stopping the continuation at the suffixno draft on periodic text, the best case for lookuphand_example_prompt_lookup (mutant s02)
N-gram ties to the highest iddrafts differ from the specified onesngram_draft_hand_example (mutant s03)
Greedy penalties not seeing tokens accepted this roundgreedy speculative output differs from greedyverify_matches_python_l8_6 (mutant s04)
Accepting with probability p(x)p(x), ignoring q(x)q(x)sampled output follows the wrong law whenever the draft is stochastichand_example_sampled_step (mutant s05)
Residual ∣p−q∣\lvert p - q \rvertrejected positions favor the drafted tokensampled_spec_keeps_the_target_distribution (mutant s06)
Drawing uru_r only on rejectionthe generator drifts from Python’s after the first acceptanceverify_matches_python_l8_6 (mutant s07)
The bonus token from the last draft’s rowa full acceptance emits the wrong next tokenhand_example_greedy_verify (mutant s08)
uru_r drawn before uau_aseeded streams differ from Python’sverify_matches_python_l8_6 (mutant s09)
Rejected drafts’ KV keptlater tokens attend to tokens that were never emittedgreedy_spec_equals_greedy_without_spec (mutant s10)
Drafts not shortened near max_newKV written past the request’s budgetgreedy_spec_equals_greedy_without_spec (mutant s11)
Resampling from pp, not the residualthe drafted token is over-representedsampled_spec_keeps_the_target_distribution (mutant s12)
Releasing the partial last blockthe kept positions lose their KVrollback_hand_example (mutant s13)
DirectionModuleHow it uses this
BackL8.6the Python verifier and prompt lookup this port is held to, case by case
BackL10.1apply_penalties, distribution, argmax, sample, stream, and KvPool
BackL10.1release and fill on the Rust pool during rollback
ForwardL10.5runtime config enables speculative decoding through the request-serving path

The serve loop (L10.5) routes requests through RunnerTarget when [engine].speculative names a draft. This path holds one request on the engine thread until generation completes; the ordinary scheduler path remains active when speculation is absent. L10.7 exports SpecStats::accept_rate as tl_engine_spec_accept_rate; MS-L10 checks greedy output is unchanged with prompt_lookup on.

Your pieceProduction equivalentWhat it addsWhere to look
prompt lookup and n-gram draftsvLLM ngram speculative methodthe same idea inside a batched GPU enginevLLM speculative decoding
a chain of draftsMedusa, EAGLEextra heads that draft a tree of continuations; tree attention verifies them at onceEAGLE, Medusa
one target pass per roundSpecInfer, Sequoiatoken trees and draft budgets tuned to hardwareSpecInfer
a deterministic draft modelllama.cpp --drafta small model of the same family as the draftllama.cpp speculative example