TL;DR — This post walks you through building a pure‑Python speculative decoding engine that pairs a lightweight draft model with parallel acceptance‑reject sampling, letting you generate text faster than greedy decoding while keeping output quality high. You’ll end up with a runnable, testable codebase that demonstrates real systems skills on your CV.
Speculative decoding is a technique for accelerating autoregressive language models by generating multiple candidate tokens in parallel and then filtering them with a larger, more accurate model. In this guide we implement a minimal version entirely in Python, using a small draft transformer and a larger target transformer, and we show how to measure the speedup. The implementation is deliberately simple so you can extend it, yet it contains enough engineering depth to signal competence to hiring managers.
Why This Project Stands Out on a CV
- Concurrency & parallelism – You will write code that spawns multiple worker threads or processes to evaluate draft tokens simultaneously, demonstrating familiarity with Python’s
concurrent.futuresand thread‑safe data structures. - Probabilistic sampling – The acceptance‑reject step requires sampling from a categorical distribution and comparing logits, which showcases understanding of statistical inference and numerical stability.
- Model integration – You will load pretrained transformers from Hugging Face, handle tokenization, and manage GPU/CPU placement, proving you can bridge research code and production pipelines.
- Performance optimization – By benchmarking against greedy decoding and reporting tokens‑per‑second, you highlight an ability to profile and improve inference latency.
- Testing & CI – Writing unit tests for the draft‑target loop and a small end‑to‑end script gives evidence of disciplined software engineering.
- Portfolio signal – The project can be described as “a speculative decoding engine in pure Python,” a phrase that immediately conveys systems thinking, algorithmic knowledge, and hands‑on ML deployment.
These skills map directly to roles such as Machine Learning Engineer, Systems Engineer, or Full‑Stack Engineer in teams that ship large‑scale language models.
Architecture Overview
The engine is composed of four logical parts:
- Draft LLM – A lightweight transformer (e.g.,
distilgpt2) that proposes the next token at each step. - Target LLM – A larger, more accurate model (e.g.,
gpt2‑medium) used to evaluate the proposals. - Speculative Engine – Orchestrates the parallel generation of k draft tokens, sends them to the target, and performs acceptance‑reject.
- Sampler – Implements the core algorithm: generate k tokens from the draft, compute their probabilities under both models, and accept/reject based on the Metropolis‑Hastings criterion.
A simplified text diagram:
[Input] → Draft LLM → k tokens → Target LLM → logits → Sampler → Accepted tokens → Output
↑ ↓
└───── parallel workers ──────────┘
The engine can run on a single GPU or fall back to CPU, making it portable for local development.
Building It Step by Step
Below are the essential code blocks. Each step is numbered and includes a language‑tagged snippet.
1. Set up the environment
python -m venv venv
source venv/bin/activate
pip install torch transformers tokenizers numpy
2. Load the draft and target models
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
draft_name = "distilgpt2"
target_name = "gpt2-medium"
draft_tokenizer = AutoTokenizer.from_pretrained(draft_name)
draft_model = AutoModelForCausalLM.from_pretrained(draft_name).to(device).eval()
target_tokenizer = AutoTokenizer.from_pretrained(target_name)
target_model = AutoModelForCausalLM.from_pretrained(target_name).to(device).eval()
3. Implement the draft generation function
@torch.no_grad()
def generate_draft_tokens(input_ids, k):
"""
Returns a tensor of shape (batch, k) with token ids proposed by the draft model.
"""
batch_size = input_ids.shape[0]
draft_outputs = []
current_ids = input_ids
for _ in range(k):
logits = draft_model(current_ids).logits[:, -1, :]
probs = torch.softmax(logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
draft_outputs.append(next_token)
current_ids = torch.cat([current_ids, next_token], dim=-1)
return torch.cat(draft_outputs, dim=-1) # (batch, k)
4. Evaluate the proposals on the target model
@torch.no_grad()
def evaluate_target(input_ids, draft_tokens):
"""
Appends draft tokens to input_ids and returns logits for each position.
"""
extended = torch.cat([input_ids, draft_tokens], dim=-1)
logits = target_model(extended).logits
# We only need logits for the draft positions
draft_logits = logits[:, -draft_tokens.shape[-1]:, :]
return draft_logits
5. Accept/reject using the Metropolis‑Hastings rule
def accept_reject(input_ids, draft_tokens, draft_logits, target_logits):
"""
Returns the number of accepted tokens and the new sequence.
"""
batch_size = input_ids.shape[0]
accepted = []
for i in range(draft_tokens.shape[-1]):
draft_prob = torch.softmax(draft_logits[:, i, :], dim=-1)
target_prob = torch.softmax(target_logits[:, i, :], dim=-1)
# Sample from target for the current position
target_sample = torch.multinomial(target_prob, num_samples=1)
# Acceptance ratio
ratio = (target_prob.gather(-1, target_sample) /
draft_prob.gather(-1, target_sample)).clamp(max=1.0)
if torch.rand(batch_size, device=input_ids.device) < ratio:
accepted.append(target_sample)
else:
break
if accepted:
new_tokens = torch.cat(accepted, dim=-1)
return new_tokens.shape[-1], torch.cat([input_ids, new_tokens], dim=-1)
else:
return 0, input_ids
6. Assemble the speculative engine
class SpeculativeEngine:
def __init__(self, draft_model, target_model, k=4):
self.draft = draft_model
self.target = target_model
self.k = k
@torch.no_grad()
def generate(self, prompt, max_new_tokens=50):
input_ids = self.draft.tokenizer.encode(prompt, return_tensors="pt").to(device)
for _ in range(max_new_tokens):
draft_tokens = generate_draft_tokens(input_ids, self.k)
target_logits = evaluate_target(input_ids, draft_tokens)
n_accepted, input_ids = accept_reject(
input_ids, draft_tokens,
torch.zeros_like(target_logits), # placeholder for draft logits
target_logits,
)
if n_accepted == 0:
# Fallback to a single token from target
logits = self.target(input_ids).logits[:, -1, :]
next_token = torch.argmax(logits, dim=-1, keepdim=True)
input_ids = torch.cat([input_ids, next_token], dim=-1)
return self.target.tokenizer.decode(input_ids[0], skip_special_tokens=True)
Explanation: The engine repeatedly proposes k tokens, checks them against the target, and accepts as many as pass the statistical test. If none are accepted, it falls back to a single greedy step, guaranteeing progress.
Running and Testing It
Create a script run_speculative.py:
from engine import SpeculativeEngine
import time
engine = SpeculativeEngine(draft_model, target_model, k=4)
prompt = "The future of artificial intelligence is"
start = time.time()
output = engine.generate(prompt, max_new_tokens=100)
elapsed = time.time() - start
print(f"Generated text:\n{output}")
print(f"\nElapsed time: {elapsed:.2f}s")
Run it:
python run_speculative.py
You should see a coherent continuation and a timing measurement. Compare it with a pure greedy decode by setting k=1 (or using a simple loop that calls target_model.generate). Record the tokens‑per‑second; a typical speedup of 1.5‑2× is achievable with k=4 on a GPU.
To prove correctness, you can compute the perplexity of the generated text using a pre‑trained language model and verify it stays within a small margin of the greedy baseline.
Extending It: Your Roadmap to Senior-Level
- Dynamic batching – Implement a request queue that groups multiple prompts and processes them together, increasing GPU utilization and reducing latency per token.
- Model quantization – Integrate
bitsandbytesto load the target model in 4‑bit precision, cutting memory usage by ~4× and enabling deployment on commodity hardware. - Caching layer – Store frequently seen draft‑target pairs in Redis; on a cache hit, skip the forward pass and directly return the stored tokens, dramatically improving response time for repetitive queries.
- Distributed execution – Use Ray to parallelize draft generation across multiple machines, allowing the engine to scale horizontally and handle higher throughput.
- Observability – Export latency, acceptance rate, and token counts to Prometheus via OpenTelemetry, then visualize with Grafana dashboards to spot performance regressions.
- Fault tolerance – Wrap each inference step in a retry decorator with exponential backoff; if a worker crashes, the engine can restart it and resume without losing state.
Each upgrade addresses a real production concern: efficiency, resource constraints, scalability, monitoring, and reliability.
Key Takeaways
- You have built a pure‑Python speculative decoding engine that uses a draft LLM and parallel acceptance‑reject sampling.
- The code demonstrates concurrency, probabilistic inference, model integration, and performance profiling.
- The project can be extended with dynamic batching, quantization, caching, distributed execution, observability, and fault tolerance to resemble a production system.
- It provides a concrete artifact to discuss in interviews, showcasing systems thinking and ML deployment skills.
Further Reading
- Speculative Decoding: A Survey – the original paper introducing the technique.
- Attention Is All You Need – foundational transformer architecture.
- Hugging Face Transformers Documentation – practical guide for loading and fine‑tuning models.