3. Matrices as functions

Why this layer exists

Every learned layer in a transformer is a matrix multiply, usually followed by something that is not linear. The query, key, value and output projections of attention are matrix multiplies. The two or three projections of the feed-forward block are matrix multiplies with an activation function between them. The final projection from the hidden state back onto the vocabulary is one more. Between them sit a handful of cheap elementwise operations: normalisation, the activation, the softmax inside attention. The learned parameters that are not in the embedding table live almost entirely in weight matrices, and a model’s non-embedding parameter count is, to a close approximation, the sum of their sizes.

Two practical consequences follow, and both come back later in the book. First, reading model code is reading shapes. A transformer implementation is a sequence of arrays with shapes like (batch, seq, d_model) flowing through matrices with shapes like (d_model, d_ff), and most of what goes wrong in such code, at the level of a single line, is a shape that was not what the author thought it was. Second, the arithmetic of inference is dominated by those multiplies. In llama.cpp the operation is ggml_mul_mat, and the backends (CPU, CUDA, Metal, Vulkan) each carry their own hand-tuned kernels for it. When a model generates one token, it reads every weight matrix once and does one multiply-add with each weight, which is why the speed of single-stream decoding tracks memory bandwidth and why quantization (chapter 14) targets the weight matrices: storing each weight in about 4 bits instead of 16 cuts the bytes read per token by more than a factor of three.

Chapter 2 used one matrix–vector product, unit @ unit[q], to score a query against a whole vocabulary. This chapter treats the matrix as the object: a function from vectors to vectors, whose composition is matrix multiplication. The lab builds Linear, a forward-only affine layer whose shape contract, (..., in) to (..., out), is the one the models of Part II use for their projections.

Mechanism

A matrix is a function

A matrix W\mathbf{W} of shape (m, k) is a grid of mkmk numbers, WijW_{ij} in row ii and column jj. Its product with a vector x\mathbf{x} of shape (k,) is a vector y=Wx\mathbf{y} = \mathbf{W}\mathbf{x} of shape (m,), with entries

yi=∑j=1kWijxj,i=1,…,m. y_i = \sum_{j=1}^{k} W_{ij}\, x_j , \qquad i = 1, \ldots, m .

Read as a function, W\mathbf{W} takes a vector in Rk\mathbb{R}^k and returns a vector in Rm\mathbb{R}^m. The number of columns is the input dimension and the number of rows is the output dimension. A (m, k) matrix times a (k,) vector is only defined when the two kk agree, and the result has the matrix’s other dimension.

A function ff from Rk\mathbb{R}^k to Rm\mathbb{R}^m is linear if, for all vectors x,y\mathbf{x}, \mathbf{y} of shape (k,) and all scalars α,β\alpha, \beta,

f(αx+βy)=αf(x)+βf(y). f(\alpha\mathbf{x} + \beta\mathbf{y}) = \alpha f(\mathbf{x}) + \beta f(\mathbf{y}) .

Scale or add the inputs, and the outputs scale and add the same way. Every matrix gives a linear function. Entry ii of W(αx+βy)\mathbf{W}(\alpha\mathbf{x} + \beta\mathbf{y}) is

∑j=1kWij(αxj+βyj)=α∑j=1kWijxj+β∑j=1kWijyj, \sum_{j=1}^{k} W_{ij}\,(\alpha x_j + \beta y_j) = \alpha \sum_{j=1}^{k} W_{ij}\, x_j + \beta \sum_{j=1}^{k} W_{ij}\, y_j ,

which is entry ii of αWx+βWy\alpha\,\mathbf{W}\mathbf{x} + \beta\,\mathbf{W}\mathbf{y}. The step in the middle is only distributing multiplication over addition and pulling constants out of a sum.

The converse also holds: every linear function from Rk\mathbb{R}^k to Rm\mathbb{R}^m is a matrix. Write ej\mathbf{e}_j for the vector of shape (k,) with a 1 in position jj and zeros elsewhere. Any x\mathbf{x} is x1e1+⋯+xkekx_1\mathbf{e}_1 + \cdots + x_k\mathbf{e}_k, so by linearity

f(x)=x1f(e1)+⋯+xkf(ek). f(\mathbf{x}) = x_1 f(\mathbf{e}_1) + \cdots + x_k f(\mathbf{e}_k) .

A linear function is therefore fixed by what it does to the kk vectors ej\mathbf{e}_j. Stack the kk outputs f(ej)f(\mathbf{e}_j), each of shape (m,), side by side as the columns of an (m, k) matrix, and that matrix computes ff. A linear layer with kk inputs and mm outputs is, in full generality, mkmk numbers.

Two readings of the matrix–vector product

The formula for yiy_i can be read two ways, and both are in constant use.

Row reading. yiy_i is the dot product of row ii of W\mathbf{W}, shape (k,), with x\mathbf{x}. Each output coordinate scores the input against one row, which can be read as a learned pattern. This is the reading chapter 2 used: with the unit-length word vectors as rows, the product is the cosine against every word.

Column reading. Write wj\mathbf{w}_j for column jj of W\mathbf{W}, shape (m,). Then

Wx=x1w1+x2w2+⋯+xkwk, \mathbf{W}\mathbf{x} = x_1\mathbf{w}_1 + x_2\mathbf{w}_2 + \cdots + x_k\mathbf{w}_k ,

a weighted sum of the columns, with the input’s entries as the weights. This is the construction from the converse above, with wj=f(ej)\mathbf{w}_j = f(\mathbf{e}_j). It says the output always lies in the set of vectors reachable by mixing the columns: a layer cannot produce anything outside that set, whatever its input.

The two readings compute the same numbers because they are the same double sum taken in a different order. Entry ii of the column reading is ∑jxj(wj)i=∑jxjWij\sum_j x_j (\mathbf{w}_j)_i = \sum_j x_j W_{ij}, which is the row reading’s yiy_i. A small case, with W\mathbf{W} of shape (2, 3) and x=(1,1,2)\mathbf{x} = (1, 1, 2):

W=(12001−1),Wx=(1+2+00+1−2)=1(10)+1(21)+2(0−1)=(3−1). \mathbf{W} = \begin{pmatrix} 1 & 2 & 0 \\ 0 & 1 & -1 \end{pmatrix}, \qquad \mathbf{W}\mathbf{x} = \begin{pmatrix} 1 + 2 + 0 \\ 0 + 1 - 2 \end{pmatrix} = 1\begin{pmatrix} 1 \\ 0 \end{pmatrix} + 1\begin{pmatrix} 2 \\ 1 \end{pmatrix} + 2\begin{pmatrix} 0 \\ -1 \end{pmatrix} = \begin{pmatrix} 3 \\ -1 \end{pmatrix} .

The middle expression is two dot products, one per row; the right-hand one is three scaled columns.

Matrix multiplication is composition

Take two linear functions in sequence: B\mathbf{B} of shape (k, m) maps Rm\mathbb{R}^m to Rk\mathbb{R}^k, and A\mathbf{A} of shape (n, k) maps Rk\mathbb{R}^k to Rn\mathbb{R}^n. For an input x\mathbf{x} of shape (m,), the composite is A(Bx)\mathbf{A}(\mathbf{B}\mathbf{x}), shape (n,). Write it out, calling the intermediate z=Bx\mathbf{z} = \mathbf{B}\mathbf{x}, shape (k,):

(A(Bx))i=∑p=1kAipzp=∑p=1kAip∑j=1mBpjxj=∑j=1m(∑p=1kAipBpj)xj. \big(\mathbf{A}(\mathbf{B}\mathbf{x})\big)_i = \sum_{p=1}^{k} A_{ip}\, z_p = \sum_{p=1}^{k} A_{ip} \sum_{j=1}^{m} B_{pj}\, x_j = \sum_{j=1}^{m} \Big( \sum_{p=1}^{k} A_{ip}\, B_{pj} \Big) x_j .

The last step swaps the order of two finite sums. The bracket depends on ii and jj but not on x\mathbf{x}, so it is the entry in row ii, column jj of a single (n, m) matrix that does the whole job. That matrix is the product AB\mathbf{A}\mathbf{B}:

(AB)ij=∑p=1kAipBpj, (\mathbf{A}\mathbf{B})_{ij} = \sum_{p=1}^{k} A_{ip}\, B_{pj} ,

shape (n, k) times (k, m) gives (n, m). The definition is not arbitrary: it is exactly what makes (AB)x=A(Bx)(\mathbf{A}\mathbf{B})\mathbf{x} = \mathbf{A}(\mathbf{B}\mathbf{x}) hold. Entry (i,j)(i, j) is the dot product of row ii of A\mathbf{A} with column jj of B\mathbf{B}, both of length kk, which is why the inner dimensions must agree.

An n by k grid A times a k by m grid B equals an n by m grid AB. Row i of A and column j of B are outlined heavily; their dot product is the single outlined cell (i, j) of AB. k n m k m n row i column j i, j × = A B AB

Three facts follow. The product is associative, (AB)C=A(BC)(\mathbf{A}\mathbf{B})\mathbf{C} = \mathbf{A}(\mathbf{B}\mathbf{C}), because both sides are the composite function “apply C\mathbf{C}, then B\mathbf{B}, then A\mathbf{A}“, and a linear function has only one matrix. It is not commutative: AB\mathbf{A}\mathbf{B} and BA\mathbf{B}\mathbf{A} are different functions in general, and are usually not both defined. And a vector is the special case m=1m = 1: x\mathbf{x} of shape (k,) behaves as a (k, 1) matrix, and the matrix–vector product is the matrix–matrix product with one column.

Depth without a nonlinearity buys nothing

Stack two linear layers, W1\mathbf{W}_1 of shape (h, k) and W2\mathbf{W}_2 of shape (m, h), on an input x\mathbf{x} of shape (k,). By the composition result,

W2(W1x)=(W2W1)x, \mathbf{W}_2(\mathbf{W}_1\mathbf{x}) = (\mathbf{W}_2\mathbf{W}_1)\,\mathbf{x} ,

and W2W1\mathbf{W}_2\mathbf{W}_1 is one matrix of shape (m, k). Two layers compute a function that one layer could have computed. By induction the same holds for any depth: a stack of linear layers is one linear layer. It can be worse than one layer, too. Every output of the stack is W2\mathbf{W}_2 applied to something, so by the column reading it is a mix of the hh columns of W2\mathbf{W}_2, each of shape (m,). If h<kh < k and h<mh < m, the stack can therefore reach at most an hh-dimensional slice of Rm\mathbb{R}^m, a restriction a single (m, k) layer does not have.

This is why every block in a transformer puts something non-linear between its matrices. The feed-forward block is “project up, apply an elementwise non-linear function, project down”; without the middle step it would collapse to a single (d, d) matrix. Chapter 9 introduces the non-linearity and builds the first multi-layer model on it.

Affine maps and the bias

A linear function sends zero to zero: f(0)=f(0⋅0)=0⋅f(0)=0f(\mathbf{0}) = f(0 \cdot \mathbf{0}) = 0 \cdot f(\mathbf{0}) = \mathbf{0}. A layer that must output something other than zero for a zero input needs a constant added:

f(x)=Wx+b, f(\mathbf{x}) = \mathbf{W}\mathbf{x} + \mathbf{b} ,

with W\mathbf{W} of shape (m, k), x\mathbf{x} of shape (k,), and the bias b\mathbf{b} of shape (m,). A function of this form is affine. It is not linear when b≠0\mathbf{b} \neq \mathbf{0}, since f(x+y)=Wx+Wy+bf(\mathbf{x} + \mathbf{y}) = \mathbf{W}\mathbf{x} + \mathbf{W}\mathbf{y} + \mathbf{b} has one bias where f(x)+f(y)f(\mathbf{x}) + f(\mathbf{y}) has two. Subtracting f(0)=bf(\mathbf{0}) = \mathbf{b} recovers the linear part, f(x)−f(0)=Wxf(\mathbf{x}) - f(\mathbf{0}) = \mathbf{W}\mathbf{x}, and that is exactly how the lab test test_linear_is_affine checks a layer. The composition argument carries over: two affine maps in a row are one affine map (exercise e), so biases do not rescue depth either. The Llama family of models drops the biases from its projections altogether; the layer in this chapter keeps them, since they cost mm parameters against mkmk for the weights.

Transpose

The transpose of A\mathbf{A}, shape (n, k), is A⊤\mathbf{A}^\top, shape (k, n), with rows and columns exchanged: (A⊤)ij=Aji(\mathbf{A}^\top)_{ij} = A_{ji}. Treating a vector a\mathbf{a} of shape (d,) as a (d, 1) column, a⊤\mathbf{a}^\top is a (1, d) row, and the product a⊤b\mathbf{a}^\top\mathbf{b} is a (1, 1) matrix whose single entry is ∑iaibi\sum_i a_i b_i: the dot product of chapter 2, written as a matrix product.

Transposes appear in model code whenever two conventions meet. The rule that governs them, (AB)⊤=B⊤A⊤(\mathbf{A}\mathbf{B})^\top = \mathbf{B}^\top\mathbf{A}^\top, is exercise (b).

Batches and the row-vector convention

The mathematics above writes vectors as columns and puts the matrix on the left: Wx\mathbf{W}\mathbf{x} with W\mathbf{W} of shape (out, in). Code almost always does the opposite. An input is a row, the weight is stored as (in, out), and the layer computes

y = x @ W + b    # (in,) @ (in, out) + (out,) -> (out,)

The reason is batching. Stack BB input rows into a matrix X\mathbf{X} of shape (B, in). Then XW\mathbf{X}\mathbf{W} has shape (B, out), and the product formula gives its entry in row rr, column jj as

(XW)rj=∑p=1inXrpWpj, (\mathbf{X}\mathbf{W})_{rj} = \sum_{p=1}^{\text{in}} X_{rp}\, W_{pj} ,

which involves only row rr of X\mathbf{X}. So row rr of XW\mathbf{X}\mathbf{W} is row rr of X\mathbf{X}, shape (in,), times W\mathbf{W}, exactly as if that input had been processed alone. One matrix multiply applies the layer to every row independently, and a batch of one is the same code as a batch of a thousand. Putting the batch on the first axis also keeps each input row contiguous in memory.

The two conventions compute the same thing. The row form x⊤W\mathbf{x}^\top\mathbf{W}, with W\mathbf{W} of shape (in, out), has the same entries as the column form W⊤x\mathbf{W}^\top\mathbf{x} (exercise b), so a weight stored in one convention is the transpose of the same weight stored in the other. PyTorch’s nn.Linear stores its weight as (out_features, in_features) and computes x @ W.T + b; the Linear in this book stores (in_features, out_features) and computes x @ W + b. Both are the same layer, and a checkpoint converted between the two conventions has its weight matrices transposed.

Broadcasting

In x @ W + b, the left operand of + has shape (B, out) and the right has shape (out,). The addition works because of broadcasting, numpy‘s rule for elementwise operations on arrays of different shapes. Stated precisely:

  1. Line the two shapes up at their trailing (rightmost) axes. If one shape has fewer axes, pad it on the left with axes of size 1.
  2. Compare each aligned pair of sizes. They are compatible if they are equal or if one of them is 1. Any other pair is an error.
  3. The result has, on each axis, the larger of the two sizes. An operand whose size on an axis is 1 is repeated along that axis. No copy is made; the repeat is done with a stride of zero.

For the bias: (B, out) and (out,) align as (B, out) and (1, out); the pairs are (B,1)(B, 1), compatible, and (out,out)(\text{out}, \text{out}), equal; the result is (B, out), and the same bias row is added to every row of the batch.

The rules also explain the silent failure chapter 2 warned about. (3,) + (1,) is legal, because a size-1 axis broadcasts against anything, and returns shape (3,). A worse case is a column meeting a row: a vector of per-example losses with shape (B,) added to targets of shape (B, 1) aligns as (1, B) against (B, 1), and both pairs contain a 1, so the result is a (B, B) matrix of every loss plus every target. No error is raised, and a following .mean() returns a plausible-looking number. The defence is to check shapes explicitly at the boundaries where they are known, which is what assert_shape is for.

Leading axes

Language-model activations have more than two axes: a batch of BB sequences of TT tokens, each a vector of width dd, is an array of shape (B, T, d). numpy‘s @ (and np.matmul) handles this by treating everything but the last two axes as a stack of matrices, and broadcasting across the stack with the rules above. For the linear layer’s case, (B, T, in) @ (in, out), the two-axis weight broadcasts against every one of the BB matrices of shape (T, in) and the result has shape (B, T, out). A one-axis left operand is promoted to a one-row matrix for the product and the added axis is removed afterwards, so (in,) @ (in, out) gives (out,).

The consequence is that a linear layer acts on the last axis only. Whatever leading axes the input has, batch, sequence position, attention head, pass through unchanged. The layer is written once, for shape (..., in) to (..., out), and the same code serves an input of any rank. The projections in the models of Part II follow this contract.

Initialization scale

A layer’s weights have to start somewhere, and the scale of the starting values matters. Each output coordinate is a sum of in products xjWjix_j W_{ji}. If the weights were of size about 1, that sum would grow with the number of terms, and a stack of layers would multiply the growth layer by layer until the activations overflowed or, with weights too small, shrank to zero. The remedy used here is to draw each weight and bias uniformly from [−1/in,1/in][-1/\sqrt{\text{in}}, 1/\sqrt{\text{in}}]. With that scale, an input whose entries have size about 1 produces outputs whose entries have size about 1 (somewhat smaller, by a constant factor), whatever the input width. It is also PyTorch’s default for nn.Linear. Why in\sqrt{\text{in}} and not in\text{in} is a statement about the variance of a sum of independent random terms; chapter 9 derives it once probability (chapter 4) supplies the tools.

Walkthrough

The reference module is py/tinygpt/linear.py.

def assert_shape(x, *dims):
    """Raise ValueError unless x has exactly len(dims) axes matching dims; None matches any size."""
    shape = np.shape(x)
    if len(shape) != len(dims) or any(d is not None and d != s for d, s in zip(dims, shape)):
        raise ValueError(f"expected shape {dims}, got {shape}")

assert_shape is a shape check for function boundaries. The expected shape is given as separate arguments, with None for an axis whose size varies, so assert_shape(x, None, 3) means “a matrix with three columns and any number of rows”. np.shape works on lists as well as arrays, so the check needs no conversion.

The rank comparison comes first and is not redundant. zip stops at the shorter of its two arguments, so without the len(shape) != len(dims) test a (2, 3) array would pass a check for (2,) and a check for (2, 3, 1): the zip would compare the axes the two shapes share and ignore the rest. Both are among the rejections the lab tests. The function raises ValueError, the exception numpy itself uses for incompatible shapes, and the message prints both shapes, which is the information needed to find the bug.

def matmul(a, b):
    """(n, k) @ (k, m) -> (n, m): out[i, j] is the dot product of row i of a and column j of b."""
    a, b = np.asarray(a, dtype=np.float64), np.asarray(b, dtype=np.float64)
    if a.ndim != 2 or b.ndim != 2:
        raise ValueError(f"matmul needs two matrices, got {a.shape} and {b.shape}")
    n, k = a.shape
    if b.shape[0] != k:
        raise ValueError(f"inner dimensions differ: {a.shape} @ {b.shape}")
    m = b.shape[1]
    out = np.zeros((n, m))
    for i in range(n):
        for j in range(m):
            for p in range(k):
                out[i, j] += a[i, p] * b[p, j]
    return out

matmul is the index formula written as three loops: ii over rows of the output, jj over its columns, pp over the shared inner dimension. a and b are converted to float64 for the same reason dot did it in chapter 2. The shape checks reject anything that is not a pair of matrices with matching inner dimensions. A vector is rejected rather than promoted, which is stricter than @: np.zeros(3) @ np.zeros((3, 2)) returns shape (2,), while matmul raises. The strictness is the point of a from-scratch version whose job is to make every shape visible.

The innermost statement runs once per (i,j,p)(i, j, p) triple, so matmul does exactly nkmnkm multiply-adds. Cost is conventionally counted in FLOPs, floating-point operations, with a multiply-add counting as two: 2nkm2nkm FLOPs for one product.

Apply that to one token passing through a square layer of width dd: the input has shape (1, d), the weight (d, d), so n=1n = 1, k=m=dk = m = d, and the product costs d2d^2 multiply-adds, 2d22d^2 FLOPs. For d=4096d = 4096 that is 16,777,216 multiply-adds, about 3.4×1073.4 \times 10^7 FLOPs, for one token through one projection. The pattern generalises. Every weight in the layer takes part in exactly one multiply-add per token, so the FLOPs for one token through any weight matrix are twice its number of weights, and the FLOPs for one token through a whole model are, to first order, twice its non-embedding parameter count. The embedding table is excluded because looking up a token’s row copies it and does no multiply-adds. The biases, normalisations and activations add work proportional to the widths, not their products; the attention scores add work that grows with context length (chapter 10). For a batch of nn tokens the matrix multiplies cost nn times as much, but the weights are still read from memory once per batch, not once per token. That ratio, arithmetic per byte of weight read, is what chapter 17’s batching improves.

class Linear:
    """y = x W + b, applied to the last axis of x: (..., in) -> (..., out)."""

    def __init__(self, in_features, out_features, rng):
        bound = 1.0 / np.sqrt(in_features)
        self.weight = rng.uniform(-bound, bound, size=(in_features, out_features))
        self.bias = rng.uniform(-bound, bound, size=(out_features,))

    def __call__(self, x):
        x = np.asarray(x, dtype=np.float64)
        if x.shape[-1] != self.weight.shape[0]:
            raise ValueError(f"expected last axis {self.weight.shape[0]}, got shape {x.shape}")
        return x @ self.weight + self.bias

Linear is the affine map of the mechanism in the row-vector convention: the weight is stored (in_features, out_features) so the forward pass is x @ W + b with no transpose. Both parameters are drawn from rng.uniform(-bound, bound) with bound = 1/sqrt(in_features). The caller passes the random generator in, so two layers built from generators with the same seed are identical, and tests and training runs are reproducible without any global state.

__call__ accepts any input whose last axis has width in_features. It checks x.shape[-1] directly rather than calling assert_shape, because assert_shape fixes the number of axes and a layer should accept (in,), (B, in) and (B, T, in) alike. The product then follows the rules for leading axes, (..., in) @ (in, out) giving (..., out), and the bias, shape (out,), broadcasts across every leading axis. Without the explicit check, a wrong-width input would still fail inside @, but with a message about matmul core dimensions rather than one that names the layer’s expected width.

Exercises

(a) What is the shape of each intermediate result, and of the final result, when evaluating (8, 16) @ (16, 32) @ (32, 4) left to right? How many multiply-adds does it take, and how many if evaluated right to left?

Answer

@ is left-associative in Python, so the expression is ((8, 16) @ (16, 32)) @ (32, 4). The first product has shape (8, 32): outer dimensions 8 and 32, inner dimension 16. The second is (8, 32) @ (32, 4), shape (8, 4). At nkmnkm multiply-adds per product, left to right costs 8⋅16⋅32+8⋅32⋅4=4096+1024=51208 \cdot 16 \cdot 32 + 8 \cdot 32 \cdot 4 = 4096 + 1024 = 5120.

Right to left, (16, 32) @ (32, 4) is (16, 4), then (8, 16) @ (16, 4) is (8, 4), for 16⋅32⋅4+8⋅16⋅4=2048+512=256016 \cdot 32 \cdot 4 + 8 \cdot 16 \cdot 4 = 2048 + 512 = 2560. Associativity guarantees the same (8, 4) result, up to floating-point rounding; the cost differs by a factor of two. Order matters when one of the operands is much narrower than the others.

(b) Let x\mathbf{x} have shape (in,), treated as a (in, 1) column, and W\mathbf{W} shape (in, out). Show that the row-vector product x⊤W\mathbf{x}^\top\mathbf{W}, which is what x @ W computes, equals (W⊤x)⊤(\mathbf{W}^\top\mathbf{x})^\top. Prove the general rule (AB)⊤=B⊤A⊤(\mathbf{A}\mathbf{B})^\top = \mathbf{B}^\top\mathbf{A}^\top on the way.

Answer

Let A\mathbf{A} have shape (n, k) and B\mathbf{B} shape (k, m). Then (AB)⊤(\mathbf{A}\mathbf{B})^\top has shape (m, n), and so does B⊤A⊤\mathbf{B}^\top\mathbf{A}^\top, which is (m, k) times (k, n). Entry by entry, using the definition of transpose and the product formula,

((AB)⊤)ij=(AB)ji=∑p=1kAjpBpi=∑p=1k(B⊤)ip(A⊤)pj=(B⊤A⊤)ij, \big((\mathbf{A}\mathbf{B})^\top\big)_{ij} = (\mathbf{A}\mathbf{B})_{ji} = \sum_{p=1}^{k} A_{jp}\, B_{pi} = \sum_{p=1}^{k} (\mathbf{B}^\top)_{ip}\, (\mathbf{A}^\top)_{pj} = (\mathbf{B}^\top\mathbf{A}^\top)_{ij} ,

where the third step rewrites each factor as an entry of a transpose and swaps the order of the two scalar factors.

Now apply the rule with A=W⊤\mathbf{A} = \mathbf{W}^\top, shape (out, in), and B=x\mathbf{B} = \mathbf{x}, shape (in, 1): (W⊤x)⊤=x⊤(W⊤)⊤=x⊤W(\mathbf{W}^\top\mathbf{x})^\top = \mathbf{x}^\top(\mathbf{W}^\top)^\top = \mathbf{x}^\top\mathbf{W}, since transposing twice restores the original. Both sides have shape (1, out). So storing the weight as (in, out) and multiplying a row on the left computes the same numbers as storing its transpose and multiplying a column on the right; only the layout differs.

(c) Which of these broadcasts succeed, and what is the result shape of each: (3, 1) + (1, 4), (3,) + (4,), (2, 3, 4) + (3, 1)?

Answer
  • (3, 1) + (1, 4) succeeds with shape (3, 4). The pairs are (3,1)(3, 1) and (1,4)(1, 4); each contains a 1, and the result takes the larger size on each axis. This is an outer sum: entry (i,j)(i, j) is the ii-th entry of the left plus the jj-th of the right.
  • (3,) + (4,) fails. The only pair is (3,4)(3, 4): unequal and neither is 1. numpy raises ValueError: operands could not be broadcast together with shapes (3,) (4,).
  • (2, 3, 4) + (3, 1) succeeds with shape (2, 3, 4). Padding gives (1, 3, 1); the pairs are (2,1)(2, 1), (3,3)(3, 3), (4,1)(4, 1), all compatible. The (3, 1) column is added along the last axis of each of the two (3, 4) slices.

(d) A feed-forward projection maps width 4096 to width 11008. Count the FLOPs of one forward pass through it, including the bias, for 1 token and for a batch of 512 tokens.

Answer

The weight has shape (4096, 11008), so one token, shape (1, 4096), costs 4096×11008=45,088,7684096 \times 11008 = 45{,}088{,}768 multiply-adds, or 90,177,53690{,}177{,}536 FLOPs. The bias adds 11,008 additions, for 90,188,544≈9.0×10790{,}188{,}544 \approx 9.0 \times 10^7 FLOPs.

For 512 tokens the input is (512, 4096) and every count scales by 512: 512×90,177,536=46,170,898,432512 \times 90{,}177{,}536 = 46{,}170{,}898{,}432 FLOPs for the product, plus 512×11,008=5,636,096512 \times 11{,}008 = 5{,}636{,}096 bias additions, about 4.6×10104.6 \times 10^{10} in total. The weight matrix itself is the same 45 million numbers in both cases, so the batch does 512 times the arithmetic for the same weight traffic.

(e) Prove that the composition of two affine maps is affine, and write the combined weight and bias. Use the row-vector convention of the code: f1(x)=xW1+b1f_1(\mathbf{x}) = \mathbf{x}\mathbf{W}_1 + \mathbf{b}_1 and f2(h)=hW2+b2f_2(\mathbf{h}) = \mathbf{h}\mathbf{W}_2 + \mathbf{b}_2, with x\mathbf{x} of shape (1, k), W1\mathbf{W}_1 (k, h), b1\mathbf{b}_1 (1, h), W2\mathbf{W}_2 (h, m), b2\mathbf{b}_2 (1, m).

Answer

Substitute and distribute the matrix product over the sum:

f2(f1(x))=(xW1+b1)W2+b2=x(W1W2)+(b1W2+b2). f_2(f_1(\mathbf{x})) = (\mathbf{x}\mathbf{W}_1 + \mathbf{b}_1)\mathbf{W}_2 + \mathbf{b}_2 = \mathbf{x}(\mathbf{W}_1\mathbf{W}_2) + (\mathbf{b}_1\mathbf{W}_2 + \mathbf{b}_2) .

Distributing is legal because each entry of a product is a sum of products, and ∑p(up+vp)Wpj=∑pupWpj+∑pvpWpj\sum_p (u_p + v_p) W_{pj} = \sum_p u_p W_{pj} + \sum_p v_p W_{pj}; regrouping (xW1)W2(\mathbf{x}\mathbf{W}_1)\mathbf{W}_2 as x(W1W2)\mathbf{x}(\mathbf{W}_1\mathbf{W}_2) is associativity. The result is an affine map with weight W=W1W2\mathbf{W} = \mathbf{W}_1\mathbf{W}_2, shape (k, m), and bias b=b1W2+b2\mathbf{b} = \mathbf{b}_1\mathbf{W}_2 + \mathbf{b}_2, shape (1, m). The first layer’s bias passes through the second layer’s weight like any other input. Two stacked Linear layers with nothing between them are one Linear layer.

Lab

Implement assert_shape, matmul and Linear in py/labs/ch03/starter.py, then run

make lab CH=03 IMPL=mine

until it passes. Read py/labs/ch03/test_lab.py first. It pins the details the prose leaves to judgement: assert_shape must reject a rank mismatch in either direction, not only a size mismatch; matmul must raise ValueError for a vector argument and for mismatched inner dimensions, and must match @ on random input; Linear must accept inputs with any number of leading axes, reject a wrong last-axis width with ValueError, and draw both weight and bias within ±1/in\pm 1/\sqrt{\text{in}}. test_linear_is_affine subtracts the layer’s output at zero before checking additivity, since with a bias the layer itself is not linear.

Then measure what the explicit loops cost. Save this script outside the repository, for example as /tmp/matmul_timing.py:

import time

import numpy as np

from tinygpt.linear import matmul


def seconds(f, repeat):
    """Best wall-clock time of `repeat` calls to f."""
    best = float("inf")
    for _ in range(repeat):
        t0 = time.perf_counter()
        f()
        best = min(best, time.perf_counter() - t0)
    return best


rng = np.random.default_rng(0)
for n in [64, 256]:
    a, b = rng.normal(size=(n, n)), rng.normal(size=(n, n))
    np.testing.assert_allclose(matmul(a, b), a @ b)
    loops = seconds(lambda: matmul(a, b), repeat=1)
    blas = seconds(lambda: a @ b, repeat=5)
    print(f"n={n:3d}  matmul {loops:8.3f} s   @ {blas * 1e6:8.1f} us   ratio {loops / blas:>9,.0f}")

and run it from py/ with PYTHONPATH=. uv run python /tmp/matmul_timing.py, changing the import to from labs.ch03.starter import matmul to time yours. @ is timed as the best of five calls because its first call can include one-off start-up costs; the loop version is timed once, since a single 256×256 run already takes seconds. One run on one machine, a laptop with an Intel Core i3-1315U, printed:

n= 64  matmul    0.273 s   @     67.9 us   ratio     4,020
n=256  matmul   22.331 s   @   1462.5 us   ratio    15,269

These figures are one run, not a benchmark. The machine was busy with other work at the time, and two further runs of the same script gave ratios between about 1,000 and about 20,000, mostly because the time for @ moved by a factor of ten. Your hardware will give different numbers again. The shape of the result is what to explain.

  • The loop version scales as n3n^3. Going from 64 to 256 multiplies nn by 4 and the work by 43=644^3 = 64: from 262,144 to 16,777,216 multiply-adds. The measured time grew by a factor of 82, close to 64 given the noise. Dividing time by multiply-adds gives the cost of one trip through the inner loop body: about 1.0 microseconds at n=64n = 64 and 1.3 at n=256n = 256.
  • Each trip through that body is interpreted. out[i, j] += a[i, p] * b[p, j] runs as a sequence of bytecode instructions. Each of the three reads, out[i, j], a[i, p] and b[p, j], parses an index tuple and allocates a new numpy scalar object for the value; then come a multiply, an add, and a store back into the array, each dispatched at run time on the types of its operands. The one floating-point multiply-add the line exists for is a tiny fraction of that work.
  • @ calls BLAS. numpy hands the product to a compiled BLAS library (on Linux x86-64 the numpy wheels ship OpenBLAS, which is what the run above used; the macOS arm64 wheels use Apple’s Accelerate). Its kernels are machine code with no per-element dispatch, use SIMD instructions that do several multiply-adds at once, split the matrices into blocks sized to fit in cache so each loaded number is reused many times, and can spread the work across cores. At 256×256 the run above works out to 2⋅25632 \cdot 256^3 FLOPs in 1.46 ms, about 23 billion floating-point operations per second.

The ratio, three to four orders of magnitude, is the reason no later chapter calls matmul: it exists to make the index formula concrete, and @ is what the models use. It is also the reason llama.cpp spends its engineering effort on matrix-multiply kernels for each backend: the arithmetic is fixed by the model, and how fast it runs depends on how the kernel uses the hardware.

Further reading

  • Gilbert Strang, Introduction to Linear Algebra (5th ed., 2016), chapters 1–2. Matrices acting on vectors, the row and column pictures of a matrix–vector product, and the rules of matrix multiplication.
  • NumPy documentation, “Broadcasting”. The authoritative statement of the rules above, with worked shape examples.
  • Supplementary video series: 3Blue1Brown, Essence of Linear Algebra (2016), the chapters on linear transformations and on matrix multiplication as composition. The function view of a matrix, drawn in motion.

results matching ""

    No results matching ""