ml-models / Mathematical foundations / Matrices and matrix multiplication intro

Matrices and matrix multiplication

Matrix multiplication is many dot products done at once, and it is where almost all of a neural network's compute goes. This page covers what it computes, the shape rules PyTorch enforces, batched matmul and einsum, how nn.Linear is one matmul plus a bias, and how to count the FLOPs, with every number worked and every function linked at v2.15.0.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. The definition
  5. Shape rules and torch.matmul
  6. Batched matmul and einsum
  7. A linear layer is a matmul plus a bias
  8. Counting FLOPs
  9. Identity, transpose and inverse
  10. Worked example
  11. Implementation
  12. Tradeoffs
  13. Check yourself
  14. Connections
  15. Read first
  16. Read next
  17. References

Problem

A layer with 4096 outputs computes 4096 dot products for every input vector, and a batch holds thousands of input vectors. Writing that as a loop over dot products is slow and hides the structure. We need one operation that means "every row of this, dotted with every column of that". It should be easy to reason about on paper, cheap to count, and fast on hardware built for it.

That operation is matrix multiplication. Reading its shapes fluently is what lets you debug a model ("expected 512, got 768"), estimate its cost before running it, and see that attention, linear layers and embeddings lookups are the same few operations.

Intuition

A matrix is a grid of numbers with m rows and k columns, shape [m, k]. Think of it as a stack of m row vectors. Multiplying it by a [k, n] matrix takes each of the m rows and dots it with each of the n columns, giving an [m, n] grid of results. The inner sizes (both k) must match because a dot product needs two vectors of the same length, and they disappear from the result because each dot product collapses them to one number.

Matrix multiplication as rows dotted with columns A is a 2 by 3 matrix on the left, B a 3 by 2 matrix above, and their product C, 2 by 2, where their row and column meet. The second row of A, [4, 5, 6], and the first column of B, [7, 9, 11], are highlighted. Their dot product, 4·7 + 5·9 + 6·11 = 139, fills the highlighted cell in the second row and first column of C. A [2, 3] 1 2 3 4 5 6 B [3, 2] 7 8 9 10 11 12 58 64 139 154 C = A @ B [2, 2] C[1][0] = A[1] · B[:, 0] = 4·7 + 5·9 + 6·11 = 28 + 45 + 66 = 139 4 cells × 3 multiply-adds = 24 FLOPs

Each output cell sits where its row of A and its column of B line up. The inner size, 3, is used up by the dot product; the outer sizes, 2 and 2, become the result's shape.

Mechanics

The definition

Cij = Σp=1..k Aip Bpj,   A: [m, k], B: [k, n] → C: [m, n]

Two other readings of the same sum are worth keeping. Column j of C is A times column j of B, a weighted sum of A's columns. Row i of C is row i of A times B, a weighted sum of B's rows. The second is how to read a linear layer: each output is a mixture of the weight rows, weighted by the input. Matmul is associative, (AB)C = A(BC), but not commutative: AB and BA usually differ, and often only one of them has valid shapes.

Shape rules and torch.matmul

torch.mm takes exactly two 2-D matrices (mm). torch.matmul, which @ calls, dispatches on rank (matmul):

LeftRightResultWhat happens
[3] [3] [] dot product, a 0-d scalar
[2, 3] [3, 4] [2, 4] matrix–matrix
[2, 3] [3] [2] matrix–vector: the vector is a column
[3] [3, 4] [4] vector–matrix: a 1 is prepended, then removed
[10, 2, 3] [3, 4] [10, 2, 4] batched: the matrix is reused for all 10
[5, 1, 2, 3] [7, 3, 4] [5, 7, 2, 4] batch dims broadcast: (5, 1) with (7,)

The last two dimensions are the matrices; everything before them is a batch and broadcasts. When shapes don't fit, the error names the two inner sizes, so "mat1 and mat2 shapes cannot be multiplied (32x768 and 512x10)" means a 768 met a 512.

The shape rule for matrix multiplication, plain and batched Top row: [m, k] @ [k, n] gives [m, n]; the two k's are linked and crossed out, m and n carry through. Bottom row: [b, m, k] @ [b, k, n] gives [b, m, n]; the batch dimension b is matched like broadcasting, the k's cancel, and m and n carry through. [m, k] @ [k, n] → [m, n] the inner k must match and is summed away; m and n carry through [b, m, k] @ [b, k, n] → [b, m, n] b matrix products side by side; the batch dims broadcast like elementwise ops

Batched matmul and einsum

torch.bmm multiplies two stacks of matrices of shape [b, m, k] and [b, k, n], with no broadcasting (bmm). addmm and baddbmm fuse "β·C + α·(A @ B)" into one kernel (addmm, baddbmm). torch.einsum names every axis with a letter and sums over any letter missing from the output, so "ik,kj->ij" is matmul, "bik,bkj->bij" is bmm, and "bhqd,bhkd->bhqk" is every attention head's QKᵀ at once (einsum).

A linear layer is a matmul plus a bias

y = x Wᵀ + b,   x: [batch, in], W: [out, in], b: [out] → y: [batch, out]

nn.Linear stores its weight as [out_features, in_features], one row per output neuron, and its forward is a single call to F.linear (Linear.forward, F.linear). So each output is a dot product of the input with one weight row: the neuron from the previous page. The transpose is why the weight is [out, in] while the math reads "in to out". The input can have any number of leading dimensions, [batch, seq, in] included; only the last must equal in_features.

nn.Linear as one matmul and one broadcast add An input box x of shape [batch, in] feeds a matmul with the transposed weight Wᵀ of shape [in, out], giving [batch, out]. A bias of shape [out] is broadcast and added to every row, giving the output y of shape [batch, out]. x [batch, in] @ Wᵀ [in, out] + b [out] y [batch, out] [batch, out] broadcast to every row

Counting FLOPs

Each of the m·n output cells needs k multiplies and k adds, so

FLOPs(A @ B) = 2 · m · k · n   (times b for a batch of b)

A linear layer applied to T tokens costs 2 · T · in · out. That is where the rule of thumb "a forward pass costs about 2 FLOPs per parameter per token" comes from, and backward costs about twice the forward, giving the 6·N·D estimate for training a model of N parameters on D tokens. Moving the matrices costs only (mk + kn + mn) numbers, so for large matrices compute grows much faster than memory traffic, which is why GPUs run matmuls near peak and most other ops far below it.

Identity, transpose and inverse

  • The identity I has ones on the diagonal and zeros elsewhere; AI = A (torch.eye).
  • The transpose Aᵀ swaps rows and columns, and (AB)ᵀ = BᵀAᵀ. In PyTorch .T/.t()/.mT return a view with swapped strides, no copy (torch.t).
  • The inverse A⁻¹ of a square matrix undoes it: AA⁻¹ = I. For M = [[2, 1], [1, 1]], M⁻¹ = [[1, -1], [-1, 2]]. Only matrices with a non-zero determinant have one (linalg.inv; torch.inverse is an alias). In practice you rarely invert: to solve Ax = b, torch.linalg.solve is faster and more accurate.
TermShape/ParamsMeaningSource
Matrix product [m, k] @ [k, n] → [m, n] Every row of the left dotted with every column of the right. torch.mm
matmul / @ any ranks ≥ 1; batch dims broadcast Dot, matrix–vector, matrix–matrix or batched, chosen by rank. torch.matmul
Batched matmul [b, m, k] @ [b, k, n] b independent products; no broadcasting. torch.bmm
Fused multiply-add beta, alpha β·input + α·(A @ B) in one kernel. addmm, baddbmm
Einstein summation equation string, e.g. "bik,bkj->bij" Any product-and-sum over named axes. torch.einsum
Linear layer weight [out, in], bias [out] y = x Wᵀ + b over the last dimension of x. nn.Linear, F.linear
Identity n, optional m Ones on the diagonal; the "do nothing" matrix. torch.eye
Inverse square, non-singular The matrix that undoes A. linalg.inv

Worked example

A linear layer by hand. Two inputs with three features each, x = [[1, 2, 3], [4, 5, 6]], shape [2, 3]. A layer with two outputs has W = [[1, 0, -1], [0.5, 0.5, 0.5]], shape [2, 3], and b = [0, 1].

  1. Transpose the weight: Wᵀ = [[1, 0.5], [0, 0.5], [-1, 0.5]], shape [3, 2]. Now x @ Wᵀ is [2, 3] @ [3, 2] → [2, 2].
  2. Output 0 dots each input with W's first row, [1, 0, −1], which computes "first feature minus third": 1 − 3 = −2 and 4 − 6 = −2.
  3. Output 1 dots with [0.5, 0.5, 0.5], half the sum: 0.5·6 = 3 and 0.5·15 = 7.5.
  4. x @ Wᵀ = [[-2, 3], [-2, 7.5]]. Add b = [0, 1] to every row: y = [[-2, 4], [-2, 8.5]].
  5. Cost: 2 · 2 · 3 · 2 = 24 FLOPs for the matmul, plus 4 adds for the bias.

Scaling the count up. The same formula sizes real models:

Operationm · k · nFLOPs
The layer above 2 · 3 · 2 24
Linear 4096 → 4096 on 2048 tokens 2048 · 4096 · 4096 68.7 GFLOP
FFN up-projection 4096 → 16384 on 2048 tokens 2048 · 4096 · 16384 274.9 GFLOP
Attention scores QKᵀ, one head, 2048 tokens, d = 128 2048 · 128 · 2048 1.1 GFLOP

Implementation

import torch
import torch.nn as nn

A = torch.tensor([[1., 2., 3.], [4., 5., 6.]])
B = torch.tensor([[7., 8.], [9., 10.], [11., 12.]])
A @ B                                   # tensor([[ 58.,  64.], [139., 154.]])
torch.einsum("ik,kj->ij", A, B)         # the same product, axes named

layer = nn.Linear(3, 2)                 # weight [2, 3], bias [2]
with torch.no_grad():
    layer.weight.copy_(torch.tensor([[1., 0., -1.], [.5, .5, .5]]))
    layer.bias.copy_(torch.tensor([0., 1.]))
layer(A)                                # tensor([[-2.0, 4.0], [-2.0, 8.5]])
A @ layer.weight.T + layer.bias         # identical: what F.linear computes

x = torch.randn(32, 10, 3)              # [batch, seq, in]
layer(x).shape                          # torch.Size([32, 10, 2])
torch.matmul(torch.randn(5, 1, 2, 3), torch.randn(7, 3, 4)).shape  # [5, 7, 2, 4]

From scratch, the triple loop every kernel optimizes:

def matmul(a, b):
    m, k, n = len(a), len(b), len(b[0])
    assert len(a[0]) == k, "inner sizes must match"
    c = [[0] * n for _ in range(m)]
    for i in range(m):
        for j in range(n):
            for p in range(k):
                c[i][j] += a[i][p] * b[p][j]
    return c

Tradeoffs

  • Order of a chain matters. (AB)C and A(BC) give the same answer at very different costs. With A [1000, 10], B [10, 1000], C [1000, 1]: (AB)C costs 22,000,000 FLOPs, A(BC) only 40,000. LoRA's low-rank update relies on this.
  • matmul versus einsum. einsum is clearer for anything with more than two axes and usually lowers to the same kernels, but an unusual equation can fall back to a slower path; profile hot loops.
  • Precision. On recent GPUs, float32 matmuls may run in TF32, which keeps float32 range but only about 10 mantissa bits. That is controlled by torch.backends.cuda.matmul.allow_tf32 and torch.set_float32_matmul_precision. bfloat16 and float16 matmuls are faster still and accumulate in float32.
  • Shapes that suit the hardware. Tensor cores work on tiles; sizes that are multiples of 8 (or 64, 128) run noticeably faster, which is why vocabularies get padded.
  • Don't invert. Explicit inverses are slow and amplify rounding error for nearly singular matrices; use linalg.solve or a factorization.

Check yourself

What is the shape of [8, 1, 5, 6] @ [3, 6, 2]?

[8, 3, 5, 2]. The matrix dims give [5, 2]; the batch dims (8, 1) and (3,) broadcast to (8, 3).

Why is nn.Linear(768, 10).weight shaped [10, 768] and not [768, 10]?

Each row is one output neuron's weight vector, and F.linear computes x Wᵀ + b. Storing one row per output keeps each neuron's weights contiguous.

How many FLOPs does [64, 512] @ [512, 1000] take?

2 · 64 · 512 · 1000 = 65,536,000.

Write attention's QKᵀ for tensors shaped [batch, heads, seq, d] with einsum.

torch.einsum("bhqd,bhkd->bhqk", q, k): d is missing from the output, so it is summed, giving one score per query–key pair per head.

Connections

References

  • Strang. Introduction to Linear Algebra, 6th ed., chapter 2 (matrices and their multiplication). Wellesley-Cambridge Press, 2023.
  • Kaplan et al. Scaling Laws for Neural Language Models. 2020, §2.1 on counting compute as 2N FLOPs per token forward. arXiv:2001.08361
  • Hu et al. LoRA: Low-Rank Adaptation of Large Language Models. 2021. arXiv:2106.09685
  • PyTorch v2.15.0: torch.matmul and nn.Linear.