Section 2 - Your First Graph¶
Section 1 called one operator on its own. A real design usually chains
several: a normalization, a couple of projections, an activation. A
Graph records that chain once, so
the intermediate tensors between operators stay on the device instead of
round-tripping to the host between calls.
Walkthrough of first_graph.py¶
import iron
from iron.operators import AXPY, ReLU
class ReluThenAxpy(iron.Graph):
"""``scalar_factor * relu(x) + y``, two operators as one compiled image."""
def __init__(self, scalar_factor=2.0):
self.scalar_factor = scalar_factor
def body(self, x, y):
activated = ReLU.step(x)
return AXPY.step(activated, y, scalar_factor=self.scalar_factor)
A graph is a subclass of iron.Graph whose body you write. Its positional
parameters (x, y here) are the graph's inputs; its return value is the
graph's output. __init__ is plain Python — anything it does runs once, at
construction, not per call.
Inside body, Op.step(...) is how an operator joins the graph: it
records a step rather than running one, and gives back a Handle — a
stand-in for a tensor, remembering where it lives and what produced it.
ReLU.step(x) records a ReLU step reading x; the activated handle it
returns is what AXPY.step(activated, y, ...) reads next. Nothing has run
yet — body only runs once per input shape, when the graph is recorded
(Section 3 covers the one-compile-many-calls part of that).
Op.step(...) infers an operator's shape parameters (AXPY's size, here)
from the handles it is given, the same way GEMV.step(w, x) reads M and
K off w and x's shapes — you don't repeat a dimension the handles
already carry. A construction keyword an operator still needs (AXPY's
scalar_factor) is passed to .step(...) same as to the class itself.
Compiling and calling it¶
compile(**shapes) runs body once on handles of the given shapes (this is
the recording), then lowers the recorded steps to an image for the current
device: one full ELF on NPU2, one xclbin per step on NPU1. The returned
CompiledGraph is also stored on graph itself, keyed by shape, so you
don't have to hold onto it: graph(x, y) finds the version whose shapes
match x and y's and calls it. If no version is loaded yet, calling the
graph with an uncompiled shape compiles it on the spot and logs that it did.
Once any version is loaded, an uncompiled shape raises ValueError rather
than compiling automatically. Compile every shape you'll need before the
first device call: the versions share a scratch arena that a later compile
can grow — see Section 4.
Checking it against numpy¶
As with a single operator, graph.reference(*tensors) runs body on the
host, each step through its operator's own reference(); no NPU needed.
The test uses it to check
the example device-free.
A device call is checked the same way Section 1 checked AXPY alone —
aie.utils.verify.compare(result, reference, tolerance) — but a graph's
tolerance is not one number invented for the whole chain: it is the
tolerance of whichever step actually rounds. ReLU is exact in bf16 (it only
selects max(x, 0)); AXPY is the one step with a kernel contract, so its
tolerance() is the whole chain's. first_graph.py reads it straight off
the compiled graph's own recording (compiled.recording.steps), rather than
reconstructing an AXPY by hand. A graph where more than one step rounds
needs a different approach — Section 6.
Exercises¶
- Run
python3 first_graph.pyon an attached NPU; you should seePASS!. - Add a third operator to the chain (for example another
AXPY.stepadding a third inputz) and extendbody's signature andmain()'s tensors to match. One compile still serves one call to the whole chain. print(graph.record(x=(2048,), y=(2048,)))— aRecording— and inspect its.steps: each is one recorded operator instance and the handles it reads and writes. This is whatcompilecalls internally before lowering to an image.- Read
op.resolved().tolerance()forReLUon its own (as Section 1 did forAXPY) and confirm it really isTolerance.exact()— nortol,atolorulpsset.