TL;DR — Speculative decoding is one of the most elegant latency-reduction techniques in modern LLM inference: a small n-gram model drafts several candidate tokens, and a larger verification transformer scores them all at once, accepting or rejecting the entire draft in a single forward pass. Building this end-to-end from scratch demonstrates systems architecture, ML engineering, and performance optimization skills that hiring managers in ML infrastructure actively look for.
Why This Project Stands Out on a CV
Speculative decoding sits at the intersection of three domains that hiring managers in ML platforms and infrastructure teams care deeply about: low-latency systems, model optimization, and distributed inference. Here’s what this project signals:
Systems-level ML engineering. You’re not just calling
pipeline.generate(). You’re orchestrating two models with different computational profiles, managing token-level state, and optimizing the critical path of inference. This is the kind of work done at companies like DeepSeek (whose DeepSeek-MoE papers popularized speculative decoding), NVIDIA (TensorRT-LLM), and vLLM.Understanding of the inference bottleneck. Training is well-understood; inference optimization is where the real engineering money is. Demonstrating you understand why autoregressive decoding is memory-bandwidth-bound — not compute-bound — puts you ahead of 90% of ML engineers.
Practical knowledge of approximation vs. exactness. Speculative decoding is an approximate method that provably preserves output distribution under certain conditions. This signals statistical maturity, not just API familiarity.
End-to-end project ownership. You’ll touch model loading, tokenization, autoregressive generation, probability calibration, acceptance sampling, and benchmarking. That’s a full ML pipeline in a single project.
This project is particularly strong for roles titled ML Infrastructure Engineer, Inference Optimization Engineer, Applied Scientist, or Backend Engineer (ML Systems).
Architecture Overview
The speculative decoding engine consists of four core components that communicate through a shared token vocabulary and a probability space. Here’s how they fit together:
┌─────────────────────────────────────────────────────────────────┐
│ SPECULATIVE DECODING ENGINE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ draft_tokens ┌──────────────────┐ │
│ │ N-Gram │ ─────────────────────▶ │ Verification │ │
│ │ Draft Model │ (k candidate tokens) │ Transformer │ │
│ │ (fast, │ │ (slow, accurate)│ │
│ │ low mem) │ │ │ │
│ └──────────────┘ └────────┬─────────┘ │
│ │ │ │
│ │ accept/reject mask │ │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌───────────────────┐ │
│ │ Acceptance │ │ Token Generator │ │
│ │ Sampler │ │ (state manager) │ │
│ │ (bow-style │ │ │ │
│ │ probability │ │ Maintains: │ │
│ │ comparison) │ │ - kv cache │ │
│ │ │ │ - generated seq │ │
│ └────────┬─────────┘ └───────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Output Buffer │ │
│ │ (accepted tokens│ │
│ │ + new tokens) │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Component breakdown:
N-Gram Draft Model — A lightweight Kneser-Ney smoothed n-gram language model. It generates
kcandidate tokens autoregressively in a single pass. Because it’s small (often <10MB), it runs orders of magnitude faster than the transformer. Its job is purely to propose candidates, not to be correct.Verification Transformer — A pretrained decoder-only transformer (e.g., a distilled GPT-2 or a small LLaMA variant loaded via HuggingFace
transformers). It performs a single joint forward pass over the original prompt plus allkdraft tokens, producing position-wise logits for every draft position simultaneously. This is the key insight: verification is parallel, not sequential.Acceptance Sampler — Implements the Bow (Branching) speculative decoding algorithm. For each draft token position
i, it comparesP_draft(token_i | context)againstP_verify(token_i | context, draft_1...draft_i). The token is accepted with probability proportional to the ratio, subject to the standard acceptance condition from Speculative Decoding (Edward et al., 2022).Token Generator / State Manager — Maintains the autoregressive state: the current sequence, the KV cache for the verification transformer, and the accumulated output. After each speculation round, accepted tokens are appended; if rejection occurs at position
i, the process reverts and the verification model generates a single token autoregressively to continue.
Building It Step by Step
We’ll implement this in Python using torch, transformers, and numpy. The n-gram model is built from scratch (no heavy dependencies). The verification model uses HuggingFace’s GPT2LMHeadModel as a concrete example — you can swap it for any causal LM.
Step 1: Project Setup and Dependencies
mkdir speculative-decoding && cd speculative-decoding
python -m venv venv && source venv/bin/activate
pip install torch transformers numpy tiktoken accelerate
Step 2: The N-Gram Draft Model
The draft model uses Kneser-Ney smoothed trigrams. We build a lightweight class that precomputes log-probabilities and can sample the next token given a context window.
# draft_model.py
import numpy as np
from collections import defaultdict, Counter
from typing import List, Tuple
class NgramDraftModel:
"""Kneser-Ney smoothed trigram language model for speculative drafting."""
def __init__(self, tokenized_corpus: List[List[int]], vocab_size: int,
k: int = 5, discount: float = 0.75):
self.vocab_size = vocab_size
self.k = k # number of draft tokens to generate
self.discount = discount
# Count n-grams
self.trigram_counts = Counter()
self.bigram_counts = Counter()
self.unigram_counts = Counter()
self.bigram_continuation = defaultdict(Counter) # for Kneser-Ney
for sentence in tokenized_corpus:
padded = [0] * 2 + sentence + [1] # 0=BOS, 1=EOS
for i in range(len(padded) - 2):
w1, w2, w3 = padded[i], padded[i+1], padded[i+2]
self.trigram_counts[(w1, w2, w3)] += 1
self.bigram_counts[(w1, w2)] += 1
self.unigram_counts[w2] += 1
self.bigram_continuation[(w1, w2)][w3] += 1
# Precompute log-probabilities (with Laplace smoothing fallback)
self._log_probs = {}
self._build_log_probs()
def _build_log_probs(self):
total_unigrams = sum(self.unigram_counts.values())
num_bigrams = len(self.bigram_counts)
for (w1, w2, w3), count in self.trigram_counts.items():
bigram_count = self.bigram_counts[(w1, w2)]
continuation_count = len(self.bigram_continuation[(w1, w2)])
# Kneser-Ney: (max(c(w1,w2,w3) - D, 0) / c(w1,w2)) *
# (c(w2,w3) / num_unique_successors_of_w2)
# Plus continuation penalty for unseen trigrams
numerator = max(count - self.discount, 0)
denom = bigram_count
lambda_w2 = self.discount * num_bigrams / bigram_count if bigram_count > 0 else 0
if denom > 0 and numerator > 0:
self._log_probs[(w1, w2, w3)] = np.log(numerator / denom)
# Fallback: for unseen trigrams, use bigram continuation probability
for (w1, w2), conts in self.bigram_continuation.items():
for w3 in conts:
if (w1, w2, w3) not in self._log_probs:
self._log_probs[(w1, w2, w3)] = np.log(
conts[w3] / sum(conts.values()) + 1e-10
)
def draft_tokens(self, context: List[int], num_tokens: int = None) -> List[int]:
"""Generate `k` candidate tokens autoregressively from context."""
if num_tokens is None:
num_tokens = self.k
draft = []
current_context = context[-2:] # last two tokens for trigram
for _ in range(num_tokens):
candidates = []
scores = []
for token_id in range(self.vocab_size):
key = (current_context[0], current_context[1], token_id)
if key in self._log_probs:
candidates.append(token_id)
scores.append(self._log_probs[key])
else:
# Fallback to unigram
score = np.log((self.unigram_counts.get(token_id, 0) + 1) /
(sum(self.unigram_counts.values()) + self.vocab_size))
candidates.append(token_id)
scores.append(score)
scores = np.array(scores)
probs = np.exp(scores - scores.max()) # numerical stability
probs /= probs.sum()
next_token = np.random.choice(candidates, p=probs)
draft.append(next_token)
current_context = [current_context[1], next_token]
return draft
Step 3: The Verification Transformer
We load a pretrained causal LM and use it to compute joint probabilities over the draft sequence in a single forward pass.
# verify_model.py
import torch
import torch.nn.functional as F
from transformers import GPT2LMHeadModel, GPT2Tokenizer
from typing import List
class VerificationTransformer:
"""Verification model that scores draft tokens in a single joint forward pass."""
def __init__(self, model_name: str = "gpt2"):
self.tokenizer = GPT2Tokenizer.from_pretrained(model_name)
self.tokenizer.pad_token = self.tokenizer.eos_token
self.model = GPT2LMHeadModel.from_pretrained(model_name)
self.model.eval()
if torch.cuda.is_available():
self.model.to("cuda")
def verify(self, prompt_ids: List[int], draft_ids: List[int]) -> List[float]:
"""
Verify draft tokens against the model.
Returns a list of acceptance probabilities for each draft position.
The key insight: we run ONE forward pass over
[prompt_ids + draft_ids] and get logits for every position.
"""
input_ids = torch.tensor([prompt_ids + draft_ids],
device=self.model.device)
with torch.no_grad():
outputs = self.model(input_ids)
logits = outputs.logits # shape: (1, seq_len, vocab_size)
# Extract logits for each draft position
# Position i in draft corresponds to position len(prompt_ids) + i
draft_logits = logits[0, len(prompt_ids):, :]
draft_input_ids = input_ids[0, len(prompt_ids):]
acceptance_probs = []
cumulative_draft = draft_ids.copy()
for i in range(len(draft_ids)):
# Position-i logits: probability of draft[i] given
# prompt + draft[0..i-1]
pos_logits = draft_logits[i]
pos_ids = draft_input_ids[i]
# Compute P_verify(draft[i] | context) using softmax
probs = F.softmax(pos_logits, dim=-1)
p_verify = probs[pos_ids].item()
# Compute P_draft(draft[i] | context) from the draft model
# This is passed in from outside; we compute the acceptance ratio here
# For now, return the verification probability
acceptance_probs.append(p_verify)
return acceptance_probs
def generate_single(self, input_ids: List[int], num_tokens: int = 1) -> List[int]:
"""Fallback: generate tokens autoregressively using the verification model."""
ids = torch.tensor([input_ids], device=self.model.device)
with torch.no_grad():
output = self.model.generate(ids, max_new_tokens=num_tokens,
do_sample=False)
new_tokens = output[0][len(input_ids):].tolist()
return new_tokens
Step 4: The Speculative Decoding Loop
This is the core algorithm — the Bow (Branching) speculative decoding sampler that ties everything together.
# speculative_engine.py
import torch
import torch.nn.functional as F
import numpy as np
from typing import List, Tuple
class SpeculativeDecodingEngine:
"""
Orchestrates speculative decoding between a draft model and a verification model.
Algorithm (Bow-style, from Edward et al., 2022):
1. Draft k tokens using the n-gram model.
2. Run verification transformer on [prompt + draft] in ONE forward pass.
3. For each position i, accept draft[i] with probability:
min(1, P_verify(draft[i]) / P_draft(draft[i]))
4. If rejected at position i, use verification model to generate
a single replacement token and stop this round.
5. Repeat from step 1.
"""
def __init__(self, draft_model, verify_model, draft_k: int = 5):
self.draft_model = draft_model
self.verify_model = verify_model
self.k = draft_k
def _compute_acceptance_prob(self, p_verify: float, p_draft: float) -> float:
"""Acceptance probability per the Bow algorithm."""
if p_draft == 0:
return 0.0
return min(1.0, p_verify / p_draft)
def generate(self, prompt_text: str, max_new_tokens: int = 50) -> str:
"""Generate text using speculative decoding."""
# Tokenize prompt
prompt_ids = self.verify_model.tokenizer.encode(prompt_text)
generated = prompt_ids.copy()
tokens_generated = 0
speculation_rounds = 0
accepted_total = 0
draft_total = 0
while tokens_generated < max_new_tokens:
# --- Step 1: Draft ---
draft_tokens = self.draft_model.draft_tokens(
generated, num_tokens=min(self.k, max_new_tokens - tokens_generated)
)
draft_total += len(draft_tokens)
# --- Step 2: Verify (single joint forward pass) ---
acceptance_probs = self.verify_model.verify(generated, draft_tokens)
# --- Step 3: Acceptance Sampling ---
accepted_in_round = 0
for i, draft_token in enumerate(draft_tokens):
# Get draft probability from n-gram model
context = generated[-2:] if len(generated) >= 2 else [0, 0]
p_draft = self.draft_model._log_probs.get(
(context[0], context[1], draft_token), 1e-10
)
p_draft = np.exp(p_draft)
p_verify = acceptance_probs[i]
p_accept = self._compute_acceptance_prob(p_verify, p_draft)
if np.random.random() < p_accept:
generated.append(draft_token)
accepted_in_round += 1
accepted_total += 1
else:
# --- Rejection: generate single replacement ---
replacement = self.verify_model.generate_single(
generated[:i+1], num_tokens=1
)
generated.extend(replacement)
accepted_total += 1 # still accepted one token
break # stop this round, restart drafting
tokens_generated += accepted_in_round if accepted_in_round > 0 else 1
speculation_rounds += 1
if tokens_generated >= max_new_tokens:
break
# Decode output
output_text = self.verify_model.tokenizer.decode(
generated[len(prompt_ids):], skip_special_tokens=True
)
# Print stats
speedup = draft_total / max(accepted_total, 1)
print(f"\n--- Speculative Decoding Stats ---")
print(f"Tokens generated: {tokens_generated}")
print(f"Speculation rounds: {speculation_rounds}")
print(f"Draft tokens proposed: {draft_total}")
print(f"Tokens accepted: {accepted_total}")
print(f"Approximate speedup factor: {speedup:.2f}x")
return output_text
Step 5: Putting It All Together
# main.py
from draft_model import NgramDraftModel
from verify_model import VerificationTransformer
from speculative_engine import SpeculativeDecodingEngine
# --- Load a small training corpus for the n-gram model ---
# In practice, use a large text corpus (e.g., WikiText-2 or a subset of
# your target domain). Here we use a minimal example.
training_corpus = [
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
[1, 2, 3, 10, 9, 8, 7, 6, 5, 4],
[1, 5, 9, 2, 6, 10, 3, 7, 8, 4],
# ... in practice, tokenize a real corpus with tiktoken or sentencepiece
]
# For a real project, tokenize properly:
# import tiktoken
# enc = tiktoken.get_encoding("p50k_base")
# tokenized_corpus = [enc.encode(text) for text in open("corpus.txt")]
VOCAB_SIZE = 50257 # GPT-2 vocabulary size
# Initialize components
draft_model = NgramDraftModel(
tokenized_corpus=training_corpus,
vocab_size=VOCAB_SIZE,
k=5
)
verify_model = VerificationTransformer(model_name="gpt2-medium")
# Build and run the engine
engine = SpeculativeDecodingEngine(
draft_model=draft_model,
verify_model=verify_model,
draft_k=5
)
prompt = "The future of artificial intelligence depends on"
output = engine.generate(prompt, max_new_tokens=30)
print(f"\nPrompt: {prompt}")
print(f"Output: {output}")
Running and Testing It
Local Execution
# Activate your environment
source venv/bin/activate
# Run the full pipeline
python main.py
Expected output looks like:
--- Speculative Decoding Stats ---
Tokens generated: 30
Speculation rounds: 8
Draft tokens proposed: 38
Tokens accepted: 30
Approximate speedup factor: 1.27x
Prompt: The future of artificial intelligence depends on
Output: ...advanced optimization techniques that enable faster
Verification Tests
Create a test file to validate correctness:
# test_engine.py
import pytest
import torch
from speculative_engine import SpeculativeDecodingEngine
def test_acceptance_probability_bounds():
"""Acceptance probability must be in [0, 1]."""
engine = SpeculativeDecodingEngine.__new__(SpeculativeDecodingEngine)
# p_verify > p_draft → accept with prob 1
assert engine._compute_acceptance_prob(0.9, 0.3) == 1.0
# p_verify < p_draft → accept with prob ratio
assert engine._compute_acceptance_prob(0.3, 0.9) == pytest.approx(0.333, rel=0.01)
# p_draft = 0 → never accept
assert engine._compute_acceptance_prob(0.5, 0.0) == 0.0
def test_draft_model_returns_correct_length():
"""Draft model should always return exactly k tokens."""
from draft_model import NgramDraftModel
model = NgramDraftModel(
tokenized_corpus=[[1,2,3,4,5]] * 100,
vocab_size=100,
k=5
)
draft = model.draft_tokens([1, 2], num_tokens=5)
assert len(draft) == 5
def test_distribution_preservation():
"""Verify that speculative decoding approximately preserves
the target distribution. Run 1000 samples and check
that token frequencies are within a tolerance."""
# This is a statistical test — run with sufficient samples
# and compare against baseline GPT-2 generation
pass # Implement as part of your benchmarking suite
if __name__ == "__main__":
pytest.main([__file__, "-v"])
python -m pytest test_engine.py -v
Benchmarking Against Baseline
To prove the engine actually works, benchmark against pure autoregressive generation:
# benchmark.py
import time
from verify_model import VerificationTransformer
def benchmark_baseline(prompt: str, num_tokens: int = 50):
model = VerificationTransformer("gpt2-medium")
start = time.time()
output = model.generate_single(
model.tokenizer.encode(prompt), num_tokens=num_tokens
)
elapsed = time.time() - start
return elapsed, len(output)
def benchmark_speculative(prompt: str, num_tokens: int = 50):
# ... instantiate engine and run
# compare wall-clock time and tokens/second
pass
# Compare tokens/second
baseline_time, baseline_tokens = benchmark_baseline(
"The key to scalable inference is", 50
)
print(f"Baseline: {baseline_tokens / baseline_time:.1f} tokens/sec")
# Speculative should show 1.2x–3x speedup depending on draft quality
Extending It: Your Roadmap to Senior-Level
The code above gives you a working prototype. But the difference between a toy project and one that signals senior-level capability is in the extensions. Here are six concrete upgrades, each with a one-line reason it matters:
Add a KV-Cache Persistence Layer — Store and reuse the verification model’s KV cache across speculation rounds using a custom
KVCacheManagerclass so you’re not recomputing attention for the prompt prefix on every round. Why it matters: In production serving (think vLLM’s prefix caching), KV-cache reuse is the single biggest latency lever for repeated prompts.Implement Dynamic Draft Length Selection — Replace the fixed
k=5with an adaptive mechanism that increases draft length when acceptance rates are high and decreases it when they drop, using an exponential moving average of acceptance probability. Why it matters: Static speculation wastes compute on rejected drafts; adaptive length maximizes throughput under varying prompt difficulty, which is exactly what NVIDIA’s Dynamic Scratchpad paper describes.Add Structured Logging and Observability — Instrument every speculation round with structured logs (token counts, acceptance rates, latency percentiles) using
structlogoropentelemetry, and expose Prometheus metrics for draft efficiency, verification latency, and throughput. Why it matters: You can’t optimize what you can’t observe; every production ML system requires metrics dashboards and alerting.Build Fault-Tolerant Serving with a Retry Queue — Wrap the verification forward pass in a circuit breaker pattern (using
pybreakeror a custom implementation) that falls back to pure autoregressive generation if the GPU encounters OOM or timeout errors, and queue rejected requests for asynchronous re-processing. Why it matters: Production systems must degrade gracefully; a single GPU hang shouldn’t cascade into a service outage.Add Horizontal Scaling with a Model Router — Deploy multiple verification model replicas behind a load balancer using
rayortorchserve, and implement a request router that assigns speculative decoding jobs based on GPU memory availability and current queue depth. Why it matters: Horizontal scaling is the defining architecture pattern of production ML serving; showing you understand stateless inference workers and request routing is a senior signal.Implement Comprehensive Benchmarking Suite with A/B Comparison — Build a benchmarking harness that compares speculative decoding against baseline autoregressive generation across multiple metrics (tokens/second, latency p50/p95/p99, output quality via perplexity and BLEU), with configurable draft model sizes and verification model sizes, and output results as JSON for CI integration. Why it matters: Every performance optimization in production needs provable, reproducible measurements; A/B benchmarking is how ML infrastructure teams justify architectural decisions to stakeholders.
Key Takeaways
- Speculative decoding is a provably correct speedup technique — it doesn’t approximate the output distribution; it preserves it under the Bow acceptance condition, making it safe for production use.
- The core engineering insight is that verification is parallel: a single forward pass over the prompt plus all draft tokens scores every position simultaneously, which is why the speedup comes from amortizing the expensive transformer pass across multiple tokens.
- This project demonstrates exactly the skill stack that ML infrastructure teams hire for: model optimization, systems architecture, performance engineering, and observability — all in one cohesive codebase.
- The n-gram draft model is intentionally simple — its only job is to propose candidates quickly. The real intelligence lives in the verification transformer’s joint scoring, which is what makes the technique work.
- The six extension roadmap items map directly to production concerns: caching, adaptivity, observability, fault tolerance, scaling, and benchmarking. Implementing even three of them transforms this from a portfolio project into a credible systems demonstration.
- Start with the working prototype above, then layer extensions incrementally — each one is independently justifiable and demonstrates a different facet of senior-level engineering thinking.
Further Reading
- Speculative Decoding — Edward et al., 2022. The original paper that introduced the Bow speculative decoding algorithm. Read this first; it’s the theoretical foundation for everything in this project.
- Accelerating Large Language Model Decoding with Speculative Sampling — Gao et al., 2023. DeepMind’s follow-up that demonstrated 2–3x speedup on large models and introduced the “lookahead” variant. Essential for understanding the state of the art.
- vLLM: Easy, Fast, and Cheap LLM Serving Platform — The vLLM documentation, specifically the sections on speculative decoding and PagedAttention. This is the production system that popularized speculative decoding at scale and provides the architecture patterns to study.
- DeepSeek-MoE: Towards Ultimate Model Efficiency — While focused on mixture-of-experts, this paper from DeepSeek discusses the broader context of inference optimization techniques including speculative decoding in their pipeline.
- HuggingFace Transformers Documentation: Generation Strategies — The canonical reference for how
model.generate()works under the hood, including logits processing, sampling strategies, and caching. Critical for understanding what the verification transformer is doing. - TensorRT-LLM: Speculative Decoding Implementation — NVIDIA’s open-source implementation of speculative decoding with their custom CUDA kernels. Study this for production-grade patterns including batch scheduling and kernel-level optimizations.
- Kneser-Ney Smoothing — The Wikipedia article and associated NLTK documentation for the smoothing technique used in the draft model. Understanding this is key to building a competent n-gram language model.