← Topics

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: y=w⊙x/RMS(x)y = w \odot x/\mathrm{RMS}(x), attention softmax(QKT/8)V\mathrm{softmax}(QK^T/\sqrt{8})V, SwiGLU down(silu(gate)⊙up)\mathrm{down}(\mathrm{silu}(\mathrm{gate})\odot\mathrm{up}).

§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

vocab 128d_model 32layers 2heads 4kv-heads 2head_dim 8d_ff 64seq 64

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: y=w⊙x/RMS(x)y = w \odot x/\mathrm{RMS}(x).

Four-stage strip · toggle the silent token

x [2,5,32]
mean(x²) [2,5,1]
rsqrt(+eps)
× w[32] → y

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.

Inverse frequencies · head_dim=8, theta=10000, w(j) = 10000^(-2j/8) = 10^(-j)
pair jdims (halves)ω rad/stepperiod (tokens)
0(0,4)16.28
1(1,5)0.162.8
2(2,6)0.01628
3(3,7)0.0016283

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

x [2,5,32] → gate, up [2,5,64] → silu(gate) × up → down → [2,5,32]
g = 0.8 · silu(g) = 0.552u = 1.5out = 0.828
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.

embed[N→A] +[N→M] +[N→A] +[N→M] +[N]head

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)

embed 4096
attn 6144
mlp 12288
head 4096
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.

tinyHF Llamashape
embedmodel.embed_tokens[128,32]
attn q/k/v/oself_attn.q/k/v/o_proj32→32/16/16/→32
mlp gate/up/downmlp.gate/up/down_proj32→64/64→32
input/post normsinput/post_attention_layernorm[32]
normmodel.norm[32]
lm_headlm_head[32,128]
max|Δ| = 0.0 ✓ · hidden + logits · tol 1e-4seed 0 · 2×7 probe · eager + fp32 (no Flash/SDPA reordering)
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])

stageshapenote
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 ↑