Every form of a graph builder#
A tour of what you can write inside @eagle.Graph.builder, one form at a time, each
with a line saying what it does. The first-hour version is in
the graph model; how it all works is in under the hood.
Everything here runs on the CPU; CuPy arrays make the same functions CUDA graphs.
Colab or Kaggle: run this first cell (it installs the packages and downloads the helper files this notebook uses). Elsewhere it only checks the imports.
import warnings
import numpy as np
import eagle
import hawk
from hawk import Mutable, Param, Scalar, UInt32
from hawk import math as hm
@hawk.kernel
def inc(x: Scalar) -> Scalar:
return x + 1.0
@hawk.kernel
def dbl(x: Scalar) -> Scalar:
return 2.0 * x
@hawk.kernel
def add(a: Scalar, b: Scalar) -> Scalar:
return a + b
@hawk.kernel
def square(v: Scalar) -> Scalar:
return v * v
@hawk.kernel
def affine(x: Scalar, a: Param, b: Param) -> Scalar:
return a * x + b
@hawk.kernel
def below(s: Scalar, lim: Param, flag: Mutable[UInt32]): # writes a one-cell guard: 1 while any sample is below lim
flag = hm.uint32(hawk.sum(s < lim))
@hawk.kernel
def over(s: Scalar, lim: Param, flag: Mutable[UInt32]): # writes a one-cell flag: 1 when any sample is above lim
flag = hm.uint32(hawk.sum(s > lim))
@hawk.kernel
def accumulate(s: Scalar, y: Mutable[Scalar]): # writes the caller's array y in place
y = s + 1.0
def cell(v): # a one-cell uint32 array: a device condition
return np.array([v], dtype=np.uint32)
x = np.arange(4.0)
A builder, a block, a block on a hand-made graph#
@eagle.Graph.builder
def twice(g: eagle.Graph, v): # a BLOCK: a parameter annotated eagle.Graph; it appends to that graph
return dbl(dbl(v))
@eagle.Graph.builder
def first(x): # a BUILDER: no Graph parameter; it makes and returns one
g = eagle.Graph() # the graph under construction
g.result = twice(g, inc(x)) # kernel calls become nodes; the block appends two more
return g # a builder returns its graph
g = first(x) # build: records the graph, runs nothing
print(g()) # run: replays it and returns `result`
from eagle.graph import opaque
src, dst = np.arange(4.0), np.zeros(4)
h = eagle.Graph() # a hand-made graph ...
h.add_node(opaque(lambda: np.add(src, 1.0, out=dst), reads={"x": src}, writes={"y": dst}), name="shift")
z = twice(h, dst) # ... that a block extends, after `shift`
h.build().launch()
print(z)
[ 4. 8. 12. 16.]
[ 4. 8. 12. 16.]
Forks, branches, wait#
@eagle.Graph.builder
def forks(x):
g = eagle.Graph()
a = dbl(x)
with g.parallel() as p: # a fork: its branches may run at the same time
with p.branch() as b: # branch b: two nodes in order
l = inc(a)
l2 = dbl(l)
with b.parallel() as q: # a fork inside a branch: open it from the branch handle
with q.branch() as b1:
u = inc(l2)
with q.branch() as b2:
v = dbl(l2)
with p.branch() as w: # branch w ...
w.wait(l) # ... starts after the node that wrote `l`
r = add(l, a)
g.result = (u, v, r) # leaving a `with` joins: what follows waits for all branches
return g
print(forks(x)())
(array([ 3., 7., 11., 15.]), array([ 4., 12., 20., 28.]), array([ 1., 5., 9., 13.]))
if, a Python if, a Python for#
@eagle.Graph.builder
def choose(x, flag, which, debug):
g = eagle.Graph()
if flag: # a one-cell uint32 array: a device condition, read at every replay
x = dbl(x)
elif which: # elif on another cell
x = inc(x)
else: # else
x = add(x, x)
if debug: # a Python value: decided once, at build
x = inc(x)
for k in (inc, dbl): # a Python loop: unrolled at build
x = k(x)
g.result = x
return g
c = cell(1)
g = choose(x, c, cell(1), True)
print(g())
c[0] = 0
print(g()) # same graph, the other arm: the cell is read per replay
[ 4. 8. 12. 16.]
[ 6. 8. 10. 12.]
Loops on the device#
@eagle.Graph.builder
def capped(x, flag):
g = eagle.Graph()
for _ in range(50): # capped: at most 50 turns ...
if not flag: break # ... leaves as soon as the cell reads 0
x = dbl(x)
below(x, 100.0, flag) # the body rewrites the cell
g.result = x
return g
@eagle.Graph.builder
def counted(x, n):
g = eagle.Graph()
for i in range(n): # a counted loop: the cell n is the trip count, read per replay
x = inc(x)
g.result = x
return g
@eagle.Graph.builder
def early_exit(x, flag, done):
g = eagle.Graph()
while flag: # an uncapped loop: it warns (see below)
x = inc(x)
over(x, 2.5, done)
if done: break # mid-body break: skips the rest of the body, leaves the loop
x = dbl(x)
below(x, 50.0, flag)
g.result = x
return g
@eagle.Graph.builder
def skipping(x, flag, skip):
g = eagle.Graph()
while flag:
x = inc(x)
below(x, 6.0, flag)
over(x, 2.5, skip)
if skip: continue # mid-body continue: skips the rest of this turn only
x = dbl(x)
g.result = x
return g
print(capped(np.ones(4), cell(1))())
print(counted(np.zeros(4), cell(3))())
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
print(early_exit(np.zeros(4), cell(1), cell(0))())
print(w[0].message) # the warning an uncapped `while` gives
print(skipping(np.zeros(4), cell(1), cell(0))())
[128. 128. 128. 128.]
[3. 3. 3. 3.]
[3. 3. 3. 3.]
uncapped device loop at early_exit:4: a body that never clears its guard never returns; cap it with for _ in range(N): if not cell: break
[6. 6. 6. 6.]
match#
@eagle.Graph.builder
def chooser(x, sel):
g = eagle.Graph()
match sel: # a one-cell array: one arm runs, read per replay
case 0: # a literal case
x = dbl(x)
case 2:
x = inc(x)
case _: # everything else
x = square(x)
g.result = x
return g
for s in (0, 2, 5):
print(s, chooser(np.full(4, 3.0), cell(s))()[0])
0 6.0
2 4.0
5 9.0
Buffers and results#
@eagle.Graph.builder
def keep(x):
g = eagle.Graph()
mine = np.zeros(4)
a = inc(x, buffer=True) # eagle allocates and keeps `a`
b = dbl(a, buffer=mine) # your array is used for b
g.result = {"a": a, "pair": (b, [square(b)])} # a result tree: dict, tuple, list, nested
return g
print(keep(x)())
@eagle.Graph.builder
def void(x, y): # no result: the outputs are the Mutable arguments
g = eagle.Graph()
accumulate(x, y) # writes the caller's y in place
return g
y = np.zeros(4)
print(void(x, y)(), y) # g() is None; y holds the output
{'a': array([1., 2., 3., 4.]), 'pair': (array([2., 4., 6., 8.]), [array([ 4., 16., 36., 64.])])}
None [1. 2. 3. 4.]
Running#
g = first(x)
print(g()) # one replay; returns result
print(g(10)) # ten replays; the result after the last
g.bind(x=x + 10.0) # new array, same graph, in place
print(g())
g.launch(2) # two replays and wait (what g(n) does, without the return)
g.enqueue(3) # three replays, without waiting ...
g.synchronize() # ... until you ask
print(g.result) # the result arrays, read without a launch
[ 4. 8. 12. 16.]
[ 4. 8. 12. 16.]
[44. 48. 52. 56.]
[44. 48. 52. 56.]
Gradients#
@eagle.Graph.builder
def model(x, y, record=False):
g = eagle.Graph(record=record) # record=True keeps what the reverse needs
a = affine(x, 2.0, 1.0, buffer=True) # a top-level buffer: a name a gradient can use
accumulate(a, y) # y: an argument a kernel writes in place
g.result = square(a)
return g
y = np.zeros(4)
gr = model(x, y, record=True)
gr() # a recorded graph runs with g() or launch(), not enqueue
ones = np.ones(4)
r = eagle.diff.reverse(gr.record, {"result": ones}, wrt=("x", "a")) # seed `result`; wrt an argument and a buffer
print(r["x"], r["a"])
r = eagle.diff.reverse(gr.record, {"y": ones}, wrt=("x",)) # seed an argument a kernel writes
print(r["x"])
r = eagle.diff.reverse(gr.record, {"a": ones}, wrt=("x",)) # seed a buffer
print(r["x"])
[ 4. 12. 20. 28.] [ 2. 6. 10. 14.]
[2. 2. 2. 2.]
[2. 2. 2. 2.]
What it refuses#
Each refusal says what to change. The limits that belong to recorded loops are listed with the gradients tutorial.
def refused(label, f):
try:
f()
except Exception as e:
print(f"{label}:\n {type(e).__name__}: {str(e)[:330]}\n")
def returns_array():
@eagle.Graph.builder
def bad(x):
g = eagle.Graph(); return inc(x)
bad(x)
def top_level_wait():
@eagle.Graph.builder
def bad(x):
g = eagle.Graph(); g.wait(x); return g
bad(x)
def overlap():
@eagle.Graph.builder
def bad(x):
g = eagle.Graph(); a = dbl(x)
with g.parallel() as p:
with p.branch() as b:
u = inc(a)
with p.branch() as c:
v = add(u, a) # reads b's u without waiting
return g
bad(x)
def uncapped_under_record():
@eagle.Graph.builder
def bad(x, flag):
g = eagle.Graph(record=True)
while flag:
x = dbl(x); below(x, 10.0, flag)
return g
bad(np.ones(4), cell(1))
def shape_change():
g = first(x)
g.bind(x=np.arange(5.0))
def no_wrt():
g = model(x, np.zeros(4), record=True); g()
eagle.diff.reverse(g.record, {"result": np.ones(4)})
def temporary_key():
g = model(x, np.zeros(4), record=True); g()
eagle.diff.reverse(g.record, {"result": np.ones(4)}, wrt=("x", "t"))
def enqueue_recorded():
g = model(x, np.zeros(4), record=True); g.enqueue()
for label, f in (("a builder returns arrays", returns_array), ("wait outside a fork", top_level_wait),
("branches touching one array", overlap), ("an uncapped while under record=True", uncapped_under_record),
("bind with another shape", shape_change), ("reverse without wrt", no_wrt),
("wrt a name that is not a key", temporary_key), ("enqueue on a recorded graph", enqueue_recorded)):
refused(label, f)
a builder returns arrays:
TypeError: bad: a builder returns its graph: `return g`; declare the arrays with `g.result = ...`
wait outside a fork:
RuntimeError: eagle.Graph.wait: only inside a parallel region, on a branch handle
branches touching one array:
ValueError: eagle.Graph.build: two branches of one parallel region touch one plane and one writes it: put them in one branch, or order them with <branch>.wait(plane) [eagle.Graph.build: 'u' (node 1) and 'v' (node 2) are siblings of one fork/join group but overlap (RAW): 'u' plane 'out' and 'v' plane 'a' share memory; give each branch its ow
an uncapped while under record=True:
ValueError: bad:4: an uncapped device loop cannot be recorded (record=True): cap it: for _ in range(N): if not cell: break
bind with another shape:
ValueError: eagle.Graph.bind: 'x' changed shape/dtype: call the builder again
reverse without wrt:
ValueError: eagle.diff.reverse: a builder graph has no default gradients: pass wrt=(...) naming the arguments it could take: x, y
wrt a name that is not a key:
KeyError: "eagle.diff.reverse: 't' is not a builder argument, 'result' or a buffer= label: gradients flow through the arrays that enter and leave the graph. For a temporary, give it a dedicated buffer: `t = kernel(..., buffer=True)` (or pass an array), then use that name"
enqueue on a recorded graph:
RuntimeError: eagle.Graph.enqueue: a record=True graph runs with launch(), which waits for the run its Record describes
One graph with everything#
The pieces above, together: a fork with a wait, blocks, a capped device loop, a state seeded from an argument, an
output written in place, a bind, a replay and a gradient. Each cell below builds on the one before it.
The kernels. Toy bodies; the roles are hawk’s.
import numpy as np
import hawk
from hawk import Mutable, Param, Scalar, UInt32
@hawk.kernel
def seed(x: Scalar) -> Scalar: return 0.5 * x
@hawk.kernel
def left(a: Scalar) -> Scalar: return a + 1.0
@hawk.kernel
def right(l: Scalar) -> Scalar: return l - 1.0
@hawk.kernel
def polish(v: Scalar) -> Scalar: return 2.0 * v
@hawk.kernel
def tweak(v: Scalar) -> Scalar: return v * v
@hawk.kernel
def merge(s: Scalar, t: Scalar) -> Scalar: return s - t
@hawk.kernel
def remaining(s: Scalar, flag: Mutable[UInt32]): # a reduction writes the guard cell
flag = hawk.math.uint32(hawk.sum(s > 1.0))
@hawk.kernel
def accumulate(s: Scalar, r: Scalar, r2: Scalar, y: Mutable[Scalar]): # writes the caller's y in place
y = s + r + r2
@hawk.kernel
def scale(x: Scalar, a: Param, b: Param) -> Scalar: return a * x + b
@hawk.kernel
def square(v: Scalar) -> Scalar: return v * v
x = np.arange(8.0); x2 = x + 1.0; y = np.zeros(8); flag = np.array([1], dtype=np.uint32)
Blocks. A parameter annotated eagle.Graph means “append to the graph you are given”.
import eagle
@eagle.Graph.builder # a BLOCK: appends to the open graph it is given,
def twice(g: eagle.Graph, v): # returns what it computed, like any function
return tweak(tweak(v))
@eagle.Graph.builder # a block calling a block, with a device loop
def settle(g: eagle.Graph, s, l, flag):
for _ in range(50): # the capped device loop: at most 50 turns
if not flag: break # the guard: a 1-cell uint32 array
s = merge(s, twice(g, l)) # one buffer, in place; twice: loop-local
remaining(s, flag) # the body writes the guard through its Mutable
return s
Build. A builder makes a graph, records into it and returns it. Calling it on arrays builds that graph; nothing runs.
@eagle.Graph.builder # a BUILDER: no Graph parameter; makes and returns one
def whole(x, flag, y, record=False) -> eagle.Graph:
g = eagle.Graph(record=record) # the graph under construction; keywords = build's
a = seed(x)
with g.parallel() as p: # fork; the `with` exit IS the join
with p.branch() as b: # branch b: two nodes
l = left(a)
l2 = polish(l) # b goes on
with p.branch() as w: # branch w waits for the END of `left`: the plane is the mark
w.wait(l) # a sync point; w may now read l
r = right(l)
with p.branch() as c: # branch c: its own stream; a block inside a branch
r2 = twice(c, a)
s = settle(g, x, l2, flag) # a block appended after the join; x seeds the state
accumulate(s, r, r2, y) # y: a builder argument written in place -> an output
g.result = s # the declared result: a hawk return tree
return g
g = whole(x, flag, y) # BUILD + first BIND: records, lowers, instantiates
Bind. New arrays, same program, in place.
g.bind(x=x2) # by the builder's parameter names; no copy, no rebuild;
# same shape/dtype/device/n (else: "call whole again")
<eagle.Graph 10 nodes, 5 stages, cpu>
Run. Call the graph. It returns the declared result, and the Mutable arguments hold theirs.
s = g() # one replay -> the result tree; y holds its output
s = g(10) # ten replays, the result after the last
g.launch(10); g.enqueue(5); g.synchronize() # pipeline verbs, frozen (return the Graph)
latest = g.result # the same arrays, read without a launch
print(latest)
[1. 2. 3. 4. 5. 6. 7. 8.]
Gradients. The keys are the names that enter and leave the graph: builder arguments and result.
gr = whole(x, flag, y, record=True) # a recorded twin (record=True is a build fact)
s = gr() # a recorded graph runs with launch()/g(), never enqueue
grads = eagle.diff.reverse(gr.record, {"result": np.ones(8), "y": np.ones(8)}, wrt=("x",))
print(grads["x"])
[ 2.5 2.75 4.5 9.25 18.5 33.75 56.5 88.25]
The result forms. A first-hour graph, a tree of results, and a graph with no result.
import numpy as np, eagle
@eagle.Graph.builder
def chain(x):
g = eagle.Graph()
g.result = square(scale(x, 2.0, 1.0))
return g
g = chain(np.arange(8.0)) # build
z = g() # run: z == (2x+1)**2
print(z)
[ 1. 9. 25. 49. 81. 121. 169. 225.]
@eagle.Graph.builder
def pair(x): # a tree: exactly the shapes hawk's `return` allows
g = eagle.Graph(); v = scale(x, 2.0, 1.0)
g.result = {"v": v, "sq": (square(v), [v])}
return g
@eagle.Graph.builder
def void(x, y): # no result: the outputs are the Mutable arguments
g = eagle.Graph(); accumulate(x, x, x, y)
return g
pg, vg = pair(x), void(x, y) # pg() -> the dict; vg() -> None, y written
print(pg(), vg(), y)
{'v': array([ 1., 3., 5., 7., 9., 11., 13., 15.]), 'sq': (array([ 1., 9., 25., 49., 81., 121., 169., 225.]), [array([ 1., 3., 5., 7., 9., 11., 13., 15.])])} None [ 0. 3. 6. 9. 12. 15. 18. 21.]