Skip to content

Graphs, DAGs, and an iterative topological sort

ModuleM06.1 · build · Python · Pass 2 · 2 to 3 h
You buildpython/tinyllm/autograd/graph.py: toposort(root, parents), the order in which backward visits a computation graph
Contractcourse/contracts/py/tinyllm/autograd/graph.pyi
Testscourse/tests/M06.1/test_graph.py (what they check: section 4)
Needsnothing to build first. Reading: Discrete Math 1 (sets, relations, induction)
Used byM08.2 scalar reverse-mode autodiff walks this order · later L0.1 (Tensor.backward) and L8.7 (the automata of constrained decoding)
MilestoneMS-P2 (the foundations gate)
Optional depthCormen, Leiserson, Rivest, and Stein, Introduction to Algorithms, sections 20.3 and 20.4 (depth-first search, topological sort); Sedgewick and Wayne, Algorithms, section 4.2; Kahn, “Topological sorting of large networks” (1962)
  • A topological order of a directed graph lists every vertex before the vertices it points to; it exists exactly when the graph has no directed cycle (a DAG) (test_cycle_raises).
  • Backward needs every node before its inputs: a value’s gradient is complete only after every consumer has added its share. The reverse of a depth-first post-order is such an order (test_shared_input_comes_after_every_consumer).
  • Breadth-first order and pre-order look right on small graphs and are wrong on graphs of uneven depth (test_uneven_depth_defeats_breadth_first).
  • One visited mark per node makes the walk linear in the size of the graph instead of exponential in its depth (test_each_node_once_and_parents_called_once).
  • The walk must be iterative and keyed by identity: a 10^5-deep chain breaks a recursive one, and tensors cannot go in a set (test_deep_chain_no_recursion_error, test_nodes_with_elementwise_eq).
Terminal window
ol start M06.1 # stubs python/tinyllm/autograd/graph.py into your repo
ol tests M06.1 # read the test catalog first: rung R0, you write no tests here
ol check M06.1 # exit code is the verdict
ol diff M06.1 # after passing: your code against the reference

Pass 2 builds automatic differentiation: first on scalars (M08.2), then on tensors (L0.1). A loss is computed from the parameters through hundreds of operations, and backward has to push the derivative of the loss back through all of them to every parameter. Each operation can only pass a gradient to its inputs once its own gradient is complete, and a value used twice (a weight shared by every time step, an x in x * y + x) receives contributions from several places. Visit the nodes in the wrong order and a shared weight is updated with half its gradient: the loss goes down a little, then stalls, and nothing crashes. Visit them recursively and the first recurrent model you unroll over 1000 tokens dies with RecursionError. This module defines graphs and DAGs from the beginning and builds the one function that fixes the order: toposort(root, parents).

SymbolMeaningType
G=(V,E)G = (V, E)a directed graph: a set of vertices VV and a set of edges EE
u→vu \to van edge from uu to vv, an element (u,v)∈E(u, v) \in E
n=∣V∣n = \lvert V \rvert, m=∣E∣m = \lvert E \rvertnumber of vertices and of edgesint
rrthe root: the loss, where backward startsa node
parents(v)\mathrm{parents}(v)the values vv was computed from: the edges v→pv \to piterable of nodes
π\pian order of the vertices, a list in which each appears oncelist
π(v)\pi(v)the position of vv in π\piint

A directed graph is a set of vertices VV and a set of ordered pairs E⊆V×VE \subseteq V \times V called edges. The edge (u,v)(u, v) is written u→vu \to v: it leaves uu and enters vv. A path is a sequence v0→v1→⋯→vkv_0 \to v_1 \to \dots \to v_k of edges; a cycle is a path with k≥1k \ge 1 that ends where it started. A vertex vv is reachable from rr when some path leads from rr to vv. In code, a graph is usually stored as adjacency lists: for each vertex, the list of vertices its edges enter. parents is exactly that: a function from a node to the list of its out-neighbours.

A directed acyclic graph (DAG) is a directed graph with no cycle. A topological order is a list π\pi of the vertices in which every edge points forward: u→vu \to v implies π(u)<π(v)\pi(u) < \pi(v).

A graph has a topological order if and only if it is a DAG. If there were a cycle v0→v1→⋯→v0v_0 \to v_1 \to \dots \to v_0, the order would need π(v0)<π(v1)<⋯<π(v0)\pi(v_0) < \pi(v_1) < \dots < \pi(v_0), which is impossible. Conversely, a finite DAG has a vertex with no incoming edge (follow edges backwards from any vertex; without a cycle you must stop, and where you stop nothing points in). Put that vertex first, delete it, and the rest is still a DAG, so by induction on nn it has a topological order too. That proof is also an algorithm (Kahn’s): repeatedly take a vertex with no remaining incoming edge.

2.3 Computation graphs and the backward order

Section titled “2.3 Computation graphs and the backward order”

A computation graph has one node per value. The edges go from each value to the values it was computed from, v→pv \to p for every p∈parents(v)p \in \mathrm{parents}(v): L=m+sL = m + s has edges L→mL \to m and L→sL \to s. The loss r=Lr = L reaches every node that matters.

Backward applies the chain rule. If pp is used by consumers v1,…,vkv_1, \dots, v_k, then

∂L∂p=∑i=1k∂L∂vi∂vi∂p,\frac{\partial L}{\partial p} = \sum_{i=1}^{k} \frac{\partial L}{\partial v_i} \frac{\partial v_i}{\partial p},

so pp may pass its gradient on only after every consumer viv_i has contributed. In edge terms: every edge v→pv \to p needs vv processed before pp. That is precisely a topological order of the graph with edges v→pv \to p, starting at the root. (Seen from the forward computation, where inputs come first, it is the reverse topological order; same thing, opposite arrows.)

Depth-first search (DFS) from rr follows one edge as deep as it can go before trying the next. Each vertex is in one of three states: white (not seen), gray (entered, still exploring its edges, on the stack), black (finished). The post-order lists vertices in the order they turn black.

Claim: in the post-order, every vertex comes after all the vertices it points to. Take an edge v→pv \to p and look at the moment DFS examines it, while vv is gray. If pp is white, DFS enters pp and finishes it before it can finish vv. If pp is black, it already finished. If pp is gray, pp is an ancestor of vv on the current path, so there is a path p→⋯→vp \to \dots \to v and with the edge v→pv \to p a cycle: in a DAG this cannot happen, and meeting a gray vertex is exactly how DFS detects a cycle. In both remaining cases pp turns black before vv.

Reversing the post-order therefore puts every vertex before the vertices it points to: the root first, each node before its parents. Every vertex is entered once (the colour test), and each edge is examined once, so the whole walk costs O(n+m)O(n + m). Without the black state, a vertex reached along two paths is explored twice, and a stack of dd diamonds (each node with two parents that share one parent below) has 2d2^d paths.

Recursion keeps the gray path on the call stack, and CPython stops at about 1000 nested calls (sys.getrecursionlimit()). A 1000-step unrolled recurrence is a path 1000 nodes long. The iterative version keeps the path on an explicit list of pairs (node, iterator over its parents):

  1. Push (r, iter(parents(r))) and mark r gray.
  2. Look at the top pair (v, it). Take the next parent p from it: white: mark it gray, push (p, iter(parents(p))), and go back to 2 (descend); gray: a cycle, raise; black: skip it and take the next parent.
  3. When it is exhausted: pop, mark v black, append v to the post-order.

Keeping the iterator on the stack is what makes this the same traversal as the recursive one: when a child finishes, the parent resumes exactly where it left off. parents(v) is called once per node, when the node is pushed.

Tensors overload == to compare elementwise and return an array, and a Python class that defines __eq__ without __hash__ becomes unhashable. Two different tensors can also hold equal values and still be different nodes. So visited state is a dictionary keyed by id(node), the object’s identity. An id is only unique among objects that are alive; every node we record stays alive because it sits on the stack or in the output list.

A DAG usually has many topological orders. toposort returns one specific order: the reverse post-order of the DFS that visits parents in the order parents lists them. That matters because backward adds contributions in this order, and floating-point addition is not associative: a different valid order changes the last bits of the gradients, and two runs of the same seed stop being bit-identical (P11). Fixing the order here keeps L0.5’s “same seed, same run” promise.

L=(x⋅y)+(x+c)L = (x \cdot y) + (x + c) with intermediate nodes m=x⋅ym = x \cdot y and s=x+cs = x + c:

parents(L) = [m, s] parents(m) = [x, y] parents(s) = [x, c]
parents(x) = parents(y) = parents(c) = []

The iterative DFS, one row per step (stack written bottom to top, with what each iterator has left):

StepTop of stackNext parentActionPost-order so far
1L [m, s]m (white)push m
2m [x, y]x (white)push x
3x []nonepop, emit xx
4m [y]y (white)push yx
5y []nonepop, emit yx, y
6m []nonepop, emit mx, y, m
7L [s]s (white)push sx, y, m
8s [x, c]x (black)skipx, y, m
9s [c]c (white)push cx, y, m
10c []nonepop, emit cx, y, m, c
11s []nonepop, emit sx, y, m, c, s
12L []nonepop, emit Lx, y, m, c, s, L

Reversed: [L, s, c, m, y, x]. Check every edge: L before m and s; m before x and y; s before x and c. This is test_hand_example_exact_order.

Now run backward in that order with x=2x = 2, y=3y = 3, c=1c = 1 (so m=6m = 6, s=3s = 3, L=9L = 9), starting from ∂L/∂L=1\partial L / \partial L = 1:

VisitGradient completePasses on
L1to s: 1, to m: 1
s1to x: 1, to c: 1
c1
m1to x: y=3y = 3, to y: x=2x = 2
y2
x1 + 3 = 4

∂L/∂x=y+1=4\partial L / \partial x = y + 1 = 4: correct. A pre-order (emit on entry) gives [L, m, x, y, s, c]: x is visited right after m, holding only 3, and passes 3 on. That is the bug test_shared_input_comes_after_every_consumer exists for.

def toposort(root: T, parents: Callable[[T], Iterable[T]]) -> list[T]:
"""Every node reachable from root, each once, root first, every node before
each of its parents: the reverse DFS post-order, parents taken in the order
`parents` yields them. Keyed by id(); iterative; parents called once per node.
ValueError when a cycle is reachable from root."""

M08.2 and L0.1 call it as toposort(loss, lambda v: v._parents) and then walk the list, each node adding its local derivatives into its parents’ gradients.

TestKINDChecksWhy it matters downstream
test_hand_example_exact_orderunit, smokesection 3’s order exactlybit-identical gradients across runs
test_shared_input_comes_after_every_consumerpropertyx after both m and scomplete gradients for shared weights
test_uneven_depth_defeats_breadth_firstboundarya parent that is also a grandparent comes lastbreadth-first order is not enough
test_each_node_once_and_parents_called_onceproperty12 stacked diamonds: 37 nodes, parents once eachlinear time on deep networks
test_repeated_parent_appears_onceboundaryx * x lists x twice, the order oncesquares and self-products
test_nodes_with_elementwise_eqboundarynodes whose == raises and whose hash is Nonetensors as nodes in L0.1
test_deep_chain_no_recursion_errorboundarya chain of 100 001 nodeslong unrolled recurrences in L3.1
test_single_nodeboundary, smokea leaf as the roota parameter used directly as the loss
test_cycle_raisesboundaryValueError on a reachable cycleautomata in L8.7
test_random_dags_valid_and_match_graphlib_on_cyclesproperty, differential200 random DAGs valid; with a back edge added, raises exactly when the stdlib graphlib finds a cyclean independent implementation agrees
PitfallSymptomCaught by
a recursive DFSRecursionError once the graph is about 1000 nodes deeptest_deep_chain_no_recursion_error (mutant s01)
emitting a node when it is first entered (pre-order)a shared input passes on a partial gradienttest_shared_input_comes_after_every_consumer (mutant s02)
putting nodes in a set or using them as dict keysTypeError: unhashable type on the first tensortest_nodes_with_elementwise_eq (mutant s03)
no mark for finished nodesduplicates in the order, exponential time on diamondstest_each_node_once_and_parents_called_once (mutant s04)
breadth-first orderinvalid as soon as one input sits at two depthstest_uneven_depth_defeats_breadth_first (mutant s05)
treating a gray node like a black onea cyclic automaton gets a silently wrong ordertest_cycle_raises (mutant s06)
visiting parents in reversestill valid, but a different order and different gradient bitstest_hand_example_exact_order (mutant s07)
DirectionModuleHow it uses this
BackDiscrete Math 1relations, induction, and proof by contradiction (reading)
ForwardM08.2scalar reverse-mode autodiff: backward walks toposort(loss, parents)
ForwardL0.1Tensor.backward does the same over tensors, accumulating gradients in this order
ForwardL8.7regex and JSON-schema automata are graphs; reachability and cycles come from this traversal
ForwardM06.2tries are trees, the simplest DAGs (reading)

If you skip this module, ol check M08.2 stops with BLOCKED ... needs M06.1: build it, or pass --ref-deps.

Your pieceProduction equivalentWhat it addsWhere to look
toposort over parentsPyTorch autograd enginecounts each node’s dependencies first, then runs a ready queue (Kahn’s algorithm) across devices and threadstorch/csrc/autograd/engine.cpp, compute_dependencies
one fixed orderJAXa traced program (a jaxpr) is already a topologically ordered list, so the transpose walks it backwardsjax/_src/interpreters/ad.py, backward_pass
cycle checkPython graphlib.TopologicalSorterKahn’s algorithm with incremental get_ready/done for parallel schedulersCPython Lib/graphlib.py
DAG of tasksworkflow engines (Airflow, Temporal child workflows)the same order schedules jobs whose inputs are other jobs’ outputsyour dur.* modules