02 — From-Scratch Inference (Round 2 Build)¶
“Round 1 taught you to train. Round 2 teaches you to serve. The two skills share vocabulary and share nothing else.”
This is the meat of Phase 1. Time budget: 3–5 weeks of evenings.
The goal: a single Python file (~500–800 LOC) that loads a real HuggingFace model from safetensors and produces byte-identical output to transformers.generate(do_sample=False). When this runs green, you will understand — physically — what every LLM inference server is doing.
The Target Model¶
Pick ONE of these and stick with it:
Model |
Params |
Attention |
Why it’s a good target |
|---|---|---|---|
|
1.24 B |
GQA (32 Q, 8 KV) |
Small, canonical, modern arch, easy fp16 on any GPU |
|
1.54 B |
GQA (12 Q, 2 KV) |
Same family as bigger Qwens; strong benchmark posture |
|
1.71 B |
GQA |
Small, high-quality; a pleasure to inspect |
All three use RoPE and GQA — exactly the mechanisms you must implement. Pick Llama-3.2-1B unless you have a strong reason otherwise; the Llama family is the most widely-documented reference.
Section 1 — The Safetensors Format¶
Spec: https://github.com/huggingface/safetensors
Every modern model checkpoint on HuggingFace ships as safetensors (increasingly, never pickle). You should be able to parse the format yourself — no library. Do this before you use safetensors.torch.load_file.
The layout, in bytes¶
[ 8 bytes: u64 header_size, little-endian ]
[ header_size bytes: UTF-8 JSON metadata ]
[ remainder: raw tensor bytes, tightly packed ]
The JSON metadata is a dict of tensor-name → {dtype, shape, data_offsets}. data_offsets is [start, end] inclusive-exclusive within the tensor-bytes region.
Do this drill (30 minutes)¶
import json, struct, numpy as np
def parse_safetensors(path):
with open(path, 'rb') as f:
header_size = struct.unpack('<Q', f.read(8))[0]
header = json.loads(f.read(header_size).decode('utf-8'))
tensors = {}
for name, info in header.items():
if name == '__metadata__':
continue
shape = info['shape']
dtype = info['dtype'] # e.g., 'F16', 'BF16', 'F32'
start, end = info['data_offsets']
f.seek(8 + header_size + start)
raw = f.read(end - start)
# dtype → numpy dtype mapping (bf16 needs special handling)
tensors[name] = (raw, dtype, shape)
return tensors
Acceptance: read Llama-3.2-1B’s safetensors file, dump the tensor-name → shape mapping, and match it against model.state_dict().keys() from HF. Zero mismatches.
Why this matters: in Phase 4 you’ll want to mmap huge model files and stream them into VRAM. Understanding the format now is one afternoon of investment that pays for years.
Section 2 — The Tokenizer¶
Use the library, but understand BPE¶
Use tokenizers (HuggingFace’s Rust-backed library) for the real work — do not reimplement production BPE for a real model. But do implement toy BPE in ~100 lines of Python on a small corpus. This is the exact assignment Stanford CS336 Assignment 1 walks you through.
The BPE algorithm, in six lines of pseudocode¶
Initialize vocabulary = all bytes (256 tokens).
Repeat until vocab_size reached:
Count all adjacent pairs in the corpus.
Find the most frequent pair (a, b).
Add a new token "ab" to the vocab.
Replace all (a, b) with "ab" in the corpus.
Modern tokenizers add: byte-level BPE (Llama, GPT), regex pre-tokenization (GPT-2’s famous r"""'s|'t|...""" regex), special tokens (BOS, EOS, PAD, tool tokens).
The round-trip test¶
from tokenizers import Tokenizer
tok = Tokenizer.from_pretrained('meta-llama/Llama-3.2-1B')
text = "The quick brown fox jumps over the lazy dog."
ids = tok.encode(text).ids
back = tok.decode(ids)
assert back == text
This MUST be lossless for well-formed input. If your inference stack decodes anything wrong, this is the first place to check.
The Karpathy tokenizer video¶
“Let’s build the GPT Tokenizer” (~2h): https://www.youtube.com/watch?v=zduSFxRajkE
Watch this. Then implement your toy BPE from scratch on a small corpus (e.g., TinyShakespeare). Compare vocab merges to what tokenizers produces. Look at the actual Llama-3 tokenizer.json — wc -l gives you ~128k vocab entries; open it in an editor and see the actual merges. This concreteness sticks.
Section 3 — The Model, Piece by Piece¶
Build these classes in a single model.py. All shapes documented in comments — wrong shape is bug #1 in every from-scratch implementation.
3.1 Config¶
@dataclass
class ModelConfig:
vocab_size: int
hidden_size: int # aka d_model
intermediate_size: int # SwiGLU MLP hidden dim
num_hidden_layers: int # transformer blocks
num_attention_heads: int # Q heads
num_key_value_heads: int # KV heads (GQA)
head_dim: int # usually hidden_size // num_attention_heads
max_position_embeddings: int
rope_theta: float # RoPE base frequency (10000 or 500000 for Llama-3)
rms_norm_eps: float
tie_word_embeddings: bool
Load it from HF’s config.json. Print it. Every field should have meaning to you.
3.2 RMSNorm¶
class RMSNorm(nn.Module):
def __init__(self, dim, eps=1e-6):
super().__init__()
self.weight = nn.Parameter(torch.ones(dim))
self.eps = eps
def forward(self, x):
# x: (..., dim)
variance = x.pow(2).mean(-1, keepdim=True)
x = x * torch.rsqrt(variance + self.eps)
return self.weight * x
Why RMSNorm and not LayerNorm? RMSNorm drops the mean-subtraction (Zhang & Sennrich, 2019: https://arxiv.org/abs/<phone_number_or_numberic_id_or_random_id_10>). Slightly faster, no quality hit. Llama, Qwen, Mistral all use it. Note there’s no bias term either — the recentering-invariance argument justifies both simplifications.
3.3 RoPE (Rotary Positional Embedding)¶
Paper: Su et al., “RoFormer” (2021): https://arxiv.org/abs/<phone_number_or_numberic_id_or_random_id_11>
The math, briefly: for a position m and a pair of dimensions (2i, 2i+1), rotate (x_{2i}, x_{2i+1}) by angle m * θ_i where θ_i = base^(-2i/d). In matrix form for one 2-D pair:
[ cos(mθ) -sin(mθ) ] [ x_{2i} ]
[ sin(mθ) cos(mθ) ] [ x_{2i+1} ]
Applied to Q and K before attention. Not to V. The property that matters: <RoPE(q, m), RoPE(k, n)> depends only on m - n (relative position). This is why RoPE generalizes better than absolute learned positional embeddings.
Standard implementation (Llama-style):
def precompute_rope_cache(head_dim, max_seq_len, base=10000.0, device='cuda'):
# freqs: [head_dim/2]
freqs = 1.0 / (base ** (torch.arange(0, head_dim, 2, device=device).float() / head_dim))
# positions: [max_seq_len]
t = torch.arange(max_seq_len, device=device).float()
# outer product: [max_seq_len, head_dim/2]
freqs = torch.outer(t, freqs)
# cos and sin caches
cos = freqs.cos() # [max_seq_len, head_dim/2]
sin = freqs.sin()
return cos, sin
def apply_rope(x, cos, sin):
# x: [batch, seq, n_heads, head_dim]
# split last dim into pairs [head_dim/2, 2] and interpret as (real, imag)
x1 = x[..., 0::2] # even dims
x2 = x[..., 1::2] # odd dims
# rotate
rotated1 = x1 * cos - x2 * sin
rotated2 = x1 * sin + x2 * cos
# interleave back
return torch.stack([rotated1, rotated2], dim=-1).flatten(-2)
The trap: HF’s Llama uses a different dimension pairing (contiguous halves, not interleaved). Look at transformers/models/llama/modeling_llama.py apply_rotary_pos_emb — you’ll see it splits x[..., :d/2] and x[..., d/2:] and rotates them as (a, b) → (a*cos - b*sin, b*cos + a*sin). Use HF’s convention if you want token-identical output.
3.4 Grouped-Query Attention (GQA)¶
Paper: Ainslie et al., “GQA” (2023): https://arxiv.org/abs/<phone_number_or_numberic_id_or_random_id_12>
The idea: instead of H query heads each with its own K, V (MHA) or all sharing one K, V (MQA), use G KV heads where each is shared by H/G query heads. Llama-3-8B: 32 Q, 8 KV, ratio 4. Llama-3-70B: 64 Q, 8 KV, ratio 8. KV cache shrinks by H/G× — the whole reason GQA exists.
class GroupedQueryAttention(nn.Module):
def __init__(self, config):
super().__init__()
self.n_heads = config.num_attention_heads
self.n_kv_heads = config.num_key_value_heads
self.head_dim = config.head_dim
self.n_rep = self.n_heads // self.n_kv_heads # broadcast factor
self.wq = nn.Linear(config.hidden_size, self.n_heads * self.head_dim, bias=False)
self.wk = nn.Linear(config.hidden_size, self.n_kv_heads * self.head_dim, bias=False)
self.wv = nn.Linear(config.hidden_size, self.n_kv_heads * self.head_dim, bias=False)
self.wo = nn.Linear(self.n_heads * self.head_dim, config.hidden_size, bias=False)
def forward(self, x, kv_cache, position_ids, cos, sin):
B, T, C = x.shape
q = self.wq(x).view(B, T, self.n_heads, self.head_dim)
k = self.wk(x).view(B, T, self.n_kv_heads, self.head_dim)
v = self.wv(x).view(B, T, self.n_kv_heads, self.head_dim)
q = apply_rope(q, cos[position_ids], sin[position_ids])
k = apply_rope(k, cos[position_ids], sin[position_ids])
# Append to KV cache
k, v = kv_cache.append(k, v) # returns full k, v history
# Repeat KV to match Q head count
k = repeat_kv(k, self.n_rep) # (B, T_full, n_heads, head_dim)
v = repeat_kv(v, self.n_rep)
# Transpose to (B, n_heads, T, head_dim) for matmul
q = q.transpose(1, 2)
k = k.transpose(1, 2)
v = v.transpose(1, 2)
# Scaled dot-product attention with causal mask
scale = 1.0 / math.sqrt(self.head_dim)
scores = torch.matmul(q, k.transpose(-2, -1)) * scale
# Causal mask: q of position i attends to k of positions 0..i
# (During decode T=1, mask is trivial; during prefill it's a triangle.)
scores = apply_causal_mask(scores, position_ids)
attn = torch.softmax(scores, dim=-1, dtype=torch.float32).to(q.dtype)
out = torch.matmul(attn, v) # (B, n_heads, T, head_dim)
out = out.transpose(1, 2).contiguous().view(B, T, self.n_heads * self.head_dim)
return self.wo(out)
def repeat_kv(x, n_rep):
# x: (B, T, n_kv_heads, head_dim) -> (B, T, n_kv_heads * n_rep, head_dim)
if n_rep == 1:
return x
B, T, H, D = x.shape
return x[:, :, :, None, :].expand(B, T, H, n_rep, D).reshape(B, T, H * n_rep, D)
The compute-KV-savings drill: For Llama-3-8B (32 Q heads, 8 KV heads, head_dim 128), full MHA would use 32 KV heads at inference. GQA uses 8. KV cache memory is exactly 8/32 = 1/4 of the equivalent MHA. Compute exactly: 2 * 32 * 8 * 128 * 2 bytes/token = 131,072 bytes/token. Full MHA equivalent would be 2 * 32 * 32 * 128 * 2 = 524,288. Ratio 4×.
3.5 SwiGLU MLP¶
Paper: Shazeer, “GLU Variants Improve Transformer” (2020): https://arxiv.org/abs/<phone_number_or_numberic_id_or_random_id_13>
class SwiGLU(nn.Module):
def __init__(self, config):
super().__init__()
self.gate = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
self.up = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
self.down = nn.Linear(config.intermediate_size, config.hidden_size, bias=False)
def forward(self, x):
return self.down(F.silu(self.gate(x)) * self.up(x))
Note: 3 linear layers, not 2 (as in vanilla FFN). Parameter count is 3× hidden×intermediate, not 2×. Llama-3 intermediate size is chosen so total MLP params ≈ 4× hidden² × [Llama-3’s factor]. Details in the arithmetic notebook.
3.6 The Transformer Block¶
class TransformerBlock(nn.Module):
def __init__(self, config):
super().__init__()
self.attn_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
self.attn = GroupedQueryAttention(config)
self.mlp_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
self.mlp = SwiGLU(config)
def forward(self, x, kv_cache, position_ids, cos, sin):
x = x + self.attn(self.attn_norm(x), kv_cache, position_ids, cos, sin)
x = x + self.mlp(self.mlp_norm(x))
return x
Pre-norm (Llama-style), not post-norm (original transformer). Pre-norm is more numerically stable for deep networks.
3.7 The Full Model¶
class Llama(nn.Module):
def __init__(self, config):
super().__init__()
self.embed = nn.Embedding(config.vocab_size, config.hidden_size)
self.layers = nn.ModuleList([TransformerBlock(config) for _ in range(config.num_hidden_layers)])
self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
if config.tie_word_embeddings:
self.lm_head.weight = self.embed.weight
cos, sin = precompute_rope_cache(config.head_dim, config.max_position_embeddings, config.rope_theta)
self.register_buffer('cos', cos)
self.register_buffer('sin', sin)
def forward(self, input_ids, kv_caches, position_ids):
x = self.embed(input_ids)
for layer, kv in zip(self.layers, kv_caches):
x = layer(x, kv, position_ids, self.cos, self.sin)
x = self.norm(x)
return self.lm_head(x) # (B, T, vocab)
Section 4 — The KV Cache¶
Why it exists¶
During autoregressive decoding, at step t we compute Q, K, V for position t, then attend to K/V of positions 0..t. If we don’t cache K and V, each step re-runs the full transformer over the entire history — O(t) work at every step — for total O(N²) generation cost. With a cache, each step is O(t) memory access but only new-token compute → total O(N) work per token, dominated by memory bandwidth.
Implementation¶
Allocate contiguous tensors up-front (avoids re-allocation each step):
class KVCache:
def __init__(self, max_batch, max_seq, n_kv_heads, head_dim, dtype=torch.float16, device='cuda'):
self.k = torch.zeros((max_batch, max_seq, n_kv_heads, head_dim), dtype=dtype, device=device)
self.v = torch.zeros((max_batch, max_seq, n_kv_heads, head_dim), dtype=dtype, device=device)
self.length = 0
def append(self, k_new, v_new):
# k_new: (B, T_new, n_kv_heads, head_dim)
B, T_new, H, D = k_new.shape
self.k[:B, self.length:self.length+T_new] = k_new
self.v[:B, self.length:self.length+T_new] = v_new
self.length += T_new
return self.k[:B, :self.length], self.v[:B, :self.length]
The naive-vs-cached measurement¶
Write two versions of generate():
Naive: re-run the model on the entire sequence
[prompt..., generated_so_far]for every new token. Discard everything but the last logit.Cached: run the model on
[new_token]only, with the cache handling the history.
Measure wall-clock time to generate 128 tokens from a 32-token prompt with both. On a 1B model on a 3090 you should see roughly:
Naive: quadratic slowdown; last token takes ~5–10× the first token’s time.
Cached: flat per-token latency after the first (prefill) token.
Plot both. The plot is a required artifact of Phase 1.
Section 5 — The Sampling Ladder¶
Implement each sampler as a LogitsProcessor callable that takes and returns a (B, vocab) tensor of logits. Chain them.
class TemperatureProcessor:
def __init__(self, T): self.T = T
def __call__(self, logits): return logits / self.T
class TopKProcessor:
def __init__(self, k): self.k = k
def __call__(self, logits):
v, _ = torch.topk(logits, self.k, dim=-1)
threshold = v[..., -1, None]
return torch.where(logits < threshold, torch.full_like(logits, -float('inf')), logits)
class TopPProcessor:
def __init__(self, p): self.p = p
def __call__(self, logits):
sorted_logits, sorted_idx = torch.sort(logits, descending=True, dim=-1)
probs = torch.softmax(sorted_logits, dim=-1)
cumprobs = torch.cumsum(probs, dim=-1)
mask = cumprobs > self.p
mask[..., 1:] = mask[..., :-1].clone()
mask[..., 0] = False
sorted_logits.masked_fill_(mask, -float('inf'))
return sorted_logits.scatter(-1, sorted_idx, sorted_logits)
class RepetitionPenaltyProcessor:
def __init__(self, penalty, seen_ids): self.penalty = penalty; self.seen = seen_ids
def __call__(self, logits):
for i, ids in enumerate(self.seen):
for tid in ids:
logits[i, tid] = logits[i, tid] / self.penalty if logits[i, tid] > 0 else logits[i, tid] * self.penalty
return logits
def sample(logits, processors):
for p in processors:
logits = p(logits)
probs = torch.softmax(logits, dim=-1)
return torch.multinomial(probs, 1)
Greedy verification: the whole payoff of Phase 1 is that with sample = argmax, your output matches HF exactly. If it doesn’t, the bug is in one of: RoPE convention, causal mask, RMSNorm epsilon location, GQA broadcasting, or safetensors weight-name mapping. In that order of frequency.
Section 6 — Weight Loading (The Boring, Critical Part)¶
HF names weights like model.layers.0.self_attn.q_proj.weight. You name them layers.0.attn.wq.weight. You need a mapping:
HF_TO_MINE = {
'model.embed_tokens.weight': 'embed.weight',
'model.layers.{i}.input_layernorm.weight': 'layers.{i}.attn_norm.weight',
'model.layers.{i}.self_attn.q_proj.weight': 'layers.{i}.attn.wq.weight',
'model.layers.{i}.self_attn.k_proj.weight': 'layers.{i}.attn.wk.weight',
'model.layers.{i}.self_attn.v_proj.weight': 'layers.{i}.attn.wv.weight',
'model.layers.{i}.self_attn.o_proj.weight': 'layers.{i}.attn.wo.weight',
'model.layers.{i}.post_attention_layernorm.weight': 'layers.{i}.mlp_norm.weight',
'model.layers.{i}.mlp.gate_proj.weight': 'layers.{i}.mlp.gate.weight',
'model.layers.{i}.mlp.up_proj.weight': 'layers.{i}.mlp.up.weight',
'model.layers.{i}.mlp.down_proj.weight': 'layers.{i}.mlp.down.weight',
'model.norm.weight': 'norm.weight',
'lm_head.weight': 'lm_head.weight', # or tied to embed
}
Write a load_from_hf(model, safetensors_path) function that loads and shape-checks every tensor. The first time a shape mismatch happens, you’ll want to have this catcher in place.
Section 7 — Long-Context and RoPE Extensions (Awareness)¶
Read but don’t implement yet:
Position Interpolation (PI): rescale positions by
L_new / L_originalbefore RoPE. Fast to apply, some quality loss. Chen et al. 2023: https://arxiv.org/abs/<phone_number_or_numberic_id_or_random_id_14>NTK-aware scaling: scale the RoPE base frequency instead of positions. Preserves high-frequency information better.
YaRN (Yet another RoPE extensioN): piecewise treatment of high vs low frequencies + attention temperature scaling. Peng et al., ICLR 2024: https://arxiv.org/abs/<phone_number_or_numberic_id_or_random_id_15>. Repo: https://github.com/jquesnelle/yarn
LongRoPE (Microsoft, 2024): search-based non-uniform interpolation, scales to 2M+ tokens.
Llama-3.1’s approach: YaRN-style, scaling factor 8× to reach 131k from 8k base. See
config.jsonfields:rope_scaling.factor,rope_scaling.low_freq_factor,rope_scaling.high_freq_factor,rope_scaling.original_max_position_embeddings.
Best 2025 explainer covering APE → RoPE → PI → NTK → YaRN evolution: https://amaarora.github.io/posts/2025-09-21-rope-context-extension.html
You will implement long-context RoPE in Phase 4 when you serve at 128k contexts. For now, know what these are so rope_scaling in config.json doesn’t confuse you.
Section 8 — The Verification Ritual¶
A checklist to run before declaring Phase 1 exit:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
import my_impl
tok = AutoTokenizer.from_pretrained('meta-llama/Llama-3.2-1B')
hf_model = AutoModelForCausalLM.from_pretrained('meta-llama/Llama-3.2-1B', torch_dtype=torch.float16).cuda()
my_model = my_impl.load('meta-llama/Llama-3.2-1B').cuda()
prompt = "The three most important numbers in inference engineering are"
ids = tok(prompt, return_tensors='pt').input_ids.cuda()
# 1. Token-identical greedy output for 50 tokens.
hf_out = hf_model.generate(ids, max_new_tokens=50, do_sample=False)
my_out = my_impl.generate(my_model, ids, max_new_tokens=50, do_sample=False)
assert torch.equal(hf_out, my_out), (hf_out, my_out)
# 2. Logit-close: for the last token of the prompt, logits within 1e-2 relative in fp16.
with torch.no_grad():
hf_logits = hf_model(ids).logits[:, -1, :]
my_logits = my_impl.forward_once(my_model, ids)[:, -1, :]
diff = (hf_logits - my_logits).abs().max().item()
assert diff < 1e-1, diff # fp16 accumulates ~1e-2 to 1e-1 error on real models
# 3. Perplexity on a real corpus (WikiText-2 test set) within 5% of HF.
ppl_hf = evaluate_perplexity(hf_model, tok, corpus)
ppl_my = evaluate_perplexity(my_model, tok, corpus)
assert abs(ppl_hf - ppl_my) / ppl_hf < 0.05
When all three pass, you own inference. When they don’t, use these diagnostic strategies (they will find every bug):
Compare hidden state after each layer. Instrument
layer_0output,layer_1output, etc., and diff against HF’soutput_hidden_states=True. The first layer that diverges is where your bug is.Compare Q, K, V, and attention scores individually. RoPE convention mismatch shows up in Q/K but not V. Causal mask bugs show in scores.
Bit-check for the classic: sinusoidal RoPE pair convention (interleaved vs half-half).
Time Estimate¶
Task |
Hours |
|---|---|
Safetensors parser |
2 |
Toy BPE tokenizer |
4 |
RMSNorm, SwiGLU, config parsing |
3 |
RoPE + verification against HF |
4–6 (this bites more than expected) |
GQA attention + repeat_kv |
4 |
Full model assembly + weight loader |
4 |
Naive generate + cached generate + comparison plot |
4 |
Sampling ladder |
3 |
Verification ritual + debugging |
6–12 (the bug-hunt is the education) |
Total |
~40–50 hours — 3–5 weeks of evenings |
When the verification ritual is green, commit. Push. Tweet the perplexity graph. Move to Phase 2.