← Back to DevBytes

How to Implement Speculative Decoding for Faster LLM Responses

How to Implement Speculative Decoding for Faster LLM Responses

Large language models have transformed how we build applications, but inference latency remains a persistent bottleneck. Each token generated by an autoregressive LLM requires a full forward pass through the model, and for long responses this sequential cost adds up quickly. Speculative decoding is a technique that can dramatically reduce latency without changing the output distribution of the model. In this tutorial, you will learn what speculative decoding is, why it matters, how to implement it from scratch, and how to apply it using popular libraries.

What Is Speculative Decoding?

Speculative decoding is an inference acceleration method that uses a small, fast "draft" model to propose candidate tokens, which are then verified in parallel by a larger "target" model. Because modern GPUs are underutilized when generating a single token at a time, batching several candidate tokens into one forward pass lets us extract far more value from each computation.

The key insight is that verification is cheaper than generation. When the target model verifies a sequence of draft tokens, it produces logits for every position in a single pass. If the draft model guessed correctly, we effectively generated multiple tokens for the cost of one forward pass. If it guessed wrong, we simply discard the tokens after the first mismatch and continue — and crucially, the final output distribution is mathematically identical to what the target model would have produced on its own.

Why It Matters

The Algorithm in Detail

At each step, the draft model autoregressively generates k candidate tokens. These tokens, along with their probabilities, are passed to the target model. The target model performs a single forward pass over the entire candidate sequence and produces its own probability distribution at each position. For each draft token, we apply rejection sampling:

This procedure guarantees that the accepted tokens follow the target model's distribution exactly.

A Minimal Implementation from Scratch

Below is a simplified but complete implementation in PyTorch. It assumes both models expose a logits method that returns logits for a sequence of token IDs.

import torch
import torch.nn.functional as F

def sample_from_logits(logits, temperature=1.0):
    if temperature <= 0:
        return torch.argmax(logits, dim=-1)
    probs = F.softmax(logits / temperature, dim=-1)
    return torch.multinomial(probs, num_samples=1).squeeze(-1)

def speculative_decode(target_model, draft_model, input_ids, max_new_tokens=128, k=4):
    """
    Generate tokens using speculative decoding.
    
    Args:
        target_model: large model with .logits(ids) -> logits [seq, vocab]
        draft_model: small model with .logits(ids) -> logits [seq, vocab]
        input_ids: starting token IDs as a 1D LongTensor
        max_new_tokens: total tokens to generate
        k: number of draft tokens per speculation round
    
    Returns:
        Tensor of generated token IDs
    """
    generated = input_ids.clone()
    tokens_generated = 0
    
    while tokens_generated < max_new_tokens:
        # 1. Draft model proposes k tokens autoregressively
        draft_tokens = []
        draft_probs = []
        current = generated.clone()
        
        for _ in range(k):
            d_logits = draft_model.logits(current)[-1]  # last position
            d_probs = F.softmax(d_logits, dim=-1)
            next_token = torch.multinomial(d_probs, num_samples=1)
            draft_tokens.append(next_token)
            draft_probs.append(d_probs)
            current = torch.cat([current, next_token])
        
        draft_tokens = torch.stack(draft_tokens)  # [k]
        
        # 2. Target model verifies all k tokens in one forward pass
        verify_input = torch.cat([generated, draft_tokens])
        t_logits = target_model.logits(verify_input)
        # Logits at positions corresponding to each draft token
        t_logits_for_draft = t_logits[generated.shape[0] - 1 : generated.shape[0] - 1 + k]
        t_probs = F.softmax(t_logits_for_draft, dim=-1)  # [k, vocab]
        
        # 3. Rejection sampling
        accepted = 0
        for i in range(k):
            d_tok = draft_tokens[i]
            p_d = draft_probs[i][d_tok]
            p_t = t_probs[i][d_tok]
            
            if p_t >= p_d:
                # Always accept
                generated = torch.cat([generated, d_tok.unsqueeze(0)])
                accepted += 1
                tokens_generated += 1
                if tokens_generated >= max_new_tokens:
                    break
            else:
                # Accept with probability p_t / p_d
                r = torch.rand(1).item()
                if r < (p_t / p_d).item():
                    generated = torch.cat([generated, d_tok.unsqueeze(0)])
                    accepted += 1
                    tokens_generated += 1
                else:
                    # Reject: sample from rescaled distribution
                    rescaled = torch.clamp(t_probs[i] - draft_probs[i], min=0)
                    if rescaled.sum() > 0:
                        rescaled = rescaled / rescaled.sum()
                    else:
                        rescaled = t_probs[i]
                    new_token = torch.multinomial(rescaled, num_samples=1)
                    generated = torch.cat([generated, new_token])
                    tokens_generated += 1
                    break
        
        # 4. If all accepted, sample a bonus token from target
        if accepted == k and tokens_generated < max_new_tokens:
            bonus_logits = t_logits[-1]
            bonus_token = sample_from_logits(bonus_logits)
            generated = torch.cat([generated, bonus_token.unsqueeze(0)])
            tokens_generated += 1
    
    return generated

This implementation prioritizes clarity over performance. In production, you would batch the draft model's autoregressive steps, use KV caching for both models, and avoid the Python-level loop over k by vectorizing the acceptance check.

Using Hugging Face Transformers

The Hugging Face transformers library supports speculative decoding natively through the AssistedGeneration API. You only need to provide a draft model via the assistant_model argument.

from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

model_id = "meta-llama/Llama-2-13b-hf"
draft_id = "meta-llama/Llama-2-7b-hf"

tokenizer = AutoTokenizer.from_pretrained(model_id)
target_model = AutoModelForCausalLM.from_pretrained(
    model_id, torch_dtype=torch.float16, device_map="auto"
)
draft_model = AutoModelForCausalLM.from_pretrained(
    draft_id, torch_dtype=torch.float16, device_map="auto"
)

prompt = "Explain how transformers process sequential data."
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")

outputs = target_model.generate(
    **inputs,
    assistant_model=draft_model,
    max_new_tokens=256,
    do_sample=True,
    temperature=0.7,
)

print(tokenizer.decode(outputs[0], skip_special_tokens=True))

Under the hood, the library handles KV cache management, draft token proposal, parallel verification, and rejection sampling. The output is statistically identical to running the target model alone.

Using vLLM for Production Inference

For production deployments, vLLM offers a high-performance implementation. You launch the server with a draft model specified, and speculative decoding is applied automatically.

# Start the server with speculative decoding enabled
python -m vllm.entrypoints.openai.api_server \
    --model meta-llama/Llama-2-13b-hf \
    --speculative-model meta-llama/Llama-2-7b-hf \
    --num-speculative-tokens 5 \
    --port 8000

Then query it like any OpenAI-compatible endpoint:

import openai

client = openai.Client(base_url="http://localhost:8000/v1", api_key="dummy")

response = client.chat.completions.create(
    model="meta-llama/Llama-2-13b-hf",
    messages=[{"role": "user", "content": "Write a short poem about latency."}],
    max_tokens=200,
)
print(response.choices[0].message.content)

Best Practices

Common Pitfalls

One frequent mistake is using a draft model with a different tokenizer than the target. Even subtle differences in token boundaries break the algorithm because the rejection sampling assumes token-level alignment. Another pitfall is forgetting to handle the bonus token case when all draft tokens are accepted — omitting it leaves free performance on the table. Finally, mixing greedy decoding in the draft model with sampling in the target model can skew acceptance rates; keep the sampling strategy consistent or use the proper assisted-generation path that handles this correctly.

Conclusion

Speculative decoding is one of the most practical techniques for reducing LLM inference latency without sacrificing output quality. By pairing a fast draft model with a powerful target model and leveraging parallel verification, you can achieve 2-3x speedups on real workloads. Whether you implement it from scratch for learning, use the Hugging Face assistant_model API for prototyping, or deploy with vLLM for production scale, the principles remain the same: choose a compatible draft model, tune the number of speculative tokens, and measure acceptance rates to guide optimization. As LLMs continue to grow in size, techniques like speculative decoding will only become more essential for delivering responsive AI experiences.

— Ad —

Google AdSense will appear here after approval

← Back to all articles