Every tool the rest of the book leans on, from tensor shapes and the chain rule to Adam, initialization, and PyTorch internals. Built from intuition up to the depth interviewers probe.
Tensors, shapes, and broadcasting
Here is a bug that quietly ruins real training runs. You have predictions from a model and the targets you want them to match, and you compute the mean squared error. The code runs, the loss goes down, the model trains. It just trains toward the wrong thing, and nothing ever crashes.
The model's output had shape (5, 1) and the targets had shape (5,). Subtracting them did not raise an error. It quietly built a table of every prediction minus every target, and the "loss" became the average over all 25 pairs. Most of those pairs compare example with example , which is meaningless. The loss it reports is 0.30, while the true error is 0.01.
This section is about never writing that bug, and about the handful of ideas (shapes, broadcasting, memory layout, and einsum) that make tensor code readable and fast. They are the grammar of everything else in this book. Every equation from here on comes with shapes, and every interview coding round assumes you can manipulate them without thinking.
A tensor is a box of numbers with a shape
In machine learning, a tensor is an -dimensional array of numbers that all share one type. The word comes from physics, where it means something more specific, but in code it means exactly what NumPy calls an ndarray and PyTorch calls a Tensor. Three properties describe it.
- The shape is a tuple of sizes, one per axis. An array of shape
(2, 3, 4)has 24 numbers. - The rank (or
ndim) is the number of axes. A scalar has rank 0, a vector rank 1, a matrix rank 2. - The dtype is the number format, such as
float32,bfloat16, orint64. It sets the memory per element and the precision, which matters a great deal in §0.9.
Almost every tensor you meet in modern AI has a conventional shape, and knowing the conventions lets you read code at a glance. These are the ones this book uses.
| object | shape | example |
|---|---|---|
| a batch of token ids | 8 sequences of 1,024 tokens | |
| embeddings or hidden states | in GPT-2 small | |
| a batch of images | color channels | |
| attention scores | one table per head | |
| a linear layer's weight in our NumPy code | so that | |
nn.Linear.weight in PyTorch | PyTorch computes |
Here is the batch size, the sequence length, the model width, the number of attention heads, and the channels, height, and width of an image. That last pair of rows trips people up constantly. PyTorch stores a linear layer's weight as (out, in), so when you port a NumPy derivation to PyTorch you will see a transpose appear. Neither is wrong. Just know which one you are looking at.
How big is a tensor
A tensor's memory is its number of elements times the bytes per element, 4 for float32 and 2 for bfloat16. That one multiplication tells you, before you run anything, which tensors in a model are cheap and which will not fit. Take GPT-2 small (, heads, 12 layers, a vocabulary of 50,257 tokens) and a modest batch of sequences of tokens.
The hidden states are small. The attention scores, one table per head, are about 30 times larger per layer, and storing them for every layer costs more than the model itself. The logits are a surprise to most people, because a 50,257-way score for every token outweighs the whole model. Those two numbers motivate two pieces of engineering you will meet later, FlashAttention, which never stores the score table (§12.3), and computing the loss over the vocabulary in chunks. Get in the habit of doing this arithmetic, because "will it fit" is the first question of every systems interview.
Reductions remove the axis you reduce over
A reduction (sum, mean, max, and so on) collapses one axis. If X has shape (B, d), then X.sum(axis=0) adds down the batch and leaves shape (d,), while X.sum(axis=1) adds across features and leaves (B,). The axis you name is the axis that disappears.
Often you want to keep a size-1 placeholder where the axis was, so that the result lines up with the original for the next operation. That is what keepdims=True does.
That last line normalizes each row by its own total. Without keepdims, the divisor would have shape (2,), and dividing a (2, 3) array by it raises an error here only because 2 does not equal 3. With a square matrix it would not raise anything. It would divide each column by a row total, which is the same silent bug as at the top of the page. Karpathy hits exactly this while normalizing a count matrix in the makemore video linked below.
Broadcasting, the rule that saves loops and causes bugs
Broadcasting is how NumPy and PyTorch combine arrays of different shapes in elementwise operations. You have already used it if you have ever written X - X.mean(axis=0) or logits + bias. The rule is short, and it is worth knowing exactly.
To combine shapes and , line them up from the right. If one shape has fewer axes, pad it on the left with 1s until both have the same length. Then compare the shapes axis by axis.
- If the two sizes are equal, that is the output size.
- If one of them is 1, the output size is the other one, and the size-1 array is repeated (stretched) along that axis.
- Otherwise the shapes are incompatible and you get an error.
In plain words, trailing axes must match, and a size of 1 (or a missing axis on the left) means "use the same value for every position".
Let's apply it to the bug. The prediction had shape (5, 1) and the target (5,). Line them up from the right. The target becomes (1, 5) after padding. Axis by axis, against gives , and against gives . The result is (5, 5), which is what we saw.
| first | second | result | what it is for |
|---|---|---|---|
| an outer sum, every row value plus every column value | |||
| adding a bias to every token | |||
| scaling or masking each token as a whole | |||
| a padding mask shared by every head and every query | |||
| the silent bug | |||
| error | trailing sizes disagree |
Try these and your own in the visualizer. Click a dimension to change it, and add or remove axes.
Making broadcasting do what you mean
Most of the time you choose which axes line up by inserting size-1 axes yourself. Indexing with None (also spelled np.newaxis) inserts one, so for u of shape (3,), u[:, None] is a (3, 1) column and u[None, :] a (1, 3) row. Then u[:, None] - v[None, :] is the table of every difference , on purpose this time. The reverse operation, squeeze, removes size-1 axes, and it is the fix for the bug at the top of the page, y_pred[:, 0] or y_pred.squeeze(-1). PyTorch calls the same two operations unsqueeze and squeeze.
Broadcasting never copies the data
When NumPy "repeats" the size-1 array, it does not actually build the bigger array. It reads the same memory again and again. To see how, we need to look at how arrays are laid out in memory, which also explains views, copies, and why transposes are free.
Strides, views, and copies
Memory is a flat line of bytes. An array of shape (2, 3, 4) stores its 24 numbers in one contiguous block, and a small tuple called the strides says how many bytes to jump to move one step along each axis. For float64 (8 bytes per number) in the default row-major (C) order, the last axis is contiguous, so its stride is 8. One step along the middle axis skips a whole row of 4 numbers, 32 bytes. One step along the first axis skips numbers, 96 bytes.
The position of element , in bytes from the start of the buffer, is
where is the stride of axis in bytes. In plain words, to find an element, walk steps of size along each axis and add up the jumps.
This one formula explains a lot of tensor behavior.
- Transpose is free. Swapping two axes swaps their strides and touches no data. The result is a view, a new shape and stride tuple pointing at the same memory.
- Basic slicing is free.
X[:, ::2]doubles a stride. Still a view. - Broadcasting is free. A stretched axis gets stride 0, so every step along it reads the same numbers.
- Some operations must copy. Fancy indexing with an integer array (
X[[0, 2]]) gathers arbitrary rows, which no stride tuple can describe, so it copies. Reshaping a transposed array usually copies too, as we'll see.
A contiguous array is one whose elements sit in memory in exactly the order you would visit them in a row-major loop. A transposed array is not contiguous. That matters for speed, because GPUs and CPUs read memory fastest in long consecutive runs (you'll meet this as memory coalescing in §11.3). It also matters for correctness in PyTorch, where x.view(...) refuses any new shape that the existing strides cannot express (as with most reshapes of a transposed tensor), and you must call x.contiguous() first or use x.reshape(...), which copies when it has to.
Implement the broadcasting rule
Write broadcast_shape(s1, s2) that takes two shapes as tuples of ints and returns the broadcast result shape as a tuple, or raises ValueError if they are incompatible. Do not call NumPy's broadcasting functions. Implement the rule from the text, including the left padding.
Pairwise distances, the classic broadcasting problem
A problem you will meet in k-means, in nearest-neighbor search, and in interviews is to compute every squared distance between the rows of and the rows of , giving with .
There are two good ways. The direct one is a broadcast, A[:, None, :] - B[None, :, :], of shape , squared and summed over the last axis. It is clear, but it builds an intermediate. With and that is numbers, about 300 GB in float32.
The better one expands the square (the same identity that ties distance to the dot product for word vectors in §1.3),
In plain words, the squared distance is two squared norms minus twice a dot product, and all dot products together are one matrix multiply, . The norms are an column and a row, and broadcasting adds them into an table. Memory drops from to , and the work goes to a matmul, which is the fastest operation on any accelerator.
All pairwise squared distances, two ways
Implement pairwise_sq_dists(A, B) for A of shape (n, d) and B of shape (m, d), returning the (n, m) matrix of squared Euclidean distances, using the norm expansion (one matrix multiply, no (n, m, d) intermediate). Floating-point cancellation can make a tiny distance come out slightly negative, so clip the result at 0.
Reshape is not transpose
Reshape and transpose both change a shape, and that is where the resemblance ends. Reshape keeps the elements in the same row-major order and only regroups them into new axes. Transpose (or permute in PyTorch) reorders the axes, which changes which elements are neighbors. Confusing the two produces tensors of exactly the right shape and completely wrong content, the most dangerous kind of bug.
The canonical example is splitting a model's hidden state into attention heads, which you will do in multi-head attention (§4.5). Take one sequence of tokens with width , and split it into heads of width . Each head should see both tokens, restricted to its own slice of features. Head 0 gets features 0 and 1 of every token, and head 1 gets features 2 and 3.
The right version gives head 0 the rows [0 1] and [4 5], the first two features of each token. The wrong version gives head 0 all of token 0 and head 1 all of token 1. Both have shape (1, 2, 2, 2), both train, and the wrong one is a different (worse) model. The rule of thumb is that you may only reshape to split or merge axes that are adjacent and in the right order. Here we want to split into , which is adjacent, and then move in front of , which is a reordering and needs a transpose.
Split and merge attention heads
Implement split_heads(X, h) taking (B, T, d) to (B, h, T, d // h), and its inverse merge_heads(Y) taking (B, h, T, dh) back to (B, T, h * dh). Head j must contain features j*dh to (j+1)*dh - 1 of every token. merge_heads(split_heads(X, h)) must return X exactly. Raise ValueError if d is not divisible by h.
Sliding windows with strides alone
Strides can do more than describe transposes. Any regular pattern of reads can be described by a shape and a stride tuple, including overlapping ones. A sliding window of width , moving step elements at a time over a 1-D signal of length , is an array of shape with windows. Its row stride is step elements and its column stride is one element. No data is copied, even though windows overlap. This is the idea behind fast convolution in §0.8, and numpy.lib.stride_tricks.sliding_window_view uses exactly this.
Zero-copy sliding windows
Implement sliding_windows(x, k, step) for a 1-D array x, returning a 2-D array whose row i is x[i*step : i*step + k], for every window that fits entirely. Use np.lib.stride_tricks.as_strided. The result must be a view that shares memory with x (no copying), and you must not call sliding_window_view. Return it read-only (pass writeable=False), because writing into overlapping windows would corrupt x.
Einsum, the universal contraction
Matrix multiply, batched matmul, dot products, outer products, traces, transposes, and sums are all instances of one operation. You multiply some tensors elementwise along matching indices and then sum over the indices you don't want to keep. Einstein summation notation, einsum, writes that operation directly.
An einsum string names each axis of each input with a letter, then names the axes of the output after an arrow. The rule is that any letter that appears in the inputs but not in the output is summed over. Letters shared between inputs are multiplied together along that axis.
In plain words, appears in both inputs and not in the output, so it is the summed (contracted) index. That is a matrix multiply.
| einsum | math | what it is |
|---|---|---|
i,i-> | dot product | |
i,j->ij | outer product | |
ij->ji | transpose | |
ii-> | trace | |
ij,jk->ik | matrix multiply | |
bij,bjk->bik | batched matmul | |
btd,de->bte | a linear layer on every token | |
bhqd,bhkd->bhqk | attention scores for every head | |
bhqk,bhkd->bhqd | attention output |
The last two rows are the whole of attention's matrix arithmetic, which is why einsum shows up all over research code. The cost of an einsum is easy to read off too. It is the product of the sizes of all the distinct letters, because that is how many multiply-adds the nested loops would do. For bhqd,bhkd->bhqk that is , the famous quadratic cost of attention.
Contraction order changes the cost, not the answer
When you multiply three or more tensors, the result does not depend on the order you contract them in, but the cost can differ by orders of magnitude. Take , , and . Computing first builds a matrix ( multiply-adds), then multiplies it by (another ), for in total. Computing first builds a matrix () and then a result (), for . That is 100 times less work for the same answer. np.einsum(..., optimize=True) searches for a good order automatically. The same idea, applied by hand, is how LoRA keeps its cost low in §14.5, by never forming the product of its two thin matrices.
Einsum puzzles
Implement each function with a single np.einsum call (no @, no loops, no transpose). Shapes are given in the docstrings.
Batched matrix multiply
The @ operator (np.matmul, torch.matmul) treats the last two axes of each operand as matrices and broadcasts over all the others. So (B, T, d) @ (d, e) gives (B, T, e), a linear layer applied to every token at once, because the weight is treated as a single matrix broadcast over . And (B, h, T, d_h) @ (B, h, d_h, T) gives (B, h, T, T), one score table per head per sequence. Two special cases are worth remembering. A 1-D operand on the left is treated as a row vector and on the right as a column vector, and the added axis is removed from the result. That is why v @ M and M @ v both work without reshaping.
Underneath, batched matmul is one call to a highly tuned kernel (a batched GEMM, for "general matrix multiply"). A Python loop over the batch would launch separate small kernels, which on a GPU is far slower. Writing operations in batched form is the single most important performance habit in this book. You'll see why at the hardware level in the roofline model (§11.2). This section treats a matrix as a box of numbers to be moved and multiplied. What a matrix does to space, its eigenvalues, singular values, and norms, is the subject of §0.10.
The shape bugs that never crash
Here are the bugs that most often survive into real training runs, in rough order of how often they bite.
| bug | what happens | the fix |
|---|---|---|
(N, 1) combined with (N,) | silent broadcast, wrong loss | squeeze or reshape one side, assert shapes |
| reshape where a transpose was needed | right shape, scrambled content | only reshape adjacent axes, transpose to reorder |
| softmax over the wrong axis | probabilities that sum to 1 over the batch instead of over classes | always pass axis=-1 (or dim=-1) explicitly |
reduction without keepdims | the next broadcast lines up the wrong axes | use keepdims=True when the result is combined with the input |
a mask of shape (B, T) against scores (B, h, T, T) | aligned from the right, so lines up with a axis | reshape to (B, 1, 1, T) first |
x.T on a 3-D tensor | reverses all axes, not just the last two | use swapaxes(-1, -2) or PyTorch's x.mT |
| integer arrays | x / 2 makes a new float array, but writing it back with x[:] = x / 2 into an int array silently truncates | create float arrays on purpose |
| in-place write into a view | the original changes too | .copy() or .clone() when you need independence |
| a float32 array combined with a float64 array | the result is silently float64, twice the memory and slower | create arrays with an explicit dtype and keep it |
The common thread is that broadcasting and reshaping are permissive by design, so the type system will not save you. Only shape comments, explicit axes, and assertions will.
In PyTorch
PyTorch tensors follow the same rules, with a few extra names. view is a reshape that never copies (and fails if it would need to), reshape copies when it must, permute is a general transpose, expand is a zero-copy broadcast (stride 0), and repeat really copies.
Two PyTorch details are worth a sentence each. Strides in PyTorch are counted in elements, not bytes. And x.T on tensors that are not 2-D is deprecated, because reversing every axis is rarely what you want. Use x.mT to swap the last two axes, or permute for anything else.
Read more
- NumPy documentation. Broadcasting. The official statement of the rule, with pictures.
- NumPy documentation. Internal memory layout of an ndarray. Strides and contiguity, precisely.
- Harris et al. (2020). Array programming with NumPy. The design of the array model that PyTorch and JAX inherited, with clear figures of strides and broadcasting.
- PyTorch documentation. Tensor Views. Which operations return views and which copy.
- Edward Z. Yang (2019). PyTorch internals. How a tensor is a storage plus sizes, strides, and an offset, from one of PyTorch's core developers.
- Tim Rocktäschel (2018). Einsum is All you Need - Einstein Summation in Deep Learning. A short, readable tour of einsum for deep learning.