Speculative decoding#
Track 01 - Inference · Notebook 07 · Runtime: ≈30 s on CPU
Prerequisites:
01_inference/01(KV cache).Papers:
Leviathan et al. 2022, Fast Inference from Transformers via Speculative Decoding (2211.17192).
Chen et al. 2023, Accelerating Large Language Model Decoding with Speculative Sampling (2302.01318).
What#
A small “draft” model proposes γ tokens. The expensive “target”
model verifies all γ in one forward. Tokens are accepted with
probability min(1, p_target(x) / q_draft(x)); on rejection, a
residual sample is drawn from the re-normalised difference
distribution. The target distribution is preserved exactly.
The acceptance ratio in one line: if the draft underestimated this
token (p_target > q_draft), the target likes it more than the draft
did — accept it. If the draft overestimated it (p_target < q_draft),
accept only with probability p_target / q_draft so the marginal output
distribution still equals the target’s. The residual sampler covers the
remaining mass exactly. This is what makes speculative sampling
distributionally lossless rather than a quality-throughput tradeoff.
Why it works: the target forward dominates the latency. Running it
once over γ+1 positions costs about the same as running it once on
a single position - you get extra tokens for free whenever the draft
agrees.
We simulate with stubbed logit distributions (no real LLM). Two numerical checks that actually matter:
Distributional equivalence. TV distance between the speculative output distribution and the target-only distribution is <0.05 (speculative decoding must not change the output distribution).
Empirical speedup matches the closed-form
E[tok/step] = (1 - α^(γ+1)) / (1 - α).
from llm_systems_cookbook.nb import bootstrap
from collections import Counter
import numpy as np
s = bootstrap("01_inference_07_speculative_decoding")
Target and draft distributions#
We work on a 20-word vocabulary. The target distribution is
handcrafted per-step (random but seeded). The draft distribution is
the target plus some noise - on average it agrees with the target
~70 % of the time (i.e. α ≈ 0.7).
VOCAB = 20
GAMMA = 4
def target_dist(rng: np.random.Generator) -> np.ndarray:
logits = rng.normal(0, 1.0, size=VOCAB)
probs = np.exp(logits - logits.max())
return probs / probs.sum()
def draft_dist(target: np.ndarray, rng: np.random.Generator, noise: float = 0.3) -> np.ndarray:
perturbation = rng.normal(0, noise, size=VOCAB)
logits = np.log(target + 1e-12) + perturbation
p = np.exp(logits - logits.max())
return p / p.sum()
Speculative sampling loop#
For each step:
Sample
γtokens from the draft.Compute target probs at all positions (in real life, one forward).
For each drafted token i, accept with
min(1, p_t(x)/q_d(x)).On the first rejection (or if all accepted), sample a residual token from
normalize(relu(p_t - q_d)).
accept_trials = 0
accept_hits = 0
def speculative_step(rng: np.random.Generator) -> tuple[list[int], int]:
'''Return the accepted tokens and how many were generated this step.'''
global accept_trials, accept_hits
target = target_dist(rng)
draft = draft_dist(target, rng)
accepted: list[int] = []
drafted = rng.choice(VOCAB, size=GAMMA, p=draft)
for x in drafted:
p_t = float(target[x])
q_d = float(draft[x])
u = rng.random()
accept_trials += 1
if u < p_t / max(q_d, 1e-12):
accept_hits += 1
accepted.append(int(x))
else:
residual = np.clip(target - draft, 0, None)
residual = residual / max(residual.sum(), 1e-12)
accepted.append(int(rng.choice(VOCAB, p=residual)))
return accepted, len(accepted)
accepted.append(int(rng.choice(VOCAB, p=target)))
return accepted, len(accepted)
def target_only_step(rng: np.random.Generator) -> int:
target = target_dist(rng)
return int(rng.choice(VOCAB, p=target))
rng_spec = np.random.default_rng(0)
rng_ref = np.random.default_rng(0)
N_STEPS = 5000
spec_tokens: list[int] = []
ref_tokens: list[int] = []
tokens_per_step: list[int] = []
for _ in range(N_STEPS):
toks, n = speculative_step(rng_spec)
spec_tokens.extend(toks)
tokens_per_step.append(n)
for _ in range(len(spec_tokens)):
ref_tokens.append(target_only_step(rng_ref))
avg_tok_per_step = float(np.mean(tokens_per_step))
print(f"avg accepted tokens per speculative step = {avg_tok_per_step:.3f}")
print(f"max γ+1 = {GAMMA + 1}")
Distributional equivalence#
TV distance between empirical speculative output distribution and target-only output distribution. Should be small.
def empirical(tokens: list[int]) -> np.ndarray:
c = Counter(tokens)
total = len(tokens)
return np.array([c.get(i, 0) / total for i in range(VOCAB)])
tv = 0.5 * np.abs(empirical(spec_tokens) - empirical(ref_tokens)).sum()
print(f"TV distance (spec vs target-only) = {tv:.3f}")
s.check(
"tv_distance_small",
lambda: tv < 0.10,
msg=f"TV = {tv:.3f}",
)
Empirical vs closed-form speedup#
For α ≈ 0.7 and γ = 4:
E[tok/step] = (1 - α^(γ+1)) / (1 - α)
≈ (1 - 0.7^5) / 0.3
≈ 2.76
# True alpha = fraction of per-token acceptance decisions that accepted.
alpha = accept_hits / max(accept_trials, 1)
closed_form = (1 - alpha ** (GAMMA + 1)) / (1 - alpha)
print(f"empirical alpha (direct) = {alpha:.3f}")
print(f"empirical tok/step = {avg_tok_per_step:.3f}")
print(f"closed-form E[tok/step] = {closed_form:.3f}")
print(f"error = {abs(avg_tok_per_step - closed_form) / closed_form:.1%}")
s.check(
"alpha_estimate_reasonable",
lambda: 0.4 <= alpha <= 0.9,
msg=f"alpha = {alpha:.3f}",
)
s.check(
"tok_per_step_above_one",
lambda: avg_tok_per_step > 1.5,
msg=f"{avg_tok_per_step:.3f}",
)
s.check(
"closed_form_matches_empirical",
lambda: abs(avg_tok_per_step - closed_form) / closed_form < 0.15,
msg=f"empirical={avg_tok_per_step:.3f} closed_form={closed_form:.3f}",
)
s.check(
"spec_never_exceeds_max_tokens",
lambda: max(tokens_per_step) <= GAMMA + 1,
msg=f"max tokens/step = {max(tokens_per_step)}",
)
Draft length vs expected speedup#
Plot E[tok/step] as a function of γ for a few acceptance rates α -
this is the closed-form (1 - α^(γ+1)) / (1 - α). Overlay the
empirical measurement for our (α, γ=4) point. The right panel shows
the wall-clock win if each target forward costs 10 ms and each draft
token 1 ms.
import matplotlib.pyplot as plt
gammas = list(range(1, 9))
alphas = [0.3, 0.5, 0.7, 0.9]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 3.4))
for a in alphas:
ax1.plot(gammas, [(1 - a ** (g + 1)) / (1 - a) for g in gammas],
"o-", label=f"α = {a}")
ax1.plot([GAMMA], [avg_tok_per_step], marker="*", markersize=14,
color="tab:red", linestyle="", label=f"empirical (α≈{alpha:.2f})")
ax1.set_xlabel("draft length γ")
ax1.set_ylabel("E[accepted tokens / step]")
ax1.set_title("closed-form accept curve + empirical")
ax1.legend(fontsize=8)
ax1.grid(True, alpha=0.3)
# Toy cost model: target forward = 10 ms, draft token = 1 ms.
t_target, t_draft = 0.010, 0.001
target_only = 1 / t_target
spec_rate = avg_tok_per_step / (t_target + GAMMA * t_draft)
ax2.bar(["target-only", f"speculative γ={GAMMA}"],
[target_only, spec_rate],
color=["tab:gray", "tab:blue"])
ax2.set_ylabel("tokens / sec (toy model)")
ax2.set_title(f"wall-clock speedup ≈ {spec_rate / target_only:.2f}x")
ax2.grid(True, axis="y", alpha=0.3)
fig.tight_layout()
plt.show()
Exercises#
Vary γ. Plot E[tok/step] vs γ for α ∈ {0.3, 0.5, 0.7, 0.9}. There’s a sweet spot: too-large γ wastes draft compute on likely rejections.
Quality of the draft. Reduce draft noise so α rises to 0.9; measure the speedup. The ceiling is γ+1 (you’d need a perfect draft, which defeats the purpose).
Real draft + target. Pair SmolLM2-135M as draft with Qwen2.5-1.5B as target via
transformers.generate+assistant_model. Measure real wall-clock speedup.
References#
Leviathan 2022 §3 for the derivation of the rejection rule.
Hugging Face transformers
assistant_modeldocs for the production two-model loop.
s.summary()
s.save()