Section 8 - From Graphs to a Language Model¶
Every primitive this guide covered — body, handles and .step(...)
(Section 2), per-call bounds (Section 3), weights and state (Section 4),
carried values (Section 5), the host reference and check=True (Section 6),
profiles (Section 7) — is what iron/lm/ is built from. A
language model is not a new layer of machinery on top of iron.Graph; it is
a Graph recorded the same way the rest of this guide's graphs were, over a
module tree of weights that load from a checkpoint by name instead of being
constructed as plain numpy arrays.
A whole model, named¶
iron/applications/llama/model.py
is the whole of Llama 3 on top of the shared decoder:
class Llama(CausalLM):
"""Llama 3 on ``config``'s shape and a checkpoint's ``weights``."""
config = LlamaConfig
profile = Path(__file__).with_name("profiles")
Everything Llama-specific is LlamaConfig (RoPE frequency scaling, and the
precision its wider GEMMs need — Section 4 and 7's territory) and a
directory of profiles (Section 7). The rest — the decoder's layers, its
attention and feed-forward blocks, its KV cache, sampling, chat turns — is
CausalLM, one shared graph every text decoder
(Llama, Gemma4) composes.
Walkthrough of toy_decoder.py¶
A real checkpoint's weights are gated on Hugging Face and are more setup
than a guide should ask for. toy_decoder.py instead draws Llama's own
Llama class a tiny, one-layer config and random weights, the same way
tests/applications/llama/reference/test.py and
iron/tests/support/llama_model.py
build toy-sized decoders for their own device-free and device tests:
TOY = LlamaConfig(
vocab_size=1024, hidden_size=512, num_hidden_layers=1,
num_attention_heads=16, num_key_value_heads=4, head_dim=64,
intermediate_size=2048, rope_theta=500000.0,
max_seq_len=256, prefill_chunk=256,
)
checkpoint = toy_checkpoint(TOY, directory) # a sparse .safetensors file
model = Llama(TOY, checkpoint.tree("random", seed=0))
Checkpoint(path).tree("random", seed) is what a model's own load
reads from a real checkpoint directory; given a sparse file (a header and a
hole, toy_checkpoint's), it draws every weight at seed instead, reading
nothing past the header. Llama(config, weights) then runs exactly what the
real Llama.load does under the hood: CausalLM.__init__ builds the module
tree and calls Graph.load_state_dict (Section 4) to bind it.
CausalLM.body, in the vocabulary this guide taught¶
def body(
self,
ids: Handle | None = None,
*,
token: Carried[np.int32],
position: Carried[np.int32],
start: Scratchpad[np.int32] = np.int32(0),
rows: Scratchpad[np.int32] = np.int32(1),
rope_start: Scratchpad[np.int32] = np.int32(0),
):
...
ids, an optional positional input (Section 2): a prompt chunk's token ids when given,Nonefor a decode step, which instead reads the last sampledtoken.tokenandposition, carried (Section 5): the host seeds them once per conversation; after that, each call carries forward the token it just sampled and the position one past what it just ran.Sessionis what manages one conversation's carried values and caches across many calls;toy_decoder.pydrives the compiled versions directly, the same way Section 5'sWalkdid, to show the values themselves rather thanSession's bookkeeping.start,rowsandrope_start, scratchpad (Section 3):rowsbounds how much of a prompt chunk's compiled capacity this call actually runs, the samex[:n]mechanism Section 3'sBoundedReluused, here reaching every layer's attention and KV cache writes throughCall.
Each layer's attention keeps its KV cache in iron.state (Section 4), one
KVCache per layer, sized by max_seq_len once and read only up to the
current position every call ("one compile, any context",
AGENTS.md).
Compiling its two versions and running a prompt plus a few decode steps¶
decode = model.compile(**model.shapes()[0]) # no input: the decode step
prompt = model.compile(feeds=decode, **model.shapes()[1]) # ids: a prompt chunk
_, carry = prompt(ids, token=..., position=..., start=0, rows=8, rope_start=0)
for _ in range(decode_steps):
_, carry = decode(**carry) # carry is exactly decode's kwargs
model.shapes() names each version's input shape, the decode step first
(none); feeds=decode sizes the decode step's Emit to start from the
prompt chunk's carried values, on NPU2. The prompt call's carry comes back
as exactly the keyword arguments the decode step's body takes, so
decode(**carry) chains one call into the next — the pattern
iron/lm/session.py's host_draws uses to walk
a device decoder the same way, and that CausalLM is built to support once
dispatched end-to-end on the full-ELF device loop (CarriedLoop, Section 5).
Checking it¶
_, ref_carry = model.reference(ids, token=..., position=..., ...)
for _ in range(decode_steps):
_, ref_carry = model.reference(**ref_carry)
graph.reference(...) (Section 6) runs the identical body on the host.
Greedy sampling is deterministic, so a correct device run draws the exact
same tokens as the host reference — toy_decoder.py checks the drawn token
sequences for equality, which needs no tolerance at all: either the device
drew the same token or it did not.
A whole decoder has dozens of chained rounding steps (every projection,
norm, activation), so no single step's tolerance covers its logits the way
one did in Sections 2 and 4. compile(check=True) is what verifies a graph
like this: each step checked against its own operator's reference() and
tolerance(), on its own dispatch, raising at the first one that misses.
Most of a prompt chunk's steps are bounded per call (rows, Section 3), and
a per-call bound's reference is not known until the call, so check=True
logs those as skipped rather than silently passing them:
— an honest limit, not a gap in the guide: a per-call-bounded step still
runs and still produces a result, just one check=True cannot judge
without the bound it receives only at the call it is checking.
Exercises¶
- Run
python3 toy_decoder.pyon an attached NPU2; it compiles both versions, runs a prompt and a few decode steps, checks the drawn tokens against the host reference, and recompiles withcheck=Trueto verify every step that can be. - Raise
--decode-stepsand watch how many tokens still agree with the host reference — a toy model's random weights make for an arbitrary sequence, but correctness never depends on what the weights mean. - Read the quick start in the top-level README again:
from iron.applications.llama import Llama
model = Llama.load("meta-llama/Llama-3.2-1B")
print(model.generate("The capital of France is", 8))
load does what toy_decoder.py did by hand — build the model, compile
its versions, use them — over a real checkpoint's weights instead of
drawn ones; generate drives a Session the way this section's loop
drove the compiled versions directly.