Search Tech Journey

Find topics, journeys and posts

back to blog
mlbeginner 120m read

DL S014 · PyTorch Tensors and Autograd

Rewrite the MNIST MLP in PyTorch and understand `.backward()`. Part of the 'Deep Learning & LLMs From Scratch' 80-session self-study series.

🧠SoftwareM03 · PyTorch fluency· Session 014 of 130 120 min

🎯 Rewrite the MNIST MLP in PyTorch and understand `.backward()`.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 14 / 80 · Module M03 · ~2 hours

The story — Soumith's confession, and the graph that builds itself

At NeurIPS 2016, Soumith Chintala walked on stage with a slide that had one word on it: "Autograd". PyTorch had shipped three months earlier. The room was polite. TensorFlow was already the default; every job posting said "experience with TensorFlow". Soumith's pitch was heretical: what if you didn't declare a graph at all — what if the graph built itself, one Python line at a time? The room applauded but nobody quite believed it. Six months later Karpathy tweeted a screenshot of his research code and captioned it "I have switched". By 2019, Facebook, OpenAI, Uber, and half of DeepMind were on PyTorch. By 2024, over 92% of new papers on Papers-with-Code shipped PyTorch first (source: PWC framework survey, Jan 2025). The dynamic graph won.

That's not a marketing story. It's a story about what removes friction from your brain. Static-graph frameworks (TF 1.x, Theano) asked you to first build a symbolic description of your model, compile it, and then feed it data. Debugging meant reading autogenerated C++ stack traces. Print statements didn't work. Dynamic-graph frameworks let you drop print(x) mid-forward-pass and get a number. That is the entire difference. Everything downstream — nanoGPT existing, HuggingFace being HuggingFace, you sitting here reading this — flows from that one design decision.

For thirteen sessions we've been doing something a little masochistic: we wrote a neural network with NumPy. Every matmul was ours. Every gradient — every single dL/dW1, dL/db1, dL/dW2 — we derived on paper and typed by hand. That's how you understand deep learning. But it's not how you do deep learning past about 20 lines of model code.

Here's the number that matters: the MNIST MLP from Session 011 was ~120 lines. A CNN would be ~400. A transformer with all the residual and norm plumbing, done by hand? A few thousand — and every one of them a place where the chain rule can go wrong. Nobody would ship anything.

PyTorch is what we reach for once we've paid the tuition. It gives us two superpowers: (1) tensors that live on the GPU with the same NumPy-ish API you already know, and (2) an autograd engine that records every operation into a computation graph and, on .backward(), walks the graph in reverse and fills in every gradient for you. Session 010 was you being autograd, by hand, on paper. Today, PyTorch does it in one line — and if you understood Session 010, that one line will not feel like magic. It'll feel like relief.

You will be able to
  • Create a PyTorch tensor with `requires_grad=True` and know exactly what that flag turns on.
  • Explain what a *dynamic* computation graph is and how it's built during the forward pass.
  • Call `.backward()` on a scalar loss and read the gradient from `.grad` on every leaf tensor.
  • Rewrite the Session 011 MNIST MLP forward pass in ~15 lines of PyTorch.
  • Debug the classic 'gradient is None' and 'gradient keeps growing' bugs without googling.

Prerequisites

  • Session 010 — you derived backprop for an MLP by hand. Today that becomes .backward().
  • Session 011 — the 120-line NumPy MLP is the thing we're rewriting.
  • Session 013 — tensor-op fluency; the API is 90% the same as NumPy.

A note on which PyTorch you're running

Everything in this session works on PyTorch 2.0+, but the war stories at the end and the torch.compile discussion assume PyTorch 2.5 (Oct 2024) or 2.6 (Jan 2025) — the versions that ship TorchDynamo as default-stable, FlexAttention, and the redesigned memory profiler. Check with torch.__version__; if you're below 2.5, pip install --upgrade torch before starting. Colab defaults to 2.5+ as of early 2025.



1 · A tensor is a NumPy array that remembers

The single most important idea in PyTorch, stated as bluntly as I can:

A PyTorch tensor is a NumPy array that (a) can live on the GPU and (b) can remember every operation performed on it so we can compute derivatives later.

That's it. Everything else — nn.Module, DataLoader, torch.optim, torch.compile, the entire ecosystem — is built on top of this one primitive.

import torch x = torch.tensor([1.0, 2.0, 3.0]) # like np.arrayprint(x.shape) # torch.Size([3])print(x + 1) # tensor([2., 3., 4.])print(x @ x) # tensor(14.) same @ as NumPy

If you closed your eyes you'd think that was NumPy. Good. Now the twist:

x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = (x * x).sum()
y.backward()
print(x.grad)                            # tensor([2., 4., 6.])

We asked for dy/dx and got it. No paper. No chain rule by hand. y = x0² + x1² + x2², so dy/dx = [2x0, 2x1, 2x2] = [2, 4, 6]. Correct.

That's autograd. The rest of this session is unpacking exactly what happened between line 2 and line 3.

A GPS that logs every turn you took
🌍 Real world
💻 Code world

A tiny bit of history: from Wengert lists to Theano to autograd

Reverse-mode automatic differentiation was invented in 1970 by Seppo Linnainmaa (Master's thesis, University of Helsinki) — years before backprop was rediscovered by Rumelhart/Hinton/Williams in 1986. Linnainmaa's key data structure was a Wengert list: a linearised record of every arithmetic operation, walked backwards to compute derivatives via the chain rule. That's exactly what PyTorch's autograd is. Every framework you've heard of — Theano (2007), Torch7 autograd (2015), Chainer (2015, the first dynamic-graph framework in Python and PyTorch's direct spiritual ancestor), TensorFlow eager (2017), JAX (2018) — is a re-implementation of Linnainmaa's Wengert list with better ergonomics. When you write y.backward(), you are running an algorithm from 1970 on a GPU chip from 2024. That's kind of beautiful.

Further reading: Baydin, Pearlmutter, Radul, Siskind — "Automatic Differentiation in Machine Learning: a Survey" (2018, still the definitive intro). Also: PyTorch autograd internals doc by Alban Desmaison — the actual engineer who maintains it.


2 · requires_grad=True — what the flag actually does

Setting requires_grad=True on a tensor tells PyTorch: "any operation that uses this tensor should be recorded, so I can later ask for d(anything)/d(this)". From that moment on, every op involving x builds a node in a hidden data structure called the computation graph.

x = torch.tensor(3.0, requires_grad=True)
y = x ** 2                # y = x²,   also gets grad tracking
z = y + 1                 # z = y + 1
print(z)                  # tensor(10., grad_fn=<AddBackward0>)

Notice the grad_fn=<AddBackward0> in the printout. That's PyTorch saying "I remember that z was made by an Add op — if you ask me for gradients later, I know which backward function to call".

Three vocabulary words to nail
  • Leaf tensor — a tensor you created directly (with requires_grad=True). Its `.grad` gets filled by `.backward()`.
  • Intermediate tensor — a tensor produced by an op on other tensors. It has a `grad_fn` but no `.grad` after backward (unless you call `.retain_grad()`).
  • Scalar loss — the tensor you call `.backward()` on. It MUST be a single number (`.shape == ()`), because we can only differentiate scalars w.r.t. tensors.

2.1 Why must the loss be a scalar?

Because a derivative ∂L/∂w is defined as "how much does one number change when I nudge one weight". If L is a vector or matrix, there's no single number to differentiate. You'd need a Jacobian, and PyTorch (rightly) refuses to build a (len(L), num_params)-shape monster unless you explicitly ask for it.

That's why every training loop ends with loss = criterion(pred, y) (a scalar) followed by loss.backward(). If you accidentally do loss.sum().backward() when loss was already a scalar, no harm; if you do loss.backward() when loss is a vector, you'll see:

RuntimeError: grad can be implicitly created only for scalar outputs

Fix: aggregate first. Usually .mean() (matches typical loss reductions).

Try itWatch `grad_fn` change as you compose ops

Open a Python REPL. Create x = torch.tensor(3.0, requires_grad=True). Compute y = x**2, then z = y.sin(), then w = z + 5, printing each intermediate tensor. Watch grad_fn change: PowBackward0, SinBackward0, AddBackward0. Each intermediate tensor knows exactly which backward function to call. Then run w.backward() and print x.grad. Verify by hand: dw/dx = cos(x**2) * 2x = cos(9) * 6 ≈ -5.47. That two-minute exercise turns "autograd" from mystery to muscle memory.

💡 Hint · Print the tensor itself, not `.grad` — you're looking at `grad_fn`.

3 · The dynamic computation graph, drawn

Let's do the smallest non-trivial example possible: L = (w * x + b - y)², a single linear neuron's squared error.

x = torch.tensor(2.0)                                # input, no grad
y = torch.tensor(5.0)                                # target, no grad
w = torch.tensor(3.0, requires_grad=True)            # leaf
b = torch.tensor(1.0, requires_grad=True)            # leaf
 
pred  = w * x + b                                    # 7.0
err   = pred - y                                     # 2.0
loss  = err ** 2                                     # 4.0
 
loss.backward()
print(w.grad, b.grad)                                # tensor(8.) tensor(4.)

Let's draw the graph PyTorch built:

The forward pass walked left→right, doing arithmetic and appending nodes. On loss.backward(), PyTorch walked right→left through the same graph, using the chain rule at each node:

  • dL/derr = 2·err = 4
  • derr/dpred = 1dL/dpred = 4
  • dpred/dw = x = 2dL/dw = 4·2 = 8
  • dpred/db = 1dL/db = 4·1 = 4

That's exactly the Session 010 chain rule, but PyTorch typed it for you.

The word "dynamic" in "dynamic computation graph": the graph is built on every forward pass. If your model has an if statement that takes a different branch this time, the graph is different this time. Autograd will still work. (Contrast: TensorFlow 1.x used a static graph — you had to declare it up front. Everyone hated it. Everyone.)


4 · Rewriting the MNIST MLP in PyTorch

Time for the payoff. The Session 011 forward pass was ~40 lines. Here it is in PyTorch:

import torch
import torch.nn.functional as F
 
# Parameters — leaves with requires_grad=True
W1 = torch.randn(784, 128, requires_grad=True) * 0.01
b1 = torch.zeros(128, requires_grad=True)
W2 = torch.randn(128, 10, requires_grad=True) * 0.01
b2 = torch.zeros(10, requires_grad=True)

Wait — there's a subtle bug in the code above. torch.randn(...) * 0.01 produces a non-leaf tensor (it's the result of a multiplication, so autograd tracks it as MulBackward). The requires_grad=True is on the operand, not the result you assigned to W1. Calling .backward() will fill gradients into the original torch.randn(...), which you no longer have a reference to. W1.grad will be None.

Correct idiom:

W1 = (torch.randn(784, 128) * 0.01).requires_grad_(True)
b1 = torch.zeros(128, requires_grad=True)
W2 = (torch.randn(128, 10) * 0.01).requires_grad_(True)
b2 = torch.zeros(10, requires_grad=True)

The trailing underscore in requires_grad_ means "in-place". We first do the scaling without grad tracking, then flip the flag on the final tensor, making it a leaf. This is the single most common autograd gotcha for beginners. Burn it in.

Now the forward pass:

def forward(X):
    h1  = F.relu(X @ W1 + b1)         # (batch, 128)
    logits = h1 @ W2 + b2             # (batch, 10)
    return logits

Three lines. Compare to the Session 011 version, which was the same computation but with np.dot, manual ReLU, and hand-typed weight init. Same math. Cleaner code.

Loss and backward:

X = torch.randn(32, 784)
y = torch.randint(0, 10, (32,))
 
logits = forward(X)
loss   = F.cross_entropy(logits, y)   # scalar
loss.backward()
 
print(W1.grad.shape)                  # torch.Size([784, 128])  ✅
print(b1.grad.shape)                  # torch.Size([128])       ✅

We wrote zero backprop code. F.cross_entropy combined softmax + NLL into a numerically stable scalar, and .backward() filled all four .grad fields. Every derivative you painstakingly typed in Session 011? Gone. This is why we're all still employed and not going insane.


5 · The SGD update — and why you need .zero_grad()

We have .grad. Now do the update:

lr = 0.1
with torch.no_grad():                 # don't track THIS math
    W1 -= lr * W1.grad
    b1 -= lr * b1.grad
    W2 -= lr * W2.grad
    b2 -= lr * b2.grad
 
# CRITICAL — zero the gradients before the next step
W1.grad.zero_()
b1.grad.zero_()
W2.grad.zero_()
b2.grad.zero_()

Two things worth staring at.

5.1 torch.no_grad() — turn off recording

The parameter update is not something we want to differentiate through. If we didn't wrap it in no_grad(), PyTorch would build graph nodes for W1 -= lr * W1.grad, and the next forward pass would build on top of them, and by step 100 you'd have a computation graph the length of a novella and OOM the moment you called .backward(). no_grad() says "for this block, don't track". Absolutely required.

5.2 .grad accumulates — you must zero it

This is not a bug. It's a design choice, and once you know why, it makes sense. When .backward() is called, PyTorch adds the new gradients to whatever's already in .grad. This lets you do things like:

  • Split a huge batch into 4 mini-batches, .backward() each one, and the gradients accumulate as if you'd done the whole batch (this is gradient accumulation, and we cover it in Session 024).
  • Compute gradients w.r.t. multiple losses without redoing the forward pass.

But it means: if you don't .zero_() between training steps, gradients from step 1 will still be sitting in .grad when step 2 backprops. Your effective batch size becomes step_count × real_batch_size, and your training will look weird and slowly diverge.

Every PyTorch training loop ends with optimizer.zero_grad() (which loops over parameters and does exactly this). Session 015 will show you torch.optim.SGD, which packages this up nicely.


6 · The full mini-training-loop (raw, no nn.Module yet)

Putting it all together:

lr = 0.1
for step in range(200):
    # 1. FORWARD
    logits = forward(X)
    loss   = F.cross_entropy(logits, y)
 
    # 2. BACKWARD
    loss.backward()
 
    # 3. STEP
    with torch.no_grad():
        for p in [W1, b1, W2, b2]:
            p -= lr * p.grad
            p.grad.zero_()
 
    if step % 20 == 0:
        print(f"step {step:3d}  loss {loss.item():.4f}")

That is a complete supervised-learning training loop. Nine functional lines. In Session 011 the equivalent code was pushing 100. This is the moment the tuition pays off. You wrote every gradient by hand in Session 010 — now you know what's inside .backward(), so it isn't magic. It's just a very efficient version of what you already did.

6.1 What .backward() is doing under the hood

When you call loss.backward(), PyTorch's C++ autograd engine does roughly this:

  1. Walk the graph starting from loss, in reverse topological order.
  2. At each node, call its grad_fn.backward(grad_output) — a hand-written C++ function (one per op: MmBackward, AddBackward, ReluBackward, etc.) that knows the local Jacobian-vector product.
  3. Accumulate the result into each input tensor's .grad (leaves) or pass it further backward (intermediates).
  4. Free saved activations as soon as they're no longer needed (this is why you can only call .backward() once by default — pass retain_graph=True if you truly need to call it again).

The engine is multithreaded by default since PyTorch 1.6: independent branches of the graph run in parallel on CPU. On GPU, all backward ops are queued to the same CUDA stream as the forward, so they overlap naturally with the next forward pass if you're careful. The parallelism is invisible until you're profiling — Session 019 shows how to see it in the Kineto trace.

6.2 Vector-Jacobian products — the actual math autograd computes

Autograd never materialises a full Jacobian. If y = f(x) maps ℝⁿ → ℝᵐ, the Jacobian ∂y/∂x is an m×n matrix — potentially gigantic (imagine m = n = 1B). What autograd computes is a vector-Jacobian product (VJP): given ∂L/∂y (a length-m row vector, called the upstream gradient), it computes ∂L/∂x = (∂L/∂y) · (∂y/∂x) in one pass, giving you a length-n vector.

For most ops this VJP is cheaper than materialising J. Example: matmul Y = X @ W. The Jacobian ∂Y/∂W has shape (mn, np) = O(mn²) entries. The VJP is just dW = X.T @ dY, one matmul, same cost as the forward. This is the whole efficiency trick of reverse-mode AD: backward costs ~2× forward, regardless of parameter count. Forward-mode AD (which JAX also supports via jax.jvp) computes Jacobian-vector products instead — cheaper when you have few inputs and many outputs (rare in DL, common in physics simulation).


7 · .detach(), .item(), and .data — three ways to leave the graph

Three utilities you will use every hour.

7.1 .item() — pull a scalar out to a Python number

loss_val = loss.item()          # <class 'float'>, no tensor, no graph

Only works on scalar tensors. Use it for logging, printing, tqdm postfix. Never use tensors in print(loss) inside a training loop — you'll build up references and leak memory.

7.2 .detach() — new tensor, same data, no graph

logits_for_metrics = logits.detach()   # can compute accuracy, plot, etc.

Detach whenever you're going to use a tensor's values but never backprop through them. E.g., computing accuracy on training predictions — you want the numbers, not the gradient path.

7.3 .data — legacy escape hatch

tensor.data returns a raw tensor pointing at the same memory, with no graph. Don't use this. It's still in the API for historical reasons. Modern code uses .detach() (safer — errors out if you try to backprop through it later).

7.4 .clone() vs .detach() — the confusion table

The four combinations you'll meet in the wild:

CallShares memory?Shares graph?Use when
x (no call)yesyesnormal forward
x.clone()no (copy)yesyou'll modify in-place but want to keep the graph
x.detach()yesnologging, metrics, no-grad math on same values
x.detach().clone()nonoyou want a totally independent snapshot

Common bug: saved = x.detach() then later saved += noise — you just corrupted x. Fix: saved = x.detach().clone().


7.5 · torch.compile — the 2023–2025 story you cannot skip

The biggest change to PyTorch since autograd itself is torch.compile, which shipped in PyTorch 2.0 (March 2023) and became default-stable in 2.5 (Oct 2024). One line:

model = torch.compile(model)          # or: @torch.compile decorator

What it does, in one sentence: it traces your Python forward pass with TorchDynamo (a Python bytecode-level tracer), fuses the resulting graph with TorchInductor (the backend, which lowers to Triton kernels on GPU and OpenMP-vectorised C++ on CPU), and hands you a compiled function that's typically 1.3×–2.5× faster than eager mode on modern GPUs. On the HuggingFace transformers benchmark suite (BERT, GPT-2, T5, ViT), median speedup on A100 is ~1.7× (PyTorch Foundation 2024 benchmarks).

The magic vs. TF 1.x: it's still dynamic. TorchDynamo watches Python bytecode, extracts pure-tensor subgraphs, and falls back to eager mode whenever it hits Python it can't trace (a print, a data-dependent if, a call into a C extension). You never lose eager's debuggability. This design — called "graph breaks" — is what took seven years to figure out.

The rule of thumb for M03–M12: develop and debug in eager, then wrap model = torch.compile(model) for real runs. If you see graph breaks (torch._dynamo.explain(model)(x)), that's where your speedup is leaking.

2025 twist — FlexAttention: In PyTorch 2.5, FlexAttention lets you write custom attention patterns (causal, sliding-window, ALiBi, document masks) as tiny Python functions and get FlashAttention-2-level speed via torch.compile. In 2.6, FlexAttention supports arbitrary block-sparse patterns. We use this heavily in Module M07.


8 · War stories — the autograd bugs that cost me a weekend

War story `.grad` is None and I don't know why

The very first PyTorch code I ever wrote:

W = torch.randn(10, 5) * 0.01
W.requires_grad = True
loss = (W @ x - y).pow(2).sum()
loss.backward()
print(W.grad)             # None. Nothing. Silent failure.

I stared at it for an hour. Reason: torch.randn(10, 5) * 0.01 produces a non-leaf tensor. Then I set .requires_grad = True on the non-leaf, which PyTorch silently ignores for gradient storage (leaves get .grad, non-leaves don't unless you .retain_grad()).

Fix: create the leaf correctly.

W = torch.empty(10, 5).normal_(std=0.01).requires_grad_(True)
# or
W = torch.randn(10, 5, requires_grad=True)
# then scale INSIDE no_grad if you must:
with torch.no_grad():
    W *= 0.01

If .grad is None after backward, it's almost always because your tensor isn't a leaf. Print W.is_leaf when in doubt.

War story The loss that never went down (and was actually going down 100× too fast)

I forgot optimizer.zero_grad() in a research script. My gradients accumulated across every step. By step 50, W.grad was the sum of 50 correct gradients — so my effective learning rate was 50× what I set. The loss exploded to NaN in ~80 steps. I blamed the model. I blamed the data. I changed the learning rate. I changed the initialization. Two hours later, I noticed the missing zero_grad.

Fix (in every training loop, forever):

for step, (xb, yb) in enumerate(loader): optimizer.zero_grad() # ALWAYS FIRST loss = loss_fn(model(xb), yb) loss.backward() optimizer.step()

The order is zero → forward → backward → step. Learn it as one word.

War story The metric that quietly held the whole graph in RAM

I logged the training loss every step like a normal person:

losses = []for xb, yb in loader: ... loss.backward() optimizer.step() losses.append(loss) # the bug

Notice: I appended loss (the tensor with its graph) not loss.item() (a Python float). After 10,000 steps I had 10,000 tensors, each holding a reference to its computation graph, each holding references to activations. RAM went from 4 GB to 60 GB. OOM.

Fix: always .item() when logging.

losses.append(loss.item())

Trivial once you know. Painful when you don't.


9 · The autograd cheat sheet

Things I run in my head every time
  • Leaves get `.grad` — intermediates don't. Check with `.is_leaf`.
  • Loss must be scalar. Aggregate with `.mean()` or `.sum()` first.
  • Wrap parameter updates in `torch.no_grad()`.
  • Zero grads before every backward, or use `optimizer.zero_grad()`.
  • Log with `.item()`. Detach for anything you'll keep around.
  • If `.grad` is None: not a leaf, or you never called `.backward()`, or you called `.backward()` before setting `requires_grad=True`.

10 · Diagram — forward builds, backward walks

The forward pass is the graph construction. There is no separate "compile" step. Every op you run just... appends a node. Then backward() unwinds it.

Common misconception
✗ What most people think

".backward() computes the gradients and I read them from .grad. So if I want gradients twice — say for two different losses — I just call it twice."

✓ What is actually true

.backward() both computes the gradients and frees the graph's saved intermediates as it walks. The second call finds the buffers gone and raises. And where it does not raise, the gradients have accumulated rather than been replaced. Two different failure modes from the same misunderstanding: the graph is consumed by traversal, and .grad is a running total.

Why the myth is so sticky

Because freeing on traversal is invisible in the loop you actually write. Zero, forward, backward, step — the graph is rebuilt from scratch every iteration, so it never matters that the old one was destroyed. The design is also the right default: those saved activations are usually the largest thing in memory, and holding them past their single use would mean every training step leaked until OOM. So the framework optimises for the overwhelmingly common case and puts the multi-backward case behind an explicit retain_graph=True — which then reads like a magic incantation rather than what it is, an instruction to keep paying for memory you are about to reuse.

Prove it to yourself

Both failure modes in one script:

import torch
x = torch.tensor(2.0, requires_grad=True)
y = x ** 3
y.backward()
print(x.grad)                 # 12.0  = 3x^2
try:
    y.backward()              # graph already freed
except RuntimeError as e:
    print("raised:", str(e)[:60])

x.grad = None
y = x ** 3
y.backward(retain_graph=True)
y.backward()                  # allowed now...
print(x.grad)                 # 24.0 -- ACCUMULATED, not recomputed
From first principles
Start with the question

Why does an in-place operation on a tensor that requires grad sometimes raise, and sometimes silently work — and why does PyTorch need a version counter at all?

  1. 1
    The backward of an op needs specific forward values. y = x * w needs both x and w; y = exp(x) needs only its output; y = x + w needs neither.
    forced by · each backward formula references whichever forward quantities its derivative happens to contain
  2. 2
    Autograd therefore saves only what each op will need, holding references to those tensors on the tape until backward runs.
    forced by · saving everything would multiply memory for no benefit, so the engine saves the minimum per op
  3. 3
    An in-place edit mutates the underlying buffer without creating a new tensor. Any saved reference to it now points at different numbers than the ones the forward pass computed.
    forced by · the tape stores a reference, not a snapshot — copying on save would defeat the whole point of saving instead of recomputing
  4. 4
    The engine cannot detect this by comparing values, since it has no copy of the originals. So each tensor carries a version counter, bumped on every in-place edit, and each save records the version it saw.
    forced by · a monotonic counter is the only O(1) way to detect "this changed since I looked" without storing the data
  5. 5
    At backward, a mismatch between the recorded and current version means the saved value is stale, and the engine raises rather than compute a wrong gradient. If no op saved that tensor, no version was recorded and the same in-place edit is perfectly safe.
    forced by · correctness only depends on the values a backward formula actually reads
⇒ Therefore

Therefore in-place safety is not a property of the operation you performed but of whether some downstream backward needs the value you overwrote. That is why the same += is fine in one model and fatal in another.

And note the prediction: ops whose backward needs no forward value should tolerate in-place edits freely, while ops that save their input should not. Verify it — x + w followed by an in-place edit of x should backward cleanly, whereas x * w followed by the same edit should raise about a version mismatch. It also predicts the safe idiom under torch.no_grad(): parameter updates are in-place edits of leaf tensors, and they are safe precisely because the graph that saved the old parameter values has already been consumed by backward() before step() runs.

Mental modelA tensor with a receipt

A PyTorch tensor is a NumPy array carrying a receipt: grad_fn, which records which operation produced it and which tensors it came from. Follow the receipts backwards from any value and you reconstruct its entire history. Leaves — parameters and inputs — have no receipt; they are where the chain terminates and where .grad gets deposited.

Everything confusing about autograd is a question about receipts. .detach() hands you the same numbers with the receipt torn off. no_grad() stops issuing receipts for a while. "Backward through the graph a second time" means the receipts were shredded after their one use.

  • Leaf tensors receive .grad; non-leaves do not (their gradients are computed and discarded unless you call retain_grad()).
  • .grad accumulates. zero_grad() is not hygiene, it is part of the algorithm — and its absence is a slow-training bug, not a crash.
  • Optimizer updates run under no_grad() because the update itself must not become part of next step's graph.
  • .item() / .detach() exit the graph. Keeping a raw loss tensor in a list keeps its whole graph alive — the most common "memory grows every epoch" bug.
🔔 Fires when you see

Fire this model the moment you see: RuntimeError: Trying to backward through the graph a second time · element 0 of tensors does not require grad · a version-counter error about an in-place op · .grad that is None · memory climbing across epochs · retain_graph=True in code you did not write · a loss appended to a Python list.

The tradeoff

You need a tensor's values without its gradient history. .detach(), a no_grad() block, or .item()?

.detach()
+ you gain returns a tensor sharing the same storage with no graph attached — zero copy, stays on device, still usable in further tensor math; the right tool for stopping a gradient mid-graph (target networks, stop-gradient terms, teacher outputs in distillation)
− you pay shares memory with the original, so an in-place edit through the detached view still mutates the source and can trip the version counter downstream; and it detaches only that one tensor, not the surrounding computation
pick when you need the values as a tensor for more computation, and you specifically want gradients to stop at this point
torch.no_grad() block
+ you gain suppresses graph construction for everything inside, so no intermediates are saved at all — a real memory and speed win over detaching after the fact; the correct wrapper for evaluation loops and optimizer updates
− you pay it is a scope, not a value, so it is easy to leave a needed computation inside it by accident and then wonder why nothing trains; and it does not retroactively free anything created outside
pick when an entire region of code needs no gradients — inference, validation, metric computation, parameter updates
.item() / .cpu().numpy()
+ you gain leaves the tensor world completely, so nothing can keep the graph alive; the only safe thing to put in a logging list or a running average
− you pay forces a device-to-host synchronisation, which stalls the pipeline — calling it every step in a tight loop is a measurable throughput cost, and calling it inside an inner loop is worse
pick when the value is leaving the computation for logging, a progress bar, or an early-stopping check — and ideally batched or done every N steps rather than every step
What a senior engineer actually does

Use the largest scope that fits: no_grad() for whole regions, detach() for a single edge you want cut, .item() only at the boundary where numbers leave the model. Reaching for .detach() everywhere works but still pays to build the graph before discarding it, which is exactly the cost no_grad() avoids.

The bug worth internalising: accumulating total_loss += loss instead of total_loss += loss.item() keeps every graph from every batch alive for the whole epoch. It looks like a memory leak, it is reported as one constantly, and it is one character of fix.



11 · 🧠 Retention scaffold

Quick recall · click to reveal
★ = stretch question

One-line summary (write it in your own words): _______________________________

Spaced review: re-read §3 (the graph drawing) + §6.2 (VJP math) in 24 hours. Revisit the full session on day 7. Redo the §8 war-story diagnoses from memory on day 14.

Next session (S015): nn.Module — the object that packages parameters + forward together, gives you .parameters() iteration for optimizers, .state_dict() for saving, and .train() / .eval() mode switches. Your MLP will shrink from 40 lines to 8 and start looking like a real project.

Sticky note (keep on your desk): zero → forward → backward → step — one word. Everything else is a variation.


Previous: ← DL S013 · Vectorization · Next: DL S015 · nn.Module