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:

  1. N-Gram Draft Model — A lightweight Kneser-Ney smoothed n-gram language model. It generates k candidate 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.

  2. 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 all k draft tokens, producing position-wise logits for every draft position simultaneously. This is the key insight: verification is parallel, not sequential.

  3. Acceptance Sampler — Implements the Bow (Branching) speculative decoding algorithm. For each draft token position i, it compares P_draft(token_i | context) against P_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).

  4. 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:

  1. Add a KV-Cache Persistence Layer — Store and reuse the verification model’s KV cache across speculation rounds using a custom KVCacheManager class 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.

  2. Implement Dynamic Draft Length Selection — Replace the fixed k=5 with 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.

  3. Add Structured Logging and Observability — Instrument every speculation round with structured logs (token counts, acceptance rates, latency percentiles) using structlog or opentelemetry, 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.

  4. Build Fault-Tolerant Serving with a Retry Queue — Wrap the verification forward pass in a circuit breaker pattern (using pybreaker or 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.

  5. Add Horizontal Scaling with a Model Router — Deploy multiple verification model replicas behind a load balancer using ray or torchserve, 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.

  6. 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.