Learning 05 · how the dimensions line up

Reading shapes: making the axes line up

Context: M1, loading the weights · Status: living

The hardest part of reading neural-net code isn't the math — it's keeping the shapes straight. Which axis is which? Why is the weight shape backwards from the data? Where did 2048 come from when hidden_size is 1024? This is the map we come back to every time a tensor shows up — clarity first, using Qwen3-0.6B's real numbers throughout.

Clarity first. Once we can see the dimensions line up, the code — and later the speed work — is much easier. Every question below is one rule applied over and over.
📖 Inference Engineering · §2.1 (transformer block, p.42), §2.2.2–2.2.3 🔧 ds4 · shapes in ds4.c + metal/{dense,flash_attn,glu,norm}.metal 🧭 Raschka · “Understanding LLMs” — inspect the config, line up the dims

The one rule

Inner dimensions must match

A matrix multiply A · B is legal iff the inner dimensions match. The shared axis is contracted (summed away); the outer dims survive:

A: (m × k)   B: (k × n)   ⟹   A·B: (m × n)
        └────────┘
        these two k's (the "inner" dims) must be equal — k is then
        "contracted" (summed away); the outer dims m and n survive.

That's it. Every shape question below is an application of this one rule. When a forward pass breaks, 9 times out of 10 it's two ks that didn't match — so we make the ks visible and assert them.

The #1 gotcha

Weights are stored [out, in]

A linear layer computes y = x · Wᵀ. In PyTorch — and therefore in the safetensors file — the weight is stored transposed relative to the data flow, as [out_features, in_features]. The stored shape looks reversed from the arrow because of that Wᵀ:

x 1 × 1024 W [2048 × 1024] = [out × in] y 1 × 2048 contract: in = 1024 (x's width = W's columns) — the k that must match read it as: in = 1024 ──▶ out = 2048 the stored shape [out, in] is reversed from the arrow because y = x · Wᵀ
data (x, y) weight W · [out, in] the contracted axis
Reading self_attn.q_proj.weight [2048, 1024] as in=1024 ──▶ out=2048: it takes a 1024-wide vector and produces a 2048-wide one. Internalize this once and the whole tensor table becomes readable.

The residual stream

A fixed-width bus through every block

A transformer block doesn't change the width of the thing flowing through it. Tokens enter as H-wide vectors, every sub-layer reads from and writes back to that same H-wide residual stream, and the block outputs H-wide vectors. For Qwen3-0.6B, H = hidden_size = 1024. Attention and the FFN fan out to wider working widths internally, then project back to H before rejoining the bus:

one block · × 28 …──H──▶ ──H──▶… H = 1024 · the width is constant end-to-end attn · 16·d=2048 FFN · I=3072 norm + norm + every “+” folds the sub-layer output back into the H-wide bus — leave H, work wider, return to H
H-wide residual bus (+ folds) internal fan-out width norm (element-wise scale)
The rhythm of every block: leave the H-wide bus, do work at 16·d / I, come back to H. The fan-out widths are where the other dimensions come from.

The legend

Qwen3-0.6B's named dimensions

Every shape in the model is built from these seven numbers, straight from its config.json:

symbolname (config.json)valuewhat it is
Vvocab_size151936how many distinct tokens
Hhidden_size1024residual-stream width
Lnum_hidden_layers28transformer blocks
dhead_dim128width of one attention head
num_attention_heads16query heads → 16·d = 2048
num_key_value_heads8key/value heads → 8·d = 1024
Iintermediate_size3072FFN inner width

Two of these deserve their own diagram because they're the usual sources of confusion.

Decoupled dimension

head_dim is decoupled from hidden_size

Naively you'd expect num_heads × head_dim == hidden_size. It doesn't here: 16 × 128 = 2048 ≠ 1024. Qwen3 lets the attention working width differ from the residual width. So q_proj projects H ──▶ 16·d, and after attention o_proj projects back 16·d ──▶ H:

H = 1024 the bus q_proj [2048,1024] 2048 = 16 heads × d = 128 h0 h1 h15 attention happens per head, then concat ──o_proj [1024,2048]──▶ H = 1024 a reader that “knows” hidden == heads × head_dim will mis-shape q_proj — the asserts catch that.
The 16·d = 2048 attention width splits into 16 slices of width d = 128; o_proj concatenates them and projects back to H = 1024.

Grouped-Query Attention

Query heads share key/value heads

GQA uses fewer key/value heads than query heads. Here: 16 query heads, 8 kv heads → a group size of 16 / 8 = 2 (every 2 query heads share one kv head). That's why q_proj is width 2048 but k_proj/v_proj are width 1024 = 8·d:

q heads · 16 · width 2048 Q0 Q1 Q2 Q3 Q4 Q5 KV0 KV1 KV2 kv heads · 8 · width 1024 group = 2 saving K/V shrinks the KV cache at M4; for M1 it just explains the asymmetric shapes.
Every 2 query heads share one key/value head (group size 16 / 8 = 2), so k_proj/v_proj are half the width of q_proj.

Width by width

Walking one block

The full tensor set of a block reads as a chain of in ──▶ out arrows, every arrow obeying the one rule. (norm weights are 1-D scale vectors, not matrices — they multiply element-wise, so their width just states which thing they scale.)

tensorstored shapereads as
embed_tokens (global)[V, H]token id ──▶ H  (a row lookup)
input_layernorm[H]scale the H-wide bus
q_proj[16·d, H] = [2048, 1024]H ──▶ 16·d
k_proj[8·d, H] = [1024, 1024]H ──▶ 8·d
v_proj[8·d, H] = [1024, 1024]H ──▶ 8·d
q_norm[d] = [128]scale each query head (width d)
k_norm[d] = [128]scale each key head (width d)
o_proj[H, 16·d] = [1024, 2048]16·d ──▶ H  (back onto the bus)
post_attention_layernorm[H]scale the H-wide bus
gate_proj[I, H] = [3072, 1024]H ──▶ I  SwiGLU: act(gate) * up
up_proj[I, H] = [3072, 1024]H ──▶ I
down_proj[H, I] = [1024, 3072]I ──▶ H  (back to the bus)
model.norm (final)[H]scale the H-wide bus
lm_head (tied)[V, H]H ──▶ V  (tied to embed_tokens; a redundant copy in this file)

Notice the rhythm: everything leaves the H-wide bus, does work at 16·d / 8·d / I, and comes back to H. If you can see that, you can read any dense transformer's weights.

Tied embeddings — one table, two jobs. tie_word_embeddings: true means the output projection is the embedding table — the same weights, used twice: a row lookup on the way in (token id ──▶ H) and transposed as the output projection on the way out (H ──▶ V logits). That one [V, H] table is 151936 × 1024 ≈ 155.6M params — ~26% of the model's ~596M. A quarter of “the weights” is just the vocabulary.
“Tied” is about the math, not the file. It's tempting to conclude “tied ⟹ the file has no lm_head.weight.” Not so — and Qwen3-0.6B is the counter-example: it's tied and ships a lm_head.weight byte-for-byte identical to embed_tokens. So the file carries the [V, H] table twice: 311 tensors, ~751M stored params but only ~596M logical (the “0.6B”). Whether a tied export stores the redundant copy is the exporter's choice — so fs inspect treats lm_head.weight as optional when tied, and flags a present copy as redundant rather than double-counting it. This is why we read the header instead of assuming: see learning 10.

Where it shows up

How this reads in fs inspect

fs inspect makes all of the above legible at a glance. It prints three parts:

Run against the real model, the table + verdict read (abridged):

── tensors ─────────────────────────────────────────────────────────────────
  TENSOR                          DTYPE SHAPE            PARAMS   in ──▶ out
  global
    model.embed_tokens.weight     BF16  [151936, 1024]  155,582,464  id ──▶ H  (row gather)
  each block  × 28   (shown: layer 0)
    self_attn.q_proj.weight       BF16  [2048, 1024]      2,097,152  1024 ──▶ 2048
    self_attn.k_proj.weight       BF16  [1024, 1024]      1,048,576  1024 ──▶ 1024
    self_attn.o_proj.weight       BF16  [1024, 2048]      2,097,152  2048 ──▶ 1024
    mlp.gate_proj.weight          BF16  [3072, 1024]      3,145,728  1024 ──▶ 3072
    mlp.down_proj.weight          BF16  [1024, 3072]      3,145,728  3072 ──▶ 1024
    …                                                                (11 per block)
  final
    lm_head.weight   (tied)       BF16  [151936, 1024]  155,582,464  1024 ──▶ 151936

── verdict ─────────────────────────────────────────────────────────────────
  ✓ all 311 expected tensors present, shapes match the config
  note: lm_head.weight present but tied — a redundant byte-identical copy of embed_tokens
  params: 751,632,384 stored · 596,049,920 logical (the "0.6B")
  embeddings: 155,582,464 = 26.1% of logical

Every number here is derived — the GQA asymmetry (2048 vs 1024), the deduped 596M, the 26.1% — nothing hard-coded. That cross-check is the M1 verification, and its asserts carry into M2 so a mis-wired matmul fails loudly instead of producing quiet garbage.

Learning-first, then fast. Right now we assert shapes everywhere and convert nothing we don't have to. Making the math go fast (fusing, batching, skipping checks on the hot path) is a separate lesson later — and itself a good one.