AGENTS.md¶
The rules a coding agent or contributor follows in this repository, and the map of where things live. Each module's docstring is the reference for what it does; this file does not repeat it.
IRON is a Python library for AMD Ryzen™ AI NPUs (NPU1 is AIE2, NPU2 is
AIE2P) over mlir-aie's aie.iron. bfloat16 is its main data type.
Elsewhere:
- README.md: installation, the quick start and the models.
- CONTRIBUTING.md: the pull request workflow, style, licence headers, the git hook, and short walkthroughs for adding a model or an operator.
Environment¶
Install as the README says. XRT must be sourced
(source /opt/xilinx/xrt/setup.sh) before anything runs on a device.
requirements.txt pins the mlir-aie wheel IRON is tested with, a release
or a nightly (latest-wheels-4), which brings the kernel library, and the
llvm-aie (Peano) nightly that mlir-aie commit validated
(utils/peano-requirements.txt). .github/workflows/ci-lint.yml's ref:
is the same commit, the Python pyright types against. A pull request that
needs a newer mlir-aie bumps all three in the same pull request
(update-mlir-aie.yml does it for a release or a nightly).
- Compiled artifacts live in mlir-aie's content-keyed cache,
~/.npu/cache/<hash>/or$NPU_CACHE_HOME; nothing is written to the working directory. IRON_EXAMPLE_WEIGHTS_DIR(default/srv) is where model tests find checkpoints (iron.lm.testing.weights_dir).MLIR_AIE_KERNEL_SOURCES=/path/to/mlir-aiebuilds kernels from another tree; the path is part of the compile key.- No build context: an operator compiles for the current device and
nothing else. On a host without an NPU, bind one with
aie_utils.set_current_device(from_name("npu2", n_cols=8))(aie.iron.device.from_name); the test tree'snpu2fixture does this. Kernels are built with Peano; a kernel that needs xchesscc asks itsaie.iron.kernelsfactory (use_chess=True).
Commands¶
pytest iron/operators/ iron/tests/ -m "not extensive" # what CI runs on every PR
pytest iron/operators/ iron/tests/ # the extensive suite
pytest iron/tests/operators/test_catalog.py -k AXPY # one declared operator
pytest tests/applications/ # model tests
pytest ... -n auto # in parallel
pytest ... --csv-output=tests_latest.csv # write benchmark figures
pyright # types, as CI's Types job
Without an NPU, the device-free suite is
pytest iron/tests/common iron/tests/lm tests/applications iron/tests/operators iron/tests/infrastructure -m "not extensive".
Formatting and licence checks are in CONTRIBUTING.md.
Where things live¶
iron/operators/: one operator per module,relu.py, or a package withop.pywhen it also has a hand-written design, a reference, a README or a device test (flm/gemm/).flm/holds the FastFlowLM ports; its operators are also catalogued under dotted names ("flm.ProjectionGEMM"), which is how aProfilenames them.flm.ProjectionGEMMis notiron.operators.GEMM.iron/common/declare/: IRON'sOperatorover mlir-aie's declaration (aie.utils.compile.jit'sparam(),tunable(),per_call()), and the bound operands andValues a build reads. AValueis one operator'sper_call()member,op.value(name), whichop.<name>also returns except for an extent, which reads as its number. AlsoXclbin,ProfileandPrecision.iron/common/design/: an operator's MLIR module (OperatorDesign) and its runtime sequence (OperatorRuntime).iron/common/graph/:iron.Graph, recording andcompile.iron/common/image/: what a graph lowers onto: the step sequence, the buffer allocator, image composition (several designs in one image), packaging, and an operator called on its own. All internal.iron/common/feature_map.py:Conv2d, a convolution over a channels-last feature map's rows.iron/common/elementwise.py: the shared elementwise array, its families andSweep.iron/common/testing.py:Testing,Case,vectors,run_test,time_call.iron/common/tracing.py:dump_tracesfor an operator built withtrace=.iron/tune/: autotuning, at the edge: nothing in the core imports it.python -m iron.tune.cli GEMV M=2048 K=8192times one operator;python -m iron.lm.tune <application>rewrites a model'sprofiles/<device>.json.iron/lm/: what every language model shares (see "Language models").iron/applications/<name>/: one model each;tests/applications/<name>/its tests.iron/tests/: the tests by subsystem, each moduletest_<name>.py. What several share is iniron/tests/support/; no test module imports another.- Kernels are not here: they are C++ in mlir-aie's
aie_kernels/, reached through the factories inaie.iron.kernels, each returning anExternalFunctionwhose.contractcarries its reference, its tolerance and the scalars it binds.
Hardware¶
- NPU1 (Phoenix, Hawk Point): 4 rows by 4 usable columns. NPU2 (Strix, Strix Halo, Krackan): 4 rows by 8 columns.
- Data moves host (L3) to shim DMA, to memory tiles (L2), to each core's local memory (L1, tens of KB), where the kernel runs.
- An operator's size rules are its
compatible()and its domains; a shape it must refuse is iniron/tests/operators/test_rejected_shapes.py.
Adding an operator¶
CONTRIBUTING.md walks through the common case. The rules:
- One module,
iron/operators/<name>.py; a package withop.pyonly when it needs more than one module. - Declare
class X(Operator): - Parameters are mlir-aie's
param(). A host shape's dimension is one (M: int = param()). A tunable istunable(). Its value comes from the call if given, else the profile, elseresolve(dev), else its default. Identity (array_key,design_key) is taken after resolution. - Operands are aie.iron's
In/Out/InOut, declared by shape. An operand withobj_shape=(in the units a core reads;per=a column count) is its own stream into the array.when=flagmakes an operand exist only where a bool parameter is true. - Every trip count a core reads is a
per_call(derive=...), so the array never depends on an extent. Anything else the array bakes is declaredarray=True. -
A quantity computed from other parameters is:
Read by Declare host Python only @propertya compile-time parameter held to its formula param(derive=lambda op: ...)a compile-time parameter a caller may override param(default=lambda op: ...)a tunable resolve(dev)always fillstunable(init=False)a core per_call(derive=...) -
build(), onlayout(): a copy of the operator holding only the parameters its cores read (declaration.key) and its members. It makes ObjectFIFOs and Workers, usesrange_()(notrange) for core loops, binds each streamed operand's lanes to fifo ends, gives each core anRtpfor its values and returnsBuilt(objects, rtps). sequence(rt)only when the derived runtime sequence is not the one wanted (gemm.py,mha.py).- A shipped binary is a subclass declared with its image,
class Shipped(X, image=Xclbin(url=, sha256=, filename=)), whosebuild()pins each operand's lanes to the image's shim channels (operand.pin([...])) and builds no cores. It cannot run inside a graph. - The kernel comes from an
aie.iron.kernelsfactory. A scalar it takes at run time is the operator'sscalars(), in the kernel's order. A kernel no factory covers is anExternalFunctionwithsymbol_prefix=naming its configuration and acontract=KernelContract(...);iron/tests/toolchain/test_inline_kernel.pyis the example. A new C++ kernel goes into mlir-aie with a factory; IRON hosts none. reference(*inputs)only where the kernel's contract is not already the reference: numpy, upcast to float32, round once. A composite builds it from its kernels' contract references where it can, and says why in a comment where it cannot.ops()where one operation per output element is not the count.tolerance()where the kernel's contract is not what the output is held to (a data mover returnsTolerance.exact()).test = Testing(cases, draw=...)on the class. A tolerance that is not exact says where its number comes from innote=. Every operator without atest.pyof its own has at least onebench=Truecase in the default suite, large enough that dispatch overhead (about 160 us) does not dominate, and alower=Truecase for each layout or dtype its first default case does not reach.iron/tests/operators/test_catalog.pyruns every declaration; a test with a body of its own goes beside the operator astest.py.- Import it in
iron/operators/__init__.py(an flm port iniron/operators/flm/__init__.py).
What an operator overrides¶
| Hook | Required | Runs |
|---|---|---|
build() |
yes, unless an Elementwise base builds it from kernel()/chain() |
once per build |
reference(*inputs) |
unless a kernel's contract already is one | in tests and the graph reference |
resolve(dev) |
if the class declares a tunable() |
the first time it meets a device |
validate() |
no | at construction |
compatible() |
no | once every tunable is resolved |
sequence(rt) |
no | building the runtime sequence |
tolerance() |
no (the kernel's contract) | judging a device run or a check-mode step |
ops() |
no (one per output element) | a test's throughput figure |
extent_unit(buffer) |
no | splitting a bounded call's rows over a round-robin stream |
array_device() |
no (the bound device) | when the array covers part of the device (GEMM) |
xclbin_operator() |
no (itself) | locating the xclbin a shipped subclass runs |
exported_design() |
no (the derived module) | compiling a hand-placed design in its place |
Construction is keyword-only (GEMV(M=..., K=...)) and never records.
Inside a graph's body(), GEMV.step(w, h, **kw) records a step and
returns a handle, as calling an instance, op(h, **values), does. Outside
a graph, op(*tensors) compiles once and runs on the device.
Graphs¶
A graph is a subclass of iron.Graph whose body() is recorded on handles,
once per input shape, compiled to one image per shape (a version) and
called per token. Its docstring and compile's say how inputs, per-call
values, weights, state and profiles are named. The rules:
- A capability a model needs is an operator when it is new compute, and a
graph when it composes operators the library has (
iron.lm'sLogMel,Conv2d,ImagePatches). A model calls such a graph in its body rather than copying its steps. - Tunables come from the graph's
profile, not keywords at every call: a JSON file per device, one entry per operator class and shape, matched on the fields the operator is constructed with (key MHA byseq_pad, notseq_len).iron/applications/llama/profiles/is the worked example; the nightly trends, not a test, say whether a profile pays off. compile(full_elf=None)picks one full ELF on NPU2 and an xclbin per step on NPU1 (iron/common/image/packaging.py;Graph.compilelogs why at INFO).check=Trueruns each step on its own dispatch against its reference and raises at the first step outside its tolerance.graph.reference(...)runs the body through each operator'sreference()on the host.
iron/applications/llama/model.py is the worked example;
iron/tests/common/test_graph.py records it device-free and
iron/tests/toolchain/ builds it.
Design rationale and known limits¶
Check the code before relying on a line here; it is the authority.
Rationale¶
- One array, every extent.
build()sees only the key's parameters, and trip counts are per-call values the sequence writes, so one build serves many shapes.iron/tests/toolchain/test_array_identity.pycompares core ELFs built at two extents. - Length-free extents. A per-call bound (
x[:n]) is mlir-aie's runtime transfer length, in whole 16-byte units. GEMM bounds its compute, not its DMA; MHA moves only the key and value blocks up to the bound, so attention over a cache costs the context, not the cache. - One compile, any context. A decoder is two versions, a decode step
and a prompt chunk;
max_seq_lensizes only the caches and the RoPE table. Caches are(max_seq_len, num_key_value_heads, head_dim)so a call's write is contiguous and no descriptor strides by the context.test_llama_does_not_grow_with_the_contextandtests/applications/long_context/hold every decoder to this. - DMA descriptors. A pattern past one buffer descriptor's limits is
split by the compiler; one whose size a call bounds must fit a single
descriptor (
operand.fits,operand.split). - Placement. Tile placement is left to aiecc's placer unless a physical
invariant needs a pin (MHA's shared-L1 P*V cores) or the placer fails
(GEMM's shim tiles;
GEMMPlacement). A placement choice can be anEnum-annotated tunable, searched over its members. Elementwiseis not upstream'stransform_parallel: its trip count is aValue, the library owns its sequence, and it has its own column budget.
Not built, or limited¶
- An xclbin image is one dispatch per step; several steps in one xclbin dispatch are refused.
- A full ELF call is one command however long it runs: a prompt chunk's attention grows with the context, and a tower's with its image. The driver's command timeout must cover the longest, as the README's installation sets it.
- Tuning times each operator alone, not inside the image it runs in. Kernel cycle counts skip only GEMM settings, only on npu2, and never with a column-major B.
Tests¶
from aie.utils.verify import Tolerance
from iron.common.testing import run_test, vectors
from iron.operators import Tanh
op = Tanh(size=2048, num_aie_columns=1, num_channels=1, tile_size=2048)
errors = run_test(op, vectors(op), tolerance=op.resolved().tolerance())
assert not errors, errors
- A test that dispatches is marked
@pytest.mark.supported_devices(...)and nothing else; conftest.py releases the runtime after it. run_test(..., record=record_property)writes latency, bandwidth and throughput to the CSV; any other figure goes torecord_property.- IRON is numpy only:
ml_dtypes.bfloat16for bf16. torch and transformers are needed only to record goldens (pytest <test> --record-goldens -p no:xdist). - A bug fix comes with a test that fails without it.
CI¶
Each hardware workflow runs one job per runner, Phoenix (NPU1) and Krackan (NPU2):
small.yml:iron/operators/andiron/tests/, non-extensive, on every PR.extensive.yml: the same with extensive tests, nightly and on pushes tomainanddevel.examples.yml:tests/applications/, non-extensive, on every PR: each model loaded once for its shortest call (a 13-token prompt, one token drawn; an embedding model's or a toy's own checks) and the device-free reference tests.nightly.yml: everytests/applications/test, extensive ones too, each night: the depth a model's checks have (accuracy, determinism, sampling, chat turns, long generations, the each-step form, long_context's deep checks); a benched TTFT, TPS or Latency 10% worse than the night before's fails it (pretty_trends.py --fail-on).ci-lint.yml: black, clang-format and reuse, and pyright over[tool.pyright]'s paths inpyproject.toml.pr-comment.ymlandpublish-pages.yml: the performance trends of a PR and the benchmark history site.
On main and devel, publish-results.yml, the only job that can push,
merges each run's tests_latest.csv into {arch}/{suite}/all.csv on the
ci branch (ci/scripts/merge_results.sh); ci/scripts/ turns those into
the PR comment and the charts. Only cases marked @pytest.mark.bench (a
Case(bench=True)) are tracked.
Language models¶
iron/lm/ is what every language model shares; each model is a package
under iron/applications/ over it, and nothing in the library imports a
model. This is the one description of that structure; the model READMEs
link here.
The model layer is JAX-shaped and torch-named. A model is a pure function,
recorded into a graph, over an explicit tree of weights; it keeps torch's and
transformers' names so checkpoints load and references compare by name.
iron.functional gets a function only once a kernel computes it. Per-tile
programs and their autotuning live at the operator and kernel layer, where
tunables and profiles already do that job.
config.py:DecoderConfig, a model's shape from the checkpoint'sconfig.json.checkpoint.py:Checkpoint, a mapped.safetensors(sharded through its index, or a PyTorch checkpoint read without torch);tree("read" | "random" | "unread")is the weights by the checkpoint's own names.blocks.py: the leaf modules that declare their weights withiron.param(Linear,Embedding,RMSNorm,LayerNorm), the blocks a layer is made of (Attention,GatedMLP,MLP,DecoderLayer,EncoderLayer,SandwichLayer,PleLayer), the memory a layer owns (KVCache,NoCache) and the model'sRope; and the front-end graphs.model.py:Model, which loads from a checkpoint's directory or repository id (Model.load, its context fitted to the host byModel.fit) and generates (generate(prompt, max_tokens, sampler=None),stream, timings onmodel.stats); itsrenamesmaps a checkpoint's module names to the parts'.Model.cpu_reference()is what a model is judged by: transformers' logits recorded ingolden.npzbeside the checkpoint (golden.py), or with drawn weights the graph's own reference on the host (OnHost, which runs it in place of the device).causal.py:CausalLM, a decoder as one graph of a prompt chunk and a decode step, its modules aDecoderoflayer_classlayers.session.py:Session, one conversation over a decoder's loaded versions (the tokens its caches hold,logitsand the draws); a model'ssessionis the onelogitsandgeneraterun in, and several share one model.encoder.py,multimodal.py:EncoderandTower, andMultimodal.cli.py:main, the command line every model shares (--helplists its options,-vlogs iron's INFO lines), andexports, a model package's lazy__getattr__.generation.py,testing.py: sampling and the device tests' checks; they need onlylogits(tokens).tune.py:python -m iron.lm.tune.
A model's device test is tests/applications/<name>/test.py and imports
its application as iron.applications.<name>: CI's example suite runs
tests/applications/, and the benchmark history names a row by that
directory. A decoder's device-free test is
tests/applications/<name>/reference/test.py.