TL;DR — Speculative decoding can cut LLM inference latency by 2–3× without sacrificing quality, and building a working engine in Python is a project that immediately signals systems programming, ML deployment, and algorithmic sophistication to any hiring manager. This guide gives you real, runnable code for the draft model, speculative sampling, and rejection sampling loop—plus a roadmap to turn it into a production-flavored artifact.
Large language models are fantastic at generating text, but their autoregressive nature makes them slow: each token depends on the previous one, so you cannot parallelize generation. Speculative decoding, introduced by Leviathan et al. (2022) and refined by Chen et al. (2023), uses a smaller “draft” model to propose several tokens at once, then a larger “target” model verifies them in a single forward pass. The result is a significant speedup with zero loss in output quality, thanks to a clever rejection sampling trick. In this post, I’ll walk you through building a complete speculative decoding engine in pure Python (with PyTorch for the models) that you can run on a laptop and extend for production.
Why This Project Stands Out on a CV
Hiring managers for backend, ML infrastructure, or systems roles look for candidates who can ship efficient, correct, and observable code. A speculative decoding engine checks several boxes at once:
- Algorithmic depth: You’re implementing a non-trivial statistical algorithm (rejection sampling) and understanding why it preserves the target distribution. That’s a signal you can reason about probability and correctness under constraints.
- Systems performance: You’ll measure real speedups, manage GPU memory, and optimize batch sizes. This is the same skill set used to build high-throughput inference services at companies like vLLM or TensorRT-LLM.
- ML deployment: You’ll load and run two different models, handle tokenization, and manage the generate/verify loop—practical experience that maps directly to production LLM serving.
- End-to-end ownership: From model loading to algorithm design to benchmarking, you own the entire pipeline. Recruiters love “I built X from scratch” stories because they demonstrate autonomy and depth.
If you’re applying for roles at AI infrastructure startups, big tech ML platforms, or even quant funds that run heavy Monte Carlo simulations, this project gives you a concrete artifact to discuss in interviews.
Architecture Overview
The engine has four core components that fit together in a tight loop:
- Draft Model – A smaller, faster language model (e.g., a 1-layer transformer or a distilled model) that proposes candidate tokens. It runs autoregressively but is cheap enough to generate multiple tokens quickly.
- Target Model – The larger, high-quality model you actually want to use (e.g., a 6-layer transformer). It is used only for scoring the draft’s proposals, not for generating from scratch.
- Speculative Sampling Algorithm – The core logic: the draft model generates
Ktokens, then the target model evaluates allKin parallel. For each token, you compare the draft’s probability against the target’s probability and decide whether to accept or reject. - Rejection Sampling Loop – If a token is rejected, you resample from the corrected distribution (target minus draft) and continue. This loop ensures the final output matches the target model’s distribution exactly.
Here’s a simplified text diagram:
Input Prompt
│
▼
┌─────────────┐ ┌──────────────┐
│ Draft Model │──►│ Generate K │
│ (small) │ │ tokens │
└─────────────┘ └──────┬───────┘
│
▼
┌──────────────────┐
│ Target Model │
│ (large) │
│ Score all K │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Rejection Loop │
│ Accept/Reject/ │
│ Resample │
└────────┬─────────┘
│
▼
Output Text
In code, these components will be implemented as Python classes that communicate via a shared token buffer. The draft model runs on CPU or a smaller GPU memory partition, while the target model can be offloaded to a more powerful GPU if available.
Building It Step by Step
I’ll use PyTorch and the Hugging Face transformers library for the models, but the speculative sampling logic is pure Python. You can swap in any two autoregressive models that share a tokenizer.
Step 1: Set Up the Models and Tokenizer
First, install the dependencies and load a small draft and a larger target. For demonstration, I’ll use GPT-2 variants, but you can substitute any model pair.
pip install torch transformers accelerate
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
# Load a shared tokenizer (both models should use the same vocab)
tokenizer = AutoTokenizer.from_pretrained("gpt2")
tokenizer.pad_token = tokenizer.eos_token
# Draft model: a smaller GPT-2 (e.g., 124M params)
draft_model = AutoModelForCausalLM.from_pretrained("gpt2")
draft_model.eval()
# Target model: a larger GPT-2 (e.g., 774M params) — you can use a different architecture
target_model = AutoModelForCausalLM.from_pretrained("gpt2-large")
target_model.eval()
# Move to GPU if available
device = "cuda" if torch.cuda.is_available() else "cpu"
draft_model.to(device)
target_model.to(device)
In a real scenario, you might use a distilled model (like distilgpt2) for the draft and a full GPT-2 for the target. The key is that the draft is at least 2–4× faster per token.
Step 2: Implement the Speculative Sampling Algorithm
The heart of the engine is the speculative_sample function. It takes an input prompt, the two models, and a max token budget. It returns the generated text and the number of tokens accepted per step (useful for benchmarking).
@torch.no_grad()
def speculative_sample(
prompt: str,
draft_model,
target_model,
tokenizer,
max_new_tokens: int = 100,
k: int = 5, # number of draft tokens to propose
temperature: float = 1.0,
device: str = "cpu",
) -> tuple[str, list[int]]:
"""
Perform speculative sampling.
Returns (generated_text, list_of_accepted_counts).
"""
input_ids = tokenizer.encode(prompt, return_tensors="pt").to(device)
generated = input_ids
accepted_counts = []
for _ in range(max_new_tokens):
# Step 1: Draft model generates k tokens autoregressively
draft_input = generated
draft_probs = []
draft_tokens = []
for _ in range(k):
draft_outputs = draft_model(draft_input)
logits = draft_outputs.logits[:, -1, :] / temperature
probs = torch.softmax(logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
draft_probs.append(probs)
draft_tokens.append(next_token)
draft_input = torch.cat([draft_input, next_token], dim=-1)
# Stack draft tokens and probabilities
draft_tokens = torch.cat(draft_tokens, dim=-1) # shape: (1, k)
draft_probs = torch.cat([p.gather(1, t.unsqueeze(-1)) for p, t in zip(draft_probs, draft_tokens.T)], dim=0) # (k,)
# Step 2: Target model scores the entire draft sequence in one pass
target_input = torch.cat([generated, draft_tokens], dim=-1)
target_outputs = target_model(target_input)
target_logits = target_outputs.logits[:, -k-1:-1, :] / temperature # logits for the k draft tokens
target_probs = torch.softmax(target_logits, dim=-1)
# Step 3: Rejection sampling
accepted = 0
for i in range(k):
draft_prob = draft_probs[i]
target_prob = target_probs[i, draft_tokens[0, i]]
# Accept with probability min(1, target_prob / draft_prob)
accept_ratio = target_prob / draft_prob
if torch.rand(1).item() < accept_ratio:
accepted += 1
else:
break
# Step 4: If we rejected some, resample from the corrected distribution
if accepted < k:
# Resample from the target distribution at the rejection point
resample_logits = target_logits[accepted - 1] if accepted > 0 else target_logits[0]
resample_probs = torch.softmax(resample_logits, dim=-1)
new_token = torch.multinomial(resample_probs, num_samples=1)
draft_tokens = torch.cat([draft_tokens[:, :accepted], new_token], dim=-1)
accepted += 1 # count the resampled token
# Append accepted tokens to the generated sequence
generated = torch.cat([generated, draft_tokens[:, :accepted]], dim=-1)
accepted_counts.append(accepted)
# Stop if we've hit the max
if generated.shape[1] - input_ids.shape[1] >= max_new_tokens:
break
output_text = tokenizer.decode(generated[0, input_ids.shape[1]:], skip_special_tokens=True)
return output_text, accepted_counts
This function is the core of the engine. It highlights the key insight: the target model only runs once per speculative step, no matter how many tokens the draft proposed. The rejection loop ensures the output distribution matches the target model exactly.
Step 3: Add a Baseline for Comparison
To prove the speedup, you need a baseline: generate tokens the old-fashioned way, one by one, with the target model.
@torch.no_grad()
def baseline_generate(
prompt: str,
model,
tokenizer,
max_new_tokens: int = 100,
temperature: float = 1.0,
device: str = "cpu",
) -> str:
"""Standard autoregressive generation with the target model."""
input_ids = tokenizer.encode(prompt, return_tensors="pt").to(device)
generated = input_ids
for _ in range(max_new_tokens):
outputs = model(generated)
logits = outputs.logits[:, -1, :] / temperature
probs = torch.softmax(logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
generated = torch.cat([generated, next_token], dim=-1)
return tokenizer.decode(generated[0, input_ids.shape[1]:], skip_special_tokens=True)
Step 4: Wire It All Together in a Main Script
Now create a runnable script that compares the two methods.
import time
def main():
prompt = "The meaning of life is"
max_tokens = 50
k = 4 # speculative lookahead
# Warm-up
baseline_generate(prompt, target_model, tokenizer, max_new_tokens=5, device=device)
speculative_sample(prompt, draft_model, target_model, tokenizer, max_new_tokens=5, k=k, device=device)
# Benchmark baseline
start = time.time()
baseline_output = baseline_generate(prompt, target_model, tokenizer, max_new_tokens=max_tokens, device=device)
baseline_time = time.time() - start
# Benchmark speculative
start = time.time()
spec_output, accepted_counts = speculative_sample(
prompt, draft_model, target_model, tokenizer, max_new_tokens=max_tokens, k=k, device=device
)
spec_time = time.time() - start
print(f"Baseline output: {baseline_output}")
print(f"Speculative output: {spec_output}")
print(f"Baseline time: {baseline_time:.3f}s")
print(f"Speculative time: {spec_time:.3f}s")
print(f"Speedup: {baseline_time / spec_time:.2f}×")
print(f"Average accepted tokens per step: {sum(accepted_counts) / len(accepted_counts):.2f}")
if __name__ == "__main__":
main()
Save this as speculative_engine.py and run it. You should see a meaningful speedup (often 1.5–3×) even on modest hardware, and the outputs should be semantically similar because the algorithm preserves the target distribution.
Running and Testing It
To run the engine locally:
- Clone the repository (or create a new directory) and install dependencies.
- Ensure you have a CUDA-capable GPU for best performance, though CPU works for small models.
- Run the script:
python speculative_engine.py
You’ll see the baseline and speculative outputs, along with timing and speedup metrics. To prove correctness, you can compare the outputs of both methods on the same prompt—they should be different samples but from the same distribution. For a stronger test, measure the perplexity of the speculative output using a pre-trained language model; it should be close to the baseline’s perplexity.
For automated testing, add a unit test that checks:
- The speculative output has the requested length.
- The accepted counts are non-negative and ≤
k. - The output does not contain unexpected tokens (e.g., tokenizer special tokens).
Extending It: Your Roadmap to Senior-Level
A toy engine is a starting point. Here are six concrete upgrades that transform it into something production-flavored, each with a reason it matters:
Model Persistence and Caching – Save the draft and target models in a format like ONNX or TorchScript, and cache them on disk. Why it matters: Production systems avoid reloading models on every request; this reduces cold-start latency and enables seamless model updates.
Horizontal Scaling with a Model Server – Wrap the engine in a FastAPI service and use a tool like
uvicornwith multiple workers, or offload models to separate GPU pods via Kubernetes. Why it matters: Real-world serving must handle concurrent requests; scaling horizontally prevents a single heavy request from blocking others.Observability: Metrics, Logging, and Tracing – Emit Prometheus metrics for latency, acceptance rate, and GPU memory usage. Add structured logs with request IDs and OpenTelemetry traces. Why it matters: Without observability, debugging performance regressions or failures in production is guesswork.
Fault Tolerance and Fallbacks – Implement a retry mechanism if the target model fails to respond, and a fallback to the baseline generation if the draft model produces garbage (detected by a sudden drop in acceptance rate). Why it matters: Production systems must degrade gracefully; a speculative engine that crashes when the draft is off-distribution is a liability.
Benchmarking Suite and A/B Testing – Build a script that runs the engine on a fixed dataset (e.g., a subset of WikiText) and compares perplexity, latency, and throughput against the baseline. Integrate with
mlflowto track experiments. Why it matters: You need data to justify the speculative approach to stakeholders and to tune hyperparameters likekand temperature.Dynamic Draft-Target Pairing – Allow swapping models at runtime based on workload (e.g., use an even smaller draft for low-traffic periods). Why it matters: Production traffic is variable; adapting the draft model size to current load optimizes the cost-performance trade-off.
Key Takeaways
- Speculative decoding is a practical algorithm for reducing LLM inference latency without sacrificing output quality.
- Building a working engine in Python demonstrates systems programming, ML deployment, and algorithmic correctness—skills that stand out on any CV.
- The core logic (draft generation, target scoring, rejection sampling) can be implemented in under 100 lines of Python.
- Real-world impact comes from extending the engine with persistence, scaling, observability, and benchmarking.
- Always measure speedup and correctness against a baseline to validate your implementation.
Further Reading
- Speculative Decoding Paper: Leviathan et al., “Fast Inference from Transformers via Speculative Decoding” – The original paper that introduced the technique.
- Improved Algorithm: Chen et al., “Efficient Large Language Model Inference with Speculative Sampling and a Small Draft Model” – Refinements and analysis.
- Production Systems: vLLM Documentation – A high-throughput LLM serving system that uses speculative decoding internally.
- Hugging Face Transformers: Transformers Docs – For loading and running models in Python.
- PyTorch Performance Tips: PyTorch Performance Tuning Guide – For optimizing your engine’s GPU usage.
- Rejection Sampling Theory: Wikipedia: Rejection Sampling – The statistical foundation of the acceptance loop.
By following this guide, you’ll have a runnable, extensible speculative decoding engine that not only works but also serves as a springboard for deeper systems and ML engineering projects. Happy coding!