The LLM StackFrom Silicon to Agents
Part I — Mathematical & Systems Foundations
32 min read·Updated ·▶ Run the code (Colab)

1.7 Automatic Differentiation & PyTorch Internals

Backpropagation is the algorithm that makes deep learning tractable, but writing it by hand for every new architecture is error-prone, slow, and frankly miserable. Automatic differentiation (autodiff) is the engineering discipline that solves this: given any composition of differentiable operations expressed as code, autodiff computes exact gradients — not finite differences, not symbolic algebra output — mechanically and efficiently. PyTorch’s autograd engine is the most widely used reverse-mode autodiff system in research and production. Understanding it at the mechanism level pays dividends every time you write a custom loss, a non-standard layer, or debug a gradient that quietly went to zero.

This chapter covers the full stack: the mathematics of reverse-mode autodiff and the tape metaphor, the PyTorch computation graph and its memory model, leaf tensors and the .grad accumulation protocol, what the graph actually stores (saved tensors, and how to count their bytes), the hook protocol that DDP and FSDP are built on, no_grad and inference mode, custom autograd.Function, and the lower layers of PyTorch (the dispatcher, ATen, views vs. copies, contiguity, and broadcasting semantics). We connect the theory to the practice with a fully-worked custom function example and numerical traces you can follow by hand.

We assume you have read Neural Networks From Scratch: MLPs & Backprop and are comfortable with the chain rule. We also reference Calculus, Optimization & Convexity for the Jacobian formalism and GPU Architecture & The Memory Hierarchy for the hardware context that makes contiguity matter.


Why Autodiff Exists: The Three Alternatives and Their Failures

Before diving into the mechanism, it is worth naming the alternatives that autodiff replaced.

Manual backprop requires the programmer to derive and implement the gradient of every operation. This was the norm in the symbolic-layer era (Theano, early Caffe). It is correct when done carefully, but it couples forward and backward code, makes architectural experiments tedious, and is a perennial source of subtle bugs.

Numerical differentiation (finite differences) approximates the derivative as:

\[ \frac{\partial f}{\partial x_i} \approx \frac{f(x + \epsilon e_i) - f(x - \epsilon e_i)}{2\epsilon} \]

This requires \(2N\) forward passes for \(N\) parameters — completely impractical for networks with billions of parameters. It is still useful for gradient checking (verifying autodiff implementations), where you compare the autodiff gradient against a small finite-difference estimate for a handful of parameters.

Symbolic differentiation (as in computer algebra systems like Mathematica) manipulates expression trees algebraically. The output is a symbolic formula for the derivative. It produces exact answers but suffers from expression swell — derivatives of composite functions grow exponentially in size with depth, and the resulting code is typically far slower than an equivalent imperative implementation.

Reverse-mode autodiff threads the needle: it computes exact derivatives (to floating-point precision), scales in \(O(1)\) forward passes regardless of \(N\), and operates on ordinary imperative code. Its only real cost is memory for the intermediate activations stored during the forward pass — a cost we will see how to reduce with gradient checkpointing (covered in Memory-Efficient Training: Checkpointing, Offloading & LoRA Math).


Reverse-Mode Autodiff: The Tape

The Jacobian-Vector Product View

Let \(f : \mathbb{R}^n \to \mathbb{R}^m\) be a differentiable function. Its derivative at a point \(x\) is a linear map \(Df(x) : \mathbb{R}^n \to \mathbb{R}^m\), represented as the \(m \times n\) Jacobian matrix \(J\). For most neural network losses, \(m = 1\), so the Jacobian is a \(1 \times n\) row vector — the gradient.

Reverse-mode autodiff computes the vector-Jacobian product (VJP):

\[ \bar{x} = \bar{y}^\top J \]

where \(\bar{y}\) is the upstream gradient (a \(1 \times m\) row vector) and \(\bar{x}\) is the resulting gradient with respect to \(x\). When \(m = 1\) and \(\bar{y} = 1\), this recovers the ordinary gradient \(\nabla_x f\).

The key insight is that we never materialize \(J\) itself — we only ever compute VJPs. This makes reverse-mode autodiff efficient when the output dimension \(m\) is small (e.g., \(m = 1\) for scalar losses) regardless of how large \(n\) is.

The Tape Metaphor

During the forward pass, autograd records every operation applied to tensors that require gradients, building a directed acyclic graph (DAG). Edges point from outputs to inputs (in the backward direction). Nodes are Function objects that know how to compute the VJP for their operation. This data structure is called the tape or Wengert list (after Robert Wengert, who described it in 1964).

During the backward pass, we traverse the tape in reverse topological order. At each node, we: 1. Receive the upstream gradient \(\bar{y}\) from the node above. 2. Call the node’s backward function to compute the VJP: \(\bar{x} = \bar{y}^\top J\). 3. Accumulate \(\bar{x}\) into the gradient of the input tensor and pass it downstream.

After loss.backward() returns, every leaf tensor with requires_grad=True has its .grad field populated with the accumulated gradient.

x input MatMul z pre-ReLU ReLU h hidden Linear logits raw scores CE Loss L scalar loss seed ȳ = 1.0 stores W stores z stores h stores logits dL/dlogits = ȳ dL/dh dL/dz dL/dx dL/dW x̄ = ȳᵀ J
The tape in two phases: forward builds the graph, backward propagates VJPs. During the forward pass (blue, left-to-right) each operation is appended to the tape and stashes the activations it will need — W for MatMul, z for ReLU, h for Linear, logits for CE Loss. During the backward pass (orange, right-to-left) the engine traverses the tape in reverse, applying the VJP rule x̄ = ȳᵀ J at every node; MatMul yields two gradient branches: dL/dx back to the input and dL/dW to the weight matrix.

PyTorch Autograd: The Computation Graph

Tensors, requires_grad, and Leaf Nodes

Every PyTorch tensor has a requires_grad flag. When True, operations on it are tracked. A tensor is a leaf if it was created directly by user code (e.g., nn.Parameter, or a tensor created with requires_grad=True), rather than being the output of some tracked operation. After backward(), only leaf tensors’ .grad fields are populated; intermediate (non-leaf) tensors’ gradients are not retained by default (to save memory).

import torch

# Leaf tensor: created directly, not the result of an op
x = torch.tensor([2.0, 3.0], requires_grad=True)
w = torch.tensor([0.5, -1.0], requires_grad=True)

# Non-leaf (intermediate) tensor: result of an operation
y = (x * w).sum()   # y = 2*0.5 + 3*(-1.0) = 1.0 - 3.0 = -2.0

print(x.is_leaf)   # True
print(y.is_leaf)   # False
print(y.grad_fn)   # <SumBackward0 object at 0x...>

y.backward()

# Only leaf gradients are populated
print(x.grad)   # tensor([ 0.5, -1.0])  -- dy/dx_i = w_i
print(w.grad)   # tensor([ 2.0,  3.0])  -- dy/dw_i = x_i
# y.grad would be None (non-leaf, gradient not retained)

The grad_fn attribute is a reference to the Function node that created this tensor. Following .grad_fn.next_functions traverses the graph toward the leaves.

The grad_fn Graph

# Inspecting the graph manually
a = torch.tensor(3.0, requires_grad=True)
b = torch.tensor(4.0, requires_grad=True)
c = a * b           # MulBackward0
d = c + a           # AddBackward0
e = d ** 2          # PowBackward0

print(e.grad_fn)                          # PowBackward0
print(e.grad_fn.next_functions)           # ((AddBackward0, 0),)
print(e.grad_fn.next_functions[0][0].next_functions)
# ((MulBackward0, 0), (AccumulateGrad, 0))  ← 'a' appears twice!

Notice that a appears twice in the graph (once as an input to c = a*b and once as the second input to d = c+a). PyTorch correctly accumulates both gradient contributions into a.grad.

e (seed: e.grad = 1.0, backward() starts here) .grad_fn PowBackward0 e = d ** 2 next_functions[0] AddBackward0 d = c + a non-leaf: .grad discarded (grad_fn only) next_functions[0] (c) next_functions[1] (a) MulBackward0 c = a * b non-leaf: .grad discarded (grad_fn only) next_functions[0] (a) next_functions[1] (b) + contributions summed -> a.grad AccumulateGrad -> a.grad AccumulateGrad -> b.grad a leaf: requires_grad -> .grad b leaf: requires_grad -> .grad grad_fn (backward op) leaf tensor AccumulateGrad sink backward() seeds e.grad=1, walks nodes in reverse topological order, and adds each node's VJP into its inputs -- fork (a used twice) => sum at the leaf's AccumulateGrad.
The grad_fn DAG that e.backward() actually walks. Because leaf a feeds both c = a*b and d = c+a, two separate backward paths reach it; PyTorch sums both contributions at a's single AccumulateGrad node. Intermediate tensors c and d are non-leaf — their gradients are computed in passing but discarded.

Gradient Accumulation and .grad_fn Ownership

By default, .grad accumulates (adds) across multiple .backward() calls. This is intentional and exploited by gradient accumulation in training:

optimizer.zero_grad()          # clear accumulated grads
for mini_batch in accumulation_steps:
    loss = model(mini_batch) / accumulation_steps
    loss.backward()            # accumulates into .grad
optimizer.step()               # update once with the full-batch gradient

If you forget zero_grad(), gradients from the previous step corrupt the current one — a classic bug.

What the Graph Stores: Saved Tensors and Their Cost

The tape is not free. Each Function node keeps alive exactly the tensors its backward formula needs — its saved tensors — and those references are why activation memory, not parameter memory, dominates the peak footprint of a training step. PyTorch exposes them for inspection through underscore-prefixed attributes on grad_fn, which is the fastest way to answer “what is this op actually holding on to?”:

import torch

x = torch.randn(1024, 512, requires_grad=True)
w = torch.randn(512, 512, requires_grad=True)

y = x @ w                      # MmBackward0 needs BOTH operands: A_bar = C_bar B^T, B_bar = A^T C_bar
print(y.grad_fn._saved_self.shape)    # torch.Size([1024, 512])  -- x is pinned by the graph
print(y.grad_fn._saved_mat2.shape)    # torch.Size([512, 512])   -- w is pinned by the graph

r = torch.relu(y)              # ReluBackward0 saves only the OUTPUT (mask = output > 0)
print([a for a in dir(r.grad_fn) if a.startswith("_saved")])   # ['_saved_result']

e = y.exp()                    # ExpBackward0: d/dx e^x = e^x, so the output suffices
print(e.grad_fn._saved_result.shape)  # torch.Size([1024, 512])

Two design rules fall out of this. First, prefer backward formulas that read the output, as StableSigmoid below does: relu, exp, sigmoid, and tanh all save one tensor instead of two. Second, matmuls are the expensive nodes — every Linear in your model pins its input activation for the whole backward pass.

Counting the tape for one Stack-100M micro-batch

Take the capstone configuration from The Pretraining Run: A Complete Single-GPU Training Loop: d_model = 512, intermediate = 1408 (SwiGLU), 30 blocks, micro-batch of 32 sequences × 2048 tokens = 65,536 tokens, activations in bf16 (2 bytes).

One tensor of shape (tokens, d_model) is \(65{,}536 \times 512 \times 2 = 67{,}108{,}864\) bytes — exactly 64 MiB. One tensor at the SwiGLU width is \(65{,}536 \times 1408 \times 2 =\) 176 MiB.

A single block’s MLP saves the block input (64 MiB, pinned once and shared by the gate and up projections), the gate and up pre-activations (176 MiB each, needed by the SiLU and the elementwise product), and the product that feeds the down projection (176 MiB): \(64 + 3\times176 \approx\) 0.6 GiB per block, from the MLP alone, before attention or the norms contribute anything. Thirty blocks of that is on the order of 17 GiB — which is why activation checkpointing (later in this chapter) becomes the deciding factor in how large a micro-batch fits, even though the parameters are only ~0.2 GiB in bf16.

Because saved tensors are just Python-visible objects flowing through the engine, you can intercept them. torch.autograd.graph.saved_tensors_hooks(pack, unpack) installs a pair of callbacks: pack runs when a tensor is saved (return anything you like — a CPU copy, a quantized blob, a filename), and unpack runs when backward needs it back. This one mechanism is the substrate under CPU activation offloading and quantized-activation training:

from torch.autograd.graph import saved_tensors_hooks

# Offload every saved activation to host memory, fetch it back in backward.
# pack may return ANY object; unpack must turn it back into the tensor.
# (torch.autograd.graph.save_on_cpu(pin_memory=True) is the built-in version of this.)
def pack(t):
    return (t.device, t.to("cpu", non_blocking=True))

def unpack(packed):
    device, t = packed
    return t.to(device, non_blocking=True)

with saved_tensors_hooks(pack, unpack):
    out = model(batch)          # graph built here holds CPU copies, not GPU tensors
loss = out.sum()
loss.backward()                 # unpack runs as each node is reached

Trading PCIe bandwidth for HBM this way is only a win when the transfer overlaps compute; see Memory-Efficient Training: Checkpointing, Offloading & LoRA Math for when offloading beats recomputation.

Hooks: Intercepting Gradients As They Flow

The engine calls user callbacks at two points, and essentially every distributed-training library is built on them.

  • tensor.register_hook(fn) fires when that tensor’s gradient has been computed. Return None to merely observe, or return a tensor to replace the gradient that continues downstream. This is how you implement per-tensor clipping, gradient reversal layers, or a probe that logs which layer first goes NaN.
  • tensor.register_post_accumulate_grad_hook(fn) (PyTorch 2.1+) fires on a leaf after .grad has been updated — that is, once the parameter’s gradient is final for this backward. nn.Module.register_full_backward_hook gives the same idea at module granularity.
import torch

lin = torch.nn.Linear(4, 4)
x = torch.randn(2, 4)

# Observe (and optionally rewrite) the gradient as it reaches this tensor.
h1 = lin.weight.register_hook(lambda g: print("weight grad norm:", g.norm().item()))

# Fires once, after lin.weight.grad is final. DDP attaches its all-reduce here.
def on_grad_ready(param):
    print("grad ready for", tuple(param.shape))

h2 = lin.weight.register_post_accumulate_grad_hook(on_grad_ready)

lin(x).sum().backward()
h1.remove()                     # hooks are handles -- always remove them
h2.remove()

Why this matters beyond debugging: DistributedDataParallel registers exactly this kind of hook on every parameter, so that as soon as a bucket of parameters has its gradients ready, the all-reduce for that bucket launches while the rest of the backward is still running. That overlap of communication with computation is the entire reason DDP is not bandwidth-bound — see Distributed Training I: Data Parallelism, DDP, ZeRO & FSDP, which reconstructs the bucketing engine from these primitives. FSDP uses the same hook points to trigger the reduce-scatter and to free resharded parameters. For the debugging use, a register_hook that checks torch.isfinite(g).all() per layer is the cheap always-on complement to the anomaly detector at the end of this chapter.


torch.no_grad, inference_mode, and Detach

torch.no_grad()

Inside a no_grad context, autograd does not record operations even for tensors with requires_grad=True. No grad_fn is attached to outputs, and no tape is built. Use this for inference and for optimizer steps (parameter updates must not themselves be differentiated).

with torch.no_grad():
    output = model(input)   # no graph built; saves memory and compute

torch.inference_mode()

Introduced in PyTorch 1.9, inference_mode is a stronger version of no_grad: tensors created inside it are marked as “inference tensors” and cannot be used in a future requires_grad computation. This allows PyTorch to skip additional bookkeeping. Prefer it over no_grad for pure inference paths.

with torch.inference_mode():
    logits = model(input_ids)
    probs = torch.softmax(logits, dim=-1)

.detach()

.detach() returns a new tensor that shares the same storage but is detached from the computation graph — it has requires_grad=False and no grad_fn. Common uses:

  • Logging or visualization of activations without building a graph.
  • Stopping gradient flow in architectures like target networks in RL (where we want the target to be fixed).
  • The “stop-gradient” trick in self-supervised learning (BYOL, SimSiam).
# Stop-gradient for target network
with torch.no_grad():
    target_features = target_encoder(x)  # equivalent to detach here

# Or explicitly:
target_features = online_encoder(x).detach()

Custom autograd.Function

PyTorch’s built-in operations cover almost every need, but sometimes you need a custom forward/backward pair: a fused kernel, a numerically stable reformulation, or a straight-through estimator. torch.autograd.Function gives you a clean interface to plug into the autograd engine.

The Function Interface

import torch
from torch.autograd import Function

class MyOp(Function):
    @staticmethod
    def forward(ctx, *inputs):
        # ctx is a context object for stashing tensors for backward
        # return output tensor(s)
        ...

    @staticmethod
    def backward(ctx, *grad_outputs):
        # grad_outputs: upstream gradients (one per forward output)
        # return gradient tensors (one per forward input, or None if not differentiable)
        ...

The ctx object is the bridge: ctx.save_for_backward(...) stashes tensors (only tensors, not Python scalars), and ctx.saved_tensors retrieves them in backward. To stash non-tensor values, assign them as attributes (ctx.alpha = alpha).

Example: Numerically Stable Sigmoid with Custom Backward

The naive sigmoid \(\sigma(x) = 1/(1+e^{-x})\) can overflow for large negative \(x\) (exp of large positive becomes inf) or lose precision for large positive \(x\). The stable version clips large magnitudes and uses torch.sigmoid in practice, but here we implement it from scratch with a custom backward to illustrate the interface:

import torch
from torch.autograd import Function


class StableSigmoid(Function):
    """
    Sigmoid with a numerically stable forward and analytic backward.
    We store only the output (not input) for memory efficiency,
    since d_sigma/dx = sigma(x) * (1 - sigma(x)).
    """

    @staticmethod
    def forward(ctx, x: torch.Tensor) -> torch.Tensor:
        # Use PyTorch's stable sigmoid internally
        y = torch.sigmoid(x)
        # Save output, not input -- saves memory for large activations
        ctx.save_for_backward(y)
        return y

    @staticmethod
    def backward(ctx, grad_output: torch.Tensor) -> torch.Tensor:
        (y,) = ctx.saved_tensors
        # Jacobian of sigmoid: dy/dx = y * (1 - y)
        # VJP: grad_input = grad_output * dy/dx  (element-wise for elt-wise ops)
        grad_input = grad_output * y * (1.0 - y)
        return grad_input


# Register as a callable
stable_sigmoid = StableSigmoid.apply


# --- Test correctness against autograd ---
torch.manual_seed(0)
x = torch.randn(4, requires_grad=True)
x_ref = x.detach().clone().requires_grad_(True)

y = stable_sigmoid(x)
y_ref = torch.sigmoid(x_ref)

# Forward agreement
assert torch.allclose(y, y_ref, atol=1e-6), "Forward mismatch"

# Backward agreement via gradient checking
y.sum().backward()
y_ref.sum().backward()
assert torch.allclose(x.grad, x_ref.grad, atol=1e-6), "Backward mismatch"

print("x      :", x.detach().numpy().round(4))
print("sigma  :", y.detach().numpy().round(4))
print("grad   :", x.grad.numpy().round(4))
# x      : [ 1.5410 -0.2934 -2.1788  0.5684]
# sigma  : [0.8238 0.4271 0.1017 0.6387]
# grad   : [0.1449 0.2446 0.0912 0.2307]  -- sigma*(1-sigma)

Example: Straight-Through Estimator (STE)

The STE is a classic trick used in quantization-aware training (QAT) and binary neural networks. The forward pass applies a non-differentiable rounding operation; the backward pass pretends the function was the identity, passing the upstream gradient through unchanged:

class StraightThroughRound(Function):
    """
    Forward:  y = round(x)   (non-differentiable: gradient is 0 a.e.)
    Backward: dy/dx = 1      (identity straight-through estimator)
    """

    @staticmethod
    def forward(ctx, x: torch.Tensor) -> torch.Tensor:
        return x.round()

    @staticmethod
    def backward(ctx, grad_output: torch.Tensor) -> torch.Tensor:
        # Pass gradient through unchanged -- the "straight-through" trick
        return grad_output


ste_round = StraightThroughRound.apply

x = torch.tensor([0.3, 1.7, -0.6], requires_grad=True)
y = ste_round(x)
print(y)           # tensor([ 0.,  2., -1.])
y.sum().backward()
print(x.grad)      # tensor([1., 1., 1.])  -- as if dy/dx = 1 everywhere

gradcheck: Numerically Verifying Custom Backwards

PyTorch provides torch.autograd.gradcheck to compare your custom backward against finite differences:

from torch.autograd import gradcheck

# Use double precision for numerical stability of finite differences
x_check = torch.randn(3, dtype=torch.float64, requires_grad=True)
result = gradcheck(stable_sigmoid, (x_check,), eps=1e-6, atol=1e-4)
print(f"gradcheck passed: {result}")  # True

Always run gradcheck on new Function implementations — it catches sign errors, missing factors, and wrongly accumulated terms.

When autograd.Function Is Not Enough: torch.library

autograd.Function is the right tool for eager-mode research code, and it is how FlashAttention and most Triton kernels expose a differentiable Python surface. It has one important limitation in the 2026 stack: TorchDynamo cannot see through an arbitrary Function whose forward calls opaque code (a raw CUDA launch, a @triton.jit kernel), so dropping one into a torch.compiled model risks a graph break that costs you the surrounding fusions. The supported alternative is to register the operation as a real PyTorch operator with torch.library.custom_op (or torch.library.triton_op for Triton) and attach its gradient with torch.library.register_autograd — then the op is visible to torch.compile, torch.export, and the dispatcher like any ATen op. Writing GPU Kernels with Triton and CUDA Programming Essentials for ML Engineers walk through both registrations end to end.

Two smaller modernizations of the interface are worth knowing. PyTorch 2.x supports splitting forward(ctx, ...) into a ctx-free forward(...) plus a separate setup_context(ctx, inputs, output) staticmethod; this separation is what lets the same Function be transformed by torch.func (set generate_vmap_rule = True, or supply an explicit vmap staticmethod, to make it vmap-compatible). And ctx.needs_input_grad — a tuple of booleans, one per forward input — lets backward skip computing gradients nobody asked for, returning None instead; on a fused kernel that can halve the backward cost.


Worked Numerical Example: Forward + Backward Trace

Tracing autograd for a 2-layer network

Consider a tiny two-layer network with no bias, scalar output, and ReLU:

\[ z_1 = W_1 x, \quad h = \text{ReLU}(z_1), \quad z_2 = w_2^\top h, \quad L = \tfrac{1}{2}z_2^2 \]

Let \(x = [1, 0]^\top\), \(W_1 = \begin{bmatrix}2 & 1\\ -1 & 3\end{bmatrix}\), \(w_2 = [0.5, 0.5]^\top\).

Forward pass:

\[ z_1 = \begin{bmatrix}2\cdot1+1\cdot0\\ -1\cdot1+3\cdot0\end{bmatrix} = \begin{bmatrix}2\\ -1\end{bmatrix} \]
\[ h = \text{ReLU}(z_1) = \begin{bmatrix}2\\ 0\end{bmatrix} \]
\[ z_2 = 0.5 \cdot 2 + 0.5 \cdot 0 = 1.0 \]
\[ L = \tfrac{1}{2}(1.0)^2 = 0.5 \]

Backward pass (VJPs):

\(\bar{z}_2 = dL/dz_2 = z_2 = 1.0\)

\(\bar{w}_2 = \bar{z}_2 \cdot h = 1.0 \cdot [2, 0]^\top = [2, 0]^\top\)

\(\bar{h} = \bar{z}_2 \cdot w_2 = 1.0 \cdot [0.5, 0.5]^\top = [0.5, 0.5]^\top\)

ReLU backward: mask where \(z_1 > 0\) is \([1, 0]\), so: \(\bar{z}_1 = \bar{h} \odot \mathbb{1}[z_1 > 0] = [0.5, 0.5]^\top \odot [1, 0]^\top = [0.5, 0]^\top\)

\(\bar{W}_1 = \bar{z}_1 x^\top = [0.5, 0]^\top [1, 0] = \begin{bmatrix}0.5 & 0\\ 0 & 0\end{bmatrix}\)

\(\bar{x} = W_1^\top \bar{z}_1 = \begin{bmatrix}2 & -1\\ 1 & 3\end{bmatrix} \begin{bmatrix}0.5\\ 0\end{bmatrix} = \begin{bmatrix}1\\ 0.5\end{bmatrix}\)

Let us verify with PyTorch:

import torch

x  = torch.tensor([[1.0], [0.0]])               # (2,1)
W1 = torch.tensor([[2., 1.], [-1., 3.]], requires_grad=True)
w2 = torch.tensor([[0.5], [0.5]], requires_grad=True)

z1 = W1 @ x
h  = torch.relu(z1)
z2 = (w2.T @ h).squeeze()
L  = 0.5 * z2 ** 2

L.backward()

print("W1.grad:\n", W1.grad)
# [[0.5, 0.],
#  [0. , 0.]]
print("w2.grad:\n", w2.grad)
# [[2.],
#  [0.]]

The numbers match our hand trace exactly. The ReLU gate for the second neuron (\(z_1[1] = -1 < 0\)) is closed, so no gradient flows through it.

Backprop on a computation graph
One neuron, one squared error: p = w·x, s = p + b, h = act(s), e = h − y, L = e². Set the leaves, pick an activation, then walk the gradient backward one edge at a time. Each edge carries a local derivative; the chain rule is just the running product along the path.
Backward trace — one multiply per edge
Gradient check
leafclosed formvaluebackpropfinite diff.
Takeaway. Backprop never writes down a global formula for dL/dw. It seeds dL/dL = 1 at the loss and then, at every edge, multiplies the gradient arriving from downstream by that edge's local derivative — the only thing each node has to know. Because every leaf here is reached by exactly one path, the products collapse to dL/dw = 2e a′(s) x, dL/dx = 2e a′(s) w, dL/db = 2e a′(s), dL/dy = −2e; when a node feeds several consumers the arriving gradients are summed instead, which is exactly why PyTorch accumulates into .grad and why you must call zero_grad(). Choose ReLU and push s below zero to watch the gate close: a′(s) = 0 annihilates every gradient upstream of it, while dL/dy — which never crosses that edge — survives. The finite-difference column is the same check torch.autograd.gradcheck runs.

The PyTorch Dispatcher and ATen

Understanding what happens below autograd helps when you need to write custom C++ extensions, debug shape mismatches, or understand torch.compile’s transformations.

Layers of the PyTorch Stack

Python user code e.g. torch.mm(a, b) calls torch.mm(a, b) Python dispatch layer torch/_C/_VariableFunctions.pyi stubs Autograd layer Variable / Tensor with grad_fn records op for backward; strips Variable wrapper Dispatcher (c10::Dispatcher) routing table selects backend by dispatch key (CPU, CUDA, XLA, …) ATen (A Tensor Library) the "kernel" layer — e.g. at::mm_cpu / at::mm_cuda Actual computation Eigen, cuBLAS, cuDNN, custom kernels … Python-facing C++ / native
The six-layer PyTorch software stack for a single operator call. A Python call descends through the dispatch stub layer, the Autograd layer (orange tint, where grad_fn is attached), and the Dispatcher (blue, the routing hub) before reaching ATen and the hardware library. The clean Dispatcher boundary is what lets torch.compile, vmap, and custom hardware backends plug in without touching user code.

The dispatcher is a routing table. Every operation is registered under one or more “dispatch keys” (tags attached to tensors based on device/dtype/layout). When you call torch.mm(a, b), the dispatcher inspects the keys of a and b and calls the appropriate backend implementation. This architecture enables:

  • Backend extensibility: XLA, MPS, custom hardware backends plug in without touching existing code.
  • Transforms: torch.compile, vmap, grad (functorch) all work by inserting themselves at a dispatch key layer, intercepting operations.
  • Operator overriding: You can register a custom kernel for a specific (op, backend) pair.

The crucial thing to internalize is that autograd is itself a dispatch key, not a special case. Keys are ordered, and a call falls through them from highest to lowest priority. Autograd sits above the backend keys: its kernel for mm allocates the MmBackward0 node, wires up next_functions, and then redispatches the same call to the next key down (CUDA, CPU, …) to actually compute the numbers. That is why no_grad is cheap — it simply excludes the Autograd key from the dispatch set, so the call lands straight on the backend kernel with no node allocated.

The same layering explains mixed precision. torch.autocast inserts an Autocast key above Autograd, whose kernel for each listed op casts the operands to bf16/fp16 before redispatching. Because the cast happens above autograd, the tensors the graph saves are the cast (bf16) ones — which is exactly why autocast reduces activation memory, and also why an unsafe op left on autocast’s fp32 list still saves fp32 activations. See Mixed Precision, bf16 & FP8 Training.

ATen and the Operator Schema

ATen is PyTorch’s C++ tensor library. Every operation in PyTorch ultimately maps to an ATen operator, defined with a schema string like:

mm(Tensor self, Tensor mat2) -> Tensor

The schema specifies input/output types and is used by the dispatcher, torch.fx tracing, ONNX export, and the JIT compiler. You can browse all ~2000 ATen operators at torch/_C/_VariableFunctions.pyi or via torch._C._VariableFunctions.__dir__().


Views, Copies, Contiguity, and Memory Layout

Views vs Copies

Storage -- one contiguous memory buffer (12 elements) 012 345 678 91011 012 345 678 91011 offset(i, j) = base + i*s0 + j*s1 views 1-3 below all read THIS buffer -- only shape / stride / offset differ x = arange(12).reshape(3,4) contiguous shape (3,4) stride (4,1) offset 0 reads: all 12 cells 0123 4567 891011 last dim varies fastest row-major: reshape needs no copy here x.T (transpose) non-contiguous shape (4,3) stride (1,4) offset 0 reads: all 12 cells (same buffer) 048 159 2610 3711 strides swapped -- ZERO data moved same 12 cells, walked in a different order x[0:2, :] (slice) contiguous shape (2,4) stride (4,1) offset 0 reads: cells 0-7 only (a sub-window) 0123 4567 a sub-window of the same buffer -- just a narrower shape + same strides broadcast: v.expand(4,3) non-contiguous shape (4,3) stride (0,1) offset 0 v storage -- 3 elements (a separate, smaller buffer): v0v1v2 v0v1v2 v0v1v2 v0v1v2 v0v1v2 4 logical rows, re-reading the same 3 cells stride 0 = re-read the SAME 3 cells; storage stays 3 elements, not 12 .contiguous() is the ONLY one of these four that allocates a fresh buffer and copies -- every other view above still points at the original storage.
One buffer, four ways of reading it. A tensor is just (shape, stride, offset) layered on top of a flat storage buffer: reshape, .T, and slicing move zero data because they only change that metadata, and expand reuses the same elements via a zero stride. Only .contiguous() actually copies.

A view of a tensor shares the same underlying storage (memory buffer). Operations like reshape, view, transpose, narrow, expand, and indexing with slices typically return views:

import torch

x = torch.arange(12).reshape(3, 4)
y = x.T                  # Transpose: a view, not a copy
z = x[0:2, :]            # Slice: a view

print(x.data_ptr() == y.data_ptr())   # True -- same storage
print(x.data_ptr() == z.data_ptr())   # True -- same storage

# Modifying y modifies x
y[0, 0] = 999
print(x[0, 0])  # 999

This is critical for autograd: gradients flow through views correctly because the autograd graph records view relationships. But it also means in-place operations on views can corrupt the computation graph — PyTorch will raise a RuntimeError if you do this during a backward pass.

Memory Layout and Contiguity

A tensor is contiguous if its elements are laid out in row-major (C-style) order: the last dimension varies fastest. Formally, for a tensor with shape \((d_0, d_1, \ldots, d_{n-1})\) and strides \((s_0, s_1, \ldots, s_{n-1})\), contiguity means \(s_k = \prod_{j=k+1}^{n-1} d_j\) for all \(k\).

x = torch.arange(6).reshape(2, 3)
print(x.is_contiguous())   # True
print(x.stride())          # (3, 1)  -- step 3 elements between rows, 1 between cols

y = x.T                    # Transpose
print(y.is_contiguous())   # False
print(y.stride())          # (1, 3)  -- stride order reversed

# Force contiguous copy (creates new storage)
z = y.contiguous()
print(z.is_contiguous())   # True

Why does contiguity matter? Most GPU kernels (and ATen CPU kernels) assume contiguous layout. Operating on non-contiguous tensors either triggers an implicit .contiguous() copy (hurting performance) or requires a strided kernel path. In tight training loops, a silent .contiguous() can add meaningful overhead.

Silent contiguous copies

torch.nn.functional.layer_norm, nn.Conv2d, and many other ops call .contiguous() internally when their input isn’t already contiguous. A common source of unexpected memory traffic is tensor.permute(...) followed by an op that forces contiguity. Check with tensor.is_contiguous() and either permute earlier or fuse permutations.

Strides and Non-standard Layouts

Strides generalize contiguity. A stride-\(s\) tensor accesses element \((i, j)\) at memory offset \(i \cdot s_0 + j \cdot s_1\). This enables:

  • Transposition: swap strides without moving data.
  • Broadcasting: set a stride to 0 to repeat data logically without copying.
  • Slicing: adjust the base pointer and reduce the size along a dimension.
# Broadcasting via zero stride
x = torch.tensor([1.0, 2.0, 3.0])
# Expand to (4, 3) without copying:
y = x.unsqueeze(0).expand(4, 3)
print(y.stride())        # (0, 1) -- stride-0 in the batch dimension
print(y.is_contiguous()) # False
# Storage is untouched: 3 floats = 12 bytes, even though the view "has" 12 elements.
print(y.untyped_storage().nbytes())   # 12  (.storage() is the deprecated spelling)

Broadcasting Semantics

NumPy-style broadcasting aligns shapes from the right and stretches size-1 dimensions:

\[ (B, 1, H, W) + (C, H, W) \to (B, C, H, W) \]

PyTorch implements this via the stride-0 trick above: a size-1 dimension that gets broadcast is assigned stride 0. No data is copied. However, when autograd differentiates through a broadcast, the backward must sum the gradient over the broadcast dimensions to match the original tensor’s shape. This is done automatically by torch.Tensor.expand’s backward and by SumBackward.

a = torch.ones(3, 1, requires_grad=True)   # shape (3, 1)
b = torch.ones(3, 4)                       # shape (3, 4), no grad

c = a + b   # broadcasts a to (3, 4)
c.sum().backward()

# grad of 'a' sums over the broadcast dim:
print(a.grad)   # tensor([[4.], [4.], [4.]])  -- sum over 4 columns

Interview Corner and Practical Patterns

Interview Corner

Q: Walk me through what happens when you call loss.backward() in PyTorch. What data structures are involved, and what does the engine actually execute?

A: When you called forward operations on tensors with requires_grad=True, PyTorch built a DAG of Function nodes connected via next_functions pointers, with each node holding a forward closure and a backward implementation. loss.backward() seeds the process by setting the gradient of loss to 1.0, then it calls torch.autograd.Engine, which runs a topological sort of the DAG and processes nodes in reverse order using a thread pool. At each node it calls the node’s backward() method, passing in the accumulated upstream gradient, and receives gradients for the node’s inputs, which it accumulates into those tensors’ .grad fields (for leaves) or pushes onto the work queue (for non-leaves). The key implementation detail is that gradients are accumulated (added), not assigned, which is what allows gradient accumulation across micro-batches. After the traversal completes, leaf tensors with requires_grad=True hold the full gradient in .grad. Non-leaf gradients are discarded unless you called retain_grad() on them. The graph itself is freed after backward() by default (retain_graph=False), releasing the stored intermediate activations.

Practitioner tip: retain_graph for multi-task losses

If you need to call backward() multiple times on the same graph (e.g., computing separate gradients for a shared encoder with two losses applied sequentially), use loss1.backward(retain_graph=True) for all but the last call. Without retain_graph=True, the graph is freed after the first backward() and subsequent calls raise RuntimeError: Trying to backward through the graph a second time.

Gradient Checkpointing (Activation Recomputation)

For very deep networks or long transformer sequences, storing all activations for the backward pass dominates memory. torch.utils.checkpoint.checkpoint trades compute for memory by recomputing activations during the backward pass instead of storing them:

from torch.utils.checkpoint import checkpoint

def block_forward(x, layer):
    return layer(x)

# During backward, the forward of this segment will be re-run
x_out = checkpoint(block_forward, x_in, layer, use_reentrant=False)

Mechanically, checkpoint runs the segment’s forward under no_grad (so no tape is built inside it) and installs a single Function node that, when backward reaches it, re-runs the forward with grad enabled to rebuild exactly the saved tensors it needs. The tape therefore keeps only the segment boundaries instead of every intermediate — for a transformer block that turns the ~0.6 GiB of MLP activations counted earlier into a single block-input tensor — at the cost of roughly one extra forward pass of compute per checkpointed segment (on the order of 30% added step time when every block is checkpointed). It is standard practice in LLM pretraining. See Memory-Efficient Training: Checkpointing, Offloading & LoRA Math for the full analysis, including per-operator selective checkpointing, and The Pretraining Run: A Complete Single-GPU Training Loop for the capstone’s decision about when a 100M model actually needs it.

Second-Order Gradients and create_graph

PyTorch can differentiate through the backward pass itself by passing create_graph=True to backward() or using torch.autograd.grad. This is used for MAML (model-agnostic meta-learning), Hessian-vector products, and implicit differentiation:

x = torch.tensor(3.0, requires_grad=True)
y = x ** 3         # y = x^3, dy/dx = 3x^2

# First-order gradient
(grad_x,) = torch.autograd.grad(y, x, create_graph=True)
print(grad_x)   # tensor(27.)  -- 3 * 3^2

# Second-order gradient (differentiates grad_x w.r.t. x)
(grad2_x,) = torch.autograd.grad(grad_x, x)
print(grad2_x)  # tensor(18.)  -- 6x = 6*3

When create_graph=True, the autograd graph for the backward computation is itself tracked, enabling higher-order differentiation. This doubles (or more) the memory cost.

torch.func (functorch): Functional Transforms

PyTorch’s torch.func module (formerly functorch) exposes composable functional transforms over arbitrary PyTorch functions:

from torch.func import grad, vmap, jacrev

# Compute gradient of a scalar function
f = lambda x: (x ** 2).sum()
grad_f = grad(f)
print(grad_f(torch.tensor([1.0, 2.0, 3.0])))  # tensor([2., 4., 6.])

# Batched Jacobian via vmap + jacrev
def model(params, x):
    return params @ x

J = vmap(jacrev(model, argnums=1))(params_batch, x_batch)

These transforms work at the dispatcher level, inserting themselves as dispatch keys. They compose: vmap(grad(f)) gives a batched gradient function. This is the correct modern approach to Hessian-vector products and per-sample gradients (rather than looping or using create_graph).


The Full Stack: From Python Op to GPU Kernel

Let us trace torch.mm(a, b) for two CUDA tensors end to end:

1 Python call torch.mm(a, b) 2 Python binding torch._C._VariableFunctions.mm 3 Autograd layer checks requires_grad; if set, creates MmBackward 4 Dispatcher keys {CUDA, AutogradCUDA}; picks at::mm CUDA 5 ATen CUDA kernel at::mm (CUDA) delegates to cuBLAS gemm 6 cuBLAS optimal tiled GEMM on the GPU (hardware library) 7 Result returned output tensor; grad_fn = MmBackward (if grad needed) MmBackward (registered in ATen) C = A · B A = C Bᵀ B = Aᵀ C each is itself an mm call, so it re-enters the Dispatcher (another cuBLAS GEMM).
torch.mm(a, b) traced end-to-end through the PyTorch stack. The call descends through seven stages — Python call, C++ binding stub, Autograd (where MmBackward is created and a, b are recorded), the Dispatcher (which routes to the CUDA backend), ATen, and finally cuBLAS. On the return path the output tensor carries grad_fn = MmBackward; when backward is later called, that node computes the gradients of A and B — themselves mm calls that re-enter the same Dispatcher path.

The backward MmBackward is registered in ATen’s derivative formulas. For \(C = AB\):

\[ \bar{A} = \bar{C} B^\top, \qquad \bar{B} = A^\top \bar{C} \]

These are themselves mm calls, so they go through the same dispatcher path and are executed as cuBLAS GEMMs.

torch.compile and the Dispatcher

torch.compile (based on TorchDynamo + TorchInductor) traces the Python bytecode, extracts a subgraph as a torch.fx.Graph, applies fusion passes, and emits an optimized kernel. It interacts with the dispatcher by inserting a CompiledFunctionBackend dispatch key. Because the dispatcher is a clean abstraction boundary, torch.compile can replace and fuse sequences of ATen ops without touching user code. See Kernel Fusion, torch.compile, CUDA Graphs & Compilers for details.


Numerical Precision Considerations in Autograd

Autograd inherits the numerical properties of the operations it differentiates. Two important failure modes:

Vanishing/exploding gradients occur when VJPs amplify or attenuate the gradient signal across many layers. ReLU gates reduce flow (any negative pre-activation kills the gradient path), while sigmoid and tanh saturate (their derivatives approach zero). Residual connections, layer norm, and careful initialization (Kaiming, Xavier) combat this. See Neural Networks From Scratch: MLPs & Backprop and Numerical Computing, Floating Point & Precision.

Catastrophic cancellation in backward can occur when forward activations are large and the gradient requires subtracting nearly equal numbers. Log-softmax with cross-entropy is a canonical example: naive implementation computes \(\log(\sum \exp)\) and then subtracts, losing precision. PyTorch’s F.cross_entropy uses the log-sum-exp trick to sidestep this, and the autograd graph for the fused version is more numerically stable than separately differentiating log and softmax.

In-place operations and autograd

In-place ops (those ending in _, like relu_, add_) can corrupt the computation graph because they overwrite a tensor that a backward function holds a reference to. PyTorch tracks version counters and raises a RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation if you trigger this. The rule of thumb: avoid in-place ops on tensors that requires_grad=True. If you need them for memory reasons, ensure they happen outside the part of the graph that needs to be differentiated.

Localizing NaNs in the Backward Pass: set_detect_anomaly

The hardest autograd bug to diagnose is a loss or gradient that becomes NaN or Inf during the backward pass while the forward pass looked perfectly finite — e.g. an operation whose value is well-defined at a point but whose derivative is not. The backward NaN surfaces far from its cause (in optimizer.step(), or as a NaN grad norm in a logging call), so the offending forward op is hidden several layers upstream.

torch.autograd.set_detect_anomaly(True) (or the scoped context manager with torch.autograd.detect_anomaly():) turns on anomaly detection, which does two things: it checks every op’s output for NaN (note: it flags NaN, not Inf), and — crucially — it records the forward-pass Python stack trace for each Function node, so that when a backward computes a NaN it raises a RuntimeError whose traceback points at the exact forward line that created the offending op.

import torch

# A forward that is finite at x = 0 but whose *gradient* is not:
#   d/dx sqrt(x) = 0.5 / sqrt(x)  ->  inf at x = 0; the product rule below then
#   multiplies that inf by x = 0, so the backward pass hits 0 * inf = nan.
# (Plain x.sqrt() alone gives an inf grad, which anomaly mode does NOT flag --
#  it only checks for NaN -- so the product is what triggers the RuntimeError.)
x = torch.zeros(3, requires_grad=True)

# Without anomaly detection you only see a NaN grad, not WHERE it came from:
y = (x * x.sqrt()).sum()   # forward: tensor(0.) -- looks fine
y.backward()
print(x.grad)              # tensor([nan, nan, nan]) -- silent, no traceback

# With anomaly detection, backward raises AT the offending forward op:
x = torch.zeros(3, requires_grad=True)
with torch.autograd.detect_anomaly():
    y = (x * x.sqrt()).sum()  # anomaly mode stores this line's stack trace
    y.backward()
# RuntimeError: Function 'SqrtBackward0' returned nan values in its 0th output.
# The traceback includes:  "y = (x * x.sqrt()).sum()"  <- the forward line to fix

The fix is typically an epsilon or a clamp on the input to the unstable op — e.g. x.clamp_min(1e-12).sqrt(), or adding eps inside the op — after which re-running under detect_anomaly() no longer raises. The same mechanism catches the more common real-world cause in LLM training: a log, sqrt, division, or pow fed a zero or negative value from an upstream overflow. set_detect_anomaly(True) is the global switch you flip once at the top of a debug run; with torch.autograd.detect_anomaly(): scopes it to a single step.

Anomaly detection is debug-only

Enabling anomaly detection stores a Python stack trace for every op in the forward graph and NaN-checks every intermediate, which can slow training by 10x or more and greatly increases memory and host overhead. Never leave it on in a production or full training run — wrap only the single reproducing step, or gate it behind a --debug-anomaly flag, and turn it off (torch.autograd.set_detect_anomaly(False)) once the offending op is found.

In a large pretraining run, reach for this at STEP 3 (“isolate the step”) of the loss-spike playbook in Training Stability, Loss Spikes & Debugging Large Runs: after rolling back to the pre-spike checkpoint and replaying the exact batch, wrapping that single forward+backward in detect_anomaly() pinpoints the op, complementing the forward-hook activation probe used there to find which layer’s activations blew up first.


Key Takeaways

Key Takeaways

  • Reverse-mode autodiff computes exact gradients in \(O(1)\) forward passes by recording a tape of operations and traversing it in reverse, computing vector-Jacobian products (VJPs) at each node.
  • PyTorch builds the computation graph dynamically (define-by-run) during the forward pass; each output tensor has a grad_fn pointing to the Function node that created it, and next_functions edges point toward the leaves.
  • After loss.backward(), only leaf tensors (those created directly with requires_grad=True, typically nn.Parameter) accumulate gradients in .grad; intermediate tensors’ gradients are discarded unless retain_grad() is called. tensor.register_hook intercepts (and can replace) a gradient mid-flight, and register_post_accumulate_grad_hook fires once a leaf’s .grad is final — the hook DDP and FSDP use to overlap gradient communication with the rest of the backward pass.
  • torch.no_grad() and torch.inference_mode() suppress graph construction for inference; inference_mode is stricter and slightly faster. Always use one or the other during eval.
  • Custom autograd.Function subclasses let you inject arbitrary forward/backward logic into the autograd graph; use ctx.save_for_backward for tensors, gradcheck to verify correctness.
  • PyTorch’s dispatcher routes each operation through an ordered set of dispatch keys; Autocast and Autograd are keys layered above the backend key (CPU, CUDA, XLA), each doing its work and redispatching downward — which is why no_grad is free and why autocast makes the graph save bf16 activations. torch.compile, vmap, and grad (torch.func) are dispatcher-level transforms that compose the same way.
  • Views share storage with the original tensor (zero copy); contiguity determines whether kernels can operate without an implicit copy. Non-contiguous tensors frequently cause silent performance regressions.
  • Broadcasting is implemented via stride-0 dimensions; the backward pass of a broadcast automatically sums gradients over the expanded dimensions.
  • The graph’s memory cost is its saved tensors — inspect them via grad_fn._saved_*, prefer backward formulas that read the output rather than the input, and remember that a single micro-batch of a 100M model parks tens of GiB there. Gradient checkpointing (recompute the segment, keep only its boundaries) and saved_tensors_hooks (offload or compress what is saved) are the two levers for shrinking it.

State of the Art & Resources (2026)

Reverse-mode autodiff is a mature and stable discipline; the frontier today lies in composable functional transforms (vmap, jvp, vjp), ahead-of-time graph capture via torch.compile, and second-order methods for meta-learning and physics-based optimization. PyTorch’s dispatcher abstraction continues to absorb new hardware backends and compiler passes without breaking user-facing APIs.

Foundational work

Recent advances (2023–2026)

Open-source & tools

  • pytorch/functorch — the original JAX-like composable transform library (vmap, grad, jacrev) for PyTorch; now archived, having been fully merged into torch.func.
  • pytorch/pytorch torch/csrc/autograd/ — the canonical C++ autograd engine source; engine.cpp and function.h are the fastest path to understanding execution ordering and thread pools.

Go deeper

Further Reading

  • Baydin et al., “Automatic Differentiation in Machine Learning: a Survey” (2018) — the definitive academic survey of all autodiff modes.
  • Paszke et al., “Automatic differentiation in PyTorch” (NIPS 2017 Autodiff Workshop) — the original PyTorch autograd paper.
  • Wengert, “A simple automatic derivative evaluation program” (1964) — the original Wengert list / tape paper.
  • PyTorch documentation, “Extending PyTorch” — official reference for torch.autograd.Function and the dispatcher.
  • Pytorch contributor docs, “PyTorch Dispatcher internals” (E. Yang, PyTorch blog, 2021) — deep dive into the dispatcher architecture.
  • Frostig et al., “Decomposing reverse-mode automatic differentiation” (2021) — the JAX / functorch perspective on composable transforms.
  • PyTorch GitHub, torch/csrc/autograd/ — the C++ engine source; reading engine.cpp and function.h is the fastest way to understand execution ordering and thread pools.

Exercises

1. In the leaf-tensor example, y = (x * w).sum() prints y.is_leaf == False and y.grad_fn is a SumBackward0, while x.is_leaf == True. Explain in your own words what makes a tensor a leaf versus a non-leaf, and predict what y.grad would be after y.backward(). Then describe the one-line change that would make PyTorch populate y.grad.

Solution

A tensor is a leaf if it was created directly by user code rather than as the output of a tracked operation. x and w were built with torch.tensor(..., requires_grad=True), so they are leaves; y is the result of a multiply-then-sum, so it is a non-leaf and carries a grad_fn (SumBackward0) pointing at the Function that produced it.

After y.backward(), y.grad is None. PyTorch only populates .grad for leaf tensors with requires_grad=True; intermediate (non-leaf) gradients are computed transiently during the backward traversal and then discarded to save memory. (Here y is also the scalar we called backward() on, so its “gradient” would just be the seed 1.0, but it is still not retained.)

To keep it, call retain_grad() on the non-leaf before the backward pass:

y = (x * w).sum()
y.retain_grad()      # ask autograd to keep this non-leaf's .grad
y.backward()
print(y.grad)        # tensor(1.)  -- now populated

2. (Quantitative — VJP by hand.) Using the network and numbers from the “Tracing autograd” worked example (\(x=[1,0]^\top\), \(W_1=\begin{bmatrix}2&1\\-1&3\end{bmatrix}\), \(w_2=[0.5,0.5]^\top\), \(L=\tfrac12 z_2^2\)), suppose you change the input to \(x=[1,1]^\top\) and leave \(W_1, w_2\) unchanged. Redo the forward and backward passes by hand and report \(z_1\), \(h\), \(z_2\), \(L\), \(\bar{w}_2\), and \(\bar{W}_1\). Which ReLU gates are open now?

Solution

Forward. With \(x=[1,1]^\top\):

\[ z_1 = \begin{bmatrix}2\cdot1+1\cdot1\\ -1\cdot1+3\cdot1\end{bmatrix} = \begin{bmatrix}3\\ 2\end{bmatrix} \]

Both entries are positive, so both ReLU gates are open:

\[ h = \text{ReLU}(z_1) = \begin{bmatrix}3\\ 2\end{bmatrix}, \qquad z_2 = 0.5\cdot3 + 0.5\cdot2 = 2.5, \qquad L = \tfrac12 (2.5)^2 = 3.125 \]

Backward. Seed \(\bar{z}_2 = dL/dz_2 = z_2 = 2.5\).

\[ \bar{w}_2 = \bar{z}_2\, h = 2.5\cdot[3,2]^\top = [7.5,\ 5.0]^\top \]
\[ \bar{h} = \bar{z}_2\, w_2 = 2.5\cdot[0.5,0.5]^\top = [1.25,\ 1.25]^\top \]

ReLU mask is \([1,1]\) (both open), so \(\bar{z}_1 = \bar{h}\odot\mathbb{1}[z_1>0] = [1.25,\ 1.25]^\top\). Then

\[ \bar{W}_1 = \bar{z}_1 x^\top = \begin{bmatrix}1.25\\ 1.25\end{bmatrix}\begin{bmatrix}1 & 1\end{bmatrix} = \begin{bmatrix}1.25 & 1.25\\ 1.25 & 1.25\end{bmatrix} \]

Because both gates are now open, gradient flows to every entry of \(W_1\), unlike the original case where the second neuron’s closed gate zeroed out its row.

3. (Conceptual.) In the MyOp/custom-Function section, StableSigmoid.forward calls ctx.save_for_backward(y) on the output y, not the input x, and the comment says this “saves memory.” Explain why saving the output is sufficient here, and give one example of an activation whose backward cannot be written from the output alone.

Solution

The backward of sigmoid needs its local derivative \(d\sigma/dx = \sigma(x)(1-\sigma(x))\). Since \(\sigma(x)\) is the forward output \(y\), that derivative is \(y(1-y)\) — it can be computed entirely from y without ever referencing x. So stashing y is enough, and we avoid keeping a second tensor of the same size around; for large activation maps this halves the memory that op contributes to the graph. The VJP is then the element-wise grad_output * y * (1 - y).

An activation whose backward needs the input is ReLU: \(d\,\text{ReLU}/dx = \mathbb{1}[x>0]\). From the output alone you cannot recover the mask for the boundary — an output of \(0\) is ambiguous (it could come from any \(x\le 0\)), and more generally the gate depends on the sign of the input, not the value of the output. (Tanh, by contrast, is like sigmoid: \(d\tanh/dx = 1 - \tanh^2(x) = 1 - y^2\), computable from the output.)

4. (Conceptual — broadcasting backward.) In the broadcasting example, a has shape (3, 1), b has shape (3, 4), c = a + b, and a.grad comes out as [[4.], [4.], [4.]] after c.sum().backward(). Explain where the 4 comes from, and predict a.grad if instead a had shape (1, 4) (with b still (3, 4)).

Solution

When a (shape (3,1)) is added to b (shape (3,4)), autograd broadcasts a’s singleton column into 4 logical copies (implemented with stride 0, no data copied). During backward, each of the 4 columns of c receives an upstream gradient of \(1\) from c.sum(). Because those 4 columns all trace back to the same stored element of a, the backward of a broadcast must sum the gradient over the broadcast dimension to match a’s original shape. Summing four \(1\)s per row gives \(4\), so a.grad == [[4.], [4.], [4.]].

If instead a has shape (1, 4), the broadcast now stretches the singleton row into 3 rows. Each column-position element of a is shared across the 3 rows of c, so the backward sums over the row dimension: \(3\) per entry. Thus

a.grad  # tensor([[3., 3., 3., 3.]])  -- shape (1, 4), summed over the 3 rows

5. (Implementation.) Implement a custom autograd.Function called ClampSTE that clamps its input to the range \([lo, hi]\) in the forward pass but uses a straight-through estimator in the backward pass — i.e. the gradient passes through unchanged (as if the clamp were the identity) regardless of whether an element was clipped. Follow the chapter’s Function conventions (static methods, ctx, .apply), remember that lo/hi are non-tensor scalars, and demonstrate on x = [-2.0, 0.5, 3.0] with lo=0.0, hi=1.0 that the forward clamps but every element’s .grad is 1.

Solution

lo and hi are Python scalars, so they must be stashed as attributes on ctx (not via save_for_backward, which is tensor-only). The backward is a pure pass-through of grad_output; the two scalar inputs are non-differentiable, so we return None for them.

import torch
from torch.autograd import Function


class ClampSTE(Function):
    """
    Forward:  y = clamp(x, lo, hi)      (saturates outside [lo, hi])
    Backward: dy/dx = 1 everywhere      (straight-through estimator)
    """

    @staticmethod
    def forward(ctx, x: torch.Tensor, lo: float, hi: float) -> torch.Tensor:
        # scalars -> stash as attributes, not save_for_backward
        ctx.lo = lo
        ctx.hi = hi
        return x.clamp(lo, hi)

    @staticmethod
    def backward(ctx, grad_output: torch.Tensor):
        # pass gradient straight through; lo, hi are non-differentiable -> None
        return grad_output, None, None


clamp_ste = ClampSTE.apply

x = torch.tensor([-2.0, 0.5, 3.0], requires_grad=True)
y = clamp_ste(x, 0.0, 1.0)
print(y)            # tensor([0.0000, 0.5000, 1.0000])  -- clamped
y.sum().backward()
print(x.grad)       # tensor([1., 1., 1.])  -- STE: identity gradient

Note the number of returned gradients (three: one per forward input) must match the forward signature. Contrast with torch.clamp, whose true backward zeroes the gradient of clipped elements (returning [0., 1., 0.] here); the STE deliberately ignores the saturation, which is exactly what makes it useful for quantization-aware training.

6. (Debugging — anomaly detection.) A colleague reports that their forward pass is finite but x.grad comes out NaN, using code of the form y = (x * x.sqrt()).sum(); y.backward() with x containing a zero. Explain, using the chapter’s account of detect_anomaly, (a) why the forward is finite but the backward is NaN, (b) why anomaly detection is what surfaces the location, and © a concrete one-line fix.

Solution

(a) Why the backward is NaN. The forward is well-defined at \(x=0\): \(\sqrt{0}=0\) and \(0\cdot0=0\), so y is finite. But the derivative of \(\sqrt{x}\) is \(0.5/\sqrt{x}\), which is \(+\infty\) at \(x=0\). The product rule for \(f(x)=x\sqrt{x}\) gives \(f'(x)=\sqrt{x} + x\cdot\frac{0.5}{\sqrt{x}}\); at \(x=0\) the second term is \(0\cdot\infty\), which floating-point evaluates as 0 * inf = NaN. So the backward produces NaN even though the forward was finite — the classic “value defined, derivative not” trap.

(b) Why anomaly detection surfaces the location. By default the NaN appears silently in x.grad, far from its cause. with torch.autograd.detect_anomaly(): records the forward-pass Python stack trace for each Function node and NaN-checks every op’s output during backward. When SqrtBackward0 returns NaN, it raises a RuntimeError whose traceback points at the exact forward line (y = (x * x.sqrt()).sum()) that created the offending op. (Note it flags NaN specifically, not Inf — a bare x.sqrt() giving an inf grad would not trip it; the 0 * inf = NaN product is what raises.)

© A one-line fix. Add an epsilon/clamp to the input of the unstable op so its derivative stays finite:

y = (x * x.clamp_min(1e-12).sqrt()).sum()

After this, re-running under detect_anomaly() no longer raises. Remember anomaly detection is debug-only (it can slow training 10x or more), so wrap only the single reproducing step and turn it off afterward.

7. (Quantitative — saved-tensor accounting.) Under torch.autocast(dtype=torch.bfloat16), a nn.Linear(512, 1408, bias=False) whose master weight is fp32 is applied to an activation of 65,536 tokens. (a) Which tensors does the resulting MmBackward0 node save, and how many bytes is each? (b) How does the answer change if the surrounding block is wrapped in torch.utils.checkpoint.checkpoint(..., use_reentrant=False)?

Solution

(a) The matmul backward is \(\bar{A} = \bar{C}B^\top\) and \(\bar{B} = A^\top \bar{C}\), so the node must save both operands — visible as _saved_self and _saved_mat2.

  • The input activation, shape (65_536, 512) in bf16: \(65{,}536 \times 512 \times 2 = 67{,}108{,}864\) bytes = 64 MiB.
  • The weight, shape (512, 1408). Note the subtlety: autocast inserts a bf16 cast of the fp32 master weight above the autograd key, so what the graph saves is that cast copy, not the master parameter — a genuinely new allocation of \(512 \times 1408 \times 2 = 1{,}441{,}792\) bytes ≈ 1.4 MiB. (Autocast caches the cast within a region, so the same copy is reused by every call in that region.)

The activation dominates by ~46x, which is the general rule at LLM scale: what the tape holds is proportional to tokens, not to parameters.

(b) checkpoint runs the wrapped forward under no_grad, so no MmBackward0 node — and hence no saved tensors — is created at all during the first forward. Only the checkpoint segment’s input is retained. During backward the segment is re-run with grad enabled, the 64 MiB input activation and the 1.4 MiB weight cast are recreated, the local backward runs, and they are freed again immediately. Peak activation memory drops to one boundary tensor per segment in exchange for one extra forward pass of compute.