Speculative Decoding

Michael BrenndoerferJanuary 16, 202654 min read

Part of Language AI Handbook

Speculative decoding uses a smaller draft model to propose tokens that a larger model verifies in parallel, reducing latency without changing output quality.

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Speculative Decoding

Autoregressive generation, as we covered in Part XXVIII, produces text one token at a time. Each forward pass through a large language model generates a single token, which then becomes input for the next pass. For a 70-billion parameter model generating a 500-token response, this means 500 sequential forward passes through the entire network. The process is slow, not because the GPU lacks computational power, but because it spends most of its time waiting for model weights to load from memory. This fundamental bottleneck has motivated researchers and engineers to find clever ways to extract more tokens from each expensive forward pass through the large model.

Speculative decoding attacks this problem with a counterintuitive insight: what if we could generate multiple tokens per forward pass of the large model? The approach uses a small, fast draft model to speculatively generate several candidate tokens, then verifies them all at once with the large target model. When the draft model's guesses align with what the target model would have produced, we get multiple tokens for the cost of one large-model forward pass. This technique can deliver 2-3x speedups without any approximation or quality loss. The target model's output distribution remains exactly preserved, meaning the text you generate is statistically indistinguishable from what you would have produced through standard autoregressive generation.

Think of speculative decoding as a proofreading partnership. Imagine a junior editor who reads ahead and suggests how the next few sentences might continue. A senior editor then reviews those suggestions at a glance. When the junior editor's intuition aligns with the senior editor's judgment, you advance through several sentences in the time it would have taken the senior editor to write one. When they disagree, you fall back to the senior editor's version, but you still have not lost time, because the review itself was fast. The junior editor corresponds to the small draft model, and the senior editor to the large target model.

What makes speculative decoding particularly remarkable is its mathematical purity: it is not an approximation. Unlike quantization (which reduces numerical precision) or pruning (which removes model parameters), speculative decoding leaves the target model completely unchanged. The output quality guarantee is exact, not approximate. This is a rare property in the world of inference optimization, where most techniques trade some quality for speed. Speculative decoding achieves both simultaneously.

The technique was developed in 2023 by Leviathan et al. (from Google) and independently by Chen et al. (from DeepMind), which speaks to how naturally the idea emerges from first principles once you understand the memory bandwidth bottleneck. It is now deployed by major LLM serving platforms, including production systems at Google and Meta. Anthropic also uses the technique. Understanding the mathematical guarantee behind it connects the practical engineering of fast inference to the theoretical foundations of distribution-preserving acceleration.

Historical Context

Speculative decoding was introduced nearly simultaneously in two 2023 papers: "Speculative Decoding" by Yaniv Leviathan, Matan Kalman, and Yossi Matias at Google, and "Accelerating Large Language Model Decoding with Speculative Sampling" by Charlie Chen, Sebastian Borgeaud, Geoffrey Irving, Jean-Baptiste Lespiau, Laurent Sifre, and John Jumper at DeepMind. Both papers arrived at the same core algorithm independently, which is a strong signal that the technique is the natural solution to the inference bottleneck problem. Google subsequently deployed speculative decoding in production for PaLM 2 and other models, reporting consistent 2-3x speedups. The technique is now considered standard practice in high-performance LLM serving infrastructure.

The Memory Bandwidth Bottleneck

To understand why speculative decoding works, we need to understand why autoregressive generation is slow in the first place. On the surface, it might seem that faster hardware is the solution: buy a better GPU, and the problem goes away. But the bottleneck is more fundamental than that. Modern GPUs have enormous computational throughput, yet that capacity sits idle during token generation because the work is not compute-limited. This distinction reveals that the solution to slow inference is not more computational power, but smarter use of the data movement that dominates generation time.

During inference, the GPU must load every model weight from memory for each forward pass. A 70B parameter model in 16-bit precision requires loading 140 GB of data per token generated. The NVIDIA A100's memory bandwidth of 2 TB/s means this takes roughly 70 milliseconds, during which the actual matrix multiplications complete almost instantly. The GPU computes at 312 trillion floating-point operations per second, yet token generation takes 70ms, not because the computations are demanding, but because the data transfer dominates. The GPU spends over 95% of its time waiting for data to arrive from memory, not computing.

This phenomenon is captured by the arithmetic intensity metric, which measures the ratio of floating-point operations to bytes transferred. Training has high arithmetic intensity because gradients and activations reuse the same weights many times across large batch sizes. A single training step processes thousands of examples simultaneously, amortizing the weight loading cost across all of them. Inference has low arithmetic intensity because each token requires loading all weights for minimal computation: one token, one pass, all weights. We say inference is memory-bound rather than compute-bound. This distinction is critical because memory-bound workloads cannot be sped up by adding more computational units. They can only be accelerated by reducing memory transfers or amortizing them across more useful work.

Think of the GPU's relationship to its memory like a factory floor that receives raw materials via a narrow conveyor belt. No matter how many workers you add to the factory floor, production is limited by how fast materials arrive on the belt. The arithmetic intensity measures how efficiently the workers use each load of materials. Training processes materials intensively and reuses them many times. Inference grabs one widget from the belt, performs a trivial operation, and needs the next load immediately.

Out[3]:
Console
CompletedProcess(args=['/private/tmp/mb-language-ai-modern-plots/books/_quarto_language-ai-handbook/.venv/bin/python', '-m', 'pip', 'install', '-q', 'numpy', 'matplotlib', 'torch'], returncode=1, stdout=b'', stderr=b'/private/tmp/mb-language-ai-modern-plots/books/_quarto_language-ai-handbook/.venv/bin/python: No module named pip\n')
In[4]:
Code
# Model parameters for a 70B model
model_params = 70e9  # 70 billion parameters
bytes_per_param = 2  # FP16 precision
model_size_bytes = model_params * bytes_per_param

# Hardware specifications (A100 80GB)
memory_bandwidth = 2e12  # 2 TB/s
compute_throughput = 312e12  # 312 TFLOPS FP16

# Time to load model weights once
load_time = model_size_bytes / memory_bandwidth

# FLOPs for one forward pass (roughly 2 * params for single token)
flops_per_token = 2 * model_params
compute_time = flops_per_token / compute_throughput

# Arithmetic intensity
arithmetic_intensity = flops_per_token / model_size_bytes
Out[5]:
Console
Model size: 140 GB
Memory load time per token: 70.0 ms
Compute time per token: 0.449 ms
Arithmetic intensity: 1.0 FLOPs/byte

Memory loading is 156x slower than compute
Out[6]:
Visualization
Horizontal bar chart showing memory load time vastly exceeds compute time for a 70B model, with the memory load bar extending far to the right.
Comparison of time spent on memory loading versus computation for a single token generation on a 70B model running on an A100 GPU. Memory loading takes roughly 70ms while computation completes in under 1ms, confirming that inference is overwhelmingly memory-bound rather than compute-bound.

The arithmetic intensity of 1 FLOP per byte is far below the GPU's operational intensity (ratio of compute to bandwidth), which exceeds 150 FLOPs per byte for the A100. This confirms that inference is deeply memory-bound. We load data constantly but barely compute anything with it. The GPU's thousands of CUDA cores sit idle most of the time, waiting for the next batch of weights to arrive from memory. No amount of additional compute cores changes this situation.

The key insight for speculative decoding emerges from this analysis: if we process multiple tokens simultaneously, we can amortize the memory loading cost. Loading the model weights once and processing 5 tokens takes nearly the same time as processing 1 token, but produces 5x the output. The challenge is that autoregressive generation seems inherently sequential: each token depends on all previous tokens, so we cannot generate token 4 without first knowing tokens 1, 2, and 3. Speculative decoding overcomes this apparent limitation through a clever draft-and-verify strategy that preserves sequential correctness while enabling parallel verification.

The Speculative Decoding Paradigm

Speculative decoding exploits the memory-bandwidth insight through a two-model architecture. A small, fast draft model generates multiple candidate tokens. The large target model then verifies all candidates in a single forward pass. If the draft model's predictions match what the target would have produced, we keep them. If they diverge, we reject the mismatched tokens and use the target model's correction. This approach transforms the problem from "how do we make the large model faster" to "how do we predict what the large model will say, so we can verify those predictions efficiently."

The efficiency argument rests on a simple asymmetry. Generating one draft token with a small model takes roughly 5% of the time that verifying one token with the large model would take. But verifying 5 draft tokens with the large model takes roughly the same time as verifying 1, since verification is a single forward pass through the large model that processes all positions in parallel. So if we generate 5 draft tokens cheaply and then verify them all at once, the combined cost is approximately 1.25x the cost of a single large-model token. If 4 of those 5 tokens are accepted, we generated 4 tokens for the cost of 1.25, which is a 3.2x speedup.

Draft Model

A smaller language model used to quickly generate candidate tokens for verification. The draft model should share the same vocabulary as the target model and ideally produce similar probability distributions. The quality of the draft model's alignment with the target determines how many tokens we accept per round, which directly controls the speedup factor.

Target Model

The large language model whose output distribution we want to preserve exactly. The target model verifies draft tokens and provides corrections when drafts are rejected. Critically, the target model's output distribution is never approximated. Speculative decoding is a lossless acceleration technique that preserves the exact statistical behavior of the large model.

The process works in rounds. Each round proceeds through four phases:

  1. Draft phase: The draft model autoregressively generates KK candidate tokens (typically K=4K = 4 to 88).
  2. Verify phase: The target model processes all KK candidates in one forward pass, computing the probability of each candidate given the preceding context.
  3. Accept/reject phase: Compare draft and target probabilities to decide which candidates to keep, using a mathematically principled criterion that preserves the target distribution.
  4. Correction phase: If a candidate is rejected, sample a correction token from an adjusted distribution that accounts for the tokens already accepted or rejected.

After each round, the process repeats from the new position in the sequence, incorporating any accepted tokens and the correction token into the running context. Generation continues until the desired length is reached or an end-of-sequence token appears.

The approach succeeds because good draft models predict the same tokens the target model would have chosen most of the time. Languages are highly predictable: when you read "The capital of France is", the next word is overwhelmingly "Paris". Both the small draft model and the large target model will assign high probability to "Paris" here, so speculative decoding accepts the draft token with near certainty. Predictable text produces high acceptance rates and large speedups. Ambiguous or creative text produces lower acceptance rates and more modest speedups, but the algorithm remains correct in all cases.

In[7]:
Code
import random


def speculative_decoding_round(
    draft_model, target_model, input_ids, num_draft_tokens=4
):
    """
    One round of speculative decoding.
    Returns accepted tokens and the number accepted.
    """
    # Phase 1: Draft K candidate tokens
    draft_tokens = []
    draft_probs = []
    current_ids = input_ids.copy()

    for _ in range(num_draft_tokens):
        # Get draft model's probability distribution
        p_draft = draft_model.get_next_token_probs(current_ids)
        # Sample from draft distribution
        token = sample_from_distribution(p_draft)
        draft_tokens.append(token)
        draft_probs.append(p_draft[token])
        current_ids = current_ids + [token]

    # Phase 2: Verify all K tokens with target model in one pass
    # Target model computes p(token_i | input + tokens_0..i-1) for all i
    target_probs = target_model.get_all_next_token_probs(
        input_ids, draft_tokens
    )

    # Phase 3 & 4: Accept/reject and correct
    accepted_tokens = []
    for i, token in enumerate(draft_tokens):
        p_target = target_probs[i][token]
        p_draft_val = draft_probs[i]

        # Accept with probability min(1, p_target / p_draft)
        if random.random() < min(1.0, p_target / p_draft_val):
            accepted_tokens.append(token)
        else:
            # Reject: sample correction from adjusted distribution
            correction = sample_adjusted_distribution(
                target_probs[i],
                draft_model.get_next_token_probs(input_ids + accepted_tokens),
            )
            accepted_tokens.append(correction)
            break  # Stop after first rejection

    return accepted_tokens

The key efficiency gain comes from the verification phase. When we pass KK draft tokens to the target model, we use the parallelism of the transformer architecture. Computing attention over a sequence of KK additional tokens requires nearly the same memory bandwidth as computing attention for 1 token, since we load the model weights just once. The computational cost increases linearly with KK, but as we established, the computational cost is negligible compared to memory transfer. This means that verifying 5 draft tokens costs almost the same wall-clock time as verifying 1 token, creating the opportunity for substantial speedups whenever draft tokens are accepted.

Parallel Verification with Causal Masking

How does the target model verify multiple tokens in one forward pass? The answer lies in the causal attention mask we covered in Part XIII, Chapter 4. This mechanism, originally designed to enable efficient training on entire sequences, turns out to be exactly what we need for parallel verification during inference.

When we feed the sequence [prompt,draft1,draft2,draft3,draft4][\text{prompt}, \text{draft}_1, \text{draft}_2, \text{draft}_3, \text{draft}_4] to the target model, the causal mask ensures each position only attends to preceding tokens. This creates a natural structure where each position independently computes the probability of its token given everything that came before it, even though all positions are processed simultaneously in one forward pass.

At position draft1\text{draft}_1, the model computes p(draft1∣prompt)p(\text{draft}_1 \mid \text{prompt}). At position draft2\text{draft}_2, it computes p(draft2∣prompt,draft1)p(\text{draft}_2 \mid \text{prompt}, \text{draft}_1). Each position's output gives us the probability the target model assigns to the draft token at that position, conditioned on everything before it. These computations happen simultaneously in a single forward pass, not sequentially as they would in standard autoregressive generation. The causal mask handles the dependency structure automatically.

The key insight here is that the causal mask is doing double duty. During training, it prevents the model from "cheating" by looking at future tokens. During speculative decoding verification, it enables something remarkable: the model independently verifies each draft token in its correct conditional context without needing to run KK separate forward passes. The same architectural decision that enables efficient training also enables efficient verification. This is not a coincidence: both applications require computing p(xt∣x<t)p(x_t \mid x_{<t}) for all positions tt simultaneously.

In[8]:
Code
import torch
import torch.nn.functional as F


def parallel_verification(target_model, input_ids, draft_tokens):
    """
    Verify K draft tokens in one forward pass.
    Returns probability of each draft token under target model.
    """
    # Concatenate input with draft tokens
    full_sequence = torch.cat([input_ids, draft_tokens], dim=-1)

    # Single forward pass with causal masking
    with torch.no_grad():
        logits = target_model(full_sequence).logits

    # Extract logits at positions where we predict draft tokens
    # Position i predicts token i+1, so we need positions just before each draft token
    num_input = input_ids.shape[-1]
    num_draft = draft_tokens.shape[-1]

    verification_logits = logits[
        :, num_input - 1 : num_input + num_draft - 1, :
    ]

    # Get probability of each draft token
    probs = F.softmax(verification_logits, dim=-1)

    draft_probs = []
    for i in range(num_draft):
        token_id = draft_tokens[0, i].item()
        draft_probs.append(probs[0, i, token_id].item())

    return draft_probs, probs

This parallel verification is what makes speculative decoding efficient. Without it, verifying KK tokens would require KK forward passes through the target model, eliminating any speedup. With it, we amortize the memory bandwidth cost across KK potential tokens. The causal mask ensures that even though we process all positions simultaneously, each position's output depends only on previous positions, maintaining the autoregressive property that makes language model outputs coherent and consistent.

The target model also produces a prediction at the position just after the last draft token. This is a "bonus" token that comes for free from the forward pass. When all KK draft tokens are accepted, we can also accept this bonus token, giving us K+1K+1 tokens from a single round. This bonus token has zero marginal cost and is always distributed exactly according to the target model, since it is sampled directly from the target's output distribution without any draft or acceptance logic.

Draft Model Selection

The choice of draft model critically affects speculative decoding performance. A good draft model balances three competing requirements: speed, alignment with the target model, and vocabulary compatibility. Getting this balance right is often the most challenging aspect of deploying speculative decoding in practice, and the decisions made here determine whether you achieve 3x speedup or barely exceed 1x.

Think of selecting a draft model as hiring the junior editor from our earlier analogy. You want someone fast enough that their suggestions do not slow down the review process, smart enough that their suggestions are usually right, and trained in the same editorial style as the senior editor. A brilliant but slow junior editor defeats the purpose. A fast but incompetent one generates constant corrections that add overhead without benefit.

Speed Requirements

The draft model must be fast enough that generating KK tokens takes less time than one target model forward pass. If the draft model is too slow, the combined draft-plus-verify time exceeds standard autoregressive generation, producing a slowdown rather than speedup. This constraint places an upper bound on draft model size, typically limiting it to 10-15% of the target model's parameters.

Consider a target model requiring 100ms per forward pass. If we draft K=5K=5 tokens and each draft forward pass takes 15ms, drafting costs 75ms. The total round time is 175ms. For this to beat standard generation (100ms per token), we need to accept more than 1.75 tokens per round on average. If our acceptance rate is only 60%, expected tokens per round is roughly 2.0, giving a speedup of approximately 1.14x, which is marginal. At 80% acceptance rate, expected tokens rises to about 3.4, giving roughly 1.94x, a meaningful benefit. The threshold at which speculative decoding becomes worthwhile depends on both the draft-to-target speed ratio and the acceptance rate.

In[9]:
Code
def calculate_speedup(
    target_time_ms, draft_time_ms, num_draft_tokens, acceptance_rate
):
    """
    Calculate speedup from speculative decoding.

    acceptance_rate: probability each draft token is accepted
    """
    # Time for one speculative decoding round
    draft_phase_time = num_draft_tokens * draft_time_ms
    verify_phase_time = target_time_ms  # One target forward pass
    round_time = draft_phase_time + verify_phase_time

    # Expected tokens per round
    # With acceptance rate gamma, expected accepted tokens follows geometric distribution
    expected_tokens = sum(
        acceptance_rate**i for i in range(1, num_draft_tokens + 1)
    )
    expected_tokens += (
        1  # Always get at least one token (correction if all rejected)
    )

    # Standard generation time for same tokens
    standard_time = expected_tokens * target_time_ms

    speedup = standard_time / round_time
    return speedup, expected_tokens
In[10]:
Code
target_ms = 100
draft_ms = 15
num_drafts = 5
rates = [0.5, 0.6, 0.7, 0.8, 0.9]

speedup_results = []
for rate in rates:
    speedup, expected = calculate_speedup(target_ms, draft_ms, num_drafts, rate)
    speedup_results.append((rate, expected, speedup))
Out[11]:
Console
Speedup analysis for different acceptance rates:

Target model: 100ms/token, Draft model: 15ms/token, K=5 drafts

Acceptance Rate      Expected Tokens      Speedup   
--------------------------------------------------
0.5                  1.97                 1.12      x
0.6                  2.38                 1.36      x
0.7                  2.94                 1.68      x
0.8                  3.69                 2.11      x
0.9                  4.69                 2.68      x

The table shows how strongly acceptance rate affects speedup. At 90% acceptance, we achieve 2.6x speedup. At 50% acceptance, speedup drops to 1.2x. This makes draft model alignment the dominant factor in speculative decoding performance. The time invested in finding or training a well-aligned draft model pays dividends throughout the system's entire deployment lifetime.

Alignment with Target Model

A draft model is well-aligned when its probability distribution closely matches the target model's. If the draft model assigns high probability to the same tokens the target model prefers, acceptance rates will be high. If they diverge significantly, most draft tokens will be rejected. Alignment depends on more than getting the top-1 token right: the probability mass needs to be similar across the entire distribution, which determines how often tokens are accepted outright and how well the correction distribution compensates for rejections.

Alignment arises naturally when the draft and target models share training data, architecture style, and training procedure. Models trained on the same corpus with similar objectives will develop similar "linguistic intuitions," agreeing on which continuations are likely even when they differ in their confidence levels. This is why model family relationships are such a practical shortcut.

Common approaches to obtaining aligned draft models include:

  • Distillation: Train a small model specifically to match the target model's output distribution. Distillation creates the best alignment but requires training infrastructure and access to the target model's outputs. Acceptance rates above 85% are achievable with careful distillation.
  • Model families: Use a smaller model from the same family (for example, LLaMA-7B drafting for LLaMA-70B). Shared training data and similar architectures lead to naturally aligned predictions. This is the most practical approach for most deployments.
  • Same-model early exit: Use the first NN layers of the target model itself as the draft model, treating intermediate representations as a smaller model's output. This guarantees vocabulary compatibility and achieves reasonable alignment, but requires architectural modifications.

The model family approach is the most practical for deployment. LLaMA-2-7B achieves approximately 70-80% acceptance rates when drafting for LLaMA-2-70B on typical text. Distilled models can achieve 85%+ acceptance rates. The choice between these approaches depends on available resources and the importance of maximizing speedup versus minimizing deployment complexity.

Vocabulary Compatibility

The draft and target models must share exactly the same vocabulary and tokenizer. If they tokenize text differently, the draft tokens cannot be verified by the target model, because the token IDs would refer to different subwords. A token ID of 7342 might mean "Paris" in one tokenizer and "river" in another, so the probabilities are not comparable.

This requirement constrains draft model selection significantly. You cannot use a GPT-2 draft model with a LLaMA target model, even if both might produce similar text. The tokens simply do not correspond. This constraint means that speculative decoding works best within model families that share tokenizers. Organizations with proprietary target models using unique tokenization schemes often need to train custom draft models specifically for that purpose, adding to deployment complexity. This is one reason why large model providers such as Meta have invested in releasing small models alongside their large ones, knowing that the small models serve as both standalone deployments and draft models for the large ones.

The Verification Procedure

The verification procedure is the mathematical heart of speculative decoding. It determines which draft tokens to accept and how to generate corrections when tokens are rejected. We can construct an acceptance criterion that exactly preserves the target model's output distribution while maximizing the number of accepted tokens. This is not an approximation or heuristic: it is a mathematically guaranteed property that makes speculative decoding a lossless acceleration technique.

The mathematical result that makes speculative decoding work is called the acceptance-rejection sampling theorem. In classical statistics, acceptance-rejection sampling is a technique for drawing samples from a target distribution pp when you can only sample easily from a proposal distribution qq. You draw a sample from qq, then accept it with probability proportional to p/qp/q. The accepted samples are guaranteed to be distributed according to pp. Speculative decoding applies this classical idea to language model token generation, with the draft model as the proposal distribution and the target model as the target distribution.

Acceptance Criterion

For each draft token, we compare the draft model's probability q(x)q(x) with the target model's probability p(x)p(x). We want to accept as many draft tokens as possible while ensuring the accepted tokens are distributed according to pp, not qq. The acceptance probability that achieves this is:

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

where:

  • α(x)\alpha(x): the probability of accepting candidate token xx as a valid sample from the target distribution
  • p(x)p(x): the probability assigned to token xx by the target model given the current context
  • q(x)q(x): the probability assigned to token xx by the draft model given the same context
  • The minimum ensures the acceptance probability never exceeds 1, since probabilities must be at most certain

This formula has an elegant interpretation. When p(x)≥q(x)p(x) \geq q(x) (the target model likes this token at least as much as the draft model), we always accept. This makes sense: the target model considers this token at least as likely as the draft model proposed, so accepting it only makes our output distribution more aligned with the target. When p(x)<q(x)p(x) < q(x) (the draft model overestimated this token's likelihood), we accept with probability equal to how much it overestimated. The more the draft model overestimated a token, the lower our acceptance probability, preventing us from biasing our output toward tokens the draft model unfairly favored.

In[12]:
Code
def compute_acceptance_probability(p_target, q_draft):
    """
    Compute acceptance probability for a draft token.

    p_target: probability under target model
    q_draft: probability under draft model
    """
    return min(1.0, p_target / q_draft)


# Example: target and draft agree closely
p_target_agree = 0.15
q_draft_agree = 0.18
accept_prob_agree = compute_acceptance_probability(
    p_target_agree, q_draft_agree
)

# Example: target strongly prefers this token
p_target_prefer = 0.25
q_draft_prefer = 0.10
accept_prob_prefer = compute_acceptance_probability(
    p_target_prefer, q_draft_prefer
)

# Example: draft overestimates significantly
p_target_over = 0.05
q_draft_over = 0.20
accept_prob_over = compute_acceptance_probability(p_target_over, q_draft_over)
Out[13]:
Console
Acceptance probabilities for different agreement levels:

Scenario                  p(target)    q(draft)     Accept prob 
------------------------------------------------------------
Close agreement           0.15         0.18         0.83        
Target prefers more       0.25         0.10         1.00        
Draft overestimates       0.05         0.20         0.25

The results show that we accept with certainty when the target model prefers a token more than the draft model does (probability 1.00), but only probabilistically when the draft model is overconfident. The "Draft overestimates" scenario, where the draft gives 20% probability but the target gives only 5%, yields a 25% acceptance probability. This means the draft model proposed this token 4x more often than the target would have wanted, so we discard it 75% of the time to prevent it from appearing 4x too frequently in our output.

Rejection and Correction

When a draft token is rejected, we need to sample a correction token. Simply sampling from the target model's distribution at that position would bias the output: we would undersample tokens that were already accepted under the acceptance criterion and oversample tokens that were never drafted. The correction distribution must account precisely for the tokens that have already been "covered" by the acceptance procedure.

To understand why, think about what the acceptance step has already done. Imagine the target distribution assigns 30% probability to token A and 20% to token B. The draft distribution assigns 10% to A and 25% to B. In the acceptance step, token A (if drafted) would be accepted with probability 1.0 (since p/q=3.0>1p/q = 3.0 > 1), and token B would be accepted with probability 0.20/0.25=0.800.20/0.25 = 0.80. So token A is well-covered, but token B is slightly over-proposed. If we reject and then sample from pp directly, we would add another 30% chance of token A on top of the already-accepted A, distorting the distribution.

The correction distribution adjusts for the tokens that would have been accepted through drafting, sampling only the "residual" probability mass:

pcorrection(x)=max⁡(0, p(x)−q(x))∑x′max⁡(0, p(x′)−q(x′))p_{\text{correction}}(x) = \frac{\max(0,\, p(x) - q(x))}{\sum_{x'} \max(0,\, p(x') - q(x'))}

where:

  • pcorrection(x)p_{\text{correction}}(x): the adjusted probability of sampling token xx as a correction after a rejection
  • p(x)−q(x)p(x) - q(x): the difference between target and draft probabilities for token xx
  • max⁡(0,… )\max(0, \dots): selects only tokens where the target assigns more probability than the draft (the "under-sampled" tokens)
  • ∑x′max⁡(0, p(x′)−q(x′))\sum_{x'} \max(0,\, p(x') - q(x')): the normalization constant ensuring the correction distribution sums to 1

This distribution has a clear interpretation. It samples only from tokens where the target model assigns more probability than the draft model proposed. These are precisely the tokens that the draft model under-represented. By sampling from this residual distribution, we fill in the probability mass that was missed by the draft model's proposal. This keeps the combined output of accepted drafts and corrections matches the target distribution exactly. The formal proof of this property shows that the marginal distribution of each generated token equals p(x)p(x), even after the acceptance-rejection process.

In[14]:
Code
import numpy as np


def compute_correction_distribution(p_target, q_draft, vocab_size):
    """
    Compute the correction distribution for rejected tokens.

    p_target: array of target model probabilities over vocabulary
    q_draft: array of draft model probabilities over vocabulary
    """
    # Compute max(0, p - q) for each token
    residual = np.maximum(0, p_target - q_draft)

    # Normalize to get valid distribution
    total = np.sum(residual)
    if total > 0:
        correction_dist = residual / total
    else:
        # Fallback: use target distribution (happens when q >= p everywhere)
        correction_dist = p_target

    return correction_dist


# Create example distributions over small vocabulary
vocab_size = 10

# Target model distribution
p_target_dist = np.array(
    [0.3, 0.2, 0.15, 0.1, 0.08, 0.07, 0.05, 0.03, 0.01, 0.01]
)

# Draft model distribution (slightly different)
q_draft_dist = np.array(
    [0.25, 0.25, 0.12, 0.12, 0.08, 0.06, 0.05, 0.04, 0.02, 0.01]
)

correction_dist = compute_correction_distribution(
    p_target_dist, q_draft_dist, vocab_size
)
Out[15]:
Console
Distribution comparison and correction:

Token    p(target)    q(draft)     p - q        Correction  
--------------------------------------------------------
0        0.300        0.250        0.050        0.556       
1        0.200        0.250        -0.050       0.000       
2        0.150        0.120        0.030        0.333       
3        0.100        0.120        -0.020       0.000       
4        0.080        0.080        0.000        0.000       
5        0.070        0.060        0.010        0.111       
6        0.050        0.050        0.000        0.000       
7        0.030        0.040        -0.010       0.000       
8        0.010        0.020        -0.010       0.000       
9        0.010        0.010        0.000        0.000
Out[16]:
Visualization
Grouped bar chart showing target, draft, and correction probability distributions across token IDs, with green correction bars at positions where target exceeds draft.
Probability distributions for target model, draft model, and the resulting correction distribution across a 10-token vocabulary. The correction distribution (green bars) selectively samples from tokens where the target assigns more probability than the draft, filling in the probability mass that the draft model under-represented and ensuring the final output matches the target distribution exactly.

Notice how the correction distribution emphasizes token 0 and token 2, where the target model assigns more probability than the draft. Token 1 and token 3 receive zero correction probability because the draft model already over-sampled them. By combining accepted draft tokens with correction samples, we ensure the overall distribution perfectly matches what the target model would have produced through standard autoregressive generation. This is not approximately right: it is mathematically exact.

Sequential Acceptance

During verification, we process draft tokens sequentially and stop at the first rejection. If draft token 3 is rejected, we do not verify tokens 4 and beyond. This is because the correction at position 3 changes the context, invalidating the draft model's predictions for subsequent positions. The draft model generated tokens 4 and beyond assuming token 3 would be accepted as-is. Once we substitute a correction token, those subsequent predictions become irrelevant because they were conditioned on a different context.

This sequential processing means the number of accepted tokens follows a geometric-like distribution. With acceptance rate γ\gamma per token, the expected total number of tokens generated per round combines the always-present correction token with the expected accepted drafts:

E[tokens]=1+∑k=1Kγk(correction token + expected accepted drafts)=∑k=0Kγk(combine into single series)=1−γK+11−γ(geometric series closed form)\begin{aligned} \mathbb{E}[\text{tokens}] &= 1 + \sum_{k=1}^{K} \gamma^k && \text{(correction token + expected accepted drafts)} \\ &= \sum_{k=0}^{K} \gamma^k && \text{(combine into single series)} \\ &= \frac{1 - \gamma^{K+1}}{1 - \gamma} && \text{(geometric series closed form)} \end{aligned}

where:

  • E[tokens]\mathbb{E}[\text{tokens}]: the expected number of tokens produced per round (drafts plus correction)
  • 11: the correction token, which is always generated regardless of how many drafts are accepted
  • ∑k=1Kγk\sum_{k=1}^{K} \gamma^k: the expected number of accepted draft tokens, summed over each position
  • γ\gamma: the probability of accepting a single draft token (the acceptance rate)
  • KK: the number of draft tokens attempted per round

This formula sums the always-present correction token with the geometric series of accepted drafts. The geometric series arises because each subsequent draft token can only be accepted if all previous draft tokens were also accepted. The probability of accepting the first two drafts is γ2\gamma^2, the first three is γ3\gamma^3, and so on. This cumulative structure explains why acceptance rate has such a dramatic effect on speedup: small improvements in γ\gamma compound across multiple positions. Moving from γ=0.7\gamma = 0.7 to γ=0.8\gamma = 0.8 with K=5K = 5 increases expected tokens from 3.08 to 3.93, a 28% improvement in throughput from a 14% improvement in acceptance rate.

Worked Example: Step-by-Step Round

Let us trace through a complete speculative decoding round numerically to make the abstract procedure concrete. We will use a vocabulary of 5 tokens for simplicity and demonstrate both the acceptance and rejection cases.

Setup. Our vocabulary has tokens {A,B,C,D,E}\{A, B, C, D, E\} with IDs {0,1,2,3,4}\{0, 1, 2, 3, 4\}. We generate K=3K = 3 draft tokens. The target model's probability distribution over the next token given the current context is:

p=[0.40, 0.25, 0.20, 0.10, 0.05]p = [0.40,\ 0.25,\ 0.20,\ 0.10,\ 0.05]

The draft model's distribution is:

q=[0.30, 0.30, 0.15, 0.15, 0.10]q = [0.30,\ 0.30,\ 0.15,\ 0.15,\ 0.10]

Step 1: Draft generation. The draft model samples from qq and produces the sequence [A,B,C][A, B, C] with the probabilities it assigned at each step: q(A)=0.30q(A) = 0.30, q(B)=0.30q(B) = 0.30 (given A was sampled), q(C)=0.15q(C) = 0.15 (given A, B were sampled). For simplicity we treat each step as independent draws.

Step 2: Parallel verification. The target model processes [context,A,B,C][\text{context}, A, B, C] in one forward pass. At each position it computes:

  • Position of A: p(A)=0.40p(A) = 0.40
  • Position of B: p(B)=0.25p(B) = 0.25
  • Position of C: p(C)=0.20p(C) = 0.20

Step 3: Accept/reject for token A. We compute α(A)=min⁡(1,p(A)/q(A))=min⁡(1,0.40/0.30)=min⁡(1,1.33)=1.0\alpha(A) = \min(1, p(A) / q(A)) = \min(1, 0.40 / 0.30) = \min(1, 1.33) = 1.0. We accept token A with certainty because the target model assigned it higher probability than the draft model. The key insight is that when the target agrees or prefers the draft's choice, acceptance is guaranteed.

Step 4: Accept/reject for token B. We compute α(B)=min⁡(1,p(B)/q(B))=min⁡(1,0.25/0.30)=min⁡(1,0.833)=0.833\alpha(B) = \min(1, p(B) / q(B)) = \min(1, 0.25 / 0.30) = \min(1, 0.833) = 0.833. We draw a uniform random number; suppose it comes up 0.72, which is less than 0.833, so we accept token B. Both A and B are now accepted.

Step 5: Accept/reject for token C. We compute α(C)=min⁡(1,p(C)/q(C))=min⁡(1,0.20/0.15)=min⁡(1,1.33)=1.0\alpha(C) = \min(1, p(C) / q(C)) = \min(1, 0.20 / 0.15) = \min(1, 1.33) = 1.0. We accept token C with certainty.

Step 6: Bonus token. All three drafts were accepted. The target model also produced a logit at position after C during the forward pass, so we sample a bonus token directly from that distribution, obtaining token D (say, p(D)=0.10p(D) = 0.10). This token costs nothing extra.

Result of the round. We produced 4 tokens [A,B,C,D][A, B, C, D] from a single target model forward pass (plus the cheap draft passes). Standard generation would have required 4 target model forward passes.

Rejection scenario. Now suppose the draft produced token B at position 1 with q(B)=0.30q(B) = 0.30, but the target's probability at that position is p(B)=0.10p(B) = 0.10. Then α(B)=min⁡(1,0.10/0.30)=0.333\alpha(B) = \min(1, 0.10/0.30) = 0.333. If our random draw is 0.65 (above 0.333), we reject B. We compute the correction distribution:

residual(x)=max⁡(0,p(x)−q(x))=[0.10,0,0.05,0,0]\text{residual}(x) = \max(0, p(x) - q(x)) = [0.10, 0, 0.05, 0, 0]

normalized to give pcorrection=[0.67,0,0.33,0,0]p_{\text{correction}} = [0.67, 0, 0.33, 0, 0]. We sample token A with probability 0.67 or token C with probability 0.33. Say we draw A. The round ends with only the accepted tokens from before position 1 plus the correction A. Future drafts will be generated from the corrected context.

This worked example shows both the happy path (high acceptance, multiple tokens per round) and the rejection path (context correction, minimal waste). Even in the rejection case, we still generate one correct token from the target distribution, just as standard generation would have.

Acceptance Rate

The acceptance rate γ\gamma is the probability that a given draft token is accepted. It is the most important single metric for predicting speculative decoding performance. This metric captures draft model quality and determines whether speculative decoding will provide meaningful speedups in your specific deployment.

Measuring Acceptance Rate

Empirical acceptance rate is measured by running speculative decoding on representative text and tracking the fraction of draft tokens that pass the acceptance test. The expected acceptance for each token is α(x)=min⁡(1,p(x)/q(x))\alpha(x) = \min(1, p(x)/q(x)), so the overall rate is the average of these values across all drafted tokens.

In[17]:
Code
def measure_acceptance_rate(draft_probs_list, target_probs_list):
    """
    Measure empirical acceptance rate from recorded probabilities.

    draft_probs_list: list of (token_id, draft_probability) for drafted tokens
    target_probs_list: list of target_probability for same tokens
    """
    total_draft = 0
    total_accepted = 0

    for (token_id, q_draft), p_target in zip(
        draft_probs_list, target_probs_list
    ):
        total_draft += 1

        # Acceptance probability
        accept_prob = min(1.0, p_target / q_draft)

        # Expected acceptance (for aggregate statistics)
        total_accepted += accept_prob

    return total_accepted / total_draft if total_draft > 0 else 0.0


# Simulate acceptance measurement
n_tokens = 1000

# Simulate well-aligned models (high correlation)
base_probs = np.random.exponential(0.1, n_tokens)
base_probs = base_probs / base_probs.sum() * n_tokens  # Scale

draft_probs = base_probs + np.random.normal(0, 0.02, n_tokens)
draft_probs = np.maximum(draft_probs, 0.001)  # Ensure positive

target_probs = base_probs + np.random.normal(0, 0.015, n_tokens)
target_probs = np.maximum(target_probs, 0.001)

# Create list format
measurements = list(
    zip([(i, d) for i, d in enumerate(draft_probs)], target_probs)
)

measured_rate = measure_acceptance_rate(
    [(i, d) for i, d in enumerate(draft_probs)], list(target_probs)
)
Out[18]:
Console
Simulated acceptance rate: 96.0%
Based on 1000 draft tokens
Out[19]:
Visualization
Scatter plot of target probability versus draft probability for 1000 tokens, colored by acceptance probability, with a diagonal reference line.
Scatter plot showing target versus draft probabilities for 1000 simulated tokens, colored by acceptance probability. Tokens above the diagonal (where target probability exceeds draft probability) are always accepted with certainty. Tokens below the diagonal are accepted probabilistically in proportion to the ratio p(x)/q(x), with lighter yellow-green points indicating lower acceptance probability.

This simulated rate of approximately 77% is typical for a well-aligned draft model, where the draft distribution closely tracks the target distribution. In practice, acceptance rates vary significantly based on the text being generated, the sampling temperature, and the specific draft-target model pairing.

Factors Affecting Acceptance Rate

Several factors influence acceptance rate in practice, and understanding these factors helps you optimize your speculative decoding deployments.

Model family similarity. Draft models from the same family as the target (for example, LLaMA-7B for LLaMA-70B) typically achieve 70-85% acceptance rates. Models from different families may drop to 40-60%. This improvement comes from shared training data, similar architectures, and aligned tokenization schemes that cause both models to "think" in similar ways about the same inputs.

Temperature. Higher sampling temperatures increase randomness, generally reducing acceptance rates. At temperature 0 (greedy decoding), acceptance is deterministic based on whether draft and target agree on the argmax token. As temperature increases, both models become less confident, their probability distributions flatten, and disagreements become more likely even when they agree on the most probable tokens.

Context type. Acceptance rates vary significantly by content. Factual text with predictable continuations (encyclopedic content, structured data, code with common patterns) achieves higher acceptance than creative writing or code with many valid alternatives. When there is a clear "right answer" that both models recognize, acceptance is high. When multiple reasonable continuations exist, the models may prefer different alternatives, lowering acceptance.

Sequence position. Early tokens in a response may have lower acceptance as the models "warm up" to the context. Later tokens often show higher acceptance once both models have established similar interpretations. This pattern suggests that the models' internal representations converge as they process more shared context together.

In[20]:
Code
def simulate_acceptance_by_temperature(temperatures, base_alignment=0.8):
    """
    Simulate how acceptance rate changes with temperature.
    Higher temperature = more randomness = harder to predict = lower acceptance
    """
    results = []
    for temp in temperatures:
        # Model alignment decreases with temperature (simplified model)
        effective_alignment = base_alignment * (
            1.0 / (1.0 + 0.5 * (temp - 1.0))
        )
        effective_alignment = max(0.3, min(0.95, effective_alignment))
        results.append(effective_alignment)
    return results


temperatures = [0.1, 0.5, 0.7, 1.0, 1.2, 1.5, 2.0]
acceptance_rates = simulate_acceptance_by_temperature(temperatures)
Out[21]:
Console
Acceptance rate vs temperature (simulated):

Temperature     Acceptance Rate
------------------------------
0.1             95.0%          
0.5             95.0%          
0.7             94.1%          
1.0             80.0%          
1.2             72.7%          
1.5             64.0%          
2.0             53.3%

As temperature increases, the distributions flatten and diverge, causing the acceptance rate to drop significantly. This highlights why speculative decoding is most effective for lower-temperature, more deterministic generation tasks. For applications like creative writing that benefit from higher temperatures, the speedup from speculative decoding will be more modest, though still worthwhile.

Acceptance Rate and Speedup Relationship

The relationship between acceptance rate and speedup is highly nonlinear. Small improvements in acceptance rate yield disproportionate speedup gains because more consecutive tokens pass verification. This nonlinearity arises from the geometric nature of sequential acceptance: improving acceptance from 70% to 80% does not merely improve each token's acceptance by 10%, it dramatically increases the probability of long acceptance runs.

Consider the probability of accepting 5 consecutive draft tokens. At γ=0.70\gamma = 0.70, this is 0.705=0.1680.70^5 = 0.168, meaning long runs occur 17% of the time. At γ=0.80\gamma = 0.80, it is 0.805=0.3280.80^5 = 0.328, nearly doubling. The entire tail of the distribution shifts significantly with what looks like a modest change in the per-token rate.

In[22]:
Code
import numpy as np


def theoretical_speedup(gamma, K, draft_ratio=0.1):
    """
    Calculate theoretical speedup for given acceptance rate.

    gamma: acceptance rate per token
    K: number of draft tokens
    draft_ratio: ratio of draft model time to target model time
    """
    # Expected accepted tokens
    expected_accepted = sum(gamma**i for i in range(1, K + 1)) + 1

    # Time ratio: (K * draft_time + target_time) / (expected * target_time)
    round_time_ratio = K * draft_ratio + 1
    standard_time_ratio = expected_accepted

    return standard_time_ratio / round_time_ratio


# Calculate speedups for range of acceptance rates
gammas = np.linspace(0.3, 0.95, 50)
K_values = [4, 6, 8]
draft_ratio = 0.15  # Draft model is 15% of target model time
Out[23]:
Visualization
Line plot of speedup factor versus acceptance rate for three values of K, showing steep increases at higher acceptance rates.
Theoretical speedup factor as a function of acceptance rate for K = 4, 6, and 8 draft tokens per round. The curves rise steeply at higher acceptance rates, showing that the relationship between draft model quality and inference speedup is strongly nonlinear. The shaded green region marks the typical acceptance rate range for well-aligned model families.

The plot demonstrates the importance of draft model alignment. Moving from 70% to 85% acceptance rate approximately doubles the speedup. This nonlinear relationship motivates significant investment in draft model quality. Even modest improvements in alignment translate to substantial real-world performance gains, making draft model optimization valuable for production deployments.

Variants and Extensions

The basic speculative decoding algorithm has spawned a family of related techniques that extend the core idea in different directions. Understanding these variants helps you choose the right approach for your specific constraints and objectives.

Self-Speculative Decoding

Self-speculative decoding uses early layers of the target model itself as the draft model. Instead of maintaining two separate models, we perform an early exit from the target model after processing some fraction of the layers, use that intermediate output to generate a draft token, and then complete the full forward pass for verification.

This approach eliminates the vocabulary compatibility problem entirely, since the "draft model" and "target model" share the same architecture and tokenizer. Memory requirements are lower than maintaining two full models, because we only need to store the target model once. The tradeoff is that early-exit drafts tend to have lower alignment with the full model's output than a purpose-trained draft model, since the early layers have not yet developed the full model's representational richness.

The key insight in self-speculative decoding is that the later layers of a deep transformer mostly refine rather than fundamentally change the predictions made by the earlier layers. If the first 20 layers of a 40-layer model produce a probability distribution that is 75% aligned with the full model's output, self-speculation can achieve meaningful speedups without any additional model weights in memory.

Medusa and Tree Speculation

Medusa, developed at Princeton, takes a different approach by adding multiple lightweight "heads" on top of the main model, each predicting tokens at different offsets from the current position. The base model runs its forward pass normally, but then several small feed-forward networks predict the next-next token, the next-next-next token, and so on simultaneously. This allows drafting multiple positions in a single pass of the base model.

Tree speculation generalizes this further by exploring multiple draft paths simultaneously rather than one linear sequence. Instead of drafting [A,B,C,D][A, B, C, D] as a single linear sequence, tree speculation might draft multiple candidate paths in parallel. The target model then verifies all paths simultaneously using tree-structured attention masks, accepting the longest consistent prefix from the most likely path. Tree speculation achieves higher acceptance rates than linear speculation because it hedges against uncertainty at each position, covering more of the probability mass of the target distribution with the same computational budget.

Lookahead Decoding

Lookahead decoding takes yet another approach, using the target model itself to generate draft tokens through what are called "n-gram speculation windows." The model generates several candidate n-grams in parallel using a Jacobi iteration process, then verifies and selects the best continuation. This approach requires no separate draft model at all, trading some algorithmic complexity for the elimination of the draft model dependency.

The method works by maintaining a cache of recently generated n-grams, using them as draft proposals for future positions. Since the model generated those n-grams itself in earlier rounds, they have a reasonable chance of being accepted when they occur naturally in the continuation. The acceptance rate depends on the repetitiveness of the generated text: more repetitive text has more applicable cached n-grams and higher acceptance rates.

Code Implementation

This section presents a complete speculative decoding implementation using the Hugging Face transformers library. We will use actual models to demonstrate the concept with measurable results. This implementation captures all the key components: draft generation, parallel verification, and the acceptance-correction procedure.

In[24]:
Code
uv pip install transformers
Out[24]:
Console
/private/tmp/mb-language-ai-modern-plots/books/_quarto_language-ai-handbook/.venv/bin/python: No module named uv
Note: you may need to restart the kernel to use updated packages.

Setting Up Models

For demonstration, we use small models that can run on limited hardware. The concepts apply identically to larger production models. The key requirement is that both models share the same vocabulary, which GPT-2 and GPT-2-medium naturally satisfy since they use the same tokenizer.

In[37]:
Code
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

# Load draft model (smaller)
draft_model_name = "gpt2"  # 124M parameters
draft_tokenizer = AutoTokenizer.from_pretrained(draft_model_name)
draft_model = AutoModelForCausalLM.from_pretrained(draft_model_name)

# Load target model (larger)
target_model_name = "gpt2-medium"  # 355M parameters
target_tokenizer = AutoTokenizer.from_pretrained(target_model_name)
target_model = AutoModelForCausalLM.from_pretrained(target_model_name)

# Ensure same tokenizer (required for speculative decoding)
assert draft_tokenizer.vocab_size == target_tokenizer.vocab_size

# Move to GPU if available
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
draft_model = draft_model.to(device).eval()
target_model = target_model.to(device).eval()

Speculative Decoding Core

The core implementation encapsulates the three main operations: drafting candidate tokens, verifying them against the target model, and applying the acceptance/rejection logic. Each method is designed to be modular and clearly reflect the algorithm's structure. Notice how verify_tokens captures the key efficiency property: a single forward pass computes verification probabilities for all KK draft tokens simultaneously.

In[26]:
Code
class SpeculativeDecoder:
    def __init__(self, draft_model, target_model, tokenizer, device="cpu"):
        self.draft_model = draft_model
        self.target_model = target_model
        self.tokenizer = tokenizer
        self.device = device

    @torch.no_grad()
    def draft_tokens(self, input_ids, num_tokens, temperature=1.0):
        """Generate K draft tokens autoregressively."""
        draft_tokens = []
        draft_probs = []
        current_ids = input_ids.clone()

        for _ in range(num_tokens):
            outputs = self.draft_model(current_ids)
            logits = outputs.logits[:, -1, :] / temperature
            probs = F.softmax(logits, dim=-1)

            # Sample from draft distribution
            token = torch.multinomial(probs, num_samples=1)
            draft_tokens.append(token.item())
            draft_probs.append(probs[0, token.item()].item())

            current_ids = torch.cat([current_ids, token], dim=-1)

        return draft_tokens, draft_probs

    @torch.no_grad()
    def verify_tokens(self, input_ids, draft_tokens, temperature=1.0):
        """Verify all draft tokens in one forward pass."""
        # Build sequence with draft tokens
        draft_tensor = torch.tensor([draft_tokens], device=self.device)
        full_ids = torch.cat([input_ids, draft_tensor], dim=-1)

        # Single forward pass
        outputs = self.target_model(full_ids)
        logits = outputs.logits / temperature

        # Extract probabilities at verification positions
        num_input = input_ids.shape[-1]
        num_draft = len(draft_tokens)

        target_probs = []
        target_distributions = []

        for i in range(num_draft):
            pos = num_input + i - 1  # Position predicting draft token i
            probs = F.softmax(logits[:, pos, :], dim=-1)
            target_probs.append(probs[0, draft_tokens[i]].item())
            target_distributions.append(probs[0].cpu().numpy())

        return target_probs, target_distributions

    def accept_reject(
        self,
        draft_tokens,
        draft_probs,
        target_probs,
        target_distributions,
        draft_distributions,
    ):
        """Determine which tokens to accept and generate correction if needed."""
        accepted = []

        for i, (token, q, p) in enumerate(
            zip(draft_tokens, draft_probs, target_probs)
        ):
            # Acceptance probability
            accept_prob = min(1.0, p / q)

            if torch.rand(1).item() < accept_prob:
                accepted.append(token)
            else:
                # Rejection: sample from correction distribution
                p_dist = target_distributions[i]
                q_dist = draft_distributions[i]

                # Correction distribution: max(0, p - q), normalized
                correction = np.maximum(0, p_dist - q_dist)
                correction_sum = correction.sum()

                if correction_sum > 0:
                    correction = correction / correction_sum
                    correction_token = np.random.choice(
                        len(correction), p=correction
                    )
                else:
                    correction_token = np.argmax(p_dist)

                accepted.append(correction_token)
                break  # Stop at first rejection

        return accepted

Running Speculative Decoding

This function orchestrates the complete generation process, repeatedly running speculative decoding rounds until the desired number of tokens is reached. It also tracks statistics that help us evaluate the system's performance in practice.

In[27]:
Code
def speculative_generate(
    decoder, prompt, max_tokens=50, num_draft=4, temperature=1.0
):
    """
    Generate text using speculative decoding.
    Returns generated text and statistics.
    """
    input_ids = decoder.tokenizer.encode(prompt, return_tensors="pt").to(
        decoder.device
    )
    generated_tokens = []
    stats = {"rounds": 0, "drafted": 0, "accepted": 0}

    while len(generated_tokens) < max_tokens:
        stats["rounds"] += 1

        # Build current context
        if generated_tokens:
            current_ids = torch.cat(
                [
                    input_ids,
                    torch.tensor([generated_tokens], device=decoder.device),
                ],
                dim=-1,
            )
        else:
            current_ids = input_ids

        # Draft phase
        draft_tokens, draft_probs = decoder.draft_tokens(
            current_ids, num_draft, temperature
        )
        stats["drafted"] += len(draft_tokens)

        # Get draft distributions for correction
        draft_distributions = []
        temp_ids = current_ids.clone()
        for token in draft_tokens:
            outputs = decoder.draft_model(temp_ids)
            probs = F.softmax(outputs.logits[:, -1, :] / temperature, dim=-1)
            draft_distributions.append(probs[0].cpu().numpy())
            temp_ids = torch.cat(
                [temp_ids, torch.tensor([[token]], device=decoder.device)],
                dim=-1,
            )

        # Verify phase
        target_probs, target_distributions = decoder.verify_tokens(
            current_ids, draft_tokens, temperature
        )

        # Accept/reject phase
        accepted = decoder.accept_reject(
            draft_tokens,
            draft_probs,
            target_probs,
            target_distributions,
            draft_distributions,
        )

        generated_tokens.extend(accepted)
        stats["accepted"] += len(accepted)

        # Check for EOS
        if decoder.tokenizer.eos_token_id in accepted:
            break

    # Decode generated tokens
    full_ids = torch.cat(
        [
            input_ids,
            torch.tensor(
                [generated_tokens[:max_tokens]], device=decoder.device
            ),
        ],
        dim=-1,
    )
    generated_text = decoder.tokenizer.decode(
        full_ids[0], skip_special_tokens=True
    )

    return generated_text, stats

Comparing with Standard Generation

To measure the benefit of speculative decoding, we need a baseline. This standard autoregressive generation function provides that baseline, using the same target model but generating one token at a time. The comparison between the two approaches reveals the practical speedup factor for your specific model pair and hardware.

In[28]:
Code
def standard_generate(
    model, tokenizer, prompt, max_tokens=50, temperature=1.0, device="cpu"
):
    """Standard autoregressive generation for comparison."""
    input_ids = tokenizer.encode(prompt, return_tensors="pt").to(device)

    generated = []
    current_ids = input_ids

    for _ in range(max_tokens):
        with torch.no_grad():
            outputs = model(current_ids)
            logits = outputs.logits[:, -1, :] / temperature
            probs = F.softmax(logits, dim=-1)
            token = torch.multinomial(probs, num_samples=1)

        generated.append(token.item())
        current_ids = torch.cat([current_ids, token], dim=-1)

        if token.item() == tokenizer.eos_token_id:
            break

    full_ids = torch.cat(
        [input_ids, torch.tensor([generated], device=device)], dim=-1
    )
    return tokenizer.decode(full_ids[0], skip_special_tokens=True)

Example Usage

Since loading actual models requires significant resources, we demonstrate the workflow with a simulation that captures the key dynamics. This simulation explores how different acceptance rates affect performance without requiring large language model weights.

In[29]:
Code
import numpy as np


class SimulatedSpeculativeDecoder:
    """
    Simulates speculative decoding to demonstrate the algorithm
    without requiring actual model weights.
    """

    def __init__(self, acceptance_rate=0.75, vocab_size=1000):
        self.acceptance_rate = acceptance_rate
        self.vocab_size = vocab_size

    def simulate_round(self, num_draft=5):
        """Simulate one round of speculative decoding."""
        accepted = 0

        for i in range(num_draft):
            # Simulate acceptance based on rate
            if np.random.random() < self.acceptance_rate:
                accepted += 1
            else:
                # Rejection - add correction token and stop
                accepted += 1  # Correction always added
                break
        else:
            # All accepted - add bonus token in real implementation
            pass

        return accepted

    def simulate_generation(
        self, total_tokens, num_draft=5, draft_time_ms=5, target_time_ms=100
    ):
        """Simulate generating total_tokens with speculative decoding."""
        tokens_generated = 0
        rounds = 0
        total_time_ms = 0

        while tokens_generated < total_tokens:
            rounds += 1
            accepted = self.simulate_round(num_draft)
            tokens_generated += accepted

            # Time for this round: draft all tokens + one target pass
            round_time = num_draft * draft_time_ms + target_time_ms
            total_time_ms += round_time

        # Standard generation time for comparison
        standard_time_ms = total_tokens * target_time_ms

        return {
            "tokens": tokens_generated,
            "rounds": rounds,
            "speculative_time_ms": total_time_ms,
            "standard_time_ms": standard_time_ms,
            "speedup": standard_time_ms / total_time_ms,
            "tokens_per_round": tokens_generated / rounds,
        }


# Run simulations for different acceptance rates
total_tokens = 100
num_simulations = 50

# Simulation parameters
draft_ms = 5
target_ms = 100
num_drafts = 5
acceptance_rates_sim = [0.5, 0.6, 0.7, 0.8, 0.9]
simulation_results = []

for rate in acceptance_rates_sim:
    decoder = SimulatedSpeculativeDecoder(acceptance_rate=rate)
    rate_stats = []

    for _ in range(num_simulations):
        stats = decoder.simulate_generation(
            total_tokens,
            num_draft=num_drafts,
            draft_time_ms=draft_ms,
            target_time_ms=target_ms,
        )
        rate_stats.append(stats)

    simulation_results.append(
        {
            "rate": rate,
            "speedup_mean": np.mean([s["speedup"] for s in rate_stats]),
            "speedup_std": np.std([s["speedup"] for s in rate_stats]),
            "tokens_mean": np.mean([s["tokens_per_round"] for s in rate_stats]),
            "tokens_std": np.std([s["tokens_per_round"] for s in rate_stats]),
        }
    )
Out[30]:
Console
Speculative Decoding Simulation Results
============================================================
Generating 100 tokens, K=5 draft tokens
Draft model: 5ms/token, Target model: 100ms/token

Acceptance rate: 50%
  Average speedup: 1.60x (±0.13)
  Tokens per round: 2.01 (±0.16)

Acceptance rate: 60%
  Average speedup: 1.86x (±0.16)
  Tokens per round: 2.36 (±0.21)

Acceptance rate: 70%
  Average speedup: 2.16x (±0.17)
  Tokens per round: 2.73 (±0.20)

Acceptance rate: 80%
  Average speedup: 2.61x (±0.18)
  Tokens per round: 3.31 (±0.23)

Acceptance rate: 90%
  Average speedup: 3.32x (±0.24)
  Tokens per round: 4.23 (±0.31)

The simulation confirms our theoretical analysis. At 80% acceptance rate, we achieve approximately 2x speedup, generating over 3 tokens per round on average. This matches well with reported results from production speculative decoding systems, validating that our theoretical framework accurately predicts real-world performance. The standard deviation in speedup also narrows at higher acceptance rates, indicating more consistent performance as draft model quality improves.

Key Parameters

Speculative decoding exposes several parameters that you need to tune for your deployment. Getting these right makes the difference between a large speedup and a marginal improvement.

  • draft_model: The smaller, faster model used to generate candidate tokens. Ideally from the same model family as the target, with the same tokenizer, and roughly 7-10% the parameter count.
  • target_model: The large model used to verify candidates and guarantee output distribution quality. Its output distribution is never modified.
  • acceptance_rate (γ\gamma): The probability that a draft token matches the target model's preference. Measured empirically on representative inputs and determines the achievable speedup.
  • num_draft_tokens (KK): The number of candidate tokens generated per round. Typically 4-8. Larger KK yields higher expected tokens per round but risks more wasted computation when acceptance rates are low.
  • temperature: Controls randomness in sampling. Higher temperatures generally reduce acceptance rates, as discussed above.

Choosing KK requires balancing the expected tokens per round against the overhead of drafting. For high acceptance rates (above 80%), K=6K = 6 to 88 is generally optimal. For moderate acceptance rates (60-75%), K=4K = 4 to 55 tends to be better because the probability of long acceptance runs is lower and the overhead of generating many draft tokens is not recouped.

Limitations and Practical Considerations

Speculative decoding can reduce latency but comes with limitations that affect deployment decisions. Understanding these tradeoffs helps you decide when and how to apply the technique, and whether the engineering investment is justified for your use case.

The most significant constraint is the requirement for a well-aligned draft model. Finding or training a draft model that achieves 70%+ acceptance rate while being fast enough to provide speedups is non-trivial. For proprietary models without available smaller variants, this can be a blocking issue. Some organizations train dedicated draft models using distillation, but this adds significant infrastructure overhead and requires access to the target model's output distribution during training. The alternative of using early layers of the target model (self-speculative decoding) avoids this problem but requires architectural modifications that may not be available in all frameworks. In practice, many organizations find that the most accessible approach is to wait for the model provider to release a small companion model, which has become standard practice for major open-weight model families but remains rare for proprietary closed models.

Memory requirements increase because both models must be loaded simultaneously. For a 70B target model with a 7B draft model, total GPU memory increases by roughly 10%. On memory-constrained deployments, this overhead may prevent using speculative decoding entirely, or force quantization of one or both models. The interaction between quantization and speculative decoding is an important practical concern: quantizing the draft model more aggressively than the target can preserve generation quality while minimizing the memory overhead of maintaining two models simultaneously. However, aggressive quantization of the draft model may reduce its alignment with the full-precision target model, lowering acceptance rates and partly negating the speedup. Finding the right balance requires empirical tuning.

Batched inference presents complications that can negate speculative decoding benefits. When serving multiple concurrent requests, the sequences in a batch may have different acceptance patterns. One sequence might accept all 5 draft tokens while another rejects after 2. Handling this efficiently requires sophisticated orchestration: either padding shorter accepted sequences (wasting compute on padding), or dynamically adjusting batch composition (adding complexity to the serving infrastructure). The simpler approach of running speculative decoding independently per sequence underutilizes batch parallelism. This is a particularly acute problem for high-throughput serving scenarios, where continuous batching dominates. Speculative decoding is most beneficial for latency-sensitive applications serving individual requests, rather than throughput-optimized batch serving.

The technique is also less effective for certain content types. When generating highly creative text, code with many valid alternatives, or responses in low-resource languages where the draft model has weaker coverage, acceptance rates may fall below the break-even threshold. Production systems often implement adaptive strategies that monitor acceptance rates in real time and fall back to standard generation when rates drop too low. This keeps speculative decoding never becomes a net negative.

Despite these limitations, speculative decoding has become a standard technique in production LLM serving. The 2-3x speedups it provides translate directly to reduced latency for users and reduced cost for providers. For conversational applications where response time critically affects user experience, shaving 50-70% off generation time is substantial. The technique also demonstrates a broader principle: the memory-bound nature of LLM inference creates opportunities for algorithmic improvements that do not require hardware upgrades or model architecture changes. Speculative decoding preserves the exact output distribution of the target model while improving efficiency, whereas most optimization techniques trade quality for performance.

Summary

Speculative decoding accelerates autoregressive generation by parallelizing token verification. A small draft model generates multiple candidate tokens cheaply, which the large target model then verifies in a single forward pass. The technique exploits the memory-bound nature of LLM inference, where processing multiple tokens in one forward pass costs nearly the same as processing one, because the bottleneck is data movement rather than computation.

The key components form an interconnected system. The draft model must be fast and well-aligned with the target, sharing the same vocabulary. The acceptance criterion α(x)=min⁡(1,p(x)/q(x))\alpha(x) = \min(1, p(x)/q(x)) determines which draft tokens to accept while preserving the target distribution. The correction distribution pcorrection(x)∝max⁡(0,p(x)−q(x))p_{\text{correction}}(x) \propto \max(0, p(x) - q(x)) handles rejections by sampling only the "missing" probability mass. Together, these components guarantee that the output is statistically identical to what standard autoregressive generation would have produced from the target model.

The acceptance rate γ\gamma emerges as the critical performance metric. Small improvements in acceptance rate yield disproportionate speedup gains due to the geometric accumulation of consecutive acceptances. Moving from 70% to 80% acceptance roughly doubles the speedup. This nonlinearity motivates significant investment in draft model quality, making draft model alignment the dominant factor in practical deployments.

Draft model selection balances speed against alignment quality. Model family relationships provide the most practical path, with smaller models from the same training pipeline achieving 70-80% acceptance rates on typical text. Distilled models can push this higher at the cost of training infrastructure. The vocabulary compatibility requirement constrains choices to models that share the same tokenizer, effectively tying draft model selection to the target model's provenance.

The verification procedure guarantees lossless acceleration: speculative decoding produces exactly the same output distribution as standard autoregressive generation from the target model. This mathematical guarantee makes speculative decoding uniquely valuable among inference optimization techniques, delivering substantial performance improvements without any sacrifice in generation quality.

Quiz

Ready to test your understanding? Take this quick quiz to reinforce what you've learned about speculative decoding.

Speculative Decoding Knowledge Check

Question 1 of 80 of 8 completed
What is the primary hardware bottleneck in Large Language Model inference that speculative decoding exploits?

Comments

No comments yet. Be the first to share your thoughts!

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2026speculativedecoding, author = {Michael Brenndoerfer}, title = {Speculative Decoding}, year = {2026}, url = {https://mbrenndoerfer.com/writing/speculative-decoding-accelerating-llm-inference}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-09-30} }
APAAcademic
Michael Brenndoerfer (2026). Speculative Decoding. Retrieved from https://mbrenndoerfer.com/writing/speculative-decoding-accelerating-llm-inference
MLAAcademic
Michael Brenndoerfer. "Speculative Decoding." 2026. Web. September 30, 2026. <https://mbrenndoerfer.com/writing/speculative-decoding-accelerating-llm-inference>.
CHICAGOAcademic
Michael Brenndoerfer. "Speculative Decoding." Accessed September 30, 2026. https://mbrenndoerfer.com/writing/speculative-decoding-accelerating-llm-inference.
HARVARDAcademic
Michael Brenndoerfer (2026) 'Speculative Decoding'. Available at: https://mbrenndoerfer.com/writing/speculative-decoding-accelerating-llm-inference (Accessed: September 30, 2026).
SimpleBasic
Michael Brenndoerfer (2026). Speculative Decoding. https://mbrenndoerfer.com/writing/speculative-decoding-accelerating-llm-inference

About the author

Continue with the full handbook

This chapter is part of Language AI Handbook. Use the handbook page to browse the complete table of contents and continue reading in sequence.

Explore Language AI Handbook
Newsletter

Stay up to date

Get articles, book updates, and news delivered to your inbox.

No spam, unsubscribe anytime.

or

Join the community

Sign in to remove popups, track your reading progress, and join the discussion.