Learning 08 · from a shape to a byte offset

Row-major layout & strides

Context: M2, embedding gather + matmul · Status: living

Learning 05 taught us to read [out,in]: a shape says what the axes mean. It does not say where element (row,col) lives. M2 forces that second question as soon as it reads a weight.

A shape names the axes. A layout orders their elements. Strides turn an index on those axes into one flat offset.

Failed Star chooses one deliberately plain representation while learning the math: contiguous row-major f32, always. No views, hidden transpose, or variable strides. A later reorder must be an explicit copy whose cost and payoff we can measure.

📖 Inference Engineering (Kiely) · §2.1 (p.42) 🔧 ds4 · get_rows.metal + dense.metal 🧭 NumPy · ndarray memory layout

One grid, an ordered buffer

Shape is not layout

A shape [3,4] gives three rows and four columns. The same logical grid could be stored row-first as a,b,c,d,e,… or column-first as a,e,i,b,…. Safetensors is C-contiguous, and our Matrix keeps that row-major order after widening to f32.

logical grid · shape [R=3,C=4] row 0row 1row 2 col 0col 1 col 2col 3 abcd efgh ijkl store rows left → right flat row-major buffer index value 0123 4567 891011 abcd efgh ijkl row 0 · 4 contiguousrow 1row 2
Last axis contiguous: move one column → one element; move one row → four. Element strides are [C,1] = [4,1], so (2,1) = 2·4+1 = 9.
flat_index(row, col) = row · C + col

Matrix::row(r) = data[r · cols .. (r + 1) · cols]

That slice exposes the invariant: one logical row is one uninterrupted run of cols values.

The indexing multipliers

Strides turn coordinates into offsets

A stride is the number of elements skipped when one axis advances. For row-major [R,C], strides are [C,1]. In N dimensions, the last stride is 1 and every stride to its left is the product of dimensions to its right:

offset(i₀,…,iₙ) = i₀·stride₀ + … + iₙ·strideₙ
viewshapestridesrows contiguous?
row-major original[R,C][C,1]yes
transpose view[C,R][1,C]no

A general tensor library can make transpose “free” by swapping shape and strides while leaving the bytes alone. Failed Star's M2 Matrix intentionally cannot: it always means len = rows·cols and stride (cols,1).

A real offset

Token 785 in embed[V,H]

The safetensors reader gives a tensor-relative range. Add its start to the row-major element offset, then multiply by bf16's two bytes:

file_byte(row,col)
  = data_start + tensor.start + (row·C + col)·2

For the real embedding table [V,H] = [151936,1024], the oracle prompt begins with token id 785 (“The”). Relative to the tensor start:

locationcalculationbyte offset / range
first (785,0)(785·1024+0)·21,607,680
last (785,1023)(785·1024+1023)·21,609,726
whole row1,024 adjacent bf16 values[1,607,680, 1,609,728) = 2,048 bytes

M2 widens that row into 1,024 adjacent f32 values: 4,096 owned bytes. Width changes; row-major order does not. This also explains M1's validation tensor bytes = R·C·dtype.size(): if it fails, no stride formula can make header and blob agree.

Gather copies one contiguous row

Embedding is not multiplication. For embed[V,H] and ids[seq], gather selects each id's H-wide row, preserving token order:

ids [2,0,2] · embed [V=4,H=3] → output [seq=3,H=3]
row 2 [20,21,22]                 [20,21,22]
row 0 [ 0, 1, 2]                 [ 0, 1, 2]
row 2 [20,21,22]                 [20,21,22]

embedding_gather asserts every id is below V, finds the row start, and copy_from_slices one contiguous width — the CPU counterpart of ds4's get_rows.

Reading the triple loop

Matmul: three indices, three flat offsets

For A[M,K] · B[K,N] → C[M,N], one output cell is:

C[i,j] = Σₖ A[i,k] · B[k,j]

A[i,k] → A.data[i·K + k]
B[k,j] → B.data[k·N + j]
C[i,j] → C.data[i·N + j]

M2's naive (i,j,k) loop makes the access pattern visible: A advances contiguously while B jumps by N. Tiling, SIMD, transposition, and Metal can improve that later; first we need a baseline we understand and can measure.

The shape convention pays off

[out,in] means two contiguous rows

Neural-network weights are stored W[out,in], while the equation is Y = X·Wᵀ. The superscript T is mathematical bookkeeping: for output feature o, linear dots input row X[t,:] directly with weight row W[o,:]. Both are contiguous and exactly in wide.

X[seq,in] · row t W[out,in] · row o one contiguous in-wide row one contiguous in-wide row x₀x₁xᵢₙ₋₁ w₀w₁wᵢₙ₋₁ dot product multiply + sum Y[t,o] = Σᵢ X[t,i]·W[o,i] Y = X·Wᵀ Wᵀ is mathematical — no physical transpose
One stored weight row contains every incoming weight for one output feature. The equation reads Wᵀ; memory is never physically transposed.

Numerical neighbors

Rows stay explicit elsewhere, too

helperlayout consequence
RMSNormnormalizes each complete [seq,H] row; never mixes tokens
SiLUelement-wise, so layout cannot change the result
RoPErotates halves of each d-wide q/k row; one contiguous sin/cos row per position, oracle-locked at Qwen's final position 40959
top-kreads the flat [V] logits row; original flat index remains token id

Every helper asserts its shape contract before indexing. Known-answer tests check both numbers and loud failures; RoPE also checks that rotation preserves vector length.

Mental model. Shape tells you what an index means. Strides tell you how far it moves. Row-major [R,C] means strides [C,1], so (r,c) = r·C+c. The model-specific payoff: [out,in] stores one output neuron's weights as one contiguous row, so x·Wᵀ needs no physical transpose.