Chapter 6: PyTorch Fundamentals
Learn PyTorch from tensors to training at scale: autograd, nn.Module, DataLoaders, the training loop, custom autograd, mixed precision and torch.compile.
- Topics
- 8
- Videos
- 8
- Min read
- 111
Chapter Overview
Every chapter so far has handed you a piece of the machinery behind modern AI: Python to express ideas, NumPy to compute on whole arrays at once, calculus to know which way is downhill, probability to define what a good model is, and linear algebra to understand what a layer actually does to its input. What none of them gave you is a way to put those pieces together at scale: to define a model with millions of parameters, compute the gradient of a loss with respect to every one of them without deriving a single derivative by hand, and run it all on a GPU.
That is the job of PyTorch. Its tensors look and feel like NumPy arrays, but they can live on a GPU and they can record every operation applied to them, so that a single call to loss.backward() applies the chain rule through the whole computation. On top of that sit a small number of abstractions (nn.Module, Dataset, DataLoader, optimizers) that every research codebase and production training job is built from.
The chapter climbs from beginner to advanced:
- Tensors and Tensor Operations: the data structure, its dtypes, devices and memory layout, then indexing, broadcasting, views and einsum
- Autograd: how the computational graph is recorded and how reverse-mode differentiation fills in .grad
- nn.Module and Datasets & DataLoaders: packaging parameters into models and feeding them batches
- The Training Loop: losses, optimizers and schedulers, and why every line of the canonical loop exists
- Custom Autograd Functions & Hooks: extending PyTorch when the built-ins are not enough
- Performance at Scale: where time and memory go, mixed precision, gradient accumulation and torch.compile
Every topic has an explainer video, runnable code, and links to the PyTorch problems on PixelBank so you can practise each idea right after reading it.
Chapter Roadmap
Click any topic to jump in
Tensors
Shape, dtype, device and strides: the n-dimensional array PyTorch computes on.
Tensor Operations
Indexing, broadcasting, views vs copies, matmul and einsum on whole tensors.
Gradients and the layers that own them
Autograd
The recorded graph and reverse-mode differentiation that fill in every .grad.
nn.Module
Parameters, submodules, train/eval modes and state_dict: how models are packaged.
Batches in, updates out
Data Loading
Dataset, DataLoader, collate and transforms: turning raw data into batches.
Training Loop
zero_grad, forward, loss, backward, step: losses, optimizers and schedulers.
Custom gradients and running fast at scale
Custom Autograd & Hooks
autograd.Function and hooks: custom gradients and looking inside a model.
Performance at Scale
Memory budgets, mixed precision, accumulation, checkpointing and torch.compile.
Topics
8 topics in this chapter
Suppose you have a batch of 32 RGB images at 224 by 224 pixels and a network with millions of weights. You need to push every pixel through that network thousands of times, on a GPU, while also recording how each output depends on each weight. The NumPy array you mastered in NumPy & Data Wrangling gets you halfway: it stores numbers densely and vectorizes arithmetic, but it lives only in CPU memory and forgets how it was computed. PyTorch's answer is the tensor, an n-dimensional array that behaves almost exactly like a NumPy array but can live on a GPU and, as later topics show, can track gradients. The vectors and matrices of Linear Algebra are simply tensors with one and two dimensions.
This topic builds the data structure in the order you meet it in practice. We start by creating tensors and the copy-versus-share distinction with NumPy, then read a tensor's shape, rank and element count. Next come dtypes, where precision trades against memory, and devices, where the data physically lives. We close with the layer underneath every tensor, a flat storage plus strides, which explains why some reshapes are free and others copy.
Definition
A PyTorch tensor is an n-dimensional array whose elements all share one type. It is described by four pieces of metadata: a shape (the size of each dimension), a dtype (the element type, torch.float32 by default for floats), a device (where its memory lives, such as cpu or cuda:0), and strides that map each multi-index onto an offset in a flat block of memory called the storage.
In this topic
- 1Creating Tensors: Copy or Share
- 2Shape, ndim and numel
- 3Dtypes, Promotion and Casting
- 4Devices: Where the Data Lives
- 5Storage, Strides and Contiguity
Creating Tensors: Copy or Share
Every computation starts with data in a tensor, and the first trap is not knowing whether you just copied it. torch.tensor(data) reads a Python list or NumPy array and always allocates a fresh copy, inferring the dtype: Python integers become int64, Python floats float32. Factories build tensors from a shape alone: torch.zeros, torch.ones, torch.arange(start, end, step), torch.linspace(start, end, steps), torch.rand (uniform on ) and torch.randn (standard normal), while torch.zeros_like(x) copies another tensor's shape, dtype and device. torch.from_numpy(a) instead wraps the array's existing buffer, so writes through either name appear in both, exactly like the views from NumPy & Data Wrangling, and it keeps NumPy's float64.
Sharing costs : from_numpy writes a small header (data pointer, shape, strides, dtype) and touches none of the elements, while torch.tensor costs time and doubles peak memory. For a 1 GB float32 array that is the difference between microseconds and copying a full gigabyte. Random factories are deterministic given a seed: after torch.manual_seed(0), torch.rand(2, 2) always starts with 0.4963, which is what makes experiments reproducible across runs and machines with the same PyTorch build.
a = np.arange(4, dtype=np.float32). You make t = torch.from_numpy(a) and c = torch.tensor(a), then run t *= 2. What do a and c hold?
Shape, ndim and numel
Most PyTorch bugs are shape bugs, so reading a tensor's geometry is the first debugging reflex. x.shape (a torch.Size, which is a tuple) lists the size of each dimension . x.ndim counts the dimensions: a scalar has 0, a vector 1, a matrix 2, matching the objects of Linear Algebra. x.numel() is the total element count, the product of every , and x.element_size() gives bytes per element, so their product is the memory the data occupies. Conventions matter: vision models expect (N, C, H, W) with the batch first, and swapping two equal-sized dimensions raises no error at all, it silently computes garbage.
Memory grows multiplicatively with every dimension, so doubling the image side quadruples numel: at the same float32 batch needs about 77 MB. Tensor ndim is not the matrix rank from Linear Algebra: ndim counts the indices needed to address one element, while matrix rank counts linearly independent columns. A matrix of all ones has ndim 2 but matrix rank 1. numel is also the cost model for any element-wise operation, which touches each element exactly once, so it predicts time as well as memory.
A batch of 32 RGB images at 224 by 224 pixels, stored as float32. What are ndim, numel() and the memory in bytes?
Dtypes, Promotion and Casting
The dtype fixes both precision and memory. Floats default to float32 (4 bytes), integers to int64 (8 bytes, used for labels and indices), and bool holds masks. float16 and bfloat16 use 2 bytes: float16 keeps more precision but overflows above 65504, while bfloat16 keeps float32's range with only about 3 significant digits. When an operation mixes dtypes, PyTorch promotes along the category order in the formula, where means ranks below, so int64 divided by 2 gives float32, never truncation. Within one category, a zero-dimensional tensor or Python scalar never raises a dimensioned tensor's precision. Cast explicitly with .to(dtype), .float() or .long(); float-to-int truncates toward zero.
A float stores a sign, an exponent and a mantissa. Exponent bits set the range, mantissa bits the precision: machine epsilon, the gap just above 1.0, is for float16, for bfloat16 and for float32. So 1.001 survives in float16 as 1.00098 but rounds to 1.0 in bfloat16, while 70000 overflows float16 to inf yet becomes 70144 in bfloat16. Training cares more about range than precision, since gradients span many orders of magnitude, which is why bfloat16 became the usual low-precision type for large models.
i = torch.tensor([1, 2, 3]) and h = torch.ones(3, dtype=torch.float16). What dtypes do i / 2, i // 2, h + torch.tensor(2.0) and h + torch.tensor([2.0]) have?
Devices: Where the Data Lives
A GPU only accelerates data in its own memory, so every tensor carries a device: cpu, cuda:0 for the first NVIDIA GPU, or mps on Apple silicon. In the formula and are operands and each superscript says where it lives; an operation across devices raises a RuntimeError instead of copying silently (a zero-dimensional CPU tensor is the one exception, treated like a scalar). x.to(device) returns a copy on the target device, or x itself if it is already there, and .cpu() brings data back, which .numpy() requires because NumPy only sees host memory. The portable idiom picks the device once: device = "cuda" if torch.cuda.is_available() else "cpu".
Device transfers cross the PCIe bus, whose 4.0 x16 generation peaks near 32 GB/s, while a high-end GPU reads its own HBM memory at 2 to 3 TB/s, roughly 100 times faster. Moving a 19 MB image batch therefore costs on the order of a millisecond, longer than many layers take to run. The rule is to move data once per batch and keep weights, activations and gradients resident on the GPU. Calls like .item() or .cpu() inside a loop also force the CPU to wait for the GPU, quietly serializing the pipeline.
Your weights w are on cuda:0 but the input x = torch.ones(3) is still on the CPU. What does w + x do, and how does .to behave?
Storage, Strides and Contiguity
Why can transposing a huge tensor take microseconds while .view sometimes refuses to run? Because a tensor is a window onto a flat, one-dimensional storage. The formula locates element : start at the storage offset , then step elements for each unit of index , where is the stride of dimension . A fresh tensor has strides , row-major and contiguous. Transposing, slicing and permute only rewrite shape, strides and offset, returning views that share storage, like NumPy views. The catch: .view needs compatible strides and raises a RuntimeError on a transposed tensor, whereas .reshape or .contiguous() falls back to copying.
Strides turn layout changes into metadata edits: transposing an -element tensor rewrites a few integers instead of moving values. The price is paid later, in memory access, because walking a transposed tensor jumps elements per step and wastes the 64-byte cache lines the CPU fetches, so kernels often call .contiguous() once and pay up front. A stride of 0 even lets one stored value appear many times, which is how expand builds a view of a 3-element vector without copying.
x = torch.arange(12).reshape(3, 4). Which storage slot holds x[2, 1]? What are the strides of x.t() and of the slice x[:, 1:3]?
Code Examples
Copy versus share, and what each dtype costs
import numpy as np
import torch
a = np.array([1.0, 2.0, 3.0]) # NumPy defaults to float64
shared = torch.from_numpy(a) # same memory, no copy
copied = torch.tensor(a) # fresh copy
a[0] = 100.0
print("shared:", shared.tolist(), shared.dtype)
print("copied:", copied.tolist())
for dt in [torch.float32, torch.float16, torch.bfloat16, torch.int64, torch.bool]:
x = torch.zeros(1000, 1000, dtype=dt)
print(f"{str(dt):15s} {x.element_size()} bytes/elem {x.numel() * x.element_size() / 1e6:.1f} MB")
big = torch.tensor(70000.0)
print("70000 as float16:", big.half().item(), " as bfloat16:", big.bfloat16().item())
print("1.001 as float16:", torch.tensor(1.001).half().item(), " as bfloat16:", torch.tensor(1.001).bfloat16().item())
After editing the NumPy array, the tensor built with torch.from_numpy prints [100.0, 2.0, 3.0] with dtype torch.float64, because it shares the buffer and keeps NumPy's default dtype, while the torch.tensor copy still prints [1.0, 2.0, 3.0]. A million elements cost 4.0 MB in float32, 2.0 MB in float16 and bfloat16, 8.0 MB in int64 and 1.0 MB in bool. Casting 70000 gives inf in float16 but 70144.0 in bfloat16, and casting 1.001 gives 1.0009765625 in float16 but 1.0 in bfloat16: float16 has the finer precision, bfloat16 the wider range.
Strides, views and contiguous copies
import torch
x = torch.arange(12).reshape(3, 4)
print("x shape", tuple(x.shape), "stride", x.stride(), "contiguous", x.is_contiguous())
t = x.t() # transpose: swap strides, no copy
print("t shape", tuple(t.shape), "stride", t.stride(), "contiguous", t.is_contiguous())
print("same storage:", t.data_ptr() == x.data_ptr())
x[0, 1] = 99 # edit through x ...
print("t[1, 0] =", t[1, 0].item()) # ... visible through t
try:
t.view(12)
except RuntimeError as e:
print("view failed:", str(e)[:27])
c = t.contiguous() # real copy, row-major again
print("c stride", c.stride(), "same storage:", c.data_ptr() == x.data_ptr())
print("flattened:", c.view(12).tolist())
The 3 by 4 tensor has stride (4, 1) and is contiguous. Its transpose has shape (4, 3), stride (1, 4), is not contiguous, and reports the same storage pointer, so writing 99 into x[0, 1] makes t[1, 0] print 99. Calling view(12) on the transpose fails with view size is not compatible, while contiguous() copies into new storage with stride (3, 1), which flattens to [0, 4, 8, 99, 5, 9, 2, 6, 10, 3, 7, 11], the column-by-column reading of the original.
Theory Exercise
Problem:
A freshly created float32 tensor x has shape (2, 3, 4) and is contiguous. (a) What are its strides? (b) At which storage element, and which byte offset, does x[1, 2, 3] live? (c) What shape and strides does x.permute(2, 0, 1) have, is it contiguous, and can you call .view(24) on it?
Hints:
- In a contiguous row-major tensor the last dimension has stride 1, and each earlier stride is the product of all later sizes.
- Use offset = o + sum of index times stride, with o = 0 for a fresh tensor, then multiply by the element size for bytes.
- permute reorders shape and strides together without moving data.
Coding Exercise
Problem:
Build a tensor from the nested list [[1, 2, 3], [4, 5, 6]] and print its shape, ndim, numel, dtype and device. Cast it to float32 and print its mean, move it to the best available device, then transpose it and report the strides and contiguity before and after calling .contiguous().
Hints:
- torch.tensor infers int64 from Python integers; .float() (or .to(torch.float32)) casts, and .mean() needs a floating dtype.
- Pick the device with torch.cuda.is_available() so the same code runs with or without a GPU, then call .to(device).
- .t() swaps the strides without copying, so the result is not contiguous; .contiguous() copies into row-major order.
Related Problems on PixelBank
A classifier hands you a (32, 10) tensor of scores for a batch of 32 images and a (32,) tensor of correct labels. You need every row turned into probabilities, the score of the right class pulled out of each row, a channels-last image batch flipped to channels-first, and every query compared with every key in an attention layer. Written as Python loops over rows, that is slow on a CPU and wastes a GPU entirely; written as tensor operations, each is one line that runs as a single kernel.
The previous topic, Tensors: PyTorch's Core Data Structure, gave you the objects themselves: shapes, dtypes, devices, and the storage-plus-strides layout. This topic gives you the verbs. Much of it will feel familiar from NumPy & Data Wrangling, but the PyTorch names (dim instead of axis, gather, view, bmm) and a few sharp edges differ.
We start with element-wise arithmetic and reductions, then select data with indexing, masks and gather, then combine mismatched shapes through broadcasting. Next come views, the zero-copy reshapes that strides make possible, and the in-place operations that make views risky. We finish with matrix products, from @ to einsum, which every later layer is built on.
Definition
A tensor operation maps input tensors to an output whose shape follows fixed rules: element-wise ops produce the broadcast shape, reductions remove the reduced dim (or keep it as size 1 with keepdim=True), indexing selects or gathers entries, views reinterpret the same storage with new shape and strides, and matrix products contract a shared inner dimension, .
In this topic
- 1Element-wise Operations and Reductions
- 2Indexing, Boolean Masks and gather
- 3Broadcasting in PyTorch
- 4Views: reshape, view, permute and In-place Ops
- 5Matrix Products: @, bmm and einsum
Element-wise Operations and Reductions
Every arithmetic operator (+, *, /, torch.exp) works element-wise: output entry depends only on input entry , so a whole batch runs in one kernel. A reduction collapses a dimension instead. In the formula, is an matrix (rows are examples, columns are features), runs over the columns, and is row 's total, so X.sum(dim=1) has shape . dim names the axis that disappears, exactly like NumPy's axis. Passing keepdim=True leaves it as size 1, giving , which keeps later arithmetic aligned. Failure mode: mean() on an integer tensor raises a RuntimeError instead of rounding.
A reduction over an tensor reads all entries but writes only , so like element-wise work it is limited by memory bandwidth, not arithmetic. On a GPU it is harder to parallelize: thousands of threads each sum a chunk, then partial sums are combined in a tree of about of the thread count steps. Because floating-point addition is not associative, can round differently from , so a GPU sum may differ from a sequential CPU sum in the last digits. Integer reductions have no such drift.
X = torch.tensor([[1., 2., 3.], [4., 5., 6.]]) has shape (2, 3). What do X.sum(dim=0) and X.sum(dim=1) return, how do you normalize each row to sum to 1, and why does X / X.sum(dim=1) fail?
Indexing, Boolean Masks and gather
Slicing with integers and ranges, such as L[:, 1:3], returns a view: nothing is copied, so writing into it changes the original. Advanced indexing with a boolean mask (L[L > 1.5]) or an integer list returns a copy, and a mask always yields a flat 1-D result. gather picks one entry per position along dim: in the formula, input is the source tensor, index is an integer tensor with the same number of dimensions, and takes index's shape. Failure mode: a (3,) label vector against a 2-D input raises an error until you unsqueeze(1) it. Slicing syntax matches NumPy's.
Basic slicing is free because it only edits metadata. L has strides (4, 1); the slice L[:, 1:3] keeps strides (4, 1) and records a storage offset of 1 element, so it costs whatever the tensor size. Advanced indexing cannot be written as offset plus strides, since the chosen positions are arbitrary, so PyTorch must allocate and copy, for selected entries. gather is paired with scatter_add in autograd: in the backward pass each read position's gradient is added back into a zero tensor shaped like the input.
Logits L of shape (3, 4) are [[2, 0.5, 1, 0.1], [0.3, 3, 0.2, 1], [1, 1, 0, 4]] and labels y = [2, 0, 3]. Pick each row's score for its correct class.
Broadcasting in PyTorch
Without broadcasting, comparing points to points needs a double loop or two manually tiled copies. PyTorch follows NumPy's rules exactly: align shapes from the right; each pair of sizes must be equal or one must be 1, and the size-1 side is stretched. In the formula, and are the two point counts and the coordinate dimension; inserting size-1 axes with None or unsqueeze lets every point of one set meet every point of the other. The stretch is virtual: expand sets that stride to 0. Failure mode: compatible-but-wrong shapes never error, so a (3, 1) prediction minus a (3,) target silently becomes (3, 3).
Broadcasting is an outer product in disguise: copies a column across positions without storing the copies. The result shape is computed per axis as the larger size, which is why torch.broadcast_shapes((8, 1, 6, 1), (7, 1, 5)) returns (8, 7, 6, 5). The output is still real memory: the inputs cost but the difference costs . For large inputs torch.cdist avoids it with , turning the work into a matrix product.
Points a = [[0, 0], [1, 1], [3, 4]] (3, 2) and b = [[0, 0], [1, 0], [0, 1], [3, 0]] (4, 2). Compute all pairwise Euclidean distances without loops.
Views: reshape, view, permute and In-place Ops
Image loaders often produce channels-last batches while convolutions expect , and copying every batch just to reorder axes wastes memory. Recall from the previous topic that a tensor is storage plus strides, the elements skipped per step along each dim. Here is batch size, and height and width, channels. permute only reorders shape and strides, so the result is a view sharing storage, no longer contiguous. view refuses strides it cannot reinterpret, reshape copies when it must, .contiguous() forces the copy. The risk: an in-place op such as add_ (trailing underscore) writes through every view of that storage.
A view is legal when each new dimension can be walked with a single stride over the same storage. Merging dims and into one requires ; a transpose breaks that equality, so view fails. In-place ops break autograd through related bookkeeping: every tensor carries a version counter, shared by its views, that each in-place op increments. Autograd records the version of each tensor it saves and checks it in backward, so q.sigmoid() followed by mul_(2) makes backward() fail, reporting is at version 1; expected version 0, because the sigmoid gradient needs its original output.
x = torch.arange(6).reshape(2, 3) and t = x.t(). What are the strides, what happens with t.view(6) and t.reshape(6), and what does an in-place add_ on x.view(3, 2) do to x?
Matrix Products: @, bmm and einsum
Linear layers and attention scores are sums of products over a shared dimension, which element-wise * cannot do; @ (torch.matmul) contracts the last dim of the left operand with the second-to-last of the right. In the formula, is the batch size, each is , each is , and runs over the shared inner size : the row-times-column rule from Linear Algebra. torch.bmm demands exactly 3-D inputs with equal batch; matmul also broadcasts leading dims. torch.einsum spells out indices: in 'bnm,bmp->bnp' a letter missing from the output is summed. Mismatched inner sizes raise mat1 and mat2 shapes cannot be multiplied.
Multiplying by costs multiply-adds, about floating-point operations, yet touches only numbers. For , , that is 4,194,304 operations over 57,344 numbers, roughly 73 per number, while an element-wise add does one per three numbers moved. That high arithmetic intensity is why GPUs reach peak speed on matrix products. einsum is not a separate algorithm: PyTorch permutes and reshapes the operands and dispatches to the same batched matmul.
Attention: Q and K both have shape (2, 4, 8, 16) for batch 2, 4 heads, 8 tokens, head size 16. Compute every query-key dot product. And what does torch.bmm do with X of shape (4, 5, 3) and a plain (3, 2) weight W?
Code Examples
Cross-entropy by hand with keepdim, gather and masks
import torch
import torch.nn.functional as F
L = torch.tensor([[2.0, 0.5, 1.0, 0.1],
[0.3, 3.0, 0.2, 1.0],
[1.0, 1.0, 0.0, 4.0]]) # (3, 4) logits
y = torch.tensor([2, 0, 3]) # correct classes
log_p = L - L.logsumexp(dim=1, keepdim=True) # (3, 4) - (3, 1)
picked = log_p.gather(1, y.unsqueeze(1)).squeeze(1)
print('log-prob of true class:', picked.round(decimals=4))
print('manual loss:', round((-picked.mean()).item(), 4))
print('F.cross_entropy:', round(F.cross_entropy(L, y).item(), 4))
correct = L.argmax(dim=1) == y # boolean mask
print('correct mask:', correct, 'accuracy:', round(correct.float().mean().item(), 4))
print('confident logits:', L[L > 1.5])
Subtracting the (3, 1) logsumexp from the (3, 4) logits broadcasts row by row and gives log-probabilities. gather then reads each row's true class: -1.5542, -2.9338 and -0.1114. Their negated mean, 1.5331, matches F.cross_entropy, which also prints 1.5331. The argmax mask is [False, False, True], so accuracy is 0.3333, and the boolean mask L > 1.5 returns the flat tensor [2., 3., 4.].
Views share memory, and in-place ops can break autograd
import torch
x = torch.arange(6.).reshape(2, 3)
v = x.view(3, 2) # same storage
v.add_(10) # in-place through the view
print('x after v.add_(10):', x.tolist())
print('same storage:', v.data_ptr() == x.data_ptr())
t = x.t()
print('t strides:', t.stride(), 'contiguous:', t.is_contiguous())
print('reshape copies:', t.reshape(6).data_ptr() != x.data_ptr())
q = torch.tensor([0.0, 1.0, 2.0], requires_grad=True)
s = q.sigmoid()
s.mul_(2) # overwrites what backward needs
try:
s.sum().backward()
except RuntimeError as e:
print('backward failed:', str(e).split(':')[0])
(q.sigmoid() * 2).sum().backward() # out-of-place: fine
print('grad:', q.grad)
Adding 10 in place through the view v changes x to [[10, 11, 12], [13, 14, 15]] because both share one storage. The transpose has strides (1, 3) and is not contiguous, so reshape has to copy and returns new memory. Multiplying the sigmoid output in place makes backward fail with one of the variables needed for gradient computation has been modified by an inplace operation, while the out-of-place version gives the gradient [0.5, 0.3932, 0.2100].
Theory Exercise
Problem:
Let x = torch.arange(24).reshape(2, 3, 4), which has strides (12, 4, 1), and y = x.permute(2, 0, 1). Give the shape and strides of y, say whether it is contiguous, and predict whether y.view(4, 6) and y.view(24) succeed.
Hints:
- `permute` reorders sizes and strides with the same index list and copies nothing.
- Two adjacent dims $i, i+1$ can be merged by `view` only if $\text{stride}_i = \text{stride}_{i+1} \times \text{size}_{i+1}$.
- Non-contiguous does not automatically mean `view` fails; check the specific dims being merged.
Coding Exercise
Problem:
You receive a channels-last image batch imgs of shape (4, 8, 8, 3) (use torch.manual_seed(0) and torch.rand). (1) Permute it to channels-first (4, 3, 8, 8) and check contiguity. (2) Compute the per-channel mean over batch, height and width with keepdim=True and subtract it by broadcasting. (3) Flatten each image to (4, 192): show that view fails on the permuted tensor and reshape works. (4) Project with a (192, 10) weight using @, and confirm torch.einsum gives the same result.
Hints:
- `permute(0, 3, 1, 2)` moves the channel axis to position 1; it returns a non-contiguous view.
- `mean(dim=(0, 2, 3), keepdim=True)` gives shape (1, 3, 1, 1), which broadcasts against (4, 3, 8, 8).
- Wrap `view(4, -1)` in try/except; `reshape(4, -1)` or `.contiguous().view(4, -1)` copies the data into a layout that can be flattened.
You have a model with a million weights and a single number, the loss, that says how wrong it is. To improve the model you need the derivative of that loss with respect to every weight. Deriving a million partial derivatives by hand is hopeless, and estimating each one numerically, by nudging a weight and re-running the model, would cost a million forward passes per training step.
In Mathematical Foundations you learned the chain rule, and in the previous topic, Tensor Operations, Indexing & Broadcasting, you learned the operations models are built from. Autograd joins the two: while your forward code runs, PyTorch records each operation in a graph, and one call to .backward() walks that graph in reverse, applying the chain rule at every node and delivering every gradient for roughly the cost of one more forward pass.
We start with requires_grad and the dynamic graph, then see what .backward() really computes (vector-Jacobian products), then where gradients land and why they accumulate. Finally we switch recording off when it is wasted, and keep it on long enough to take gradients of gradients. The next topic, nn.Module, relies on all of it.
Definition
Autograd is PyTorch's reverse-mode automatic differentiation engine. During the forward pass it records every operation on tensors with requires_grad=True into a dynamic directed acyclic graph of grad_fn nodes. Calling .backward() on a scalar traverses that graph from output to inputs, chaining local derivatives as vector-Jacobian products, and accumulates each result into the .grad field of the leaf tensors.
In this topic
- 1requires_grad and the Dynamic Graph
- 2backward(): Reverse Mode as Vector-Jacobian Products
- 3Leaf Tensors, .grad and Gradient Accumulation
- 4Switching Recording Off: no_grad, inference_mode and detach
- 5retain_graph and create_graph: Higher-Order Gradients
requires_grad and the Dynamic Graph
Without a record of how the loss was computed, nothing is differentiable. Setting requires_grad=True on a floating-point tensor tells PyTorch to log every operation that touches it. Each result gets a grad_fn, a node such as MulBackward0 that remembers its inputs and how to differentiate itself. In the formula, is the input, an intermediate and the output; is the product of two local derivatives, the chain rule from Mathematical Foundations. The graph is rebuilt every forward pass, so Python if statements and loops just work. Integer tensors cannot require gradients; without the flag, .backward() raises an error.
A define-by-run graph is a trace, not a program: it captures only the path your Python actually took. For f(x) = x * x if x > 0 else -x, calling at records MulBackward0 and yields gradient , while records NegBackward0 and yields . Structurally the graph is a DAG of pointers: each node lists its parents in next_functions, and every path ends at an AccumulateGrad sink that writes into a leaf. Backward is then a reverse topological sweep, visiting each node once, so its cost is linear in the number of recorded operations.
With x = 2 and w = 3, both created with requires_grad=True, compute y = w * x and z = y ** 2 + 1. Which grad_fn nodes are recorded, and what are x.grad and w.grad after z.backward()?
backward(): Reverse Mode as Vector-Jacobian Products
.backward() does not derive a symbolic formula; it runs reverse-mode automatic differentiation. Here is an input with entries, an output with entries, their Jacobian, the upstream gradient shaped like , and the result shaped like . Each grad_fn computes this product for its own operation and the engine chains them back to the leaves: the chain rule as Linear Algebra's matrix products. A scalar loss is seeded with . A non-scalar output has no implied seed, so y.backward() raises grad can be implicitly created only for scalar outputs; pass a gradient shaped like , or call .sum() first.
Why reverse rather than forward? Forward-mode differentiation pushes one input direction through the graph per pass, so a loss over parameters needs passes to assemble its gradient. Reverse mode pushes one output direction backwards, so a scalar loss needs a single pass whatever is, at a small constant multiple of the forward cost. That asymmetry is why one call trains millions of weights. The Jacobian is never materialised either: for with a million entries it would hold numbers, yet the product only touches the diagonal.
For v = torch.tensor([1., 2., 3.], requires_grad=True) and y = v * v, what does y.backward(gradient) store in v.grad for gradient = [1, 1, 1] and for gradient = [1, 0, 10]?
Leaf Tensors, .grad and Gradient Accumulation
Where do gradients land? Only on leaf tensors: ones you created directly with requires_grad=True, like weights, whose grad_fn is None. In the formula, is the scalar loss, a leaf and x.grad its stored gradient, which backward adds to rather than overwrites. Intermediate results are non-leaves; their .grad stays None, with a warning, unless you call .retain_grad(). Accumulation lets several micro-batches build one large-batch gradient, but forgetting to reset silently doubles every gradient on the next backward, so training loops set .grad to None or call zero_grad() each step. Updating a leaf in place raises an error, the in-place risk from the previous topic, so updates run under torch.no_grad().
Accumulation is not a quirk; it is the chain rule itself. When one leaf feeds several operations, the multivariable chain rule says its total derivative is the sum of the contributions along every path. In at there are two paths, each contributing , so x.grad is . Because differentiation is linear, , adding into .grad across separate backward calls is the very same operation as adding across paths inside one graph, which is why weight sharing needs no special handling.
With a = torch.tensor(1.5, requires_grad=True), call (a * a).backward() three times without zeroing. What is a.grad after each call, and how do you fix it?
Switching Recording Off: no_grad, inference_mode and detach
Recording is wasted work whenever you will not call backward: scoring a validation set, serving a deployed model, or applying a weight update that must not itself join the graph. Inside with torch.no_grad(): results get requires_grad=False and no grad_fn, and no activations are saved. torch.inference_mode() also skips version tracking, but its tensors can never re-enter autograd: using one in a recorded computation raises Inference tensors cannot be saved for backward. detach() acts on one tensor: equals in value and shares its storage, like the views from the Tensors topic, yet contributes zero derivative to any upstream input . Freezing a parameter with requires_grad_(False) is the permanent version.
Turning off the tape changes memory, not arithmetic: values computed under no_grad are identical. What disappears is saved state. Ten chained tanh calls on a float32 tensor save 10 tensors, 40 MB, for backward; the same loop under no_grad saves none. Saved memory grows as depth times activation size, which is why inference fits much larger batches than training. Mathematically detach is the stop-gradient operator: the identity map in the forward direction whose Jacobian is declared to be zero.
For x = torch.tensor(3.0, requires_grad=True), compare x.grad after backward on L = x * x and on L = x * x.detach().
retain_graph and create_graph: Higher-Order Gradients
Backward frees the saved tensors it consumes, so a second backward through the same graph raises Trying to backward through the graph a second time; retain_graph=True keeps them for another pass. Higher-order derivatives need more: the gradient itself must be recorded. In the formula, is an output, an input, the first derivative and its derivative, the second derivative from Mathematical Foundations. Passing create_graph=True to torch.autograd.grad or .backward() returns a gradient with its own grad_fn, ready to differentiate again. Gradient penalties, meta-learning and Hessian-vector products depend on this. Without the flag the gradient is a constant and the second call fails.
Double backward treats the backward pass as one more differentiable program. With create_graph=True every vector-Jacobian product is itself recorded, so the gradient is a graph you can differentiate again, at the price of keeping both graphs in memory. The Hessian of a model with parameters has entries, a trillion for a million weights, but the product costs only about two backward passes: differentiate the scalar with respect to the parameters. Curvature analysis and Newton-style methods rely on exactly this.
For y = x ** 3 at , get and with torch.autograd.grad. What changes without create_graph=True, and what does retain_graph=True do for two y.backward() calls?
Code Examples
Walking the recorded graph and checking it against the chain rule
import torch
x = torch.tensor(2.0, requires_grad=True) # leaf, we want dz/dx
w = torch.tensor(3.0, requires_grad=True) # leaf, we want dz/dw
y = w * x # recorded: MulBackward0
z = y ** 2 + 1 # recorded: PowBackward0, AddBackward0
print("z =", z.item(), "| z.grad_fn:", type(z.grad_fn).__name__)
fn, chain = z.grad_fn, []
while fn is not None: # walk the recorded tape backwards
chain.append(type(fn).__name__)
nxt = [f for f, _ in fn.next_functions if f is not None]
fn = nxt[0] if nxt else None
print("backward chain:", " -> ".join(chain))
z.backward()
print("autograd dz/dx =", x.grad.item(), " dz/dw =", w.grad.item())
print("by hand dz/dx =", (2 * y * w).item(), " dz/dw =", (2 * y * x).item())
print("leaves:", x.is_leaf, w.is_leaf, "| y is leaf:", y.is_leaf)Two leaves x = 2 and w = 3 produce z = (w times x) squared plus 1 = 37.0, and z.grad_fn is AddBackward0. Following next_functions from z prints the backward chain AddBackward0 -> PowBackward0 -> MulBackward0 -> AccumulateGrad, the last node being the sink that writes into a leaf's grad field. After z.backward() autograd reports dz/dx = 36.0 and dz/dw = 24.0, matching the hand-derived 2yw and 2yx exactly. x and w are leaves (True True) while the intermediate y is not (False).
An L2 gradient penalty with create_graph
import torch
torch.manual_seed(0)
w = torch.randn(3, requires_grad=True)
x = torch.randn(8, 3)
target = torch.randn(8)
loss = ((x @ w - target) ** 2).mean()
# create_graph=True keeps the gradient itself differentiable
(g,) = torch.autograd.grad(loss, w, create_graph=True)
penalty = (g ** 2).sum() # L2 gradient penalty
print("loss:", round(loss.item(), 4), "| grad norm^2:", round(penalty.item(), 4))
print("g.grad_fn:", type(g.grad_fn).__name__)
total = loss + 0.1 * penalty
total.backward() # second-order term flows into w.grad
print("w.grad with penalty:", [round(v, 4) for v in w.grad.tolist()])
w.grad = None
loss2 = ((x @ w - target) ** 2).mean()
loss2.backward()
print("w.grad without: ", [round(v, 4) for v in w.grad.tolist()])A 3-weight linear model on 8 random points has loss 7.4934. torch.autograd.grad with create_graph=True returns a gradient whose squared norm is 40.2939 and which carries its own grad_fn, MvBackward0, so it can be differentiated again. Backpropagating loss + 0.1 * penalty gives w.grad = [6.9946, -4.8571, -5.0599], clearly different from the plain-loss gradient [4.4362, -3.1141, -3.304]: the extra term is a second-order (Hessian-vector) contribution flowing through the double-backward graph.
Theory Exercise
Problem:
Let with requires_grad=True, and . Without running code, what is x.grad after y.backward(torch.tensor([1., 2.]))? Why does y.backward() with no argument fail?
Hints:
- Write the 2x2 Jacobian with one row per output and one column per input.
- backward(v) computes the Jacobian transposed times v, not the Jacobian times v.
- A non-scalar output has no implied seed gradient.
Coding Exercise
Problem:
Take 8 points on the line y = 3x + 0.5 and parameters w = b = 0 with requires_grad=True. (1) Compute dL/dw and dL/db of the mean squared error with autograd and check them against the chain rule by hand. (2) Rebuild the same gradient by accumulating four micro-batches of 2 points. (3) Show what happens if you forget to zero .grad. (4) Take one gradient-descent step with learning rate 0.1 inside torch.no_grad() and confirm the loss drops.
Hints:
- With `err = w * x + b - y`, the chain rule gives `dL/dw = mean(2 * err * x)` and `dL/db = mean(2 * err)`.
- Divide each micro-batch loss by the number of micro-batches, so the accumulated sum equals the full-batch mean gradient.
- Set `w.grad = None` before a fresh backward, and do the update inside `with torch.no_grad():` so it is not recorded and `w` stays a leaf.
Related Problems on PixelBank
A small digit classifier that maps a 784-pixel image through 128 hidden units to 10 scores already holds 101,770 numbers to learn. With only the tools from Autograd & the Computational Graph you would create every weight by hand with requires_grad=True, keep them all in a list for the optimizer, remember to switch dropout off at test time, and write your own code to save and restore them. Forget one tensor and it silently never trains.
nn.Module is PyTorch's answer: a class that owns its parameters, finds the parameters of every layer you attach to it, and gives you one handle for counting, moving, switching modes and saving the whole model.
We start with the module itself: __init__, forward and the automatic registration that builds a tree of submodules. Next comes nn.Linear, the layer behind most networks, and its exact shapes. Then containers such as nn.Sequential and nn.ModuleList that compose layers, followed by train() versus eval(), which change what dropout and batch normalization do. We finish with weight initialization, the choice that decides whether a signal survives twenty layers, and with state_dict for saving and loading.
Definition
An nn.Module is a Python class whose __init__ declares its layers and learnable tensors and whose forward maps inputs to outputs. Every nn.Parameter or submodule assigned as an attribute is registered automatically, so parameters(), train(), eval(), .to(device) and state_dict() act on the whole tree of modules at once.
In this topic
- 1nn.Module: __init__, forward and Auto-Registration
- 2nn.Linear: y = xW^T + b
- 3Containers and Shape Helpers: Sequential, ModuleList, ModuleDict, Flatten
- 4train() vs eval(): Dropout, BatchNorm and Buffers
- 5Weight Initialization: Xavier and Kaiming
nn.Module: __init__, forward and Auto-Registration
In the autograd topic you tracked every leaf tensor yourself; a real network has dozens, and losing one means it never trains. An nn.Module does the bookkeeping. is the set of nn.Parameter tensors assigned directly in __init__, and are the submodules assigned the same way. nn.Module.__setattr__ intercepts self.fc1 = nn.Linear(...) and files it as a child, so parameters() walks the whole tree and named_parameters() yields dotted names like fc1.weight. Call model(x), not model.forward(x), so hooks run. Failure modes: skipping super().__init__() raises an AttributeError, and a plain tensor attribute is silently ignored.
A model is a tree: modules are nodes, parameters are leaves, and every dotted name such as encoder.block.0.weight is the path from the root to a leaf. parameters() is a depth-first traversal, so its cost grows with the number of modules, not with the number of weights. The parameter count is a sum of products of shapes, and float32 memory is 4 bytes per entry: 101,771 parameters occupy about 0.41 MB. Because names are paths, freezing a branch or loading part of a checkpoint is just a filter on prefixes.
An MLP assigns fc1 = nn.Linear(784, 128), fc2 = nn.Linear(128, 10), scale = nn.Parameter(torch.ones(1)) and plain = torch.ones(3). How many parameters does parameters() see, and in what order?
nn.Linear: y = xW^T + b
Most networks are mostly nn.Linear, so its shapes must be second nature. is the batch, one row per example. nn.Linear(in_features, out_features) stores weight as , out-features first, hence the transpose, and bias of length , broadcast across rows as in the tensor-operations topic. Each output entry is the dot product of one input row with one row of , the matrix-vector product from Linear Algebra. Any leading dimensions pass through. Failure mode: if the last input dimension is not , PyTorch raises mat1 and mat2 shapes cannot be multiplied.
Row of is a template, and output measures how strongly the input aligns with that template, plus an offset . Storing as keeps each template contiguous in memory. The layer costs floating-point operations and holds parameters, so widening both sides by 2 quadruples compute and weights. Geometrically it is an affine map: it rotates, stretches and projects the input space, then shifts it, but it can never bend a straight line.
For nn.Linear(4, 3) and an input of shape (2, 4), what are the weight, bias and output shapes, and how many parameters are there? What happens with a (5, 7, 4) input, or a (2, 5) one?
Containers and Shape Helpers: Sequential, ModuleList, ModuleDict, Flatten
Writing forward for a plain stack of layers is boilerplate. nn.Sequential implements the composition above: each is one layer, applied in order, and net[0] indexes them. Activations such as nn.ReLU hold no parameters; they are the nonlinear between linear ones. nn.Flatten() reshapes (B, 1, 28, 28) into (B, 784) because it starts at dimension 1 and keeps the batch, while torch.flatten starts at 0 and would merge examples. When forward needs loops or branches, store layers in nn.ModuleList, or nn.ModuleDict for named heads. Failure mode: a plain Python list of layers is invisible, so parameters() reports zero. Composition is the function chaining of Mathematical Foundations.
Without activations a stack collapses: , which is one affine map whose rank is at most the narrowest width. Depth buys nothing until a nonlinearity sits between layers. With ReLU the network becomes piecewise linear: each hidden unit folds input space along a hyperplane, and stacked folds carve it into a number of linear regions that can grow exponentially with depth.
Push a batch of shape (32, 1, 28, 28) through nn.Sequential(nn.Flatten(), nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 10)). Trace the shapes and count the parameters.
train() vs eval(): Dropout, BatchNorm and Buffers
Some layers must behave differently while learning and while predicting, and model.train() / model.eval() flip a training flag on every submodule at once. In dropout, is the drop probability, a random 0-or-1 mask and an input; survivors are scaled by so the expected output still equals . Batch normalization trains with the current batch's mean and variance while updating running_mean and running_var, which are buffers: saved state that is not learned, registered with register_buffer. In eval it uses those buffers. Forgetting eval() makes predictions random; eval() does not stop autograd, so pair it with torch.no_grad().
Scaling by makes dropout unbiased: , so the next layer sees the same average signal in both modes. Each mask picks one of thinned subnetworks, and eval mode approximates averaging them. The running statistics are an exponential moving average with momentum 0.1: the starting value keeps weight after batches, about 0.35 after 10 and 0.005 after 50, so eval statistics mostly reflect the last few dozen batches.
Apply nn.Dropout(0.5) to torch.ones(10) in training and in eval mode. Then run one training batch whose first feature has mean 5.4431 through a fresh nn.BatchNorm1d(3): what is running_mean[0]?
Weight Initialization: Xavier and Kaiming
The scale of the random starting weights decides whether a signal survives twenty layers or fades to zero before training begins. is one weight, is in_features (the weight's second dimension) and is out_features. A pre-activation sums products, so its variance is times the input's mean square, the variance-of-a-sum rule from Probability & Statistics. ReLU zeroes about half, hence Kaiming's factor 2; Xavier targets tanh-like activations. nn.init.kaiming_normal_ works in place. Failure modes: too small vanishes, too large explodes into NaN.
Each ReLU layer multiplies the signal's mean square by , so after layers it scales like . Kaiming sets exactly. PyTorch's default has variance , so and twenty layers would shrink it by without biases. Gradients flow backward through the transposed weights and pick up instead, which is why mode='fan_out' exists and why Xavier averages the two fans.
Stack 20 nn.Linear(512, 512) layers with ReLU and feed 1,000 inputs of standard deviation 1. Compare the pre-activation standard deviation at layers 1, 5 and 20 for the default init and for Kaiming normal with zero bias.
Code Examples
Inspect, save and reload a state_dict
import io
import torch
import torch.nn as nn
torch.manual_seed(0)
net = nn.Sequential(nn.Linear(4, 8), nn.BatchNorm1d(8), nn.ReLU(), nn.Linear(8, 2))
net.train()
net(torch.randn(16, 4) * 3 + 2) # one training forward updates BN buffers
sd = net.state_dict()
for k, v in sd.items():
print(k, tuple(v.shape))
buf = io.BytesIO() # in-memory stand-in for a .pt file
torch.save(sd, buf)
buf.seek(0)
fresh = nn.Sequential(nn.Linear(4, 8), nn.BatchNorm1d(8), nn.ReLU(), nn.Linear(8, 2))
print(fresh.load_state_dict(torch.load(buf)))
net.eval(); fresh.eval()
x = torch.randn(3, 4)
print("same predictions:", torch.equal(net(x), fresh(x)))One training forward pass updates the BatchNorm buffers, then the state_dict lists 9 entries: 0.weight (8, 4), 0.bias (8,), the BatchNorm weight, bias, running_mean and running_var, all (8,), 1.num_batches_tracked with shape (), and 3.weight (2, 8) and 3.bias (2,). The dict is saved to an in-memory buffer and loaded into a freshly built model, which prints All keys matched successfully. With both models in eval mode the predictions are identical, so the check prints True. The fresh model must have the same architecture: state_dict holds tensors, not code.
A Python list hides layers from the module
import torch
import torch.nn as nn
class Stack(nn.Module):
def __init__(self, registered):
super().__init__()
layers = [nn.Linear(8, 8) for _ in range(3)]
self.layers = nn.ModuleList(layers) if registered else layers
def forward(self, x):
for layer in self.layers:
x = torch.relu(layer(x))
return x
good, bad = Stack(registered=True), Stack(registered=False)
print("ModuleList params:", sum(p.numel() for p in good.parameters()))
print("Python list params:", sum(p.numel() for p in bad.parameters()))
print("names:", [n for n, _ in good.named_parameters()][:2])
print("both still run:", tuple(good(torch.randn(2, 8)).shape), tuple(bad(torch.randn(2, 8)).shape))Both versions hold three Linear(8, 8) layers and both run forward to an output of shape (2, 8), but only the ModuleList version registers them: it reports 216 parameters (3 times 72) with names such as layers.0.weight, while the plain Python list reports 0. An optimizer built from the second model's parameters would have nothing to train, with no error to warn you.
Theory Exercise
Problem:
For nn.Sequential(nn.Linear(10, 20), nn.BatchNorm1d(20), nn.ReLU(), nn.Dropout(0.3), nn.Linear(20, 5)), how many learnable parameters are there, and how many entries does state_dict() contain? Name the entries that are not parameters.
Hints:
- A Linear holds in*out + out parameters; BatchNorm1d(C) holds a weight and a bias of length C.
- ReLU and Dropout have no parameters and no buffers, but they still occupy an index in the Sequential.
- BatchNorm also registers buffers, and `state_dict()` saves parameters and persistent buffers.
Coding Exercise
Problem:
Build an nn.Sequential MNIST classifier: nn.Flatten, then Linear 784 to 256, ReLU, Linear 256 to 64, ReLU, Linear 64 to 10. Use model.apply to give every Linear Kaiming-normal weights (for ReLU) and zero biases, print each weight's shape and standard deviation next to the target sqrt(2 / fan_in), count all parameters and check the output shape for a batch of 8 images of shape (1, 28, 28).
Hints:
- `model.apply(fn)` calls `fn` on every submodule recursively; inside, test `isinstance(m, nn.Linear)`.
- A Linear weight has shape `(out_features, in_features)`, so fan_in is `weight.shape[1]`.
- Each Linear holds in*out + out parameters; sum `p.numel()` over `model.parameters()`.
You have 50,000 images on disk, each a different photo stored as height by width by colour bytes from 0 to 255, plus a label file in which one class outnumbers another nine to one. The model you built in Building Models with nn.Module wants something very different: a float tensor of shape with values near zero, delivered fresh every step, in a new random order each epoch, without loading all 50,000 images into memory and without the GPU sitting idle while Python decodes files.
PyTorch splits this job into three small, swappable pieces. A Dataset answers two questions, how many samples there are and what sample looks like. Transforms turn one raw sample into a model-ready tensor. A DataLoader decides the order, groups samples into batches, and can run the loading in parallel worker processes.
We build in that order: map-style datasets and train/validation splits, then the image transforms, then batching and epoch arithmetic, then custom collate_fn for variable-length sequences, and finally samplers that rebalance a skewed dataset. The training loop in the next topic consumes exactly what this pipeline produces.
Definition
A map-style Dataset is any object with __len__, returning the number of samples , and __getitem__(i), returning sample for . A DataLoader draws indices from a sampler, fetches those samples (optionally in worker processes), and merges each group of batch_size samples into one batch with a collate_fn that stacks them along a new leading dimension.
In this topic
- 1Map-Style Datasets, TensorDataset & Random Splits
- 2Transforms: ToTensor, Normalize & Compose
- 3DataLoader Batching, Shuffling & the Last Batch
- 4Collate Functions & Padding Variable-Length Sequences
- 5Samplers & WeightedRandomSampler for Class Imbalance
Map-Style Datasets, TensorDataset & Random Splits
A training loop should not care whether samples live in a tensor, a folder of JPEGs or a database. A map-style dataset is that contract: is the number of samples, is the input of sample and its label. TensorDataset(X, y) implements it for tensors that share a first dimension, returning (X[i], y[i]). A custom dataset subclasses Dataset and loads lazily inside __getitem__, so memory holds one sample, not the whole corpus. The failure mode is a mismatched first dimension or an off-by-one __len__, which raises IndexError mid-epoch. It is the NumPy row indexing from NumPy & Data Wrangling, behind an interface.
A Subset copies no data: it stores an index list and maps subset[j] to dataset[indices[j]], so a split costs integers rather than floats. Because random_split draws one permutation of and cuts it, train and validation are disjoint and together cover every index exactly once. That disjointness is the whole point: any overlap leaks training samples into the validation score and makes the estimate of generalisation optimistic. Fixing the generator seed makes the permutation, and therefore every reported validation number, reproducible.
X has shape (1000, 20) and y has shape (1000,). Wrap them in a TensorDataset and split 80/20 into train and validation sets. What do len(ds), ds[0] and the split sizes give?
Transforms: ToTensor, Normalize & Compose
Images arrive as uint8 arrays of shape with values 0 to 255, but convolution layers expect float tensors of shape centred near zero. Here is the raw byte at row , column , channel ; and are that channel's mean and standard deviation after scaling to ; is the result, channels first. ToTensor does the division by 255 and the HWC-to-CHW move, Normalize subtracts and divides by , and Compose chains them so __getitem__ calls one function. Failure mode: ToTensor only rescales uint8 or PIL input. This is per-channel standardisation from NumPy & Data Wrangling, applied per sample.
Normalisation is an affine map , so it changes nothing a linear layer could not learn; its value is optimisation. With inputs centred and of unit spread, the first layer's pre-activations start in the useful range of the activation function, and gradient magnitudes for different input channels are comparable, so one learning rate suits all weights. Unnormalised bytes in multiply first-layer gradients by roughly 255, which forces a much smaller learning rate and slows every other layer.
A uint8 pixel is (R, G, B) = (255, 128, 0). What does Compose([ToTensor(), Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])]) turn it into?
DataLoader Batching, Shuffling & the Last Batch
Gradient descent on one sample is noisy and on all samples is slow, so training uses mini-batches. is the dataset length and the batch_size; the loader yields batches, the last one holding the remainder if it is not zero. shuffle=True installs a RandomSampler that draws a fresh permutation every epoch, while the default SequentialSampler walks . drop_last=True discards the short final batch. Failure modes: an unshuffled, class-sorted dataset gives single-class batches, and a final batch of size 1 crashes BatchNorm in training mode. This is the mini-batch gradient from the calculus chapter, made concrete.
Loading and computing should overlap. If one batch takes seconds to assemble and seconds to train on, a single process costs per step; with num_workers=k worker processes preparing batches ahead, the cost approaches . In a probe where each item slept 10 ms, 64 items took about 0.65 s with 0 workers, 0.36 s with 2 and 0.21 s with 4. pin_memory=True puts batches in page-locked RAM so the copy to a GPU can run asynchronously.
A dataset of 1000 samples is loaded with batch_size=64. How many batches per epoch, how big is the last one, and what changes with drop_last=True?
Collate Functions & Padding Variable-Length Sequences
After the sampler picks indices, the loader must merge a list of samples into tensors. The default collate stacks each field along a new first dimension: samples , each of shape , become one tensor of shape ; tuples and dicts are collated field by field. Stacking needs equal shapes, so sentences of different lengths raise a RuntimeError from torch.stack. A custom collate_fn fixes this by padding every sequence to the longest in the batch with pad_sequence and returning the true lengths so the model can mask padding. It is the torch.stack from Tensor Operations, applied per batch.
Padding wastes compute in proportion to length variance within a batch. If a batch has lengths , the padded tensor costs slots while only carry data, so the waste fraction is . For attention layers the cost grows with , so one long outlier dominates the whole batch. That is why length-bucketing samplers group sequences of similar length together: they shrink toward without dropping any data.
A batch holds four token sequences of lengths 3, 5, 2 and 4. What does the default collate do, and what does a padding collate_fn return?
Samplers & WeightedRandomSampler for Class Imbalance
With 900 negatives and 100 positives, always predicting the majority scores 90 percent, and a shuffled batch of 100 holds about 10 positives. A sampler decides which indices the loader requests. WeightedRandomSampler draws index with probability , where is the class of sample , is how many samples share that class, and is the sample's weight. Inverse-frequency weights give each class the same total weight, so batches come out roughly balanced. It needs replacement=True to draw minority samples repeatedly, and it is mutually exclusive with shuffle=True. Repetition risks overfitting the minority. These are the categorical probabilities from Probability & Statistics.
Sampling with replacement from 1000 weighted draws means each minority sample is expected to appear times per epoch, while each majority sample appears times. The chance a given majority sample is never drawn is about , so an epoch covers only around 43 percent of the majority; the seeded example below covered 396 of 900. Oversampling therefore trades coverage of the common class for balance, which is why reweighting the loss is a common alternative.
Labels are 900 zeros and 100 ones. Compute the inverse-frequency weights and the expected share of class 1 per epoch of 1000 draws. How does it compare with shuffle=True?
Code Examples
Rebalancing a 9:1 dataset with WeightedRandomSampler
import torch
from torch.utils.data import TensorDataset, DataLoader, WeightedRandomSampler
y = torch.cat([torch.zeros(900, dtype=torch.long), torch.ones(100, dtype=torch.long)])
ds = TensorDataset(torch.randn(1000, 4), y)
counts = torch.bincount(y) # tensor([900, 100])
weights = 1.0 / counts[y].float() # one weight per sample
sampler = WeightedRandomSampler(weights, num_samples=len(ds), replacement=True,
generator=torch.Generator().manual_seed(0))
plain = DataLoader(ds, batch_size=100, shuffle=True,
generator=torch.Generator().manual_seed(0))
balanced = DataLoader(ds, batch_size=100, sampler=sampler)
print("class counts:", counts.tolist())
print("minority per batch, shuffle:", [int(yb.sum()) for _, yb in plain])
print("minority per batch, weighted:", [int(yb.sum()) for _, yb in balanced])
idx = torch.tensor(list(sampler))
print("distinct samples in one weighted epoch:", idx.unique().numel(), "of", len(ds))
The labels are 900 zeros and 100 ones, so bincount prints class counts [900, 100]. With plain shuffle=True, the ten batches of 100 hold 13, 7, 10, 12, 11, 11, 8, 10, 7 and 11 minority samples, about the true 10 percent. With inverse-frequency weights the same loader holds 47, 54, 50, 45, 54, 54, 48, 55, 49 and 44, close to half. The price shows in the last line: because the sampler draws with replacement, a weighted epoch touches only 496 distinct samples of 1000, repeating minority examples and skipping many majority ones.
Padding variable-length sequences with a custom collate_fn
import torch
from torch.utils.data import Dataset, DataLoader
from torch.nn.utils.rnn import pad_sequence
class Sentences(Dataset):
def __init__(self):
self.seqs = [torch.arange(1, n + 1) for n in (3, 5, 2, 4)] # token ids
def __len__(self):
return len(self.seqs)
def __getitem__(self, i):
return self.seqs[i], i % 2 # (tokens, label)
def pad_collate(batch):
seqs, labels = zip(*batch)
lengths = torch.tensor([len(s) for s in seqs])
return pad_sequence(seqs, batch_first=True, padding_value=0), lengths, torch.tensor(labels)
try:
next(iter(DataLoader(Sentences(), batch_size=4)))
except RuntimeError as e:
print("default collate:", e)
tokens, lengths, labels = next(iter(DataLoader(Sentences(), batch_size=4, collate_fn=pad_collate)))
print(tokens)
print("shape:", tuple(tokens.shape), "lengths:", lengths.tolist(), "labels:", labels.tolist())
print("padding fraction:", round(1 - lengths.sum().item() / tokens.numel(), 2))
Four token sequences of lengths 3, 5, 2 and 4 cannot be stacked, so the default collate raises: stack expects each tensor to be equal size, but got [3] at entry 0 and [5] at entry 1. The custom pad_collate pads every sequence with zeros to the longest length, printing a tensor of shape (4, 5), the true lengths [3, 5, 2, 4] and labels [0, 1, 0, 1]. Only 14 of the 20 slots hold real tokens, so the padding fraction prints 0.3.
Theory Exercise
Problem:
A dataset has 50,000 samples. You hold out 20 percent for validation with random_split, then train for 10 epochs with batch_size=128. How many optimizer steps happen per epoch and in total, with and without drop_last=True, and how many samples does drop_last skip each epoch?
Hints:
- First find the training-set size: 80 percent of 50,000.
- Batches per epoch is the ceiling of N/B without `drop_last` and the floor with it; one batch is one optimizer step.
- The skipped samples are the remainder N - B * floor(N/B).
Coding Exercise
Problem:
Write a custom Dataset that wraps 100 random uint8 images of shape (8, 8, 3) and integer labels, applying Compose([ToTensor(), Normalize([0.5]*3, [0.5]*3)]) inside __getitem__. Split it 80/20 with random_split, build train and validation DataLoaders with batch_size=32, and print the split sizes, the number of batches, one batch's shapes and dtypes, its value range, and the size of the last training batch.
Hints:
- `__len__` returns `len(self.images)`; `__getitem__` indexes one image, applies `self.transform` if it is set, and returns `(image, label)`.
- `random_split(ds, [0.8, 0.2], generator=torch.Generator().manual_seed(0))` accepts fractions and returns two `Subset` objects.
- `ToTensor` maps 0..255 to 0..1 and HWC to CHW; `Normalize` with mean 0.5 and std 0.5 then maps 0..1 to -1..1. Expect 80/32 to round up to 3 batches.
You have a model from Building Models with nn.Module and batches streaming out of a DataLoader from Datasets, DataLoaders & Transforms. Call the model on a batch and you get predictions, but its weights are still random and nothing improves. Something has to score those predictions, turn the score into gradients, and nudge every weight in a direction that lowers it, thousands of times, without diverging and without losing a day of work when the machine reboots. That something is the training loop. It ties the gradient descent from Mathematical Foundations to the .backward() call from Autograd & the Computational Graph.
We start with the canonical step (zero_grad, forward, loss, backward, step) and why dropping any line breaks training silently. Then we choose the loss: CrossEntropyLoss wants raw logits, not probabilities. Next come optimizers, from plain SGD through momentum to Adam and AdamW, including per-layer learning rates. Learning-rate schedules and warmup follow, because the right step size changes over a run. We finish with the safety equipment: gradient clipping, a validation pass under model.eval() and torch.no_grad(), and checkpoints that save the optimizer alongside the model.
Definition
A training loop repeatedly draws a mini-batch, clears stale gradients, runs the forward pass, reduces predictions and targets to a scalar loss , back-propagates , and lets an optimizer update the parameters as , where is the learning rate chosen by a scheduler and is the optimizer's update rule (the identity for plain SGD).
In this topic
- 1The Canonical Training Step
- 2Loss Functions: Logits In, Scalar Out
- 3Optimizers: SGD, Momentum, Adam and AdamW
- 4Learning-Rate Schedules and Warmup
- 5Clipping, Validation and Checkpoints
The Canonical Training Step
Autograd only computes gradients: nothing changes the weights until you ask, and PyTorch never resets gradients for you. In the formula are the parameters after updates, is the forward pass on mini-batch with targets , is the scalar loss, its gradient and the learning rate. In code that is five lines: optimizer.zero_grad() clears .grad, pred = model(x) runs forward, loss = loss_fn(pred, y) scores it, loss.backward() fills .grad, and optimizer.step() applies the update. Because backward() adds into .grad, the accumulation rule from Autograd & the Computational Graph, skipping zero_grad() sums every past gradient and the effective step keeps growing.
Each line maps onto one term of the update. The forward pass evaluates , backward evaluates by reverse-mode differentiation at roughly twice the cost of the forward pass, and step is a cheap element-wise update over every parameter. Because is a random mini-batch, is an unbiased but noisy estimate of the full-dataset gradient whose variance shrinks like in the batch size . Accumulating over backward calls on purpose and stepping once is therefore how practitioners emulate a times larger batch.
Fit on with a single weight , mean squared error and SGD at . What does the second step do if you forget optimizer.zero_grad()?
Loss Functions: Logits In, Scalar Out
A loss turns predictions into one number whose gradient points somewhere useful; the classic bug is feeding it the wrong kind of prediction. In the formula is the vector of raw scores (logits) that the last nn.Linear emits, is the integer index of the correct class and its score. nn.CrossEntropyLoss is exactly log_softmax plus negative log-likelihood, fused via the log-sum-exp trick so it never overflows. Pass raw logits and int64 class indices: float targets of shape (N,) raise expected scalar type Long but found Float. For binary or multi-label outputs use nn.BCEWithLogitsLoss; for regression, nn.MSELoss. This is the softmax and log-likelihood from Probability & Statistics.
The gradient with respect to the logits is strikingly simple: , the softmax probability minus the one-hot target. Every logit is pushed by exactly its prediction error, bounded in , so the loss never saturates while the model is wrong. Fusing the log into the loss is also numerically necessary: for a logit of on a positive label, BCEWithLogitsLoss returns the exact with gradient , while BCELoss on sigmoid(z) sees a probability of exactly 0, clamps the log to report , and returns a gradient of 0.
A 3-class model outputs logits and the true class is 0. Compute the cross-entropy, then see what happens if you apply softmax before CrossEntropyLoss.
Optimizers: SGD, Momentum, Adam and AdamW
Plain SGD uses one step size everywhere, crawling along shallow valleys and zig-zagging across steep ones. Momentum keeps a velocity so consistent gradients add up. Adam, shown above for parameters and learning rate , keeps , a bias-corrected mean of gradients, and , a mean of their squares; guards the division, so steps are about at any gradient scale. The term is AdamW's decoupled weight decay; Adam(weight_decay=...) instead adds it to the gradient, where the division distorts it. Param groups give layers their own , extending gradient descent from Mathematical Foundations.
Adam's step is dimensionless: multiply every gradient by 1000 and the update is unchanged. On the very first step , so a fresh parameter moves by almost exactly whether or ; at both move by about . That invariance is the feature and the danger: the learning rate becomes a distance in parameter space. Memory follows too: momentum stores one extra tensor per parameter and Adam stores two, exp_avg and exp_avg_sq.
A weight receives zero gradient from the data. With and , compare three steps of Adam(weight_decay=0.01) with three steps of AdamW(weight_decay=0.01).
Learning-Rate Schedules and Warmup
A constant learning rate large enough for early progress keeps the weights bouncing around the minimum late in training, so a scheduler changes over time. In cosine annealing is the rate at step , the starting rate, the floor (0 by default) and (T_max) the length of the decay. StepLR instead multiplies by gamma every step_size epochs. LinearLR warmup ramps up from a fraction of so Adam's noisy early cannot launch huge steps; SequentialLR chains warmup into cosine. Call scheduler.step() after optimizer.step(), once per unit that T_max counts; reversing the order skips the first value and PyTorch warns.
With , cosine annealing averages exactly over its run, because the cosine term integrates to zero over , so the shape is what matters. Near and the slope of the cosine is zero, so the rate changes slowly where training is fragile and quickly in the middle. Small late steps act like averaging: SGD noise around a minimum has variance roughly proportional to , so shrinking lets the weights settle into the basin instead of rattling around it, which is why validation loss often drops in the final epochs.
With and CosineAnnealingLR(T_max=10) stepped once per epoch, what learning rate does epoch 3 use? Compare it with StepLR(step_size=3, gamma=0.1).
Clipping, Validation and Checkpoints
One bad batch can produce a gradient thousands of times larger than usual, flinging the weights somewhere unrecoverable and the loss to nan. Gradient clipping caps the step: is every parameter gradient concatenated into one vector, its Euclidean length and the max_norm threshold. torch.nn.utils.clip_grad_norm_ rescales in place between backward() and step(), keeping its direction, and returns the pre-clip norm, worth logging. Two habits finish the loop. Validate under model.eval() (dropout off, batch norm on running statistics, as in Building Models with nn.Module) and torch.no_grad(). Checkpoint optimizer and scheduler state_dict with the model's: moment buffers are training state too.
Clipping by norm preserves the gradient's direction and only bounds its length, so each SGD update moves at most in parameter space: a trust region whose radius is set by two numbers. Clipping each entry to with clip_grad_value_ does not preserve direction, since large components are flattened while small ones survive. Checkpoint size follows from the optimizer: with Adam each parameter carries its value plus two moment buffers, so the saved optimizer state is about twice the size of the weights, and dropping it on resume restarts both moment estimates from zero.
Gradients for a two-parameter model are and max_norm=1.0. What does clip_grad_norm_ return, and what does it leave in .grad? What changes if ?
Code Examples
A complete loop: AdamW, cosine schedule, clipping and validation
import torch, torch.nn as nn
torch.manual_seed(0)
X = torch.randn(512, 2); y = (X[:, 0] * X[:, 1] > 0).long() # XOR-like labels
Xtr, ytr, Xva, yva = X[:400], y[:400], X[400:], y[400:]
model = nn.Sequential(nn.Linear(2, 32), nn.ReLU(), nn.Linear(32, 2))
loss_fn = nn.CrossEntropyLoss()
opt = torch.optim.AdamW(model.parameters(), lr=0.05, weight_decay=0.01)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=50)
for epoch in range(50):
model.train()
for i in range(0, 400, 50):
opt.zero_grad()
loss = loss_fn(model(Xtr[i:i+50]), ytr[i:i+50])
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
opt.step()
sched.step()
if epoch % 10 == 9:
model.eval()
with torch.no_grad():
acc = (model(Xva).argmax(1) == yva).float().mean().item()
print(f"epoch {epoch+1} train loss {loss.item():.3f} val acc {acc:.3f} lr {sched.get_last_lr()[0]:.4f}")A 2-to-32-to-2 MLP learns XOR-like labels (class 1 when both inputs share a sign) from 400 training points, with 112 held out. Each epoch runs 8 mini-batches of 50 through the five-line step plus norm clipping, then steps the cosine scheduler once. Every 10 epochs it validates in eval mode under no_grad. Validation accuracy is already 0.955 at epoch 10 and stays between 0.946 and 0.955, the last batch loss falls from 0.055 to 0.037, and the learning rate decays from 0.0452 at epoch 10 to 0.0000 at epoch 50.
Losses take logits: CrossEntropyLoss is log_softmax plus NLL
import torch, torch.nn as nn, torch.nn.functional as F
logits = torch.tensor([[2.0, 1.0, 0.1], [0.5, 2.5, 0.3]]) # raw scores, batch of 2
target = torch.tensor([0, 1]) # class indices (int64)
ce = nn.CrossEntropyLoss()(logits, target)
manual = F.nll_loss(F.log_softmax(logits, dim=1), target)
print("CrossEntropyLoss: ", round(ce.item(), 4))
print("log_softmax + NLL: ", round(manual.item(), 4))
print("softmax applied twice: ", round(nn.CrossEntropyLoss()(logits.softmax(1), target).item(), 4))
z, yb = torch.tensor([-120.0]), torch.tensor([1.0]) # very confident, very wrong
print("BCEWithLogitsLoss: ", nn.BCEWithLogitsLoss()(z, yb).item())
print("BCELoss(sigmoid(z)): ", nn.BCELoss()(torch.sigmoid(z), yb).item())
print("MSELoss: ", nn.MSELoss()(torch.tensor([2.5, 0.0]), torch.tensor([3.0, -1.0])).item())For a batch of two samples, CrossEntropyLoss and the explicit log_softmax plus NLL composition both print 0.3185, confirming they are one computation. Applying softmax before the loss silently inflates it to 0.745 with no error. For a logit of -120 on a positive label, BCEWithLogitsLoss returns the exact 120.0 while BCELoss on sigmoid outputs is clamped at 100.0. MSELoss on predictions 2.5 and 0.0 against targets 3.0 and -1.0 averages (0.25 + 1) / 2 = 0.625.
Theory Exercise
Problem:
PyTorch's SGD with momentum keeps a buffer and updates , then , with initialised to the first gradient. Starting from with , and gradients on three steps, compute after each step and compare with plain SGD.
Hints:
- On step 1 the buffer is just the gradient, so the first step equals plain SGD.
- On step 3 the gradient flips sign, but the buffer still carries 0.9 times the previous velocity.
Coding Exercise
Problem:
Train an nn.Linear(1, 1) on 100 noisy samples of (even rows train, odd rows validate) using SGD with momentum 0.9 and a StepLR schedule. Write train_epoch (the five-line step plus scheduler.step()) and validate (eval mode, no gradients). After 50 epochs, save the model, optimizer and scheduler state into an in-memory buffer, train 10 more epochs, then restore the checkpoint, retrain the same 10 epochs and confirm the final weight matches exactly.
Hints:
- Order inside `train_epoch`: `opt.zero_grad()`, forward, loss, `loss.backward()`, `opt.step()`, then `sched.step()`.
- `validate` should call `model.eval()` and wrap the forward pass in `torch.no_grad()` so no graph is recorded.
- Save a dict of `state_dict()` results with `torch.save` into an `io.BytesIO`; after `buf.seek(0)`, `torch.load` it and call `load_state_dict` on the model, the optimizer and the scheduler.
Suppose you train a network whose weights are rounded to integers in the forward pass. torch.round is a staircase with derivative zero almost everywhere, so after loss.backward() every weight receives a gradient of exactly 0 and the optimizer never moves. Or suppose your loss contains : at the exponential overflows to inf in float32 and the gradient comes back nan, silently poisoning every parameter on the next step. And when a deep model stops learning, you want to see what each layer outputs without rewriting its forward.
The previous topic, The Training Loop: Losses, Optimizers & Schedulers, treated backward() and step() as black boxes; here you open the box. You need the computational graph and vector-Jacobian products from Autograd & the Computational Graph and the module tree from Building Models with nn.Module.
We first write a custom torch.autograd.Function with its own backward, then use it for the two classic reasons: straight-through estimators and numerically stable gradients. Next, gradcheck proves a hand-written backward correct. Finally, hooks let you read and rewrite the activations and gradients of any model from the outside.
Definition
A custom autograd function is a subclass of torch.autograd.Function with two static methods: forward computes the outputs and stores what is needed on ctx, and backward maps the upstream gradient to one gradient per input. A hook is a callback registered on a module or tensor that runs during the forward or backward pass and is removed through its handle.
In this topic
- 1Anatomy of a torch.autograd.Function
- 2Custom Gradients: Straight-Through and Stable Backward
- 3Verifying Gradients with gradcheck
- 4Forward Hooks and Pre-Hooks on Modules
- 5Backward Hooks: Inspecting and Rewriting Gradients
Anatomy of a torch.autograd.Function
When you invent an operation, or want a different gradient than the math implies, autograd has nothing to record. A torch.autograd.Function fills that gap. Here is the input, the output, the scalar loss, the upstream gradient (shaped like ), the local derivative and the element-wise product. forward(ctx, x) computes and calls ctx.save_for_backward(x); backward(ctx, grad_out) returns one gradient per forward input, None for non-tensors. Returning too few raises a RuntimeError; editing a saved tensor in place trips a version-counter error. It is the vector-Jacobian product from Autograd & the Computational Graph, written by hand.
Autograd never materializes the Jacobian. For an element-wise on elements the Jacobian is an diagonal matrix, and the product collapses to : time and memory instead of . That is why backward receives grad_out rather than being asked for a derivative matrix. Whatever you put on ctx is the memory price, held until backward runs. For sigmoid it is cheaper to save the output than the input, since needs nothing else.
A custom MyReLU saves its input and returns grad_out * (x > 0) in backward. For x = [-2, -0.5, 0, 1.5, 3] and the loss with c = [1, 2, 3, 4, 5], what is x.grad?
Custom Gradients: Straight-Through and Stable Backward
Write a custom Function when the true derivative is useless or numerically broken. First case: is a staircase whose true gradient is 0, so nothing upstream learns. The straight-through estimator (STE) keeps rounding in forward but has backward return the upstream gradient unchanged; marks a chosen rule, not a chain-rule result from Mathematical Foundations. The second case is stability: autograd differentiates through exp, which overflows at and returns nan, while a hand-written backward returning stays finite. The STE is biased, so clipped variants zero it outside .
The STE is justified by writing rounding as identity plus bounded noise: with . Treating as a constant makes the derivative 1, so the STE follows the gradient of the smooth surrogate , evaluated at a point at most 0.5 away; its error grows with the curvature of . For softplus, the stable form never exponentiates a positive number, so the log argument stays in and overflow is impossible.
x = [0.3, 1.7, -0.6, 2.5] goes through rounding, then with c = [1, 2, 3, 4]. What is x.grad with the built-in torch.round, and with an STE RoundSTE?
Verifying Gradients with gradcheck
A wrong backward does not crash: training just runs on bad gradients and quietly underperforms. torch.autograd.gradcheck(fn, (x,)) catches this by comparing your analytic Jacobian with a numerical one. In the formula, is output element , input element , the unit vector nudging only , and a tiny step (default ). Each central difference fills one column of the numerical Jacobian; any entry differing beyond atol=1e-5 plus rtol=1e-3 raises GradcheckError. Inputs must be float64 with requires_grad=True: in float32, rounding error in the numerator swamps the difference and even a correct backward fails. This is the finite-difference derivative from Mathematical Foundations, repurposed as a test oracle.
Central differences have truncation error but rounding error of order , where is machine epsilon. In float64, and give a rounding error near , far below the tolerance. In float32, , so , an error budget as large as the derivative itself, which the 0.2384 versus 0.25 gap reflects. Cost matters too: two forward passes per input element, evaluations in all, so gradcheck is run on tiny inputs.
A buggy sigmoid Function returns g * y instead of g * y * (1 - y). Run gradcheck at x = [0, 2] in float64. What does it report, and what happens to the correct version in float32?
Forward Hooks and Pre-Hooks on Modules
To log what layer 12 outputs, you could edit its forward, but often you do not own that code. module.register_forward_hook(fn) attaches a callback instead. In the formula, is the module, its tuple of positional inputs and its output. A pre-hook (register_forward_pre_hook) runs before forward and may return replacement inputs; a forward hook runs after and may return a replacement output. Both fire on every call, so a module used twice fires twice. Store out.detach(): a graph-attached tensor keeps the whole forward graph alive. Each registration returns a RemovableHandle; it all relies on the submodule tree from Building Models with nn.Module.
A hook costs one Python call per module invocation plus whatever the callback does, so the asymptotic cost of the forward pass is unchanged. The hidden cost is memory: keeping activations of shape in float32 holds bytes, and without detach it also pins every intermediate needed for backward. Statistics expose signal propagation: through five tanh layers of width 256 with default initialization, the activation standard deviation shrinks from 0.461 to 0.065, a vanishing signal that careful initialization schemes aim to prevent.
Hook the ReLU in nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 3)) (seed 0) and feed a batch of 5 random inputs. What does the hook capture, and what fraction of the hidden units output exactly 0?
Backward Hooks: Inspecting and Rewriting Gradients
To see or change gradients, hook the backward pass. t.register_hook(h) calls with , the gradient of the loss with respect to tensor , the moment it is computed; if returns a tensor, it replaces for everything further back, which is how per-tensor gradient scaling works. It also exposes gradients of intermediate tensors, whose .grad stays None. On modules, register_full_backward_hook(fn) calls fn(module, grad_input, grad_output), where grad_input covers the module's inputs, not its weights; it raises a RuntimeError if the next layer edits the output in place, as nn.ReLU(inplace=True) does. Keep handles and call handle.remove(). This intercepts the backward() call from the previous topic.
A tensor hook is a function spliced into the chain rule. If is linear, like scaling by 0.5, it is equivalent to scaling the loss contribution that flows through , so every gradient upstream of scales by the same factor. A nonlinear hook such as clipping to unit norm need not be the gradient of any loss: the optimizer then follows a vector field that is not a true gradient, acceptable for stability but outside standard convergence guarantees. A module backward hook adds one autograd node per wrapped module, a constant overhead per layer.
w = [1, 2, 3] with requires_grad=True and loss . Register w.register_hook(lambda g: g * 0.5). What is w.grad, and what is it after handle.remove()?
Code Examples
A numerically stable softplus with its own backward
import torch
class StableSoftplus(torch.autograd.Function):
@staticmethod
def forward(ctx, x):
ctx.save_for_backward(x)
return x.clamp(min=0) + torch.log1p(torch.exp(-x.abs()))
@staticmethod
def backward(ctx, grad_out):
(x,) = ctx.saved_tensors
return grad_out * torch.sigmoid(x) # d/dx log(1 + e^x)
x = torch.tensor([-100.0, 0.0, 2.0, 100.0], requires_grad=True)
torch.log(1 + torch.exp(x)).sum().backward()
print("naive grad :", x.grad)
x.grad = None
StableSoftplus.apply(x).sum().backward()
print("stable grad:", x.grad)
xd = torch.randn(8, dtype=torch.float64, requires_grad=True)
print("gradcheck :", torch.autograd.gradcheck(StableSoftplus.apply, (xd,)))
Autograd differentiates the naive log(1 + exp(x)) through exp, which overflows at x = 100, so the naive gradient prints nan in the last slot (and 3.7835e-44, 0.5, 0.8808 for the others). The custom StableSoftplus never exponentiates a positive number and its backward returns sigmoid(x) directly, giving 0.0000, 0.5000, 0.8808, 1.0000 with no nan. gradcheck on 8 random float64 inputs prints True.
Per-layer activation statistics with forward hooks
import torch
import torch.nn as nn
torch.manual_seed(0)
layers = []
for _ in range(5):
layers += [nn.Linear(256, 256), nn.Tanh()]
net = nn.Sequential(*layers)
stats = {}
def make_hook(name):
def hook(module, inputs, output):
stats[name] = round(output.std().item(), 3)
return hook
tanhs = [m for m in net if isinstance(m, nn.Tanh)]
handles = [m.register_forward_hook(make_hook(f"tanh{i}"))
for i, m in enumerate(tanhs, start=1)]
net(torch.randn(64, 256))
print("activation std per layer:", stats)
for h in handles:
h.remove()
stats.clear()
net(torch.randn(64, 256))
print("after remove:", stats)
A forward hook on each of the five Tanh layers of a width-256 network records the standard deviation of its output for a batch of 64 random inputs. It prints 0.461, 0.253, 0.148, 0.093 and 0.065 for tanh1 through tanh5: with default initialization the signal shrinks by roughly a third to a half at every layer. After every handle is removed, a second forward pass leaves the dict empty, printed as after remove: {}.
Theory Exercise
Problem:
A 1-bit quantizer computes . Three backward rules are proposed: (a) the true derivative, (b) the plain straight-through estimator, (c) a clipped STE that passes the gradient only where . For x = [-1.5, -0.3, 0.4, 2.0] and an upstream gradient of [1, 1, 1, 1], what gradient does x receive under each rule, and why is (c) usually preferred?
Hints:
- sign is constant on each side of 0, so its derivative is 0 everywhere it exists.
- The plain STE copies the upstream gradient regardless of x.
- Ask what happens to a weight at 2.0 whose sign is already +1 if it keeps receiving gradient.
Coding Exercise
Problem:
Implement LeakyReLUFn, a torch.autograd.Function that returns x where x > 0 and slope * x elsewhere, saving the input with ctx.save_for_backward and returning a gradient for both forward arguments (None for the float slope). Check that its gradients match F.leaky_relu, verify it with gradcheck in float64, then use it inside a small nn.Module and capture the output of the first Linear layer with a forward hook before removing the hook.
Hints:
- `backward` must return as many values as `forward` took after `ctx`: here a tensor gradient for x and `None` for slope.
- The local derivative is 1 where x > 0 and slope elsewhere; `torch.where` builds it in one line.
- `register_forward_hook` returns a handle; store `o.detach()` in a dict inside the hook and call `handle.remove()` when done.
Your model trains happily on a laptop. Then you scale it to 7 billion parameters and the arithmetic turns against you: in float32 with Adam, the weights, gradients and optimizer state alone need 112 GB before a single activation is stored, while a large single GPU holds 80. You try to find the slow part with a stopwatch, and a 4096 by 4096 matrix multiply that really takes about 27 ms reports well under one. Both the memory bill and the clock are lying to you until you understand what the hardware is doing.
The previous topic, Extending PyTorch: Custom Autograd Functions & Hooks, showed how autograd saves tensors for backward and how hooks let you look inside it. That machinery is exactly what costs memory here. We start by accounting for every byte of a training step, then learn to time a GPU correctly despite asynchrony and transfer costs. With honest measurements in hand we shrink the bill: mixed precision halves bytes and speeds up math, gradient accumulation and activation checkpointing trade time for memory, and torch.compile fuses kernels. A closing pointer shows how data parallelism spreads the same step over many GPUs.
Definition
Performance engineering in PyTorch is budgeting a training step's memory (weights, gradients, optimizer state, saved activations) and time (kernel compute, memory traffic, host-device copies), measuring both correctly under asynchronous GPU execution, and reducing them with lower-precision arithmetic, gradient accumulation, activation checkpointing, compiler kernel fusion and data-parallel replication, while preserving the mathematical result up to floating-point tolerance.
In this topic
- 1Where the Memory Goes: Bytes per Parameter
- 2GPU Asynchrony and Correct Timing
- 3Mixed Precision: autocast and GradScaler
- 4Trading Compute for Memory: Gradient Accumulation and Checkpointing
- 5torch.compile: Graph Capture and Kernel Fusion
Where the Memory Goes: Bytes per Parameter
Out-of-memory errors rarely come from the weights alone. Here is the parameter count and each float32 value costs 4 bytes: one copy for the weights, one for .grad, and two for Adam's running averages and from the training loop. is activation memory: the tensors autograd saves for backward, as with ctx.save_for_backward. grows with batch size and depth, not with . Failure modes: a model that loads fine crashes at the first optimizer.step(), because Adam allocates and lazily. Freezing a backbone (requires_grad False) leaves its .grad as None and gives it no Adam state, saving 12 of the 16 bytes.
The two terms scale along different axes. The term is fixed by architecture and optimizer: batch size never touches it. Activations scale like per layer (batch, sequence length, width), and attention scores add a term, so long sequences blow up even when is modest. That split tells you which tool to reach for: optimizer-state tricks and freezing attack , while smaller micro-batches, lower precision and checkpointing attack .
An MLP Linear(1024, 4096) -> ReLU -> Linear(4096, 1024) is trained with Adam in float32. How many bytes per parameter does it need before activations, and what would a 7-billion-parameter model need?
GPU Asynchrony and Correct Timing
When you call a @ b on a CUDA tensor, PyTorch only enqueues a kernel on a stream and returns, so Python races ahead of the GPU. Here is the CPU time to enqueue the work and is the time the GPU spends running it. A stopwatch without torch.cuda.synchronize() captures only . The flip side is hidden stalls: .item(), .cpu(), printing a CUDA tensor or if loss > 0 silently synchronize and serialize your loop. Host-to-device copies cross PCIe, far slower than GPU memory; pinned memory with non_blocking=True, which pin_memory=True in the DataLoader topic prepares, lets the copy overlap compute.
Each kernel is limited by either arithmetic or memory: . The matmul does about 683 flops per byte of its three 64 MiB matrices, so it is compute-bound. An element-wise kernel does about one flop per 8 bytes, so it is bandwidth-bound: on this card a chain of them streamed roughly 297 GB/s. PCIe delivered 13 GB/s, about 23 times slower, so a copy is cheap only when it overlaps work the GPU is already doing.
Time a 4096 by 4096 float32 matmul on a GPU (an RTX 2060 here) with and without synchronization. How far off is the naive timer, and what does a 64-image batch of 224 by 224 RGB cost to copy?
Mixed Precision: autocast and GradScaler
On an RTX 2060 a 4096 matmul runs about 4 times faster in float16 on tensor cores (7.0 versus 27.2 ms), and activations halve. torch.autocast runs matmuls and convolutions in the low dtype; weights, losses and reductions stay float32. The catch is range: float16 tops out at 65504 and rounds values below about to zero. Loss scaling fixes this. is the loss, the parameters and the scale; by the chain rule every gradient is multiplied by , and divides it out in float32. GradScaler skips steps with inf or NaN gradients and halves . bfloat16 keeps float32's range, so it needs no scaler.
A float is . float16 spends 5 bits on the exponent and 10 on the fraction; bfloat16 spends 8 and 7, matching float32's exponent. Multiplying by a power of two only adds to , so scaling and unscaling are exact: no rounding is introduced, the gradient distribution just slides up into float16's window. That is why GradScaler grows by 2 after 2000 clean steps and halves it on overflow: it hunts for the largest shift that still fits under 65504.
A gradient entry equals . What does it become in float16, in float16 after scaling by , and in bfloat16?
Trading Compute for Memory: Gradient Accumulation and Checkpointing
You want batch 64 but only 16 fits. Split it into micro-batches; is the mean loss of micro-batch and the mean over all 64. Call backward() on each : .grad accumulates across calls, the behaviour the Autograd topic warned about, now used deliberately. Then step() and zero_grad() once. Forget the and the gradient is times too large. BatchNorm still sees only micro-batch statistics. When even one micro-batch overflows, activation checkpointing helps: torch.utils.checkpoint.checkpoint(block, h, use_reentrant=False) stores only the block's input and recomputes its forward during backward, trading time for activation memory.
Accumulation is exact because differentiation is linear: the gradient of a sum is the sum of gradients. DistributedDataParallel uses the same identity across devices: each of GPUs computes gradients on its shard, an all-reduce averages them, and every replica takes an identical step. Checkpointing has its own arithmetic: backward costs about twice a forward, so one extra forward adds roughly a third to the step, close to the 32 to 35 percent measured across runs. Checkpointing every layers of an -layer net cuts activation memory to .
A Linear(8, 1) model sees 32 examples. Compare the full-batch gradient with 4 accumulated micro-batches of 8, with and without dividing by . Then: what does checkpointing 16 blocks of Linear(1024, 1024) + GELU cost on a GPU at batch 4096?
torch.compile: Graph Capture and Kernel Fusion
Eager PyTorch runs one kernel per operation, each reading inputs from GPU memory and writing its output back. For on float32 elements (4 bytes each), five kernels move values, while one fused kernel reads once and writes once: . torch.compile does this in two stages: TorchDynamo traces Python into an FX graph, guarded on shapes and dtypes, then TorchInductor emits fused Triton (GPU) or C++ (CPU) kernels. Costs: the first call compiles for seconds, a new shape recompiles, and Python branching on tensor values causes a graph break. It is Autograd's dynamic graph, captured ahead of time.
Element-wise chains are bandwidth-bound, so their cost is bytes moved, not flops; fusion raises arithmetic intensity by keeping intermediates in registers, never in DRAM. That is why the measured speedup tracks almost exactly. Matmuls, by contrast, already run near peak in cuBLAS, so compile mostly helps the glue between them. Dynamo's guards make reuse cheap: a call whose shapes and dtypes satisfy a cached graph's guards skips tracing, and after 8 recompiles of one function it falls back to eager.
Run on floats on an RTX 2060, eager versus compiled. Then call a compiled function with input sizes 8, 8, 16, 32, 64: how many graphs get captured?
Code Examples
Measure what activation checkpointing saves
import torch, torch.nn as nn
from torch.utils.checkpoint import checkpoint
torch.manual_seed(0)
blocks = nn.ModuleList(nn.Sequential(nn.Linear(256, 256), nn.GELU()) for _ in range(8))
x = torch.randn(512, 256, requires_grad=True)
weights = {p.data_ptr() for p in blocks.parameters()}
def forward(use_ckpt):
saved = [0]
def pack(t): # called for every tensor autograd keeps for backward
if t.data_ptr() not in weights: # count activations only
saved[0] += t.numel() * t.element_size()
return t
with torch.autograd.graph.saved_tensors_hooks(pack, lambda t: t):
h = x
for blk in blocks:
h = checkpoint(blk, h, use_reentrant=False) if use_ckpt else blk(h)
h.sum().backward() # checkpointed blocks re-run their forward here
return saved[0] / 2**20, x.grad.norm().item()
print('plain: %.1f MiB saved, grad norm %.6f' % forward(False))
x.grad = None
print('checkpointed: %.1f MiB saved, grad norm %.6f' % forward(True))A saved-tensors hook counts every activation autograd stores for backward across 8 blocks of Linear(256, 256) plus GELU at batch 512, ignoring the weights. The plain forward saves 8.0 MiB: each block keeps its 0.5 MiB input for the Linear and another 0.5 MiB for the GELU. Wrapping each block in checkpoint keeps only the block input, 4.0 MiB, and recomputes the rest during backward. The input gradient norm is 0.023918 both ways, so the answer is unchanged; only memory and time move.
Watch torch.compile capture graphs and recompile
import torch
graphs = []
def counting_backend(gm, example_inputs):
graphs.append(gm) # one entry per captured FX graph
return gm.forward # run it eagerly; no code generation
def f(x):
return torch.nn.functional.gelu(x * 2 + 1).sum()
cf = torch.compile(f, backend=counting_backend)
for n in [8, 8, 16, 32, 64]:
cf(torch.randn(n))
print('input size', n, '-> graphs captured:', len(graphs))
def g(x):
y = x.sin()
if y.sum() > 0: # Python branch on a tensor VALUE
return y.cos()
return y.tan()
graphs.clear()
torch.compile(g, backend=counting_backend)(torch.randn(4))
print('data-dependent if -> graphs:', len(graphs))A counting backend records each FX graph that TorchDynamo captures without generating any kernels, so it runs in a moment on a CPU. Calling the compiled function with sizes 8, 8, 16, 32 and 64 prints graph counts 1, 1, 2, 2, 2: the repeated size reuses the cached graph, the first new size forces one recompile with a symbolic length, and that dynamic graph serves every later size. A function that branches in Python on the value of y.sum() is split by a graph break into 2 graphs.
Theory Exercise
Problem:
You fine-tune a 1.3-billion-parameter model with AdamW in float32 on a 24 GB GPU (use 1 GB = 10^9 bytes). Activations cost 0.75 GB per example and scale linearly with micro-batch size. (a) How much memory do weights, gradients and optimizer state take, and what is the largest micro-batch that fits? (b) How many accumulation steps reach an effective batch of 64? (c) If you freeze a 1.0B-parameter backbone and train only the 0.3B head (assume activation memory is unchanged), what micro-batch and accumulation count would you choose?
Hints:
- Trainable parameters cost 16 bytes each (weights, grads, two Adam moments); frozen ones keep only their 4-byte weights.
- Divide the memory left over by 0.75 GB and round down; for accumulation, choose a micro-batch that divides 64.
Coding Exercise
Problem:
Emulate a batch of 64 with 4 accumulated micro-batches of 16. Train an nn.Sequential(Linear(10, 32), ReLU, Linear(32, 1)) with SGD (lr 0.1) for 5 steps three ways from the same initial weights: full batch, accumulation with the loss divided by K, and accumulation where you forget the division. Print each final full-batch loss and the largest weight difference between the first two models.
Hints:
- Call `optimizer.zero_grad()` once per optimizer step, not once per micro-batch; `.grad` must accumulate over the K backward calls.
- `X.chunk(K)` splits the batch along dim 0 into K equal micro-batches; zip it with `y.chunk(K)`.
- Re-seed before building each model so all three start from identical weights, and compare parameters with `(a - b).abs().max()`.