Skip to content

Graph-based Language Models - #222

Open
hunhoffe wants to merge 982 commits into
develfrom
iron-next
Open

hunhoffe wants to merge 982 commits into
develfrom
iron-next

Conversation

@hunhoffe

@hunhoffe hunhoffe commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

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 and
EmbeddingGemma 2.

The work builds directly on what devel validated: the operator arrays and tilings, the kernels (now upstream in mlir-aie's aie_kernels/ and aie.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/operators 18.5k lines, iron/tests 18.3k, iron/common/graph 8.5k, iron/common/declare 3.3k, iron/common/image 3.1k, iron/lm 2.9k and iron/applications 2.4k.

Suggested reading order. AGENTS.md (Architecture, Graphs, Design rationale), then iron/common/declare/, iron/operators/relu.py →clamp.py → gemv.py, then iron/common/graph/compiled.py, iron/tests/common/graphs.py, iron/lm/causal.py, and iron/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 overrides sequence(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:

# iron/operators/relu.py
class ReLU(UnaryElementwise):
    """AIE-accelerated ReLU activation function."""

    test = Testing(Sweep(), draw=dict(centered=("x",)))  # both signs

    def kernel(self):
        return eltwise.relu_sized(self.tile_size)

A fuller declaration (iron/operators/gemv.py, abridged):

class GEMV(Operator):
    test = Testing(_cases, draw=dict(normal=("A", "B")))

    M: int = Param()
    K: int = Param()
    num_batches: int = Param(default=1)
    num_aie_columns: int = Auto()
    num_channels: int = Auto()
    tile_size_input: int = Auto(domain=Divisors(of=lambda op, dev: op.M, cap=...))
    ...

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; otherwise resolve(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, and when=flag makes 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=) and Sweep(...) 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):

class VectorAdd(BinaryElementwise):
    def kernel(self):
        tiles = [self.a.tile, self.b.tile, self.y.tile, np.int32]
        contract = KernelContract(
            roles=(In, In, Out, Param),
            parameter_bindings=((3, self.tile_size),),
            reference=lambda a, b: a + b,
        )
        return ExternalFunction("vadd", source_string=VADD, arg_types=tiles, contract=contract)

Invariants

  • One array serves every extent. array() sees only the array tier, and reading an extent there raises. Trip counts are Values that the sequence writes. iron/tests/toolchain/array_identity.py compiles operators at two extents and compares the core ELFs byte for byte.
  • Tunable precedence: the call site's value, then the graph's Profile, then resolve(dev), then the Auto() default. Identity (array_key(), design_key()) is taken after resolution, so two calls that resolve alike share a build.
  • IRON hosts no kernels. Kernels come from mlir-aie's factories (aie.iron.kernels). Each factory's ExternalFunction carries the reference and tolerance (.contract), and the catalog test judges an operator against them unless Testing(tolerance=) overrides.
  • numpy only. References are numpy and ml_dtypes.bfloat16. Torch is no longer a requirement.
  • No build context. An operator takes the current device and nothing else. On a host without an NPU, bind one with aie_utils.set_current_device(from_name("npu2", n_cols=8)) (the test tree's npu2 / npu1 fixtures do this).

2. The lazy catalog

import iron and iron.operators load names on first use (PEP 562), so importing one operator imports only its module:

from iron.operators import GEMM  # imports iron.operators.gemm, nothing else
from iron.operators.flm import GEMM as FLMGEMM, DequantBFP  # flm is its own catalog

iron/tests/infrastructure/lazy_imports.py checks this in a fresh interpreter.
dir() and type checkers still see every name (TYPE_CHECKING imports).

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. Copy replaces MemCopy and StridedCopy. The SwiGLU composites are now graphs in iron.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.
  • Its keyword-only parameters, annotated Scratchpad[T], Carried[T] or DispatchTime[T], are per-call values.
  • Its return values are its outputs.
  • Weights and 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:

class Rotate(iron.Graph):
    """RoPE over the first `n` positions, `n` given per call."""

    def body(self, x, angles, *, n: Scratchpad[np.int32]):
        return RoPE(x[:n], angles[:n])


class Project(iron.Graph):
    """The first `n` rows of `x` projected by `w`."""

    def body(self, x, w, *, n: Scratchpad[np.int32]):
        return GEMM(x[:n], w, b_col_maj=True)

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. From iron/tests/operators/gemm_bounded_rows.py:

project = Project().compile(x=(M, K), w=(N, K), boundaries=boundaries)
for n in BOUNDS:                                   # (512, 1, 300, 64, 65, 512, 17, 128, 3)
    got = np.asarray(project(x, w, n=n)).reshape(M, N)

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=True prints the packaging decision and how each per-call value lowers (section 5).
  • graph.reference(*tensors, **values) runs the same body() through each operator's reference() on host arrays, with states modelled.
  • A graph called inside another graph's body traces into the caller: its steps, weights and states become the caller's, and one compile covers both. iron.lm.blocks.SwiGLU, LogMel, Conv2d and ImagePatches are such reusable graphs.

Invariants

  • All versions of a graph share one scratch arena, so a weight is on the device once and a state one version writes is where the next one reads it. The arena is split into banks of at most 3.5 GiB, one buffer object each.
  • Tracing refuses what cannot be traced. Branching on a per-call value raises TypeError (Value.__bool__), and so do *args/**kwargs in body, an output that is a graph weight or input, and an input defaulting to anything but None.

4. Per-call values and bounds: Scratchpad and x[:n]

There are three kinds of per-call values (iron.common.declare.value):

Kind What it is Full ELF xclbin
Scratchpad[T] patched at call time written to the ctrl scratchpad a dispatch-time scalar: an offset use regenerates the stream, a core-read use is an RTP write
Carried[T] computed by the graph for its own next call (section 6) as Scratchpad, and the device can loop host writes it back
DispatchTime[T] regenerates the sequence per call forces xclbin sizes, strides and offsets regenerated

Arithmetic on a value builds an Affine (pos + 1, chunk * C), which can
index or slice a handle, state or weight:

  • x[:n] bounds an extent per call. The bound carries through reshape and transpose and reaches every operator that consumes the view.
  • self.kv[:, pos] / cache[step.position] is a per-call offset.
  • table[ids] with ids a graph input or a handle the device made is a gather on the NPU (Copy(self.embedding[ids[:rows]])).

Invariants and limits

  • Under a bound, GEMM limits its compute (row blocks, and K per call, on both packagings), and MHA's K and V move only the blocks up to the bound. Attention over a cache therefore costs the context, not the cache length.
  • A per-call size is mlir-aie's runtime transfer length (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.
  • Every per-call index is checked against its view before the call reaches the device.
  • On a full ELF the preamble syncs the per-call words before opening the cores' barriers. a0a7d024f fixed this order: before it, from the second call on, each core read the previous call's words. gemm_bounded_rows.py and mha_bounded_rows.py drive one compile across alternating bounds to catch any regression.

5. Packaging: one full ELF, or a chain of xclbins

iron/common/image/packaging.py derives the image from the traced graph and
the device:

  • Full ELF (iron.ELF), one fused sequence and one dispatch per call. This is the default on AIE2P (npu2).
  • xclbin chain (iron.XCLBIN, boundaries=iron.each_step), one dispatch per step. Any of these forces it: a DispatchTime value, an npu1 device (full_elf(dev) is dev.arch is AIEArch.AIE2p), or boundaries=iron.each_step. A new xclbin chain starts every 32 designs.

Limits

  • A fused sequence inside one xclbin (several steps in one dispatch) has no proven construction yet and is refused with NotImplementedError. So npu1 runs every step as its own dispatch.
  • A shipped-image operator (image=Xclbin(...)) is refused in a graph; run it on its own with OperatorImage(op).

Standalone use, outside a graph (iron/operators/flm/gemm/README.md):

from iron.common.image import OperatorImage
from iron.operators.flm import GEMM
from iron.operators.flm.gemm.design import Epilogue

op = GEMM(M=1024, K=1536, N=6144, epilogue=Epilogue.SILU)
OperatorImage(op)(A, op.pack_B(B), C_out)

Build outputs live in mlir-aie's JIT cache (~/.npu/cache/<hash>/, or
NPU_CACHE_HOME), keyed on content. Nothing is written to the working
directory.

6. Carried values and the device loop

A Carried[T] value is one the graph computes for its own next call, returned last via iron.carry(...). On a full ELF the device can start the next call itself (CarriedLoop). From iron/tests/common/graphs.py:

class Walk(iron.Graph):
    def __init__(self, successor):
        self.successor = iron.weight(successor)

    def body(self, *, node: Carried[np.int32], steps: Carried[np.int32]):
        nxt = Copy(self.successor[node], dtype=np.int32)
        return iron.carry(node=nxt, steps=steps + 1)

CausalLM uses this to sample on the NPU: Sample draws the next token from the logits and a per-position draw row, and carries token and position + 1. On npu2 the host only restarts the loop and reads the tokens
back 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 (mha.py) has causal or bidirectional attention, a sliding window, scale, d above 64, and kv_len with a DMA-bounded kv_valid for 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 accurate exp2 (867b88de0). B_q, num_pipelines, pv_cores and placement are searched by the tuner. MHA is placed on npu1's 4 columns as well as npu2's 8.
  • GEMM (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 searches tile_m/tile_k/tile_n and placement. An M that no tile_m splits is refused at construction.
  • flm.GEMM (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, and iron.lm.blocks.project() uses it for prompt-chunk projections on both devices. flm.Shipped is the downloaded binary, kept for comparison only.
  • GEMV (gemv.py) supports num_batches, repeat= (one matrix serving consecutive batches, for GQA scores without a materialized copy), two lanes per column (num_channels=2), and finish / prepare links (a SiLU, a residual add or an RMSNorm folded into its cores).
  • Copy (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).
  • Sample, Emit and Merge do on-device sampling and carry the next call's words.
  • Front-end operators for EmbeddingGemma 2's towers: Limbs / Magnitude / Log (float32 log-mel steps), Resample / ResampleTaps / PatchPositions (image resize into patches), DepthwiseConv1d, ElementwiseMax, Clamp (bounds as runtime parameters), and RowwiseAdd / RowwiseMul.

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:

Domain Values
Width() the field's own value and every power of two up to the device's columns, widest first (the default for a per= tunable)
Divisors(of=, step=, cap=, span=) the resolved value times powers of two that divide of, are multiples of step and at most cap
Choices(options) a fixed set, the resolved value first
Placement({...}) named tile placements (8.2)
# iron/operators/mha.py
B_q: int = Auto(array=True, domain=BLOCK_SIZES)
num_pipelines: int = Auto(1, array=True, domain=Width())
placement: str = Auto(array=True, domain=PLACEMENT)
pv_cores: int = Auto(array=True, repr=False, domain=Choices((1, 2)))

A tunable that a call or profile gives explicitly is held, not searched. Auto(derived=True) fields are always derived by resolve() 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 Pins holding cores, memtiles and shims at a Level (TILE, COLUMN or FREE for the placer's choice):

# iron/operators/mha.py
PLACEMENT = Placement(
    {
        "memtiles": Pins(cores=Level.COLUMN, shims=Level.FREE),
        "tiles": Pins(),
        "columns": Pins(cores=Level.COLUMN, memtiles=Level.FREE, shims=Level.FREE),
    }
)

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 under NPU_CACHE_HOME/iron/fits.

8.3 Measuring: probes, cost tables and the CostCache

  • probe.py times 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>.json for the device's own packaging, costs_<device>_<dispatch>.json for 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 under NPU_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.
  • A design whose work follows a per-call value (attention over the context) is measured at a ladder of contexts and priced at their weighted mean (StepCost.points).
# one operator at every setting it tunes to
python -m iron.common.graph.tune GEMV M=2048 K=8192
python -m iron.common.graph.tune Softmax rows=32 cols=2048 --value valid_cols=1500

# a model's table, by the plan its Runner gives
python -m iron.lm.tune llama --checkpoint /path/to/model.safetensors
python -m iron.lm.tune llama --checkpoint /path/to/model.safetensors --dispatch separate
python -m iron.lm.tune embedding_gemma2

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.py finds three kinds of fold:

  • a step folded into its input's producer (Fold, e.g. SiLU into the gate GEMV's epilogue, a residual add into a GEMV), including chains of folds;
  • a step folded into the matvecs that read its output (Prologue, e.g. RMSNorm into the following GEMVs);
  • a copy into a state folded into its producer's drain.

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 as coresident= to compile(). It chooses widths, folds and packs together to minimise the modelled time of a full-ELF runlist:

D0 + Σ steps t_step + Σ device entries (base + Σ members load) + R if the entries are odd

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.json

iron/tests/infrastructure/narrowing.py and iron/tests/toolchain/narrowing.py cover the search and the cost model, and probe.check_model compares the model against a measured run of the graph it tuned.

9. Profiles: tunables as data

A graph's profile is a Profile, or a directory holding one <device>.json per 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) and with profile: are the API.
test_llama_names_only_the_tunables_that_matter checks that every keyword the Llama graph still passes is one the profile could not have supplied.

10. iron.lm and the applications

iron/lm/ holds what every model shares, and each model is an application package under iron/applications/. Nothing in the library imports a model.

  • base.py:
    • Config, the model's shape.
    • Checkpoint, a mapped .safetensors file 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.
    • Model and Runner/main, the command line, plus --random-weights SEED for running without real weights or a tokenizer.
  • causal.py: CausalLM, a decoder as one graph. It has two versions, a decode step and a prefill_chunk-row prompt chunk, each compiled once. max_seq_len sizes only the caches and the RoPE table.
  • encoder.py: Encoder, compiled once at max_tokens rows, with the length bound per call.
  • blocks.py: project() (GEMV for one row, flm.GEMM for more, an optional bias via RowwiseAdd), swiglu / SwiGLU, geglu, sandwich, attention, LogMel, Conv1d / Conv2d, MaxPool2d, ConcatChannels, ImagePatches, and Block / 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):

def body(self, ids=None, pixel_values=None, *, token: Carried[np.int32],
         position: Carried[np.int32], chunk: Scratchpad[np.int32], rows: Scratchpad[np.int32]):
    c = self.config
    L, C, A = c.max_seq_len, c.prefill_chunk, c.rope_width
    prompt = ids is not None
    if prompt:
        x = Copy(self.embedding[ids[:rows]])
        angles = Copy(self.rope.reshape(L // C, C, A)[chunk, :rows])
    else:
        x = Copy(self.embedding[token]).reshape(1, c.emb_dim)
        angles = Copy(self.rope[position]).reshape(1, A)
    x = self.embed(x, pixel_values, rows if prompt else None)
    step = Step(prompt, angles, chunk, rows, position)
    for i, weights in enumerate(self.layers):
        x = self.layer(step, i, weights, x)
    if prompt:
        x = Copy(x[rows - 1]).reshape(1, c.emb_dim)  # the last row's logits alone
    logits = self.head(x)
    _, drawn = Sample(logits.reshape(c.vocab_size), self.draws, self.drawn,
                      row=position * ROW_WORDS, at=position)
    return logits, iron.carry(token=drawn, position=position + 1)

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_context checks that no activation or array follows max_seq_len.

Llama 3.2 1B (iron/applications/llama/)

Llama(Block, CausalLM) adds only its head. The layer is Block.layer, and the oracle is LlamaOracle(BlockOracle, Oracle):

from iron.applications.llama.model import Runner

runner = Runner("model.safetensors", "tokenizer.model")
model = runner.npu()                    # compiles and loads both versions; weights uploaded
logits = model.logits(runner.encode("The capital of France is"))
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
  • Llama 3 RoPE frequency scaling is applied, and contexts go up to 32768 by default (--max-seq-len, a multiple of 2048). The compile is the same at any context.
  • Prompts run in fixed chunks against the caches. A chat turn reruns only the chunks that changed, and its logits match a from-scratch run bit for bit (check_chat_turn).
  • Limit: a prompt chunk is one dispatch and its attention grows with the context. Past about 48k tokens a chunk exceeds amdxdna's default watchdog (tdr_timeout_ms), so very long prompts need it raised (AGENTS.md, "Not built, or limited").
  • XRT maps device buffers locked, so ulimit -l must cover one arena bank.

EmbeddingGemma 2 (iron/applications/embedding_gemma2/)

The text encoder (EmbeddingGemma, an Encoder) is compiled once at 2048 rows, with the prompt length bound per call. The audio and vision towers (audio.py, vision.py) are graphs that Multimodal calls in its body, so
text, 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.

class EmbeddingGemma(Encoder):
    def body(self, ids, *, n: Scratchpad[np.int32]):
        return self.encoder(Copy(self.embedding[ids]), n)
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 from devel) is unchanged in substance; its test moves to tests/applications/gemma4_flm/.

11. Phoenix (npu1) and npu2

npu1 (AIE2, Phoenix / Hawk Point) npu2 (AIE2P, Strix / Strix Halo / Krackan)
columns 4 8
graph image xclbin chain, one dispatch per step one full ELF (or --each-step)
per-call Scratchpad dispatch-time scalar / RTP write ctrl scratchpad word
decode loop host restarts each step on the token the NPU drew device loop (CarriedLoop)
Llama prompt flm.GEMM projections, MHA on 4 columns, a chunk at a time the same, on 8 columns
EmbeddingGemma 2 text, audio and vision towers and the multimodal versions all, plus TestEachStep

The decode GEMVs run two lanes per column on Phoenix. --each-step builds the npu1 form on npu2, which is how TestEachStep checks it there. CI covers both devices: phoenix-{small,extensive,test-examples}.yml and krackan-{small,extensive,examples}.yml.

12. Seeing a graph

TracedGraph and CompiledGraph draw themselves: graph.trace(**shapes).html(), version.dot(), and version.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:

Where What
iron/tests/operators/catalog.py every operator's Testing cases against reference() on a device
iron/tests/operators/*.py operator behaviour the catalog does not reach (bounded rows and K, gathers, long-context MHA) and rejected_shapes.py (device-free refusals)
iron/tests/infrastructure/ graphs, sequences, the allocator, carried values, narrowing and probes
iron/tests/toolchain/ device-free lowering gates (needs aiecc and Peano): every operator lowers; fused graphs build to full ELFs and xclbins; array_identity.py, inline_kernel.py
iron/tests/lm/ the iron.lm.blocks graphs (SwiGLU, LogMel, Conv1d/2d, MaxPool2d, ConcatChannels, ImagePatches)
iron/tests/common/ shared graphs and helpers, plus device-free graph and Llama-reference tests
iron/operators/**/test.py operators with a device test of their own (flm, swiglu_prefill_stream)
tests/applications/<name>/test.py Llama, EmbeddingGemma 2, Gemma 4 on FastFlowLM

Markers and options (pytest.ini, root conftest.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.
  • extensive is excluded from the default suite; bench marks the cases CI charts over time.
  • --random-weights SEED, --max-seq-len N and --cost-table PATH apply to the language-model tests. --csv-output sets the metrics CSV path (default tests_latest.csv).
  • npu1 / npu2 fixtures bind a device for device-free resolve and compile.
# what CI runs per device (operators suite), and the extensive suite
pytest iron/operators/ iron/tests/ -m "not extensive"
pytest iron/operators/ iron/tests/

# one operator's catalog cases; in parallel
pytest iron/tests/operators/catalog.py -k AXPY
pytest iron/operators/ iron/tests/ -n auto -m "not extensive"

# device-free gates (no NPU needed; aiecc and Peano on PATH)
pytest iron/tests/toolchain/

# applications (weights under $IRON_EXAMPLE_WEIGHTS_DIR, default /srv)
pytest tests/applications/
pytest tests/applications/llama/ --random-weights 0

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 exp2 costs decode throughput, as listed below.

What Result Host Commit
Llama 3.2 1B decode, profile vs tuned (random weights, 32768 caches, 1024-token prompt, 100 steps) 14.80 → 16.50 tok/s (+11.5%); model predicted 57.5 ms/step, measured 60.60; tuned and untuned greedy logits bitwise equal Strix Halo, xcoradaie218 1398c5f0c
Llama 3.2 1B decode on Phoenix, profile vs tuned (16-token prompt, 20 tokens) 361.5 → 337.8 ms/token; KL to oracle unchanged Phoenix, xcoradaie205 88f23339c
Llama 3.2 1B, xclbin chain on npu2, profile vs tuned 3.58–3.61 → 4.76–4.83 tok/s; KL 5.47e-5, top-1 100/100 Strix Halo, xcoradaie218 34ff047b1
GEMV + residual add, folded vs unfolded M=2048 K=2048: 430.6 → 367.3 µs (−14.7%); outputs bit-identical Strix Halo, xcoradaie218 61d210b05
Gather 5120 rows of a 262144×512 bf16 table, 1 vs 4 feeds 2002 → 903 µs, bit-exact vs np.take Strix Halo, xcoradaie220 727a96180
MHA float32 softmax state and O accumulator (accuracy fix) +4.9% to +9.1% time; mean abs error 0.038 → 0.0068 at 2048 rows over 32768 keys Strix Halo, xcoradaie220 11a66db95
Accurate exp2 in MHA on AIE2P (accuracy fix) Llama 1B max KL 0.056 → 0.013 at 1024 tokens; decode 14.97 → 13.99 tok/s (−6.6%) npu2 (commit names "220") 867b88de0
EmbeddingGemma 2 audio tower, 2 s / 7.3 s / 30 s clip 129.3 / 195.7 / 397.9 ms; mean cosine ≥ 0.997 vs oracle Strix Halo, xcoradaie220 86edca193

15. Known limits (also in AGENTS.md)

  • A fused sequence inside one xclbin is refused, so npu1 runs one dispatch per step and has no device loop.
  • A prompt chunk's attention grows with the context. Past about 48k tokens a chunk exceeds amdxdna's default watchdog.
  • A fold is refused when its cores would read more streams than a core's two input channels.
  • A per-call-bounded transfer must fit one DMA descriptor.
  • Cost-table entries are keyed by design identity, not code. After changing a reference or a tolerance, remeasure.
  • Open asks in mlir-aie: a builder for aiex.configure / aiex.run, and an accessor for L1 banking.

Note, this PR relies on: Xilinx/mlir-aie#3884

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.

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

CI performance trends

54403bb (2026-10-15T23:55:46Z)

⚠️ No results from: Krackan - Operators (failure), Krackan - Applications (failure), Phoenix - Operators (failure), Phoenix - Applications (failure). This comment covers the remaining suites only.

@hunhoffe hunhoffe left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Early notes

Comment thread .github/workflows/ci-lint.yml Outdated
Comment thread .github/workflows/krackan-examples.yml Outdated
Comment thread ci/scripts/pretty_common.py
Comment thread iron/common/declare/bound.py Outdated
Comment thread iron/common/declare/bound.py Outdated
Comment thread iron/common/design/build.py Outdated
Comment thread iron/common/design/external.py Outdated
Comment thread iron/common/design/runtime.py Outdated
Comment thread iron/common/design/runtime.py Outdated
Comment thread iron/common/design/target.py Outdated
@hunhoffe
hunhoffe marked this pull request as ready for review October 6, 2026 18:55
@hunhoffe hunhoffe changed the title [Proof of Concept] Graph-based Llama 3.2 1B Graph-based Llama 3.2 1B Oct 6, 2026
@hunhoffe hunhoffe changed the title Graph-based Llama 3.2 1B Graph-based Language Models Oct 6, 2026
hunhoffe and others added 21 commits October 10, 2026 19:07
MHA's packed decode fills a block's B_q rows with one KV group's query
heads, repeated, which asked the group to divide B_q. B_q divides 64, so
a group of 5 (Granite 4.2-3B, 40/8) or 3 (Llama 3.2 3B, 24/8) was
refused and neither model could decode.

A group that does not divide B_q is now streamed as its first and its
last `width` heads, `width` the largest power of two in the group: two
overlapping runs that cover it, one descriptor, repeated to fill the
block. Every row is one of the group's heads, so every row O drains is
that head's own output, written over itself as the repeats already were.
No kernel change and no padding: mha.cc sees every row valid, as before,
and one compile still serves every context. compatible() now refuses
only a group larger than a block.

Verified:
- On the NPU (Strix Halo): catalog MHA packed cases 32/8, 40/8 (d=64)
  and 24/8 (d=128) against reference(), 3 passed.
- Llama 3.2 1B unchanged: the MHA design for (32, 8, d=64, packed) is
  byte-identical before and after across 29 builds (npu2 at 1, 2 and 4
  pipelines, every placement, ELF bound and unbound and xclbin; npu1 at
  1 and 2). On the NPU, iron/applications/llama_3.2_1b/test.py -k
  "accuracy or determinism": 5 passed before and after, every KL
  bit-identical (accuracy MeanKL 0.00478 / P90 0.00987 / Max 0.0296),
  0 differing runs.
- Device-free: the lowering gate lowers 40/8 and 24/8 on npu1 and npu2
  (12 MHA cases passed); rejected_shapes MHA 19 passed; Llama traced at
  Granite's and Llama 3.2 3B's shapes now resolves every decode operator
  on npu2 and npu1, where before decode MHA refused.

Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 09923e6af4edc891d119c6ae37eecdb7ee0e7e37)
0361383 generalized decode's packed MHA to a KV group of any size, not
just one dividing a block, by streaming the non-dividing case as two
overlapping runs. iron/operators/mha.py's own module docstring got that
update; this AGENTS.md summary one level up did not, so it still read as
if every group divided its block.

Verified: doc-only change, read against iron/operators/mha.py's resolve()
and _packed_io's packed_rows() as they stand at 0361383.

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

fa5e2aa made every core that waits on its runtime barrier release it once
it has read its values: `wait_for_value` is an acquire-equal that leaves the
barrier set, so on a shared device a core that loops back reads the previous
run's values before the next run writes its own. Merge came later and waits
without the release, so the second of two Merges of one array ran at the
first's block count and restarted its block index part way through.

test_each_merge_of_one_array_runs_on_its_own_count runs a 64-row and a
192-row Merge on one configure (one device and the reset), twice, against
numpy. On npu2 (Strix Halo, 218) before: 128 of 192 rows wrong, e.g. row 64
gives 0 for 64; after: passes, with GEMV's regression test beside it.

EmbeddingGemma 2's graphs run one Merge per call, so they were not
affected: the multimodal versions' compiled plans share a device only
between GEMMs and ElementwiseAdds.

Co-Authored-By: Claude <noreply@anthropic.com>
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>
hunhoffe and others added 30 commits October 14, 2026 19:43
flm.GEMM's B memtile pools (one Buffer per column) were reachable only
through the sequence's memtile DMA chains, so a build emitted a pool
only where its shape's sequence touched it. At N=256 (Gemma 4's
sliding-window k/v and PLI gate) the sequence fills columns 0-3 alone:
the shape's insts_only build then had four memtiles without their pool,
and laid their objectFIFO buffers out at other addresses than the shared
configuration xclbin (of8_buff_0 at 0 in the stream's build, 147456 in
the image), so the stream's descriptors addressed buffers the image
does not place there. softmax's shape check found it.

array() now returns the pools among what only the sequence reaches, and
OperatorRuntime declares a returned Buffer at device scope ahead of the
sequence (as it already did a shipped image's parameter block), so the
static design no longer depends on the columns a shape uses. The
preamble writes residents into a returned Buffer only for a shipped
image: a generated array's pool is not a parameter block.

No other operator keeps a Buffer only its sequence reaches: flm.GEMM is
the one that issues memtile DMA chains from its sequence (the exported
flm designs register their buffers through static TileDmas).

Test (build.py, on G0's path): at M=512, K=1536, N=256 the shape's
generated static design (outside the sequence, names normalized) equals
its configuration's; it fails on 0492ff0, four pool buffers short.

Diff: library +9 -7 (2 files), tests +20 (1), docs +3 -2 (1).

Verified (xcoradaie218):
- softmax's shapecheck.py --layout gemma4 (compiled, input_with_addresses
  static lines) for 1024,1536,256,gelu / 512,1536,256 / 256,1536,256 /
  1024,1536,8960 / 1024,256,1536: all "static design MATCHES image",
  under env-dev38.sh and env-3921tip.sh (1024,1536,256,gelu DIFFERS on
  0492ff0 in softmax's shapecheck-gemma4-dev38.log).
- npu2 through coord/npu, env-dev38: those three N=256 shapes in Gemma
  4's layout against the shared image, check_on_device (numpy, floor's
  budget): pass, pass, pass.
- coord/heavy pytest -n 4 iron/tests/common rejected_shapes.py
  narrowing.py iron/operators/flm/gemm/test.py -m "not supported_devices":
  614 passed, 4 skipped. collect-only 1808; black, reuse lint clean.

Co-Authored-By: Claude <noreply@anthropic.com>
de2bd5d folded flm's LMHead whole-vocabulary test into its softcap test
over (dim, vocab), so the bench row CI records as
iron/operators/flm/lm_head/test.py::test_gemma4_vocabulary, dim_1536
(krackan/extensive) is now test_gemma4_softcap, dim_1536-vocab_262144,
and the trend and the charts would start it over. row_key, which
merge_all, pretty_trends and build_pages key every row by, now maps both
old vocabulary parametrizations (dim 1536, the bench, and dim 2560) to
their new names after RENAMED_PATHS, as RENAMED_PATHS does for moved
files. The ci branch's CSVs are left as recorded.

Verified: on the ci branch's krackan/extensive/all.csv (fetched over
https), the 10 test_gemma4_vocabulary rows key as
(...test_gemma4_softcap, dim_{1536,2560}-vocab_262144); a row with the new
name joins dim_1536's key, and select_bench_rows keeps its 5 old rows plus
the new one as one benched parametrization. The new ids match
pytest --collect-only (test_gemma4_softcap[dim_1536-vocab_262144]).

Co-Authored-By: Claude <noreply@anthropic.com>
…ver it runs

Krackan's small suite (CI, e3ffb3c, a docker runner) failed
iron/tests/infrastructure/narrowing.py::test_timing_refuses_a_figure_its_
calls_waited_for_a_cpu_through at its setup, before any timing: the
assert that every spinner started (STARVE line 26). The spinners go on the
cores Awake finds the NPU's completions interrupting, from
/proc/interrupts and /proc/irq/*/effective_affinity_list. In a container
either none is found (no xdna_mailbox count visible), so there are no
spinners, or the cores are outside the container's cpuset, so a spinner's
sched_setaffinity fails before it prints. On 218 both hold, so it passes.

- Awake holds only the completions' cores, and their siblings, that the
  process may run on (os.sched_getaffinity). In such a cpuset, timing
  would otherwise fail entering Awake with EINVAL.
- The test starves the cores timing will hold the thread on, or, where
  there are none, the one core it pins the process to. The refusal is the
  real path either way: time_interleaved measures each call's runnable
  wait from /proc/thread-self/schedstat and raises past CPU_WAIT_US. The
  scheduling it relies on (nice 19 beside nice-0 spinners of one session,
  on one core) holds with or without autogroup and with fewer cores. A
  spinner that fails to start now names the cores.

Verified on 218 (npu2, coord/npu): the test passes 3 of 3 as before, and 3
of 3 under `taskset -c 0-27`, which leaves out the core the completions
interrupt (29). There Awake finds no core it may hold, and the spinners
share core 0 with the timing thread, the fallback a container takes. A
user scope cannot narrow a cpuset here (AllowedCPUs is not delegated), so
the EINVAL itself is reasoned from sched_setaffinity's contract, not
reproduced.

Co-Authored-By: Claude <noreply@anthropic.com>
The graph core imported the tuner (narrowing, its cost table, probe
and costcache) to compile, trace and draw a tuned graph, so the
measuring code and its cost model sat inside the core. The core now
holds only what it asks of a tuner, and the tuner depends on the core.

- iron/common/graph/tuner.py holds three ABCs:
  - `Tuner.tune(traced, dev, full_elf) -> Tuning`;
  - `Tuning`: `apply(traced, dev) -> (traced, coresident)`, with
    `report`, `summary`, `moved` and `packs` for the picture;
  - `Prices.step_us(traced, dev)`.
- `Graph.compile(coresident=, tuner=)`: `coresident=JointNarrowing(...)`
  becomes `tuner=`. The two are exclusive; a tuner's packs take
  coresident's place.
- narrowing, probe, costcache and the operator CLI (graph/tune.py) move
  to iron/tune/ as narrowing.py, probe.py, costcache.py and cli.py.
  - `JointNarrowing` is a `Tuner`, its result `JointTuning` a `Tuning`.
  - `CostTable` is the `Prices` a picture takes.
  - Nothing under iron/common imports iron/tune; fold.py stays in the
    core.
- `TracedGraph.picture` and `CompiledGraph.picture` take `prices=` (was
  `table=`, which nothing passed). visualize asks the tuning and the
  prices for each step's moved tunables, pack and time instead of
  computing cost keys itself, and trace.py imports visualize at the top.
- Every importer is migrated: lm/base.py (`Model.load(tuner=)` passes
  `tuner=` through), lm/tune.py, lm/causal.py, EmbeddingGemma 2's model
  and test, and eight test modules. The operator sweep runs as
  `python -m iron.tune.cli GEMV M=2048 K=8192`.
- AGENTS.md describes the tuning layer and `tuner=`.

Diff (git diff --shortstat, renames detected): library (iron/common,
iron/tune) 9 files +181 -94; lm and applications 5 files +14 -13;
tests 8 files +30 -31; docs 2 files +21 -4.

Verified:
- `python -m pytest --collect-only -q`: 1808 collected, no errors.
- Importing iron.common.graph.{compiled,trace,visualize} loads no
  iron.tune module.
- `pytest -n 4 iron/tests/common/{visualize,cost_tables,elementwise,graph}.py`:
  124 passed, 4 skipped.
- black and reuse lint are clean. ruff is not in the venv, so I scanned
  the touched files for unused imports instead: one pre-existing
  unused `Path` in iron/tests/toolchain/narrowing.py is removed.

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>

This branch has not been deployed

No deployments
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.

3 participants