Ch. 10

PyTorch Foundations

Tensors, autograd, nn.Module composition, the 4-line training loop, mixed precision, torch.compile.

pytorchautogradtraining-loop

FIG 10 · Explainer video


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 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 of view versus reshape versus contiguous.
  • Compute gradients with torch.autograd, including the cases where you need torch.no_grad, detach, and retain_graph=True.
  • Build a custom nn.Module that holds parameters, registers buffers, and composes other modules cleanly.
  • Write a Dataset and DataLoader for tabular and image data, with num_workers, pin_memory, and shuffle set 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, and GradScaler to 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 Networksyou should already know what an MLP is, what backprop does, and have written micrograd. Without it, PyTorch's .backward is magic; with it, you trust it.
  • Ch 0 — Math & Python prereqsbroadcasting and basic linear algebra. You will also see decorators here (@torch.no_grad everywhere); a decorator is just syntactic sugar where @f above a function g means g = f(g), so the function runs wrapped in f'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 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 , 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:

Python
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):

Python
device = "cuda" if torch.cuda.is_available() else "cpu"
x = torch.randn(1000, 1000).to(device)

A tensor's GPU memory () is just its element count times its byte size: x.numel * x.element_size, which for float32 (4 bytes) means a 100M- 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.

Python
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*x

FIG 10.3.2

Operations and broadcasting

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.

Python
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:

OperationWhat it does
x.shape, x.sizeDimensions 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 at each row's true class, with idx shaped (batch, 1))
x @ yMatrix 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 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).

Python
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 ok

einsum 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 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 -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 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 .

The graph is dynamic. It is rebuilt every . 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.

Python
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=2

Three 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 . 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).

Python
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 populated

FIG 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:

Python
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.bias

Three 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 and will be silently ignored. Third, define forward, not __call__. nn.Module.__call__ wraps forward with hooks and 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:

Python
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()))   # 101770

nn.Sequential is a convenience for the common "stack modules" pattern. Use it when there is no branching:

Python
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 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 in . Use self.register_buffer("name", tensor).

Python
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).

Python
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 out

Three 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, 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:

Python
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:

Python
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 .

pin_memory=True allocates the in page-locked memory on the host, which lets '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:

Python
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-place

The four optimizers you will actually use:

SGD with . 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- learning rates, with exponential moving averages of both the (momentum) and the squared gradient (variance). The update is θtθt1ηv^t/(s^t+ϵ)\theta_t \leftarrow \theta_{t-1} - \eta \hat{v}_t / (\sqrt{\hat{s}_t} + \epsilon) where v^t\hat{v}_t is the -corrected first moment and s^t\hat{s}_t 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 . The original Adam combines L2 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 . 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:

Python
opt = optim.AdamW([
    {"params": model.embed.parameters(), "lr": 1e-5},
    {"params": model.head.parameters(),  "lr": 1e-3},
], weight_decay=0.1)

Standard pattern for : small LR for the pretrained backbone, large LR for the newly-initialized head.

FIG 10.3.8

Learning rate schedulers

A scheduler adjusts the 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.

Python
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 steps

CosineAnnealingLR: 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 at the start.

Python
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.

Python
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:

Python
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, transfer, evaluation, logging, checkpointing. Here is the full pattern, batteries included:

Python
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 history

Five details that matter.

set_to_none=True in zero_grad is faster than setting gradients to a zero , 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 is still computing. Without both, you serialize the transfer and waste GPU time.

clip_grad_norm_(..., max_norm=1.0) clips the 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 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 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:

Python
# 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 count, and the random number generator state:

Python
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. training runs most operations in float16 / bfloat16 while keeping a master copy of the weights in float32 for the optimizer update.

The two APIs:

Python
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 matters (, 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 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 , 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:

Python
model = MLP(784, 128, 10).to(device)
model = torch.compile(model)   # ← one line; rest of the code is unchanged

Under the hood, torch.compile traces the model's , 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 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 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 . 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 , and all-reduces gradients across processes after backward — an all-reduce is the collective operation that sums each process's 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:

Python
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 loop

Launch 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, 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 malicious 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. operations like scatter_add and convolution_backward use non-deterministic algorithms for speed. The same model trained twice with the same produces different results. This is fine for training, terrible for evaluation: if your "this model is safer" claim depends on a 2% 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.

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 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=True when loading checkpoints you did not produce. The 0.1% of legitimate cases that need pickle are caught immediately by a clear error message.
  • Use safetensors for 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 primitive
LIBRARY
layer = nn.Linear(in_features, out_features, bias=True)
out = layer(x)   # out = x @ layer.weight.T + layer.bias
FROM SCRATCH
class 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 out

from scratch: lab/solution.py: class Linear (Module)

  1. 1nn.Linear(in_features, out_features) __init__ that allocates weight of shape (out_features, in_features) and a bias vector
  2. 2layer.weight (a registered Parameter) self.weight = Parameter(w) — the Parameter subclass that sets requires_grad and is caught by Module.__setattr__ into _parameters
  3. 3default 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. 4layer(x) forward out = x @ self.weight.T (+ self.bias) — weight is (out,in) so it is transposed
  5. 5bias=False path self.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 primitive
LIBRARY
act = nn.ReLU()
out = act(x)            # or torch.relu(x) / x.clamp(min=0)
FROM SCRATCH
class ReLU(Module):
    def forward(self, x):
        return x.clamp(min=0)

from scratch: lab/solution.py: class ReLU (Module)

  1. 1nn.ReLU() class ReLU(Module) with a parameter-free forward
  2. 2act(x) return x.clamp(min=0) — sets negatives to 0, identity on positives
  3. 3autograd through nn.ReLU clamp 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 primitive
LIBRARY
model = nn.Sequential(nn.Linear(20, 64), nn.ReLU(), nn.Linear(64, 3))
out = model(x)
FROM SCRATCH
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 x

from scratch: lab/solution.py: class Sequential (Module)

  1. 1nn.Sequential(*layers) Sequential(*layers) storing each via setattr so Module.__setattr__ registers it into _modules
  2. 2automatic parameter collection (model.parameters()) Module.parameters() recursing into _modules — each registered layer_i yields its own params
  3. 3model(x) running layers in order forward loop: x = layer_i(x) for i in range(self._n)
  4. 4submodule registration on attribute assignment the 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 glue
LIBRARY
opt = optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
opt.zero_grad(); loss.backward(); opt.step()
FROM SCRATCH
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. 1optim.SGD(params, lr, momentum) SGD(params, lr, momentum) materializing params into a list and a per-param velocity buffer self.v
  2. 2the momentum update buffer b_t = momentum*b_{t-1} + grad self.v[i] = momentum * self.v[i] + p.grad — the dampening=0 buffer recurrence
  3. 3opt.step() parameter update p.data -= self.lr * self.v[i] under torch.no_grad() — the in-place step
  4. 4opt.zero_grad() zero_grad() looping params and calling p.grad.zero_()
  5. 5using .data / no_grad to keep the update off the graph with 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 glue
LIBRARY
loss = F.cross_entropy(logits, targets)   # logits: (N, C), targets: (N,) int64
FROM SCRATCH
def 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. 1F.cross_entropy(logits, targets) the whole cross_entropy function: log_softmax then gather-the-true-class then mean
  2. 2internal log_softmax over the class dim log_probs = logits - logits.logsumexp(dim=-1, keepdim=True) — the stable log-softmax (logsumexp is the safe denominator)
  3. 3negative-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. 4reduction='mean' (the default) nll.mean() over the batch
  5. 5targets are class INDICES, not one-hot gather(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-html

    the PyTorch tutorials in one place. Work through them in order if you want a second pass.

  • 04-stanford/cs336-lecture_02

    CS336 lecture 2 (PyTorch + resource accounting). The canonical "now you can predict the cost of a forward pass" lecture.

  • 12-karpathy-code/nanoGPT-master-train

    the canonical PyTorch training loop for transformer scale. Best read line by line.

  • 24-founder-blogs/karpathy-recipe

    the "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-html

    the official save/load tutorial. Skim for the exact API; the patterns in section 10 of this chapter are the practical version.

  • 13-fastbook/17_foundations

    Howard'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-optimization

    once 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
  1. 01-explorables/distill-momentum
  2. 04-stanford/cs336-lecture_02
  3. 08-geron-notebooks/10_neural_nets_with_keras
  4. 08-geron-notebooks/11_training_deep_neural_networks
  5. 12-karpathy-code/nanoGPT-master-model
  6. 12-karpathy-code/nanoGPT-master-train
  7. 12-karpathy-code/micrograd_lecture_second_half_roughly
  8. 13-fastbook/04_mnist_basics
  9. 13-fastbook/16_accel_sgd
  10. 13-fastbook/17_foundations
  11. 16-d2l-sections/chapter_optimization__adam
  12. 16-d2l-sections/chapter_optimization__lr-scheduler
  13. 18-lilian-weng/2021-09-25-train-large
  14. 18-lilian-weng/2023-01-10-inference-optimization
  15. 19-nanda-blog/mechanistic-interpretability-an-intuitive-explanation
  16. 22-anthropic-recent/2024-scaling-monosemanticity
  17. 24-founder-blogs/karpathy-recipe
  18. 25-alignment-canon/security-of-model-weights
  19. 26-pentest-redteam/owasp-llm-top-10
  20. 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-autogradqs-tutorial-html
  21. 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-buildmodel-tutorial-html
  22. 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-data-tutorial-html
  23. 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-intro-html
  24. 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-optimization-tutorial-html
  25. 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-quickstart-tutorial-html
  26. 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-saveloadrun-tutorial-html
  27. 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-tensorqs-tutorial-html
  28. 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-blitz-autograd-tutorial-html
  29. 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-blitz-neural-networks-tutorial-html
  30. 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-blitz-tensor-tutorial-html
  31. 27-framework-docs/pytorch-pytorch-org-tutorials-intermediate-dist-tuto-html
  32. 27-framework-docs/pytorch-pytorch-org-tutorials-recipes-recipes-saving-and-loading-models-for-inference-ht