Ch. 10
PyTorch Foundations
Tensors, autograd, nn.Module composition, the 4-line training loop, mixed precision, torch.compile.
PyTorch is what micrograd would be if it ran on GPUs, supported tensors of arbitrary rank, knew how to differentiate a hundred operations instead of four, and had a community of ten thousand contributors keeping it fast. The mental model carries over completely: a A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary → is a Value that holds an n-dimensional array. An operation on a tensor records itself in a graph. .backward walks the graph and fills in .grad. The rest of the API is conveniences on top of that core idea: nn.Module to organize parameters, DataLoader to feed batches, optim.AdamW to update weights. By the end of this chapter you will have written a training loop that you can paste into any project and ship.
FIG 10.1 · Learning outcomes
By the end of this chapter you will be able to:
- Create tensors on CPU and GPU, manipulate them with broadcasting and
einsum, and explain the memory layout ofviewversusreshapeversuscontiguous. - Compute gradients with
torch.autograd, including the cases where you needtorch.no_grad,detach, andretain_graph=True. - Build a custom
nn.Modulethat holds parameters, registers buffers, and composes other modules cleanly. - Write a
DatasetandDataLoaderfor tabular and image data, withnum_workers,pin_memory, andshuffleset correctly. - Train a model with the standard four-line loop (
zero_grad,forward,loss.backward,optimizer.step) and articulate what each line does. - Save and load model checkpoints correctly, including the optimizer state and the random number generator state.
- Use
torch.compile,autocast, andGradScalerto make training 2-4× faster, and know when each is worth the complexity. - Read CS336 lecture 2's resource accounting and predict how much VRAM a forward pass on a 100M-parameter model will use.
FIG 10.2 · What you need first
- Ch 9 — Introduction to Neural Networks — you should already know what an MLP is, what backprop does, and have written micrograd. Without it, PyTorch's
.backwardis magic; with it, you trust it. - Ch 0 — Math & Python prereqs — broadcasting and basic linear algebra. You will also see decorators here (
@torch.no_gradeverywhere); a decorator is just syntactic sugar where@fabove a functiongmeansg = f(g), so the function runs wrapped inf's behavior. - A free Colab account or a local Python 3.10+ with a CUDA-capable GPU. Most code here runs on CPU; a few sections need a GPU to be meaningful.
If you are coming from numpy and have never used PyTorch: this is the right chapter. If you are coming from TensorFlow / JAX, the section on autograd will feel familiar; the section on nn.Module is more PyTorch-specific.
FIG 10.3.1
Tensors as the API surface
A PyTorch A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary → is a multi-dimensional array with a few extra fields: dtype (data type), device (CPU or which GPU), requires_grad (whether autograd tracks it), and grad (the accumulated A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary →, populated by .backward). Everything else in the framework — modules, optimizers, distributed training, torch.compile — is bookkeeping around tensor operations.
The four ways to make a tensor:
import torch
# 1. From data
a = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
# 2. Zeros / ones / random / arange / linspace
b = torch.zeros(3, 4)
c = torch.randn(2, 3, 4) # standard normal
d = torch.arange(0, 10, step=2) # like np.arange
# 3. Like an existing tensor (matched shape and dtype)
e = torch.zeros_like(a)
# 4. From numpy (shares memory!)
import numpy as np
f = torch.from_numpy(np.array([1.0, 2.0, 3.0]))The dtype defaults to float32 for floating-point tensors and int64 for integers. The device defaults to CPU. Move a tensor across devices with .to(device):
device = "cuda" if torch.cuda.is_available() else "cpu"
x = torch.randn(1000, 1000).to(device)A tensor's GPU memory (The graphics chip's working memory, how much data it can hold at once while training.Full glossary →) is just its element count times its byte size: x.numel * x.element_size, which for float32 (4 bytes) means a 100M-One of the model's internal numbers that gets adjusted as it learns.Full glossary → model needs ~0.4 GB just for the weights. CS336 lecture 2's resource accounting (referenced under "What you'll be able to do") extends this to a full training step: counting parameters, gradients, and optimizer state, AdamW in float32 costs roughly 4+4+(4+4) = 16 bytes per parameter, before activations.
requires_grad=True is the flag that turns a tensor into an autograd leaf. Parameters (the things an optimizer updates) have requires_grad=True. Activations and intermediate values do not need it; PyTorch infers requires_grad=True for any output of an operation where at least one input has requires_grad=True.
x = torch.randn(5, requires_grad=True) # leaf, gradient will be populated
y = x ** 2 # not a leaf, but requires_grad inferred
y.sum().backward()
print(x.grad) # 2*xFIG 10.3.2
Operations and broadcasting
A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary → operations follow the same broadcasting rules as numpy. Two tensors are broadcastable if, when you align their shapes right-justified, every dimension is either equal or one of them is 1.
a = torch.randn(3, 1, 5)
b = torch.randn( 4, 5)
(a + b).shape # torch.Size([3, 4, 5])The most useful operations to internalize early:
| Operation | What it does |
|---|---|
x.shape, x.size | Dimensions of the tensor |
x.view(...), x.reshape(...) | Re-layout without copy (view) or with copy if needed (reshape) |
x.permute(d0, d1,...) | Reorder axes |
x.transpose(d0, d1) | Swap two axes |
x.unsqueeze(d) / x.squeeze(d) | Add / remove a singleton axis |
x.gather(dim, idx) | Pick entries at idx along dim (e.g. the The raw score the model outputs before it's converted into a clean percentage with sigmoid or softmax.Full glossary → at each row's true class, with idx shaped (batch, 1)) |
x @ y | Matrix multiply (handles batched matmul) |
torch.einsum("ij,jk->ik", a, b) | Generalized contraction |
x.sum(dim=...), x.mean(dim=...) | Reductions along an axis |
view vs reshape is the subtle one. view requires the tensor to be When a block of data is laid out in memory in tidy, expected order with no gaps.Full glossary → in memory; it's a zero-copy reinterpretation. reshape will copy if it has to. After transpose or permute, the tensor is usually non-contiguous; calling .view on it raises RuntimeError. The fix is either .reshape (which handles it transparently) or .contiguous.view (which makes an explicit copy first).
x = torch.randn(2, 3, 4)
y = x.permute(0, 2, 1) # (2, 4, 3) but non-contiguous
# y.view(2, 12) # RuntimeError
y.contiguous().view(2, 12) # ok
y.reshape(2, 12) # also okeinsum is the most expressive operation in PyTorch — it is generalized matrix multiplication with explicit index bookkeeping. Read the subscript string as named axes: a letter is summed over (contracted) iff it does not appear to the right of ->, while every letter listed on the right is kept in the output — so a letter shared across inputs but still present on the right (like the A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → b) is a batched/parallel axis, run in parallel rather than contracted. So "bij,bjk->bik" is batched matmul (sum over j, keep b,i,k), and "bhid,bhjd->bhij" is the A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →-score computation from a transformer. Learn einsum once and you write tensor code that reads like math.
FIG 10.3.3
Autograd: computation graph +.backward
When you operate on tensors with requires_grad=True, PyTorch builds a directed acyclic graph behind the scenes. Each operation records a grad_fn (the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → function for its operation) and the input tensors it depended on. Calling .backward on a scalar output walks this graph in reverse, calling each grad_fn to accumulate gradients into the .grad field of every leaf A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary →.
The graph is dynamic. It is rebuilt every Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary →. This is unlike TensorFlow 1.x, which had a static graph. The dynamism is what lets you use Python control flow (if, for, while) inside a model and have it differentiate correctly.
x = torch.tensor([2.0], requires_grad=True)
y = x ** 3 + 4 * x
print(y.grad_fn) # <AddBackward0 object> — the most recent op
y.backward()
print(x.grad) # tensor([16.]) — analytically 3x^2 + 4 at x=2Three things to know:
Gradients accumulate. If you call .backward twice on the same graph (without retain_graph=True), you get an error. If you call .backward on two different outputs that share leaves, gradients accumulate (sum). This is why every training loop calls optimizer.zero_grad before each loss.backward — to clear the previous step's gradients.
torch.no_grad for Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →. Inside with torch.no_grad:, PyTorch skips graph construction. This saves memory and time. Always wrap evaluation loops in it.
.detach cuts the graph. A detached tensor shares data with the original but is treated as a leaf with no grad_fn. Useful when you want to use a value as a constant without it contributing to gradients (e.g., for a target in a teacher-student loss).
import torch
import torch.nn as nn
model = nn.Linear(10, 1)
x = torch.randn(32, 10)
y_target = torch.randn(32, 1)
with torch.no_grad():
y_pred_frozen = model(x) # no graph built; no gradients
y_pred = model(x)
loss = ((y_pred - y_target) ** 2).mean()
loss.backward()
# model.weight.grad and model.bias.grad now populatedFIG 10.3.4
nn.Module composition
nn.Module is the way you organize parameters in PyTorch. It is a Python class with two responsibilities: store learnable parameters, and define a forward method that operates on them.
The minimal pattern:
import torch.nn as nn
class MyLayer(nn.Module):
def __init__(self, in_features: int, out_features: int):
super().__init__()
self.weight = nn.Parameter(torch.randn(out_features, in_features) * 0.01)
self.bias = nn.Parameter(torch.zeros(out_features))
def forward(self, x: torch.Tensor) -> torch.Tensor:
return x @ self.weight.T + self.biasThree rules. First, always call super.__init__ in your __init__, before assigning anything else. PyTorch's machinery depends on it. Second, assign learnable tensors as nn.Parameter(...) — this is what gets them registered for optimizer.parameters calls. A plain torch.Tensor attribute is not a One of the model's internal numbers that gets adjusted as it learns.Full glossary → and will be silently ignored. Third, define forward, not __call__. nn.Module.__call__ wraps forward with hooks and A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → tracking; if you override __call__ you bypass all of it.
nn.Module composes. A module that contains submodules registers their parameters automatically through Python attribute discovery:
class MLP(nn.Module):
def __init__(self, d_in: int, d_hidden: int, d_out: int):
super().__init__()
self.fc1 = nn.Linear(d_in, d_hidden)
self.fc2 = nn.Linear(d_hidden, d_out)
self.activation = nn.ReLU()
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.fc2(self.activation(self.fc1(x)))
m = MLP(784, 128, 10)
print(sum(p.numel() for p in m.parameters())) # 101770nn.Sequential is a convenience for the common "stack modules" pattern. Use it when there is no branching:
m = nn.Sequential(
nn.Linear(784, 128),
nn.ReLU(),
nn.Linear(128, 10),
)For anything with skip connections, conditional branches, or multiple outputs, write forward by hand.
Buffers. Sometimes you need to store a A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary → in a module that is not a learnable parameter but should still move with .to(device) and serialize with the model. The classic example is the running mean and variance in BatchNorm, or a A block that stops a model from peeking at later positions, so it only sees what came before.Full glossary → in A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →. Use self.register_buffer("name", tensor).
class CausalAttention(nn.Module):
def __init__(self, max_seq_len: int):
super().__init__()
# Upper-triangular mask; not learned, but should move with the module
mask = torch.triu(torch.ones(max_seq_len, max_seq_len), diagonal=1).bool()
self.register_buffer("mask", mask)FIG 10.3.5
Custom modules from scratch
To make nn.Module concrete, here is what nn.Linear actually does, written by hand. Read it next to the PyTorch source (it is one of the cleaner ones).
import math
class Linear(nn.Module):
"""nn.Linear, re-implemented."""
def __init__(self, in_features: int, out_features: int, bias: bool = True):
super().__init__()
self.in_features = in_features
self.out_features = out_features
self.weight = nn.Parameter(torch.empty(out_features, in_features))
if bias:
self.bias = nn.Parameter(torch.empty(out_features))
else:
self.register_parameter("bias", None)
self.reset_parameters()
def reset_parameters(self) -> None:
# Kaiming uniform init: same as nn.Linear's default.
nn.init.kaiming_uniform_(self.weight, a=math.sqrt(5))
if self.bias is not None:
fan_in = self.in_features
bound = 1 / math.sqrt(fan_in)
nn.init.uniform_(self.bias, -bound, bound)
def forward(self, x: torch.Tensor) -> torch.Tensor:
out = x @ self.weight.T
if self.bias is not None:
out = out + self.bias
return outThree things to notice. First, weight is shape (out_features, in_features), not (in_features, out_features). PyTorch convention. Second, reset_parameters is a separate method that the default __init__ calls; this lets users re-initialize without recreating the module. Third, when bias=False, the convention is register_parameter("bias", None) rather than self.bias = None. This puts a recognized None-valued slot in the module's internal _parameters dict, reserving the name so a real nn.Parameter can later be assigned to it cleanly. A None slot is filtered out of named_parameters, parameters, and the state_dict alike, while self.bias still resolves to None as an attribute — exactly what the if self.bias is not None checks above rely on.
You will write custom nn.Module classes for anything you can't express with nn.Sequential. Transformer blocks, ResNet blocks, A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → layers, mixture-of-experts routing — all of them are custom modules. The pattern in this section is what every one of them looks like.
FIG 10.3.6
DataLoader and Dataset
A Dataset is anything with __len__ and __getitem__. A DataLoader wraps a Dataset to produce batches, shuffle, and parallelize via worker processes.
The two Dataset patterns:
from torch.utils.data import Dataset, DataLoader
class TabularDataset(Dataset):
def __init__(self, X: np.ndarray, y: np.ndarray):
self.X = torch.from_numpy(X).float()
self.y = torch.from_numpy(y).long()
def __len__(self) -> int:
return len(self.X)
def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]:
return self.X[idx], self.y[idx]That's a "map-style" dataset. For streamed or infinite data, subclass IterableDataset and override __iter__.
For the DataLoader, the defaults are usually wrong for production. Set:
loader = DataLoader(
dataset,
batch_size=128,
shuffle=True, # crucial during training; defaults to False
num_workers=4, # parallel data loading; 0 means main process only
pin_memory=True, # speeds up CPU→GPU transfers
drop_last=True, # drop the last partial batch during training
persistent_workers=True, # avoid worker startup cost each epoch
)num_workers > 0 is what makes large data pipelines fast. Each worker is a separate process that prefetches batches in parallel with the main process running the model. Set it to the number of CPU cores you have, then tune down if you see CPU When part of a model stops reacting to input because its output is already pushed to a hard limit.Full glossary →.
pin_memory=True allocates the A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → in page-locked memory on the host, which lets The system that lets code run on a graphics chip instead of the main processor.Full glossary →'s async transfer engine move it to the GPU without an extra copy. Always set it for GPU training.
FIG 10.3.7
Optimizers: SGD, Adam, AdamW
An optimizer is a small object that knows how to update parameters given their gradients. PyTorch's optimizers all follow the same interface:
import torch.optim as optim
opt = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
# In the training loop:
opt.zero_grad() # clear last step's gradients
loss.backward() # accumulate this step's gradients
opt.step() # update parameters in-placeThe four optimizers you will actually use:
SGD with Letting past adjustments build up speed so training keeps rolling in a steady direction instead of zig-zagging.Full glossary →. The classical optimizer. optim.SGD(params, lr=0.01, momentum=0.9, weight_decay=1e-4). Best for convnets when tuned well; the LR has a narrow sweet spot.
Adam (Kingma & Ba, 2014). Adaptive per-One of the model's internal numbers that gets adjusted as it learns.Full glossary → learning rates, with exponential moving averages of both the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → (momentum) and the squared gradient (variance). The update is where is the A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary →-corrected first moment and is the bias-corrected second moment. optim.Adam(params, lr=3e-4, betas=(0.9, 0.999)). Forgiving with respect to LR. Karpathy's "Adam at 3e-4 is safe" defaults are real.
AdamW (Loshchilov & Hutter, 2019). Adam with decoupled Gently nudging a model's weights toward smaller values to keep the model simpler and less likely to overfit.Full glossary →. The original Adam combines L2 A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary → with the adaptive rescaling, which has the side effect of making weight decay weaker on parameters with large second moments. AdamW separates the two. For LLM training, AdamW is the default. optim.AdamW(params, lr=3e-4, weight_decay=0.1).
LBFGS for tiny problems where you can fit the whole dataset in one A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary →. Almost never used for deep networks.
For LLM-scale training, the modern frontier is Lion and Muon, but you should not reach for them until you have AdamW working. They are covered briefly in another chapter.
Parameter groups let you give different hyperparameters to different parts of the model:
opt = optim.AdamW([
{"params": model.embed.parameters(), "lr": 1e-5},
{"params": model.head.parameters(), "lr": 1e-3},
], weight_decay=0.1)Standard pattern for Taking a model that already learned a lot of general skills and training it a bit more on your own specific data.Full glossary →: small LR for the pretrained backbone, large LR for the newly-initialized head.
FIG 10.3.8
Learning rate schedulers
A scheduler adjusts the How big an adjustment the model makes to its numbers each time, after the gradient tells it which way to go.Full glossary → during training. PyTorch wraps these in torch.optim.lr_scheduler. The three patterns you will see most often:
StepLR / MultiStepLR: decay LR by a factor every N epochs. Classic for ImageNet-style training.
sched = optim.lr_scheduler.StepLR(opt, step_size=30, gamma=0.1)
for epoch in range(100):
train_one_epoch(...)
sched.step() # call once per epoch, after the optimizer stepsCosineAnnealingLR: smoothly decay LR from lr to eta_min over T_max steps following a cosine curve. The standard for transformer training, often combined with linear Starting training with tiny steps that grow for a little while before the main plan kicks in.Full glossary → at the start.
sched = optim.lr_scheduler.CosineAnnealingLR(opt, T_max=10_000, eta_min=1e-5)OneCycleLR: warm up then anneal in a single cycle. Smith's "1cycle" policy from 13-fastbook/16_accel_sgd. Often gives best results for fixed-budget training.
sched = optim.lr_scheduler.OneCycleLR(opt, max_lr=1e-3,
total_steps=10_000,
pct_start=0.1)FIG 10.3.9
The training loop, four lines that matter
The canonical PyTorch training step is four lines:
def train_step(model, opt, x, y, loss_fn):
opt.zero_grad() # 1. clear last step's gradients
logits = model(x) # 2. forward
loss = loss_fn(logits, y)
loss.backward() # 3. backward — populates .grad on every param
opt.step() # 4. update parameters in-place
return loss.item()Around those four lines wraps the rest: data loading, Where a piece of data lives and gets worked on: the main processor or the faster graphics chip.Full glossary → transfer, evaluation, logging, checkpointing. Here is the full pattern, batteries included:
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
def train(model, train_dl, val_dl, opt, sched, loss_fn, n_epochs, device):
model.to(device)
history = {"train_loss": [], "val_loss": [], "val_acc": []}
for epoch in range(n_epochs):
model.train()
running_loss = 0.0
for x, y in train_dl:
x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True)
opt.zero_grad(set_to_none=True)
logits = model(x)
loss = loss_fn(logits, y)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
opt.step()
if sched is not None:
sched.step()
running_loss += loss.item() * x.size(0)
train_loss = running_loss / len(train_dl.dataset)
# Eval
model.eval()
val_loss, correct = 0.0, 0
with torch.no_grad():
for x, y in val_dl:
x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True)
logits = model(x)
val_loss += loss_fn(logits, y).item() * x.size(0)
correct += (logits.argmax(1) == y).sum().item()
val_loss /= len(val_dl.dataset)
val_acc = correct / len(val_dl.dataset)
history["train_loss"].append(train_loss)
history["val_loss"].append(val_loss)
history["val_acc"].append(val_acc)
print(f"epoch {epoch}: train {train_loss:.4f} val {val_loss:.4f} acc {val_acc:.4f}")
return historyFive details that matter.
set_to_none=True in zero_grad is faster than setting gradients to a zero A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary →, because the optimizer can detect None and skip the update for unused parameters. As of PyToranother chapter.x this is the default but it is good to be explicit.
non_blocking=True in .to(device) works in concert with pin_memory=True in the DataLoader. The data transfer happens asynchronously while the previous A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → is still computing. Without both, you serialize the transfer and waste GPU time.
clip_grad_norm_(..., max_norm=1.0) clips the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → to a maximum L2 norm. This prevents the occasional huge gradient from destabilizing training. Standard for transformers; nice-to-have for CNNs. Note the trailing underscore: this is in-place.
model.train and model.eval switch A training trick where the model randomly switches off some of its own pieces each pass, so it can't lean too hard on any one of them.Full glossary → and batchnorm modes. Train uses stochastic behavior; eval uses fixed statistics. Forget to switch back and your eval numbers are wrong; forget to switch to train at the start of an One full trip through every example in your training set.Full glossary → and you train without dropout.
with torch.no_grad: around the eval loop saves a few percent of memory and time. Cheap insurance.
FIG 10.3.10
Saving and loading
Two paths. The simple version saves only the parameters:
# Save
torch.save(model.state_dict(), "model.pt")
# Load
model = MLP(784, 128, 10) # must construct with same architecture
model.load_state_dict(torch.load("model.pt", map_location="cpu"))
model.eval()state_dict is a Python dict of {layer_name: tensor}. It is the canonical serialization format because it does not depend on your class definitions surviving (whereas torch.save(model, "x.pt") pickles the whole module, which breaks across refactors).
For resuming a training run, you also want the optimizer state, the scheduler state, the One full trip through every example in your training set.Full glossary → count, and the random number generator state:
def save_checkpoint(path, model, opt, sched, epoch, rng_state):
torch.save({
"model": model.state_dict(),
"optimizer": opt.state_dict(),
"scheduler": sched.state_dict() if sched else None,
"epoch": epoch,
"rng_state": torch.get_rng_state(),
"cuda_rng_state": torch.cuda.get_rng_state() if torch.cuda.is_available() else None,
}, path)
def load_checkpoint(path, model, opt, sched, device):
ckpt = torch.load(path, map_location=device)
model.load_state_dict(ckpt["model"])
opt.load_state_dict(ckpt["optimizer"])
if sched and ckpt["scheduler"]:
sched.load_state_dict(ckpt["scheduler"])
torch.set_rng_state(ckpt["rng_state"])
if torch.cuda.is_available() and ckpt["cuda_rng_state"] is not None:
torch.cuda.set_rng_state(ckpt["cuda_rng_state"])
return ckpt["epoch"]FIG 10.3.11
Mixed precision: autocast + GradScaler
Modern GPUs (V100 onwards) compute float16 / bfloat16 arithmetic 2-8× faster than float32. Using smaller, faster number formats for most of the math while keeping precise ones only where it really matters.Full glossary → training runs most operations in float16 / bfloat16 while keeping a master copy of the weights in float32 for the optimizer update.
The two APIs:
from torch.amp import autocast, GradScaler
scaler = GradScaler()
for x, y in train_dl:
x, y = x.to(device), y.to(device)
opt.zero_grad(set_to_none=True)
with autocast(device_type="cuda", dtype=torch.float16):
logits = model(x)
loss = loss_fn(logits, y)
scaler.scale(loss).backward()
scaler.unscale_(opt) # un-scale grads before clip
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(opt)
scaler.update()autocast runs operations inside the block in float16 (or bfloat16) where it is safe and faster, falling back to float32 for operations where Out of all the times the model shouted 'yes,' how often it was actually right.Full glossary → matters (A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary →, layer norm). You do not have to manage this; PyTorch knows which is which.
GradScaler exists because float16 has a much smaller dynamic range than float32. Gradients of typical losses When a number gets so tiny that the computer rounds it down to zero and loses it.Full glossary → to zero in float16. The scaler multiplies the loss by a large constant before backward, then divides the gradients back down before the optimizer step. The scale is adaptive: it grows when no overflows happen, shrinks when they do.
bfloat16 does not need GradScaler because its range matches float32 (only the precision is reduced). If your GPU supports bf16 (A100, H100, RTX 30/40), use it: autocast(device_type="cuda", dtype=torch.bfloat16) and drop the scaler entirely. Llama, Mistral, and most modern LLMs are trained in bf16.
Speedup: 1.5-3× on memory-bound layers, ~2× on transformer A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →, less on small models where you are kernel-launch-bound.
FIG 10.3.12
torch.compile: when and why
torch.compile is PyToranother chapter.x's JIT compiler. One line gets you anywhere from 5% to 3× speedup with no code changes:
model = MLP(784, 128, 10).to(device)
model = torch.compile(model) # ← one line; rest of the code is unchangedUnder the hood, torch.compile traces the model's Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary →, fuses operations (collapses several elementwise ops into one GPU pass instead of one launch each), generates kernels — the actual GPU programs that run an op, here emitted via Triton — and re-uses them on subsequent calls. The first call is slow (compilation overhead, often 30 seconds or more); subsequent calls are fast.
Three things to know:
The default mode is "default" which trades compile time for runtime. "reduce-overhead" minimizes Python overhead and helps small models. "max-autotune" searches over kernel implementations and is best for large models with stable shapes.
torch.compile recompiles when shapes change. If your A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → size is sometimes 128 and sometimes 32, you pay the compile cost twice. Use dynamic=True to compile once with symbolic shapes, at the cost of some speedup.
Some operations break compilation. Custom The system that lets code run on a graphics chip instead of the main processor.Full glossary → kernels, certain Python control flow, and a few exotic ops. The error messages are usually clear; the fallback is to mark just the problematic submodule with @torch.compiler.disable.
FIG 10.3.13
Multi-GPU brief intro
You will eventually want to train on multiple GPUs. The two patterns:
DataParallel (legacy, single-process, multi-GPU). One process spawns threads; each thread runs the model on one GPU; gradients are averaged. Easy to set up, slow because of GIL contention and the master-GPU A spot in a model where its width is squeezed small on purpose, forcing the information to compress before it continues.Full glossary →. Use only for prototyping.
DistributedDataParallel (DDP, the real one). One process per GPU. Each process runs the full model on its GPU, computes gradients on its A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary →, and all-reduces gradients across processes after backward — an all-reduce is the collective operation that sums each process's A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → and hands every process the same averaged result, run over NVIDIA's nccl communication backend on GPUs. Scales linearly to ~64 GPUs.
The minimal DDP setup:
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
def main():
dist.init_process_group(backend="nccl")
local_rank = int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(local_rank)
device = torch.device(f"cuda:{local_rank}")
model = MLP(784, 128, 10).to(device)
model = DDP(model, device_ids=[local_rank])
# Use DistributedSampler so each rank sees a different slice of data
sampler = torch.utils.data.DistributedSampler(train_ds)
train_dl = DataLoader(train_ds, batch_size=128, sampler=sampler, ...)
for epoch in range(n_epochs):
sampler.set_epoch(epoch) # required for shuffling to work
... # rest is the standard training loopLaunch with torchrun --nproc_per_node=8 train.py. The launcher sets LOCAL_RANK, WORLD_SIZE, and the other env vars.
This is the depth another chapter needs. another chapter (deep training) covers FSDP, ZeRO, A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary → parallelism, and the rest of the distributed-training landscape. For now, knowing DDP exists and how to use it on a small cluster is enough.
FIG 10.4 · Safety lens · this chapter
PyTorch's defaults are not safety-neutral. Three places they create risk if you use them without thinking about it.
Loading untrusted checkpoints. torch.load(path) deserializes a A common way to save and reload data in code that is risky because opening an untrusted file can run hidden instructions.Full glossary →. A malicious A saved snapshot of a model partway through training so you can stop and pick up later.Full glossary → can execute arbitrary Python code on torch.load. This is not a hypothetical: there have been weights distributed on HuggingFace with embedded payload, and the fix has been the long-running migration from pickle to the safetensors format. As of PyToranother chapter.6, torch.load defaults to weights_only=True which restricts deserialization to known-safe types. Pre-2.6 code does not. Treat every torch.load of an externally-sourced file as a potential RCE. Use safetensors.torch.load_file for anything you did not produce yourself. See 26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications §supply-chain-vulnerabilities and 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-saveloadrun-tutorial-html §saving-and-loading-model-weights for the context.
Non-determinism that hides bugs. PyTorch is non-deterministic by default. The system that lets code run on a graphics chip instead of the main processor.Full glossary → operations like scatter_add and convolution_backward use non-deterministic algorithms for speed. The same model trained twice with the same A starting number that makes a program's 'random' choices come out the same every time.Full glossary → produces different results. This is fine for training, terrible for evaluation: if your "this model is safer" claim depends on a 2% The share of guesses the model got right out of all its guesses.Full glossary → improvement, and 2% is within run-to-run variance, your claim is noise. The fix is torch.use_deterministic_algorithms(True) plus CUBLAS_WORKSPACE_CONFIG=:4096:8 plus setting torch.backends.cudnn.deterministic = True. The price is ~10-30% slower training. The benefit is that your eval numbers mean something. See Karpathy's recipe on fixing the random seed.
A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → accumulation as a quiet bug source. PyTorch's .grad attribute accumulates by default. If you forget opt.zero_grad, training proceeds with stale + new gradients, and the model trains worse but does not error. This is the same class of silent-failure that Karpathy warned about. There is a related class: when you compute gradients of one A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary → with respect to another (torch.autograd.grad), the default is create_graph=False, which discards the second-order graph (the graph that would let you then differentiate through the gradient itself — a gradient of a gradient). For meta-learning / influence-function / second-order optimizers, which need exactly that, you must pass create_graph=True. Forget it, and your second-order method silently degrades to first-order. See 24-founder-blogs/karpathy-recipe §verify-loss-at-init and 22-anthropic-recent/2024-scaling-monosemanticity §gradient-debugging.
Habits to adopt:
- Always
weights_only=Truewhen loading checkpoints you did not produce. The 0.1% of legitimate cases that need pickle are caught immediately by a clear error message. - Use
safetensorsfor any model you publish. It is the format the rest of the ecosystem has migrated to, and it has no code-execution surface. - Set deterministic mode before computing eval numbers that will appear in a paper. Stochastic training is fine; stochastic claims are not.
FIG 10.5 · Under the hood
The library call, and the lines it hides
You don't have to choose between “use the library” and “build it from scratch.” Here is the one library call, the exact lines it stands in for, and when to reach for which on the job.
nn.Linear vs. from scratch
DL primitivelayer = nn.Linear(in_features, out_features, bias=True)
out = layer(x) # out = x @ layer.weight.T + layer.biasclass Linear(Module):
def __init__(self, in_features, out_features, bias=True):
super().__init__()
bound = math.sqrt(1.0 / in_features)
w = torch.empty(out_features, in_features).uniform_(-bound, bound)
self.weight = Parameter(w)
if bias:
self.bias = Parameter(torch.zeros(out_features))
else:
self.bias = None
self.in_features = in_features
self.out_features = out_features
def forward(self, x):
out = x @ self.weight.T
if self.bias is not None:
out = out + self.bias
return outfrom scratch: lab/solution.py: class Linear (Module)
- 1
nn.Linear(in_features, out_features)__init__ that allocates weight of shape (out_features, in_features) and a bias vector - 2
layer.weight (a registered Parameter)self.weight = Parameter(w) — the Parameter subclass that sets requires_grad and is caught by Module.__setattr__ into _parameters - 3
default weight bound U(-1/sqrt(fan_in), 1/sqrt(fan_in))bound = sqrt(1/in_features); torch.empty(...).uniform_(-bound, bound) — the same uniform bound nn.Linear's weight ends up with - 4
layer(x) forwardout = x @ self.weight.T (+ self.bias) — weight is (out,in) so it is transposed - 5
bias=False pathself.bias = None plus the `if self.bias is not None` guard in forward
What the one call hides
- The exact default init: nn.Linear inits its bias to U(-1/sqrt(fan_in), 1/sqrt(fan_in)), NOT zeros like the scratch version, so an identical seed will not match the library on the bias.
- Parameter registration: assigning nn.Parameter auto-adds it to .parameters() and the state_dict; a plain tensor attribute is silently not a parameter.
- The (out_features, in_features) weight layout and the implicit .T in forward — you never see the transpose.
- bias=False uses register_parameter('bias', None) so the None slot is filtered out of named_parameters()/state_dict cleanly.
- Device/dtype movement: layer.to(device) walks every registered parameter; the scratch version has no .to().
- Gotcha: weight is (out_features, in_features); writing x @ weight with no transpose is a shape error beginners can't place.
- Gotcha: A learnable tensor stored as self.w = torch.randn(...) instead of a Parameter is invisible to the optimizer and never trains.
- Gotcha: nn.Linear's bias is random-uniform, not zero — don't expect bit-identical bias vs. the scratch zero-bias version.
Prefer nn.Linear in real code; the scratch version exists only to show a dense layer is literally x @ W.T + b plus parameter bookkeeping.
On the job: You write the Linear-stack architecture (which dims, where) and occasionally a tiny Module subclass, but never the dense matmul itself.
nn.ReLU vs. from scratch
DL primitiveact = nn.ReLU()
out = act(x) # or torch.relu(x) / x.clamp(min=0)class ReLU(Module):
def forward(self, x):
return x.clamp(min=0)from scratch: lab/solution.py: class ReLU (Module)
- 1
nn.ReLU()class ReLU(Module) with a parameter-free forward - 2
act(x)return x.clamp(min=0) — sets negatives to 0, identity on positives - 3
autograd through nn.ReLUclamp is a differentiable torch op, so .backward() flows the subgradient (1 where x>0, 0 where x<0) automatically
What the one call hides
- The subgradient convention at x==0 (PyTorch's clamp/relu pass 0 there) — you never choose it.
- nn.ReLU(inplace=True), which overwrites the input tensor to save memory; the scratch version always allocates a new tensor.
- That ReLU has zero parameters — it appears in the Module tree but contributes nothing to .parameters().
- Gotcha: nn.ReLU(inplace=True) can corrupt a tensor still needed for an earlier op's backward, producing subtly wrong gradients.
- Gotcha: ReLU's hard zero for x<0 is what causes dead ReLUs (a unit stuck off forever) — invisible if you only ever call nn.ReLU().
Just call nn.ReLU / torch.relu in production; the one-liner scratch only shows an activation is an ordinary tensor op whose (sub)gradient autograd handles for free.
On the job: You pick which activation goes where in the architecture; you essentially never hand-implement ReLU's forward or backward.
nn.Sequential vs. from scratch
DL primitivemodel = nn.Sequential(nn.Linear(20, 64), nn.ReLU(), nn.Linear(64, 3))
out = model(x)class Sequential(Module):
def __init__(self, *layers):
super().__init__()
for i, layer in enumerate(layers):
setattr(self, f"layer_{i}", layer)
self._n = len(layers)
def forward(self, x):
for i in range(self._n):
x = getattr(self, f"layer_{i}")(x)
return xfrom scratch: lab/solution.py: class Sequential (Module)
- 1
nn.Sequential(*layers)Sequential(*layers) storing each via setattr so Module.__setattr__ registers it into _modules - 2
automatic parameter collection (model.parameters())Module.parameters() recursing into _modules — each registered layer_i yields its own params - 3
model(x) running layers in orderforward loop: x = layer_i(x) for i in range(self._n) - 4
submodule registration on attribute assignmentthe custom Module.__setattr__ catching isinstance(value, Module) and storing in self._modules
What the one call hides
- The whole nested parameter discovery: nn.Module walks attributes recursively to collect every parameter — exactly the _parameters/_modules tree-walk done by hand here.
- Ordered-dict semantics and string keys ('0','1',...) so the state_dict round-trips by name.
- named_modules()/children() iteration and .to(device) propagation across the whole stack.
- That nn.Sequential only works for a straight pipe — no branches, skips, or multi-input forwards (you must hand-write forward for those).
- Gotcha: nn.Sequential cannot express residual/skip connections or multiple inputs/outputs; using it where you need branching forces an ugly rewrite.
- Gotcha: Storing layers in a plain Python list instead of nn.Sequential / nn.ModuleList hides them from .parameters() and they silently never train.
Use nn.Sequential for linear stacks and a hand-written forward for anything with branching; the scratch version proves a model is just submodules whose parameters get collected by a recursive walk.
On the job: You hand-write Module.forward for any real architecture with skips/branches/multi-input; nn.Sequential is just sugar for the no-branch segments.
optim.SGD (with momentum) vs. from scratch
DL glueopt = optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
opt.zero_grad(); loss.backward(); opt.step()class SGD:
def __init__(self, params, lr=0.01, momentum=0.0):
self.params = list(params)
self.lr = lr
self.momentum = momentum
self.v = [torch.zeros_like(p, requires_grad=False) for p in self.params]
def step(self):
with torch.no_grad():
for i, p in enumerate(self.params):
if p.grad is None:
continue
self.v[i] = self.momentum * self.v[i] + p.grad
p.data -= self.lr * self.v[i]from scratch: lab/solution.py: class SGD
- 1
optim.SGD(params, lr, momentum)SGD(params, lr, momentum) materializing params into a list and a per-param velocity buffer self.v - 2
the momentum update buffer b_t = momentum*b_{t-1} + gradself.v[i] = momentum * self.v[i] + p.grad — the dampening=0 buffer recurrence - 3
opt.step() parameter updatep.data -= self.lr * self.v[i] under torch.no_grad() — the in-place step - 4
opt.zero_grad()zero_grad() looping params and calling p.grad.zero_() - 5
using .data / no_grad to keep the update off the graphwith torch.no_grad(): ... p.data -= ... so the weight update is not tracked by autograd
What the one call hides
- Buffer convention: torch SGD stores buf = mom*buf + grad and on step 1 seeds buf=grad; the scratch v starts at zeros (which equals grad on step 1 since 0*mom+grad=grad), so the BUFFERS look different across libraries but the PARAMETER TRAJECTORY is identical — compare trajectories, never buffers.
- weight_decay: torch adds grad += weight_decay*p BEFORE the momentum buffer; the scratch version has no decay at all (both default to 0 here).
- dampening and Nesterov momentum: torch supports both; this is the dampening=0, nesterov=False path only.
- state_dict()/load_state_dict() for the momentum buffers (needed to resume training); the scratch optimizer can't be checkpointed.
- Fused/foreach kernels torch uses to update all params in one launch instead of a Python for-loop.
- Gotcha: Default weight_decay in optim.SGD is 0 — it does not regularize unless you set it.
- Gotcha: Forgetting opt.zero_grad() makes .grad accumulate across steps (torch sums grads), silently degrading training with no error.
- Gotcha: Updating p (not p.data) without no_grad builds an autograd graph on the optimizer step and either errors or leaks memory.
- Gotcha: The lr sweet spot for SGD is narrow; an lr that works for Adam often diverges or stalls here.
Prefer optim.SGD/AdamW in real training (checkpointable state, weight decay, fused kernels); write the scratch step only to internalize that an optimizer is an EMA of gradients applied in-place, or to ship a custom update rule the library doesn't have.
On the job: You write the training loop (zero_grad/backward/step ordering, grad clipping, accumulation, LR schedule) by hand; the per-param update math you take from the library.
F.cross_entropy vs. from scratch
DL glueloss = F.cross_entropy(logits, targets) # logits: (N, C), targets: (N,) int64def cross_entropy(logits, targets):
log_probs = logits - logits.logsumexp(dim=-1, keepdim=True)
nll = -log_probs.gather(1, targets.long().unsqueeze(1)).squeeze(1)
return nll.mean()from scratch: lab/solution.py: cross_entropy
- 1
F.cross_entropy(logits, targets)the whole cross_entropy function: log_softmax then gather-the-true-class then mean - 2
internal log_softmax over the class dimlog_probs = logits - logits.logsumexp(dim=-1, keepdim=True) — the stable log-softmax (logsumexp is the safe denominator) - 3
negative-log-likelihood at the true class (the F.nll_loss part)-log_probs.gather(1, targets.unsqueeze(1)) — pick the log-prob of each row's target label - 4
reduction='mean' (the default)nll.mean() over the batch - 5
targets are class INDICES, not one-hotgather(1, targets.long().unsqueeze(1)) indexes by integer label
What the one call hides
- It fuses log_softmax + nll_loss in one numerically stable pass — never softmax-then-log, which overflows; the logsumexp trick is the hidden numerics.
- reduction='mean' is assumed; F.cross_entropy also offers 'sum'/'none' that you must opt into.
- targets must be int64 class indices of shape (N,) — F.cross_entropy also accepts probability targets with its own conventions.
- Extras the library ships but this hides: class weights (weight=), ignore_index, and label_smoothing=0 by default.
- It expects raw logits, not probabilities — feeding post-softmax values double-applies softmax and is a silent accuracy bug.
- Gotcha: Pass logits, never softmax output — F.cross_entropy applies log_softmax internally; passing probabilities silently trains the wrong thing.
- Gotcha: targets must be int64 indices of shape (N,), not one-hot (N,C); float or wrong-shape targets either error or quietly misbehave.
- Gotcha: label_smoothing defaults to 0 and class weight to None — no implicit regularization or class balancing.
Prefer F.cross_entropy (or nn.CrossEntropyLoss) in production for its fused stability and features; write the scratch version only to see it's log-softmax + pick-true-class + mean, and why you never softmax-then-log.
On the job: You write custom losses (focal, weighted, label-smoothed variants, multi-task sums) on top of log_softmax/nll primitives when the off-the-shelf loss doesn't fit the task.
FIG 10.6 · Chapter notebook
Build this chapter with your own hands
A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.
What you'll build
- A from-scratch Linear layer that you verify, element for element, against torch.nn.Linear.
- A from-scratch cross_entropy checked against F.cross_entropy to 1e-6, and a from-scratch SGD checked against torch.optim.SGD.
- A Dataset + DataLoader over real FashionMNIST images, then the four-line training loop that turns them into a classifier above 80% accuracy.
- One training run you break on purpose (the forgotten zero_grad) and then fix, so the bug becomes muscle memory instead of a surprise.
~5 min on CPU · 109 cells · 10 checked exercises · runs in Colab
FIG 10.7 · Going further
27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-intro-htmlthe PyTorch tutorials in one place. Work through them in order if you want a second pass.
04-stanford/cs336-lecture_02CS336 lecture 2 (PyTorch + resource accounting). The canonical "now you can predict the cost of a forward pass" lecture.
12-karpathy-code/nanoGPT-master-trainthe canonical PyTorch training loop for transformer scale. Best read line by line.
24-founder-blogs/karpathy-recipethe "recipe for training neural networks" essay. Read once now, re-read every six months.
27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-saveloadrun-tutorial-htmlthe official save/load tutorial. Skim for the exact API; the patterns in section 10 of this chapter are the practical version.
13-fastbook/17_foundationsHoward's "foundations of deep learning" chapter, which builds nn.Module from scratch on top of NumPy. A second view of this chapter's content.
18-lilian-weng/2023-01-10-inference-optimizationonce you train, you serve. The first half is the practical knobs you have in PyTorch.
FIG 10.8 · What this enables
Chapters you can now read, with the connecting idea written out.
now that the framework is no longer mysterious, another chapter fixes the issues you saw at small scale: vanishing gradients, dead ReLUs, hyperparameter search. Everything is built on the training loop in section 9 of this chapter.
same training loop, swap the model. Spend another chapter on convolutions; the rest is muscle memory.
nanoGPT is written in exactly the PyTorch style of this chapter. You will read it and find no surprises.
FIG 10.9 · 32 sources
- 01-explorables/distill-momentum
- 04-stanford/cs336-lecture_02
- 08-geron-notebooks/10_neural_nets_with_keras
- 08-geron-notebooks/11_training_deep_neural_networks
- 12-karpathy-code/nanoGPT-master-model
- 12-karpathy-code/nanoGPT-master-train
- 12-karpathy-code/micrograd_lecture_second_half_roughly
- 13-fastbook/04_mnist_basics
- 13-fastbook/16_accel_sgd
- 13-fastbook/17_foundations
- 16-d2l-sections/chapter_optimization__adam
- 16-d2l-sections/chapter_optimization__lr-scheduler
- 18-lilian-weng/2021-09-25-train-large
- 18-lilian-weng/2023-01-10-inference-optimization
- 19-nanda-blog/mechanistic-interpretability-an-intuitive-explanation
- 22-anthropic-recent/2024-scaling-monosemanticity
- 24-founder-blogs/karpathy-recipe
- 25-alignment-canon/security-of-model-weights
- 26-pentest-redteam/owasp-llm-top-10
- 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-autogradqs-tutorial-html
- 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-buildmodel-tutorial-html
- 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-data-tutorial-html
- 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-intro-html
- 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-optimization-tutorial-html
- 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-quickstart-tutorial-html
- 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-saveloadrun-tutorial-html
- 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-tensorqs-tutorial-html
- 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-blitz-autograd-tutorial-html
- 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-blitz-neural-networks-tutorial-html
- 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-blitz-tensor-tutorial-html
- 27-framework-docs/pytorch-pytorch-org-tutorials-intermediate-dist-tuto-html
- 27-framework-docs/pytorch-pytorch-org-tutorials-recipes-recipes-saving-and-loading-models-for-inference-ht