DL S069 · Speculative Decoding — 2–3× Free Speedup
The trick where a tiny draft model proposes k tokens and the big model verifies them in one forward pass. Provably lossless, 2–3× decode speedup, and now standard in every serious inference stack.
🎯 Understand speculative decoding well enough to implement it in ~80 lines, and to predict when it will help vs hurt.
Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 69 / 80 · Module M11 · ~2 hours
The story
Here's the puzzle. Decode is memory-bound (S066): you read 14 GB of weights to produce one 2-byte token. Ridiculous. If only you could produce more tokens per memory read.
You can. The trick, from Leviathan et al. (Google, 2022) and simultaneously Chen et al. (DeepMind, 2023): use a small draft model (say Llama-3.2-1B) to generate the next tokens greedily/sampled. Then run the big target model (Llama-3-70B) once on all tokens in parallel — a single forward pass — and accept as many drafted tokens as agree with what the target would have produced. On acceptance, you got tokens for one big-model forward pass. On rejection at position , you keep the first tokens and take the target's token at (always at least one token per iteration, so it's never worse than plain decode).
The kicker: this is provably lossless. Under proper rejection sampling, the output distribution is exactly the target's. You are not trading quality for speed. You are trading "some wasted small-model compute" for "much less wasted big-model memory bandwidth". And on real workloads it's 2–3× faster.
Every serious inference stack — vLLM, TensorRT-LLM, TGI, MLC, llama.cpp — ships speculative decoding now. Understanding it changes how you think about all of decode.
The 2022 idea in one image
Imagine a professor grading exam papers. Each paper takes 60 seconds. There are 100 papers. That's 100 minutes. Now imagine a TA who guesses each paper's grade in 3 seconds — mostly right, occasionally wrong. Professor's new workflow: TA scribbles guesses on all 100 papers (5 minutes). Professor reads them, agrees on 80%, corrects the other 20 in one sitting. Total: 5 minutes + one glance per paper (much less than 60s each because the answer is already there to verify) ≈ 20 minutes. 5× speedup, same grades.
That's speculative decoding. The TA is the draft model. The professor is the target model. The "verify all at once" trick works because reading 100 papers in a row is not 100× slower than reading one — the professor's eyes are already open. Same for a GPU: one forward pass on a 5-token sequence is not 5× slower than one forward pass on a 1-token sequence. It's memory-bound; you pay the weight-read cost once either way. The extra 4 tokens are essentially free compute.
Leviathan's original paper reported 2–3× speedups on T5. In 2024–2025, with tree-based drafts (Medusa, EAGLE), the same idea hits 3–5×. It might be the single highest-ROI inference trick invented in the last five years.
- Explain the speculative decoding loop in 4 steps: draft, target-forward, rejection-sample, accept/repair.
- Prove (informally) why the accept/reject rule preserves the target distribution.
- Implement greedy and sampled speculative decoding in ~80 lines of PyTorch.
- Predict acceptance rate for a given (draft, target) pair from vocab overlap intuition.
- Compare vanilla speculative vs Medusa vs EAGLE and pick the right one.
- Diagnose why speculative decoding sometimes *slows* generation and fix it.
Prerequisites
- Session 066 — why decode is memory-bound (the whole reason speculative works).
- Session 053 — softmax, temperature, top-p.
- Session 068 — distillation is one way to get a good draft model.
1 · The loop, in words
Target model (big, slow). Draft model (small, fast). Speculation length (typically 4–8).
Repeat until EOS:
- Draft: starting from the current context, autoregressively sample tokens from : , recording draft probabilities at each step.
- Target forward pass (parallel): feed the context + into as one sequence. This is one forward pass, same cost as producing a single token in vanilla decode (memory-bound: you read weights once). Extract target probabilities at positions .
- Rejection-sample sequentially for :
- Accept with probability .
- On acceptance: continue to .
- On rejection at : sample a replacement from the corrected residual , break.
- Free token: if all tokens were accepted, sample one more from — that's already there in the target's parallel pass output.
At the end of one iteration: you've produced between 1 and new tokens for one big-model forward pass. Amortized, expected tokens per big pass is where is per-position acceptance probability.
2 · Why it's lossless
The claim: tokens accepted by this scheme are distributed exactly as if you'd sampled from directly.
Sketch: consider a single position. Probability of emitting token under the scheme is:
- (draft produces ) × (accept it) + (draft produces something else, gets rejected) × (residual sample lands on )
Grind through — comes out to exactly . The residual construction is the entire trick: it exactly cancels the bias introduced by the draft. Same principle as rejection sampling in classical MCMC.
3 · Expected speedup
Let be the per-token acceptance probability (assume constant for intuition). Expected tokens per iteration:
For : tokens per big-model pass. So ~3× throughput on decode, minus overhead of the draft passes and the extra tokens in the target's forward.
Overhead calibration: target parallel forward of tokens vs 1 token. On memory-bound decode, this is nearly free — you're re-reading the same weights either way. On prefill (compute-bound), the extra tokens cost real FLOPs; that's why speculative decoding is a decode optimization, not a prefill one.
4 · Code: a working speculative decoder
The chunk everyone gets wrong is the sequential rejection sampling. Let's write it explicitly.
import torch
import torch.nn.functional as F
@torch.no_grad()
def draft(model, input_ids, k, temperature=1.0):
"""Autoregressive sample k tokens from `model`. Returns tokens (1, k) and probs list."""
tokens, probs = [], []
ctx = input_ids
for _ in range(k):
logits = model(ctx).logits[:, -1, :] # (1, V)
p = F.softmax(logits / temperature, dim=-1)
tok = torch.multinomial(p, num_samples=1) # (1, 1)
tokens.append(tok)
probs.append(p)
ctx = torch.cat([ctx, tok], dim=1)
return torch.cat(tokens, dim=1), probs # (1, k), list of (1, V)Install transformers>=4.36. Load meta-llama/Llama-3.2-1B as the assistant and meta-llama/Llama-3-8B-Instruct as the target. Generate 200 tokens on a factual prompt ("Explain photosynthesis in 3 sentences") and a creative one ("Write a haiku about compilers"). Log assistant_kwargs={"num_assistant_tokens": 5}, time both runs, and check outputs.past_key_values length vs base decode. You'll usually see 2–3× on the factual prompt and 1.2–1.5× on the creative one — that gap is the term.
Now the target verification + accept/reject:
@torch.no_grad()
def spec_step(target, draft_model, input_ids, k=5, temperature=1.0):
# 1) Draft k tokens
drafted, q_list = draft(draft_model, input_ids, k, temperature)
# 2) One target forward on context + drafted
full = torch.cat([input_ids, drafted], dim=1) # (1, T + k)
logits = target(full).logits # (1, T + k, V)
# Positions of interest: predictions for slots T..T+k inclusive
T = input_ids.shape[1]
p_all = F.softmax(logits[:, T - 1 : T + k, :] / temperature, dim=-1) # (1, k+1, V)
accepted = []
for i in range(k):
p_i = p_all[0, i] # target prob at position i
q_i = q_list[i][0] # draft prob at position i
x = drafted[0, i].item()
r = torch.rand(1, device=input_ids.device).item()
ratio = (p_i[x] / q_i[x]).clamp(max=1.0).item()
if r < ratio:
accepted.append(x)
else:
# rejection: sample from corrected residual
residual = torch.clamp(p_i - q_i, min=0.0)
residual = residual / residual.sum()
new_x = torch.multinomial(residual, 1).item()
accepted.append(new_x)
return torch.tensor([accepted], device=input_ids.device) # (1, i+1)
# All k accepted: bonus token from target's k-th slot prediction
bonus = torch.multinomial(p_all[0, k], 1).item()
accepted.append(bonus)
return torch.tensor([accepted], device=input_ids.device) # (1, k+1)Driver:
@torch.no_grad()
def spec_generate(target, draft_model, prompt_ids, max_new=200, k=5):
ids = prompt_ids
while ids.shape[1] < prompt_ids.shape[1] + max_new:
new = spec_step(target, draft_model, ids, k=k)
ids = torch.cat([ids, new], dim=1)
if (new == tokenizer.eos_token_id).any():
break
return idsReal implementations swap target(full).logits for a KV-cached version — the target only needs to process the newly appended tokens because its cache already covers input_ids. Same for the draft. In this simple version we recompute for clarity; in production this is critical for the speedup to materialize.
5 · Draft model choice
Options, in order of setup complexity:
- Same-family smaller model. Llama-3-70B target ↔ Llama-3.2-1B draft. Same tokenizer (essential), similar distribution → .
- Distilled draft (S068). Train a 1B student on the target's outputs. .
- N-gram / lookup draft (Prompt Lookup Decoding, Saxena 2023). Just grep the prompt for repeated substrings. Zero extra params, but no compute cost. Surprisingly good for code, RAG, structured output.
- Self-speculation (Medusa, Eagle). Add "prediction heads" to the target itself that predict multiple future tokens in one pass. No separate model, higher , need to train the heads.
Medusa
Cai et al. 2024: attach extra decoder heads to the target (), each predicting the token steps ahead. Fine-tune only these heads (base frozen) on a small dataset. At inference, sample from each head to get candidates, verify with a tree-attention pass. ~2.5× speedup, no separate draft model.
EAGLE / EAGLE-2 / EAGLE-3
Li et al. 2024. Sharper draft model that predicts the feature vector (pre-LM-head hidden state) at the next position, then applies the frozen LM head. More faithful to the target than a naive small model. State of the art in 2024: 3× on Llama-3-70B.
EAGLE-2 (2024) added a dynamic draft tree: instead of a fixed-depth candidate tree, expand deeper along high-confidence branches. Reported 4× speedup on Vicuna-33B.
EAGLE-3 (Li et al., March 2025) added "training-time test" — during draft-head training, they simulate the drift that happens when the draft's own output feeds back as input, and train against that distribution. Result: acceptance rate stays high as tree depth grows. Independent benchmarks (agentnative.dev 500-prompt suite, 2026) put EAGLE-3 at 2.89× median speedup on Llama-3-70B vs Medusa-2 at 2.21× vs a standard 1B draft at 1.95× vs prompt-lookup at 1.38×.
2025 default: EAGLE-3
As of mid-2025, vLLM v1 ships EAGLE-3 as the default speculator for English + code workloads. Medusa remains competitive when you can't afford the EAGLE draft training run (EAGLE-3 draft heads take ~8 H100-hours per target model). The industry consensus is: use EAGLE-3 if you can, prompt-lookup if you can't train, and turn spec off entirely if acceptance is below ~0.5.
Further reading — the speculative frontier:
- Cai et al., Medusa (ICML 2024) + Medusa-2 (2024).
- Li et al., EAGLE (ICML 2024), EAGLE-2, EAGLE-3 (2025).
- Fu et al., Lookahead Decoding (2024) — tree-based Jacobi iteration alternative.
- Miao et al., SpecInfer (ASPLOS 2024) — tree verification with token-tree attention masks.
- HuggingFace, Assisted Generation blog (updated 2024).
6 · When speculative decoding hurts
At batch size 16+, the target's forward pass is no longer memory-bound — it's compute-bound. Now the extra tokens in the target pass cost you real FLOPs, and speculative decoding slows you down. vLLM disables speculation above a batch threshold. Rule of thumb: speculative wins when batch < ~8 on a modern GPU.
Your draft was trained on general web text; your target is being used for Python code generation. drops to 0.3. Now you're doing extra work for nothing. Fix: distill/fine-tune the draft on your target domain, or switch to prompt-lookup for code (repetitive substrings help).
Llama draft, Mistral target — tokenizers differ. Token IDs mean different things. The scheme still runs and produces gibberish that's biased toward one vocab. Always assert draft.config.vocab_size == target.config.vocab_size and use the same tokenizer object.
If temperature=0 (greedy), the accept rule collapses to "accept if , else take argmax". No sampling. Simpler and slightly faster path. HuggingFace assisted_generation special-cases this.
7 · Mermaid: one iteration
8 · How to actually run this today
Easiest path — HuggingFace assisted_generation:
from transformers import AutoModelForCausalLM, AutoTokenizer
tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3-70B-Instruct")
target = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3-70B-Instruct", torch_dtype="auto", device_map="auto")
draft = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.2-1B-Instruct", torch_dtype="auto", device_map="auto")
inputs = tok("Explain speculative decoding briefly.", return_tensors="pt").to(target.device)
out = target.generate(**inputs, assistant_model=draft, max_new_tokens=200)
print(tok.decode(out[0], skip_special_tokens=True))Prompt-lookup (zero-cost draft):
out = target.generate(**inputs, prompt_lookup_num_tokens=10, max_new_tokens=200)vLLM: --speculative_model meta-llama/Llama-3.2-1B-Instruct --num_speculative_tokens 5.
TensorRT-LLM has both draft-target and Medusa/EAGLE built in.
Recall
1. Why is speculative decoding lossless?
The acceptance rule plus the residual-sampling on rejection together reproduce exactly the target's distribution . Same math as rejection sampling in MCMC.
2. Expected tokens per big-model pass for ?
tokens per iteration.
3. Why does speculative decoding fail at large batch sizes?
The target's -token parallel forward stops being memory-bound and starts costing real FLOPs. The extra work no longer amortizes over reused weight reads. Disable speculation above ~batch 8.
4. Difference between draft-target and Medusa?
Draft-target uses a separate smaller model. Medusa attaches K extra decoder heads to the target itself that predict K future tokens in parallel; no separate model.
5. What breaks silently if the draft and target have different tokenizers?
Token IDs mean different things in the two vocabularies. Accept/reject still runs but on nonsense correspondences, producing garbled output biased toward the target's vocab. Always assert same tokenizer.
Stretch: implement prompt-lookup decoding from scratch: given a prompt, find its longest suffix that appears earlier in the prompt, and use the following few tokens as the draft. Measure acceptance rate on a code-generation task vs a chat task.
In your own words: speculative decoding turns __________________________ into __________________________.
Spaced review: S053 sampling strategies, S066 KV cache (memory-bound decode is the whole reason this works).
Next session (S070): continuous batching — how servers pack multiple requests of different lengths into one GPU without wasting cycles waiting for the slowest.
Bring back tomorrow:
- The 4-step spec-decoding loop.
- Why it's lossless (rejection sampling identity).
- When it stops helping (batch > 8, tokenizer mismatch, domain drift).
"Speculative decoding trades quality for speed. The small draft model is guessing, so the output must be a bit worse than what the big model would have produced — that's the price of the speedup."
Correctly implemented speculative decoding is exactly distribution-preserving. The verification step accepts a drafted token with probability min(1, p_target/p_draft) and, on rejection, resamples from a specific residual distribution constructed so the overall output distribution is identical to sampling from the target model alone. It is not an approximation with a small error; it is an algebraic identity. You get the target model's exact distribution, faster.
This one is seductive because every other inference speedup you have met genuinely is a quality trade: quantisation, pruning, distillation, cache eviction, smaller models. You have correctly learned that free lunches do not exist, and you generalise. The escape is that speculation is not buying compute reduction at all — it is buying parallelism. The target model still evaluates every token it accepts; it just evaluates several positions in one forward pass instead of one per pass, exploiting the fact that decode leaves the tensor cores idle. Nothing was approximated because nothing was skipped. The rule "faster means worse" is true for lossy compression and false for better hardware utilisation, and the misconception is failing to notice which one you are looking at.
Check the identity empirically rather than trusting the claim:
import torch
p = torch.tensor([0.1, 0.6, 0.3]) # target distribution
q = torch.tensor([0.5, 0.2, 0.3]) # draft distribution
counts = torch.zeros(3)
for _ in range(200000):
i = torch.multinomial(q, 1).item()
if torch.rand(1).item() < min(1.0, (p[i]/q[i]).item()):
counts[i] += 1
else:
resid = (p - q).clamp(min=0); resid /= resid.sum()
counts[torch.multinomial(resid, 1).item()] += 1
print(counts/counts.sum()) # -> approaches p, not qThe empirical frequencies converge to p regardless of how bad q is. A worse draft costs throughput, never correctness.
Why can the target model verify k drafted tokens in a single forward pass, when generating those same k tokens autoregressively would require k passes? Verification looks like it should be just as sequential.
- 1Autoregressive generation is sequential because token t+1 is an input to the computation of token t+2 — you cannot start the second until the first exists.forced by · the dependency is on the sampled token value, which is not known until the forward pass completes
- 2But once a candidate sequence exists, the tokens are all known. Feeding them together with a causal mask computes, in one pass, the target's next-token distribution at every prefix simultaneously.forced by · causal masking means position i's output depends only on positions ≤ i, which are all present in the input — so the pass is a batched evaluation of k independent conditionals
- 3That pass reads the model weights exactly once, the same as a single-token decode step, because weight bytes do not depend on sequence length.forced by · decode is bandwidth-bound, so the marginal cost of extra positions is nearly free until you become compute-bound
- 4The rejection-sampling rule then converts "here are k proposals and the target's true conditionals" into a sample from the target distribution, accepting the longest correct prefix.forced by · each accepted token was drawn from the target's exact conditional given the tokens before it — which is the definition of autoregressive sampling
- 5Acceptance is a prefix property: one rejection invalidates every token after it, because those were conditioned on a token that was not chosen.forced by · conditional distributions computed on a wrong prefix are answers to a question you are no longer asking
Therefore verification is parallel for exactly the reason training is parallel and generation is not: you already have the tokens. Speculation manufactures a candidate sequence cheaply so it can borrow training-style parallelism at inference time.
And note what this predicts. First: the speedup should collapse as batch size rises, because speculation spends idle compute, and a large batch has already spent it — measure tokens/s gain at batch 1 versus batch 64 and expect the advantage to shrink toward nothing. Second: expected tokens per target pass with acceptance rate α and draft length k is (1−α^(k+1))/(1−α), which saturates in k; so raising k should show sharply diminishing returns and eventually go negative once draft cost dominates, giving an optimum you can find by sweeping. Third: because rejection kills the whole suffix, speedup should depend on acceptance rate super-linearly — a draft that agrees 80% of the time is far more than twice as useful as one that agrees 40% of the time.
Someone is dictating and you are transcribing. Writing one word at a time means asking "next?" after every word — the round trip dominates. Instead you guess the next several words yourself, then ask them to read your guess and point at the first word you got wrong. Their read is one glance regardless of how many words you wrote.
Everything before their finger is verified correct and you keep it. Everything after is discarded, because it was written assuming a word that turned out wrong. Your speedup is exactly how far the finger usually travels.
- Verification is one pass whatever the draft length, because the target reads its weights once. That single fact is the whole mechanism.
- Acceptance is a prefix: the first rejection discards everything after it. So expected gain grows with acceptance rate faster than linearly.
- The speedup comes from idle compute during memory-bound decode. No idle compute, no speedup — which is why it fades under heavy batching.
- Output distribution is exactly preserved. A bad draft costs throughput, never correctness. This means you can tune acceptance aggressively without any quality risk.
- Draft cost is on the critical path. A draft model that is too good is too slow, and the optimum is a specific small ratio of draft to target cost.
Fire this the moment you see: a single-stream latency requirement on a large model · GPU utilisation low during generation but memory bandwidth saturated · a workload with highly predictable output such as code, structured formats, or long verbatim quotes from the prompt · someone proposing a smaller model purely for latency · a claim that a decoding speedup changed the outputs (it should not, so it is a bug) · a draft length picked as a round number rather than by sweep.
You want lower single-stream latency. Where does the draft come from — a separate small model, the model itself, or the prompt?
Start by asking whether you are the right shape for this at all: speculation converts idle compute into tokens, so if you are already running large batches you have nothing to convert and should not bother. Given a genuine single-stream or low-concurrency latency target, try prompt-lookup drafting first — it costs almost nothing to implement and on copy-heavy workloads it is competitive with a draft model while consuming no memory.
If you go to a draft model, tune two things by measurement and not by intuition: acceptance rate on your own traffic, and draft length. Acceptance is the number that predicts everything, and it is workload-specific, so a rate quoted for someone else's traffic tells you nothing about yours. And instrument it in production — acceptance rate drifting downward is an early signal that your traffic distribution has moved, which is useful information well beyond decoding.