05 · Graph build — the IR and the builder
Stage: tokenizer + template (04) → graph build (IR) → backend assignment + fusion (06). By this point the prompt is a list of integer token ids and every weight is registered by name in the allocator — but nothing has been computed yet. This stage writes the entire transformer forward pass as one pure data structure: a declarative compute graph that later stages assign to hardware, fuse, allocate, and execute. Code:
src/graph/mod.rs(CNode:156,ComputeGraph:201,topo_order:238),src/graph/ops.rs(Op:47,NodeMeta:196),src/graph/builder.rs(GraphBuilder:16, method family :264–711),src/models/qwen2/graph.rs(Qwen2Graph::build:39,forward_cached:450),src/graph/params.rs(GraphParams:88),src/graph/cache.rs(try_reuse:69) — lines verified at commit15fa45c.
1. Background — where this stage sits
After doc 04 the engine holds two things: a Vec<u32> of token ids — one
integer per piece of your prompt, as produced by the byte-pair-encoding
tokenizer — and a loaded model whose weight tensors (the quantized matrices
from the GGUF file) are registered by name in the graph allocator. A forward
pass must now turn those ids into logits: one floating-point score per
vocabulary entry, from which the sampler will pick the next token. That takes
hundreds of math operations — normalizations, matrix multiplies, rotations,
attention — arranged in a very specific order, repeated for each of the
model's transformer layers.
The engine does not run those operations as a hand-written loop, at least not any more. Instead it first describes the whole computation as a data structure called a compute graph — a directed acyclic graph where each vertex ("node") is one math operation and each edge means "this node's output is that node's input". The adjective declarative is the point: building the graph states what should be computed, never how or when. The "how" (which CPU instructions, which GPU kernel) is decided later, per node, by the backend scheduler (doc 06). The "when" is the node order itself (doc 08).
Why go through this indirection? Four concrete payoffs, each of which gets a full section in §3.3:
- Reuse. Decoding is a loop: one forward pass per generated token, often
hundreds of them. If the graph is a pure function of a small parameter
struct (
GraphParams), identical parameters mean an identical graph — so the engine builds it once and replays it for every decode step, refreshing only the input data (the new token id, the new position). - Global decisions. "Which backend runs this node?" and "which buffers can share memory?" need the complete picture of the computation before anything runs — and a data structure you can walk is exactly that picture.
- Optimization as rewriting. Pattern-optimizations (fusing
silu∘mulinto one operation) become a small graph-rewrite pass instead of scattered special cases inside a forward loop. - Observability. The graph can be printed (
--dump-graphexports Graphviz DOT;--dump-graph-jsonexports JSON for the web visualizer), so you can literally see one forward pass.
There was an older design: models/qwen2/forward.rs, an imperative per-layer
loop that computed as it went and hard-coded its GPU fallbacks. It is gone —
deleted in Phase 6 of the graph refactor (commit 6af12a4) only after the
graph path reproduced its logits bit-identically, prefill and decode
(docs/COMPUTE-GRAPH-DESIGN.md §17, Phases 5–6 and 8). Its shape survives as
a historical record in docs/ARCHITECTURE.md Appendix A.
So this document is the heart of the engine: the transformer forward pass, written as a data structure. Doc 06 walks it and gives every node a backend; doc 07 gives every node a buffer; doc 08 finally executes it.
2. Principle — how it works and why
2.1 The vocabulary: graph, node, edge, IR
Some terms we will use constantly:
- Compute graph — the data structure describing one forward pass. Each node is one operation ("multiply these two matrices"), each edge is a data dependency ("node 13's output feeds node 14"). No cycles are allowed: information flows one way, input to output.
- IR — intermediate representation, a compiler word for "a program
expressed as data, sitting between the source and the machine". minfer's IR
is
ComputeGraph: a Rust struct you can traverse, compare, rewrite, and export. llama.cpp has the same idea (ggml_cgraph); minfer mirrors it. - Node (
CNode) — one operation plus everything the rest of the engine needs to know about it: which operation (anOpenum value with full parameters), which other nodes it reads (src, the edges), the shape and element type of its output, and (only later, doc 06) which backend it runs on. - Topological order — an ordering of nodes where every node appears after all of its inputs. The builder produces this by construction: it only lets you reference nodes that already exist. "Append sources before consumers" is one sentence, and it buys an enormous amount: no sort is ever needed, and executing nodes in list order (doc 08) is automatically correct.
- Backend — one compute device and its kernels:
CPU(AVX2/NEON SIMD),Metal(Apple GPU),Cuda(NVIDIA GPU). Each node gets assigned one, but that happens after this stage. - Buffer — a chunk of memory holding one node's output; a
BufRefis just a handle (which backend's pool, which slot). Allocated in doc 07; the graph only records shapes.
2.2 The three data structures
The whole IR lives in src/graph/mod.rs (273 lines including tests). You
have met the CNode fields in §2.1; what ComputeGraph itself adds is the
node list plus two id sets — inputs (the leaves filled with external data
each step) and outputs (the logits) — and a uid, a monotonic id assigned
when the graph is cached, which the CUDA backend keys its replay cache on.
A BufRef { backend, id } is just a handle for "buffer id in that
backend's pool" — created only in doc 07.
2.3 Operations carry full payloads
The Op enum is not just a tag like "MatMul". Every variant carries the
parameters that make it this operation and no other: RmsNorm { eps },
MatMul { transpose_b }, RoPE { style }, KvcacheStore { layer },
FusedQKV { layer }. Node-level extras (which weight tensor, which bias,
frequency base) ride along in a NodeMeta enum. Two consequences:
- Two graphs can be compared structurally — node by node, payload by
payload.
OpandNodeMetaboth derivePartialEqfor exactly this, and a debug/test check (GraphCache::verify_structural) asserts that rebuilding a graph with identical parameters yields an identical node sequence — the tripwire guarding the reuse invariant of §2.7. - The dumped graph is self-describing: the DOT export prints
RmsNorm { eps: 1e-6 }andKvcacheStore { layer: 23 }, so you can read a forward pass off the page.
The full op list is wider than what Qwen2 emits (Softmax, View,
Permute, Scale… exist for ggml parity), but the live graph uses a compact
set — see the census in §2.5.
2.4 The builder: an append-only factory
GraphBuilder (496 lines) is the only way to construct a graph. Its methods
come in two kinds:
input(name, shape, dtype)— declare a leaf node whose data arrives from outside each step. It records the id ingraph.inputsand nothing else.- The operation family —
embedding,rms_norm,qk_norm(Qwen3),matmul,get_rows,rope,silu,add,mul,swiglu,softmax,attn,kvcache_store/kvcache_load, plus the decode-fusion constructorsfused_qkv/qkv_bias_rope_store/fused_ffn/fused_qkv_norm. Each one computes the output shape from its inputs' shapes (so shapes propagate through the graph automatically), wraps the parameters into anOp+NodeMeta, appends the node, and returns its id.
The crucial property is stated in the module's first lines: "The builder is
pure: it only appends nodes to the graph, it never computes." Building a
graph allocates a few Vecs and does integer shape arithmetic — no matmul
runs, no GPU is touched, no buffer exists yet. Purity is what makes the reuse
invariant (§2.7) even statable.
One naming convention to keep straight, because it recurs in every excerpt:
GGUF stores weight metadata as [in, out] (input dim first) but memory
layout is [out][in] row-major, and activations are token-major [nt][d]
(nt = token count, d = features) — so matmul's output shape is
[w.shape[1], nt]: output dim, then token count.
2.5 The main event: one forward pass, node by node
Qwen2Graph::build (src/models/qwen2/graph.rs:39) is where the forward
pass is actually written down. The real thing below is from the Qwen2.5-0.5B
model (24 layers, hidden 896, 14 query heads of dim 64, 2 KV heads — more on
that below, FFN width 4864, vocabulary 151936), prefilled with a 30-token
prompt. Node ids are the real ones from a --dump-graph export.
token_ids(0) positions(1) tail_ids(2) <- input leaves (filled per step)
\ | \______ (consumed at the LAST layer only)
v v
embed(3) GetRows h = one token_embd row per id, [896, nt]
|
== layer 0 ==================================================================
rms_norm(4) <- h "attn_norm", eps 1e-6
|-- matmul(5) blk.0.attn_q [896 -> 896] Q = Wq @ normed (+ bias)
|-- matmul(6) blk.0.attn_k [896 -> 128] K = Wk @ normed (+ bias)
+-- matmul(7) blk.0.attn_v [896 -> 128] V = Wv @ normed (+ bias)
rope(8) <- n5, positions rotate Q by position
rope(9) <- n6, positions rotate K by position
kv_store.0(10) <- n9(K), n7(V), positions write into layer-0 KV region
kv_load.0(11) view of that region (no edges in)
attn(12) <- n8(Q), n11(KV), positions GQA attention, scale 1/sqrt(64)
matmul(13) blk.0.attn_output [896 -> 896] project heads back
add(14) <- embed(3), n13 <-- FIRST residual add
rms_norm(15) "ffn_norm"
matmul(16) blk.0.ffn_gate [896 -> 4864]
matmul(17) blk.0.ffn_up [896 -> 4864]
silu(18) <- n16 (orphaned by fusion, see §2.6)
SwiGLU(19) <- n16, n17 silu(gate) * up (fused form)
matmul(20) blk.0.ffn_down [4864 -> 896]
add(21) <- n14, n20 <-- SECOND residual add
== layers 1..22 identical (18 nodes each) ==================================
== layer 23 (the last): as above, plus two GetRows nodes (428, 429) =========
rms_norm(438) final norm
matmul(439) output (lm_head) [896 -> 151936] -> OUTPUT logits
Reading it top to bottom is reading the forward pass. What a beginner should take away is the shape of a transformer layer, which the IR makes unmistakable:
- An attention block: normalize, then three matrix multiplies produce Q (query), K (key), V (value) — attention's three roles; the positions are mixed in by RoPE (Rotary Position Embedding, which rotates Q and K so that attention scores depend on token distance); the fresh K and V are written into the layer's KV cache region (persistent memory holding every past token's keys and values — the thing that makes decoding cheap, doc 11); then one attention operation reads Q plus the whole KV region, and one final matrix multiply projects the result back to the hidden width.
- An FFN block (feed-forward network): normalize, two wide matrix
multiplies (gate, up), the SwiGLU activation —
silu(gate) * up, where SiLU is the smooth gate functionx·sigmoid(x)— then a narrow matrix multiply back down. - Two residual adds —
h = h + attention(...)andh = h + ffn(...). Each block computes a correction to the running hidden state instead of replacing it; the add nodes are the only places the hidden state is rewritten.
Nodes 4–21 are layer 0 (0–2 are the input leaves, 3 the embedding); layers 1
through 22 repeat the same 18-node pattern (layer 23, with the two extra
get_rows of §2.8, spans nodes 418–437); node 438 is the final norm and 439
the lm_head. The full prefill graph is 440 nodes (measured: --dump-graph
on 0.5B, 30-token prompt, n_out = 1). The complete census:
| Op kind | Count | Where |
|---|---|---|
MatMul | 169 | 7 per layer (q,k,v,wo,gate,up,down) × 24 + lm_head |
RmsNorm | 49 | 2 per layer × 24 + 1 final |
RoPE | 48 | 2 per layer (q, k) |
Add | 48 | 2 residual adds per layer |
SwiGLU | 24 | 1 per layer (rewritten from silu+mul) |
Silu | 24 | orphans left behind by that rewrite |
KvcacheStore / KvcacheLoad | 24 / 24 | 1 each per layer |
Attn | 24 | 1 per layer |
GetRows | 3 | embed + 2 tail-row selects |
Input | 3 | token_ids, positions, tail_ids |
A note on GQA — grouped-query attention, the Qwen2 trick visible in the
shapes above. Full multi-head attention stores a key and a value per query
head: with 14 heads of dim 64, that is 14 × 64 = 896 numbers per token per
layer, per K and V. Qwen2.5-0.5B instead shares each key/value among a
group of 7 query heads: only n_head_kv = 2 KV heads exist, so K and V are
128-wide (n_kv_embd = 128), and the KV cache shrinks 7×. The graph
expresses this in plain shapes — the wk/wv matmuls output 128, not 896 — and
the attn node's metadata carries the mapping (n_head, n_head_kv, head
dims, the KV row stride nkt, the scale 1/sqrt(64)).
2.6 Prefill vs decode: same skeleton, two fusion classes
A forward pass is built for exactly one n_tokens:
- Prefill (
nt > 1): all prompt tokens flow through together; the KV store writes a block of positions and attention reads the fresh prefix. - Decode (
nt == 1): one token at a time — same 18-node skeleton, the shapes just narrow to[.., 1].
Decode is where minfer buys speed with two build-time fusions — extra
builder constructors that emit fewer, bigger nodes. They are decided inside
build itself (gated on nt == 1 and a GPU backend), not by a later pass:
FusedQKV(the concat class): instead of3 matmul + 3 bias-add + 2 rope + 2 store= 10 kernel dispatches per layer, one concat matmul against a pre-concatenatedblk.{i}.attn_qkvweight, then one fused kernel applying the three biases, roping Q and K, and storing K/V — 2 dispatches. Attention reads Q from offset 0 of the concat buffer.FusedFFN: instead ofgate matmul + up matmul + silu + mul= 4 dispatches, one concat matmul againstblk.{i}.ffn_guplus one in-place SwiGLU pass — 2 dispatches. Measured on the 0.5B: decode ~269 → ~299 tok/s (+~11%) for QKV, ~303 → ~312–331 (+~3%) for FFN (COMPUTE-GRAPH-DESIGN.md§17, Phases 10–11).
Both fusions are gated by measurement, not ideology: FusedFFN is built
only when nf ≤ 16384, because on the 7B model (nf = 18944, so the concat
matmul's output is 37 888 wide) the single wide matmul measured slower than
two narrow ones — 42.5 vs 46.7 tok/s — and the gate turns it off there.
Mixed-quantizer layers (e.g. Q6_K attention-V among Q4_K q/k) cannot share a
concat weight at all; for those, a second class (qkv_bias_rope_store) keeps
three separate matmuls and fuses only the epilogue. Qwen3 has its own variant,
FusedQkvNorm, which additionally folds the per-head Q/K RMSNorm that Qwen3
requires: 3 matmul + 2 qk_norm + 2 rope + 2 store → 2.
Also note the SwiGLU/Silu rows in the census: on the unfused path the
builder still emits silu then mul, and a later pass (doc 06) rewrites the
mul into SwiGLU in place, orphaning the silu node. The orphan stays in
the node list — the scheduler skips nodes without buffers — which is why both
counts are 24. In the DOT dump you can literally see the stale edge
n16 → silu(18) alongside the fused n16 → SwiGLU(19).
2.7 The reuse invariant: topology = f(GraphParams)
Here is the sentence the whole design stands on: the graph topology is a
deterministic function of GraphParams — equal parameters produce an
identical graph, node for node. GraphParams (src/graph/params.rs:88-98)
contains exactly n_tokens, n_out (tail rows, §2.8), gtype
(prefill or decode), cparams (context size, flash-attn flag, GPU
participation, the two fusion toggles), and weights_version.
Conspicuously absent: n_past — how many tokens are already in the KV cache.
That number changes on every decode step, and it is deliberately not part
of the structure. Where does the position live instead? In an input node:
the positions leaf (node 1) is filled with fresh values before every
execution, and the KV store/load and RoPE nodes read it as data. Positions
are data, not structure — the one invariant everything else protects
(ops.rs:3-6 states it; §3.3 Q5 asks "what breaks without it").
Given that invariant, reuse is a six-field comparison: GraphCache::try_reuse
compares the new GraphParams against the cached one; equal means the graph —
and the allocator, with its persistent KV regions — are reused as-is, and only
the input data is refreshed. During a 500-token generation the prefill graph
is built once, the decode graph is built once (the first decode step, because
nt changed 30 → 1), and the remaining ~500 steps replay it.
2.8 The n_out tail: shrink the graph to the rows you sample
One forward pass produces logits for some tokens, but generation only ever
samples the last token (single sequence). Computing lm_head — the final
[896 → 151936] matmul — for all 30 prompt tokens costs
30 × 896 × 151936 ≈ 4.1 × 10⁹ multiply-accumulates; for the last token
only, ≈ 1.4 × 10⁸. minfer, like llama.cpp's inp_out_ids, draws the line
further up: after the last layer's attention output projection, two
GetRows nodes select the tail n_out rows — of the attention output and
of the residual — so the last FFN block, both final residual adds, the final
norm, and lm_head all run on n_out rows instead of nt. That is nodes
428/429 in the dump; with n_out = 1 the entire output stack is one row.
The row indices are the tail_ids input node (node 2) — again data, not
structure — filled with [nt-n_out .. nt) before execution. Measured impact
on 0.5B prefill: ~3900–4000 tok/s, ~+55% over the full-nt graph (plan §17,
deviation 16).
One subtlety worth admiring: tail_ids is declared at the head of the
graph, next to the other inputs, though its consumers sit at the very end.
The code comment explains why (graph.rs:55-60): a mid-graph input node
would split execution into extra backend boundary segments, each costing
full-stream syncs and host round trips on the GPU path. Node order is not
semantics — only the edges are — so declaration position is free to optimize
execution, not readability.
3. Implementation
3.1 Data in / data out
In (from doc 04 and doc 03):
tokens: &[u32]— the tokenized prompt (prefill) or the single generated token (decode). Becomes thetoken_idsinput node,[nt, 1, 1, 1],I32.positions: &[usize]— the KV slot of every token:0..lenfor prefill, the running position per decode step. Becomes thepositionsinput node, same shape,I32.- The loaded model: hparams (layer count, head counts, FFN width, eps,
RoPE base/scale) and the weight tensors — which the builder references
by name only (
MatMulMeta.weight_name = "blk.7.attn_q.weight"), not by pointer. The graph stays pure data; the allocator resolves names to buffers at execution time. GraphParams— the full set of structure-deciding knobs (§2.7).
Out:
- A
ComputeGraph: 440 nodes (0.5B prefill with n_out = 1),inputs = [0, 1, 2],outputs = [439](the lm_head matmul). No buffers, no backends, no numbers — nothing computed.
Two layout conventions the shapes encode (from docs/ARCHITECTURE.md §4.5):
weights are metadata [in, out] but memory row-major [out][in] — so a
matmul's output dim is w.shape[1]; activations are shape [d, nt, 1, 1]
with token-major memory [nt][d]; and integer inputs (I32) are stored
bit-exactly inside f32 buffers as f32::from_bits patterns
(fill_input_i32, doc 07) so one buffer pool serves both dtypes.
A sense of scale for the persistent parts the graph merely declares: each
layer's K region is [n_kv_embd=128, n_ctx=4096] f32 = 2 MB; K + V per layer
= 4 MB; 24 layers ≈ 101 MB of KV the allocator must keep alive across
every rebuild (doc 07 owns that machinery).
3.2 Key code
The node and the graph (src/graph/mod.rs:82-115)
#![allow(unused)] fn main() { /// Single compute node. #[derive(Debug, Clone)] pub struct CNode { pub id: NodeId, pub name: String, pub op: Op, // operation + full payload pub src: Vec<NodeId>, // input dependencies (the edges) pub out_shape: [usize; 4], // output shape [d, nt, 1, 1] convention pub out_dtype: DType, // output element type /// Backend assigned by the scheduler (Phase 4); None = undecided. pub backend: Option<Backend>, pub meta: NodeMeta, // weight names, rope/attn params, ... } /// Compute graph: topologically ordered node sequence + input/output sets. #[derive(Debug, Clone, Default)] pub struct ComputeGraph { pub nodes: Vec<CNode>, pub inputs: Vec<NodeId>, // input nodes that need external filling pub outputs: Vec<NodeId>, // output nodes (logits, etc.) /// Graph identifier for reuse detection (CUDA Graph caching etc.). /// Populated by `GraphCache::replace_graph` (monotonic per process); /// a reused graph keeps its uid. pub uid: u64, } }
Note what is absent: no buffers, no data pointers, no backend — decisions
for later stages. The IR records only structure and shape; the
backend: Option<Backend> slot exists so doc 06 can fill it in place, and
at this stage it is None everywhere.
Topological order is not maintained — it is implied. The topo_order()
doc comment (src/graph/mod.rs:151-153) says it outright: "The builder
appends sources before consumers, so nodes is already topologically
ordered; this validates the invariant and returns a stable order (used by
the allocator)." Because a node can only reference ids the builder has
already returned, nodes is sorted the moment it is built. topo_order()
still runs a full Kahn pass — but its real job is validation (cycle
detection, dangling ids), and its result is deliberately not used as the
execution order (a G3 bug story told in doc 07).
Operations with payloads (src/graph/ops.rs:36-101, abridged)
#![allow(unused)] fn main() { /// Operator type. Implements full `PartialEq` (payloads included) so debug /// builds can verify graph-rebuild structural identity; the production graph /// reuse decision is params-only (see docs/COMPUTE-GRAPH-DESIGN.md §6). #[derive(Debug, Clone, PartialEq)] pub enum Op { /// Leaf input node (token ids, positions, KV idx, ...). Filled externally /// each step via `GraphAllocator::fill_input`; never part of the topology. Input, Add, Mul, Silu, // ... Scale(f32), Softmax { dim } — ggml-parity vocabulary, no live // architecture emits them today ... RmsNorm { eps: f32, }, QkNorm { hd: usize, nh: usize, eps: f32, }, MatMul { transpose_b: bool, }, GetRows, RoPE { style: RopeStyle, }, Attn { mode: AttnMode, }, // ---- KV cache (persistent external buffer; positions are data) ---- KvcacheStore { layer: usize, }, KvcacheLoad { layer: usize, }, // ... fused: SwiGLU, FusedQKV{layer}, // QkvBiasRopeStore{layer}, FusedFFN, FusedQkvNorm{layer} }
Two things to notice. First, the KV ops carry only the layer index — no
position, no length. The module doc at ops.rs:3-6 states the invariant:
positions are data, injected via the positions input node, so the graph
topology never depends on n_past. Second, the PartialEq derive is
annotated with its purpose: structural identity checks for the reuse
invariant.
Weights and other per-node parameters travel in NodeMeta, a concrete enum
(ops.rs:160-178) rather than the plan's original Box<dyn Any> — a
recorded deviation that buys PartialEq, no downcast panics, and Clone
nodes. The workhorse is MatMulMeta { weight_name, bias_name, weight_ttype, in_dim, out_dim }: the graph references the weight by
name ("blk.7.attn_q.weight"), and the backend resolves that name to a
registered buffer at execution time. That indirection is what keeps the IR
pure data — and it is why doc 03 registered every weight under exactly these
GGUF names.
The builder's only real primitives are node(name, op, src, out_shape, out_dtype, meta) — which appends a CNode with id = graph.nodes.len() and
backend: None, and returns that id — and input(name, shape, dtype), which
creates an Op::Input leaf with no sources, records its id in
graph.inputs, and is otherwise an ordinary node (its doc comment already
names the payoff: "so n_past/positions changes never force a graph
rebuild"). Everything else in the 496-line file is sugar over node.
The builder in practice (src/graph/builder.rs:127-145)
A representative convenience method, matmul:
#![allow(unused)] fn main() { pub fn matmul(&mut self, x: NodeId, w: &Tensor, bias: Option<&Tensor>) -> NodeId { let out = w.shape[1] as usize; // GGUF: out dim = shape[1] let nt = self.graph.nodes[x].out_shape[1]; let name = format!("matmul_{}", w.name); self.node( &name, Op::MatMul { transpose_b: false }, &[x], [out, nt, 1, 1], // output width, then token count DType::F32, NodeMeta::MatMul(MatMulMeta { weight_name: w.name.clone(), bias_name: bias.map(|b| b.name.clone()), // ... weight_ttype, in_dim = w.shape[0], out_dim = w.shape[1] }), ) } }
Shapes propagate automatically: the output width comes from the weight, the
token count from the input. The bias is not a separate node — it is a name in
the metadata, and each backend's matmul kernel applies it inline (a small
taste of how aggressively this IR avoids node spam). transpose_b is
false everywhere in the live graph: minfer stores weights row-major
[out][in] and never needs the transposed form; the flag exists for ggml
vocabulary parity.
And the KV pair (src/graph/builder.rs:368-389, store side — trimmed):
#![allow(unused)] fn main() { /// Write this step's K/V into the layer's persistent KV region at the /// positions carried by `pos`. `n_ctx` sizes the persistent region. pub fn kvcache_store( &mut self, layer: usize, k: NodeId, v: NodeId, pos: NodeId, n_ctx: usize, ) -> NodeId { let n_embd = self.graph.nodes[k].out_shape[0]; // shape mirrors the persistent region so the allocator can size it self.node(&format!("kv_store.{layer}"), Op::KvcacheStore { layer }, &[k, v, pos], [n_embd, n_ctx, 1, 1], DType::F32, NodeMeta::Kvcache(/* ... */)) } }
The store node reads three sources — the rotated K, the raw V, and the
positions input — and its declared output shape [n_kv_embd, n_ctx] is the
allocator's sizing contract for the persistent region. kvcache_load is even
simpler: no sources at all (it is a view of the region, which is why
kv_load nodes have no incoming edges in the DOT dump), carrying
{layer} again and nothing positional.
The main event, part 1: inputs and the QKV branch (src/models/qwen2/graph.rs:53-130)
#![allow(unused)] fn main() { let inp_ids = b.input("token_ids", [nt, 1, 1, 1], crate::graph::DType::I32); let inp_pos = b.input("positions", [nt, 1, 1, 1], crate::graph::DType::I32); // G3 tail-row reduction input, declared at the graph HEAD (not beside // its consumers at the last layer): an input node mid-graph splits the // forward into extra CPU/CUDA boundaries (2 full-stream syncs + host // round-trip copies per step on the split path). R3-A1, // docs/CUDA_OPTIMIZATION.md Part III. Node order is not semantics — // the consumers below just reference the handle. let tail_ids = (params.n_out < nt).then(|| { b.input( "tail_ids", [params.n_out, 1, 1, 1], crate::graph::DType::I32, ) }); let mut h = b.embedding(inp_ids, model.tok_embd.as_ref().unwrap()); }
Then, inside the for (il, l) in model.layers.iter().enumerate() loop, after
rms_norm, the Q/K/V projection has three build-time classes, selected by
a gate that reads only nt, GraphParams, and the layer's own tensors:
#![allow(unused)] fn main() { let fuse_qkv = nt == 1 && params.cparams.gpu && params.cparams.fuse_qkv && l.bq.is_some() && l.bk.is_some() && l.bv.is_some(); let (q, kv) = if fuse_qkv && Self::qkv_concat_available(&l.wq, &l.wk, &l.wv) { let qkv = b.fused_qkv( normed, inp_pos, il, FusedQkvMeta { qkv_weight: format!("blk.{il}.attn_qkv"), // ... biases, dims, rope params, kv_elems ... }, ); // q lives at concat offset 0 (rows 0..nqt); K/V went into the // persistent regions via the fused store — read them back. let kv = b.kvcache_load(il, nkt, n_ctx, nk); (qkv, kv) } else if /* mixed-quant class: 3 matmuls + qkv_bias_rope_store */ { // ... } else { let q = b.matmul(normed, l.wq.as_ref().unwrap(), l.bq.as_ref()); let k = b.matmul(normed, l.wk.as_ref().unwrap(), l.bk.as_ref()); let v = b.matmul(normed, l.wv.as_ref().unwrap(), l.bv.as_ref()); let q = b.rope(q, inp_pos, hp.rope_style, RoPEMeta { /* ... */ }); let k = b.rope(k, inp_pos, hp.rope_style, RoPEMeta { /* ... */ }); b.kvcache_store(il, k, v, inp_pos, n_ctx); let kv = b.kvcache_load(il, nkt, n_ctx, nk); (q, kv) }; }
This is where "the graph is data" pays its first visible dividend: the
decision about decode fusion is an ordinary if in a pure function, and
each branch emits a different topology. A different GraphParams (nt = 1 vs
30, or MINFER_NO_FUSE_QKV=1 in the environment) yields a different graph —
and because the deciding values all live in GraphParams, the difference is
exactly reproducible.
The main event, part 2: attention, tail rows, FFN (src/models/qwen2/graph.rs:201-269, abridged)
#![allow(unused)] fn main() { // attention let attn_out = b.attn( q, kv, inp_pos, mode, AttnMeta { layer: il, n_head: nh, n_head_kv: nk, hd, hd_kv, nkt, // KV row stride = n_kv_embd scale: attn_scale, // 1/sqrt(hd), loader.rs:36-38 }, ); // output projection + residual let wo = b.matmul(attn_out, l.wo.as_ref().unwrap(), None); let is_last = il == model.layers.len() - 1; if is_last && params.n_out < nt { let tail_ids = tail_ids.expect("tail_ids input declared when n_out < nt"); let cur_tail = b.get_rows(wo, tail_ids, [ne, params.n_out, 1, 1]); let res_tail = b.get_rows(residual, tail_ids, [ne, params.n_out, 1, 1]); h = b.add(res_tail, cur_tail); } else { h = b.add(residual, wo); } }
#![allow(unused)] fn main() { let fuse_gu = nt == 1 && params.cparams.gpu && params.cparams.fuse_ffn && Self::gu_concat_available(&l.ffn_gate, &l.ffn_up) && nf <= 16384; // 7B: concat matmul measured slower // ... rms_norm(ffn_norm) ... let ffn_out = if fuse_gu { let gu = b.fused_ffn(normed, FusedFfnMeta { /* ... */ }); b.matmul(gu, l.ffn_down.as_ref().unwrap(), None) } else { let gate = b.matmul(normed, l.ffn_gate.as_ref().unwrap(), None); let up = b.matmul(normed, l.ffn_up.as_ref().unwrap(), None); let g = b.silu(gate); let sw = b.mul(g, up); b.matmul(sw, l.ffn_down.as_ref().unwrap(), None) }; h = b.add(residual, ffn_out); }
After the loop, two lines finish the pass: rms_norm(h, output_norm) then
matmul against model.output (lm_head), marked with b.output(logits),
and b.build() hands back the finished ComputeGraph. Two details deserve a
pause: the attention node takes the positions input as a third source —
attention needs it for causal masking (token t may attend to positions
0..=pos[t]; builder comment at builder.rs:325-327) — and on the last
layer the residual is also narrowed by a second get_rows, because both
sides of the add must shrink or the shapes would disagree.
The reuse check (src/graph/cache.rs:47-64)
#![allow(unused)] fn main() { /// Params-only reuse check. On success the previously stored graph is /// reused without rebuilding (caller then refreshes input data). pub fn try_reuse(&mut self, params: &GraphParams) -> bool { match (&self.prev_params, &self.graph) { (Some(prev), Some(_)) if Self::params_match(prev, params) => { self.prev_params = Some(params.clone()); true } _ => false, } } fn params_match(a: &GraphParams, b: &GraphParams) -> bool { a.n_tokens == b.n_tokens && a.n_out == b.n_out && a.gtype == b.gtype && a.cparams == b.cparams && a.weights_version == b.weights_version } }
No node walk, no comparison of 440 nodes — six field comparisons, because
§2.7's invariant makes them sufficient. The caller
(Qwen2Graph::forward_cached, models/qwen2/graph.rs:456-472) shows the
whole production loop in one glance: try_reuse; if it fails, build →
register weights → assign_backends (doc 06) → FusionPass (doc 06) →
alloc_graph (doc 07) → store in the cache with a fresh uid; then execute
(doc 08) and refresh input data. Notice that CParams.fuse_qkv/fuse_ffn
are read from the environment at params construction (MINFER_NO_FUSE_QKV=1
etc.), so the A/B toggles force rebuilds through the front door — a test
(fuse_flags_are_part_of_the_reuse_identity) guards exactly that.
3.3 Design choices (why this shape and not another)
Q1: Why a declarative graph instead of the obvious imperative loop?
The loop is genuinely simpler to write — the old forward.rs did exactly
that, and it worked. Four things it could not do well:
- Decode-step reuse. With a graph, one token of generation = fill 2–3
input buffers + walk the node list; the expensive part (build → assign →
fuse → allocate) runs once per distinct
GraphParams. - Backend assignment before execution. Deciding "this node runs on Metal" requires the global picture before running anything, so splits and cross-backend copies can be planned (doc 06/08). A loop that computes as it goes must decide mid-flight — which is how the old design ended up with per-layer fallback heuristics and surprise host round trips (ARCHITECTURE.md Appendix A.3).
- Fusion as a rewrite pass.
Mul(Silu(x), y) → SwiGLUis a few lines of pattern matching over the IR (fusion.rs:39-87), applied wherever a backend has the kernel. In an imperative loop the same optimization is hand-woven into the control flow of every model's forward code. - Observability.
--dump-graph/--dump-graph-jsonare ~40 lines each because the forward pass is data (dot.rs:13-55). The old loop had to be reverse-engineered from breakpoints.
The cost is real but bounded: 273 + 322 + 496 lines of IR/builder, and every
model expresses its forward as builder calls (Qwen2's build function is ~240
of its 2244 lines). In exchange, the old forward.rs was deleted outright
once the graph path matched it bit-for-bit (Phase 6, plan §17: "after
deletion full suite 78 pass; CLI default-path output consistent with the old
implementation"). Nothing regression-tested was lost.
Q2: Why must building be pure / side-effect-free?
Because reuse requires determinism: try_reuse compares six parameters and
then skips building entirely — safe only if the skipped build would have
produced the same graph. Purity is what makes "same params ⇒ same graph" even
meaningful, and the debug structural check enforces it (rebuild and compare,
node for node). Purity also makes building cheap and repeatable: the dump
path builds a second graph just to export it (json.rs::build_runtime_graph),
no GPU context is touched, and there is no hidden state to invalidate. The
moment someone reaches into the allocator or the GPU during build, the
invariant becomes unverifiable.
Q3: Why does Op carry full payloads?
So that graphs are comparable and self-describing. Comparison is concrete:
GraphCache::verify_structural walks two graphs asserting op == op per
node, and the unit test op_partial_eq_compares_payloads (mod.rs:255-264)
pins the semantics — RmsNorm{eps: 1e-5} ≠ RmsNorm{eps: 1e-6},
KvcacheLoad{layer: 2} ≠ {layer: 3}. Without payloads in the equality, two
graphs differing only in an eps or a layer index would look identical and the
tripwire would be blind. Self-description is the DOT dump of §2.5 —
KvcacheStore { layer: 23 } printed on the node — and the same payloads drive
execution: the scheduler resolves the layer's persistent K/V regions from
KvcacheStore{layer}, and backends read AttnMeta.scale/nkt directly.
Q4: Why are KV store/load explicit graph nodes at all? The KV cache is
"just memory" — the old design hid it inside a cache object. Making store and
load nodes buys three things. First, ordering for free: execution
follows build order, and the store node is built before the attention that
loads — so "this step's K/V are written before attention reads them" is
list order, guaranteed by the executor rather than by discipline
(doc 08; ARCHITECTURE.md §4.5.5). Second, the allocator sees the truth:
the store node's declared shape [n_kv_embd, n_ctx] sizes the persistent
region, and the load node's absence of edges marks the region as alive —
which is how KV regions survive graph rebuilds while ordinary buffers are
recycled by liveness (doc 07). Third, backends resolve locality: the
layer index lets any backend find the sibling K/V regions
(kv_pair(layer)) — attention and its KV stay on the same backend by
construction, with no per-token KV drain.
Q5: What breaks if topology secretly depends on n_past?
Everything in §2.7, concretely. Suppose the build baked the cache length into
node shapes — say attention reading a KV view of [n_kv_embd, n_past + nt]:
params_match(which does not comparen_past) would call two different graphs "equal" — step 5 would silently reuse step 4's graph, reading the wrong rows. And if you instead addedn_pasttoGraphParams,try_reusewould fail on every decode step: the graph rebuilds every token, the allocator recomputes its liveness mapping every token, and theuidchurns, defeating the CUDA Graph replay cache keyed on it — decode throughput collapses to build-and-allocate speed.- The debug structural check would catch the mismatch (equal params, different topology ⇒ panic) — but only in test builds; production takes the silent path.
This is why the invariant is repeated in the three places a reader will trip
over it — the ops.rs module doc, the params.rs module doc ("n_past
deliberately absent: it is execution data"), the builder's input doc
comment — and asserted by the unit test kv_nodes_carry_layer_only
(builder.rs:470-484), the smallest expression of "positions are data".
3.4 Pitfalls & invariants
- KV positions are data, not structure.
KvcacheStore/Loadcarry only{layer}; positions arrive via thepositionsinput node. Topology must never branch onn_past(Q5). Guarded bykv_nodes_carry_layer_only. - Topological by construction. A node can only reference ids already
returned by the builder;
topo_order()validates rather than sorts. Execution order = build order (doc 08) — which is also why a KV store built before its attention is guaranteed to run before it. - Node order is not semantics — the edges are.
tail_idsis declared at the graph head but consumed at the last layer (graph.rs:55-60), purely to keep input nodes from fragmenting execution into extra backend boundary segments. When readingbuild, followsrc, not position. - Fused and unfused are both real graphs, and they must agree. The
fusions are gated by
GraphParamsfields, so both topologies are first-class, A/B-able via environment variables, and bit-identical in output (asserted byfused_qkv_matches_unfused_decode). One recorded lesson (plan §17.26): when comparing fused vs unfused, the unfused path must still run the FusionPass — an unfused graph executed without it computes silu+mul as two kernels, differing from the single SwiGLU kernel by ~1e-6 float noise that amplifies at large magnitudes. - Fusion orphans stay in the node list. After
Mul→SwiGLUrewrites, the oldSilunode has no consumers and gets no buffer; the executor skips bufferless nodes (scheduler.rs:228-233: "dead nodes … are skipped, not executed"). Do not panic at a node count that exceeds the number of "real" operations. - Attention's output shape comes from metadata, not from Q. With
FusedQKV, Q is a slice of a wider concat buffer, soattnsizes its output fromAttnMeta(builder.rs:607). Shapes in this IR are declared facts, not derived ones. - GPU participation and fusion toggles are part of the reuse identity
(
CParams.gpu,fuse_qkv,fuse_ffn): they change the topology, so they must (and do) change the reuse decision — a test locks this in after it was nearly broken (cache.rs:135-140). - Weights are referenced by name, registered elsewhere (doc 03): a
MatMulMeta.weight_namewith no registered weight is an execution-time error — the IR cannot check it, and purity forbids it from trying.
4. Observe & verify
--dump-graph <path>— exports the forward pass as Graphviz DOT (nodes colored by assigned backend, inputs/outputs as double circles) and exits; the run prints the node count. The §2.5 census came fromMINFER_DISABLE_MPS=1 ./target/release/minfer <model> "Hello" --dump-graph /tmp/g.dot(440 nodes) and the same with--no-templateand a one-token prompt (437, the decode graph).--dump-graph-json <path>— the same graph as JSON forviz/(web visualizer); the JSON also carries theGraphParamsthat produced it.minfer viz <model>— live graph page with per-node data over SSE, built by the samebuild_runtime_graphhelper.MINFER_TRACE=<path>— per-node real-data trace during a run; the bridge from this doc's structure to doc 08's execution.MINFER_GRAPH_DUMP=<dir>— dumps per-node logits/KV outputs of the live graph (any build) for offline comparison.- Tests (all in the current tree):
topo_order_validates_chain/topo_order_detects_cycle/op_partial_eq_compares_payloads(graph/mod.rs),builder_creates_topo_sorted_graph/kv_nodes_carry_layer_only(graph/builder.rs), the reuse quartetreuse_requires_equal_params/fuse_flags_are_part_of_the_reuse_identity/allocator_survives_rebuild/structural_check_detects_different_graph(graph/cache.rs), and the model-levelgraph_logits_match_forward_real_model/tail_reduction_matches_full_nt/fused_qkv_matches_unfused_decode(models/qwen2/graph.rs). Historical acceptance bar: graph logits max diff 0.000e0 vs the old imperative path, prefill and decode (plan §17, Phases 5 and 8).
5. Cross-references
- 04 — Tokenizer and chat template: produces the
token ids this stage wraps into the
token_idsinput node. - 06 — Backend assignment and fusion: walks this graph
and fills every
backend: None; runs the FusionPass whoseMul∘Silu → SwiGLUrewrite orphaned the silu nodes in the census. - 07 — Allocator, liveness, KV regions: turns shapes into buffers, shares memory by liveness, and owns the persistent per-layer KV regions this stage merely declared.
- 08 — Scheduler + execute: executes nodes in build order — the guarantee behind "store before the attention that reads it".
- 09 — Prefill forward path and
13 — Decode loop + graph reuse: the two
callers of
forward_cached— whereGraphParamscomes from and how the params-only reuse behaves across hundreds of steps. - 11 — Attention + vec ops + KV: the kernels
behind
RmsNorm,RoPE,Attn, and the KV store/load — including how positions drive masking and cache writes. - 03 — Model dispatch and weights: registered every weight under the names this stage's metadata references.
docs/ARCHITECTURE.md§4 — the verified design summary this doc expands (§4.6 has the mermaid layer diagram); Appendix A preserves the imperative design this replaced.docs/COMPUTE-GRAPH-DESIGN.md§3–6 — the design record (IR, builder, fusion rules, reuse) and §17 — the phase-by-phase implementation log with the measured fusion/tail numbers quoted above and the full deviation list.docs/LLAMA-COMPUTE-GRAPH.md: the llama.cpp equivalent (ggml_cgraph,llm_graph_context,inp_out_ids) minfer mirrors.viz/README.md: the visualizer data format;docs/GLOSSARY.md: backstop definitions (GQA, RoPE, SwiGLU, liveness…).
← 04 — Tokenizer and chat template · Index · 06 — Backend assignment and fusion →