the pytorch path · 0/12
start the path

the PyTorch path · Autograd · lesson 02 of 4

Who owns .grad

A gradient reaching a leaf gets written into an attribute, and the timing of that write, the object it lands in, and how many times it happens are three separate questions the word accumulate quietly runs together.

the goal For any tensor in a program, say whether a backward pass will populate its .grad and why, name how many times the accumulator runs for a tensor used several times in one forward pass, and predict what a reference you kept to .grad holds after zero_grad and one more step.

mastery work · this chapter0/3
  1. go →
manual items are your word; auto items complete from your streaks, labs, and can-you ticks · stored in your browser only
§ 01

A leaf is a tensor with no grad_fn

The chapter defines a leaf as a tensor you made rather than one an operation returned. The runtime definition is narrower and easier to check: is_leaf is true when grad_fn is None. Anything an op produced under a recording context carries a node, so it is not a leaf, so no accumulator was ever built for it, so there is nothing to write into.

Reading .grad on such a tensor returns None and prints a warning first, and the warning is unusually good. It says the attribute will not be populated, names retain_grad() as the way to change that, and then guesses out loud that you probably meant to read the leaf instead. Half of PyTorch's autograd errors read like this: a statement of what happened, followed by the two things you might have meant.

The warning fires on the read, not on the backward pass, so it can appear long before or long after the pass that would have filled the attribute. That timing is worth knowing when the warning shows up in a log with nothing around it.

the warning verbatim on a non-leaf .grad read (verified, torch 2.2.2 CPU); the trailing internal-location parenthesis is trimmed
UserWarning: The .grad attribute of a Tensor that is not a leaf Tensor is being
accessed. Its .grad attribute won't be populated during autograd.backward(). If
you indeed want the .grad field to be populated for a non-leaf Tensor, use
.retain_grad() on the non-leaf Tensor. If you access the non-leaf Tensor by
mistake, make sure you access the leaf Tensor instead. See
github.com/pytorch/pytorch/pull/30531 for more informations.
§ 02

Retain_grad hangs a mailbox on an intermediate

Call retain_grad() on a non-leaf and the tensor keeps its node, keeps its place in the graph, and gains somewhere for a gradient to land on the way past. Nothing about the walk changes; one extra hook copies the gradient into .grad as it flows through.

The two numbers below are the point of doing it. With h = x * 2 and a loss of (h * h).sum(), the gradient at h is 4 and the gradient at x is 8, because the chain rule multiplies by the 2 that h was built with. Reading only x.grad you would have to divide to recover the middle; reading both, the factor is right there.

The cost is a live tensor per retained intermediate, held until you clear it. That makes retain_grad a debugging instrument rather than something to leave switched on, which is the same trade the next lesson's saved-tensor accounting makes explicit in bytes.

run it (verified, torch 2.2.2 CPU): the gradient at the middle of the chain, and at the leaf
import torch

x = torch.ones(3, requires_grad=True)
h = x * 2
h.retain_grad()
(h * h).sum().backward()
print(h.is_leaf, h.grad_fn.name())   # False MulBackward0
print(h.grad)                        # tensor([4., 4., 4.])
print(x.grad)                        # tensor([8., 8., 8.])
§ 03

The engine sums before it accumulates

Use one leaf twice in a forward pass and the natural picture is two gradients arriving at the accumulator and being added there, one after the other. That picture is wrong, and it is easy to falsify: hang a hook on the AccumulateGrad node and count how many times it runs.

It runs once. For ((x * 2) + (x * 3)).sum() the accumulator is called a single time and the tensor it receives is already 5, which is the sum of the 2 and the 3 that arrived down the two paths. The addition happened in a buffer the engine keeps per node while it waits for every incoming edge to report, not at the leaf.

So the word accumulate covers two different mechanisms with different scopes. Within one backward pass, contributions are summed in that buffer and delivered once. Across backward passes, the accumulator adds into whatever .grad already held, which is the behavior zero_grad exists to undo and the one the chapter's training loop is built around.

run it (verified, torch 2.2.2 CPU): one call, carrying an already-summed gradient
import torch

got = []
x = torch.ones(3, requires_grad=True)
y = ((x * 2) + (x * 3)).sum()
add = y.grad_fn.next_functions[0][0]
acc = add.next_functions[0][0].next_functions[0][0]
acc.register_hook(lambda grad_in, grad_out: got.append(grad_out[0].clone()))
y.backward()
print(len(got), got[0])   # 1 tensor([5., 5., 5.])
print(x.grad)             # tensor([5., 5., 5.])
§ 04

Grad is one tensor, and zero_grad does not zero it

Across steps, the accumulator adds in place into the tensor .grad already points at. Hold a reference to p.grad after one backward pass and it reads 4 after the next one, without you touching it, because it is the same object.

Then opt.zero_grad() runs and the reference stops tracking. set_to_none defaults to True here, and has since PyTorch 2.0, so the call assigns None to .grad rather than filling the existing tensor with zeros, and the tensor you were holding is left behind at its old value. Fewer kernel launches and one less live buffer per parameter, at the price of a name that describes what the method used to do.

Two consequences follow for anyone reading gradients between steps. A logger that captured p.grad once and reuses the handle is reading a tensor the optimizer abandoned. And p.grad is None rather than zero before the first backward pass of every step, so code that assumes a zero tensor is there needs set_to_none=False or a None check.

run it (verified, torch 2.2.2 CPU): the same object across two steps, then dropped rather than zeroed
import torch

p = torch.ones(3, requires_grad=True)
(p * p).sum().backward()
held = p.grad
(p * p).sum().backward()
print(held, p.grad is held)       # tensor([4., 4., 4.]) True

opt = torch.optim.SGD([p], lr=0.0)
opt.zero_grad()                   # set_to_none=True is the default
print(p.grad, held)               # None tensor([4., 4., 4.])
§ 05

The call that skips the accumulator entirely

torch.autograd.grad walks the same graph with the same engine and never reaches an AccumulateGrad node. It takes the outputs, the inputs you want gradients for, and returns a tuple. Nothing is written anywhere; .grad stays exactly as it was, None included.

That makes it the right call for anything that is not a training step. Second derivatives, influence functions, a gradient norm you want to inspect, a per-sample gradient you are about to reduce yourself: none of those want a side effect on the parameters, and all of them are one line with grad and three lines with backward plus a save and a restore.

It is also the call lesson four leans on, because gradcheck is built on it. A checker that accumulated into .grad while checking would corrupt the state of the model it was called on.

run it (verified, torch 2.2.2 CPU): the same number, returned rather than stored
import torch

z = torch.ones(3, requires_grad=True)
print(torch.autograd.grad((z * z).sum(), z))   # (tensor([2., 2., 2.]),)
print(z.grad)                                  # None

(z * z).sum().backward()
print(z.grad)                                  # tensor([2., 2., 2.])
before you move on

Check yourself

01 A leaf is used twice in one forward pass. How many times does its AccumulateGrad node run during one backward pass?

Once. The engine holds an input buffer per node and sums every incoming edge there, then calls the node a single time with the total. Measured on ((x * 2) + (x * 3)).sum(), the accumulator ran once and received 5.

02 You saved a reference to p.grad, then called opt.zero_grad(). What does your reference hold?

The gradient from before the call, unchanged. zero_grad defaults to set_to_none=True, so it assigns None to p.grad rather than writing zeros into the existing tensor, and your reference now points at a tensor the optimizer no longer uses.

03 When should a gradient be taken with torch.autograd.grad instead of backward?

Whenever the gradient is being inspected rather than applied. grad returns a tuple and writes nothing, so .grad on every parameter is left alone; backward accumulates into .grad and would need a save and a restore around it to be side-effect free.

assigned

Readings