Tiny Transformer
Tiny Transformer walkthrough
A complete mini-GPT brain in 26,784 params —A complete mini-GPT in 26,784 params: RMSNorm, RoPE, GQA, SwiGLU, wiring, HF parity.
Start quiz →26,784
params total
[2, 5, 32]
running example
0.0
max error vs HF
~0.45ms
forward (CPU)
Signal order: config → norm → position → attention → MLP → block → proof. One running example throughout: x=[2,5,32] (2 sentences, 5 tokens each, 32 numbers per token), n_heads=4, n_kv_heads=2, head_dim=8, d_ff=64, n_layers=2, vocab=128, seq=64, theta=10000, n_rep=2.
Core formulas: , attention , SwiGLU .
§0 · Dependencies
Now reproducible
torch>=2.14.0 is the only runtime dependency: everything in model/ is pure PyTorch (nn.Linear, nn.Embedding, softmax, matmul). transformers==5.17.0 is pinned in dev deps and used solely by reference.py to build the LlamaForCausalLM answer key — pinned exact because even a minor HF RoPE or norm tweak would break the 1e-4 numerical match. REFERENCE_TRANSFORMERS in code mirrors this pin. pytest is the dev-only runner, and setting pythonpath to the project root is why from model.config import ... works when you run uv run pytest inside llm-lab/. No Kafka, no server, no GPU requirement — CPU is sufficient, the whole model is 26k params.
config.py
pure arithmetic
tiny_gpt.py
+ torch.nn
reference.py
+ transformers (dev)
§1 · config.py
The recipe card everything derives from
Taped to the fridge. Nothing computes here. It says: we speak 128 tokens, every token is 32 numbers, stack 2 layers, each layer has 4 question-heads sharing 2 key/value-heads. Every nn.Linear size below is arithmetic on these numbers — change d_model to 64 and the whole building reshapes. frozen=True means a struct you cannot accidentally mutate mid-run: config is truth, not state. d_model=32 is the width of the residual stream, the highway tokens ride. n_heads=4, n_kv_heads=2, head_dim=8 is the attention geometry: 4 query vectors of 8 numbers is exactly 32 (d_model), with only 2 key/value vectors — that is Grouped-Query Attention. d_ff=64 is the MLP workroom width, tokens detour 32→64→32. rope_theta=10_000.0 is the RoPE base frequency, bigger means slower-spinning hands. tie_embeddings=False means the input lookup table and the output vote matrix are separate, costing an extra 4k params (sharing would save it).
Recipe chips
Every Linear, derived
q_proj [32, 32] = 4 heads × 8
k, v_proj [32, 16] = 2 kv-heads × 8
o_proj [32, 32] → back to stream
gate, up [32, 64] · down [64, 32]
embed [128, 32] · head [32, 128]
The guardrails: 4 % 2 == 0 means 2 query heads can cleanly share each KV head (n_rep = 2). RoPE rotates pairs of numbers like x/y coordinates — an odd dim leaves one number partnerless, so evenness is rejected in both config.py and rope.py.
model/config.py— frozen recipe plus the three guardrails
@dataclass(frozen=True)
class TinyGPTConfig:
vocab_size: int = 128
n_layers: int = 2
d_model: int = 32
n_heads: int = 4
n_kv_heads: int = 2
head_dim: int = 8
d_ff: int = 64
max_seq_len: int = 64
rope_theta: float = 10_000.0
rms_norm_eps: float = 1e-5
tie_embeddings: bool = False
def __post_init__(self) -> None:
if self.n_kv_heads < 1:
raise ValueError("n_kv_heads must be >= 1")
if self.n_heads % self.n_kv_heads != 0:
raise ValueError(...)
if self.head_dim % 2 != 0:
raise ValueError("head_dim must be even so RoPE can pair dimensions")§2 · rmsnorm.py
The per-token volume knob
RMSNorm runs 5 times per forward (twice per layer + once at the end). Think of it as a volume knob per token: it forces each token's 32 numbers to unit loudness, then applies 32 learned fader sliders (self.weight, starting all-ones). What it does NOT do: never looks across the sequence — token 3's loudness knows nothing about token 4 — and it does not subtract the mean (that is LayerNorm). Llama showed mean-centering is not worth the extra reduction.
Forward pass, step by step, with x of shape [2, 5, 32]: 1. Upcast: x.float() — math in fp32 even if the stream is fp16, precision where it matters. 2. Loudness: pow(2).mean(dim=-1, keepdim=True) gives [2, 5, 1], the mean-square per token. keepdim=True keeps [2,5,1] so it broadcasts against [2,5,32]. 3. Normalize: x * rsqrt(var + eps), that is x / sqrt(mean(x²)), with eps=1e-5 avoiding divide-by-zero on silent tokens. 4. Rescale and restore: weight * x32, cast back to the input dtype. Formula: .
Four-stage strip · toggle the silent token
Why no bias, no mean? One reduction instead of two, fewer params, identical quality at this scale. Why the dtype dance? mean(pow(2)) overflows easily in fp16 — computing in fp32 then returning keeps the stream fast but the math safe. Why eps inside the sqrt? rsqrt(0) is inf, and eps guarantees a floor.
RMSNorm = per-token loudness normalizer + learned per-channel fader.
model/rmsnorm.py— one computation, keepdim=True, fp32 math with dtype restore
class RMSNorm(nn.Module):
def __init__(self, d_model: int, eps: float = 1e-5):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(d_model))
def forward(self, x: torch.Tensor):
orig_dtype = x.dtype
x32 = x.float()
variance = x32.pow(2).mean(dim=-1, keepdim=True)
x32 = x32 * torch.rsqrt(variance + self.eps)
return (self.weight.float() * x32).to(orig_dtype)§3 · rope.py
The rotating lens that gives order
Attention is order-blind — shuffle inputs, get shuffled outputs. RoPE fixes it by rotating queries and keys by an angle proportional to position. Each pair of numbers is a clock hand: position 0 leaves hands alone, position 5 twists them. When query at m dots key at n, the relative twist m−n falls out of the dot product automatically. No position table to learn, and it generalizes past max_seq_len=64 better than absolute embeddings. Applied to q and k, never v — scores need position, while the content being averaged must not be scrambled.
Building blocks: with head_dim=8, arange(0,8,2) = [0,2,4,6] gives 4 pairs, and inv_freq = 1 / theta^(i/8). Pair 0 has 10_000^0 = 1 and spins fast at 1 rad per step; pair 3 has 10_000^0.75, roughly 1000, and spins slow at 0.001 rad per step — fine versus coarse position. freqs = position × inv_freq, so position 5 on pair 0 is 5.0 rad and on pair 3 is 0.005 rad. rotate_half([a|b]) = [-b|a] is a 90° rotation in halves-pairing: the first half pairs with the second half, (0,4),(1,5),(2,6),(3,7) for dim 8. This is Llama/GPT-NeoX style, *not* neighbor pairing (0,1),(2,3) — matching the HF pairing is why the error is 0.0. Finally x·cos + rotate(x)·sin is a true 2D rotation, vectorized.
| pair j | dims (halves) | ω rad/step | period (tokens) |
|---|---|---|---|
| 0 | (0,4) | 1 | 6.28 |
| 1 | (1,5) | 0.1 | 62.8 |
| 2 | (2,6) | 0.01 | 628 |
| 3 | (3,7) | 0.001 | 6283 |
Four clock hands · drag position m
pair 0 · ω=1
θ=5.000 rad
pair 1 · ω=0.1
θ=0.500 rad
pair 2 · ω=0.01
θ=0.050 rad
pair 3 · ω=0.001
θ=0.005 rad
m = 5 · fast hand spun 5.00 rad, slow hand barely moved 0.005 rad
Forward pass with positions [0,1,2,3,4] and head_dim=8: rotary_frequencies returns cos_half, sin_half each [5, 4]; rope_cos_sin duplicates them to [5, 8] each; in attention they are unsqueezed to [1,1,5,8] and broadcast against [B,H,5,8] — same twist for every batch and head, different twist per position. Position 0 is identity (cos=1, sin=0), so apply_rope(x) = x there. Why even-only? You cannot rotate a loner, and the config check plus this check double-guard it. Why theta=10_000? The Llama default — the slow hands do not wrap within 64 tokens. Why halves-pairing, not interleaved? Because that is what LlamaForCausalLM does; pick the other pairing and the reference check fails even with identical weights.
RoPE = stamp each position as a rotation; dot products then feel relative distance for free.
model/rope.py— frequencies, halves-pairing rotation, duplication to full width
def rotary_frequencies(head_dim, positions, theta=10_000.0):
if head_dim % 2 != 0:
raise ValueError("head_dim must be even")
device = positions.device
inv_freq = 1.0 / (
theta ** (torch.arange(0, head_dim, 2, device=device, dtype=torch.float32) / head_dim)
)
freqs = positions.float().unsqueeze(-1) * inv_freq
return freqs.cos(), freqs.sin()
def rotate_half(x: torch.Tensor) -> torch.Tensor:
x1 = x[..., : x.shape[-1] // 2]
x2 = x[..., x.shape[-1] // 2:]
return torch.cat((-x2, x1), dim=-1)
def apply_rope(x, cos, sin):
return (x * cos) + (rotate_half(x) * sin)
def rope_cos_sin(head_dim, positions, theta=10_000.0):
cos_half, sin_half = rotary_frequencies(head_dim, positions, theta)
return torch.cat((cos_half, cos_half), dim=-1), torch.cat((sin_half, sin_half), dim=-1)§4 · attention.py
The meeting room
The only place tokens listen to each other. Meeting analogy: each token asks a question (query), everyone holds a name tag (key) and a memo (value). Query×key similarity decides listening weights; values are what is actually averaged. GQA means 4 askers share 2 tag printers — halving the KV cache at inference versus full MHA. It does NOT see the future (causal mask) and does NOT think per-token (that is the MLP).
project
q [2,4,5,8] ← 32→32
k, v [2,2,5,8] ← 32→16
view → transpose(1,2)
position + expand
RoPE twist on Q, K
repeat_kv ×2 → [2,4,5,8]
2 printers serve 4 askers
score + mix + merge
scores [2,4,5,5] = QKᵀ/√8
softmax fp32 → @V → o_proj
merge [2,5,32]
Forward pass with x = [2,5,32]: 1. Remix. Each Linear is input @ W, no bias. q becomes [2,4,5,8], k,v become [2,2,5,8] — the view(...).transpose(1,2) splits the flat projection into heads. 2. Position. RoPE twists q,k, taking row 0 if a [B,T] batch of position ids is passed (uniform positions across the batch are assumed). 3. Expand groups. repeat_kv(..., n_rep=2) turns [2,2,5,8] into [2,4,5,8] via a new axis, expand, and reshape — each KV head serves 2 query heads, and n_rep==1 short-circuits to pure MHA. 4. Score. q @ kᵀ / sqrt(8) gives [2,4,5,5], where [b,h,i,j] reads token i listens to token j this much; the scale keeps dot variance near 1 so softmax does not saturate. 5. Mask. The tril() lower triangle is past-allowed, future entries go to finfo.min (\−3e38) and get softmax weight \0 — token 0 hears only itself. 6. Mix and merge. softmax(dim=-1, dtype=fp32).to(q.dtype), then @ v back to [2,4,5,8], transpose and merge to [2,5,32], and o_proj remixes to model width.
5×5 causal grid · hover the past, toggle the mask
future entries → finfo.min (~−3e38) → softmax weight ~0 · token 0 hears only itself ·
Why fp32 softmax then cast? exp() overflows in fp16, the rest stays cheap. Why bias=False on all four projections? Norms already carry scale, biases add params and complicate quantization for zero gain — and it matches attention_bias=False in the HF config. Why materialize the [T,T] mask? Fine at T=64; at T=8k you would switch to scaled_dot_product_attention(is_causal=True) or FlashAttention instead of allocating the triangle.
GQA = 4 askers, 2 tag-printers, eavesdrop only on the past, then remix.
model/attention.py— repeat_kv, GQA projections, causal mask with finfo.min
def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor:
if n_rep == 1:
return x
batch, n_kv, seq, dim = x.shape
return (
x[:, :, None, :, :]
.expand(batch, n_kv, n_rep, seq, dim)
.reshape(batch, n_kv * n_rep, seq, dim)
)
class GroupedQueryAttention(nn.Module):
def __init__(self, config: TinyGPTConfig) -> None:
super().__init__()
...
self.scale = 1.0 / math.sqrt(config.head_dim)
self.q_proj = nn.Linear(config.d_model, config.n_heads * config.head_dim, bias=False)
self.k_proj = nn.Linear(config.d_model, config.n_kv_heads * config.head_dim, bias=False)
self.v_proj = nn.Linear(config.d_model, config.n_kv_heads * config.head_dim, bias=False)
self.o_proj = nn.Linear(config.n_heads * config.head_dim, config.d_model, bias=False)
def project_qkv(self, x):
batch, seq, _ = x.shape
q = self.q_proj(x).view(batch, seq, self.n_heads, self.head_dim).transpose(1, 2)
k = self.k_proj(x).view(batch, seq, self.n_kv_heads, self.head_dim).transpose(1, 2)
v = self.v_proj(x).view(batch, seq, self.n_kv_heads, self.head_dim).transpose(1, 2)
return q, k, v
def forward(self, x, position_ids):
...
attn_scores = torch.matmul(q, k.transpose(-2, -1)) * self.scale
causal = torch.ones(seq, seq, device=x.device, dtype=torch.bool).tril()
attn_scores = attn_scores.masked_fill(~causal, torch.finfo(attn_scores.dtype).min)
attn_probs = torch.softmax(attn_scores, dim=-1, dtype=torch.float32).to(q.dtype)
attn_out = torch.matmul(attn_probs, v)
attn_out = attn_out.transpose(1, 2).contiguous().view(batch, seq, -1)
return self.o_proj(attn_out)§5 · mlp.py
The homework desk
Attention mixes across tokens; this thinks within each token, blind to neighbors. With x of shape [2,5,32], gate and up each produce [2,5,64], then silu(gate) * up stays [2,5,64] before down squeezes back to [2,5,32]. silu(z) = z × sigmoid(z): smooth, slightly non-monotonic near zero, unbounded above — gentler than the hard kink of ReLU. The * is the GLU valve: the gate decides how much of each up feature survives. Stacked linears without this would collapse (W2(W1x) = (W2W1)x — no new power), so the SiLU plus multiply is what makes the 64-wide middle add capacity. Three matrices of 32×64 is 6,144 params per layer, versus 4,096 for a vanilla 2-matrix MLP — the 1.5× cost is the Llama-family trade that won, and it matches HF hidden_act=silu with mlp_bias=False exactly — one reason the 0.0 error is attainable.
The valve · drag the gate unit
SwiGLU = lift twice through two doors, gate one side with SiLU, multiply (the valve), squeeze back.
model/mlp.py— two 32→64 lifts, SiLU valve, 64→32 squeeze
class SwiGLU(Module):
def __init__(self, config: TinyGPTConfig):
super().__init__()
self.gate_proj = nn.Linear(config.d_model, config.d_ff, bias=False)
self.up_proj = nn.Linear(config.d_model, config.d_ff, bias=False)
self.down_proj = nn.Linear(config.d_ff, config.d_model, bias=False)
def forward(self, x: Tensor) -> Tensor:
return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))§6 · tiny_gpt.py
The whole building
Pre-norm decoder: embed, then twice (norm→attend→add, norm→think→add), then norm and un-embed. The x + sublayer(norm(x)) residual highway lets signal and gradients skip any block. Post-norm (Norm(x + Attn(x)), the original 2017 style) forces everything through a norm; pre-norm keeps the highway clean and trains stably — every modern decoder does it this way. Forward pass with input_ids = [2,5] ints: position_ids = arange(5) when the caller passes none; embed_tokens looks ints up in the [128,32] table to give [2,5,32] — pure dictionary, no math; two layers of the residual pair; final norm, then lm_head 32→128 gives logits [2,5,128], where logits[b,t,v] scores position t having vocab v as its next token.
Straight lane = residual stream · side rooms (N→A, N→M) only add deltas · tokens [2,5] → embed [2,5,32] → … → logits [2,5,128]
The fixed counter: parameter_breakdown() groups by startswith('embed_tokens'), '.self_attn.' in name, '.mlp.' in name, startswith('lm_head'), else norm. The old embeded_tokens typo had hidden the 4,096 embedding params inside norm; with the key fixed, the measured result (counted from the module, not a formula) is: embed 4,096 (128×32); attention 6,144 — that is 2 × (32×32 + 32×16 + 32×16 + 32×32); mlp 12,288 — 2 × 3 × 32×64; norm 160 — 5 × 32; head 4,096 — 32×128; total 26,784. Smaller than a JPEG. tie_embeddings=True would share the embed and head storage (same Parameter object, counted once).
Param budget · total 26,784 (1dp rounding)
model/tiny_gpt.py— residual layer, model wiring, fixed parameter counter
class TinyDecoderLayer(nn.Module):
def __init__(self, config: TinyGPTConfig) -> None:
super().__init__()
self.self_attn = GroupedQueryAttention(config)
self.mlp = SwiGLU(config)
self.input_layernorm = RMSNorm(config.d_model, eps=config.rms_norm_eps)
self.post_attention_layernorm = RMSNorm(config.d_model, eps=config.rms_norm_eps)
def forward(self, x: torch.Tensor, position_ids: torch.Tensor) -> torch.Tensor:
x = x + self.self_attn(self.input_layernorm(x), position_ids)
x = x + self.mlp(self.post_attention_layernorm(x))
return x
class TinyGPT(nn.Module):
def __init__(self, config: TinyGPTConfig) -> None:
super().__init__()
self.config = config
self.embed_tokens = nn.Embedding(config.vocab_size, config.d_model)
self.layers = nn.ModuleList(TinyDecoderLayer(config) for _ in range(config.n_layers))
self.norm = RMSNorm(config.d_model, eps=config.rms_norm_eps)
self.lm_head = nn.Linear(config.d_model, config.vocab_size, bias=False)
if config.tie_embeddings:
self.lm_head.weight = self.embed_tokens.weight
def parameter_breakdown(self) -> dict[str, int]:
groups = {"embed_tokens": 0, "attention": 0, "mlp": 0, "norm": 0, "lm_head": 0}
for name, param in self.named_parameters():
n = param.numel()
if name.startswith("embed_tokens"):
groups["embed_tokens"] += n
elif ".self_attn." in name:
groups["attention"] += n
elif ".mlp." in name:
groups["mlp"] += n
elif name.startswith("lm_head"):
groups["lm_head"] += n
else:
groups["norm"] += n
groups["total"] = sum(groups[k] for k in ("embed_tokens", "attention", "mlp", "norm", "lm_head"))
return groups§7 · reference.py
The answer key
No new math — it is a translator. tiny_config() returns your defaults; hf_llama_config() maps each field to HF names (d_model→hidden_size, d_ff→intermediate_size, and so on). build_hf_reference(seed=0) constructs the HF model in eval mode, and copy_llama_weights() loads every tensor slot-for-slot: embed, both norms per layer, gate/up/down, q/k/v/o, final norm, head. Proof (recorded in reports/tiny-transformer.md, torch 2.14 CPU): same weights plus the same 2×7 probe ids gives hidden error 0.0 and logits error 0.0 at tolerance 1e-4. eager plus fp32 is deliberate — Flash and SDPA kernels reorder floating-point summation and would muddy a bit-exact check at this scale. __init__.py stays the front door, re-exporting RMSNorm and apply_rope.
| tiny | HF Llama | shape |
|---|---|---|
| embed | model.embed_tokens | [128,32] |
| attn q/k/v/o | self_attn.q/k/v/o_proj | 32→32/16/16/→32 |
| mlp gate/up/down | mlp.gate/up/down_proj | 32→64/64→32 |
| input/post norms | input/post_attention_layernorm | [32] |
| norm | model.norm | [32] |
| lm_head | lm_head | [32,128] |
model/reference.py— tiny names translated to HF Llama names
def hf_llama_config() -> Any:
from transformers import LlamaConfig
cfg = tiny_config()
return LlamaConfig(
vocab_size=cfg.vocab_size,
hidden_size=cfg.d_model,
intermediate_size=cfg.d_ff,
num_hidden_layers=cfg.n_layers,
num_attention_heads=cfg.n_heads,
num_key_value_heads=cfg.n_kv_heads,
head_dim=cfg.head_dim,
max_position_embeddings=cfg.max_seq_len,
rms_norm_eps=cfg.rms_norm_eps,
rope_parameters={"rope_type": "default", "rope_theta": cfg.rope_theta},
hidden_act="silu",
attention_bias=False,
mlp_bias=False,
tie_word_embeddings=False,
attention_dropout=0.0,
initializer_range=0.02,
use_cache=False,
attn_implementation="eager",
)
# build_hf_reference(seed=0) constructs the HF model in eval mode;
# copy_llama_weights() loads every tensor slot-for-slot.Appendix A
Tensor-shape trace (x = [2,5,32])
| stage | shape | note |
|---|---|---|
| tokens | [2,5] | ids |
| embed | [2,5,32] | lookup |
| q | [2,4,5,8] | 4 heads |
| k, v | [2,2,5,8] | 2 KV heads |
| k′, v′ repeated | [2,4,5,8] | ×2 |
| scores / probs | [2,4,5,5] | causal 5×5 |
| attn out → o_proj | [2,5,32] | merge + proj |
| gate / up → valve | [2,5,64] | fork × |
| final norm | [2,5,32] | — |
| logits | [2,5,128] | head |
What to play with
Mental model and three experiments
TinyGPT = integers → 32-number vectors → twice (normalize → listen to the past with rotated queries → add; normalize → gate-think → add) → normalize → 128-way vote for what comes next. 26,784 params, 0.0 away from HF Llama.
MHA / MQA flip
Flip n_kv_heads to 4 (MHA) and 1 (MQA), print k_proj.weight.shape — q never moves, k/v swing [8,32]↔[32,32].
Try it ↑Gate kill-switch
Zero the gate_proj and watch the MLP output die — the SiLU gate is a kill switch.
Try it ↑Slow hands
Push max_seq_len past 64 and compare slow-hand RoPE angles — that is where theta=10_000 earns its keep.
Try it ↑