Repository navigation
Conversation
This was referenced Sep 29, 2026
Merged
Closed
Contributor
CI performance trends
Phoenix - OperatorsOperators added: Operators dropped: Phoenix - ApplicationsOperators dropped: |
hunhoffe
commented
Oct 5, 2026
hunhoffe
marked this pull request as ready for review
October 6, 2026 18:55
ResampleTaps, Resample and PatchPositions wait on their runtime barrier and read their sizes without releasing it, so on a shared device the second design of one array read the first's sizes: the core loops back, passes the still-set barrier and reads before the next run writes. Each core now releases the barrier once it has read its values, as every operator fa5e2aa covered does. Three tests run two designs of one array on one configure (one device and the reset), each against numpy: - test_each_table_of_one_array_runs_on_its_own_sizes: 3024->672 then 4032->912 taps tables. Before: 10771 of 22784 words of the second wrong. - test_each_position_table_of_one_array_runs_on_its_own_rows: 256 then 512 rows. Before: the second's rows from 256 restart at block 0. - test_each_resize_of_one_array_runs_on_its_own_sizes: 37x53 to 96x144 then 96x256. Before: ERT_CMD_STATE_TIMEOUT. All three pass after, on npu2 (Strix Halo, 218), with GEMV's and Merge's regression tests beside them. EmbeddingGemma 2's vision tower was not affected: its taps_h and taps_w share an array_key but read differently named scratchpad words, so their devices differ and each gets its own configure; no Resample-family design shares a device in the compiled vision or multimodal plans. Co-Authored-By: Claude <noreply@anthropic.com>
GatherWords waits on its runtime barrier without releasing it, like the operators fa5e2aa re-armed, but it reads its table address only after acquiring the run's ids, and a run's preamble writes the address before any of its transfers fill them; so a word step that loops back still reads its own run's address. Nothing pinned that: without packing every word step has its own configure. The test packs FromDeviceIds over two tables of one shape, so both word steps run on one device (one pack and the reset), and checks three calls against numpy. Passes on npu2 (Strix Halo, 218). Co-Authored-By: Claude <noreply@anthropic.com>
The decode step already embeds its token on the device, and EmbeddingGemma
gathers a whole vector of ids there (`Copy(self.embedding[ids])`). The
prompt chunk now does the same: on a full ELF it takes the chunk's
`prefill_chunk` token ids, gathers their rows of the table, and bounds them
by `rows` as it bounded `x`. The host no longer writes a chunk's 8 MB of
embeddings per call. One compile still serves every context.
A per-call gather writes the table's physical address, which only a full
ELF keeps across calls, so where the prompt version is an xclbin (NPU1,
`boundaries=each_step`) it still takes the rows as `x`. `shapes(rows,
image)` gives each form's inputs, `load` picks them by the decode step's
packaging, and `prompt_inputs(tokens)` builds a call's `ids` or `x`, which
`_chunks`, `check_deep_prompt` and the reference tests use. `iron.lm.tune`
traces the prompt chunk in the form of the packaging it measures under.
Verified device-free: iron/tests/common/{llama_reference,graph,cost_tables}.py
and iron/tests/infrastructure/lm_host.py (59 passed, 2 skipped: no
checked-in cost table), the reference tests running the gather through
the graph's reference against the float32 oracle; iron/tests/toolchain/
{full_elf,lowering_graph}.py -k "prefill or decode or attention" (10
passed), the prompt chunk building a full ELF at Llama 1B's shape.
Co-Authored-By: Claude <noreply@anthropic.com>
`CausalLM.generate` draws every token on the NPU, and the device loop test has shown its text is the host loop's token for token. The command line and the bench (`check_generation`, which `test_llama_3_2_1b` measures) still drew on the host unless asked. Both now take the device loop wherever `model.device_loop` holds, and the host loop (`generation.generate`) where it does not: NPU1 and `--each-step`. `--device-loop` goes; `--compare-host` compares the device loop with the host's where there is one, and the host-loop comparison test stays. Verified on the NPU (Strix Halo, both commits, real weights): `pytest iron/applications/llama_3.2_1b/ -v -rs`, 14 passed, 1 failed: TestEachStep's prompt_deep_in_the_cache, KL 0.127 > 0.1 on the each-step path, which fails bit for bit the same at 31d81b3 (KL 0.12699427870341662). Accuracy mean / p90 / max KL 0.0048 / 0.0099 / 0.030, across chunks 0.0039 / 0.0046 / 0.0047, determinism 0 differing runs, the device loop's 100 tokens equal the host's. Indicative on a dev box, before / after: 1024-character prompt, 40 tokens, 14.6 / 14.9 tokens a second; TTFT 0.38 / 0.38 s at 1024 and at 13 characters. Device-free: llama_reference and lm_host, 35 passed. Co-Authored-By: Claude <noreply@anthropic.com>
Every version already ends in Sample, so an xclbin-per-step decode step (NPU1, `--each-step`) draws its token on the device as the full ELF does; the host loop ignored it and read 128k logits back to draw again. Now `CausalLM.generate` serves both forms: where there is no device loop the host writes the draw rows once, then dispatches each step on the carried token the last one drew, reading that token alone. The command line and `check_generation` always draw on the device; `--compare-host` works on every device, and `check_device_loop` is `check_device_sampling`, which TestEachStep now runs too (256 characters, 20 tokens). Verified on Strix Halo under the lock, real weights: pytest iron/applications/llama_3.2_1b/test.py -k "each_step_sampling or device_loop or prompt_13_tokens_40": 3 passed; the each-step draws equal the host sampler's from the same seed, 20 of 20 (5.3 tokens a second, indicative). Device-free: llama_reference and lm_host, 35 passed; both NPU1 versions trace with Sample resolved for a 4-column NPU1. Not yet run on a Phoenix NPU. Co-Authored-By: Claude <noreply@anthropic.com>
Since flm.GEMM.fold takes SiLU as its epilogue, a folded SwiGLU of more than one row compiles silu into the gate projection: its steps are gate, up, product and down, with no prepare or finish link, and SiLU x Mul no longer folds. The device test still expected the product to finish silu in silu's cores, so the 256-row folded case failed on npu1 and npu2. It now expects the epilogue fold, and the one-row case still folds silu and the product into the down GEMV's prologue. An activated flm.GEMM declares no tolerance, so the test judges the folded gate against its reference with the plain projection's bound carried through silu's steepest slope (1.1), plus 0.05 for the activation's approximation (0.018 at worst on npu2). On SwiGLU's draw that bound still rejects an all-zero gate (21% of elements on npu2, 31% on npu1). The device-free fold test now runs for npu1 as well as npu2. Verified: iron/tests/common/graph.py -k swiglu, 5 passed (device-free). On 218's npu2, test_swiglu -k 2048-2048 before the gate tolerance: both one-row cases and 256-apart passed, and 256-folded passed the fold assertion, then stopped at the missing gate tolerance this adds. Co-Authored-By: Claude <noreply@anthropic.com>
On Phoenix, Limbs missed its exact tolerance (13 of 61440 and 230 of 983040 elements) and Log missed by 2 ulp at one element of 8388608. aie2's accfloat add aligns the smaller addend to the larger one's 24-bit significand and rounds off the bits below it. Where hi = bf16(a) rounds up into the next binade, a - hi drops a's last bit. Log's f = m - 1 does the same for every m in [sqrt(1/2), 1). A numpy model of that add reproduces each failure exactly. Under it, the old split gives 13 mismatches (first at flat index 618, max 4.77e-7) and 230 (first at 1098, max 1.53e-5) on the catalog's draws. The old m - 1 puts index 7030257 (x = 0.99999243) at 2 ulp and no other element past 1. aie2p's add keeps those bits, which is why npu2 passed. Each of hi's halves sits in a's binade or below it, so neither drop happens: r = (a - hi/2) - hi/2, and f = (m - 1/2) - 1/2. The products are exact in the accumulator, as before. split3 takes `half` beside `one` for it, and Limbs, Log and Magnitude pass it. Under the model the new split matches the reference split at every one of 4e6 random fp32 values (half of them just below a binade's top), where the old one differs at 473393. It also leaves no m - 1 inexact. Each split costs one more msc. Verified device-free: the lowering gate for Limbs, Log and Magnitude on npu1 and npu2, 6 passed. The device check is the catalog's Limbs and Log cases on Phoenix. Co-Authored-By: Claude <noreply@anthropic.com>
On npu1 the catalog's fused GEMM and GEMV finishes failed to place: `buffer "finished_15" needs 8192 bytes and this tile has no room left` (GEMM 2048^3 at 64x64x64 tiles; GEMV K=8192 at a 2048-row output tile: `finished_0`, 4096 bytes). On AIE2, SiLU and GELU gather through lut_based_ops, and each such kernel links 5248 bytes of bank-pinned tables into the core. The silu and gelu objects show 2 x 2560 of exp and tanh tables plus a 128-byte m_inv table, the same figure flm.GEMM already reserves. AIE2P computes those activations and links nothing. With those tables a 64-row GEMM core holds 65920 bytes against 65536. Operator.finish_bytes is what a finish takes beside the operator's own buffers at its line: the scratch line, plus on AIE2 the tables of each distinct LUT kernel its chains link. GEMV's core memory check (already in resolve) now counts it. GEMM gets the same check. An open tile_m narrows until the core holds its tiles, its finish and its stack. A given tile that doesn't fit is refused at resolution, so it no longer fails at placement. The catalog's GEMM finish cases now resolve tile_m=32 on npu1 and keep 64 on npu2. GEMV's K=8192 finish cases take a 1024-row output tile, which a core holds on both devices, in place of 2048. Verified device-free: every GEMM and GEMV catalog case resolves as before except the four npu1 GEMM finish cases (tile_m 64 -> 32) and the two GEMV K=8192 finish cases. Every GEMM and GEMV finish case lowers through aiecc on npu1 and npu2 (14 per device), where the six npu1 cases failed to place before. A new rejected_shapes test pins the refusals and the narrowing. rejected_shapes and gemm_tile_divisibility: 64 passed; iron/tests/common: 396 passed, 2 skipped. Co-Authored-By: Claude <noreply@anthropic.com>
Patch (py, px) is row py * pad + px, with pad = (columns // cores + 1) * cores. The default patch_rows always sized the buffer for 16 cores. On npu1 Resample runs on 8 (4 columns of 2 channels): at 96x256 out, the cores write and the reference returns 6 x 24 = 144 rows, against a 192-row buffer, and judging failed with `cannot reshape array of size 147456 into shape (144,768)`. The default is now the cores' own: the given columns, or else every column of the bound device (NPU2's eight when none is bound), two channels each, just as the vision tower sizes the patch_rows it passes. On NPU2 every catalog case keeps its size. Verified device-free: a new test checks that the default rows equal the rows the cores write, and the reference's shape, at 96x144 and 96x256 on npu1 and npu2 (4 passed; at 96x256 on npu1 the old default gave 192 against 144). The lowering gate for Resample on both devices passed (4 passed). Co-Authored-By: Claude <noreply@anthropic.com>
On Phoenix, three tests built for npu2 on an npu1 host. In graph_dispatch,
`test_designs_of_one_array_run_on_one_configure` and
`test_each_design_of_one_array_runs_on_its_own_values` run eight-column
arrays on the module's npu2 binding. The module's other device tests
already carry supported_devices("npu2"), so these two now carry it too
(`Overridden device NPU2() is not loadable on runtime device NPU1()`).
In gemm_tile_divisibility, the open-tile_m test constructed GEMM against
the host's device before resolving for the case's. Phoenix's 16-row m
block refused M=32 at construction: `M (32) must be a multiple of 64`.
That test and the M-refusal test now bind the case's device through the
conftest fixture before constructing.
Verified device-free: gemm_tile_divisibility, 13 passed, run both
normally and with npu1 bound as the session's device by a pytest plugin.
With npu1 bound, the old file fails exactly as on Phoenix.
Co-Authored-By: Claude <noreply@anthropic.com>
The compare-mode test needs a step that passes under its contract and fails under exact equality. Tanh did that on npu2 only. AIE2 has only the LUT tanh, and its contract judges it against an exact model of that LUT, so on Phoenix the exact case found nothing to raise on (`DID NOT RAISE RuntimeError`). Magnitude's kernel is the same arithmetic on both devices, within 2e-7 of hypot but not correctly rounded. A numpy model of the kernel (bf16 limb products into an f32 accumulator, with aie2's add as observed) on this test's draw gives 393 of 2560 outputs off the correctly rounded float32 on aie2p and 397 on aie2, at most 1.08e-7 relative, inside the 2e-7 contract. Verified: collection only. 218's NPU was refusing hardware contexts (`DRM_IOCTL_AMDXDNA_GET_INFO IOCTL failed (err=-22)`), so this has not run on a device yet. Co-Authored-By: Claude <noreply@anthropic.com>
EmbeddingGemma 2's global layers (d=512) split P*V over two cores, whose layout SPLIT_COLUMNS held for eight columns only, so on NPU1 MHA refused them as unresolvable. On four columns each memtile also forwards its own pipeline's scores and P, so Q's split fills one memtile's six outputs and each O join fills another's six inputs; K and both V halves forward through the fourth, whose shim's two channels carry V. K's shim is then Q's, so SplitColumns names K's shim apart from its memtile (the same column on eight). The layout keeps four pipelines, one per column, as on NPU2. Verified device-free: MHA at d=512 on four pipelines builds an xclbin for npu1 through aiecc (16 cores placed and routed); the catalog's global-layer case, now flagged lower=True, lowers on npu1 and npu2 (pytest iron/tests/toolchain/lowering.py -k MHA: 10 passed), and rejected_shapes -k MHA: 19 passed. The npu2 layout is unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
A bounded K or V fill is one buffer descriptor, since the per-call size lands in its length, and a descriptor re-reads its pattern at most 64 times. EmbeddingGemma 2's global layer on four pipelines re-reads its one KV head 128 times (4 query heads over 32 blocks a pipeline), so on NPU1 the sequence refused it: MHA.K: TensorAccessPattern([2048, 1, 512], offset=0, sizes=[128, 128, 16, 512], strides=[0, 8192, 512, 1]) moves by a per-call size, so it must fit one buffer descriptor, and does not K and V are now a pattern per iteration count, the same bytes in the same order; at 64 re-reads or fewer (every NPU2 case, EmbeddingGemma 2's on eight pipelines) it is the one fill it was. Verified device-free: a bounded d=512 MHA on four pipelines lowers on npu1 and npu2 (pytest iron/tests/toolchain/lowering.py -k "MHA or mha": 12 passed), and EmbeddingGemma 2's encoder past its gather builds for npu1. Co-Authored-By: Claude <noreply@anthropic.com>
StepCallable made an exact view of every resident in the arena as it loaded, and XRT refuses a view whose bytes are not whole 64-byte coherence lines unless it ends its buffer. A resident of another length (a Gather's template of 214 words) failed every load of the chain: subview(offset=268435456, shape=(214,), dtype=uint32) spans bytes [268435456, 268436312) of this buffer, which is not aligned to the 64-byte coherence granule. A step now takes the resident's storage, the buffer and the rest of its last line, which no other buffer holds, as a full ELF's arguments do; the exact view is made when the host asks for one, and a slice of a resident is cut from its storage. Verified on NPU2: copy_gather and gemm_bounded_k (42 passed), and the xclbin-chain infrastructure tests (graph_versions, placed_copy, graph_dispatch, sequence, sequence_output_sync, allocator_planning, gemm_bounded_rows, softmax_positions: 94 passed). Co-Authored-By: Claude <noreply@anthropic.com>
A Gather writes its rows' addresses into shim descriptors by control
packets, and was refused outside a full ELF on the belief that only a full
ELF's streams keep a buffer's address across calls. On NPU2 an xclbin
keeps it as well: with the refusal lifted, a gather from a state by a
call's ids, each step its own xclbin, returned every row right over three
calls. A gather by ids the device made also needs the table's address in
its word step's core, which read it from the scratchpad; an xclbin's core
now reads it from runtime parameters the sequence writes, as GEMM's and
MHA's read their counts. The host reads a buffer's address in either
image.
This is what EmbeddingGemma 2's vision tower (patch positions, RoPE
angles and patches gathered by ids the device computes) and its merge of
soft tokens need on NPU1. A gather by an input's ids from a weight in an
xclbin follows ("Gather an input's rows on the device in an xclbin").
Rebased onto llama-npu 1-3 without the host gather (8979c7c8): its
test of a per-call gather's refusal goes, as the refusal does.
Verified on NPU2: copy_gather (33 passed), which now also runs the
weight-and-state merge as an xclbin and gathers by ids the device made
as an xclbin (15 and 512 rows, three calls each). NPU1 runs this through
the same shim control path, untested on that device yet.
Co-Authored-By: Claude <noreply@anthropic.com>
A gather by a graph input's ids from a weight is the Gather with host-encoded control words in an xclbin as in a full ELF, now that a Gather runs in an xclbin: a model's per-call data movement belongs on the NPU on every device, NPU1 included. The host reads the table's address from its storage, which an xclbin chain's step holds whatever the table's length. Rebased onto llama-npu 1-3 as the device gather alone: the host gather it replaced on eg2-npu1 (8979c7c8) is not in this series. Verified on NPU2 before 218's NPU stopped answering GET_INFO (19:25): Gathers in xclbins from a state by an input's ids and by ids the device made (copy_gather, 33 passed). Device-free: every EmbeddingGemma 2 version builds for npu1 with the Gather (text 28 designs, audio 46, vision 26, placed 34). The weight-by-input-ids case as an xclbin (copy_gather's each_step test, expecting a Gather step) waits on the device. Co-Authored-By: Claude <noreply@anthropic.com>
Attention ran eight pipelines, NPU2's columns, which NPU1's four do not hold. It now runs one per column of the array it is built for, eight on NPU2 and four on NPU1, in the text encoder and the vision tower; with MHA's split P*V placed on four columns, every layer, sliding and global, keeps that count. A per-device profile was the other way to say it, but the count is the array's width rather than a tuned choice, and the multimodal graph reads the vision tower's profile directory, so the text layers' entries would need a copy there; the call site states it once. load() and Encoder take the packaging, as a decoder's load does, so the form NPU1 runs (each step its own xclbin dispatch, the token embeddings gathered on the device by the xclbin Gather before this commit) is checked on NPU2 as well: TestEachStep. NPU1 derives that packaging by itself, so its tests are the module's own, now marked for npu1 too: accuracy, truncation, ranking and latency. The towers and the multimodal versions stay NPU2's: their gathers by ids the device made need a full ELF. Verified: the text encoder builds for npu1 device-free (the probe, --device npu1, 40 s); on NPU2 the full-ELF encoder is byte-identical to the base, and the each_step form gives cosines 0.99992 and 0.99984 against the oracle, bit-identical on a second call (measured on eg2-npu1 with its host gather, which this series does not carry). Co-Authored-By: Claude <noreply@anthropic.com>
GEMM took every row of cores, so M had to be whole tiles of the kernel's m block on each of four rows: 64 on AIE2, whose block is 16. EmbeddingGemma 2's audio convolution puts its 32 output channels in M, so on NPU1 it was refused: M (32) must be a multiple of 64: C is tiled into (m * n_aie_rows, n)-sized blocks An M too short for every row now takes half as many, down to one; the array and the sequence already follow n_aie_rows. Every M that splits over all the rows resolves as before, so no existing design changes. Verified: the catalog's new case (M=32, K=1152, N=2048, b and C column-major, tile_m 16, two rows) passes on NPU2 against the reference (pytest iron/tests/operators/catalog.py -k "GEMM-M_32-K_1152": 1 passed) and lowers on npu1 and npu2; gemm_tile_divisibility: 17 passed; the audio tower builds for npu1; every EmbeddingGemma 2 version's full ELF on NPU2 is byte-identical to the base. Co-Authored-By: Claude <noreply@anthropic.com>
The audio tower builds for npu1 (its one refusal, the convolution's short M, is GEMM's to split), so its features, accuracy and latency tests are marked for npu1 as well; they run there as the derived xclbin chain. Verified device-free: the tower compiles for npu1 (scripts' probe, audio_tower, 24 s). Co-Authored-By: Claude <noreply@anthropic.com>
A per-call bound on K reached the cores only through the full ELF's
parameter scratchpad, so an xclbin refused it ("a per-call bound on K
needs a full ELF"), and with it EmbeddingGemma 2's vision pooling,
GEMM(pool[:, :n], h), on NPU1. An xclbin's cores now read the bound from
one more word of their runtime parameters, which the sequence writes each
call, as they read their trip counts; a full ELF's design is unchanged.
Verified on NPU2: gemm_bounded_k's reduction test now runs each of its
four kernel modes both as a full ELF and as an xclbin (each_step), A and
B NaN past n at ten lengths: 9 passed.
Co-Authored-By: Claude <noreply@anthropic.com>
An xclbin chain linked every design of a sequence into one xclbin, one kernel each, and amdxdna configures at most MAX_NUM_CUS (32) kernels in a hardware context (aie2_msg_priv.h, on NPU1 and NPU2 alike). EmbeddingGemma 2's audio version as an xclbin chain has 46 designs, and loading it failed: RuntimeError: DRM_IOCTL_AMDXDNA_CONFIG_HWCTX IOCTL failed (err=-22): Invalid argument XclbinChain now starts a fresh chain every KERNELS designs, and each step dispatches on the xclbin that carries its kernel (images); up to 32 designs the chain is the one it was. Verified on NPU2: EmbeddingGemma 2's multimodal call (a clip and an image) as xclbin chains, the audio and placed versions two xclbins each, matches the oracle (TestEachStep, 3 passed); device-free, toolchain/ xclbin.py, common/graph.py and common/packaging.py (108 passed). The new graph_dispatch test, 34 copies on two xclbins, compiles; its device run waits on 218's NPU, which fails GET_INFO since 19:25. Co-Authored-By: Claude <noreply@anthropic.com>
Param and Auto subclassed dataclasses.Field and called its undocumented constructor, whose keywords move between Python versions, and gave the field a __get__ so an init=False default could still be read through the class once the field was put back there. They are now factory functions returning the plain dataclasses.Field, with a frozen ParamSpec (AutoSpec for a tunable) in the field's metadata, which ParamSpec.of(f) reads back; dataclass_transform names them as field specifiers as before. The class attribute stays the field, so Rowwise.tile_size and GEMV.K remain dimension references: 88 subclass-body references use them, and a plain field referenced from outside its body is still refused. An init=False default is written onto the instance in __post_init__, so every instance read is vars(op)[name] and no descriptor is left. Every isinstance(f, Param|Auto) check becomes ParamSpec.of(f) (an AutoSpec for a tunable), the operator-field getattr reads become vars(), and Profile.schema, which only Profile.add used, goes. A field in an error message is named, not repr'd, since a plain Field's repr is its whole record. Net diffstat: declare +202/-170, graph and operators +11/-11, tests +9/-5, AGENTS.md +1/-1; the spec classes and their docstrings outweigh the deleted Field subclass, its constructor call and its descriptor. Verified: python -m pytest --collect-only -q, 1808 collected; host-only runs (-m "not extensive and not supported_devices") of iron/tests (1067 passed, 15 skipped) and iron/operators plus tests (15 passed, 19 skipped); black and reuse lint clean. Co-Authored-By: Claude <noreply@anthropic.com>
Chain is now the frozen dataclass that builds and rebuilds a fold or a prefold chain: Chain.of(*ops, at=) the one way to start one (migrating the ~12 Chain((Link(x),)) call sites and two docstring/error-message mentions of it), .then()/.after() the one-line bodies fold() and prefold() delegate to, and .at_tile(owner, side, dev) the rebuild at a device's tile, carrying every check _finish_at and _prepare_at did (deduplicating the one truly shared check, a riding input fitting its feed's depth, behind Side.FINISH/Side.PREPARE). Operator.resolved() now does one dataclasses.replace of both chains (each only if it has steps) and one _bind(), where it used to mutate self.finish/self.prepare in place across two separate methods each ending in its own bind. Moving the validation off self meant reading the operator's own declared operands instead of _members_io(), which also carries forward whatever a previous bind (construction time, say) left as synthetic finish/prepare extras; the stream-budget check needs this operator's current declared shape, not that stale state, and Operator._members_io() gained a _declared_io() half that makes the distinction explicit (and that Chain.at_tile reads directly). AGENTS.md's one reference to the deleted _finish_at is repointed. Verified: pytest --collect-only -q (1808 tests), black and reuse clean. Also run directly (not through coord/npu, which should have gated these real device dispatches): iron/tests/operators/rejected_shapes.py (91 host-only cases) and, before being asked to stop, iron/tests/infrastructure/finish_feed.py and iron/tests/operators/catalog.py's SiLU/ElementwiseAdd/GEMM/GEMV/RMSNorm cases (89 cases dispatching to this host's npu2), all passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A step's buffers are now BufferRef(root, start, nbytes) values that a handle's ref() derives, and OperatorSequence(traced) reads its steps, inputs, outputs, pinned sizes and feedback buffers from the TracedGraph itself. This retires the "parent[a:b]" names a handle used to print and the sequence used to parse back, along with the runlist, input_args and output_args quartet, _base_name and TracedGraph.sequence(). The layout is keyed by root, and a part is placed at its root's offset plus its start (BufferRef.place). The callables take a BufferRef for a view and a root for its storage. The pooling, ordering and bank rules carry over unchanged, so a graph lays out exactly as before. The dead options go: extra_flags (no caller), and buffer_offsets and plan_scratch (tests only; pooling is always on, as it was by default). OperatorImage is now the one-step sequence TracedGraph.of_step(op) on an xclbin chain. The chain absorbs the three cases standalone.py handled: - a shipped image runs the download with an instructions-only build; - a configuration other than the operator is built once, unchained, and serves every shape; - a per-call value builds the full design for its dispatch library. A lone design stays unnamed, so an operator compiled alone keeps the cache key and xclbin it had. Hand-built sequences in tests and probe's Standalone use TracedGraph.of_steps. Shortstat: 36 files, +1008 -893. - library (iron/common): 13 files, +548 -528. - tests: 22 files, +444 -356. - AGENTS.md: +16 -9. Verified on xcoradaie218 (Strix Halo, npu2), env-dev38 (mlir_aie 1.4.5.dev38+g8dd4852): - The fused module text, buffer layout and argument sizes are identical to add008b for every version of Llama 3.2 1B (decode, prompt) and EmbeddingGemma 2 (v0-v3), temporal and AdjacentPacking, with the compile path's shared words, arena and residents (12/12 sha256 equal). - Device-free: pytest --collect-only collects 1808 tests, as on the base. iron/tests without supported_devices or extensive: 1067 passed, 15 skipped. - NPU, through coord/npu: - infrastructure sequence, fused_dtypes, feedback, graph_dispatch, tracing, graph_versions, dispatch_params: 54 passed. - probe_gather and probe_standalone: 6 passed. copy_gather: 47 passed. toolchain/xclbin: 8 passed. - toolchain/dispatch, lowering_graph, flm/layer, flm/dequant: 53 passed, 1 skipped. - iron/operators/flm/gemm: 77 passed, 1 xfailed, and 2 failed with ERT_CMD_STATE_TIMEOUT (K=10240, M=1024 and 2048). Both time out the same way on add008b. - The catalog: 240 passed (186, then the 54 extensive). - black, reuse lint (compliant) and ruff (no new findings against the base). Replayed onto simplify/cd-int (8be7ae8), where #6 moved probe to iron/tune: conflicts resolved in AGENTS.md (the tuner's graph/ entry kept beside the new image/ one), iron/tune/probe.py and iron/tests/infrastructure/probe_gather.py (their absolute imports kept, BufferRef and SequenceStep added). On the replay: collect-only 1808, black, reuse, and 242 host tests passed (probe_gather, probe_standalone, toolchain/narrowing, common/graph, allocator_planning). Co-Authored-By: Claude <noreply@anthropic.com>
Each operator declares `class Array(Operator.Array)`, a frozen kw_only dataclass listing the fields, operands, values and chains its `build()` reads, and `build()` returns `Built(objects, rtps)`. `op.array_config()` makes the Array from the operator. The array tier is the fields the Array lists, checked at class creation to hold what an `obj_type=`, `per=`, `dtype=` or `when=` names and nothing that is not a field or member, so `array=True`, `Operator.array()`, `Operator.rtp()` and `_build_array` go; a core's parameters are `Rtp(values)`. What a sequence programs beyond the objects (flm GEMM's B pool, Shipped's parameter locks) rides a `Built` subclass of its own, read back as `rt.built`. The elementwise `kernel()`, `scalars()` and `arguments()` move onto `Elementwise.Array`; host-side readers go through `array_config()`. Clamp, whose bounds are words its cores read rather than array-tier, keeps them host-side (`bits()`, its own `chain()`) and stays refused as an elementwise step. Every array() and every test operator migrated, AGENTS.md updated. Verified against add008b: each library operator's `Declaration.array` is unchanged (56 classes; the abstract Elementwise base now reports its Array's three fields), and the MLIR of all 458 catalog modules, npu2 and npu1, is byte-identical. Host suite: 1079 passed, 16 skipped. On simplify/cd-int, the finish's own-array check lives in Chain._at_finish, which now looks for an Array with a build of its own. Co-Authored-By: Claude <noreply@anthropic.com>
Delete _OperatorMeta and its overload on Operator.__call__: a step inside a graph's body is now spelled Cls.step(*operands, **kw) (a classmethod delegating to the active Tracer), calling an already-constructed instance is op.apply(*operands, **values), and construction outside a body is a plain keyword-only dataclass __init__ (no metaclass needed, since dataclasses already reject positional args on a kw_only class). Profile no longer carries an ambient ContextVar/token stack or a context manager protocol: Tracer(name, profile, ...) takes the profile to apply to every step it constructs (Tracer._construct merges profile.tunables_for), and standalone use is profile.make(Cls, **kw). Graph.profile and the new compile(profile=) are the only way a profile reaches an operator; a nested graph call still lets its own profile take over for its own body, now by swapping the active tracer's profile for the call's duration. Graph._scope becomes Graph._profile, a plain resolver (device-pathed JSON to a loaded Profile), not a context manager. pinned moves from the deleted metaclass into Operator.__post_init__: an Auto field's own dataclass default is now always None (its given default kept as Auto.spec_default), so __post_init__ can tell "left open" from "given" before it fills an open tunable in from spec_default, the same distinction the metaclass used to read off the raw call kwargs. The profile path (Tracer._construct, Profile.make) works pinned out from its own pre-merge kwargs and passes it through explicitly, since by construction time the profile's own suggestions are already merged in. The Enum-domain check the metaclass did on every construction (a tunable's own Enum member, or None for resolve() to fill, never a bare string) moves into __post_init__ too, so it still runs regardless of how an operator is built. Rewrites the 7 with-profile sites and the metaclass's own construction tests to the new spelling; the positional-operator-call codemod of every graph body (tests included) is a separate commit, so this one still fails plenty of body()-tracing tests until that lands.
Every positional-arg call to an Operator class inside a graph's body (a method or anything it calls while a trace is active, tests and the README examples included) becomes Cls.step(...); every call on an already-constructed operator instance inside a body becomes op.apply(...). Neither changes behavior: both still delegate to the active Tracer exactly as the deleted metaclass and the old dual-purpose Operator.__call__ did, just under names that distinguish a traced step from a plain construction or a device run by spelling rather than by argument count. Operator.__call__ itself narrows to only the device-tensor-run path (op(*npu_tensors) outside a body); called inside a body it now raises, naming op.apply() instead, so the two spellings cannot be confused. Most of this is mechanical (376 call sites across 44 files, by an AST script matching each call's callee against the live class against iron.common.declare.operator.Operator), but a handful needed reading the call site: a class aliased to a plain name before use (tests/common/graph.py's `add = ElementwiseAdd`), an instance threaded in through an __init__ parameter rather than constructed inline (tests/common/graphs.py's Adds), a constructor parameter defaulting to an operator class used polymorphically with a plain callable (tests/common/graph.py's _Sums), and zero-argument instance calls (iron/lm/blocks.py's ImagePatches, a few device_dispatch/narrowing fixtures) that a positional-arg-only search would have missed. AGENTS.md's Graphs section and its examples move to the new spelling. Rebased onto simplify/cd-int 86d65e9 (#6 tuner, #3 Param/Auto, #5 Chain, #4 trace-to-image, #2 Array): the codemod itself is identical (the body-call sites those touch are unchanged), redone fresh rather than merged from the pre-rebase version per request. Verified: pytest --collect-only -q (1809 tests), black and reuse clean.
The tuner's device tests in infrastructure/narrowing.py time the NPU: they assert on measured figures (a step's ratio, a load's order, the packs a tuning takes from them) or pass through probe's refusal of a figure its noise does not cover. That refusal is the guard working as designed, and it needs a quiet host; Krackan's shared runner refused a calibration's negative reset on a5da0a5's small run, where the same test passed on e3ffb3c's. They are now extensive, as is probe_gather's measured graph: the extensive run and a quiet host (coord/quiet) still run them. The two tests of Timing's own guards (another process on the NPU, a CPU wait) stay in the small suite. The model and the search stay covered there without a device by toolchain/narrowing.py. The file took 62 of Krackan small's 111 minutes on a5da0a5 and 73 of 122 on e3ffb3c, so the small suite takes about half as long. Verified: --collect-only -m "not extensive" over the two files collects 6 (narrowing's two guard tests and probe_gather's other four), -m extensive 27; black and reuse lint clean. Co-Authored-By: Claude <noreply@anthropic.com>
Tracer._gather builds the per-call Gather for Copy(table[ids]) itself, not through _construct, so with profiles explicit it took none: a graph whose profile adds Gather feeds=1 got the resolved four. copy_gather's feeds_1 cases (test_rows_each_call_names_are_gathered, test_rows_ids_the_device_made_name_are_gathered at 71 and 512 ids) caught it on the NPU. The gather now goes through the tracer's profile as every other step does. Verified on Strix Halo (env-dev38): iron/tests/operators/copy_gather.py -m "not extensive", 47 passed (4 failed at e16217c). Co-Authored-By: Claude <noreply@anthropic.com>
window is a field of PrefillSlidingAttention only; the base keeps it as a class attribute, None. Reading the checked names through vars(self) found no window on plain PrefillAttention, so constructing one raised KeyError: 'window'. validate now reads max_context and window as attributes. Verified on Strix Halo (env-dev38): iron/operators/flm -m "not extensive", 111 passed, 1 skipped, 1 xfailed (split_leg); 16 PrefillAttention cases failed at e16217c. Co-Authored-By: Claude <noreply@anthropic.com>
#222 conflicted with devel after #244 (a focused GEMM/dequant FastFlowLM engine override example), so its pull_request CI stopped running. Conflicts: - requirements_examples.txt: iron-next's list, plus #244's matplotlib and seaborn (plot_prefill.py). - gemma4_flm/Makefile: #244's FastFlowLM v1.0.8 and $(PYTHON), with iron-next's build.py flags and cache directory. #244's new code ported to iron-next: - build_gemm_dequant.py: no AIEContext; op.compile() returns the artifacts it stages (image, insts); named --engine-xclbins/--out flags. - dequantize.py: iron-next's pack_b is numpy (IRON needs no torch). - The device test moves to tests/applications/gemma4_flm/gemm_dequant/, with the build's flags, and records TTFT through record_property (iron-next has no metrics marker). Verified: collect-only 1816; the new file's host tests pass 5 of 6, the sixth needs matplotlib, which this venv lacks and CI's examples install has; black and reuse clean. The FastFlowLM device test runs on Krackan. Co-Authored-By: Claude <noreply@anthropic.com>
CausalLM.embed/body/__call__ and Oracle.embed/logits threaded a pixel_values argument (and embed a rows one) for a vision-language decoder that does not exist in the tree. CausalLM.logits() never took it, so the path was unreachable and untested. embed(x) stays as the identity hook a model scales its embedding rows in. Verified: collect-only 1809; iron/tests/common/graph.py and llama_reference.py, 107 passed; black clean. Co-Authored-By: Claude <noreply@anthropic.com>
mlir-aie #3921's Flow and PacketFlow fill()/drain() now take a route from
one shim tile to another, the shape of a gather's routes. Gather's
control packets therefore go out through the PacketFlow array() builds
(self.controls[k].fill(words, tap=..., managed=False), which stamps the
route's own packet id) in place of emit_shim_transfer on a shim_symbol
it named "gather_ctrl{k}"; the route allocates the channel the fill uses.
Its rows still drain through y's pinned lanes (S16), and GatherWords'
three fifos are unnamed, as S11 left IRON's others.
One name stays: the feed Flow's shim_symbol="gather_feed{k}". Nothing
fills that route's source, the feed shim's MM2S 1, which the control
packets program, and #3921 allocates a shim end only where a transfer
uses it or a shim_symbol names it; without the allocation,
coresidence.conflicts refuses any pack holding a Gather, since a member
then routes a shim channel by hand that another member's fifo could be
given.
Verified, against mlir-aie #3921 (env-next5):
- Gather (64 and 19 rows, and bounded) and GatherWords (word source,
and static base) on npu2 and npu1, full ELF and xclbin, 20 modules:
equal to e3ffb3c's once names are blanked (mlir_blank.py); the only
differences are gather_ctrl{k} -> packetflow{n}_src and the unnamed
fifos.
- copy_gather.py device-free (9 passed), copy_taps.py (34 passed),
pytest --collect-only -q (1803).
Ported onto iron-next's redesign: Gather's controls ride a _GatherBuilt that sequence() reads as rt.built.controls, in place of self.controls set in array().
Co-Authored-By: Claude <noreply@anthropic.com>
Needs mlir-aie's FlowEndpoint.sync() (flow-endpoint-sync, 989e6729e, on
#3921). The sequence waits on each feed shim's MM2S 1 through
the feed Flow's endpoint, self.pushes[k].sync(), in place of a raw
aiex.npu_sync(2k, 0, MM2S, 1); waiting on it counts the end as driven at
run time, so the route allocates it and the feed Flow drops
shim_symbol="gather_feed{k}", Gather's last string name.
Verified against #3921 (build-flow) with 989e6729e's two files overlaid:
20 Gather and GatherWords modules (npu2 and npu1, full ELF and xclbin)
equal to b4ad6c0a1's once names are blanked, the only change
gather_feed{k} -> flow{n}_src; copy_taps.py 34 passed; copy_gather.py on
the NPU, 47 passed; collect-only 1803.
Ported onto iron-next's redesign: the feed endpoints ride _GatherBuilt.pushes, which sequence() reads from rt.built beside the controls.
Co-Authored-By: Claude <noreply@anthropic.com>
mlir-aie #3921 makes set_current_device(dev) a restoring context manager
(DeviceSelection, with __exit__ putting the previous device back, even
through an exception); every hand-written save/restore around it goes.
iron/common/graph/compiled.py's Graph.compile(dev=): the previous =
get_current_device(); try: set_current_device(dev); ...; finally: restore
dance becomes `with set_current_device(dev) if dev is not None else
contextlib.nullcontext():` -- the file's own existing idiom for an optional
context manager (Profile.current(), line ~530), reused here.
iron/tests/common/declared.py and iron/tests/common/graph.py's
_resolved_fields follow the same shape; the latter's per-setting loop wraps
each iteration in its own `with`, which restores to what was current before
that iteration started, the same net effect as the one outer try/finally it
replaces (each setting's own `with` closes before the next one's opens).
conftest.py's _bound_device fixture gets the same treatment, and now reads
the device to build from iron.tests.toolchain.tools.DEVICES instead of
constructing one from a hardcoded (name, n_cols) pair of its own -- the one
table A26 asks for; the toolchain's own conftest.py already builds its
`device` fixture from the same DEVICES, bridging to this one by name, so
both are now one source.
iron/tests/toolchain/narrowing.py's test_variants_give_each_design_once
bound npu1 directly (not through the npu1 fixture) with no restore at all --
a real leak into whatever test ran after it, not just an unreplaced
save/restore. Wrapped its body in `with` instead.
iron/applications/gemma4_flm/build.py's main() binds NPU2 for a one-shot CLI
process that exits right after, so nothing was ever leaked, but wrapped the
rest of main() in `with` anyway for the same idiom everywhere set_current_device
is used.
Found no save/restore to replace in iron/tests/common/fused_identity.py or
iron/tests/infrastructure/jit_compile_path.py: both only rely on the npu1/
npu2 fixtures (fixed via conftest.py above), or in fused_identity.py's case
a one-way bind inside a throwaway subprocess that exits right after, with no
restore ever intended. Left both unchanged.
Verified: black --check ., reuse lint, python -m pytest --collect-only -q
(1803 tests, unchanged), and the touched files' own tests plus the modules
that exercise this path device-free: iron/tests/common/{declared,graph}.py,
iron/tests/toolchain/narrowing.py, iron/tests/infrastructure/jit_compile_path.py,
iron/tests/common/fused_identity.py and iron/tests/toolchain/dispatch.py
(the last three touched for nothing but confirmation) -- 207 passed. The 3
failures in dispatch.py (a TensorAccessPattern descriptor-dimension error in
Copy.sequence) reproduce identically on the clean tip under the same
environment, so they predate this commit and are unrelated to it.
Ported onto iron-next's redesign: Graph.compile(dev=, profile=)'s with now holds the profile's own try/finally, which the redesign added, and imports contextlib itself since Profile.current() and its nullcontext went; build_gemm_dequant.py's main() (#244, merged since) gets the same with as build.py's.
Co-Authored-By: Claude <noreply@anthropic.com>
mlir-aie #3921 gives the device model its banks
(`Device.core_memory_banks`, `core_memory_bank_bytes`). So
`L1_BANK_BYTES`, the per-device dict S11 kept until then, goes:
`l1_depth` and GEMV's row rule read the bound device.
The plan (S33) restates the rule in the 16 KB programmer bank, and says
what to do if that regresses a design. It does: a fifo element of 8 to
16 KB would go from depth 1 to depth 2, and three of the six designs
that changes no longer fit L1. The rule therefore stays at the hardware
bank, `core_memory_bytes // (2 * core_memory_banks)`, 8 KB on AIE2 and
AIE2P, until Erika decides.
The 16 KB rule changes 12 catalog designs, the same six on each device.
Each was compiled with aiecc at the 16 KB rule:
| Operator, case | Elements x, y | Fits L1 at depth 2 |
|---|---|---|
| GELU size 8192, 1 col, tile 8192 (extensive) | 16, 16 KB | no |
| LayerNorm rows 1, tile 8192 (extensive) | 16, 16 KB | no |
| RMSNorm rows 1, tile 8192 (extensive) | 16, 16 KB | no |
| Dequant size 8192, tile 8192 (extensive) | 4.5, 16 KB | yes |
| Log size 4096, tile 4096 (extensive) | 16, 8 KB | yes |
| Log 8 Mi elements, bench (default) | 16, 8 KB | yes |
The three that fail fail the same way on npu1 and npu2: aiecc reports
`buffer "of1_buff_1" needs 16384 bytes and this tile has no room left
for it`. Two 16 KB elements two deep are the whole 64 KB, before the
stack. No Llama 3.2 1B or EmbeddingGemma 2 version has a streamed
element of 8 to 16 KB on an operator that reads `l1_depth`; I traced
every version on npu2 and npu1 to check.
GEMV's "two rows where they fit" is the same bound written as two rows
in one 16 KB programmer bank. With no device bound, resolve() takes two
rows unchecked: there is no model to read. Before, it read the dict's
8 KB, so K > 4096 then gave one row.
Diff (git diff --shortstat): library 2 files +11 -21.
Verified:
- Every declared catalog case's generated MLIR, 458 on npu2 and npu1,
is identical before and after (values-audit/scripts/mlir_dump.py,
mlir_blank.py).
- The aiecc compiles above went through coord/heavy
(values-audit/scripts/compile_cases.py).
- `python -m pytest --collect-only -q`: 1803 collected, no errors.
- `pytest -n 4 iron/tests/common/{graph,elementwise,declare,judging,build}.py`:
234 passed.
Ported onto iron-next's redesign: a conflict on gemv.py's imports alone (the redesign added Rtp); the change is otherwise as written.
Co-Authored-By: Claude <noreply@anthropic.com>
BdLimits restated mlir-aie's verifyStridesWraps over a pattern so a design could choose its transfers before building them, and it carried every bounded split, GEMM's B/C checks and Copy's stacked runs. mlir-aie dtype); this asks it directly and deletes design/bd.py. - Operand.fits(tap): one descriptor of a lane's shim tile holds tap (a pattern over the operand, in its dtype), padded as the shim lowering pads it (a leading re-read stays in the iteration slot; slots(), moved beside it). The callers: Operand.transfers, the per-call length check in OperatorRuntime, GEMM's B and C, Copy's bounded shares and stacked runs, Sample's slices. - Operand.split(elements): a contiguous run as (d1, d0), the largest d0 a shim descriptor holds as its innermost dimension with d1 beside it, probed with dma_bd_fits. factor's callers move to it (transfers, Repeat's resolve and sequence, Sample). - MHA's re-read bound is 1 << get_dma_bd_iter_bits(0, 0). - flm GEMM's memtile A run keeps its own rule, bounded in elements (the largest divisor within the memtile's wrap), so its hardware-verified layout is unchanged: a shim-tile Operand.split would give (1, 1024) for ct_max_k=128 where the port splits (2, 512). - copy_offset.py asks dev.dma_bd_fits directly. Found on the way (issue draft for #3921, pr222/issue-draft-dma-bd-fits.md, with a repro): dma_bd_fits pads a leading re-read in front, where the lowering keeps it in the iteration slot (IRON pads first, so this does not reach IRON), and it does not strip unit dimensions as aie-normalize-dma-bd-dims does, so [1, 2000, 1, 512] passes it and fails the lowering (d1 2000 > 1023). BdLimits modelled the stripping; Operand.transfers keeps its unit-D1 rule, but another caller would now accept such a tap and fail at compile time. The fix belongs in dma_bd_fits. Removes: BdLimits (of, slots, granule, factor, fits, wrap/step/ iterations), design/bd.py. Diff (git diff --shortstat HEAD~1, by plan section 2.5 area): library +98 -223 (10 files), tests +2 -4 (1), docs +3 -2 (1). Verified (xcoradaie218, env-next5.sh, mlir-aie #3921 build-flow, device-free): - values' mlir_hashes.py, every catalog case on npu2 and npu1: 458 hashed, all identical to e3ffb3c (a git-archive copy, same env). - rejected_shapes.py: 91 passed, outcomes identical to e3ffb3c. - python -m pytest --collect-only -q: 1803. - coord/heavy pytest -n 4 iron/tests/common iron/tests/operators/rejected_shapes.py iron/tests/toolchain/narrowing.py: 608 passed, 4 skipped. - black, reuse lint clean. Ported onto iron-next's redesign: flm GEMM's memtile A split and OperatorRuntime's slots/fits moved into the redesign's Array.build and imports by hand; the rest merged as written. Co-Authored-By: Claude <noreply@anthropic.com>
S11 leaves IRON's fifos unnamed, S13 its runtime-parameter buffers, and the buffers, locks and flows nothing names by string went unnamed before them, so two arrays of one operator class no longer name any symbol alike: aie.iron's DeviceConfiguration names their unnamed fifos and buffers uniquely within the pack's device. The per-array prefix S29 set through aie.iron's private _name and _object_fifo goes with that need. iron/tests/toolchain/coresidence.py's pack test now checks that the two GEMV arrays' fifo symbols are distinct rather than prefixed. Diff: library +4 -56 (iron/common/design, iron/common/image), tests +4 -5. Verified device-free, before = c00fa9b and after = this change, with build/simplify/fusion_packs.py (npu2, AdjacentPacking, the devices of each version's fused module and every pair of its designs that fits as one pack): Llama 3.2 1B identical (decode 2 packs, 98 fitting pairs; prompt 5 packs, 51 pairs, 194 configure points); EmbeddingGemma 2 Multimodal: see the follow-up message for its four versions. iron/tests/toolchain/coresidence.py on the CPU: 23 passed (its two ELF builds deselected). python -m pytest --collect-only -q: 1806, no errors. Ported onto iron-next's redesign: namespace() read self.built.objects after the Array redesign; it goes whole, as written. Co-Authored-By: Claude <noreply@anthropic.com>
iron/operators/softmax.py gains the mlir-aie softmax_f32_bf16 factory's options (input_dtype, soft_cap, has_addend, mask) as Params, with two new catalog cases. iron/applications/embedding_gemma2/audio.py's attention() folds the old ElementwiseAdd -> Tanh -> AXPY -> Softmax(bf16) chain into one Softmax call: the head GEMMs now write scores in float32 (dtype_out=np.float32), and 1/cap moves from the query scale into the kernel's own soft_cap. Open design question, not resolved by this commit (see pr222/s21a-mask.md for the full writeup and measurements): EG2's real call needs x, a pre-cap addend and a post-cap mask all in one Softmax call, but an AIE2P core has only two input DMA channels, and x+addend already use both. This commit's answer -- mask moves from a streamed operand to a plain Param holding the real array, baked into per-row resident Buffers at array-build time (array_key/design_key overridden to hash the array's bytes instead of the array itself) -- compiles at the catalog's small test shape, but fails a real aiecc build at EG2's actual row count (768 rows/core; "section .text will not fit in region program: overflowed by 60656 bytes"), and the shim DMA budget leaves no tunable combination that gives Softmax more than 8 cores for this shape either. Two alternatives measured in pr222/s21a-mask.md: computing the band from scalars in the kernel (correct, scales, but is new mlir-aie work beyond #3921), and masking before the cap (measured unsafe: up to 99.9% of a row's probability lands on the wrong, should-be-masked positions, on all five LibriSpeech clips' real logits). Holding for Erika's call on how S21a should proceed. library +213-7, applications +23-12 (iron/applications/embedding_gemma2/audio.py). Ported onto iron-next's redesign: the float32 path's build is Softmax.Array._build_f32, the new fields are listed on Softmax.Array in place of array=True, and audio.py's body calls Softmax.step/GEMM.step/Copy.step. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com>
…32_bf16 Drops the resident-Buffer mask (last commit's draft answer to the channel-budget question in pr222/s21a-mask.md) for the kernel option Erika's standing rule called for: mlir-aie's softmax_rows_banded_f32_bf16 (Xilinx/mlir-aie, branch s21a-band-softmax on dma-bd-fits-fix, 8b074e3f1, not yet upstream) computes a row's band from window, period and its own query position, so Softmax no longer declares a mask operand, a resident Buffer, or the array_key/design_key override that hashed one. EG2's attention() passes window=W, period=T straight through; self.mask and its construction are gone from audio.py. A second design pass was needed: the first attempt (one bulk DMA transfer a core, all its rows at once) hit a different real wall at EG2's shape, "buffer descriptor length exceeds the maximum... words supported by this tile type" (768 rows don't fit one descriptor, or L1). The kernel call moved back to one row a call, in a real device range_() loop (not Python-unrolled) with the row's query position computed from the loop index each iteration -- O(1) code size at any row count, unlike the dropped resident buffers. Verified: - op.compile() at EG2's real shape (rows=6144, cols=32, 8 cores, 768 rows/core) succeeds. - iron/tests/operators/catalog.py -k Softmax, all 8 cases including the banded one, pass on npu2 (coord/npu), against softmax_ref/ softmax_f32_bound -- tolerance() wraps the factory's own bound function with the band closed over, since judge() calls it with this operator's operands (x, addend) alone. library +138-76 (iron/operators/softmax.py), applications +5-10 (iron/applications/embedding_gemma2/audio.py). Ported onto iron-next's redesign: the banded build is Softmax.Array._build_f32_banded, window and period are listed on Softmax.Array, and the draft's array_key/design_key override goes as written. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com>
…tput
The five-clip trace caught what the Softmax catalog couldn't (it never
built the whole audio graph): audio.py's head GEMMs ask for both
dtype_out=np.float32 and prio_accuracy=True (the model's shared
**ACCURATE dict), and GEMM.validate() raised, since prio_accuracy's
internal f32 accumulator only ever made sense alongside a narrower
(bfloat16) output.
GEMM.wider_accumulator replaces the raw prio_accuracy reads in held()
and array(): true only where the output is not already f32, since an
f32 C already carries everything prio_accuracy's internal buffer would
add. validate() accepts prio_accuracy with either bfloat16 or float32
output; every other dtype_out is still refused. Fixed at the source, so
every caller's existing prio_accuracy=True keeps working unchanged
(wider_accumulator equals prio_accuracy whenever the output is not
float32, confirmed device-free against the f32_out and prio_accuracy
catalog cases, compiled identically to before).
A second, smaller bug the same trace found: audio.py's two Copy(scores,
src=...) calls (gathering the now-float32 scores into the band and
relative-position buffers) still defaulted to Copy's bfloat16, caught by
the tracer (Copy.x is bfloat16; operand Handle(...) is float32) before
it reached GEMM at all. Both take dtype=np.float32 now.
Verified device-free: the whole AudioTower graph traces and compiles
(iron.lm.base.Checkpoint(...).tree("random") weights, real shape);
tests/applications/embedding_gemma2/test.py -k TestAudio collects its
15 cases (3 tests x 5 clips).
library +15-4 (iron/operators/gemm.py), applications +2-2
(iron/applications/embedding_gemma2/audio.py).
Ported onto iron-next's redesign: GEMM.Array.build cannot read an operator property, so it computes the same prio_accuracy-and-not-f32 test from its own prio_accuracy and dtype_out; held() reads GEMM.wider_accumulator as written. audio.py's Copy.step calls take dtype=np.float32.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Replaces the hand-bound softmax_rows_banded_f32_bf16 (object_file.bind() with raw arg types) with activation.softmax(window=, period=), now that it has a real contract, reference and tolerance (mlir-aie#3921/simplify-upstream-stack, cf58ad789): tolerance() and reference() wrap the factory's own banded contract (row_offset each row's own index, closed over since judge() calls bound()/reference() with this operator's own operands, x and addend, alone), dropping the local _band_mask this operator carried before the factory existed. Verified: iron/tests/operators/catalog.py -k Softmax, all 8 cases including the banded one, pass on npu2 (coord/npu); op.compile() at EG2's real shape (rows=6144, cols=32, 8 cores) and the whole AudioTower graph both still compile, through a freshly-rebuilt mlir-aie install (build-3921tip) whose aiecc matches its own Python wrapper -- build-flow is stale against the python layer's current --await-link-files option (a real, unrelated build-vs-binary mismatch in the shared install, reported separately; not something this commit works around). Ported onto iron-next's redesign: the factory call lands in Softmax.Array._build_f32_banded; tolerance() and reference() stay on the operator, as written. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com>
Five clips, before (simplify-int) and after (ae20edc09), npu2 under coord/quiet, all 10 device tests passed: cosine 0.99912->0.99936 (short), 0.99288->0.99502 (medium), 0.99936->0.99848 (passage), 0.99844->0.99895 (extended), 0.99816->0.99937 (long). Latency moves 0.6-1.5%, the correct path's real, reported cost (pr222/s21a-mask.md has the full table and the latency figures). Four of five clips improve; passage alone moves the wrong way, by less than the others gain, within the comment's own ill-conditioning finding (a specific token's content, not the band or the path). AUDIO_MIN_COSINE tightens from 0.99 to 0.992: the new worst (medium's 0.9950) with the same margin the old bound carried against its own worst (0.9929 -> 0.99). AUDIO_MAX_ERROR is unchanged; relative error was not remeasured this round, so there is nothing to tighten it against. tests +13-5 (tests/applications/embedding_gemma2/test.py). Ported onto iron-next's redesign: applied as written; the test reads nothing the redesign changed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com>
flm.GEMM's shapes share one configuration xclbin, and each shape's
stream is an insts_only build of the same array. Where the placer
chose a stream's shim ends from that shape's transfers, a shape could
lay them out otherwise than the image did, and its descriptors then
named channels the image does not route: a hang, not an error.
mlir-aie's image check (softmax's hang-fix branch for #3921) compares
an insts_only build with the image it runs on and fails, naming each
resource that differs ("pin it"). OperatorDesign.compile(image=) passes
it the design, and OperatorImage passes the configuration it built to
every shape's insts_only stream (a stream generated per call has none).
Across the 65 shapes we ship (flm/gemm/test.py's test layout, iron.lm
blocks.project's, and Gemma 4's 44), 10 were refused, each naming the
same eight resources: C's drains (of4-of11), which the image places on
shim (c, 0) S2MM 0 and those shapes' streams moved to the next column's
or channel 1. array() pins each C lane's shim end to Tile(c, 0); the
channel follows. No other pin was named, no pin overflowed a shim's
BDs, and no shape needs an image of its own.
test_gemm_split_leg_bounds_runs's strict npu2 xfail goes: with the
hang fix it runs.
Diff: library +23 -9 (3 files), tests -6 (1).
Verified (xcoradaie218, build/orderhang/env-fix.sh: softmax's hang-fix
mlir-aie over the dev38 venv):
- softmax's imagecheck.py, all 65 shapes (test 7, project 14, gemma4
44): 65 "MATCHES its image" (10 refused on a5da0a5 without the pins,
softmax's imagecheck-accept.log).
- npu2 via coord/npu: iron/operators/flm/gemm/test.py in full, 80
passed (split_leg included, nothing skipped); Gemma 4's 20 N=1536 and
N=6144 shapes in its layout against the shared image, each held to
numpy under floor's budget (check_on_device): 20 passed.
- iron/tests/common/build.py, declare.py: 87 passed. collect-only 1812;
black, reuse lint clean.
Needs mlir-aie's CompilableDesign.compile(image=), not yet in the pin.
Ported onto iron-next's redesign: OperatorImage is now the one-step XclbinChain, so image= goes where XclbinChain's shared-configuration branch compiles a shape's stream (iron/common/image/fused.py), which checks a graph's steps on a shared configuration too; C's pin lands in GEMM.Array.build.
Co-Authored-By: Claude <noreply@anthropic.com>
…eeded
Erika's ask: now that mlir-aie #3921 fixes the placer, see whether GEMM's
and MHA's hand-pinned tiles (GEMMPlacement, MHAPlacement) can go. $ESHARE/
placer-repro/ named in the ask does not exist, so the repro shapes below
are reconstructed from each enum's own docstring and validate()'s raises.
GEMM: the shim pin is unconditional (not part of the enum at all; it is
always on) and stays. Compiling M=2048, K=8192, N=2048, 8 columns,
b_col_maj=True with every shim freed (AnyShimTile, reverted after) under
env-3921tip (mlir-aie 533e0c121) still fails:
'aiex.dma_configure_task' op Too many simultaneously active buffer
descriptors on tile (4,0), which supports up to 16.
the same class of failure the pin was added for (a different tile this
time, (4,0) not (5,0), but the same 16-BD ceiling). GEMMPlacement itself
(SHIMS, the default, vs TILES, which also pins every memtile and core) was
compared at the catalog's own tuner comparison case (M=K=N=2048, 2 columns,
b_col_maj=True): both numpy-correct on npu2 (run_test, zero errors), but
under coord/quiet TILES is measurably faster, not within noise (median
11348 vs 12074 us, MAD 58 vs 354 us over 20 runs -- the two median ± MAD
ranges do not overlap). The enum and its tuner domain stay.
MHA: placement is required to be TILES whenever P*V splits over two cores
(pv_cores > 1) by an explicit raise in validate(), for a reason no placer
can resolve: the two P*V cores share SPLIT_COLUMNS' own adjacent tiles, the
line between them depending on shared L1. Untouched.
At pv_cores == 1, the only case I could reach in the time this step had,
the num_pipelines=8 default bench case (MEMTILES, the default, vs every
tile freed with no column pin at all) is numpy-correct both ways and the
timing is within noise under coord/quiet (median 42946 vs 42916 us, MAD 31
vs 23 us over 20 runs) -- a real lead for dropping the pin on this path,
recorded in the docstring but not acted on: the rest of the catalog, Llama
and EG2 are unchecked, and the ask was every one of them before deleting
anything.
Audit of everything else IRON pins or names by hand: outside flm (left to
values, in parallel), only gemm.py, mha.py (above) and copy.py's Gather use
Tile(c, r)/.pin(). Gather's shim pin is not a placement choice: its control
packets write fixed physical register addresses (bd_base, mm2s_1_control)
of specific shim tiles, so no placer, however good, can stand in for it.
symbol_prefix= (merge.py, resample, swiglu_prefill_stream) names a kernel
object's compiled configuration, not a tile; unrelated to the placer. No
other Tile()/.pin()/via= sites exist in iron/common or iron/operators
outside flm.
Verified: black --check ., python -m pytest --collect-only -q (1812 tests,
unchanged -- only docstrings changed, no code path). The measurements above
ran under coord/heavy (compiles) and coord/quiet (timing), npu2.
Ported onto iron-next's redesign: applied as written; docstrings only.
Co-Authored-By: Claude <noreply@anthropic.com>
Found $ESHARE/placer-repro/ at /proj/rdi/staff/ehunhoff/iron-next-shared/ placer-repro/ (my first pass looked in the wrong place and missed it). Its STATUS.md already carried an NPU timing for the same free-vs-pinned MHA question on a different host (xcoradaie211, free 7730 vs pinned 7706 us, +0.3%); this commit checks it through IRON's own catalog, correctness and timing included, on 218. MHAPlacement (MEMTILES, TILES, COLUMNS) goes, along with its tuner domain and the two catalog cases that compared TILES and COLUMNS against the default: with no choice left to compare, there is nothing for them to measure. `every = pv_cores > 1` replaces it in array(): the two P*V cores of a split head still pin to SPLIT_COLUMNS' own adjacent tiles, sharing L1 across the line between them, a physical invariant no placer can infer (validate()'s matching raise is dead code once placement is no longer settable, and goes with it). Every other tile -- cores, memtiles, shims -- is now AnyComputeTile/AnyMemTile/AnyShimTile, left to the placer. Checked against mlir-aie #3921's placer fixes (533e0c121), under coord/heavy (compiles) and coord/quiet (timing), npu2: all nine pv_cores=1 catalog cases -- the default bench case, grouped-query, the cache chunk, Llama 3.2 1B's decode, Granite 4.2-3B's and Llama 3.2 3B's head groups, the two- pipeline case, and EmbeddingGemma 2's sliding layer -- compile free, are numpy-correct (run_test, zero errors on all nine, both placements), and read within noise of the old pins on eight of the nine. The ninth, the 16384-token grouped-query prefill chunk, shows a small but repeatable gap (free 338408-338559 us vs pinned 337945-338246 us over two repeats of 10 runs each, medians ~0.05-0.18% apart) -- smaller than the pin's own run-to- run spread, so not kept for it. EmbeddingGemma 2's global layer (pv_cores=2, the tenth case) keeps its pin, correctly: that is the one the physical invariant is for. One case's first measurement, Llama's decode (initially 763.6 vs 820.5 us, a clear-looking +7.4%), did not survive an isolated rerun (748.8/743.0 vs 738.0/739.8 us): a long back-to-back sweep of many designs on one NPU hold is not reliable for a small, sub-millisecond design, so the isolated repeat is the number trusted here. Noted for whoever measures placement next on this box. Migrated iron/tests/toolchain/narrowing.py's two tests of the Enum-tunable mechanism itself (searched by name, keys the array, a profile's placement starts the search, an Enum field takes only its own members) from MHA to GEMM, which still has one; dropped MHA's placement from the `_domains` it asserts. iron/common/declare/operator.py's three comments naming "GEMM's and MHA's placements" as the Enum-tunable example now name GEMM's alone. AGENTS.md's Placement rationale names GEMM's enum as the pattern and records MHA's one remaining pin and why, instead of presenting both operators as examples of it. Verified: black --check ., reuse lint, python -m pytest --collect-only -q (1810 tests, two fewer -- the dropped TILES/COLUMNS catalog cases), iron/tests/toolchain/narrowing.py in full (94 passed, device-free, runs mlir-aie's placer in process) and iron/tests/toolchain/lowering.py -k MHA (16 passed, every device/layout MHA's lowering gate covers, through aiecc). Ported onto iron-next's redesign: MHAPlacement leaves MHA.Array's fields too, every = pv_cores > 1 lands in MHA.Array.build, and the migrated narrowing test seeds its profile through profile.make(GEMM, ...) in place of with profile:. Co-Authored-By: Claude <noreply@anthropic.com>
mlir_aie 1.4.5a1 is Xilinx/mlir-aie main cb6f2d14a, #3921 merged, from the v1.4.5-alpha.1 release page; llvm-aie 22.0.0.2026101201+83ba9ea9 is the Peano that commit names. A fresh venv with these installs, imports and collects 1818 tests at this tip. Co-Authored-By: Claude <noreply@anthropic.com>
The port of "GEMM's prio_accuracy is a no-op, not an error, at f32 output" left the test written twice: a GEMM.wider_accumulator property for held(), and the same expression inline in Array.build(), which cannot read an operator property. It now lives on GEMM.Array alone, and held() reads it through array_config(). Verified: python -m pytest --collect-only -q (1818), black --check .; GEMM(M=K=N=2048) resolves on npu2 and npu1 with no flags, prio_accuracy, and prio_accuracy with float32 output, where held() calls it before resolution; wider_accumulator is True only for prio_accuracy at bf16 output, as before. Co-Authored-By: Claude <noreply@anthropic.com>
On Krackan's docker runner (CI, 54403bb) the test now gets past its setup but the nice-0 spinners never starve the timing thread, so the timing completes and the expected refusal never comes (returncode 0). Starving a thread on purpose needs a host whose scheduler the test controls, as the file's other timing tests need a quiet one, so it joins them in extensive. Co-Authored-By: Claude <noreply@anthropic.com>
#245 makes iron/common/compilation/base.py copy the dispatch library where Windows refuses the symlink. iron-next deleted iron/common/compilation: CompilableDesign in mlir-aie builds and places the dispatch library, and neither iron-next nor mlir-aie main creates that symlink, so the file stays deleted and the change has nothing to apply to. Co-Authored-By: Claude <noreply@anthropic.com>
Collaborator
|
LGTM |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR moves IRON from per-operator scripts and hand-assembled sequences to declared operators composed into traced graphs. A model is a Python class whose
body()calls operators on handles. It is traced once per input shape and compiled once, and per-call values (a position, a row count) are patched in without a rebuild. A cost-model tuner sits on top, and two applications run end to end on Phoenix (npu1) and Strix/Krackan (npu2): Llama 3.2 1B andEmbeddingGemma 2.
The work builds directly on what
develvalidated: the operator arrays and tilings, the kernels (now upstream in mlir-aie'saie_kernels/andaie.iron.kernels),llama_npu.py's fused-ELF decode, the FastFlowLM ports and the stream-dse SwiGLU. This PR ports those proven designs into one declared form and builds the graph and tuning layers on it.Size. 882 commits, 380 files, +55k / −20k. The rough Python split at the tip:
iron/operators18.5k lines,iron/tests18.3k,iron/common/graph8.5k,iron/common/declare3.3k,iron/common/image3.1k,iron/lm2.9k andiron/applications2.4k.Suggested reading order.
AGENTS.md(Architecture, Graphs, Design rationale), theniron/common/declare/,iron/operators/relu.py→clamp.py→gemv.py, theniron/common/graph/compiled.py,iron/tests/common/graphs.py,iron/lm/causal.py, andiron/common/graph/narrowing.py(its module docstring is the cost model).1. Operators: one declared class
Each operator is a single class: its fields, its operands and the values its cores read, plus
array(), which builds ObjectFIFOs and Workers around an mlir-aie kernel. The library derives the runtime sequence from that declaration, and an operator overridessequence(rt)only when it needs a different order (GEMM and MHA do).The smallest operator names its kernel and its test cases, and nothing else:
A fuller declaration (
iron/operators/gemv.py, abridged):The vocabulary (
iron/common/declare/):Param(): what a host shape names.Param(..., array=True)puts a field in the array tier.Auto(): a tunable. A call site or a profile may give it; otherwiseresolve(dev)fills it from the device and the extents.Auto(domain=...)declares what the tuner searches (section 8).In/Out: operands declared by shape.tile=makes each operand its own stream into the array,per=gives its lane count, andwhen=flagmakes it optional (RMSNorm(x, weight=w)).Value(derive=...): trip counts and other scalars the cores read, derived from the extents.Extent(field): an extent a graph can bound per call (section 4).Testing(cases, tolerance=, draw=): the shapes the operator is checked at on a device.Case(..., extensive=, bench=, lower=)andSweep(...)describe the cases.An inline-kernel "hello world" lives in
iron/tests/toolchain/inline_kernel.py. It shows the whole contract a kernel carries (roles, scalar bindings and reference):Invariants
array()sees only the array tier, and reading an extent there raises. Trip counts areValues that the sequence writes.iron/tests/toolchain/array_identity.pycompiles operators at two extents and compares the core ELFs byte for byte.Profile, thenresolve(dev), then theAuto()default. Identity (array_key(),design_key()) is taken after resolution, so two calls that resolve alike share a build.aie.iron.kernels). Each factory'sExternalFunctioncarries the reference and tolerance (.contract), and the catalog test judges an operator against them unlessTesting(tolerance=)overrides.ml_dtypes.bfloat16. Torch is no longer a requirement.aie_utils.set_current_device(from_name("npu2", n_cols=8))(the test tree'snpu2/npu1fixtures do this).2. The lazy catalog
import ironandiron.operatorsload names on first use (PEP 562), so importing one operator imports only its module:iron/tests/infrastructure/lazy_imports.pychecks this in a fresh interpreter.dir()and type checkers still see every name (TYPE_CHECKINGimports).Main catalog,
iron/operators/__init__.py: AXPY, Clamp, Copy, DepthwiseConv1d, Dequant, ElementwiseAdd / RowwiseAdd, ElementwiseMax, ElementwiseMul / RowwiseMul, Emit, GELU, GEMM, GEMV, LayerNorm, LeakyReLU,Limbs, Log, Magnitude, Merge, MHA, PatchPositions / Resample / ResampleTaps, ReLU, RMSNorm, Repeat, RoPE, Sample, Sigmoid, SiLU, Softmax, Tanh and Transpose.
CopyreplacesMemCopyandStridedCopy. The SwiGLU composites are now graphs iniron.lm.blocks.3. Graphs: trace once, compile once, call many times
A graph is a subclass of
iron.Graph:body()'s positional parameters are its inputs.Scratchpad[T],Carried[T]orDispatchTime[T], are per-call values.iron.state(...)buffers are whatever the instance holds, named by attribute path (self.layers[3].q→layers.3.q).From
iron/tests/common/graphs.py:compile(**shapes)traces and builds one version per input signature. Calling the graph runs the matching version, and compiles it on the first call if needed. Fromiron/tests/operators/gemm_bounded_rows.py:Graph.compile(*, dev=None, boundaries=None, image=None, verbose=False, record="memory", feeds=None, coresident=None, fold=False, **shapes):dev=binds a device for this compile only.compile()links the image and stops there; the runtime that loads it is created on the first call, so a host with the toolchain and no NPU can compile ahead of time.verbose=Trueprints the packaging decision and how each per-call value lowers (section 5).graph.reference(*tensors, **values)runs the samebody()through each operator'sreference()on host arrays, with states modelled.iron.lm.blocks.SwiGLU,LogMel,Conv2dandImagePatchesare such reusable graphs.Invariants
TypeError(Value.__bool__), and so do*args/**kwargsinbody, an output that is a graph weight or input, and an input defaulting to anything butNone.4. Per-call values and bounds:
Scratchpadandx[:n]There are three kinds of per-call values (
iron.common.declare.value):Scratchpad[T]Carried[T]DispatchTime[T]Arithmetic on a value builds an
Affine(pos + 1,chunk * C), which canindex or slice a handle, state or weight:
x[:n]bounds an extent per call. The bound carries throughreshapeandtransposeand reaches every operator that consumes the view.self.kv[:, pos]/cache[step.position]is a per-call offset.table[ids]withidsa graph input or a handle the device made is a gather on the NPU (Copy(self.embedding[ids[:rows]])).Invariants and limits
fill/drain(length_parameter=, length_unit=)) in whole 16-byte units, so a bounded tile is at least 16 bytes. A transfer whose size a call bounds must fit one DMA descriptor, because the patch lands in its length field. A pattern past the descriptor limits (BdLimits) is split by the compiler, with the per-call offset patched into every piece.a0a7d024ffixed this order: before it, from the second call on, each core read the previous call's words.gemm_bounded_rows.pyandmha_bounded_rows.pydrive one compile across alternating bounds to catch any regression.5. Packaging: one full ELF, or a chain of xclbins
iron/common/image/packaging.pyderives the image from the traced graph andthe device:
iron.ELF), one fused sequence and one dispatch per call. This is the default on AIE2P (npu2).iron.XCLBIN,boundaries=iron.each_step), one dispatch per step. Any of these forces it: aDispatchTimevalue, an npu1 device (full_elf(dev)isdev.arch is AIEArch.AIE2p), orboundaries=iron.each_step. A new xclbin chain starts every 32 designs.Limits
NotImplementedError. So npu1 runs every step as its own dispatch.image=Xclbin(...)) is refused in a graph; run it on its own withOperatorImage(op).Standalone use, outside a graph (
iron/operators/flm/gemm/README.md):Build outputs live in mlir-aie's JIT cache (
~/.npu/cache/<hash>/, orNPU_CACHE_HOME), keyed on content. Nothing is written to the workingdirectory.
6. Carried values and the device loop
A
Carried[T]value is one the graph computes for its own next call, returned last viairon.carry(...). On a full ELF the device can start the next call itself (CarriedLoop). Fromiron/tests/common/graphs.py:CausalLMuses this to sample on the NPU:Sampledraws the next token from the logits and a per-position draw row, and carriestokenandposition + 1. On npu2 the host only restarts the loop and reads the tokensback at the end. On npu1 (xclbin chain) the host reads the drawn token and dispatches the next step; the draw still happens on the NPU.
7. The operators language models lean on
mha.py) has causal or bidirectional attention, a slidingwindow,scale,dabove 64, andkv_lenwith a DMA-boundedkv_validfor attention over a cache. Decode is MHA with one query: a KV group's heads are packed into a block, and a group that does not divide the block is covered by two overlapping runs. The softmax running state and the O accumulator are float32. On AIE2P the softmax uses mlir-aie's accurateexp2(867b88de0).B_q,num_pipelines,pv_coresandplacementare searched by the tuner. MHA is placed on npu1's 4 columns as well as npu2's 8.gemm.py) bounds rows and K per call, zeroes the tail of the last K tile, and can finish its C tiles with a folded elementwise step. It searchestile_m/tile_k/tile_nandplacement. AnMthat notile_msplits is refused at construction.flm/gemm/) is the FastFlowLM port with a fused f32→bf16 epilogue (activation, clamp) whose M, K and N are runtime parameters, so one xclbin serves every shape. It reads B as stored (b_col_maj) on AIE2 and AIE2P, andiron.lm.blocks.project()uses it for prompt-chunk projections on both devices.flm.Shippedis the downloaded binary, kept for comparison only.gemv.py) supportsnum_batches,repeat=(one matrix serving consecutive batches, for GQA scores without a materialized copy), two lanes per column (num_channels=2), andfinish/preparelinks (a SiLU, a residual add or an RMSNorm folded into its cores).copy.py) is the single data mover: views on both sides, multi-channel splits on the run both sides share, per-call offsets, and gathers by host or device ids (GatherWords, up to four pairs of shim columns).8. The tuner
The tuner chooses each design's tunables, which steps fold together and which designs share a device configuration. It decides from measured costs, never from bit-unequal guesses: a setting is a candidate only if its output is bit-identical to the default's, or within the gate of both.
8.1 Domains: what is searched
A tunable declares its search space with
Auto(domain=...)(iron/common/declare/domain.py). Each domain is evaluated on the resolved operator, so the default is always among its values:Width()per=tunable)Divisors(of=, step=, cap=, span=)of, are multiples ofstepand at mostcapChoices(options)Placement({...})A tunable that a call or profile gives explicitly is held, not searched.
Auto(derived=True)fields are always derived byresolve()and are never tuned.8.2 Placement as a tunable
Where an array's tiles go is a choice too, given as a name for a
Pinsholding cores, memtiles and shims at aLevel(TILE,COLUMNorFREEfor the placer's choice):The hand-found placement is the default. Before a setting is measured, the tuner asks the placer and router whether it fits (
narrowing.fitting), and the verdicts persist underNPU_CACHE_HOME/iron/fits.8.3 Measuring: probes, cost tables and the CostCache
probe.pytimes each design alone (t_step,alone), the configure cost between pairs of designs (a calibration triangle of operator classes), loads, and packs of designs measured as one device. Timing runs only while no other process holds the NPU and while the interrupted cores are held out of deep idle, and a figure whose calls waited on a CPU is refused.CostTable(narrowing.py) is one graph's working set, as JSON, for one device and one packaging:costs_<device>.jsonfor the device's own packaging,costs_<device>_<dispatch>.jsonfor the other. Entries are keyed by design identity, so a design that has changed since measurement is missing from the table and left as the profile gives it. Each table records a single power mode.CostCache(costcache.py) holds every design ever measured on this NPU, one JSON per entry underNPU_CACHE_HOME/iron/costs/<platform>/<power mode>/. It is keyed on the build recipe, the per-call values and the input contents, so a design shared by two graphs is measured once. Code is not part of the key: after editing a reference or a tolerance, run with--remeasure.StepCost.points).Run these with XRT sourced and the NPU otherwise idle. No cost table is checked in; you measure one on the NPU you use it on.
8.4 The fold pass
iron/common/graph/fold.pyfinds three kinds of fold:Fold, e.g. SiLU into the gate GEMV's epilogue, a residual add into a GEMV), including chains of folds;Prologue, e.g. RMSNorm into the following GEMVs);compile(fold=True)takes every admissible fold. Under the tuner, each fold is priced against the graph without it and taken only where the modelled time drops.Limit: a fold that would make a core read more streams than its two input channels is refused (
Operator._finish_at). For example, EmbeddingGemma 2's residual add does not fold into a weighted RMSNorm.8.5 Joint narrowing
JointNarrowing(table)is passed ascoresident=tocompile(). It chooses widths, folds and packs together to minimise the modelled time of a full-ELF runlist:For an xclbin chain the model is
F + Σ steps c + Σ switches L(incoming), and each design's setting is chosen independently.NarrowingKinds(widths=, folds=, packs=)turns individual decisions off for ablations. A model takes a table on its command line:python -m iron.applications.llama.model model.safetensors tokenizer.model \ --cost-table iron/applications/llama/costs_npu2.jsoniron/tests/infrastructure/narrowing.pyandiron/tests/toolchain/narrowing.pycover the search and the cost model, andprobe.check_modelcompares the model against a measured run of the graph it tuned.9. Profiles: tunables as data
A graph's
profileis aProfile, or a directory holding one<device>.jsonper device. Entries are keyed by operator class and shape, and the most specific match fills any tunable a call leaves open:{"entries": [ {"operator": "GEMV", "M": 2048, "K": 2048, "num_batches": 1, "tile_size_input": 4, "tile_size_output": 256}, {"operator": "MHA", "num_heads": 32, "num_KV_heads": 8, "d": 64, "seq_pad": 1, "num_pipelines": 4},(
iron/applications/llama/profiles/npu2.json.)Profile.load(path),profile.save(path)andwith profile:are the API.test_llama_names_only_the_tunables_that_matterchecks that every keyword the Llama graph still passes is one the profile could not have supplied.10.
iron.lmand the applicationsiron/lm/holds what every model shares, and each model is an application package underiron/applications/. Nothing in the library imports a model.base.py:Config, the model's shape.Checkpoint, a mapped.safetensorsfile or a sharded index.tree(fill=)returns the weights by the checkpoint's own names:"read","random"(drawn at a seed, in the checkpoint's shapes) or"unread"(header only, for tracing and tuning).Oracle, a float32 host forward pass, widened one layer at a time.ModelandRunner/main, the command line, plus--random-weights SEEDfor running without real weights or a tokenizer.causal.py:CausalLM, a decoder as one graph. It has two versions, a decode step and aprefill_chunk-row prompt chunk, each compiled once.max_seq_lensizes only the caches and the RoPE table.encoder.py:Encoder, compiled once atmax_tokensrows, with the length bound per call.blocks.py:project()(GEMV for one row, flm.GEMM for more, an optional bias viaRowwiseAdd),swiglu/SwiGLU,geglu,sandwich,attention,LogMel,Conv1d/Conv2d,MaxPool2d,ConcatChannels,ImagePatches, andBlock/BlockOracle.generation.py,testing.py,tune.py: sampling, the accuracy and determinism checks, a model's device-test helpers, and the tune CLI.The whole decoder body (
iron/lm/causal.py):The caches are
(max_seq_len, n_kv_groups, head_dim), so each call's write is contiguous and no descriptor steps by the context.test_llama_does_not_grow_with_the_contextchecks that no activation or array followsmax_seq_len.Llama 3.2 1B (
iron/applications/llama/)Llama(Block, CausalLM)adds only its head. The layer isBlock.layer, and the oracle isLlamaOracle(BlockOracle, Oracle):python -m iron.applications.llama.model /path/to/model.safetensors /path/to/tokenizer.model \ --prompt-len 2048 --num-tokens 40 # --check-accuracy, --check-determinism N, --each-step, --cost-table--max-seq-len, a multiple of 2048). The compile is the same at any context.check_chat_turn).tdr_timeout_ms), so very long prompts need it raised (AGENTS.md, "Not built, or limited").ulimit -lmust cover one arena bank.EmbeddingGemma 2 (
iron/applications/embedding_gemma2/)The text encoder (
EmbeddingGemma, anEncoder) is compiled once at 2048 rows, with the prompt length bound per call. The audio and vision towers (audio.py,vision.py) are graphs thatMultimodalcalls in its body, sotext, a clip and an image encode as one graph. The NPU runs the token-embedding gather, log-mel features, image resize to patches, patch positions and the multimodal merge. Each part has a float32 oracle, checked against Hugging Face computed live when torch and transformers are installed (otherwise those tests skip), on COCO photos and LibriSpeech clips fetched by sha256.
python -m iron.applications.embedding_gemma2.model /path/to/embeddinggemma-2 \ --query "What causes the northern lights?" \ --document "Charged particles from the sun." "Photosynthesis in plants."gemma4_flm(the FastFlowLM engine example fromdevel) is unchanged in substance; its test moves totests/applications/gemma4_flm/.11. Phoenix (npu1) and npu2
--each-step)ScratchpadCarriedLoop)TestEachStepThe decode GEMVs run two lanes per column on Phoenix.
--each-stepbuilds the npu1 form on npu2, which is howTestEachStepchecks it there. CI covers both devices:phoenix-{small,extensive,test-examples}.ymlandkrackan-{small,extensive,examples}.yml.12. Seeing a graph
TracedGraphandCompiledGraphdraw themselves:graph.trace(**shapes).html(),version.dot(), andversion.picture(table=...)to annotate steps with priced times. The pictures show each step's design, the steps folded into it, moved tunables and packs, the scratch arena over the runlist, and where a step sits on the array (iron/common/graph/visualize.py).13. Tests, and how to run them
Layout:
iron/tests/operators/catalog.pyTestingcases againstreference()on a deviceiron/tests/operators/*.pyrejected_shapes.py(device-free refusals)iron/tests/infrastructure/iron/tests/toolchain/array_identity.py,inline_kernel.pyiron/tests/lm/iron.lm.blocksgraphs (SwiGLU, LogMel, Conv1d/2d, MaxPool2d, ConcatChannels, ImagePatches)iron/tests/common/iron/operators/**/test.pytests/applications/<name>/test.pyMarkers and options (
pytest.ini, rootconftest.py):@pytest.mark.supported_devices("npu1", "npu2")marks a device test. It is the only mark a device test needs: on a host without an NPU runtime it skips with the reason, and the runtime is released after it.extensiveis excluded from the default suite;benchmarks the cases CI charts over time.--random-weights SEED,--max-seq-len Nand--cost-table PATHapply to the language-model tests.--csv-outputsets the metrics CSV path (defaulttests_latest.csv).npu1/npu2fixtures bind a device for device-free resolve and compile.14. Measured numbers, as recorded in the commits
These are point measurements taken at the named commits. Later commits change some of them; for example, the accurate
exp2costs decode throughput, as listed below.1398c5f0c88f23339c34ff047b161d210b05np.take727a9618011a66db95exp2in MHA on AIE2P (accuracy fix)867b88de086edca19315. Known limits (also in AGENTS.md)
aiex.configure/aiex.run, and an accessor for L1 banking.Note, this PR relies on: Xilinx/mlir-aie#3884
PR Merge Checklist
develcommit and pointing todevel.