Index Ai Academy
Phase 6 · Modern LLM engineering · Inference

Speculative decoding

Draft with a small model, verify with a big one.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Big language models write one token at a time, and each token takes a full run of the model. Speculative decoding makes this two to three times faster, with no change at all to what the model says. A small, cheap model guesses a few tokens ahead, and the big model checks the guesses in one go. Providers use it widely. It's also a good example of an idea you'll meet again in AI systems: let a cheap model do most of the work, and let an expensive one check it.

What you'll be able to do

  • Explain why checking several tokens costs about the same as generating one
  • Implement draft-and-verify decoding and confirm it gives exactly the large model's output
  • Show that the accept/reject rule preserves the large model's distribution when sampling
  • Judge what makes a good draft model and when speculation pays off

What it covers

  1. Checking is cheaper than writing
  2. Worked example: one round, word by word
  3. Trying it on a real model
  4. It works when sampling too
  5. Testing the rule 50,000 times
  6. Making it pay

Checking is cheaper than writing

Picture a shop assistant fetching items from a back storeroom. Each trip is slow because of the walk, not the carrying. Bringing back five items takes about as long as bringing back one.

A large model has the same problem. To write each new token, it does one forward pass: one full run of the model, from input to output. Each pass is slow mostly because it must read every weight from memory. That read is the walk to the storeroom. (You saw this in the lessons on the KV cache and on serving.)

Writing goes one token at a time. The model can't choose token 6 until it has chosen token 5. So a 100-token answer needs 100 slow passes.

Checking is different. One forward pass over a piece of text gives the model's prediction at every position at once. That's how training works: the model scores every next token in a sentence in a single pass. So if someone hands the model a guess for the next few tokens, it can check all of them in one pass. That costs about the same as writing one token.

Draft, then check

Speculative decoding turns this into a speed-up. Two teams published it in 2023: Leviathan and colleagues at Google, and Chen and colleagues at DeepMind. It uses two models:

  • a small, fast draft model that makes guesses;
  • the large target model, whose answers you actually want.

Let kk be the number of tokens the draft guesses each round. Each round has three steps.

  1. The draft proposes the next kk tokens. This is cheap.
  2. The target runs once on the text so far plus the kk guesses. That one pass gives its own pick for each position.
  3. Keep the guesses for as long as the target agrees. At the first disagreement, take the target's token instead and end the round. If every guess was right, the target's pass also gives one extra token for free.

So each target pass adds at least one token, and at most k+1k + 1.

Quick check
The draft guesses 4 tokens. The target agrees with the first 2 and disagrees with the third. How many tokens does this round add?
  1. 5
  2. 3
  3. 4
  4. 2
Show the answer

3

You keep the 2 matching guesses, plus the target's own token where the third guess was wrong. The fourth guess is thrown away.

Worked example

Worked example: one round, word by word

The text so far is "The mug". The draft guesses three words: is, made, of. The target runs once and gives its own pick after "The mug", after "The mug is", and so on: is, made, from, recycled. The first two match. The third doesn't, so the round keeps the target's from and stops. Predict the list before you run the code.

draft  = ["is", "made", "of"]                 # the draft's 3 guesses
target = ["is", "made", "from", "recycled"]   # the target's pick after each prefix
kept = []
for guess, choice in zip(draft + [None], target):
    kept.append(choice)
    if choice != guess:
        break
print(kept)
It runs right here in your browser, with nothing to install.

It prints ['is', 'made', 'from']: three words from one target pass. The draft's of was thrown away, and the target's from took its place. Now change the draft's last guess to "from" and run it again. All three guesses match, so you also keep the target's bonus word: four words from one pass.

Trying it on a real model

Now the real thing. The target is tiny-gpt-chat, the course's small chat model. The draft is about the cheapest model possible: a bigram table. For each word in the product catalog, it stores the word that most often comes next. (In Phase 5 you built a bigram model that works one letter at a time.)

Both models decode greedily: they always take the likeliest token. With greedy decoding, the draft-and-check rule must give exactly the same text as running the target alone. The code checks that. It also counts how many target passes each method needs, for kk = 2, 4 and 8. (torch here is the course's in-browser PyTorch subset.)

import requests
import torch
from tinygpt import load_pretrained

target, tok = load_pretrained("tiny-gpt-chat")             # the big model
end = tok.token_id("<end>")
data = tok.encode(requests.get("https://api.gradient.test/v1/corpus").json()["text"])
counts = torch.zeros(len(tok), len(tok))
for a, b in zip(data[:-1], data[1:]):
    counts[a, b] += 1
draft_next = counts.argmax(dim=-1)                          # the draft: a bigram table's most common next token

@torch.no_grad()
def greedy(prompt, n=60):
    ids, calls = list(prompt), 0
    while len(ids) - len(prompt) < n and ids[-1] != end:
        ids.append(int(target(torch.tensor([ids[-64:]]))[0, -1].argmax()))
        calls += 1
    return ids[len(prompt):], calls

@torch.no_grad()
def speculative(prompt, k, n=60):
    ids, calls = list(prompt), 0
    while len(ids) - len(prompt) < n and ids[-1] != end:
        draft = []
        for _ in range(k):                                  # 1. the draft guesses k tokens (cheap)
            draft.append(int(draft_next[(ids + draft)[-1]]))
        logits = target(torch.tensor([(ids + draft)[-64:]]))[0]
        calls += 1                                          # 2. one target pass checks all k at once
        choices = logits[-k - 1:].argmax(dim=-1).tolist()   #    the target's pick after each prefix
        for guess, choice in zip(draft + [None], choices):  # 3. keep guesses until the first mismatch,
            ids.append(choice)                              #    then take the target's own token
            if choice != guess or choice == end or len(ids) - len(prompt) >= n:
                break
    return ids[len(prompt):], calls

products = ["Cove camp mug", "Lark headlamp", "River sleeping bag", "Summit daypack", "Ember down vest"]
prompts = [tok.encode(f"<user> Describe the {p} . <assistant>") for p in products]
plain = [greedy(p) for p in prompts]
tokens = sum(len(t) for t, _ in plain)
print(f"plain greedy decoding: {tokens} tokens, {sum(c for _, c in plain)} target passes")
for k in (2, 4, 8):
    spec = [speculative(p, k) for p in prompts]
    same = all(s == t for (s, _), (t, _) in zip(spec, plain))
    calls = sum(c for _, c in spec)
    print(f"speculative, k = {k}:   identical output: {same}   {calls} target passes ({tokens / calls:.1f} tokens per pass)")

The text is identical, and the big model ran half as many times. Even a bigram table guesses the easy tokens correctly: "is made from", "It is", the period.

Longer drafts barely help here. This draft rarely gets more than one or two tokens right in a row, so most extra guesses get thrown away. A better draft would push the number of tokens per pass higher.

Quick check
When the draft's guesses are wrong, what does speculative decoding output?
  1. A mix of the draft's and the target's tokens
  2. The draft's tokens, slightly lower quality
  3. Nothing until the draft guesses correctly
  4. Exactly what the large model alone would have produced; wrong guesses only cost time
Show the answer

Exactly what the large model alone would have produced; wrong guesses only cost time

Every kept token is one the target agreed with. At a mismatch, the target's own token is used.

It works when sampling too

Real chat models don't always take the likeliest token. They sample: they pick at random, using the model's probabilities as weights. Now "the target agrees" gets fuzzy, because the target has no single pick to compare against.

The 2023 papers solved this with an accept-or-reject rule. It makes speculation give exactly the same mix of tokens as the target alone. Three symbols first:

  • xx is the token the draft proposed.
  • p(x)p(x) is the target's probability for that token.
  • q(x)q(x) is the draft's probability for the same token.

Accept the draft's token with this probability:

min⁡(1, p(x)q(x))\min\left(1,\ \frac{p(x)}{q(x)}\right)

Here min⁡\min means "the smaller of".

In plain words: if the target likes the token at least as much as the draft does, always keep it. If the draft liked it more than the target does, keep it only some of the time. The fraction p(x)/q(x)p(x)/q(x) says how often.

Here is an example with five words a model might use to describe a sleeping bag:

wordtarget ppdraft qq
light0.400.30
warm0.250.10
packable0.200.40
durable0.100.15
roomy0.050.05
  • The draft proposes "packable". The target gives it 0.20, the draft 0.40. 0.20 ÷ 0.40 = 0.5, so you keep it half the time.
  • The draft proposes "warm". The target gives it 0.25, the draft 0.10. 0.25 ÷ 0.10 = 2.5. The smaller of 1 and 2.5 is 1, so you always keep it.
p = {"light": 0.40, "warm": 0.25, "packable": 0.20, "durable": 0.10, "roomy": 0.05}
q = {"light": 0.30, "warm": 0.10, "packable": 0.40, "durable": 0.15, "roomy": 0.05}
for word in ["packable", "warm"]:
    print(word, min(1, p[word] / q[word]))

It prints packable 0.5 and warm 1.

When a guess is rejected

A rejected token needs a replacement. It comes from a leftover list: for each token, the part of the target's probability that the draft didn't cover.

max⁡(0, p−q)\max(0,\ p - q)

Here max⁡\max means "the larger of", so any negative result becomes 0. You then normalize the list: divide each number by the list's total, so the list adds up to 1 again.

In plain words: for each word, take how much more the target wants it than the draft offered. If the draft already offered too much, count zero. Scale the list so it adds up to 1, and pick the replacement from it.

With the five words:

  • light: 0.40 − 0.30 = 0.10
  • warm: 0.25 − 0.10 = 0.15
  • packable: 0.20 − 0.40 is below zero, so 0
  • durable: 0.10 − 0.15 is below zero, so 0
  • roomy: 0.05 − 0.05 = 0

The total is 0.25. Divide each number by 0.25: light becomes 0.4 and warm becomes 0.6. So a rejected token is replaced by "light" 40% of the time and "warm" 60% of the time. Those are the two words the draft under-rated.

p = {"light": 0.40, "warm": 0.25, "packable": 0.20, "durable": 0.10, "roomy": 0.05}
q = {"light": 0.30, "warm": 0.10, "packable": 0.40, "durable": 0.15, "roomy": 0.05}
leftover = {w: max(0, p[w] - q[w]) for w in p}
total = sum(leftover.values())
print(round(total, 2))
print({w: round(v / total, 2) for w, v in leftover.items()})

It prints 0.25, then {'light': 0.4, 'warm': 0.6, 'packable': 0.0, 'durable': 0.0, 'roomy': 0.0}.

Quick check
The target gives a token probability 0.1. The draft gave it 0.4. Predict what this prints: the chance the draft's token is kept.
print(min(1, 0.1 / 0.4))
  1. 0.1
  2. 1
  3. 4.0
  4. 0.25
Show the answer

0.25

0.1 ÷ 0.4 = 0.25, which is smaller than 1. The draft over-rated this token four times over, so it's kept only a quarter of the time.

Testing the rule 50,000 times

The code below runs the full rule 50,000 times on the same five words. It uses NumPy arrays instead of dictionaries. The draft is noticeably wrong: it over-rates "packable" and under-rates "warm". Compare the last column with the target's column.

import numpy as np

words = ["light", "warm", "packable", "durable", "roomy"]
p = np.array([0.40, 0.25, 0.20, 0.10, 0.05])      # the big (target) model's next-token probabilities
q = np.array([0.30, 0.10, 0.40, 0.15, 0.05])      # the small draft model's guess

rng = np.random.default_rng(0)
def speculative_token():
    x = rng.choice(5, p=q)                          # the draft proposes a token
    if rng.random() < min(1, p[x] / q[x]):          # accept with probability min(1, p/q)
        return x, True
    residual = np.maximum(p - q, 0)                 # otherwise resample from what the draft under-weighted
    return rng.choice(5, p=residual / residual.sum()), False

samples = [speculative_token() for _ in range(50_000)]
freq = np.bincount([s for s, _ in samples], minlength=5) / len(samples)
print(f"{'token':<10} {'target p':>8} {'draft q':>8} {'speculative':>12}")
for w, pi, qi, f in zip(words, p, q, freq):
    print(f"{w:<10} {pi:>8.2f} {qi:>8.2f} {f:>12.3f}")
print(f"\ndraft accepted {np.mean([a for _, a in samples]):.0%} of the time (theory: sum of min(p, q) = {np.minimum(p, q).sum():.0%})")

The last column closely matches the target's, even though every token started as a draft guess. The bad draft changed nothing about the output.

The draft's token was kept 75% of the time. That number is the overlap between the two lists of probabilities:

∑xmin⁡(p(x), q(x))\sum_x \min\big(p(x),\ q(x)\big)

The symbol ∑\sum (sigma) means "add up over every token xx".

In plain words: for each word, take the smaller of its two probabilities. Add those up. The more closely the draft imitates the target, the bigger the overlap.

For the five words: 0.30 + 0.10 + 0.20 + 0.10 + 0.05 = 0.75. In Python, the sum is a loop:

p = [0.40, 0.25, 0.20, 0.10, 0.05]
q = [0.30, 0.10, 0.40, 0.15, 0.05]
print(round(sum(min(a, b) for a, b in zip(p, q)), 2))

It prints 0.75. A bigger overlap means more guesses are kept, so each target pass yields more tokens.

Go deeper Why the output matches the target exactly

Work out the chance that the method outputs some token xx. There are two routes to it.

  1. The draft proposes xx (chance q(x)q(x)) and it's accepted (chance min⁡(1,p(x)/q(x))\min(1, p(x)/q(x))). Multiply the two and you get min⁡(q(x),p(x))\min(q(x), p(x)).
  2. The draft's token is rejected, and then xx is picked from the leftover list. Rejection happens with chance 1−∑min⁡(p,q)1 - \sum \min(p, q). The leftover list's total, before normalizing, is that same number: both lists add up to 1, so whatever isn't overlap is leftover. The two cancel, and this route gives max⁡(0,p(x)−q(x))\max(0, p(x) - q(x)).

Add the routes. If p(x)p(x) is bigger, you get q(x)+(p(x)−q(x))=p(x)q(x) + (p(x) - q(x)) = p(x). If q(x)q(x) is bigger, you get p(x)+0=p(x)p(x) + 0 = p(x). Either way, the chance of xx is exactly the target's.

Check it with "warm". Route 1 gives the smaller of 0.10 and 0.25, which is 0.10. Rejection happens with chance 1 − 0.75 = 0.25, and "warm" gets 0.6 of the leftovers, so route 2 gives 0.25 × 0.6 = 0.15. The total is 0.25: the target's probability for "warm".

Quick check
What does the accept-or-reject rule guarantee?
  1. The output has exactly the same distribution as the large model alone
  2. Every guess gets accepted
  3. The output is shorter
  4. The draft model's answers always win
Show the answer

The output has exactly the same distribution as the large model alone

The two routes to each token add up to the large model's own probability, so you get the speed at no cost in quality.

Making it pay

  • The draft must be much cheaper and fairly accurate. The draft is usually a small model from the same family as the target. It shares the target's tokenizer, the tool that splits text into tokens, so the guesses line up token for token. It's often distilled from the target: trained to imitate it.
  • Some methods skip the separate model. Medusa and EAGLE add small extra prediction heads to the target itself. Prompt lookup drafts by copying stretches of the prompt. That works well when the answer quotes its input: summaries, code edits, and RAG answers built from retrieved documents.
  • Speed-ups are typically 2–3× for large models. It depends on how predictable the text is. Code and structured output speed up more than creative writing.
  • It costs extra compute. Rejected guesses are wasted work, so speculation saves waiting time, not total work. When a server already runs huge batches, the GPU's arithmetic becomes the limit instead of memory. Then speculation helps less, so providers tune it to their traffic.
  • Quality doesn't change. Quantization trades a little quality for speed. Speculative decoding is lossless: by design, the output follows the target's probabilities exactly.

For marketers: this is one reason the same model can feel faster from one provider than another, or faster this month than last, without any change in its answers.

Try it now

The first rejected token ends the round, so acceptance rate matters far more than draft length. This is a simplified model of the speed-up.

Quick check
Which job is likely to get the biggest speed-up from speculative decoding?
  1. Writing an original poem
  2. Filling in a product template in a fixed format
  3. Neither: speculation only helps very short answers
  4. Both speed up equally, because quality is unchanged
Show the answer

Filling in a product template in a fixed format

Structured, predictable text lets the draft guess right more often, so each target pass keeps more tokens. A poem is much harder to guess.

Practice what you just read

  • 6 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Why can the target model check k drafted tokens in about the time it takes to generate one?

  1. One forward pass computes predictions at every position, and the cost is dominated by reading the weights once
  2. It skips the attention layers when checking, since the draft model has already worked out which tokens matter
  3. Checking only looks at the last token, since if that one is right, all the tokens before it must be right too
  4. The draft model does the checking, so the target model only has to read the result and accept or reject it

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson