How eagle runs a graph#
This page follows a graph builder from the decorator to a replay, so you can see where each piece of eagle lives. None of it is needed to use the builder; it is here for the curious and for anyone who wants to predict what a function will cost.
decorate call the builder build run
rewrite once -> record kernel calls -> CUDA: capture once, -> g(): replay
as nodes (nothing runs) instantiate bind: patch pointers
CPU: a host schedule
Decorating: one rewrite#
@eagle.Graph.builder reads the function’s source and rewrites it once (the result
is kept, so a second call does not parse again). The rewrite turns if, while,
for and match into calls on a recorder; everything else is left alone. The
function is still ordinary Python: you can print inside it, and a for over a Python
list works.
Calling the builder: recording#
Calling the builder runs the rewritten function once, with the recorder active. A kernel
call does not launch; it binds the arguments and appends a node to the graph, and
allocates one array for each output the kernel returns (buffer= supplies or keeps
that array). The value you get back is that array, so the next call reads it, and
that is how eagle finds the edges: a node that reads an array comes after the node that
wrote it, and two nodes that could race are refused.
A condition decides what is recorded:
A Python value (
if debug:,for k in kernels:) is decided once, here, and only the chosen path is recorded.A one-cell
uint32array is a device condition. Both arms are recorded, under a conditional node, and which one runs is decided on the device at every replay.whileand the cappedfor _ in range(N): if not cell: breakbecome a loop node; abreakorcontinuein the body guards the rest of it;matchis a switch.
Buffers. What a graph allocates is what the hand-built version would: one array per kernel output that is not written in place. A name that is rebound inside a device construct and read after it is merged in place into the array it held before, when the kernel is per-sample (it reads and writes only its own sample) and reduces nothing across samples. If that array is one of your arguments, the graph keeps its own copy and copies your array into it at the start of every launch, so your array is never changed. A temporary made inside a loop body is one array, reused each turn.
Forks and wait#
g.parallel() records a fork with one child graph per branch; leaving the with
is the join, and what follows is ordered after all of them. On the GPU each branch is
captured on its own stream. b.wait(x) becomes one ordering edge: an event recorded on the
stream of the node that wrote x, and a wait on that event in the branch that reads it. In
the captured graph that is a plain edge, so it costs nothing per replay. On the CPU the
branches are tasks and a wait is an event the consumer’s task waits on.
Build#
The recorded nodes go into an ordinary eagle.Graph and build runs, as it
does for a hand-made one (the first graph of The graph model: eagle.Graph).
On the GPU the graph is captured once into a CUDA graph and instantiated. A conditional is a CUDA conditional node (IF, IF/ELSE, SWITCH, WHILE); an
elifchain decides its conditions once at the head, into a cell. A driver without IF/ELSE or SWITCH nodes gets the same arms from plain IF nodes, with one extra node for the decision. Nested conditionals are captured on the stream of the body they sit in.On the CPU the nodes become a host schedule: each stage runs its nodes one after another over the thread team, or, for a fork of small kernels, runs the branches as tasks.
Which side is chosen by where the arrays live; g.backend and g.schedule say what was chosen.
Running, bind and what they cost#
g() is launch(1) plus returning result: on the GPU one cudaGraphLaunch and a
wait, on the CPU the closures in order. g(n) is n of those and one wait. Nothing is
parsed, recorded or compiled again.
g.bind(x=new) swaps the arrays by the builder’s parameter names. On the GPU it patches the
pointers in the instantiated graph, one call per node that held the array (microseconds each),
with no capture; if a node cannot be patched (your own code over the array, say), eagle
rebuilds and warns once. On the CPU it re-packs the arguments. The shape, dtype and device
must be the same.
Gradients#
record=True makes the graph keep what a reverse pass needs, and keeps a map from names to
arrays: each builder argument to the array bound to it, result to the arrays of the
result tree, and each buffer= array to its label. eagle.diff.reverse() reads the map
to turn the names in seeds and wrt into arrays, then walks the nodes last to first
running each kernel’s derivative (a fork’s branches reversed together, a loop’s turns
last to first). A name that is not in the map, a plain temporary, has no array that
survives the run, so it is refused with the way out: give it a buffer=.
See also Every form of a graph builder, every form of the builder in one executed page.