Inference, Serving & Scaling
IC5IC6

Speculative Decoding: Draft-then-Verify

A small model gambles on the next few tokens, the big model checks all of them in one cheap pass, and you pay for memory bandwidth you were already wasting.

15 min read · 13 sections
0

1. Quick anchor

Autoregressive decoding generates one token per forward pass, and each pass through a large model is memory-bound: you drag the entire weight matrix and KV cache through HBM just to compute a single matrix-vector product. The GPU's compute units sit ~60-80% idle. Speculative decoding exploits that slack. A cheap draft model proposes k tokens ahead; the expensive target model then runs one forward pass over all k proposed positions in parallel — a matrix-matrix multiply that costs almost the same wall-clock time as a single decode step. A clever acceptance rule keeps every token that the target "would have" produced anyway, so the output distribution is provably identical to plain sampling. You get multiple tokens for the price of one forward pass, and you pay only in wasted draft compute on rejected guesses.

2. Why interviewers probe this

Speculative decoding is the cleanest test of whether a candidate actually understands the roofline of LLM inference, not just the API.

  • IC5 signal: Can you explain why decode is memory-bound and articulate the speedup as a function of acceptance rate and draft length? Can you implement the accept/reject loop correctly and prove it's distribution-preserving? Do you know when it helps (low batch, latency-critical) versus when it's a liability (high batch, throughput-saturated)?
  • IC6 signal: Can you reason about it as a fleet-level decision? Where does it sit relative to continuous batching, chunked prefill, and quantization? Can you choose between a separate draft model, self-speculation (Medusa/EAGLE), and n-gram lookup based on workload? Can you derive the crossover batch size where it stops paying off, and reason about acceptance-rate scaling laws when sizing a draft?

3. Concept build-up

Beginner explainerNew here? The words first

The words first.

  • Autoregressive decode — generating text one token at a time, each token feeding back as input for the next.
  • Memory-bound — the operation is limited by how fast you can read data from GPU memory, not by arithmetic speed.
  • Arithmetic intensity — FLOPs of compute done per byte read from memory; low intensity means you're starving the compute units.
  • Target model — the big, accurate model whose exact output you want to reproduce.
  • Draft model — a small, fast model that guesses several next tokens cheaply.
  • Acceptance rate (alpha) — the fraction of drafted tokens the target accepts; the single most important knob.
  • Verification pass — one forward pass of the target over all drafted positions at once, in parallel.
  • Lossless — the final tokens are sampled from exactly the target's distribution; speculative decoding does not change quality.

Step by step.

  1. The draft model generates k candidate tokens, one after another (cheap, because it's small).
  2. The target model runs a single forward pass that scores all k positions in parallel.
  3. Walk left to right: for each drafted token, apply an accept/reject test comparing draft and target probabilities.
  4. Accept the longest matching prefix; on the first rejection, resample that one position from a corrected distribution.
  5. The target's parallel pass already computed the distribution for the position after the last accepted token — emit that one for free too.
  6. You advance by (accepted + 1) tokens per target forward pass instead of exactly 1.
  7. Repeat until the sequence is done.

Remember this: You're trading cheap, sometimes-wasted draft compute for the expensive thing you were already paying for — and getting several tokens per target pass instead of one.

3.1 The roofline: why decode is memory-bound

Take a 70B model in BF16. Generating one token in plain decode reads ~140 GB of weights from HBM (plus the KV cache) to do a batch-of-1 matrix-vector multiply. On an H100 with 3.35 TB/s of bandwidth, just reading the weights takes ~42 ms — and the FLOPs to multiply them against a single token vector finish in a fraction of that time. The GPU stalls waiting on memory; compute utilization on decode typically lands at 20-40%. That idle compute is the resource speculative decoding monetizes.

The key asymmetry: a target forward pass over a single token and a forward pass over k=5 tokens cost nearly the same wall-clock time, because both are dominated by the same weight read. The k-token version turns the matrix-vector op into a matrix-matrix op that finally uses the idle FLOPs. So if you can guess what those k tokens should be and verify them in one pass, you get up to k+1 tokens for the latency of roughly one decode step.

3.2 The accept/reject rule (why it's lossless)

The non-obvious part is that you can do this without changing the output distribution. The algorithm (Leviathan et al. 2023; Chen et al. 2023) works token-by-token over the draft. Let p(x) be the target's probability for token x at a position and q(x) the draft's probability for the token it sampled there.

  • Sample a uniform r in [0, 1).
  • Accept the drafted token x if r < min(1, p(x) / q(x)).
  • If the draft was at least as confident as the target (q(x) >= p(x)) you accept with probability p(x)/q(x); if the target liked it more (p(x) >= q(x)) you always accept.
  • On rejection, you stop and resample that position from the residual distribution p_resid(x) = norm(max(0, p(x) - q(x))) — the part of the target's mass the draft under-weighted. Then you discard the rest of the draft.

The math guarantee: tokens emitted this way are distributed exactly as if you had sampled from p directly. The proof is a two-case probability decomposition; the residual-resampling step is precisely what corrects the bias the draft introduced. This is why you can swap in a sloppy draft model with zero quality risk — a bad draft only lowers your acceptance rate, never your accuracy. (At temperature 0 / greedy, the rule collapses to "accept iff the draft's argmax equals the target's argmax.")

Accept/reject on real numbers

Symbols: p = target's probability for a token, q = draft's probability for the token it proposed, r = a random draw in [0,1).

Say at one position the draft proposes the token "blue" with q(blue) = 0.6. The target, in its verification pass, assigns p(blue) = 0.3.

  • Acceptance probability = min(1, p/q) = min(1, 0.3/0.6) = 0.5.
  • Draw r = 0.42. Since 0.42 < 0.5, accept "blue". We keep it and move to the next drafted token.
  • Counterfactually, draw r = 0.71. Since 0.71 >= 0.5, reject. We now resample this position from the residual max(0, p - q) over the whole vocab, renormalized — favoring tokens the target liked but the draft under-rated (e.g. "green", where p=0.4, q=0.05). Then we throw away every drafted token after this one.

What it did: it kept a cheap guess only as often as the math says it must to stay faithful to p, and on a miss it spent one corrected sample to stay exactly on the target's distribution.

3.3 The speedup math

Let alpha be the per-token acceptance probability (assume i.i.d. for intuition; real acceptance decays with position). With a draft of length k, the expected number of tokens accepted before the first rejection is a truncated geometric series. The expected tokens produced per target forward pass is:

E[tokens] = (1 - alpha^(k+1)) / (1 - alpha)

The +1 accounts for the bonus token the target emits for free after the last accepted position. The wall-clock speedup is roughly:

speedup ≈ E[tokens] / (1 + k·c)

where c is the cost of one draft forward pass relative to one target forward pass (e.g. a 1B draft against a 70B target gives c ≈ 0.015). The denominator is the overhead: one target verification pass plus k draft passes.

Concretely, with alpha = 0.7, k = 5, c = 0.02: E[tokens] = (1 - 0.7^6)/0.3 ≈ (1 - 0.118)/0.32.94 tokens per target pass, and overhead 1 + 5·0.02 = 1.1, so speedup ≈ 2.94 / 1.12.7x. Push alpha to 0.85 and you get E[tokens] ≈ 4.4, speedup ~3.9x. This is why acceptance rate dominates everything — it's inside an exponent. Draft length k has diminishing returns: each extra token is accepted with probability alpha^position, so beyond k ≈ 4-8 you're mostly paying draft cost for tokens that rarely survive.

Two practical corollaries: (1) the right k depends on alpha — high acceptance justifies longer drafts; (2) you want the cheapest draft that hits your acceptance target, because c and per-step latency scale with draft size. The 2025 scaling-laws work (arXiv:2505.07858) formalizes the draft-size-to-acceptance relationship so you can size a draft without grid-searching.

3.4 Where the draft comes from

There are three families, and choosing among them is the real engineering decision.

Separate draft model. A genuinely smaller model from the same family (e.g. Llama-3.2-1B drafting for Llama-3.3-70B). Highest ceiling on acceptance because it's a real LM, but you pay extra HBM for its weights and KV cache, and the draft must share the target's tokenizer. Sweet spot: draft is roughly 5-25% of target params.

Self-speculation (Medusa, EAGLE). Instead of a separate model, you bolt lightweight prediction heads onto the target itself.

  • Medusa adds several parallel decoding heads that each predict a token at a future position directly from the target's last hidden state. Cheap and simple, but each head predicts independently, so acceptance drops at longer speculation lengths.
  • EAGLE trains a single small autoregressive transformer head that consumes the target's internal feature vectors (not just token embeddings) to draft. By predicting at the feature level it captures sequential dependencies Medusa misses. EAGLE-2 adds dynamic draft trees (verify many candidate continuations at once); EAGLE-3 reaches a roughly flat ~70-80% acceptance across draft positions and beats Medusa by ~15-18 percentage points on acceptance. EAGLE-3 is built into vLLM, SGLang, and TensorRT-LLM as of 2025-2026.

N-gram / prompt lookup. Training-free. You maintain a suffix automaton over recently generated and prompt tokens, and when the current suffix matches, you "draft" by copying the continuation that followed last time. Zero extra parameters, zero extra HBM. Astonishingly effective on repetitive or extractive workloads — code with repeated identifiers, JSON/structured output, RAG where the model quotes the context, edit/rewrite tasks. Useless on creative, low-repetition text.

3.5 Draft trees and tree attention

A linear draft of k tokens commits to one continuation. A draft tree proposes several branches (e.g. top-2 at each of a few positions), then verifies the whole tree in a single target pass using a custom tree attention mask so each candidate path only attends to its own ancestors. You accept the longest valid root-to-node path. This raises expected accepted length per pass for a modest increase in verified-token count, and is the core trick behind EAGLE-2/3 and SpecInfer. The cost: more complex kernels and a larger verification batch dimension, which eats into the "verification is free" assumption as the tree grows.

4. Minimal implementation

Here is a correct, distribution-preserving single-sequence implementation. It's deliberately framework-free so the accept/reject logic is visible; production systems (vLLM, SGLang) fuse all of this into batched kernels with tree attention.

import torch
import torch.nn.functional as F
 
@torch.no_grad()
def speculative_generate(target, draft, input_ids, n_new, k=5, temperature=1.0):
    """Lossless speculative decoding for one sequence.
    target, draft: callables returning logits [batch, seq, vocab].
    Emits exactly the target's sampling distribution."""
    device = input_ids.device
    seq = input_ids
    produced = 0
 
    def sample(logits):  # logits: [vocab]
        if temperature == 0:
            return logits.argmax(-1, keepdim=True)
        probs = F.softmax(logits / temperature, dim=-1)
        return torch.multinomial(probs, 1)
 
    while produced < n_new:
        # 1) Draft k tokens autoregressively (cheap, small model).
        draft_ids, draft_probs = [], []
        cur = seq
        for _ in range(k):
            logits = draft(cur)[0, -1]                 # [vocab]
            p = F.softmax(logits / max(temperature, 1e-6), dim=-1)
            tok = torch.multinomial(p, 1) if temperature > 0 else logits.argmax(-1, keepdim=True)
            draft_ids.append(tok)
            draft_probs.append(p)
            cur = torch.cat([cur, tok.view(1, 1)], dim=1)
 
        draft_ids = torch.cat(draft_ids)               # [k]
 
        # 2) ONE target pass scores all k drafted positions + the next-token slot.
        t_logits = target(cur)[0, -(k + 1):]           # [k+1, vocab]
        t_probs = F.softmax(t_logits / max(temperature, 1e-6), dim=-1)
 
        # 3) Walk the draft, accept/reject token by token.
        n_accepted = 0
        for i in range(k):
            tok = draft_ids[i].item()
            p_t = t_probs[i, tok]                       # target prob of drafted token
            p_d = draft_probs[i][tok]                   # draft prob of drafted token
            r = torch.rand(1, device=device)
            if r < torch.clamp(p_t / (p_d + 1e-9), max=1.0):
                n_accepted += 1
            else:
                # Reject: resample THIS position from residual (p_target - p_draft)+.
                resid = torch.clamp(t_probs[i] - draft_probs[i], min=0)
                resid = resid / resid.sum()
                corrected = torch.multinomial(resid, 1)
                seq = torch.cat([seq, draft_ids[:i].view(1, -1),
                                 corrected.view(1, 1)], dim=1)
                produced += n_accepted + 1
                break
        else:
            # All k accepted -> append them + the FREE bonus token from t_probs[k].
            bonus = (t_probs[k].argmax(-1, keepdim=True) if temperature == 0
                     else torch.multinomial(t_probs[k], 1))
            seq = torch.cat([seq, draft_ids.view(1, -1), bonus.view(1, 1)], dim=1)
            produced += k + 1
 
    return seq

The load-bearing details: (1) the single target(cur) call over k+1 positions is what makes verification "free" — it's one weight read. (2) On rejection you resample from the clamped residual max(0, p_target - p_draft), not from p_target — that correction is what makes the whole thing exactly equal to sampling from the target. (3) The else branch on the for loop fires only when nothing was rejected, harvesting the bonus token. A real implementation caches KV for the accepted prefix so the next iteration doesn't recompute it, and batches across sequences.

5. Production tradeoffs

Approach Acceptance ceiling Extra HBM Setup cost Best workload Failure mode
Separate draft model High (real LM) Draft weights + KV Need matching tokenizer General chat, low batch Tokenizer mismatch; HBM pressure
Medusa heads Medium Small (heads only) Train heads on target Latency-critical, single stream Acceptance decays with length
EAGLE-3 High (~70-80% flat) Small (one head) Train head on features General, production default Training pipeline; feature coupling
N-gram / lookup Variable (workload-dependent) ~zero None Code, JSON, RAG, edits Collapses on creative text

The decisive variable is batch size / load, and it's where IC6 answers live. Speculative decoding spends extra compute (draft passes + a larger verification matmul) to buy back latency on memory-bound decode. At batch size 1, decode is deeply memory-bound, compute is idle, and you cash in — 2-3x TPOT improvement is routine. But continuous batching also fills idle compute, by stacking many independent sequences into one matmul. As batch size climbs, decode shifts from memory-bound toward compute-bound; the idle FLOPs speculative decoding was monetizing no longer exist. Now the extra draft and verification compute is pure overhead, and you can lose throughput.

The practical regime map: speculative decoding is a latency optimization (minimize TTFT-adjacent and TPOT for a single user), continuous batching is a throughput optimization (maximize tokens/sec/GPU across users). They trade off. High-end serving stacks gate speculation dynamically — enable it when the running batch is small (interactive, bursty, or latency-SLO traffic) and disable it under heavy batched load. The crossover is hardware- and model-specific but commonly lands somewhere in the low-tens of concurrent sequences; you measure it, you don't guess it. Other failure modes: tokenizer or vocab mismatch between draft and target (silent corruption or zero acceptance), KV cache for the draft eating your memory budget, and acceptance rate that looks great on your eval prompts but cratering on out-of-distribution production traffic.

6. How it's asked

[IC5] Why does speculative decoding give a speedup at all, given the target does the same total FLOPs verifying as generating? Because decode is memory-bound, not compute-bound. A target forward pass over 1 token and over k tokens both cost roughly one full weight read from HBM, which dominates wall-clock time; the extra FLOPs for k tokens run on otherwise-idle compute units. So verifying k drafted tokens in parallel costs about the same latency as generating one token sequentially. You're converting idle compute into accepted tokens; the "same total FLOPs" framing misses that FLOPs weren't the bottleneck.
[IC5] Draft hits 70% acceptance, k=5. Rough speedup, and what kills it? Expected tokens per target pass is (1 - 0.7^6)/(1 - 0.7) ≈ 2.9. Net of draft overhead (cheap draft, ~10% added) you land near 2.5-2.7x on TPOT. What kills it: (1) acceptance falling on out-of-distribution traffic — it's inside an exponent, so a drop from 0.7 to 0.5 roughly halves the gain; (2) running under high batch load, where continuous batching already saturates compute and the extra draft work becomes overhead; (3) a draft that's too large, inflating c and per-step latency.
[IC5] Prove it doesn't change the output distribution. At each position the rule accepts the drafted token x with probability min(1, p(x)/q(x)) and on rejection resamples from the normalized residual max(0, p(x) - q(x)). Decompose the probability of finally emitting any token y: the "accepted" mass is q(y)·min(1, p(y)/q(y)) = min(q(y), p(y)), and the "rejected then resampled" mass works out to p(y) - min(q(y), p(y)). Summed, that's exactly p(y). So the emitted token is distributed as p, identically to plain sampling — the draft only affects how often you accept, never what distribution you sample from.
[IC6] It helps at batch 1 but slows your fleet at batch 64. Explain and fix. At batch 1, decode is memory-bound; speculation monetizes idle compute. At batch 64, continuous batching has already filled that idle compute by stacking sequences into a wide matmul — decode is now compute-bound. Speculative decoding's extra draft passes and larger verification matmul add real compute that no longer overlaps with free cycles, so you lose throughput. Fix: make speculation adaptive on running batch size — enable below an empirically measured crossover, disable above it — and route latency-SLO traffic to speculation-enabled replicas while bulk/batch traffic goes to throughput-optimized replicas. EAGLE's dynamic draft length (shrink k as batch grows) is a softer version of the same idea.
[IC6] Separate draft vs EAGLE vs n-gram — how do you choose? Driven by workload and operational constraints. N-gram/prompt-lookup first if traffic is repetitive (code, structured output, RAG quoting context) — zero training, zero HBM, and it can beat model-based drafts on those distributions. For general chat, EAGLE-3 is the production default: a single feature-level head gives ~70-80% flat acceptance for tiny HBM cost and no tokenizer-matching headache, and it ships in vLLM/SGLang/TensorRT-LLM. A separate draft model is justified only when you already have a high-quality small sibling with the same tokenizer and HBM to spare. Whatever you pick, validate acceptance on production-representative traffic, not curated evals — that's where the speedup actually lives or dies.

7. Pitfalls & flashcards

  • Measuring acceptance on the wrong data. Curated eval prompts overstate acceptance; production OOD traffic is what determines real speedup. Always validate alpha on representative traffic.
  • Leaving speculation on under load. Without batch-aware gating you'll burn throughput at high concurrency. Speculation is a latency tool, batching is a throughput tool; they fight.
  • Resampling from p instead of the residual on rejection. This silently biases the output — it's no longer lossless. The correction must be max(0, p - q) normalized.
  • Over-long drafts. Tokens at draft position i survive with probability ~alpha^i; past k ≈ 4-8 you mostly pay draft cost for tokens that get rejected.
  • Tokenizer/vocab mismatch between draft and target produces garbage or zero acceptance — a common silent failure with hand-rolled draft pairs.
  • Forgetting the bonus token. The target's parallel pass gives you a free, correctly-distributed token after the last accepted position; not harvesting it leaves speedup on the table.

Flashcard. Speculative decoding is lossless and helps only when decode is memory-bound; expected tokens per target pass = (1 - alpha^(k+1))/(1 - alpha), so acceptance rate (inside an exponent) dominates draft length.

8. Further reading

Next: /inference/continuous-batching — the throughput lever that trades off against speculation, and why the two have to be co-scheduled.

Primary sources
← More in Inference, Serving & Scaling