Skip to content

[WIP] - #215

Draft
hunhoffe wants to merge 60 commits into
develfrom
operator-model-argspec
Draft

[WIP]#215
hunhoffe wants to merge 60 commits into
develfrom
operator-model-argspec

Conversation

@hunhoffe

Copy link
Copy Markdown
Collaborator

Describe the intent of your PR here.

Added

Changed

Removed

PR Merge Checklist

  1. The PR is rebased on the latest devel commit and pointing to devel.
  2. Your PR has been reviewed and approved.
  3. All checks are passing.

hunhoffe and others added 30 commits September 18, 2026 15:27
`get_arg_spec()` is about to move out of each `op.py` and onto a shape
function declared with the operator itself. That is meant to be a pure
refactor -- the specs must come out identical for every operator and every
configuration -- but "identical across 22 classes" is not something a reviewer
can check by eye, so record it instead.

`arg_spec_snapshot.json` is the pre-refactor truth, generated on devel. The
test re-derives specs and diffs against it, so a shape or dtype that changes
during the refactor fails loudly and names the case.

Both device generations are covered. An operator reads the ShimDMA column
limit at construction, so a spec can depend on the device, and one that
silently differed between npu1 and npu2 would otherwise land as a correctness
bug on whichever generation CI does not run. Setting a device *description*
via `from_name` rather than a live device keeps this runnable anywhere -- no
XRT, no NPU.

Three findings while building the matrix, each now pinned by a test:

- `num_aie_columns` defaults (AXPY's is 8) exceed the ShimDMA limit of narrow
  devices, so cases pin it rather than inherit a value that varies by width.
- Presence of `get_arg_spec` proves nothing, since every class inherits the
  attribute. The three SwiGLU composites are `OperatorSequence` subclasses and
  raise from it on purpose; `test_every_operator_with_a_spec_is_covered`
  excludes those and fails on anything else missing from the matrix, so the
  gate cannot quietly start checking less.
- `_SwiGLUStreamGroup` needs the optional `stream` package. It is skipped on
  both sides of the comparison rather than dropped, so a snapshot generated
  without it does not read as "case added" on a machine that has it.

Verified the gate discriminates: swapping GEMM's first spec from (M, K) to
(K, M) fails 20 cases across both devices with a readable diff, and reverting
returns it to green.

Co-Authored-By: Claude <noreply@anthropic.com>
Clustering the operators by their recorded spec pattern, to find which ones
could share a base, turned up two cases in this matrix that prove less than
they appear to.

StridedCopy read as "(in, out), same shape", which would have grouped it with
the elementwise family. It is not: its input and output sizes are independent
parameters, and the case simply set both to 1024. With output_buffer_size=256
it reports in (1024,), out (256,). It belongs with Dequant and Repeat instead.

Transpose was only exercised square (64x64), which cannot distinguish a flat
(M*N,) buffer from a shape that tracks (M, N) -- a refactor emitting (N, M)
would have passed. Non-square confirms both buffers stay flat, since a
transpose changes layout rather than size.

Both are the same failure: a case whose parameters coincide cannot pin the
relationship between them. Added a differing-size StridedCopy and a
non-square Transpose.

Co-Authored-By: Claude <noreply@anthropic.com>
angle_rows is an independent parameter constrained to divide rows, and it
merely defaults to rows -- so with rows=32, angle_rows=8 the spec is
in (32,64), in (8,64), out (32,64). Every case here left it defaulted, which
made RoPE read as three buffers of one shape and grouped it with the
elementwise binaries it is not.

Third case in this matrix where coinciding parameters hid a relationship,
after StridedCopy's equal buffer sizes and Transpose's square dimensions.

Co-Authored-By: Claude <noreply@anthropic.com>
ChanneledUnaryOperator bundles two things that do not have to travel
together: the arg spec, and the design that generates the MLIR. Bundling them
is why Softmax, MemCopy and Transpose each hand-wrote

    [AIERuntimeArgSpec("in", (size,)), AIERuntimeArgSpec("out", (size,))]

instead of inheriting it -- they share the shape rule with the elementwise
activations but emit completely different MLIR, so the base was not available
to them.

Split the two. same_shape_unary() and same_shape_binary() are plain functions,
so an operator reuses a shape rule by calling it rather than by inheriting a
design it does not want. Thirteen operators now route through them: the seven
on ChanneledUnaryOperator, the three on BinaryElementwiseOperator, plus
Softmax, MemCopy and Transpose.

Also add reads/writes predicates and nbytes() to AIERuntimeArgSpec. Callers
that want to know whether a step touches a buffer compare `direction` against
a set inline, which is fine until "inout" appears -- it must answer yes to
both, and a caller that partitions arguments into inputs and outputs counts it
once and places it wrong. Liveness analysis for memory planning is exactly
such a caller.

Four operators that look like they belong in these clusters do not, each
verified against the code rather than the spec shape alone:

- RoPE's angles broadcast (angle_rows defaults to rows but is independent, so
  rows=32/angle_rows=8 gives in (32,64), in (8,64), out (32,64)).
- StridedCopy's input and output sizes are independent parameters.
- RMSNorm carries an optional weight between its input and output.
- Dequant, Repeat, GEMM, GEMV and MHA have shapes that genuinely differ.

Specs are unchanged: the snapshot gate passes untouched across all 22
operators on both device generations. Full suite is 35 failed / 40 errors
before and after -- the XRT-dependent tests, which cannot run on a host
without a device.

Co-Authored-By: Claude <noreply@anthropic.com>
An operator's spec is a function of its parameters, so declare it as one.
`arg_spec` is a staticmethod taking the fields it needs by name, and the base
binds them via inspect.signature -- so `get_arg_spec()` stops being a method
every operator reimplements and becomes something derived.

`bind()` matches parameters to attributes by name, filling from fields and
properties alike, and raises naming both the operator and the parameter it
could not supply. That failure is the point: the hand-written kwargs dicts it
replaces restated field names with nothing checking the two sides, so a rename
on one side surfaced as a TypeError from inside the callee or, when the
parameter had a default, as a silently wrong value compiled into a design.

Converted softmax, gemm and mha. The latter two are the ones worth having:

- GEMM's b_col_maj/c_col_maj transpose a declared shape rather than resize it.
- MHA's shape needs a helper call (seq_len rounds up to a pipeline multiple)
  and a branch (num_KV_heads == 0 means K/V are as wide as Q).

Neither is expressible in a declarative shape notation without that notation
becoming Python, which is why these stay plain functions -- the same
conclusion JAX reaches with abstract_eval and PyTorch with register_fake.

Making a shape rule a function of parameters rather than of an instance also
means a caller can ask what shape an operator *would* produce before building
it, which is what graph capture needs to place a value it has not constructed.

MHA._calculate_seq_padding becomes a staticmethod; both remaining callers go
through self and are unaffected.

Specs unchanged -- the snapshot gate passes across all 22 operators on both
device generations. Full suite 35 failed / 40 errors before and after, the
XRT-dependent tests.

Deliberately not done here: binding the *design* kwargs the same way. Several
design parameters are spelled differently from the field feeding them
(softmax's num_elements <- size, tile_size <- cols), so that change renames
design parameters, and nothing in this gate covers design output. It needs the
per-operator hardware tests.

Co-Authored-By: Claude <noreply@anthropic.com>
Every operator whose spec is a function of its parameters now says so. The
eight remaining conversions plus both shared bases, leaving one override.

Several were not mechanical, and the shape function is where the reason now
lives rather than in an instance attribute computed at construction:

- Dequant derived input_size in __post_init__; the packing rule (two 4-bit
  values per byte, plus a bf16 scale and zero point per group) is now stated
  where the shape is.
- RoPE's angles broadcast, so angle_rows defaults to rows inside the function
  rather than only in __post_init__ -- otherwise the rule would be correct
  when bound from an operator and wrong when called directly.
- GEMV and Transpose carry no batch dimension at all when num_batches == 1,
  rather than one of extent 1.
- RMSNorm's optional weight sits between input and output, which is why it is
  not a same-shape unary despite both ends matching.
- StridedCopy's two sizes are independent: it may gather from a large buffer
  into a small one.

_SwiGLUStreamGroup keeps a get_arg_spec() override. Its spec comes from the
exported workload graph reached through an instance attribute, so it is not a
function of the operator's fields -- exactly the case the override exists for.

That leaves 21 of 22 operators declaring a shape rule callable without an
instance, which is what graph capture needs to place a value before building
the operator that produces it.

Specs unchanged: the snapshot gate passes across all 22 operators on both
device generations. Full suite 35 failed / 40 errors, matching baseline.

Co-Authored-By: Claude <noreply@anthropic.com>
…alog

Two problems, one surfaced by my own change.

The check read this process's sys.modules, so it measured session history
rather than the import graph. Adding a test that imports MHA at module scope
(operator_binding.py) broke it, even though the catalog was still perfectly
lazy -- and the converse is worse: a session that happened not to touch MHA
would pass even if the catalog had turned eager. Running the import in a
subprocess reads the graph itself and is order-independent.

The witnesses were also wrong in spirit. The docstring I first wrote claimed
MHA and swiglu_decode were "expensive to import"; measured, MHA imports in
180ms against ReLU's 198ms and pulls in no top-level module ReLU does not.
They were never expensive -- they were arbitrary catalog members standing in
for "the rest of the catalog", which is what PEP 562 re-export
(iron/operators/__init__.py) actually saves. So drive the test from
_OPERATOR_MODULES instead: all fourteen operators are covered, and one added
to the table is covered without touching a test.

Doing that immediately found something the two witnesses could not: composite
operators legitimately import their parts. SwiGLUDecode pulls in
elementwise_mul, gemv and silu because it is an OperatorSequence built from
them. So leaves are held to "imports nothing but itself" and composites to
"does not import the entire catalog", which is the regression that matters.

Added a guard-the-guard test as well. Every other assertion here is about a
module being absent, so a probe that silently imported nothing -- or a typo in
a module name -- would make them all vacuously true.

Co-Authored-By: Claude <noreply@anthropic.com>
Each operator kept a hand-written map from its own fields to its design's
signature -- GEMM's ran to eighteen entries, StridedCopy passed twelve
positionally -- with nothing checking the two sides against each other. A
field renamed on one side and not the other surfaced as a TypeError from
inside the design, or, when the parameter had a default, as a silently wrong
value compiled into a kernel.

DesignGenerator now takes bind_from and fills the signature from the operator
at call time. Binding late matters: design modules are imported lazily because
they pull in the MLIR dialects, and reading a signature any earlier would
defeat that. Explicit kwargs still win, so an operator can override or pass
something it does not store.

Converted StridedCopy (12 positional + 3 keys -> 0), Softmax (9 -> 0), GEMV
(7 positional + 3 keys -> 0), MHA (12 -> 3) and GEMM (18 -> 7), aligning
design parameter names to the operator's vocabulary where the rename was
unambiguous.

Two stopped short deliberately. GEMM keeps m/k/n and friends explicit: those
are single letters appearing 30-odd times across a 400-line design, and a
substitution could silently merge a parameter with an unrelated loop variable
in a way the hardware tests would not reliably catch. MHA keeps S_q/S_kv
because they are distinct design parameters that merely happen to be equal
here, so neither can bind from seq_len. Both are better settled when op.py and
design.py merge and the naming can be decided in one place.

dev, trace_size and verbose move to the base, since every design takes them
and no operator stored them. trace_size is a plain class attribute rather than
a property: OperatorSequence and LayerNorm both assign self.trace_size, and a
property without a setter cannot be shadowed by an instance attribute -- as a
property it took the fusion suite from 80 passed to 80 failed. It is also
unannotated so dataclass subclasses do not adopt it as a field.

GEMM's kernel object name was written out twice, once for the design to link
against and once for the artifact to build; they are now one property, so the
object built and the object linked cannot drift apart.

Verified on a Strix npu2: iron/tests 470 passed, converted operators 325
passed, fusion suite 80/80, arg-spec snapshot unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
ChanneledUnaryOperator and BinaryElementwiseOperator passed their designs a
bare list matched by position -- [dev, size, num_aie_columns, num_channels,
tile_size, 0] plus three more appended at the call site. Position is worse
than the dicts already replaced: inserting a parameter into a design signature
shifts every argument after it with nothing to notice.

Both now bind by name, which converts ten operators at once: elementwise_add,
elementwise_mul, gelu, layer_norm, relu, sigmoid, silu, tanh, and the two
swiglu composites. The designs' parameter names move to the operators'
vocabulary (num_columns -> num_aie_columns, num_elements -> size), and
_kernel_link_file becomes kernel_obj_file to match what the design calls it.

_mlir_callback_args survives for axpy and leaky_relu, which append an extra
parameter and build their own artifact. layer_norm's override, which existed
only to pass self.trace_size instead of a hardcoded 0, is now redundant since
trace_size binds like anything else.

Fixes a bug this surfaced: get_child_mlir_module repeated DesignGenerator's
import-and-call rather than using it, so the fusion pass never saw bind_from
and every fused dispatch died with "missing 7 required positional arguments".
Both paths now share DesignGenerator.resolve(); the fusion pass needs the
module object rather than its string form, which is the whole reason the
duplicate existed.

Verified on a Strix npu2: the ten converted operators 1015 passed, iron/tests
470 passed, fusion suite included.

Co-Authored-By: Claude <noreply@anthropic.com>
_link_build_outputs_into fills an aiecc work dir from two directories and
skips any name already present, so whichever is linked first wins. It linked
the flat build dir first, which meant a leftover flat object shadowed the
arch-scoped one -- defeating the per-arch scoping move_artifacts exists to
provide, and making a design link against code compiled for another era.

That is not theoretical. It is why silu, rope, rms_norm and the fused
elementwise-add sequence all failed today with "undefined symbol" for symbols
that were present in build/<arch>/ and absent from a months-old copy in
build/: the stale one was being linked.

Swapping the order fixes it. The flat directory still supplies everything that
is not a kernel object -- mlir, xclbin, insts -- because those have no
arch-scoped copy to take precedence.

Verified by planting a deliberately corrupt flat silu.o dated August and
forcing a full rebuild: flat-first fails all 75 silu tests, arch-first passes
all 75 with the same corrupt file in place. Full iron/tests 470 passed.

Not fixed here: something still writes both build/x.o and build/<arch>/x.o for
every kernel, byte-identical and with the same mtime to the nanosecond. I could
not identify the writer -- it is not shutil.copy/copy2/copyfile/move, not
os.link/symlink/replace/rename, not compile_cxx_core_function (traced: one
call, one arch-scoped path), and an strace of openat/creat/linkat/rename over a
full rebuild shows no syscall naming the flat path. Whatever creates it, this
change makes it harmless rather than hazardous.

Co-Authored-By: Claude <noreply@anthropic.com>
op.py, design.py and reference.py were three files describing one operator,
and the split cost more than it bought: the design was reached by path with a
string function name, the reference by a function-local import, and a reader
had to open all three to see what the operator was.

They are now one module. DesignGenerator grows an `fn` field so a collapsed
operator hands its design function over directly -- importing its own module
by path would execute it a second time and build a duplicate of the class
doing the asking. PythonGeneratedMLIRArtifact takes its staleness dependency
from a new source_file property, which falls back to the function's own module
when no path was given, so a design declared beside its operator is still
tracked for rebuilds.

The lazily-imported design was the one real argument for keeping them apart:
loading MLIR dialects only when a design is actually generated. Measured, that
costs 55 ms on top of a 225 ms operator import, and the invariant the catalog
laziness test protects -- importing one operator must not import the others --
is untouched.

Verified on a Strix npu2: softmax 15 passed, iron/tests 470 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
Same collapse as softmax, plus the bind conversion these three still needed:
each passed its design a positional tuple, which matches by order alone, so
inserting a parameter into a design signature shifted everything after it.
transpose's design also spelled num_columns where the operator says
num_aie_columns.

rope keeps its README; the design and reference bodies move into op.py, and
the tests and rope_reference_convention now import the reference from .op.

Verified on a Strix npu2: these three plus iron/tests, 950 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
Four more collapses, all already bound so this is the merge alone. gemm and
mha additionally carried an empty positional args tuple left over from the
path-and-name form, which had to go with it -- an empty tuple after a keyword
argument is a syntax error, and the merge refused to write rather than emit a
broken file.

gemm is the largest of these at 1127 lines in one module. That is not small,
but it is the same code in one place instead of three, and the naming question
it raises -- the design's m/k/n against the operator's tile_m/tile_k/tile_n --
can now be settled by reading a single file.

Verified on a Strix npu2: gemm 130 passed; strided_copy, gemv, mha and
iron/tests 650 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
Four more, each needing its bind conversion first: mem_copy and dequant still
passed positional tuples, axpy and leaky_relu routed through
_mlir_callback_args to append scalar_factor and alpha. Both of those are
ordinary fields, so they bind by name like everything else and the override
becomes unnecessary. dequant's and axpy's designs also spelled num_elements
and num_columns where the operators say size and num_aie_columns.

With the design local, the DesignGenerator no longer needs callback_fn -- a
ClassVar holding the design's function name as a string -- since it can name
the function directly.

mem_copy still fails one config on this box (num_cores=16, num_channels=2,
tile_size=64, size=1024, bypass=False; the five reported failures are that one
case across iter0-4). That is unrelated: it reproduces identically on clean
origin/devel in a worktree, compiles without error, and fails only at dispatch
with ERT_CMD_STATE_TIMEOUT. Suspected driver or device difference, since CI is
reportedly green.

Verified on a Strix npu2: axpy and leaky_relu 325 passed, mem_copy and dequant
475 passed with the one known config failing, iron/tests 470 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
The last one, and the only one that needed more than a move: it had two
designs, weighted and not, selected by building a different file path and
function-name string. With both in the module that selection is a plain
conditional over two functions, which is what it always meant.

weight_length becomes a property -- the weighted design names it that, the
operator calls the same quantity tile_size, and a property lets each keep its
own vocabulary rather than renaming one to suit the other.

Every operator with its own design is now a single file plus its kernel
source. The two shared designs stay shared: channeled_unary_design.py serves
seven operators and binary_elementwise_design.py three, so collapsing those
would mean copying one design into ten files, which is the opposite of the
point.

Verified on a Strix npu2: rms_norm 295 passed, iron/tests 470 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
Ported from the graph-capture prototype, which had already got this right.
calculate_buffer_layout assigns running offsets with no liveness analysis at
all, so every intermediate in a sequence stays resident for the whole
sequence and peak memory is the sum of all buffers rather than the maximum
concurrently live. For a model that runs one block sixteen times that is
sixteen copies of each scratch buffer.

Two passes over a runlist: live_ranges gives each buffer the step interval it
must stay resident for, and plan assigns byte offsets so buffers whose
lifetimes do not overlap can share addresses. Greedy by size descending with
best-fit placement -- Algorithm 3 of Pisarchyk & Lee (MLSys 2020), which is
what TFLite ships and what TorchInductor approximates. peak_live_bytes gives
the lower bound to check any plan against.

Buffers the host addresses by name -- weights, caches, the sequence's own
inputs and outputs -- are pinned and never pooled.

The two tests covering OperatorSequence's buffer_offsets are marked
xfail(strict): that parameter does not exist yet, and these pin the contract
the wiring step has to satisfy. strict so they fail loudly once it lands
rather than passing silently as xpass.

Nothing is wired up yet, so no behaviour changes: iron/tests 470 passed plus
90 new allocator tests.

Co-Authored-By: Claude <noreply@anthropic.com>
iron/tests/infrastructure/allocator.py shared a module basename with
iron/common/allocator.py. Collection is nondeterministic under that: one run
of the full suite came back with 30 failures and 40 errors, the next with the
same tree came back clean. Renaming removes the ambiguity rather than relying
on import mode to resolve it.

iron/tests: 515 passed, 3 skipped, 10 xfailed.

Co-Authored-By: Claude <noreply@anthropic.com>
calculate_buffer_layout assigned running offsets in declaration order, so
every intermediate stayed resident for the whole sequence and an arena was as
large as the sum of everything in it. OperatorSequence now takes
buffer_offsets, and passing None keeps exactly the previous layout.

Planned offsets are rebased past the unplanned buffers rather than applied
from zero. A plan is relative to its own pool and starts at zero, so applying
it directly drops the first planned intermediate on top of the weights -- and
that aliasing is silent, because the arena simply does not grow. The test that
caught it asserts a planned buffer starts at or after the end of every
unplanned one.

The two tests covering this were xfail(strict) pending the parameter. Removing
the markers showed they had a second problem: they never set a device, so they
died with "'NoneType' object has no attribute 'resolve'" -- which reads as a
bug in the code under test rather than a missing fixture. Added the device
fixture the other suites use.

Nothing calls this with a plan yet, so behaviour is unchanged: iron/tests 525
passed.

Co-Authored-By: Claude <noreply@anthropic.com>
OperatorSequence can now derive buffer_offsets from its own runlist:
scratch_plan() walks the steps, takes each buffer's live range from
first-write to last-read, and packs the ones whose lifetimes do not overlap
into shared addresses. On a four-deep chain the scratch arena drops from 6144
to 4096 bytes, with the last intermediate reusing the first one's address.

Buffers the host addresses -- the sequence's own inputs and outputs, and
anything given an explicit size -- are pinned and never pooled: their
contents outlive the sequence, so they need private, stable addresses.

plan_scratch defaults to False. This is the first change here where a mistake
is wrong numbers rather than a crash: two buffers aliased while both are live
produce quietly incorrect results. Off by default means nothing moves until a
caller asks, and the existing fusion tests keep exercising the old layout.

Two tests state the contract: that planning shrinks the arena, and that no two
buffers overlapping in time ever overlap in bytes. The second is the invariant
a liveness bug would break, written as an assertion rather than left implicit
in a numerical comparison.

iron/tests: 545 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
Ported from the graph-capture prototype. A Traced value stands in for a
buffer, calling an operator records a step, and the buffer names that
OperatorSequence needs become generated internals rather than something a
model author types:

    with capture() as g:
        h = g(rms_norm, x, w)
        q = g(q_proj, h, wq)

Graph.__call__ allocates outputs from the operator's own arg_spec, so a
recorded value knows its shape without a second rule -- which is what the
shape functions from layer 1 were for. infer_io derives inputs and outputs
from the recording: a buffer no step produced is an input, one no step
re-consumes is an output. scratch_plan reuses the layer 2 allocator, pinning
anything the caller named.

build() emits an OperatorSequence, so capture is a frontend over the existing
dispatch machinery rather than a replacement: fused and separate dispatch,
tracing and the ELF path are all reused untouched.

The prototype's mnist test is dropped rather than ported -- it imports an
application that does not exist here, and porting an application to satisfy a
test would be the wrong order.

iron/tests: 615 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
Every capture test so far stopped at the recording -- runlist, inferred I/O,
plan -- which are all statements about bookkeeping. None of them showed that a
recorded graph computes anything. This adds the test that does: the same
arithmetic expressed as dataflow and as a hand-written runlist must produce
identical values, bit for bit.

It found that only one of the two ways to get there worked. get_callable()
went straight to the dispatch policy, but subbuffer_layout is populated during
compile(), so dispatching without compiling first died with an AttributeError
about a missing attribute rather than anything about compilation. Ahead-of-time
worked because compile() was explicit; just-in-time did not work at all.

get_callable() now compiles if that has not happened yet. compile() skips
artifacts already on disk, so the ahead-of-time path is unchanged and arriving
here twice costs nothing. Both are tested, parametrised aot/jit, and both must
agree with the hand-written sequence.

This is also the gate for buffer planning. build() pools scratch by default,
so a captured graph already runs on a planned layout -- and two buffers
aliased while both are live would show up here as wrong numbers and nowhere
else, since nothing about it raises.

iron/tests: 600 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
The numerical comparison ran only under dispatch="reference", the CPU path.
That shows the recorded wiring and the planned layout agree with a
hand-written runlist, but says nothing about the fused ELF -- which is the
path that actually runs on the device, and the one buffer planning affects.

Parametrised over both modes, so the four combinations of {aot, jit} x
{reference, fused} all have to produce identical values. Confirmed the fused
case is really the device path and not a silent fallback: the policy resolves
to FusedDispatch and the callable is SequenceFullELFCallable.

iron/tests: 600 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
scratch_plan pinned the sequence's inputs, outputs and explicitly-sized
buffers, but not slices. A step that writes a slice put it in the pool, and it
came back with an offset of its own -- unrelated to its parent, which
calculate_buffer_layout resolves it against. Nothing raises: the slice simply
reads the wrong memory.

Found by probing the written-slice case directly. The existing tests use whole
buffers, so none of them could reach it, and Llama's decode path is full of
slices -- it would have shown up there as wrong tokens.

The capture prototype already pinned slices; porting scratch_plan onto
OperatorSequence is where it was dropped.

iron/tests: 615 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
Two things the old naming got wrong.

scratch_plan() read like a noun a caller supplies. It is not: the layout is
derived entirely from the runlist -- liveness from the recorded order, sizes
from each operator's arg_spec -- and nobody passes it in. Renamed to
infer_buffer_offsets(), which says what it does. buffer_offsets stays as the
escape hatch for a caller who wants to override the inference.

plan_scratch defaulted to False. That was right when planning was unproven:
leaving it off kept the fusion tests running on the old layout as a control.
It is no longer right. Planning is now checked bit-exact on the device across
{aot, jit} x {reference, fused}, and the one real hole -- pooling a sliced
buffer, which aliases silently -- is fixed and pinned by a test. Captured
graphs already planned by default, so hand-written sequences behaving
differently was an inconsistency rather than a safeguard.

So it defaults to True, and plan_scratch=False becomes the escape hatch back
to packing every buffer back to back.

iron/tests: 615 passed, the eighty fusion tests now running on inferred
layouts.

Co-Authored-By: Claude <noreply@anthropic.com>
…guish

Retiring IRON's artifact graph onto CompilableDesign only works if its key
tells two captured graphs apart. It does not, in the obvious encoding, and
that had to be established before building on it.

Two generators that close over different MLIR but share a code object get the
SAME cache key: the recipe hash covers the code object and compile_kwargs, not
closure contents. Handing captured graphs over as bare closures would give the
second one the first one's artifacts, silently.

I nearly concluded the opposite. Probing it with `lambda: a` and `lambda: b`
shows different keys -- but those lambdas name different variables, so they
have different code objects, and the difference had nothing to do with the
graphs. Two captured graphs go through one call site and share a code object.
The probe has to keep the code identical and vary only the closure, which is
what these tests do.

compile_kwargs IS part of the recipe hash, so that is where a graph's identity
has to go. Also pinned: full_elf is in the key (fused and separate produce
different artifacts from the same MLIR), and an unchanged graph keeps its key
so the cache can hit at all.

Feasibility itself is confirmed: a captured three-step graph produces fused
MLIR with three aie.device blocks, and CompilableDesign accepts it.

iron/tests: 620 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
The seam for retiring the artifact graph. It takes a sequence that has already
produced its fused MLIR and compiles that half the upstream way, leaving the
rest alone -- so the move can happen in steps instead of one deletion that has
to land whole.

Four things about the upstream API are not guessable from its signature, and
each cost an iteration to find:

- compile_kwargs keys must be in the generator's signature AND carry a
  CompileTime[T] annotation. Note that `from __future__ import annotations`
  breaks this: the annotation becomes a string and get_type_hints resolves it
  against module globals, so a function-local import of CompileTime leaves it
  unresolvable and the key is rejected as unexpected.
- The generator must return an MLIR Module. _generate_uncached calls
  module.operation.verify() on whatever it gets, so text raises AttributeError.
- object_files does NOT stage anything; it feeds the artifact hash only.
  Objects must be copied into the work dir under bare names, because the fused
  MLIR's link_with asks for "op0_add.o" with no directory. This corrects the
  plan, which had _link_build_outputs_into being deleted along with the DAG --
  staging is load-bearing and has to survive.
- The cache key does not see closure contents, so two graphs whose generators
  share a code object collide. The MLIR's digest rides in compile_kwargs to
  keep them distinct.

Checked on hardware: a captured two-step graph compiles to a linked full ELF,
verified by its magic bytes rather than by its existence.

iron/tests: 670 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
The seam produced an ELF, which is not the same as producing the right one.
Compiled against the artifact rule it replaces, it came out 70,936 bytes to
the rule's 99,768 -- because the rule passes two flags the seam did not:

  --expand-load-pdis           switches PDIs between steps, which is what a
                               multi-device runlist is
  --get-scratchpad-parameters  emits the parameter table the host writes to

Neither is tuning. Without them the result links, loads and looks fine, and is
a different program. Nothing reports it.

Added a parity test asserting both paths build the same byte count, with those
two numbers in the docstring so a future flag regression reads as "the flags
diverged" rather than as an unexplained inequality. Byte-for-byte equality is
not available: aiecc embeds its working directory.

Still missing from the seam: --get-input-with-addresses, which the rule adds
when trace_size > 0. Tracing is not handled here yet.

iron/tests: 665 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
The seam ignored trace_size, so switching FusedDispatch onto it would have
broken traced builds without any sign. The rule adds
--get-input-with-addresses when trace_size > 0 because the trace parser reads
the lowered module for the buffer layout and each design's traced tiles;
without it the ELF builds, loads and runs, and there is simply nothing to
parse.

That flag also has to reach the cache key. The MLIR is identical either way,
so a traced and an untraced build of the same graph would otherwise share an
entry, and the traced one would be handed an ELF with no trace in it. trace
goes into compile_kwargs alongside the graph digest, which is the half of the
key that sees them.

extra_flags is threaded through too; the rule has always forwarded those.

Parity is checked for a traced build as well, against the same rule.

iron/tests: 680 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
SequenceFullELFCallable asserted artifacts[0] was a FullElfArtifact and read
its filename, so dispatch was tied not just to an ELF existing but to the
artifact graph having been the thing that built it. A sequence compiled
through CompilableDesign has exactly the same ELF and no such artifact.

full_elf_path(seq) returns an explicit elf_path when one is set and falls back
to the artifact otherwise, so both producers work and the failure names what
is actually wrong rather than tripping an isinstance assert.

One of the three couplings that have to come apart before FusedDispatch can
move. The other two are harder and are not addressed here: FullElfArtifact is
what *causes* the fused MLIR and the kernel objects to be built -- they are its
dependencies, and it is the only artifact registered -- so removing it removes
the reason its own inputs exist. Those two have to become targets in their own
right first.

Behaviour is unchanged; nothing sets elf_path yet.

iron/tests: 670 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
The switch. FullElfArtifact is no longer registered; the fused MLIR and the
kernel objects become targets in their own right, and link_elf() produces the
ELF through CompilableDesign once they exist.

Untangling that required the artifact to stop being load-bearing in two ways
at once. It was the only artifact registered, so it was both the output and
the reason its own inputs got built -- its dependencies were the MLIR and the
objects. And SequenceFullELFCallable asserted on its type to find the ELF
path, which the previous commit replaced with full_elf_path().

The parity tests that gated this are removed, because they compared against a
rule that no longer runs. One is replaced by a check that the trace flag still
reaches aiecc -- and correcting it is worth recording: --get-input-with-addresses
does not change the ELF, which comes out the same size either way. It emits a
side file, input_with_addresses.mlir, and that file is what the trace parser
reads. Asserting on ELF size passed for the wrong reason before the switch and
failed for the right one after; the test now looks for the file.

Verified on a Strix npu2: the eighty fusion tests pass on the new path, no
FullElfArtifact is registered, elf_path points at the CompilableDesign output,
and iron/tests is 665 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
hunhoffe and others added 30 commits September 18, 2026 21:03
Dead once FusedDispatch stopped registering the artifact: nothing constructs
one, so AieccFullElfCompilationRule can never match. 57 lines of the artifact
graph, and the first of it to actually go rather than be routed around.

full_elf_path() loses its fallback with them. It now fails with what is
actually wrong -- link_elf() has not run -- instead of reporting that no
FullElfArtifact is registered, which would have been true of every sequence
and told the reader nothing.

iron/tests: 665 passed, the eighty fusion tests among them.

Co-Authored-By: Claude <noreply@anthropic.com>
Two things, both found by the xclbin path failing to link.

Staging has to happen inside the generator. A cache miss runs
_cleanup_failed_compilation on the work directory before compiling, so objects
put there beforehand are wiped. The generator runs after that clear and before
aiecc, which is the only window where they survive. The fused path worked by
luck of ordering; the xclbin path did not, and its work directory was empty at
link time.

compile_xclbin_insts is the separate-dispatch counterpart. Chaining looked like
it needed something CompilableDesign lacks -- each operator's xclbin links onto
the previous one's via --xclbin-input -- but that and the kernel name are both
aiecc flags, which it already forwards. No local subclass is needed. The
predecessor goes into the cache key: two operators with identical MLIR chained
onto different xclbins are different artifacts.

It produces a 25,081-byte xclbin and a 3,248-byte insts stream for a standalone
ElementwiseAdd.

Worth recording why this looked broken first. The initial probe reused an
operator whose .mlir had been written by a fused build, which mutates
generator.kwargs["func_prefix"] without changing the artifact's filename. IRON's
filename+mtime cache handed back the prefixed MLIR, so a standalone operator
asked for op0_add.o and failed to link. That is a real defect in the artifact
graph, not just a bad probe: a fused build poisons the standalone cache entry
for the same operator, and content addressing makes it unrepresentable.

iron/tests: 665 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
build_fused_mlir mutates each operator's MLIR generator to add a func_prefix
without changing the artifact's filename. Those artifacts are dependencies of
the SequenceMLIRArtifact, so they are compiled to disk -- writing symbol-
prefixed MLIR to the exact path a standalone build of the same operator reads.
The cache keys on filename and mtime, so a later standalone build trusts it and
asks the linker for op0_add.o, which no standalone build produces.

The failure lands a long way from the cause: an undefined symbol at link time,
in a build that did nothing wrong, in a different process or session from the
fused build that poisoned the slot.

Reproduced deliberately -- fused build in one process, standalone in another,
same operator config -- after an initial attempt failed to show it. That
attempt used a size the fused build never touched, so the standalone read a
file no fused build had written. Worth recording: I had already asserted this
defect in a commit message off a single contaminated observation, then
retracted it when the bad probe came back clean. Both the claim and the
retraction were made on evidence that could not support them.

The prefix changes what the MLIR is, so it now changes where it is written.

iron/tests: 670 passed, with the new case failing before the fix and passing
after.

Co-Authored-By: Claude <noreply@anthropic.com>
The DAG's staleness check only compared filename and mtime, so a fused
build mutating a shared operator's generator.kwargs (func_prefix) in
place -- without touching the artifact's path -- left a standalone
build trusting a stale, differently-prefixed file with a newer mtime
than its source. 7335940 patched that one instance by also renaming
the fused artifact's filename by hand.

PythonGeneratedMLIRArtifact now stamps a recipe hash (generator code
identity + kwargs, via upstream's _compute_recipe_hash) alongside its
MLIR on generation, and is_available_in_filesystem() checks it. That
makes the whole class of collision unrepresentable instead of relying
on every mutation site remembering to rename around it, so the manual
rename in FusedDispatch.build_fused_mlir is now redundant and removed
-- the on-hardware regression test still passes without it.

device kwargs go through _device_identity_key rather than str(device):
the default object repr embeds a memory address, which would invalidate
on every fresh from_name() call even when the device hasn't changed.

iron/tests: 700 passed, 3 skipped (up from 670 baseline; the increase
is the new recipe-hash unit tests).

Co-Authored-By: Claude <noreply@anthropic.com>
Mirrors what FusedDispatch already does for the full ELF: kernel
objects still go through the artifact graph (Peano/chess compile isn't
on CompilableDesign yet), but the per-operator xclbin+insts chain is
now built by jit_compile.compile_xclbin_insts() at make_callable() time
instead of XclbinArtifact/InstsBinArtifact/AieccXclbinInstsCompilationRule.

CompareDispatch inherits set_up_artifacts from SeparateDispatch and
needed the same link_xclbins() call added to its make_callable().

iron/tests/infrastructure/sequence.py: 80 passed, including "separate"
and "compare" dispatch and their bit-identical parity check against
"fused" -- run on hardware, not mocked.

Co-Authored-By: Claude <noreply@anthropic.com>
SeparateDispatch was the only caller that ever set xclbin_input on an
XclbinArtifact, and it now chains through jit_compile.compile_xclbin_insts()'s
own plain-path parameter instead (see the prior commit). Nothing else
constructs an XclbinArtifact with it, so the field and the
--xclbin-input branch in AieccXclbinInstsCompilationRule were dead.

iron/tests/infrastructure/{sequence,jit_compile_path,compilable_design_contract}.py:
180 passed. iron/operators/flm/gemm/test.py (the standalone XclbinArtifact
caller): 185 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
SequenceMLIRArtifact + FusePythonGeneratedMLIRCompilationRule wrote the
fused MLIR to disk purely as DAG bookkeeping -- fuse_mlir() never read
that file back; it always called each operator's generator in-memory
via get_child_mlir_module(). Removing the artifact+rule pair and making
fuse_mlir() a plain function that returns text means a fused build no
longer writes any per-operator .mlir file to disk at all, which makes
the cache-poisoning bug class from the previous commits structurally
impossible here rather than just detected.

compile_sequence() (jit_compile.py) used to locate the fused MLIR by
scanning the artifact graph for a "_fused.mlir"-suffixed filename; it
now calls FusedDispatch.build_fused_mlir() directly, since there's no
artifact left to scan for.

iron/tests: 700 passed, 3 skipped (unchanged baseline).
iron/operators + iron/applications: 3165 passed, 5 failed (the one
known-unrelated mem_copy 16-core hardware timeout), 21 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
CompilableDesign.compile() bypasses its own on-disk cache entirely
whenever explicit xclbin_path/inst_path (or full_elf_path) are given --
confirmed in its source, not just the docstring: the cache-hit branch
is gated on `not explicit_paths`. compile_fused_elf/compile_xclbin_insts
always pass explicit paths, so every call recompiled through aiecc even
with a byte-identical recipe -- measured directly: two independently
constructed OperatorSequence instances with the same config each
rebuilt the ELF (mtime changed both times).

_compile_if_changed() reuses CompilableDesign's own content hash
(already relied on by compilable_design_contract.py) rather than
inventing a second one, and stamps it in a sidecar next to the first
output, mirroring PythonGeneratedMLIRArtifact.recipe_hash()'s existing
pattern. Both compile functions skip the rebuild (and the object-staging
copy) on a hit.

New tests in jit_compile_path.py assert ELF/xclbin mtime is unchanged
across two independently-built, identical-recipe compiles. Full
iron/tests: 710 passed, 3 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
MLIROperator.set_up_artifacts() was the last dispatch mode still on the
old XclbinArtifact/InstsBinArtifact/AieccXclbinInstsCompilationRule
path; FusedDispatch and SeparateDispatch moved onto jit_compile.py
earlier this session. Kernel objects stay on the artifact graph (Peano/
chess compile isn't on CompilableDesign yet -- its own kernel
auto-compile only fires for upstream's ExternalFunction, which no IRON
design uses, all 14 use plain Kernel(name, prebuilt_object)).

link_xclbin() is new: lazy and idempotent, mirroring
FusedDispatch.link_elf/SeparateDispatch.link_xclbins, called from
get_callable() the first time a standalone operator actually needs a
compiled binary. It's zero new compile logic -- compile_xclbin_insts()
already supports the no-chaining single-operator case.

get_artifacts() is deleted (confirmed zero callers left after
SeparateDispatch moved off it earlier this session).

Two operators are deliberately left untouched: flm.GEMM overrides
set_up_artifacts() with a runtime-parameter scheme (one xclbin reused
across every shape sharing a config, verified by its own
test_one_xclbin_serves_every_shape/every_clamp_bound tests asserting
the xclbin's path *and mtime* stay identical) and used to inherit
get_callable() silently -- it now gets an explicit override, a verbatim
copy of the old base implementation, so the base class changing under
it can't break it silently. flm.MMPrebuilt already overrides
get_callable() explicitly (hardcoded kernel name for its downloaded
xclbin) and is unaffected. Neither operator's set_up_artifacts() calls
super(), so neither is touched by this change.

mlir_cache_poisoning.py's _linked_objects() helper called operator.compile()
and read a .mlir artifact off disk; standalone operators no longer write
one (same reasoning as the fused-path change earlier this session), so it
now calls the generator directly -- and no longer needs to compile at all
for this check.

iron/tests: 710 passed, 3 skipped (unchanged). mlir_cache_poisoning.py +
kernel_object_arch_isolation.py: 40/40 passed, exercising the new
standalone path (ElementwiseAdd) and kernel-object registration directly.
Full iron/operators + iron/applications regression still in progress.

Co-Authored-By: Claude <noreply@anthropic.com>
The previous pin (dev4) predated two upstream changes IRON needs together:
#3584, which adds symbol_prefix plumbing and ships llvm-nm in mlir_aie/bin, and
the llvm-tool-discovery change, which teaches aie.utils.config to search the
peano tree as well as its own. dev18 was the newest nightly when this was last
checked and was literally the commit before #3584; dev26 has both.

Verified on the 8-col Strix with XRT 2.26: iron/tests is 710 passed / 3 skipped
before and after, i.e. every previously-passing test still passes. The bump also
regenerates every dialect binding for a new LLVM, so the null result is the
point.

Co-Authored-By: Claude <noreply@anthropic.com>
mlir-aie now provides prefix_symbols_in_object() and resolves llvm-objcopy,
llvm-nm and llvm-ar through aie.utils.config, so the copies here can go.

_prefix_symbols() built the nm -> rename-map -> objcopy pipeline by hand and
carried a separate implementation per platform: a Windows branch that shelled
out to an inline python -c script, and a POSIX branch that ran nm and awk under
sh. Both are replaced by one PythonCallbackCompilationCommand, which also drops
the .symbol_map and .symbol_map.syms files this left beside every prefixed
object. The upstream parser reads the symbol name as the last field rather than
awk's positional $3.

_find_tool/_find_working_tool/_tool_runs searched peano_dir, mlir_aie_dir and
PATH, because upstream's resolvers looked only in the mlir-aie bin directory and
would not find llvm-nm or llvm-ar, which ship with peano instead. That was a
workaround, not duplication -- and it is still load-bearing, just upstream's
now: on this box objcopy and nm resolve into mlir_aie/bin while ar resolves into
llvm-aie/bin. The execute-it-first guard _find_working_tool added lives there
too. peano_dir is no longer threaded into the rules, and ArchiveCompilationRule
needs no constructor at all.

Verified three ways on the 8-col Strix, since a cached object would make this
vacuous: driving KernelCompilationRule over a real kernel with prefix_symbols
set emits two commands instead of three and llvm-nm reads back op0_add_one and
op0_helper_fn; a cold fused build from an empty build dir produces op0_add.o
with op0_eltwise_add_bf16_* and zero .symbol_map sidecars; iron/tests is
765 passed / 13 skipped, unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
A checkpoint is a state_dict, so the thing that reads one should be an
nn.Module. Declaring the tree once buys load_state_dict to fill it,
named_parameters() to walk it, and -- the point here -- one name per weight that
is the same string on the checkpoint, in the module tree, and on the device
buffer. llama_npu.py currently spells out nine hand-typed HF keys per layer on
the decode side and a matching list on the prefill side, with a .T on some and
not others; llama_cpu.py keeps a third copy. This is where that becomes one row
of FROM_HF.

The tree holds parameters and nothing else -- no forward. What llama computes
stays in llama_npu.py and llama_cpu.py; a third opinion on the same arithmetic
would be the duplication this is meant to remove. Nothing consumes it yet, so
this commit is additive: the uploads move over next.

Three deviations from the obvious spelling, each measured rather than assumed:
nn.RMSNorm(dim).eps is None, which silently means finfo(bfloat16).eps ~= 0.0078
instead of llama's 1e-5, so eps is passed explicitly; from_hf builds on the meta
device and loads with assign=True, because otherwise the constructor
kaiming-initialises 1.236 B parameters (~2.5 GB) purely to overwrite them and
the filled tree holds a second 2.5 GB; and requires_grad_(False), since nothing
here trains and a consumer feeds a weight straight into a host F.linear.

Tested against the real 2.47 GB Llama-3.2-1B checkpoint, not just a stand-in:
all 146 checkpoint keys map, none go unused, the tree reports 1.2358 B
parameters, and every parameter shares storage with the checkpoint tensor it
came from. The shaped stand-in tier cannot show that FROM_HF matches the real
key spelling, so both tiers exist.

Co-Authored-By: Claude <noreply@anthropic.com>
The decode side spelled out nine Hugging Face keys per layer, the prefill side
kept a matching list with a .T on six of them, and llama_cpu.py kept a third
copy -- 35 hand-typed key strings across three files, each one a chance for the
checkpoint, the buffer and the reference to disagree silently.

The device buffers are renamed to the module tree's parameter names, so decode's
whole upload becomes a loop over named_parameters(). That is not just shorter:
get_buffer() raises on a name only one side knows, so a rename now fails at
startup instead of leaving a weight zeroed. The W_* strings were local to
llama_npu.py and never reach MLIR or a filename -- the only parsing done on a
buffer name is calculate_buffer_layout's "[" slice test -- so dots are safe.

Prefill keeps its own layout, because its GEMM wants each projection K-major
while decode's GEMV wants it as shipped. That disagreement is one keyword on
_upload() rather than a second key list, so prefill now names weights by
attribute access and a typo is an AttributeError at construction. out_head is
the exception that rules out deriving the layout from the module type: it is an
nn.Linear but must not be transposed.

Verified byte-identical rather than argued: for all 146 parameters the tensor
this uploads is torch.equal to the one the old key strings fetched, and the
prefill transposes match too. Llama now runs end to end on the NPU -- 4/4 of
iron/applications/llama_3.2_1b/test.py, first time, since XRT here is 2.26 and
the hw_context path that needed >=2.21 is no longer blocked.

Two fixes this uncovered, both pre-existing and only reachable once llama could
actually run:

SequenceFullELFCallable.params and .lowered_mlir_text still read
self.op.artifacts[0].mlir_input, but 02f78d8 made the fused MLIR stop being an
artifact, so artifacts[0] is now always a KernelObjectArtifact and both raised
AttributeError. The work dir is derived from the ELF path instead, through a
named fused_work_dir() so the convention lives in one place the way
_aiecc_work_dir's docstring asks.

The attention-scores GEMV used K == head_dim == the default vector size, and
mv.cc in mlir_aie 1.4.4.dev26 added static_assert(k >= 2*r) to protect a
pipelining pragma that assumes two iterations. Pinned to 32, checked against the
golden reference at llama's exact shape (M=1024, K=64, 32 batches) with no
errors. Upstream's assert is arguably too strong -- k == r is a well-defined
matvec -- and that is worth raising there.

Not resolved here: NPU decode output degrades after a few tokens relative to
llama_cpu.py on the same prompt and seed (prefill reproduces the prompt exactly
and the first generated tokens agree). It is not this change -- the bytes are
identical -- but it predates any observation, because llama_npu.py could not run
on this host until today. test.py asserts only returncode == 0, so it does not
catch it.

Co-Authored-By: Claude <noreply@anthropic.com>
351747c moved standalone operators onto CompilableDesign: set_up_artifacts()
registers only kernel objects, and link_xclbin() builds the xclbin/insts lazily
the first time get_callable() asks. For an operator whose design has no C++
kernel that leaves the artifact graph *empty*, so compile() did nothing at all
-- it generated no MLIR, and returned success for configurations whose MLIR
cannot be generated.

That is not just a missing error. Operators validate their configuration while
building the design, so "compile() succeeded" stopped meaning the operator is
buildable, and the diagnosis surfaced later from get_callable(), or never.
Repeat(cols=513) is the clearest case: cols has no divisor giving both a
word-aligned chunk and a chunk count inside the 10-bit wrap field, the generator
says so, and compile() reported success anyway.

compile() now drives link_xclbin() after the artifact-graph pass, skipping it
under dry_run. get_callable() still calls it too, so an operator that was never
explicitly compiled keeps working.

flm.GEMM and flm.MMPrebuilt override link_xclbin() to do nothing, symmetric with
the get_callable() overrides they already carry: their xclbins genuinely are
artifacts that the graph pass builds -- one for the config/shape RTP split, one
downloaded prebuilt -- and the base implementation would compile a second one
and defeat the point.

Caught by iron/operators/{repeat,strided_copy} rejection tests, which had turned
into 20 "DID NOT RAISE" failures. They pass again, flm is 47/47, and iron/tests
is 765 passed / 13 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
mv.cc requires DIM_K % VEC_SIZE == 0 *and* DIM_K >= 2*VEC_SIZE -- the inner
loop carries a pipelining pragma that assumes at least two iterations, and both
are static_asserts. __post_init__ only checked the first, one factor too weak,
so K == kernel_vector_size passed validation and then failed as a C++ error
from inside a Peano build: "static assertion failed due to requirement
'64U >= 2 * 64U'", with nothing pointing at the operator argument responsible.

kernel_vector_size now defaults to None and resolves to the widest legal width
for K. Passed explicitly it is checked, and the message names the rule and
lists what would work. K >= 128 still resolves to 64, so no existing
configuration changes width; K=64 gets 32 and K=32 gets 16.

This fixes the three gemv_batched shapes at K=64 that 1.4.4.dev26 broke, and
supersedes the explicit kernel_vector_size=32 that llama's attention-scores
GEMV carried -- the rule now lives in one place instead of at the call site.

Vector size is repr=False, so it is absent from the operator, MLIR and xclbin
names while appearing in the kernel object name (gemv_{K}k_{vs}vs.o). That is
the shape of a cache-poisoning bug, so it was tested rather than reasoned
about: building K=128 at the default, then explicitly at 32, then at the
default again shows the MLIR text differing between the two configurations,
link_with naming the matching object each time, the xclbin rebuilding on the
change, and the default build reproducing byte-identically.

Checked on hardware: gemv is 95/95; the vs=16 path that no test parameter
reaches compiles and matches the golden reference at K=32; llama is 4/4. A cold
llama build from an empty directory links op7_gemv_64k_32vs.o, with no
gemv_64k_64vs object, no flat duplicate objects and no .symbol_map files
anywhere in the tree.

Co-Authored-By: Claude <noreply@anthropic.com>
IRON's use of CompilableDesign was a text passthrough: link_xclbin() and
SeparateDispatch called str(op.get_mlir_artifact().generator()) themselves, then
wrapped the resulting text in a synthetic generator so upstream would accept it.
Generation therefore happened outside compile(), and everything awkward about
the seam followed from that one fact.

The cache could not see the real generator, so identity had to be faked by
hashing the emitted MLIR and smuggling the digest through compile_kwargs.
Kernels could not be declared by the design, because ExternalFunction registers
into a global set that compile() clears at the start of its own generation --
anything constructed earlier is wiped -- so objects had to be built beforehand by
a separate rule and staged by hand.

compile_xclbin_insts now takes the DesignGenerator and resolves but does not call
it; the design runs inside compile(), under its lock, in the window where
ExternalFunction._instances is collected. Identity comes from what it actually
is: the design function, hashed by code identity, plus its bound parameters.

The parameters need care, and the failure mode is silent. compile_kwargs values
that are not callables are hashed by str(), and dev stringifies to
"<abc.NPU2 object at 0x7f...>" -- an address. Passing it through would give every
process a different key, which is not an error, just an aiecc run on every call
forever. _params_key drops dev, since device identity already reaches the key via
_compute_artifact_hash as (type, arch, cols, rows), and rejects any other
parameter carrying an address rather than quietly degrading.

Two processes now compute the same cache hash for the same operator, checked
directly. Staging and object_files stay for operators that still declare prebuilt
kernels; they go when the last one declares ExternalFunctions instead. The fused
path keeps building its text up front -- fusing several designs into one module
is a real transformation, not a passthrough.

iron/tests 780 passed / 13 skipped; iron/operators 3165 passed with only the five
known mem_copy 16-core timeouts, which is the pre-existing baseline exactly.

Co-Authored-By: Claude <noreply@anthropic.com>
The first cut dropped the parameter called "dev" from the cache key, because a
device stringifies to "<abc.NPU2 object at 0x7f...>" and hashing that by str()
would re-key every process. Excluding it was right in spirit and wrong in two
ways.

Wrong to key on the name: what makes a value a device is its API, not what the
design happens to call it. A design spelling it "target" would have leaked an
address into the key, and a non-device parameter named "dev" would have been
silently dropped from it. Recognition is now duck-typed on exactly the
attributes _device_identity_key reads.

Wrong to drop it: a key that ignores the device is stable and incorrect -- two
designs differing only in target share an entry, so an NPU1 build can be handed
to NPU2. Upstream splits identity into a recipe (generator, parameters, flags)
and an artifact (sources, objects, tools, device), and a device belongs to the
second half; it is now spelled there the same way, reusing upstream's own
_device_identity_key rather than inventing a second spelling. It reads
('abc.NPU2', 'AIE2p', '8', '6'): stable across processes, and still telling NPU1
from NPU2.

The same reasoning applies to rebinding. Any device-valued parameter is re-read
from the bound device when the generator runs, rather than reusing what the
operator resolved earlier: compile() calls ensure_current_device() in between,
which can bind a device that was previously only inferred, and generating
against a different device than the key names is how a design silently ends up
built for the wrong target.

Tests now assert what matters rather than that the parameter is absent: no
address in the key, NPU1 and NPU2 keys differ, and a device is recognised when
the design calls it something else.

iron/tests 785 passed / 13 skipped; gemv, gemm and softmax 240 passed. The key
spelling changed, so this invalidates existing cache entries once.

Co-Authored-By: Claude <noreply@anthropic.com>
…dren

gemv named its kernel object twice -- once in the design's Kernel(), once in
get_kernel_artifacts() -- with the gemv_{K}k_{vs}vs.o formula written out
independently in both places and nothing keeping them in step. It now declares
one ExternalFunction per kernel, carrying the source, the -D flags and the
fusion prefix, and reports no kernel artifacts at all; upstream compiles them
and names the object by content. The gelu epilogue stops being an archive and
becomes a second ExternalFunction: each func.func carries its own link_with and
aie-assign-core-link-files aggregates them onto the core.

Two bugs had to be fixed to get there, both of which built and linked cleanly.

fuse_mlir inlines each child's device, runtime sequence included, and drives PDI
switching itself -- alternating two PDIs per configure point under
--expand-load-pdis, with needs_additional_reset keeping the count even. Moving
fused generation inside compile() put the children under _iron_full_elf, which
makes a runtime sequence load its own PDI because on that path no xclbin
configures the device. Both schemes then ran at once. Nothing failed to build:
the ELF linked and the device hung at dispatch with ERT_CMD_STATE_TIMEOUT.
Exactly one program in a fused build is a full ELF and it is not the children,
so _fuse_as_children shadows the flag for them -- which the old code got for
free by generating outside compile() entirely. The cache key is computed through
the same helper, because keying under one value and building under the other
describes a different program and nothing reports that either.

build_fused_mlir decided whether to prefix an operator by asking whether it had
kernel artifacts. That was a proxy for "has kernels", and ExternalFunction
breaks it: a migrated operator reports none, so it silently went unprefixed.
Every gemv shape in llama then defined matvec_vectorized_bf16_bf16, kept apart
only by each core linking its own object. It now asks the design whether it
takes func_prefix, which is the actual contract. Verified by reading the objects
back: op1_, op11_, op12_, op17_ prefixes on both names and symbols.

Note ExternalFunction joins its prefix with an underscore of its own, for the
symbol name and the rename pass alike, so IRON's "op0_" is handed over stripped.

iron/tests 785 passed / 13 skipped; iron/operators 3165 passed with only the five
known mem_copy 16-core timeouts; llama 4/4, and its generated text is
byte-identical to before this change, so the kernel move altered no numerics.

Co-Authored-By: Claude <noreply@anthropic.com>
ChanneledUnaryOperator and BinaryElementwiseOperator between them back eleven
operators -- relu, gelu, silu, sigmoid, tanh, leaky_relu, layer_norm,
elementwise_add, elementwise_mul, swiglu_prefill, swiglu_decode -- and each of
them named its kernel object twice: once as the design's Kernel(), once as the
operator's KernelObjectArtifact. Both bases now declare an ExternalFunction in
the design and report no kernel artifacts at all.

The two shared designs, and gemv before them, had the same five lines of
declaration with the same trap in it, so it is one helper now: declare_kernel()
in iron/operators/_kernels.py, alongside _trace as the other design-side shared
piece. The trap is that IRON spells its fusion prefix "op0_" while
ExternalFunction joins with an underscore of its own, for the symbol name and
the rename pass alike, so the prefix has to be handed over stripped.

The aie2 lut_based_ops case stays exactly as it was, and the reason is worth
stating where it is now load-bearing: lut_based_ops.cpp defines tables the
kernel references transitively from C++, with no MLIR call site, so
aie-assign-core-link-files cannot discover that object by tracing func.call
edges. It has to be archived and named by an ordinary link_with, which means a
prebuilt Kernel. kernel_obj_file returning None is what selects the
ExternalFunction path; needs_lut_archive names the condition. That branch is
aie2-only and this box is aie2p, so it is untestable here and was not touched.

kernel_object_arch_isolation used ElementwiseMul as a vehicle for testing that
two arches cannot collide on one object path. That operator no longer produces
an artifact to test, so the tests move to AXPY, which still does. They are
guarding the operators left on the artifact path and should retire with it:
upstream keys an ExternalFunction's object on content and on device identity,
so the collision they describe is unrepresentable there.

iron/tests 785 passed / 13 skipped; the eleven operators 1195 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
…, gemv

Four more operators stop naming their kernel object twice, and gemv folds onto
the declare_kernel helper the shared bases already use, so all five now read the
same way. kernels_dir moves to MLIROperator beside dev: it is a parameter every
design needs and no operator stores, which is exactly what those properties are
for, and it keeps IRON_AIE_KERNELS_DIR redirecting the source while still
reaching the cache key.

mem_copy keeps its bypass branch, which means something different from the
others: no kernel at all, rather than one declared elsewhere.

kernel_object_arch_isolation no longer borrows an operator. It was written
against ElementwiseMul, moved to AXPY when that migrated, and would have moved
again -- the vehicle keeps disappearing because a design that declares an
ExternalFunction produces no artifact to test. It builds the artifact directly
now, which is honest about what it covers: a property of move_artifacts, for
the operators still on the artifact path, to retire with it. Upstream keys such
an object on content and device identity, so the collision it describes cannot
occur there.

iron/tests 785 passed / 13 skipped. axpy, transpose and dequant 96 passed;
mem_copy 63 passed with only the known 16-core timeout.

Co-Authored-By: Claude <noreply@anthropic.com>
rope and rms_norm are the straightforward shape -- one ExternalFunction per
kernel, from one source each. rope's object stops being named for the method id
and is named for the symbol instead, which is what actually distinguishes the
two variants rope.cc defines.

softmax is the first operator where declaring per kernel would have been wrong.
softmax_bf16 and mask_bf16 both live in softmax.cc, so two defaulted
declarations compile that translation unit twice into two objects, each
defining *both* symbols -- a duplicate definition once a core links them. They
share one object_file_name instead: identical source and flags give an
identical content digest, so upstream neither reports a collision nor compiles
twice. Checked rather than assumed, by reading the build back: one softmax.o
per design, carrying mask_bf16 and softmax_bf16 together.

declare_kernel grew object_file_name for that, and applies the fusion prefix to
it. Upstream names a defaulted object after the prefixed symbol but takes an
explicit one as given, so without that two fused operators would share one
object.

softmax keeps its aie2 archive, for the reason that keeps recurring:
lut_based_ops.cpp's tables are reached transitively from C++ with no MLIR call
site, so nothing discovers that object by tracing calls. kernel_obj_file
returning None is what selects the ExternalFunction path.

rope and rms_norm 115 passed; softmax 15 passed; iron/tests 785 passed / 13
skipped; llama still 2/2 on hardware, which is what exercises all three fused.

Co-Authored-By: Claude <noreply@anthropic.com>
Both are cases where one translation unit backs several entry points, so both
use the shared object name softmax needed: gemm's zero and matmul come out of
mm.cc, and all six of mha's come out of mha.cc. Declared per kernel they would
compile that unit once each and every copy would redefine every symbol in it.

gemm keeps its aie2 quirk where it was, in the operator: that arch sources a
patched mm.cc from the tree and needs an -I so the in-tree file's includes
resolve against the package copies. It is now expressed as kernel_source and
kernel_flags properties, which is all an ExternalFunction needs. The
kernel_object property is gone -- the object name is derived in the design,
where it is used.

mha stops listing mm.cc and softmax.cc as dependencies. mha.cc #includes them,
and upstream reads Peano's depfile and validates the manifest against it, which
covers the transitive headers that hand-written list never did.

Checked cold, from an empty build dir, because a warm run here proves nothing:
mha produces exactly two objects, mha.o carrying all fifteen symbols and
mha_passThrough.o, from six declarations and two compiles.

gemm and mha 155 passed; iron/tests 785 passed / 13 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
…e hashing

This operator was the last one still on the artifact graph, kept there for its
config/shape split: the xclbin is emitted at a reference shape so every shape
sharing a configuration reuses it, and only the instruction stream is per
shape. That turns out to be two compile_xclbin_insts calls, each discarding the
half it did not want, so XclbinArtifact, InstsBinArtifact and the verbatim
get_callable override all go.

Only then can its kernels move: mm_fused.cc's three entry points are collected
from ExternalFunction._instances, which is populated during generation inside
compile(), and this operator never went through compile() at all. They share one
object name, like softmax's and mha's, because they share a translation unit.

The object it produces is byte-identical to the one the artifact graph built --
a9fcbabd, before and after -- which is a stronger statement than a benchmark on
the most performance-tuned operator here: identical machine code cannot run at a
different speed.

Getting there exposed a caching bug that is not this operator's, and not new.
_compute_artifact_hash reads get_current_device(probe_runtime=False), which is
None until something binds a device -- and CompilableDesign.compile() binds it
moments later, from inside. So _compile_if_changed hashed a "no device" identity
for the first build in a process, stamped that, and the next identical build
could never match it: every process silently rebuilt once, for every operator.
Nothing failed, so nothing reported it. It surfaced here only because
test_one_xclbin_serves_every_clamp_bound is the one test that asserts a second
instance does not rebuild. Binding the device before hashing makes both sides
agree.

flm 235 passed, including the two one-xclbin tests; iron/tests 785 passed / 13
skipped. Those two tests now read _xclbin_path, since the artifact they used to
reach through no longer exists.

Co-Authored-By: Claude <noreply@anthropic.com>
…the bug

Two gaps in the previous commit's fix, both found by checking it rather than
trusting it.

The bind was unguarded. CompilableDesign._bind_generation_device wraps the same
call in try/except because binding probes the runtime, which a compile-only host
without one cannot do; an unguarded call would turn a working offline build into
a crash. Guarded identically now -- failing to bind leaves the device unset on
both sides, which still agrees with itself.

The test I added for it passed with the fix disabled, which made it worthless.
It bound the device itself before calling _compile_if_changed, so the binding
inside was never needed. It now leaves the device unset across the call, which
is the only state where the fix does anything, and fails without it.

Verified across processes as well as within one, on both paths, since a single
process was never the interesting case: three separate runs of an operator and
two of a fused sequence each reuse the first build rather than recompiling.

iron/tests 790 passed / 13 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
The tables in lut_based_ops.cpp are what aie2's exp/log kernels reference from
C++, with no MLIR call site. aie-assign-core-link-files finds objects by tracing
func.call edges, so it can never discover that one, and IRON stapled it on with
an llvm-ar archive -- a whole artifact class, a compilation rule and a binutil,
kept alive for one orphan object on one architecture.

Compiling it into the kernel's own translation unit removes the orphan instead
of working around it. declare_kernel grows bundled_sources, which generates a
source that includes the bundle and then the kernel, and hands that to
ExternalFunction as source_string -- no file on disk, and included by bare name
against the search path so the digest upstream takes does not move with the
checkout.

A generated source rather than clang's -include, which was the first thing I
tried: -include is processed before the arch macros are established and aie_api
rejects it with "'__AIE_ARCH__' macro is required".

So KernelArchiveArtifact, ArchiveCompilationRule, lut_based_ops_artifacts and
the llvm-ar dependency are all gone, and this no longer waits on the upstream
header-only change (the prompt for which stays valid -- it is still the better
fix, it just is not a blocker any more).

The path is aie2 and this box is aie2p, so it cannot be run here. It can be
compiled here, and that is the property the archive supplied: softmax built for
NPU1 produces one self-contained softmax.o, no undefined lut symbols, tables
defined, both entry points present, and no .a anywhere in the build. Running it
still needs NPU1 hardware.

iron/tests 790 passed / 13 skipped; the lut users plus flm 745 passed on aie2p.

Co-Authored-By: Claude <noreply@anthropic.com>
…hat that freed

The last operator still building an artifact through the graph. Its xclbin is
downloaded rather than compiled -- which is what the operator exists for, and
the one thing RemoteFileArtifact expresses that the compile path cannot -- but
its instruction stream was an InstsBinArtifact fed by a PythonGeneratedMLIRArtifact,
which kept four more classes and two rules reachable for one caller.

It is a compile_xclbin_insts call now, keeping the instructions and discarding
the xclbin written beside them, the same way flm.GEMM discards the half each of
its two builds did not want.

Its design returns MLIR text where the others return a Module, which upstream
reports as "AttributeError: 'str' object has no attribute 'operation'" -- naming
neither the design nor the cause. _design_generator parses a string return
rather than making every design agree on which to hand back.

With that, nothing constructs an XclbinArtifact or an InstsBinArtifact, and no
graph contains a PythonGeneratedMLIRArtifact, so the rules that compiled them
are unreachable. Deleted: XclbinArtifact, InstsBinArtifact, _MLIRInputMixin,
AieccCompilationRule, AieccXclbinInstsCompilationRule and
GenerateMLIRFromPythonCompilationRule. PythonGeneratedMLIRArtifact itself stays
-- every operator still uses it to carry its DesignGenerator, which is now all
it does.

compilation/base.py is 708 lines, from 977 on devel and 891 this morning.

iron/tests 790 passed / 13 skipped; flm, softmax and gemv 345 passed; llama 2/2.

Co-Authored-By: Claude <noreply@anthropic.com>
UNTESTED ON HARDWARE. stream-dse is not installed here, so all four stream
tests skip and nothing below has been executed end to end. CI has to be the
judge. What could be checked locally was, and is described at the end.

stream was the last user of the artifact graph, and of rename_symbols, which
upstream has no equivalent for. It needed one because stream-dse suffixes a
GEMM's symbols with its tile shape so several shapes coexist in one design,
while mm.cc defines them unsuffixed, and ExternalFunction can only prefix.

The reconciliation goes the other way instead: the object is prefixed, and the
generated MLIR is rewritten to agree. That works because IRON already rewrites
this text -- _prefixed has been applying the fusion prefix to link_with and to
every declared symbol and call site all along. _renamed is the same operation,
applied first so the fusion prefix lands on top: mm.cc's matmul_bf16_bf16
becomes mm128_64_64_matmul_bf16_bf16, and op3_mm128_64_64_matmul_bf16_bf16 in
a fused group.

declare_kernel grew symbol_prefix for this. It composes with the fusion prefix
rather than replacing it, and deliberately does not reach the object name: a
generated design names the object it links, so renaming the file out from under
it would break the link. That distinction was wrong in the first draft and the
object came out as mm128_64_64_mm_128_64_64.o.

Two other things this surfaced:

group_index was positional, which the compile seam rejects -- a design's
parameters reach the cache key by name. It is keyword-only now, and kernels_dir
is threaded in rather than reached for through the default context, so pointing
IRON at another kernel tree re-keys the build.

lut_sources sat in iron/operators/_kernels and was imported by
iron/common/operator_bases, so importing iron.operators._kernels first raised
ImportError on a partially initialized module. It only ever worked because
every test imported iron.common first. Moved to iron/common/device_utils.

What was verified locally: both halves of the naming agree, checked by building
the ExternalFunction and comparing against the rewritten text, fused and
unfused; the rewrite composes correctly, checked by lifting _renamed and
_prefixed out of the module (which cannot be imported without stream-dse) and
running them over representative MLIR. iron/tests 790 passed / 13 skipped and
265 operator tests pass, so the paths that do run here are unaffected.

With rename_symbols gone, compilation/base.py is 692 lines, from 977 on devel.

Co-Authored-By: Claude <noreply@anthropic.com>
Every operator's kernels are ExternalFunctions now, so nothing constructs a
KernelObjectArtifact, KernelCompilationRule never fires, and the objects land
in the work dir because upstream compiles them there rather than because IRON
copies them. Deleted: KernelObjectArtifact, KernelCompilationRule,
_link_build_outputs_into, stage_objects, the object_files parameter threaded
through both compile entry points, and the get_kernel_artifacts hook itself.
move_artifacts loses its arch-scoping segment with them: it existed because two
arches could collide on one build_dir path, and upstream keys an object on its
content and on device identity instead.

PythonGeneratedMLIRArtifact keeps only what it is still for -- carrying a
DesignGenerator. Its recipe_hash and availability override detected a stale
written .mlir, and nothing writes one; the collision they guarded against, a
fused build mutating a shared operator's func_prefix under a standalone build's
path, is caught by the compile cache key, which hashes the design and its
parameters including that prefix.

Three tests go with it. kernel_object_arch_isolation tested move_artifacts'
arch scoping and could not even import once that was gone. mlir_recipe_hash and
its fixture tested recipe_hash directly. mlir_cache_poisoning stays: it is the
end-to-end check that the property still holds, and it now says where.

One test was passing for a reason that had stopped being true --
"object_files does not stage, so this is the one thing the retirement cannot
delete" -- when that step is exactly what was deleted. Renamed and rewritten to
check the requirement rather than IRON's former way of meeting it.

compilation/base.py is 518 lines, from 977 on devel. What is left of the
artifact graph serves one thing: flm.MMPrebuilt's downloaded xclbin, which is
fetched rather than built and so has nothing to compile.

iron/tests 745 passed / 13 skipped; iron/operators 3165 passed with only the
five known mem_copy 16-core timeouts.

Co-Authored-By: Claude <noreply@anthropic.com>
Plan A proposes shape annotations on the design signature. Plan B moves
the declaration into an interface() method body, where the operator's own
parameters are already in scope -- which deletes the deferred-annotation
machinery Plan A needs and keeps real dataclass fields for pyright.

Plan B then splits what a compiled operator is. The toolchain already
separates the overlay (per-core ELFs + PDI, a function of the design) from
the runtime sequence (insts.bin, a function of the steps and the buffer
ABI); aiecc emits them from one dependency graph that only diverges at the
tail, and upstream's runtime already caches the two halves independently.
IRON collapses that into dispatch="fused"|"separate", which bakes in four
decisions and makes partial fusion unrepresentable. Plan B replaces the
string with four constructors -- Overlay, StaticSequence/GeneratedSequence,
Elf/Xclbin -- so the ELF/no-ELF question is which object you build, and
Elf's signature rejects a generated sequence statically.

Plan B also gives the overlay an interface of its own: shim bindings,
resident symbols, buffer sizes. flm/mm_prebuilt already performs that
agreement by hand against a downloaded xclbin, and records flm/gemm's
failure to match it in a comment; all three fields are recoverable from
files IRON already opens.

Both are drafts for a plan-refining session, not approved work. Plan B
gates everything behind two spikes (§9 step 0) because the one unverified
claim -- that an expanded-load-PDI fused sequence dispatches correctly via
the opcode-3 ABI -- fails as a device hang, not a build error.

Co-Authored-By: Claude <noreply@anthropic.com>
Plan B stood on Plan A for its priorities, diagnosis, measurements,
dismissal table and carried risk, so neither file was readable alone.
This folds the load-bearing parts of A into B and deletes both.

What came over from A, because B referenced it and could not stand
without it: the 12 priorities; the llama_npu.py line-count diagnosis that
motivates centering on the operator model rather than the graph recorder;
the duplication-vs-verification argument and the live GEMV arg_spec
disagreement that proves it (three restatements, all green, one of them
wrong); the pyright measurements that rule out synthesised dataclass
fields; the dismissal table; and the decode-drift risk a rewritten llama
will inherit and look guilty for.

Restored in the process: per-operator tuning got a section of its own
again. B had reduced it to a code snippet, which lost priorities 5 and 6
and the MAX_WRAP FIXME it retires -- now with a table of which target
model numbers are actually verified and which are still assumed.

Renumbered throughout; §-refs, O1-O15 and E1-E31 all resolve.

Still a draft, still gated on §19 step 0.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant