the pytorch path · 0/12
start the path

the PyTorch path · Autograd · lesson 01 of 4

The node behind grad_fn

Printing y.grad_fn gives you a name and an address, which is enough to believe the tape exists and not enough to read it. The object behind that repr holds a list of edges, and each edge is a pair.

the goal Given a short program, write out the node list autograd built with the edges between them, say what the second element of every next_functions pair is for, and predict the order the engine will run those nodes in from the numbers the tape assigned during the forward pass.

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

Grad_fn is an object with a type

The chapter above this lesson says y.grad_fn is the tape, which is true and stops one step short of useful. type(y.grad_fn) comes back as SumBackward0, a class generated at build time from the derivative formula table, and the object is an instance of the C++ torch::autograd::Node base exposed to Python. It answers name(), it carries a next_functions tuple, and for most ops it carries the tensors it saved under _saved_ attributes that lesson three takes apart.

Two names for the same node disagree on purpose. The repr prints the short class name, AccumulateGrad, while name() returns the fully qualified torch::autograd::AccumulateGrad, because the leaf accumulator is a hand-written C++ node rather than a generated one. When you are matching a profiler trace against a graph walk, the long name is the one the profiler emitted.

Run the walk on the simplest expression that branches and something shows up that a drawing would get wrong. x * x has two operands, so MulBackward0 holds two edges, and both edges point at the same AccumulateGrad object. Not two accumulators for one tensor. One accumulator, reached twice, whose .variable attribute is x itself.

run it (verified, torch 2.2.2 CPU): one accumulator, reached down two edges
import torch

x = torch.ones(3, requires_grad=True)
y = (x * x).sum()
mul = y.grad_fn.next_functions[0][0]
print(y.grad_fn.name())              # SumBackward0
print(len(mul.next_functions))       # 2
a, b = (n for n, _ in mul.next_functions)
print(a.name())                      # torch::autograd::AccumulateGrad
print(a is b, a.variable is x)       # True True
§ 02

An edge is a node and a slot

next_functions is a tuple of pairs, and both halves matter. The first half is the node this edge leads to, or None when the operand on that side has no gradient to receive. The second half is an integer nobody looks at until they need it, and the table below is the cheapest way to learn what fills each position.

Read the None rows first. Multiplying by a constant produces the same MulBackward0 in both spellings, but the edge for the constant side is (None, 0), so the engine has somewhere to put the operand's slot without having anywhere to send a gradient. That is how autograd represents a dead branch: not by omitting the edge, but by keeping the position and nulling the destination.

The chapter's tape exhibit walks four of these chains node by node, and two of its four entries are the pair worth holding side by side. The one-leaf multiply and the two-leaf matmul both print a node with two AccumulateGrad children, and they are not the same shape underneath. In the multiply, both children are one object; in the matmul, they are two objects with different .variable tensors.

expressiongrad_fnnext_functions
x * xMulBackward0(AccumulateGrad, 0) (AccumulateGrad, 0)
x * cMulBackward0(AccumulateGrad, 0) (None, 0)
c * xMulBackward0(None, 0) (AccumulateGrad, 0)
x + cAddBackward0(AccumulateGrad, 0) (None, 0)
x.exp()ExpBackward0(AccumulateGrad, 0)
(x * x).sum()SumBackward0(MulBackward0, 0)
x.split(1)[1]SplitBackward0(AccumulateGrad, 0)
x.view(3, 1)ViewBackward0(AccumulateGrad, 0)
next_functions for eight expressions, read off the live nodes (verified, torch 2.2.2 CPU); x requires grad, c does not
§ 03

The slot number earns its keep on multi-output ops

Every row in that table has a zero in the slot position, which is what happens when every op you try produces one output. Split a tensor and the zero stops being automatic. x.split(3) returns two tensors that share one SplitBackward0 node, and they are told apart by output_nr: the first is output zero, the second is output one.

Now the pair makes sense. An edge says which node to call and which of that node's outputs this gradient belongs to, because a node with two outputs receives two gradients and has to know which is which. Take the gradient of something built from the second half of the split and the edge reads ('SplitBackward0', 1).

One tensor attribute carries the same number on the forward side. hi.output_nr is 1 before any backward pass runs, so you can read a tensor's position in its producing node without touching the graph at all. It is also the number that appears in the version-counter error the museum wing keeps, in the phrase naming which output of which node was modified.

run it (verified, torch 2.2.2 CPU): one node, two outputs, and the slot that tells them apart
import torch

x = torch.arange(6.0, requires_grad=True)
lo, hi = x.split(3)
print(lo.grad_fn is hi.grad_fn)      # True: one node, two outputs
print(lo.output_nr, hi.output_nr)    # 0 1
edge = (hi * 2).sum().grad_fn.next_functions[0][0].next_functions
print([(n.name() if n else None, k) for n, k in edge])
# [('SplitBackward0', 1), (None, 0)]
§ 04

The tape numbers itself as it is built

Each node gets an integer when it is constructed, from a counter that starts at zero in every thread and only goes up. Four ops in forward order come out zero, one, two, three, which reads like bookkeeping until you notice which node breaks the pattern.

AccumulateGrad is not on the counter. Its number is the largest a 64-bit unsigned integer holds, 18446744073709551615, set by hand rather than drawn. A leaf accumulator is created whenever a leaf is first used, so on the counter it would land wherever that happened to be, and its position on the counter is exactly what the engine sorts by.

You can read the number off any node with _sequence_nr(). The underscore is honest about the stability promise, and the value is worth reading anyway, because it is the same integer the profiler stamps on a backward event when it correlates that event with the forward op that produced it.

run it (verified, torch 2.2.2 CPU): four ops in forward order, then the accumulator that sits outside the counter
import torch

x = torch.ones(3, requires_grad=True)
a, b = x * 2, x * 3
c = a + b
y = c.sum()
for t in (a, b, c, y):
    print(t.grad_fn.name(), t.grad_fn._sequence_nr())
# MulBackward0 0 / MulBackward0 1 / AddBackward0 2 / SumBackward0 3
acc = c.grad_fn.next_functions[0][0].next_functions[0][0]
print(acc.name(), acc._sequence_nr())
# torch::autograd::AccumulateGrad 18446744073709551615
§ 05

And the engine reads those numbers backwards

The comment above sequence_nr() in the C++ header states both jobs the number does, and the first one turns the measurement above into a scheduling rule. Higher runs first, so the op that ran last in the forward pass is the first one the backward pass reaches. Reverse order falls out of a priority queue rather than out of a stored ordering.

The caveat in the middle of that comment is the reason AccumulateGrad sits at the ceiling. Give the leaf accumulator the maximum priority and a gradient that has arrived at a leaf gets written the moment it can be, instead of waiting behind interior nodes that were created later. The buffer holding that gradient is released earlier as a result.

That is one number doing scheduling and profiling at once, and it is worth knowing that the second job is why it exists at all in a profiled build. The rest of this arc stays on the first job: which node runs, what it reads, and what it writes.

verbatim, torch/csrc/autograd/function.h:309-318 at pytorch v2.2.2, the tag this machine has installed
  /// NOTE [ Sequence Number]
  ///
  /// The sequence_nr has two main usages in autograd:
  ///
  /// 1) Helps determine the node's execution priority in the engine.
  ///    All else being equal, nodes with higher priority numbers are executed
  ///    first. Thus, nodes corresponding to ops executed later are the first to
  ///    be executed in the backward pass. One caveat is that we prioritize
  ///    AccumulateGrad nodes by explicitly setting its sequence_nr to be
  ///    UINT64_MAX.
before you move on

Check yourself

01 MulBackward0 for x * x holds two edges. How many AccumulateGrad nodes are on the other end?

One. Both edges point at the same object, which you can check with `is`, and its `.variable` attribute is x itself. Two distinct accumulators appear only when two distinct leaves feed the op, as in a matmul of two parameters.

02 What is the second element of a next_functions pair, and when is it ever not zero?

The output slot of the node the edge leads to. It is zero for every single-output op, and becomes non-zero for ops that produce several outputs from one node, such as split, where the second output is reached by an edge carrying slot 1.

03 Why does AccumulateGrad carry a sequence number of 18446744073709551615?

Because the engine runs higher numbers first, and setting the leaf accumulator to the 64-bit maximum makes a gradient that has reached a leaf get written as early as possible rather than queuing behind interior nodes. The header comment states it as a deliberate exception to the counter.

assigned

Readings