The Graph IR
The graph IR is poot's central abstraction. It is a flat, typed, topologically ordered list of primitive tensor equations in SSA (static single assignment) form. Every other subsystem (the tracer, the fusion pass, the backends, the executor) is either a producer or a consumer of this one representation.
Why a fine-grained primitive IR
ML compilers from XLA 1 to TVM 2 to JAX 3 share a common design insight: general kernel fusion requires the IR to be finer-grained than the kernels it produces. poot's IR follows the same principle. Attention, RMSNorm, RoPE, and SwiGLU are not opcodes. They are compositions of the actual atomic operations (matmul, reduce, elementwise, broadcast). The fusion pass can then synthesize kernels for supported regions of those primitives.
A practical consequence: flash attention 4 is not a fixed primitive in the IR.
compile recognizes supported attention subgraphs
and rewrites them to a single fused dispatch. A model author composes attention with
ops::attention_masked(...); the rewrite depends on the shape, target and fusion policy.
Graph structure
A simplified view of the main tables (all private; a Graph is read through accessors and changed only
through a checked edit):
struct Graph {
values: ValueTable<ValueMeta>, // shape + dtype + storage class per id
inputs: Vec<ValueId>, // all input binders: consts, slots, state
consts: Vec<ValueId>, // the subset of inputs that are weights/tables
slots: Vec<(ValueId, Slot)>, // per-step inputs and their kind
eqns: Vec<Eqn>, // topologically ordered SSA equations
outputs: Vec<GraphOutput>, // one list: the result, witnesses, exports, state next values
}
enum OutputKind { Result, Witness { .. }, Export(BoundaryId), StateNext { input: ValueId } }
A Graph is always well formed: Builder::finish and GraphEdit::commit check the whole graph. A pass
opens graph.edit(), changes the draft and commits it; a rewrite that loses a witness, an export or a
carried-state pair, or changes the result's type, is refused with a typed error.
struct Eqn {
op: OpKind, // the operation, with its parameters inline
inputs: Vec<Operand>, // Value(ValueId) or Lit(Scalar)
out: ValueId, // single output
}
struct ValueMeta {
aval: TensorType, // shape + dtype - no data
storage: Storage, // Device | Const | Slot(Slot) | State | Computed
identity: Identity, // Anonymous | Named(String) | Weight(WeightRef) | Slot(SlotKey)
}
ValueId is a u32 newtype that indexes a ValueTable; a bare usize is rejected by the compiler. Each computed value is defined by one Eqn; graph inputs are binders.
There are no separate def-use chains; a use is a ValueId into values, and an equation result
is defined by the unique Eqn whose out is that id. This makes all transforms (fusion, substitution, dead-code elimination) straightforward.
Storage classes
Device. An ordinary intermediate tensor. The planner derives its storage requirements from
ValueMeta.aval; the executor allocates or aliases buffers and retains them for cached execution.
Activation tensors such as attention scores, MLP outputs and the residual stream use this class.
Const. A weight or constant tensor (weight matrices, embedding tables, RoPE tables).
Bound from the input map and uploaded for execution. A mapped weight is identified by its WeightRef, not by a name;
weight caches use payload identity and storage representation, so different bytes never reuse a cached weight. Graph::consts() lists the const binders.
Slot(Slot). A per-step input routed through a stable device buffer that the host
rewrites in place. The Slot kind says what it carries: Token, Pos, SlotMap and WriteMap (paged KV
reads and writes; both declare the range of the words they hold, which the bind checks), and others. A role only a pass or a runtime feature declares (the adapter index, the hosted embedding row, the expert pool and its map, the sampler inputs) is defined by that owner, not by the IR: the IR knows its label and the one type rule it declares inputs by. The attention mask is not a slot: it is computed in-graph from Pos.
State. A persistent buffer carried across decode steps: the KV cache. Pre-allocated to
cap capacity per layer. Graph::state() pairs each state_in binder with a state_out
value of identical type. The executor feeds the buffer in and overwrites it with state_out
(the JAX donate_argnums contract), which also orders the KV read before the KV write.
Computed. A compiler-defined constant, such as a folded Iota (an F32 range, or an exact
I32 range). Its value comes from the graph rather than a checkpoint weight binder.
Primitive operations
The OpKind vocabulary. Tracers emit the primitives (elementwise, reduce, movement, matmul,
gather/scatter, quantization). The optimizer introduces the composite ops (Fused, FusedRow,
ContractionEpilogue, Attention, Rope, BlockTopK, SelectedAttention and the packed/dense
contraction ops), each with the same semantics as the primitive chain it replaces; a tracer cannot emit
them. The one exception is Scan, which states a recurrence rather than a schedule: tracers emit it as a
source composite with a primitive definition the CPU oracle evaluates. A sparse-attention indexer writes its
block selection and the attention over the selected blocks as primitive chains, and the selection pass of
compile forms BlockTopK and SelectedAttention from them; a device that cannot plan them refuses the
composite, because the chains have no device lowering. No op records a kernel choice: which kernel runs an
eqn (a tiled GEMM, the attention kernel, an imported kernel) is a property of its plan.
| Op | Category | Inputs | Output shape | Notes |
|---|---|---|---|---|
Unary(op) | Elementwise | x: [...] | [...] (same) | Neg, Exp, Log, Sqrt, Recip, Tanh, Erf; Not, Clz on I32 |
Binary(op) | Elementwise | a: [...], b: [...] | broadcast([a], [b]) | Add, Sub, Mul, Div, Max, Ge (indicator); GeU, And, Or, Xor, Shl, Shr on I32. Numpy broadcast rules |
Select | Elementwise | cond, if_true, if_false | [...] (same) | cond is I32, nonzero picks if_true; branches share one dtype. A native hardware select that moves the chosen element bit for bit, so a NaN or Inf in the unselected branch never reaches the output. I32, F32, F16 and BF16 branches; ROCm and PTX lower all four, while SPIR-V (wgpu, raw Vulkan) refuses F16 and BF16 with a typed DtypeLowering |
Cast(to) | Elementwise | x: [...] | [...] (same) | Storage dtype change; f32 -> bf16 rounds to bf16 precision |
Broadcast | Shape | x: [...], shape | shape | Expand dims |
Reshape | Shape | x: [...], shape | shape | Same element count; lowers to a buffer alias (no dispatch) |
Transpose | Shape | x: [...], perm | permuted [...] | Reorder axes; may lower to a strided view |
Slice | Shape | x: [...], axis, start, end | sliced [...] | Contiguous subrange on one axis |
Concat | Shape | xs: [[...]], axis | concatenated [...] | Fusion boundary |
Gather | Data | src: [...], idx, axis | indexed [...] | Embedding lookup, RoPE table row, paged KV read |
ScatterUpdate | Data | base: [P, ...], src: [N, ...], slots: [N] | same as base | Paged KV write: row t of src goes to pool slot slots[t] (-1 writes nothing), in place on the donated pool; cost follows N, not P |
DynamicUpdateSlice | Data | operand, update, index | same as operand | Dense KV-cache slot write at a scalar index |
ArgTopK(k) | Data | rank: [..., E] | [..., k] | Expert ids from a stable rank tensor (MoE routing) |
Reduce(op, axis, keepdim) | Reduce | x: [...] | [...] (axis reduced) | Sum or Max over one axis; keepdim preserves rank |
MatMul | Compute | a: [..., M, K], b: [..., K, N] | [..., M, N] | Batched; B=1 (GEMV) and B>1 (GEMM) are one op; specialization at plan time |
IndexedMatMul | Compute | x: [M, K], W: [E, K, N], idx: [M] | [M, N] | Each row contracts against its own expert; no weight copy |
PackedDequant | Compute | packed sources by descriptor role | [out, K] | Packed-weight decode primitive; format and shape from poot-quant |
PackedContraction / PackedRowGather | Compute | activation or row ids, packed sources | contraction or selected rows | Compiler-only; decode packed values while consuming them |
DenseContraction / DenseRowGather | Compute | bf16, f32 or f16 weight, or bf16 table | f32 | Compiler-only; contract a weight in checkpoint [N, K] order (bf16, f32, or f16 stored as two-byte words packed in u32) and gather rows of a bf16 table, with no transpose or widened copy |
GroupedDenseContraction | Compute | activation [G, M, K] f32, bf16 weight [G*N, K] | [G, M, N] f32 | Compiler-only; contract a checkpoint [G*N, K] weight read as G blocks of N rows against a [G, M, K] activation, so a grouped projection keeps its bf16 weight resident with no transpose or widened copy |
Rope(rot) | Compute | x, cos, sin | same as x | Introduced by rope_fusion from the rotate-half chain |
Attention | Compute | q, k, v, mask | same as q | Introduced by the attention rewrite in compile, for decode and prefill; online softmax, no score matrix. The planner generates its kernel |
Fused / FusedRow | Compute | region inputs | region root | Pointwise and row-wise (reduction-rooted) fused regions |
ContractionEpilogue | Compute | head operands (a, b), then the epilogue's extra inputs (bias, residual, ...) | as the head contraction | Introduced by the epilogue fusion from a dense contraction and the single-use pointwise chain that consumes it. The head's result keeps its dtype (a BF16 head rounds to BF16 before the first step reads it) and every step rounds through it |
AllReduce / AllGather | Compute | x: [...] | same shape | Tensor-parallel collectives. AllGather takes the number of ranks and multiplies the gather axis by it. A collective is a cut between stages: compiling one inside a stage graph is refused, and the reference evaluator refuses an AllGather over more than one rank |
What composite ops decompose to
Higher-level operations are not opcodes; they are sequences of primitives recorded by the tracer. Here are three examples.
RMSNorm 5, seven eqns, fused into one reduction kernel:
sq = Mul(x, x) # [L, D]
ss = Reduce(Sum, sq, axis=-1, keepdim) # [L, 1]
ms = Mul(ss, 1/D) # [L, 1]
mse = Add(ms, eps) # [L, 1]
den = Unary(Sqrt, mse) # [L, 1]
xn = Div(x, den) # [L, D], den broadcasts
out = Mul(xn, weight) # [L, D]
Numerically stable softmax. Five eqns (binary ops broadcast implicitly, numpy style), fused into one reduction kernel:
m = Reduce(Max, scores, axis=-1, keepdim=True)
xs = Binary(Sub, scores, m)
ex = Unary(Exp, xs)
s = Reduce(Sum, ex, axis=-1, keepdim=True)
out = Binary(Div, ex, s)
The Max and Sum reductions share the same traversal. This is the two-pass safe pattern (subtract max, then normalize). The streaming "online" algorithm used inside flash attention is different, see Attention. After flash attention rewrite, this entire pattern (plus the surrounding matmuls) disappears into one fused dispatch.
RoPE 6. The tracer emits the rotate-half chain (ops::rope_partial), with cos/sin
gathered from constant tables by position:
x1, x2 = Slice(x, 0..D/2), Slice(x, D/2..D)
out = Concat([Sub(Mul(x1,cos), Mul(x2,sin)),
Add(Mul(x1,sin), Mul(x2,cos))], axis=-1)
compile recognizes this chain and replaces it with one Rope { rot } eqn with
inputs [x, cos, sin], lowered to one synthesized kernel instead of ~6-8 dispatches.
What an attention layer looks like as a DAG
The diagram below shows the primitive eqns for one decode-step attention layer. Hover a node to highlight its edges.
Host-side shape inference
Every OpKind implements infer(input_types) -> output_type. Shape inference
runs entirely on the host at trace time. No shape computation ever happens on the device.
Two consequences. First, the executor knows every buffer size before the first dispatch and can allocate everything upfront at stable addresses, a requirement for recorded replay (CUDA graphs, HSA packets, Vulkan command buffers). Second, because all shapes are static, the planner can select kernel specializations (GEMV vs GEMM, the attention schedule) at compile time rather than conditionally at runtime.
The exception: per-step inputs (token id, position) are Storage::Slot inputs (Slot::Token, Slot::Pos), small
device buffers at stable addresses written by the host each token. The causal/windowed mask is built
in-graph from Slot::Pos against an Iota; no slot carries it. Output shapes
do not depend on slot values (the KV capacity is pre-fixed), so the captured graph remains valid
across all positions.
Quantization in the IR
PackedDequant is the single packed-weight primitive. Its poot-quant::PackedWeight descriptor names
the WeightFormat, shape and source roles. The compiler recognizes dense projections as
PackedContraction and embedding lookups as PackedRowGather; generated kernels decode only the
weight values they consume.
GPTQ, AWQ, supported GGUF quants and the registered FP8/E2M1 layouts use the descriptor-driven lowering. Support still depends on the consuming graph: a packed MoE expert stack is rewritten when its weights are bound into one indexed packed contraction that decodes only the selected experts' blocks, and the packed loader keeps the weights packed.