Learning 05 · how the dimensions line up
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.
The one rule
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
[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ᵀ:
[out, in]
the contracted axis
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 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:
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
Every shape in the model is built from these seven numbers, straight from its
config.json:
| symbol | name (config.json) | value | what it is |
|---|---|---|---|
V | vocab_size | 151936 | how many distinct tokens |
H | hidden_size | 1024 | residual-stream width |
L | num_hidden_layers | 28 | transformer blocks |
d | head_dim | 128 | width of one attention head |
| — | num_attention_heads | 16 | query heads → 16·d = 2048 |
| — | num_key_value_heads | 8 | key/value heads → 8·d = 1024 |
I | intermediate_size | 3072 | FFN 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_sizeNaively 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:
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
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:
16 / 8 = 2), so k_proj/v_proj are half the width of
q_proj.Width by width
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.)
| tensor | stored shape | reads as |
|---|---|---|
embed_tokens | [V, H] | token id ──▶ H |
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 |
post_attention_layernorm | [H] | scale the H-wide bus |
gate_proj | [I, H] = [3072, 1024] | H ──▶ I |
up_proj | [I, H] = [3072, 1024] | H ──▶ I |
down_proj | [H, I] = [1024, 3072] | I ──▶ H |
model.norm | [H] | scale the H-wide bus |
lm_head | [V, H] | H ──▶ V |
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.
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.
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
fs inspectfs inspect makes all of the above legible at a glance. It prints
three parts:
in ──▶ out
arrow — one representative block labelled × L, not 28 copies;config.json
and diffs it against the file (q/k/v ← H, o ← 16·d,
gate/up ← H, down ← I, embed = [V, H], lm_head tied),
reporting stored vs logical params and any mismatch.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.
[out, in] blob).fs inspect as the config↔weights handshake.ds4: metal/dense.metal (the matmul),
metal/flash_attn.metal (head layout), metal/glu.metal (SwiGLU),
metal/norm.metal (RMSNorm scale vectors).head_dim choices across models.docs/learnings/05-reading-shapes.md.