From 31e687103414c623629d671ba8c9471b39f59215 Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Tue, 27 Oct 2026 17:59:48 -0600 Subject: [PATCH 01/21] Restore Granite 4.2 3B from 46a5d875, as its own follow-on to #222 a0162c7d moved eighteen applications out of #222 to their own pull requests, each with its checkpoints, goldens and the library code only it exercises. Granite goes first: this ports 46a5d875's model, profiles, README and device-free tests back in, unchanged, against today's iron.lm. Co-Authored-By: Claude Sonnet 5 --- iron/applications/granite/README.md | 41 +++++ iron/applications/granite/__init__.py | 9 ++ iron/applications/granite/model.py | 93 ++++++++++++ iron/applications/granite/profiles/npu1.json | 2 + iron/applications/granite/profiles/npu2.json | 19 +++ .../granite/reference/goldens/toy.npz | Bin 0 -> 40732 bytes tests/applications/granite/reference/test.py | 42 +++++ tests/applications/granite/test.py | 143 ++++++++++++++++++ 8 files changed, 349 insertions(+) create mode 100644 iron/applications/granite/README.md create mode 100644 iron/applications/granite/__init__.py create mode 100644 iron/applications/granite/model.py create mode 100644 iron/applications/granite/profiles/npu1.json create mode 100644 iron/applications/granite/profiles/npu2.json create mode 100644 tests/applications/granite/reference/goldens/toy.npz create mode 100644 tests/applications/granite/reference/test.py create mode 100644 tests/applications/granite/test.py diff --git a/iron/applications/granite/README.md b/iron/applications/granite/README.md new file mode 100644 index 000000000..739db34db --- /dev/null +++ b/iron/applications/granite/README.md @@ -0,0 +1,41 @@ + + +# Granite 4.2 3B on the NPU + +IBM's Granite 4.2 3B, `Granite` in `model.py`, whose docstring says what is +its own: Llama's layer with four scalars from its `config.json`, an untied +head and a sharded checkpoint. How to run, test and tune it is the README's [Models](../../../README.md#models); the shared library is [AGENTS.md](../../../AGENTS.md#language-models). + +## Weights and tokenizer + +From Hugging Face +([`ibm-granite/granite-4.2-3b`](https://huggingface.co/ibm-granite/granite-4.2-3b), +Apache-2.0, ungated), over plain https, with no account: + +```bash +D=/path/to/granite-4.2-3b +REV=e459acceac81e5fe67c07d9cfc72329a332e7eb1 # the revision every KL figure here is against +for f in config.json generation_config.json tokenizer_config.json \ + special_tokens_map.json tokenizer.json model.safetensors.index.json \ + model-00001-of-00002.safetensors model-00002-of-00002.safetensors; do + curl -fL -o "$D/$f" \ + "https://huggingface.co/ibm-granite/granite-4.2-3b/resolve/$REV/$f" +done +``` + +The command line takes `$D`. Its native context, 131072 positions, holds +10 GiB of caches beside the weights' 6.8 GiB, the default where the host +has room for both (`Model.fit`). + +## Testing + +`tests/applications/granite/` also runs the each-step form +(`TestEachStep`, `--no-full-elf`), a two-chunk prompt and a chat turn. + +NPU1 runs `TestEachStep` from an empty `profiles/npu1.json` (every +operator's device defaults, as smollm2's, phi3's and qwen2's are), and +all of it passes there: generation, accuracy and determinism. Not yet +run on NPU1: `tests/applications/long_context`'s deep checks. diff --git a/iron/applications/granite/__init__.py b/iron/applications/granite/__init__.py new file mode 100644 index 000000000..7f1f2325a --- /dev/null +++ b/iron/applications/granite/__init__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Granite 4.2 3B end to end on the NPU; see ``iron/applications/granite/README.md``.""" + +from iron.lm.cli import exports + +__all__ = ["Granite"] +__getattr__ = exports(__name__, __all__) diff --git a/iron/applications/granite/model.py b/iron/applications/granite/model.py new file mode 100644 index 000000000..c5bbe9d07 --- /dev/null +++ b/iron/applications/granite/model.py @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Granite 4.2 3B over the shared decoder and layer (``iron.lm``): its +embedding and head on the NPU (``Granite``) and its shape, read from its +checkpoint's ``config.json``. Run it with ``python -m +iron.applications.granite.model``. + +Granite is Llama's layer with four scalars of its own config's: the +attention logits scale by ``attention_multiplier`` rather than ``1 / +sqrt(head_dim)``, each block's output joins its residual times +``residual_multiplier`` (``GraniteLayer``), the embedding rows are scaled +by ``embedding_multiplier`` and the logits divided by ``logits_scaling``, +folded into the final norm's weight once it loads. +Its output head is untied (``lm_head``). Its checkpoint is sharded; +``Checkpoint`` reads the index. + +The operators' tunables are the graph's profile, ``profiles/.json``, +keyed by operator shape at Granite 4.2 3B's shape: a decode step's and a +2048-row prompt chunk's, at any ``max_seq_len``, which sizes the caches and +the RoPE table alone. +""" + +import dataclasses +import math +from pathlib import Path + +import numpy as np +from ml_dtypes import bfloat16 + +from iron import Precision, lm, param +from iron.lm import CausalLM, DecoderConfig, DecoderLayer + + +@dataclasses.dataclass(frozen=True) +class GraniteConfig(DecoderConfig): + """``DecoderConfig``, Granite's own four scalars, and every + projection's ``precision``. + """ + + attention_multiplier: float = dataclasses.field(kw_only=True) + residual_multiplier: float = dataclasses.field(default=1.0, kw_only=True) + embedding_multiplier: float = dataclasses.field(default=1.0, kw_only=True) + logits_scaling: float = dataclasses.field(default=1.0, kw_only=True) + # 4.2-3B's prompt over two chunks needs bf16 macs on its projections, as + # Llama 3B's does (decision 40). + precision: Precision = lm.not_json(Precision.HIGH, kw_only=True) + + +class GraniteLayer(DecoderLayer): + """Llama's layer, its attention logits scaled by + ``attention_multiplier`` and each block's output by + ``residual_multiplier``. + """ + + def __init__(self, c: GraniteConfig, i: int): + super().__init__(c, i) + self.self_attn.scaling = c.attention_multiplier + self.residual_multiplier = c.residual_multiplier + + +class Granite(CausalLM): + """Granite on ``config``'s shape and a checkpoint's ``weights``, by its + names: the (untied) output head is ``lm_head``. Every matrix is read + ``(out, in)``, as the checkpoint ships it: GEMV's ``(M, K)`` and GEMM's + column-major B. From its sharded checkpoint and ``tokenizer.json``. + """ + + config = GraniteConfig + profile = Path(__file__).with_name("profiles") + layer_class = GraniteLayer + + def __init__(self, config: GraniteConfig, weights): + super().__init__(config, weights) + # The embedding's multiplier as a row every row is multiplied by. + m = config.embedding_multiplier + self.embed_scale = param(np.full(config.hidden_size, m, bfloat16)) + if config.logits_scaling != 1: + # The logits' divisor folded into the final norm's loaded weight. + norm = self.model.norm + norm.weight = param(norm.weight / bfloat16(config.logits_scaling)) + + def lookup(self, ids): + x = super().lookup(ids) + if self.config.embedding_multiplier == 1: + return x + E = self.config.hidden_size + x = x.reshape(math.prod(x.shape) // E, E) + return x * self.embed_scale + + +if __name__ == "__main__": + lm.main(Granite) diff --git a/iron/applications/granite/profiles/npu1.json b/iron/applications/granite/profiles/npu1.json new file mode 100644 index 000000000..a8509caeb --- /dev/null +++ b/iron/applications/granite/profiles/npu1.json @@ -0,0 +1,2 @@ +{"entries": [ +]} diff --git a/iron/applications/granite/profiles/npu2.json b/iron/applications/granite/profiles/npu2.json new file mode 100644 index 000000000..4abe590e6 --- /dev/null +++ b/iron/applications/granite/profiles/npu2.json @@ -0,0 +1,19 @@ +{"entries": [ + {"operator": "RMSNorm", "weighted": true, "rows": 1, "tile_size": 2560, "num_aie_columns": 1}, + {"operator": "GEMV", "M": 2560, "K": 2560, "num_batches": 1, "tile_size_input": 4, "tile_size_output": 256}, + {"operator": "GEMV", "M": 512, "K": 2560, "num_batches": 1, "tile_size_input": 4}, + {"operator": "MHA", "num_heads": 40, "num_KV_heads": 8, "d": 64, "seq_pad": 1, "num_pipelines": 4}, + {"operator": "GEMV", "M": 2560, "K": 64, "num_batches": 40, "tile_size_input": 4, "tile_size_output": 256}, + {"operator": "ElementwiseMul", "size": 102400, "tile_size": 4096}, + {"operator": "ElementwiseAdd", "size": 2560, "tile_size": 256}, + {"operator": "GEMV", "M": 8192, "K": 2560, "num_batches": 1, "tile_size_input": 4, "tile_size_output": 1024}, + {"operator": "SiLU", "size": 8192, "tile_size": 1024}, + {"operator": "ElementwiseMul", "size": 8192, "tile_size": 1024}, + {"operator": "GEMV", "M": 2560, "K": 8192, "num_batches": 1, "tile_size_input": 1, "tile_size_output": 256}, + {"operator": "GEMV", "M": 100352, "K": 2560, "num_batches": 1, "tile_size_input": 4, "tile_size_output": 32}, + {"operator": "RMSNorm", "weighted": true, "rows": 2048, "tile_size": 2048, "num_aie_columns": 8}, + {"operator": "MHA", "num_heads": 40, "num_KV_heads": 8, "d": 64, "seq_pad": 2048, "num_pipelines": 8}, + {"operator": "ElementwiseAdd", "size": 5242880, "tile_size": 2048}, + {"operator": "SiLU", "size": 16777216, "tile_size": 4096}, + {"operator": "ElementwiseMul", "size": 16777216, "tile_size": 4096} +]} diff --git a/tests/applications/granite/reference/goldens/toy.npz b/tests/applications/granite/reference/goldens/toy.npz new file mode 100644 index 0000000000000000000000000000000000000000..8f0460002def198ecb6c5d116137dcd23b681ea6 GIT binary patch literal 40732 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 model.config.prefill_chunk + check_accuracy(model, MAX_KL, 3, LONG, record=record_property) + + +@pytest.mark.extensive +def test_granite_4_2_3b_chat_turn(model, record_property): + """A turn of 1000 tokens reruns the second chunk alone.""" + check_chat_turn(model, LONG, 1000, record=record_property) From 261271bc47c2491a7eebf25ef03505624772c8bf Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Tue, 27 Oct 2026 17:59:54 -0600 Subject: [PATCH 02/21] Bring Granite back into the README's model list and the shared checks Granite's GraniteConfig covers test_config.py's key-mapping check alongside LlamaConfig's, and long_context's APPLICATIONS holds it to the same one-compile, any-context guarantee as Llama and Gemma 4; its deep_kl figures are 46a5d875's own, measured at 4095 positions. Co-Authored-By: Claude Sonnet 5 --- README.md | 3 ++- iron/tests/common/test_config.py | 2 ++ tests/applications/long_context/test.py | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index befeb9b21..e55d6aece 100755 --- a/README.md +++ b/README.md @@ -151,11 +151,12 @@ Each model is a package under [iron/applications/](./iron/applications/) over [i | Model | Application | Checkpoint | |---|---|---| | Llama 3.2 1B, 3B | [llama](./iron/applications/llama/) | `llama3.2-1b`, `llama3.2-3b` | +| Granite 4.2 3B | [granite](./iron/applications/granite/) | `granite-4.2-3b` | | Gemma 4 E2B text decoder | [gemma4](./iron/applications/gemma4/) | `gemma-4-e2b-it` | | EmbeddingGemma 2 | [embedding_gemma2](./iron/applications/embedding_gemma2/) | `embeddinggemma-2` | | Gemma 4 E2B in FastFlowLM | [gemma4_engine](./iron/operators/flm/gemma4_engine/) | `$FLM_MODEL_PATH` (the tests default to `fastflowlm`) | -What the text decoders (Llama and Gemma 4 above) share: +What the text decoders (Llama, Granite and Gemma 4 above) share: - **Setup**: `requirements_examples.txt`, as [installing](#installation-linux) says. XRT maps every device buffer locked, and a model's weights and caches are banks of at most 3.5 GiB (`iron.common.image.allocator.BANK`), so `ulimit -l` must cover one bank. diff --git a/iron/tests/common/test_config.py b/iron/tests/common/test_config.py index c222ea666..f9e30c93d 100644 --- a/iron/tests/common/test_config.py +++ b/iron/tests/common/test_config.py @@ -16,6 +16,7 @@ from iron.applications.embedding_gemma2.model import Config as EmbeddingGemmaConfig from iron.applications.gemma4.audio import AudioConfig from iron.applications.gemma4.vision import VisionConfig +from iron.applications.granite.model import GraniteConfig from iron.applications.llama.model import Llama3RopeScaling, LlamaConfig from iron.lm import DecoderConfig from iron.tests.support.llama_model import REAL_1B @@ -52,6 +53,7 @@ def test_from_checkpoint_takes_either_the_file_or_its_directory(): "cls", [ LlamaConfig, + GraniteConfig, EmbeddingGemmaConfig, AudioConfig, VisionConfig, diff --git a/tests/applications/long_context/test.py b/tests/applications/long_context/test.py index 47c036981..fc0b10d62 100644 --- a/tests/applications/long_context/test.py +++ b/tests/applications/long_context/test.py @@ -21,6 +21,7 @@ import iron.applications from iron.applications.gemma4 import Gemma4 +from iron.applications.granite import Granite from iron.applications.llama import Llama from iron.lm import CausalLM, Checkpoint from iron.lm.testing import ( @@ -35,6 +36,7 @@ # KL(graph reference || NPU) of one step deep in the caches. Llama 3.2 1B's # decode measured 0.002 to 0.015 just past the prompt, 0.009 to 0.033 at # 16k and 0.030 to 0.038 at 32k: it grows with the keys the step sums over. +# Granite's decode measured 0.0033 at 4095 and its prompt chunk 2.3e-6. DEEP_KL = 0.1 @@ -72,6 +74,7 @@ def path(self) -> Path: "llama_3.2_3b": Application( Llama, "llama3.2-3b", INDEX, "tokenizer.json", each_step=("npu1", "npu2") ), + "granite_4.2_3b": Application(Granite, "granite-4.2-3b", INDEX, "tokenizer.json"), # Gemma 4 E2B's decode measured 0.0002 to 0.072 on npu2 and 0.0009 to # 0.0256 on npu1, each 4k to the whole context: each size fills the # cache with its own content up to max_seq_len - 1, not a continuation From b47717ab5cc8da435ba9bf4354f5cfd8944c1219 Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Tue, 27 Oct 2026 20:16:36 -0600 Subject: [PATCH 03/21] Free Granite's device buffers when its tests finish 8c25885a gave every model test fixture a model.release() after the module's (and TestEachStep's) tests, so the next model's CREATE_BO doesn't meet a full heap behind a reference cycle still awaiting GC. Granite's fixtures, restored from before that commit, didn't have it. Co-Authored-By: Claude Sonnet 5 --- tests/applications/granite/test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/applications/granite/test.py b/tests/applications/granite/test.py index 1e24ec8ff..d57625d26 100644 --- a/tests/applications/granite/test.py +++ b/tests/applications/granite/test.py @@ -44,6 +44,7 @@ def model(load): loaded = load() assert loaded.device_loop yield loaded + loaded.release() # KL(fp32 CPU || NPU) over 40 steps measured mean 0.0031, p90 0.0062, max 0.0152. @@ -82,6 +83,7 @@ def model(self, load): model = load(full_elf=False) assert not model.device_loop yield model + model.release() if aie_utils.DefaultNPURuntime is not None: aie_utils.DefaultNPURuntime.cleanup() From 2f5c6fc6462facc3ddee7885761f8ae147fe0712 Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Tue, 27 Oct 2026 20:16:40 -0600 Subject: [PATCH 04/21] Trim Granite's docstrings to say what each piece is 754e3ae6 cut iron/lm's and the three #222 applications' docstrings to what a caller relies on, the rationale left for the commits that made it. Granite's, restored from before that commit, kept the longer form; this brings its module and Granite class docstrings to the same register. Co-Authored-By: Claude Sonnet 5 --- iron/applications/granite/model.py | 31 +++++++++++++----------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/iron/applications/granite/model.py b/iron/applications/granite/model.py index c5bbe9d07..0658d2975 100644 --- a/iron/applications/granite/model.py +++ b/iron/applications/granite/model.py @@ -3,22 +3,17 @@ """Granite 4.2 3B over the shared decoder and layer (``iron.lm``): its embedding and head on the NPU (``Granite``) and its shape, read from its -checkpoint's ``config.json``. Run it with ``python -m +checkpoint's ``config.json``. Run with ``python -m iron.applications.granite.model``. -Granite is Llama's layer with four scalars of its own config's: the -attention logits scale by ``attention_multiplier`` rather than ``1 / -sqrt(head_dim)``, each block's output joins its residual times -``residual_multiplier`` (``GraniteLayer``), the embedding rows are scaled -by ``embedding_multiplier`` and the logits divided by ``logits_scaling``, -folded into the final norm's weight once it loads. -Its output head is untied (``lm_head``). Its checkpoint is sharded; -``Checkpoint`` reads the index. - -The operators' tunables are the graph's profile, ``profiles/.json``, -keyed by operator shape at Granite 4.2 3B's shape: a decode step's and a -2048-row prompt chunk's, at any ``max_seq_len``, which sizes the caches and -the RoPE table alone. +Granite is Llama's layer with four scalars from its config: attention +scaled by ``attention_multiplier``, each block's residual by +``residual_multiplier`` (``GraniteLayer``), the embedding rows by +``embedding_multiplier`` and the logits by ``logits_scaling``. Its head is +untied (``lm_head``); its checkpoint is sharded. + +Tunables are keyed by operator shape in ``profiles/.json``, at +Granite 4.2 3B's: a decode step's and a 2048-row prompt chunk's. """ import dataclasses @@ -60,10 +55,10 @@ def __init__(self, c: GraniteConfig, i: int): class Granite(CausalLM): - """Granite on ``config``'s shape and a checkpoint's ``weights``, by its - names: the (untied) output head is ``lm_head``. Every matrix is read - ``(out, in)``, as the checkpoint ships it: GEMV's ``(M, K)`` and GEMM's - column-major B. From its sharded checkpoint and ``tokenizer.json``. + """Granite on ``config``'s shape and a checkpoint's ``weights``: the + untied output head is ``lm_head``, every matrix read ``(out, in)`` as + the checkpoint ships it. From its sharded checkpoint and + ``tokenizer.json``. """ config = GraniteConfig From c481f9aea14984e38196e7f5fce8258a94b1da0d Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Tue, 27 Oct 2026 20:16:44 -0600 Subject: [PATCH 05/21] Add Granite 4.2 3B's row to the driver-timeout table 74f7004e's table (cut from b1e224c6 to #222's models) left out Granite's row when it moved out in a0162c7d. Restored at its measured figure (86.5 s on this host today, against b1e224c6's 86.5 s): its deepest prompt chunk, at its native 131072 context, as one command. Co-Authored-By: Claude Sonnet 5 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e55d6aece..3f0213a41 100755 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ If starting from `Ubuntu 24.04` you may need to update the Linux kernel to 6.11+ |---|---|---| | Gemma 4 E2B | 131072 | 182 | | Llama 3.2 3B | 131072 | 101 | + | Granite 4.2 3B | 131072 | 86 | | Llama 3.2 1B | 131072 | 26 | | EmbeddingGemma 2, each tower and the encoder | its largest input | under 0.7 | From d3cb374424e3e4dc3d87345578c5dba5cb4612bb Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Wed, 28 Oct 2026 00:13:30 -0600 Subject: [PATCH 06/21] Hold Granite to long_context's per-step form too, like Llama and Gemma 4 bd6c053a, d23627ea and fdaaab12 widened long_context's five context sizes to the per-step form (an xclbin a step, the host starting each on the token the last one drew) on npu1 and npu2; Granite's entry, restored from before that work, was still full-ELF only. Granite has an npu1 profile (empty, every operator's device defaults), so it joins at each_step=("npu1", "npu2") like Llama's and Gemma 4's. Both forms measured on this host's npu2, 4096 to the whole 131072 context: decode deep in the cache KL 0.0010 to 0.0022, its deepest prompt chunk 1.7e-8 to 1.4e-4, top-1 agreeing throughout. npu1 is still unmeasured on this host. Co-Authored-By: Claude Sonnet 5 --- tests/applications/long_context/test.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/applications/long_context/test.py b/tests/applications/long_context/test.py index fc0b10d62..02eedbc2b 100644 --- a/tests/applications/long_context/test.py +++ b/tests/applications/long_context/test.py @@ -36,7 +36,6 @@ # KL(graph reference || NPU) of one step deep in the caches. Llama 3.2 1B's # decode measured 0.002 to 0.015 just past the prompt, 0.009 to 0.033 at # 16k and 0.030 to 0.038 at 32k: it grows with the keys the step sums over. -# Granite's decode measured 0.0033 at 4095 and its prompt chunk 2.3e-6. DEEP_KL = 0.1 @@ -74,7 +73,13 @@ def path(self) -> Path: "llama_3.2_3b": Application( Llama, "llama3.2-3b", INDEX, "tokenizer.json", each_step=("npu1", "npu2") ), - "granite_4.2_3b": Application(Granite, "granite-4.2-3b", INDEX, "tokenizer.json"), + # Granite's decode measured 0.0010 to 0.0022 on npu2, 4k to the whole + # context; npu1 still unmeasured. Each size fills the cache with its + # own content up to max_seq_len - 1, not a continuation of the last, + # so sizes aren't directly comparable. + "granite_4.2_3b": Application( + Granite, "granite-4.2-3b", INDEX, "tokenizer.json", each_step=("npu1", "npu2") + ), # Gemma 4 E2B's decode measured 0.0002 to 0.072 on npu2 and 0.0009 to # 0.0256 on npu1, each 4k to the whole context: each size fills the # cache with its own content up to max_seq_len - 1, not a continuation From dea57fc8a8b211150235d2e4ef79955c96f1fd9f Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Wed, 28 Oct 2026 00:25:43 -0600 Subject: [PATCH 07/21] Restore Granite 4.0-H 1B from 46a5d875, as its own follow-on to #222 Granite 4.0-H 1B joins Granite 4.2 3B as a follow-on to #222 (a0162c7d, 0f0a5406): the dense 1B this ports, its README, profile and device-free tests, unchanged, against today's iron.lm. Its library dependencies (selective_scan, CausalConv1d, Checkpoints) and the two later fixes follow in their own commits. Co-Authored-By: Claude Sonnet 5 --- iron/applications/granite_hybrid/README.md | 75 ++++++ iron/applications/granite_hybrid/__init__.py | 9 + iron/applications/granite_hybrid/model.py | 224 ++++++++++++++++++ .../granite_hybrid/profiles/npu2.json | 4 + .../reference/goldens/dense.npz | Bin 0 -> 40749 bytes .../granite_hybrid/reference/goldens/toy.npz | Bin 0 -> 40761 bytes .../granite_hybrid/reference/test.py | 159 +++++++++++++ tests/applications/granite_hybrid/test.py | 99 ++++++++ 8 files changed, 570 insertions(+) create mode 100644 iron/applications/granite_hybrid/README.md create mode 100644 iron/applications/granite_hybrid/__init__.py create mode 100644 iron/applications/granite_hybrid/model.py create mode 100644 iron/applications/granite_hybrid/profiles/npu2.json create mode 100644 tests/applications/granite_hybrid/reference/goldens/dense.npz create mode 100644 tests/applications/granite_hybrid/reference/goldens/toy.npz create mode 100644 tests/applications/granite_hybrid/reference/test.py create mode 100644 tests/applications/granite_hybrid/test.py diff --git a/iron/applications/granite_hybrid/README.md b/iron/applications/granite_hybrid/README.md new file mode 100644 index 000000000..46a612e3f --- /dev/null +++ b/iron/applications/granite_hybrid/README.md @@ -0,0 +1,75 @@ + + +# Granite 4.0-H + +IBM's Granite 4.0-H, `GraniteHybrid` in `model.py`, whose docstring says how +it differs from Granite: Mamba2 mixers in most layers' place of attention, +a shared MLP, and a mixture of experts in the MoE sizes. The shared library +is [AGENTS.md](../../../AGENTS.md#language-models). + +The dense 1B runs on the NPU from its real checkpoint: every layer, the +Mamba2 scan (`SelectiveScan`) and causal convolution included, one compile +serving every prompt length and its native 131072 positions, the scan's +state checkpointed at each 2048-position chunk boundary so a chat turn +reruns only its last chunk. `tests/applications/granite_hybrid/test.py` +checks it against Hugging Face's float32 model, and `reference/` the graph +at an MoE toy and a dense one, device-free. + +## Weights and tokenizer + +From Hugging Face (Apache-2.0, ungated), over plain https, with no account: + +```bash +D=/path/to/granite-4.0-h-1b +REV=d18cca4c121edb87d022116d281ce212c9136f57 # the revision every KL figure here is against +for f in config.json generation_config.json tokenizer_config.json \ + special_tokens_map.json tokenizer.json chat_template.jinja model.safetensors; do + curl -fL -o "$D/$f" \ + "https://huggingface.co/ibm-granite/granite-4.0-h-1b/resolve/$REV/$f" +done +``` + +The weights are 2.9 GB. At its native context the Mamba2 states' +checkpoints (float32, 36 layers by 65 chunk boundaries, 3.4 GiB) and the 4 +attention layers' caches (1 GiB) make the model 7.2 GiB on the device. + +## Precision + +Its projections run at `Precision.HIGH` (Granite's config's): bf16 macs, +each K tile's partial sums rounded once. On the checks below, against +MEDIUM (bfp16 macs): + +| Precision | KL over 40 steps (mean / p90 / max) | 2 chunks, 3 steps (mean / max) | TTFT, 1024 chars | TPS | +|---|---|---|---|---| +| HIGH | 0.0029 / 0.0051 / 0.0105 | 0.0098 / 0.025 | 3.7 s | 6.7 | +| MEDIUM | 0.0040 / 0.0066 / 0.0186 | 0.0063 / 0.0099 | 3.35 s | 6.7 | + +HIGH holds the 40 steps closer, at 10% of the first token's time; a +decode step is a GEMV, which sums in float32 whatever the precision. +HIGHEST (float32 partial sums) is not measured yet. + +## Accuracy and speed + +On Strix (npu2, xcoradaie211), one compile at the native context, against +the float32 golden recorded beside the checkpoint (`golden.npz`): KL over +40 teacher-forced steps after 293 tokens, mean 0.0029, p90 0.0051, max +0.0105; over a prompt of 3262 tokens, two chunks, and two decode steps +after it, max 0.025. A chat turn rerun from the states checkpointed at the +chunk boundary is bit for bit the run from scratch, and repeated runs are +bitwise identical. The first token takes 2.7 s after 13 tokens (a prompt +chunk's scan streams all 2048 rows whatever the prompt's length) and +decode runs at 6.7 tokens a second; neither is tuned. + +## The mixture-of-experts sizes + +[`granite-4.0-h-tiny`](https://huggingface.co/ibm-granite/granite-4.0-h-tiny) +(64 experts of width 512, 6 a token, in all 40 layers, 13.9 GB) builds, but +does not run at a usable rate: `Experts` streams every expert's weights for +each row through one core, 59.8 ms a row a layer at its shape on npu2 +(checked against its numpy reference), so about 2.4 s a decoded token and +80 minutes a 2048-row prompt chunk, and its runtime sequence unrolls every +row's every expert. It waits for a sparse, token-grouped prompt path and a +gathered decode GEMV. diff --git a/iron/applications/granite_hybrid/__init__.py b/iron/applications/granite_hybrid/__init__.py new file mode 100644 index 000000000..f9789360f --- /dev/null +++ b/iron/applications/granite_hybrid/__init__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Granite 4.0-H; see ``iron/applications/granite_hybrid/README.md``.""" + +from iron.lm.cli import exports + +__all__ = ["GraniteHybrid"] +__getattr__ = exports(__name__, __all__) diff --git a/iron/applications/granite_hybrid/model.py b/iron/applications/granite_hybrid/model.py new file mode 100644 index 000000000..9757a13bb --- /dev/null +++ b/iron/applications/granite_hybrid/model.py @@ -0,0 +1,224 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Granite 4.0-H on the shared decoder (``iron.lm``), its shape read from +its checkpoint's ``config.json``. + +Granite 4.0-H is Granite's layer (its four scalars, ``GraniteConfig``) +with most layers' attention replaced by a Mamba2 mixer (``Mamba``) and its +feed-forward a shared gated MLP (``SharedMLP``), plus a mixture of experts +where the config has experts (``GraniteMoE``). The attention layers rotate +nothing unless ``position_embedding_type`` is ``"rope"``. + +A Mamba2 mixer projects its input to a gate, ``x``, ``B``, ``C`` and a +time step per head; ``x``, ``B`` and ``C`` pass a short causal depthwise +convolution, then a state space scan (``SelectiveScan``); the scan's +output, gated, is RMS-normed and projected back. +The convolution's last rows and the scan's state are the layer's +``Checkpoints`` states, as LFM2's convolution is. +""" + +import dataclasses +from pathlib import Path + +import numpy as np + +import iron +import iron.functional as F +from iron.applications.granite.model import Granite, GraniteConfig, GraniteLayer +from iron.lm import ( + CausalConv1d, + Checkpoints, + Linear, + RMSNorm, + Weights, + residual_add, +) +from iron.operators.copy import Copy +from iron.operators.experts import Experts +from iron.operators.router import TopKRouter +from iron.operators.selective_scan import SelectiveScan + + +@dataclasses.dataclass(frozen=True) +class GraniteHybridConfig(GraniteConfig): + """``GraniteConfig``, which layers are attention, the Mamba2 mixer's + shape, the shared MLP's width and the experts, ``intermediate_size`` + wide. + """ + + # transformers' default; __post_init__ sets None but under "rope". + rope_theta: float | None = dataclasses.field(default=10000.0, kw_only=True) + layer_types: tuple[str, ...] = dataclasses.field( + kw_only=True, metadata={"convert": tuple} + ) + position_embedding_type: str | None = dataclasses.field(default=None, kw_only=True) + shared_intermediate_size: int = dataclasses.field(kw_only=True) + num_local_experts: int = dataclasses.field(default=0, kw_only=True) + num_experts_per_tok: int = dataclasses.field(default=0, kw_only=True) + mamba_n_heads: int = dataclasses.field(kw_only=True) + mamba_d_head: int = dataclasses.field(kw_only=True) + mamba_d_state: int = dataclasses.field(kw_only=True) + mamba_n_groups: int = dataclasses.field(default=1, kw_only=True) + mamba_d_conv: int = dataclasses.field(default=4, kw_only=True) + + def __post_init__(self): + super().__post_init__() + if len(self.layer_types) != self.num_hidden_layers: + raise ValueError( + f"GraniteHybridConfig: {len(self.layer_types)} layer_types for " + f"{self.num_hidden_layers} layers" + ) + if self.position_embedding_type != "rope": + object.__setattr__(self, "rope_theta", None) + + @property + def conv_channels(self) -> int: + """A Mamba2 mixer's convolved channels: ``x``, then ``B`` and ``C``.""" + return self.mamba_n_heads * self.mamba_d_head + 2 * ( + self.mamba_n_groups * self.mamba_d_state + ) + + def attends(self, i: int) -> bool: + """Whether layer ``i`` is attention (``"attention"``, or transformers' + newer ``"full_attention"``), not Mamba2. + """ + return self.layer_types[i] in ("attention", "full_attention") + + +class Mamba(iron.Graph): + """A Mamba2 mixer on ``c``'s shape: ``in_proj`` to the gate, ``x``, + ``B``, ``C`` and ``dt``; ``conv1d`` over ``x``, ``B`` and ``C``, then + silu; the scan; ``norm`` of its output times silu of the gate; and + ``out_proj``. + """ + + def __init__(self, c: GraniteHybridConfig): + E, H, K = c.hidden_size, c.mamba_n_heads, c.mamba_d_conv + self.inner = I = H * c.mamba_d_head + self.channels = c.conv_channels + self.groups = c.mamba_n_groups + self.in_proj = Linear(E, I + self.channels + H, False, precision=c.precision) + self.conv1d = CausalConv1d(c, self.channels, K, bias=True) + self.dt_bias, self.A_log, self.D = (iron.param(H) for _ in range(3)) + self.norm = RMSNorm(I, c.eps) + self.out_proj = Linear(I, E, False, precision=c.precision) + state = (H, c.mamba_d_head, c.mamba_d_state) + self.ssm_state = Checkpoints.of( + state, c.max_seq_len, c.prefill_chunk, np.float32 + ) + + def load_state_dict(self, tree, *, path: str = "") -> None: + super().load_state_dict(tree, path=path) + # Each head's (dt_bias, A, D) in float32. + A = -np.exp(np.asarray(self.A_log, np.float32)) + self.head = iron.param( + np.stack([self.dt_bias, A, self.D], 1).astype(np.float32) + ) + + def body(self, x, call): + I, C = self.inner, self.channels + w, p = self.in_proj.weight, self.in_proj.precision + # Three projections, not one split three ways, as LFM2's conv. + gate, xbc, dt = ( + F.linear(x, part, precision=p) for part in (w[:I], w[I : I + C], w[I + C :]) + ) + xbc = F.silu(self.conv1d(xbc, call)) + read, write = self.ssm_state.at(call) + y, final = SelectiveScan.step( + xbc, + dt, + self.head, + Copy.step(read, dtype=np.float32), + groups=self.groups, + ) + Copy.step(final, write, dtype=np.float32) + return self.out_proj(self.norm(y * F.silu(gate))) + + +class SharedMLP(iron.Graph): + """The gated MLP every layer runs: ``input_linear``, the gate's rows + over the up's, then ``output_linear``. + """ + + def __init__(self, c: GraniteHybridConfig): + E, S = c.hidden_size, c.shared_intermediate_size + self.width = S + self.input_linear = Linear(E, 2 * S, False, precision=c.precision) + self.output_linear = Linear(S, E, False, precision=c.precision) + + def body(self, x): + w, S, p = self.input_linear.weight, self.width, self.input_linear.precision + gate = F.linear(x, w[:S], precision=p) + up = F.linear(x, w[S:], precision=p) + return self.output_linear(F.silu(gate) * up) + + +class Router(iron.Graph): + """The experts' router: ``layer``, a linear to a logit per expert.""" + + def __init__(self, c: GraniteHybridConfig): + self.layer = Linear( + c.hidden_size, c.num_local_experts, False, precision=c.precision + ) + + +class GraniteMoE(iron.Graph): + """A mixture of experts: ``router``, then ``Experts`` over the + checkpoint's stacked ``input_linear`` (each expert's gate over its up) + and ``output_linear``. + """ + + def __init__(self, c: GraniteHybridConfig): + X, E, M = c.num_local_experts, c.hidden_size, c.intermediate_size + self.router = Router(c) + self.input_linear = Weights(weight=(X, 2 * M, E)) + self.output_linear = Weights(weight=(X, E, M)) + self.top_k = c.num_experts_per_tok + + def body(self, x): + gate_up, down = self.input_linear.weight, self.output_linear.weight + layer = self.router.layer + logits = F.linear(x, layer.weight, precision=layer.precision) + ids, weights = TopKRouter.step(logits, top_k=self.top_k) + return Experts.step(x, ids, weights, gate_up, down) + + +class GraniteHybridLayer(GraniteLayer): + """Granite's layer, ``mamba`` in attention's place where the layer is + Mamba2, its feed-forward ``shared_mlp`` plus ``block_sparse_moe`` where + the config has experts. + """ + + def __init__(self, c: GraniteHybridConfig, i: int): + super().__init__(c, i) + del self.mlp + self.shared_mlp = SharedMLP(c) + self.block_sparse_moe = GraniteMoE(c) if c.num_local_experts else None + self.mamba = None + if not c.attends(i): + del self.self_attn, self.cache + self.mamba = Mamba(c) + + def mix(self, h, call): + return super().mix(h, call) if self.mamba is None else self.mamba(h, call) + + def body(self, x, call): + x = residual_add( + x, self.mix(self.input_layernorm(x), call), self.residual_multiplier + ) + h = self.post_attention_layernorm(x) + y = self.shared_mlp(h) + if self.block_sparse_moe is not None: + y = self.block_sparse_moe(h) + y + return residual_add(x, y, self.residual_multiplier) + + +class GraniteHybrid(Granite): + """Granite 4.0-H on ``config``'s shape and a checkpoint's ``weights``, + from its sharded checkpoint and ``tokenizer.json``. + """ + + config = GraniteHybridConfig + profile = Path(__file__).with_name("profiles") + layer_class = GraniteHybridLayer diff --git a/iron/applications/granite_hybrid/profiles/npu2.json b/iron/applications/granite_hybrid/profiles/npu2.json new file mode 100644 index 000000000..e12883c8e --- /dev/null +++ b/iron/applications/granite_hybrid/profiles/npu2.json @@ -0,0 +1,4 @@ +{"entries": [ + {"operator": "MHA", "num_heads": 12, "num_KV_heads": 4, "d": 128, "seq_pad": 1, "num_pipelines": 4}, + {"operator": "MHA", "num_heads": 12, "num_KV_heads": 4, "d": 128, "seq_pad": 2048, "num_pipelines": 8} +]} diff --git a/tests/applications/granite_hybrid/reference/goldens/dense.npz b/tests/applications/granite_hybrid/reference/goldens/dense.npz new file mode 100644 index 0000000000000000000000000000000000000000..36de553f548339328a8697f36cfcc5960f3c0587 GIT binary patch literal 40749 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 literal 0 HcmV?d00001 diff --git a/tests/applications/granite_hybrid/reference/goldens/toy.npz b/tests/applications/granite_hybrid/reference/goldens/toy.npz new file mode 100644 index 0000000000000000000000000000000000000000..2c6e56e5b3760b719b9b8129b36bbb5b304e164b GIT binary patch literal 40761 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 literal 0 HcmV?d00001 diff --git a/tests/applications/granite_hybrid/reference/test.py b/tests/applications/granite_hybrid/reference/test.py new file mode 100644 index 000000000..4f7961755 --- /dev/null +++ b/tests/applications/granite_hybrid/reference/test.py @@ -0,0 +1,159 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Granite 4.0-H's graph against Hugging Face's GraniteMoeHybrid in float32 +over the same random weights at a toy shape, its logits recorded in +``goldens/``, device-free: the shared decoder's tests (``Decoder``), whose +prompts span several of the toy's 64-row chunks, so the Mamba2 layers' +states carry across chunk boundaries and into decode steps. +""" + +import dataclasses +import json + +import numpy as np +import pytest + +from iron.applications.granite_hybrid.model import GraniteHybrid, GraniteHybridConfig +from iron.tests.support.llama_reference import Decoder + +#: Granite 4.0-H's proportions at toy size: three Mamba2 layers of two +#: groups and one attention layer without RoPE, a shared MLP, four experts +#: of which a token takes two, and Granite's scalars; its head untied, as +#: Granite's toy, so the logits are not the embedding of the last token. +TOY = GraniteHybridConfig( + vocab_size=1024, + hidden_size=512, + num_hidden_layers=4, + num_attention_heads=8, + num_key_value_heads=2, + intermediate_size=256, + max_seq_len=256, + prefill_chunk=64, + attention_multiplier=0.0625, + residual_multiplier=0.22, + embedding_multiplier=12.0, + logits_scaling=8.0, + tie_word_embeddings=False, + layer_types=("mamba", "attention", "mamba", "mamba"), + position_embedding_type="nope", + shared_intermediate_size=512, + num_local_experts=4, + num_experts_per_tok=2, + mamba_n_heads=16, + mamba_d_head=64, + mamba_d_state=64, + mamba_n_groups=2, +) + + +@pytest.fixture(scope="module") +def golden(golden): + """The toy's golden, its weights first redrawn in place where the + shared draw would leave a layer inert: each stacked expert at its own + matrix's scale, not the whole stack's; the time steps and decays as + Mamba2 initializes them, so a state lasts many positions; and the + convolution's bias ten times the draw's, at its taps' scale. + """ + + def recorded(name, build, config, weights): + rng = np.random.default_rng(0) + for layer in weights.model.layers: + if "block_sparse_moe" in vars(layer): + moe = layer.block_sparse_moe + for w in (moe.input_linear.weight, moe.output_linear.weight): + w *= np.sqrt(w.shape[1]) + if "mamba" in vars(layer): + m, H = layer.mamba, config.mamba_n_heads + m.A_log[:] = np.log(rng.uniform(1, 16, H)) + dt = np.exp(rng.uniform(np.log(1e-3), np.log(1e-1), H)) + m.dt_bias[:] = dt + np.log(-np.expm1(-dt)) + m.conv1d.bias[:] *= 10 + return golden(name, build, config, weights) + + return recorded + + +class TestGraniteHybrid(Decoder): + model, config, model_type, name = GraniteHybrid, TOY, "granitemoehybrid", "toy" + recorded = dict(prefill_chunk=256, num_local_experts=64, mamba_n_heads=64) + json = {"mamba_expand": 2} + # A prompt with no near-tie in its logits or its routing: at seed 32 + # every token's chosen experts lead the next by at least 2.4e-2 of + # router logit at every step, so bf16 picks the float32 ones. + seed = 32 + + @classmethod + def names(cls): + c = cls.config + E, D = c.hidden_size, c.head_dim + S, M = c.shared_intermediate_size, c.intermediate_size + X, H, N = c.num_local_experts, c.mamba_n_heads, c.mamba_d_state + I = H * c.mamba_d_head + channels = I + 2 * c.mamba_n_groups * N + out = { + "model.embed_tokens.weight": (c.vocab_size, E), + "model.norm.weight": (E,), + "lm_head.weight": (c.vocab_size, E), + } + for i in range(c.num_hidden_layers): + p = f"model.layers.{i}" + out |= { + f"{p}.input_layernorm.weight": (E,), + f"{p}.post_attention_layernorm.weight": (E,), + f"{p}.shared_mlp.input_linear.weight": (2 * S, E), + f"{p}.shared_mlp.output_linear.weight": (E, S), + f"{p}.block_sparse_moe.router.layer.weight": (X, E), + f"{p}.block_sparse_moe.input_linear.weight": (X, 2 * M, E), + f"{p}.block_sparse_moe.output_linear.weight": (X, E, M), + } + if c.attends(i): + Q, KV = c.num_attention_heads * D, c.num_key_value_heads * D + out |= { + f"{p}.self_attn.q_proj.weight": (Q, E), + f"{p}.self_attn.k_proj.weight": (KV, E), + f"{p}.self_attn.v_proj.weight": (KV, E), + f"{p}.self_attn.o_proj.weight": (E, Q), + } + else: + m = f"{p}.mamba" + out |= { + f"{m}.in_proj.weight": (I + channels + H, E), + f"{m}.conv1d.weight": (channels, 1, c.mamba_d_conv), + f"{m}.conv1d.bias": (channels,), + f"{m}.dt_bias": (H,), + f"{m}.A_log": (H,), + f"{m}.D": (H,), + f"{m}.norm.weight": (I,), + f"{m}.out_proj.weight": (E, I), + } + return out + + +class TestGraniteHybridDense(TestGraniteHybrid): + """Granite 4.0-H 1B's kind: no experts, the shared MLP alone.""" + + config, name = dataclasses.replace(TOY, num_local_experts=0), "dense" + recorded = dict(prefill_chunk=256, mamba_n_heads=64) + # No router to tie; at seed 32 bf16 leaves float32 at step 5. + seed = 2 + + @classmethod + def names(cls): + return {k: v for k, v in super().names().items() if "block_sparse_moe" not in k} + + +def test_a_config_without_rope_reads_back_from_its_json(tmp_path): + (tmp_path / "config.json").write_text(json.dumps(TOY.to_json())) + assert GraniteHybridConfig.from_checkpoint(tmp_path).to_json() == TOY.to_json() + + +def test_a_rope_config_without_rope_theta_takes_transformers_default(tmp_path): + config = TOY.to_json() | {"position_embedding_type": "rope"} + (tmp_path / "config.json").write_text(json.dumps(config)) + assert GraniteHybridConfig.from_checkpoint(tmp_path).rope_theta == 10000.0 + + +def test_layer_types_short_of_the_layers_are_refused(): + with pytest.raises(ValueError, match="3 layer_types for 4 layers"): + dataclasses.replace(TOY, layer_types=TOY.layer_types[:3]) diff --git a/tests/applications/granite_hybrid/test.py b/tests/applications/granite_hybrid/test.py new file mode 100644 index 000000000..f4571ee15 --- /dev/null +++ b/tests/applications/granite_hybrid/test.py @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Granite 4.0-H 1B on the NPU, from the real checkpoint: speed, accuracy +against the float32 reference, determinism, and past one chunk: a prompt of +two and a chat turn, which restarts the Mamba2 states from a chunk +boundary's checkpoint. The model is compiled and loaded once for the +module and every test calls it in-process. + +``--random-weights SEED`` runs it on drawn weights and random prompts, on +a host without the checkpoint, and ``--max-seq-len`` at another context. +""" + +import pytest + +from iron.applications.granite_hybrid import GraniteHybrid +from iron.lm.testing import ( + check_accuracy, + check_chat_turn, + check_determinism, + check_generation, + load_from_flags, + weights_dir, +) + +WEIGHTS = weights_dir("granite-4.0-h-1b") / "model.safetensors" + +pytestmark = pytest.mark.supported_devices("npu2") + + +@pytest.fixture(scope="module") +def load(request): + return load_from_flags(request, GraniteHybrid, WEIGHTS) + + +@pytest.fixture(scope="module") +def model(load): + yield load() + + +# KL(fp32 CPU || NPU) measured mean 0.0029, p90 0.0051, max 0.0105 over 40 +# steps; over two chunks and two decode steps mean 0.0098, max 0.025. +MAX_KL = {"Mean": 0.02, "P90": 0.04, "Max": 0.05} + + +@pytest.mark.parametrize( + "prompt_len,num_tokens", + [ + pytest.param( + p, + n, + # The shortest per PR; the rest nightly. + marks=[ + pytest.mark.bench, + *([] if (p, n) == (13, 1) else [pytest.mark.extensive]), + ], + ) + for p in (1024, 13) + for n in (40, 1) + ], + ids=[ + f"granite_4_0_h_1b_prompt_{p}_tokens_{n}" for p in (1024, 13) for n in (40, 1) + ], +) +def test_granite_4_0_h_1b(model, prompt_len, num_tokens, record_property): + check_generation(model, prompt_len, num_tokens, record=record_property) + + +@pytest.mark.extensive +@pytest.mark.bench +def test_granite_4_0_h_1b_accuracy(model, record_property): + check_accuracy(model, MAX_KL, record=record_property) + + +@pytest.mark.extensive +def test_granite_4_0_h_1b_determinism(model, record_property): + check_determinism(model, record=record_property) + + +# 12000 characters of prompt.txt are a full chunk and most of a second, +# which scans on from the first's states. +LONG = 12000 + + +@pytest.mark.extensive +def test_granite_4_0_h_1b_accuracy_across_chunks(model, record_property): + """The prompt's two chunks and two decode steps after them, against the + float32 reference over the whole prompt. + """ + assert len(model.prompt(LONG)) > model.config.prefill_chunk + check_accuracy(model, MAX_KL, 3, LONG, record=record_property) + + +@pytest.mark.extensive +def test_granite_4_0_h_1b_chat_turn(model, record_property): + """A turn of 1000 tokens reruns the second chunk alone, from the states + the first left at its boundary. + """ + check_chat_turn(model, LONG, 1000, record=record_property) From 8c17311617db3b3997c3d62ff1e2d75263dfca7a Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Fri, 23 Oct 2026 19:06:24 -0600 Subject: [PATCH 08/21] Refresh Granite 4.0-H 1B's figures for prompts that begin without BOS Granite's tokenizer adds no BOS, so since prompts begin as its post-processor begins them, the accuracy checks see 292 and 3261 tokens where they saw 293 and 3262, and the golden rows, KL figures, README and bounds were for the old prompts. The golden is re-recorded device-free (transformers in float32) and the figures re-measured on xcoradaie220. Measured on Strix Halo (npu2), one compile at the native 131072 positions, projections at HIGH as before: - KL over 40 steps after 292 tokens: mean 0.0024, p90 0.0048, max 0.0063 (2 top-1 swaps); two chunks and two decode steps: mean 0.0027, max 0.0049. The chat turn is bit for bit the run from scratch; determinism 0 differing runs. - TTFT 2.9 s after 13 characters, 3.6 s after 292 tokens, 8.0 s after 2048; 6.7 tokens/s. MAX_KL was about twice the old figures; it is again about twice the new ones (mean 0.006, p90 0.012, max 0.015). The README's Precision table is left to the precision pass, which has these numbers. Net: tests +4/-3, docs +9/-8. Checked: pytest tests/applications/granite_hybrid/test.py on the NPU (8 passed, at the old bounds; the new bounds hold the same deterministic figures). collect-only, black, reuse. Co-Authored-By: Claude --- iron/applications/granite_hybrid/README.md | 17 +++++++++-------- tests/applications/granite_hybrid/test.py | 7 ++++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/iron/applications/granite_hybrid/README.md b/iron/applications/granite_hybrid/README.md index 46a612e3f..11172b886 100644 --- a/iron/applications/granite_hybrid/README.md +++ b/iron/applications/granite_hybrid/README.md @@ -53,15 +53,16 @@ HIGHEST (float32 partial sums) is not measured yet. ## Accuracy and speed -On Strix (npu2, xcoradaie211), one compile at the native context, against -the float32 golden recorded beside the checkpoint (`golden.npz`): KL over -40 teacher-forced steps after 293 tokens, mean 0.0029, p90 0.0051, max -0.0105; over a prompt of 3262 tokens, two chunks, and two decode steps -after it, max 0.025. A chat turn rerun from the states checkpointed at the +On Strix Halo (npu2, xcoradaie220), one compile at the native context, +against the float32 golden recorded beside the checkpoint (`golden.npz`): +KL over 40 teacher-forced steps after 292 tokens, mean 0.0024, p90 0.0048, +max 0.0063; over a prompt of 3261 tokens, two chunks, and two decode steps +after it, max 0.0049. A chat turn rerun from the states checkpointed at the chunk boundary is bit for bit the run from scratch, and repeated runs are -bitwise identical. The first token takes 2.7 s after 13 tokens (a prompt -chunk's scan streams all 2048 rows whatever the prompt's length) and -decode runs at 6.7 tokens a second; neither is tuned. +bitwise identical. The first token takes 2.9 s after a 13-character +prompt (a prompt chunk's scan streams all 2048 rows whatever the prompt's +length), 3.6 s after 292 tokens and 8.0 s after 2048, and decode runs at +6.7 tokens a second; none of it is tuned. ## The mixture-of-experts sizes diff --git a/tests/applications/granite_hybrid/test.py b/tests/applications/granite_hybrid/test.py index f4571ee15..cdafa57c4 100644 --- a/tests/applications/granite_hybrid/test.py +++ b/tests/applications/granite_hybrid/test.py @@ -38,9 +38,10 @@ def model(load): yield load() -# KL(fp32 CPU || NPU) measured mean 0.0029, p90 0.0051, max 0.0105 over 40 -# steps; over two chunks and two decode steps mean 0.0098, max 0.025. -MAX_KL = {"Mean": 0.02, "P90": 0.04, "Max": 0.05} +# KL(fp32 CPU || NPU) measured mean 0.0024, p90 0.0048, max 0.0063 over 40 +# steps after 292 tokens; over two chunks and two decode steps mean 0.0027, +# max 0.0049. +MAX_KL = {"Mean": 0.006, "P90": 0.012, "Max": 0.015} @pytest.mark.parametrize( From 671800ed2ab045d4260247d59bc85f3fc2dfbb2e Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Sat, 24 Oct 2026 00:14:44 -0600 Subject: [PATCH 09/21] Hold Granite 4.0-H 1B to its native context in long_context granite_hybrid was listed as unheld because Experts streams every expert's weights for each row. That is true of the MoE sizes, but the dense 1B the package ships runs no Experts. The 1B now joins APPLICATIONS, and long_context holds it to its native 131072 positions like the other decoders: nothing grows with the context but a row per position, and the deepest decode step and prompt chunk match the graph's reference. The MoE sizes have no checkpoint or test here. Measured on Strix Halo (npu2), full ELF at 131072 positions: - decode at 131071: KL 0.0035 against the graph's reference, same top-1. - the deepest prompt chunk: KL 4.1e-5, 14.8 s. - TTFT 32.8 s at 8192 tokens, 732 s at the whole 131072. Net: tests +7/-2. Checked: pytest tests/applications/long_context/test.py -k granite_4.0_h_1b: test_nothing_grows_with_the_context, decode and prompt, passes device-free; the 4 extensive device tests pass. test_every_decoder_application_is_held_to_its_context fails before and after this change, because qwen3_embedding is a CausalLM missing from both lists. collect-only, black, reuse. Co-Authored-By: Claude --- tests/applications/long_context/test.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/applications/long_context/test.py b/tests/applications/long_context/test.py index 02eedbc2b..b5a3563c9 100644 --- a/tests/applications/long_context/test.py +++ b/tests/applications/long_context/test.py @@ -22,6 +22,7 @@ import iron.applications from iron.applications.gemma4 import Gemma4 from iron.applications.granite import Granite +from iron.applications.granite_hybrid import GraniteHybrid from iron.applications.llama import Llama from iron.lm import CausalLM, Checkpoint from iron.lm.testing import ( @@ -91,6 +92,13 @@ def path(self) -> Path: "tokenizer.json", each_step=("npu1", "npu2"), ), + # Granite 4.0-H 1B's decode measured 0.0035 at 131071 and its deepest + # prompt chunk 4.1e-5, one compile at its native 131072. The MoE sizes + # the package also builds stream every expert a row and have no + # checkpoint or test here. + "granite_4.0_h_1b": Application( + GraniteHybrid, "granite-4.0-h-1b", "model.safetensors", "tokenizer.json" + ), } From 6b0f88836e234080d0f0ab297d19d61111637a70 Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Wed, 28 Oct 2026 07:22:55 -0600 Subject: [PATCH 10/21] Restore Granite 4.0-H 1B's shared library pieces 0f0a5406 left SelectiveScan, its shared state_slices, iron.lm.Checkpoints and (a0162c7d) CausalConv1d with the applications that exercised them, to come back with whichever follow-on pull request needed them again. Granite 4.0-H 1B's Mamba2 mixer needs all four: the scan over its state, the short causal convolution ahead of it (F.causal_conv1d, ConvState), and the per-chunk checkpointed state both carry between calls. iron.lm.testing's _fill_caches, used by long_context's deep checks, regains the Checkpoints branch it had before 0f0a5406 trimmed it to KVCache alone, so a deep check sees the chunk-boundary state a prompt left, not an empty one. selective_scan.py drops one case, "granite_small_prefill": the Case it marked (lower=True, run=False) relied on a run field Case removed, and the case was for a different checkpoint (Granite 4.0-H Small) out of scope here. Co-Authored-By: Claude Sonnet 5 --- iron/functional.py | 51 +++++ iron/lm/__init__.py | 4 +- iron/lm/causal.py | 109 +++++++++- iron/lm/testing.py | 18 +- iron/operators/__init__.py | 1 + iron/operators/selective_scan.py | 335 +++++++++++++++++++++++++++++++ iron/operators/state_slices.py | 74 +++++++ 7 files changed, 583 insertions(+), 9 deletions(-) create mode 100644 iron/operators/selective_scan.py create mode 100644 iron/operators/state_slices.py diff --git a/iron/functional.py b/iron/functional.py index 151552e6f..c494e8ab8 100644 --- a/iron/functional.py +++ b/iron/functional.py @@ -8,6 +8,7 @@ them. A handle's own ``+ - * /`` are steps too, and ``@`` of one row. """ +import dataclasses import math import ml_dtypes @@ -16,6 +17,7 @@ from iron.common import Precision from iron.common.graph.handle import Handle, State +from iron.operators.copy import Copy from iron.operators.flm.gemm.design import Epilogue from iron.operators.flm.gemm.op import ProjectionGEMM from iron.operators.gelu import GELU @@ -121,3 +123,52 @@ def gelu(x: Handle, approximate: str = "none") -> Handle: def rope(x: Handle, angles: Handle) -> Handle: """``x`` ``(rows, head_dim)`` turned by RoPE, a row of ``angles`` per row.""" return RoPE.step(x, angles) + + +@dataclasses.dataclass(frozen=True) +class ConvState: + """What a short causal conv carries between calls: ``read``, the + ``K - 1`` rows before a call's first, and ``write``, where the ``K - 1`` + ending it go; and the scratch states a call stages them in, ``window`` + ``(2, chunk, channels)`` and ``tail`` ``(K - 1, channels)``. + """ + + read: Handle + write: Handle + window: State + tail: State + + +def causal_conv1d(x: Handle, taps, state: ConvState) -> Handle: + """``x`` ``(rows, channels)`` through a depth-wise causal filter of + ``K`` taps, each channel its own column of ``taps`` ``(K, channels)``: + output row ``t`` is ``sum_k taps[k] * x[t - K + 1 + k]``. The ``K - 1`` + rows before ``x``'s first row are read from ``state.read``, and the + ``K - 1`` rows ending this call (counting those read) are written to + ``state.write`` for the next call. When ``x``'s rows are bounded per + call (``x[:n]``), the call is a prompt chunk of at most ``chunk`` rows; + otherwise ``x`` is one row, a decode step. + """ + read, write, window, tail = state.read, state.write, state.window, state.tail + K, C = taps.shape[0], window.shape[1] + # Block 0 ends with the rows before the call; block 1 is the call's. + flat = window.reshape(2 * C, x.shape[1]) + first = C - (K - 1) + flat[first:C] = read + rows = x.valid.get(0) + if rows is None: + flat[C] = x.reshape(x.shape[1]) + y = x * taps[K - 1] + for k in range(K - 1): + y = y + flat[first + k : first + k + 1] * taps[k] + Copy.step(flat[first + 1 : C + 1], write) + return y + window[1, :rows] = x + y = flat[first : first + C] * taps[0] + for k in range(1, K): + y = y + flat[first + k : first + k + C] * taps[k] + y = y[:rows] + for j in range(K - 1): + tail[j] = flat[first + rows + j] + Copy.step(tail, write) + return y diff --git a/iron/lm/__init__.py b/iron/lm/__init__.py index f2ee58078..aab1da4cc 100644 --- a/iron/lm/__init__.py +++ b/iron/lm/__init__.py @@ -33,7 +33,7 @@ residual_add, ) from iron.common.feature_map import Conv2d -from .causal import Call, CausalLM, Decoder +from .causal import Call, CausalConv1d, CausalLM, Checkpoints, Decoder from .encoder import Encoder, Tower from .golden import Golden, HuggingFace from .generation import Sampler, accuracy, determinism, generate_on_host @@ -43,8 +43,10 @@ __all__ = [ "Attention", "Call", + "CausalConv1d", "CausalLM", "Checkpoint", + "Checkpoints", "Config", "Conv2d", "Decoder", diff --git a/iron/lm/causal.py b/iron/lm/causal.py index bbbe165e9..b3c490e06 100644 --- a/iron/lm/causal.py +++ b/iron/lm/causal.py @@ -16,11 +16,14 @@ import numpy as np from aie.iron.kernels.sample import ROW_WORDS +from ml_dtypes import bfloat16 import iron +import iron.functional as F from iron import Carried, Scratchpad from iron.common.graph import CarriedLoop, CompiledGraph, Handle from iron.common.graph.compiled import walk +from iron.common.graph.handle import State from iron.operators.copy import Copy from iron.operators.sample import Sample @@ -37,8 +40,9 @@ @dataclasses.dataclass(kw_only=True) class Call: """One call's values, built once in ``body`` and read by every module that - keeps memory from call to call (``KVCache``, ``Rope``): a prompt chunk's - ``rows`` from ``start``, or the decode step's one at ``position``. + keeps memory from call to call (``KVCache``, ``Checkpoints``, ``Rope``): a + prompt chunk's ``rows`` from ``start``, or the decode step's one at + ``position``. """ prompt: bool @@ -63,6 +67,86 @@ class Call: embeddings: Any = None +@dataclasses.dataclass(frozen=True) +class Checkpoints: + """A state a decoder carries from position to position, a slot per + prompt-chunk boundary: slot ``k`` holds it after the positions up to + ``k * chunk`` run so far, slot 0 (never written) before the sequence. + """ + + slots: State + chunk: int + + @classmethod + def of( + cls, shape: tuple[int, ...], max_seq_len: int, chunk: int, dtype=bfloat16 + ) -> "Checkpoints": + """A state of ``shape``, a slot for every boundary of ``chunk``-position + prompt chunks up to ``max_seq_len`` positions. + + Raises: + ValueError: ``chunk`` is not a power of two, which a decode step's + slot (``position // chunk``) needs. + """ + if chunk < 1 or chunk & (chunk - 1): + raise ValueError( + f"Checkpoints: a {chunk}-position chunk is no power of two" + ) + slots = (max_seq_len - 1) // chunk + 2 + return cls(iron.state((slots, *shape), dtype), chunk) + + def at(self, call: Call) -> tuple[Handle, Handle]: + """The slot ``call`` reads, the state before its first position, and + the slot it writes, the state after its last, in a graph's body. A + call ends within the chunk it starts in (``CausalLM`` splits them). + """ + C = self.chunk + return self.slots[(call.start - 1) // C + 1], self.slots[call.position // C + 1] + + def restart(self, held: int, ran: int) -> int: + """Where a call may start to change the positions from ``held`` on, + after ``ran`` positions ran: there where the last call ended, else at + the boundary before it. + """ + return held if held == ran else held // self.chunk * self.chunk + + def slot(self, position: int) -> int: + """The slot holding the state after the first ``position`` positions.""" + return 0 if position == 0 else (position - 1) // self.chunk + 1 + + +class CausalConv1d(iron.Graph): + """A depth-wise causal convolution of ``kernel`` taps over a call's + rows, each of ``channels`` its own taps (``weight``, torch's + ``(channels, 1, kernel)``), plus ``bias`` where it holds one. The + ``kernel - 1`` rows before a call are its ``state``, a slot per + prompt-chunk boundary; ``CausalLM`` gives every one of a model the + scratch ``F.causal_conv1d`` stages a call in (``window``, ``tail``). + """ + + window: State + tail: State + + def __init__(self, c, channels: int, kernel: int, bias: bool = False): + self.weight = iron.param(channels, 1, kernel) + self.bias = iron.param(channels) if bias else None + self.state = Checkpoints.of( + (kernel - 1, channels), c.max_seq_len, c.prefill_chunk + ) + + def load_state_dict(self, tree, *, path: str = "") -> None: + super().load_state_dict(tree, path=path) + # A row per tap, each the row RowwiseMul takes. + C, _, K = self.weight.shape + self.taps = iron.param(np.ascontiguousarray(self.weight.reshape(C, K).T)) + + def body(self, x, call): + read, write = self.state.at(call) + state = F.ConvState(read, write, self.window, self.tail) + y = F.causal_conv1d(x, self.taps, state) + return y if self.bias is None else y + self.bias + + class Decoder(iron.Graph): """A decoder's modules on ``c``'s shape, as a checkpoint's ``model`` names them: ``embed_tokens``, ``layers``, layer ``i`` a ``layer(c, i)``, @@ -118,15 +202,28 @@ def __init__(self, config: DecoderConfig, weights): head = np.asarray(self.lm_head.weight) self.lm_head.weight = iron.param(np.pad(head, ((0, 1), (0, 0)))) self.layers = self.model.layers + # The scratch every causal conv of a shape stages its calls in. + scratch = {} + for conv in {id(p): p for _, _, p, _ in walk(self)}.values(): + if isinstance(conv, CausalConv1d): + (K, C), chunk = conv.taps.shape, config.prefill_chunk + if (K, C) not in scratch: + scratch[K, C] = iron.state((2, chunk, C)), iron.state((K - 1, C)) + conv.window, conv.tail = scratch[K, C] @property - def recurrent(self) -> list[KVCache]: - """Each ``KVCache`` keeping a ring, which restarts only at some - positions (``restart``). + def recurrent(self) -> list[Checkpoints | KVCache]: + """Each state a layer carries from position to position, which + restarts only at some (``restart``): ``Checkpoints``, and a + ``KVCache`` keeping a ring. """ found = {} for _, _, p, _ in walk(self): - if isinstance(p, KVCache) and p.ring is not None: + if ( + isinstance(p, Checkpoints) + or isinstance(p, KVCache) + and p.ring is not None + ): found.setdefault(id(p), p) return list(found.values()) diff --git a/iron/lm/testing.py b/iron/lm/testing.py index 5ab7c0853..57b747ed0 100644 --- a/iron/lm/testing.py +++ b/iron/lm/testing.py @@ -22,6 +22,7 @@ from iron.common.graph.compiled import walk from .blocks import KVCache +from .causal import Checkpoints from .generation import ( SEED, Sampler, @@ -353,7 +354,12 @@ def check_deep_prompt(model, rows: int, bound: float, chars: int = 1024, *, reco def _fill_caches(model, n: int, position: int): """The caches' first ``n`` rows, a prompt's, repeated up to ``position`` on the device and as the graph reference's state, for the body of the - ``with``; the prompt's own rows stay as it wrote them. + ``with``; the prompt's own rows stay as it wrote them. A ring holds the + ``ring`` positions before ``position`` so filled; past ``ring``, a + prompt's positions in it are as it left them. A state checkpointed at + each chunk boundary (``Checkpoints``) holds, in the slot a call at + ``position`` reads, the state the prompt left; the reference reads a + copy of its slots. """ version = next(iter(model.versions.values())) caches = [(x, p) for _, x, p, _ in walk(model) if isinstance(p, KVCache)] @@ -370,9 +376,17 @@ def _fill_caches(model, n: int, position: int): rows[q % p.ring] = rows[p.ring + q % p.ring] = held[q % n] version.push(cache) cache.host = rows + checkpoints = [c for c in model.recurrent if isinstance(c, Checkpoints)] + for c in checkpoints: + slots = version.read(c.slots) + slots[c.slot(position)] = slots[c.slot(n)] + version.push(c.slots) + # A step may read and write one slot: the reference, run after the + # device's step, reads it as it was, not as the device left it. + c.slots.host = slots.copy() try: yield finally: - for state in (x for x, _ in caches): + for state in [*(x for x, _ in caches), *(c.slots for c in checkpoints)]: version.push(state) state.host = None diff --git a/iron/operators/__init__.py b/iron/operators/__init__.py index 813625a50..f6aae8a54 100644 --- a/iron/operators/__init__.py +++ b/iron/operators/__init__.py @@ -25,6 +25,7 @@ from .rms_norm import RMSNorm from .rope import RoPE from .sample import Sample +from .selective_scan import SelectiveScan from .sigmoid import Sigmoid from .silu import SiLU from .softmax import Softmax diff --git a/iron/operators/selective_scan.py b/iron/operators/selective_scan.py new file mode 100644 index 000000000..f49ebe1b7 --- /dev/null +++ b/iron/operators/selective_scan.py @@ -0,0 +1,335 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""SelectiveScan: Mamba2's state space recurrence over a run of rows.""" + +from typing import ClassVar + +import numpy as np +from aie.dialects import vector +from aie.extras import types +from aie.extras.dialects import arith +from aie.helpers.taplib import TensorAccessPattern +from aie.iron import Buffer, In, ObjectFifo, Out, TaskGroup, Worker, kernels +from aie.iron.controlflow import range_ +from aie.utils.verify import Tolerance +from aie.utils.compile.jit.context import compile_context +from aie.utils.compile.utils import resolve_target_arch +from aie.utils.compile.jit.params import param, per_call, tunable, widths +from ml_dtypes import bfloat16 + +from iron.common import Built, Operator, Rtp +from iron.common.testing import Case, Testing +from iron.operators.state_slices import SLICE, depths, resolve_heads, resolve_rows + + +def _slices(op) -> int: + """A core's slices: its heads' ``SLICE``-row runs of state.""" + return op.heads // op.columns * op.head_dim // SLICE + + +def _trained(op: "SelectiveScan") -> dict: + """Heads as a trained Mamba2's are: ``A < 0``, so the state decays.""" + rng = np.random.default_rng(3) + H = op.heads + head = np.stack( + [rng.uniform(-4, 0, H), -rng.uniform(0.5, 16, H), rng.uniform(0, 2, H)], 1 + ) + xbc = rng.standard_normal((op.rows, op.width)) * 0.5 + return dict( + given={op.xbc: xbc.astype(bfloat16), op.head: head.astype(np.float32)}, + normal=(op.dt, op.state), + ) + + +class SelectiveScan(Operator): + """Each row ``t`` of ``xbc``, split into ``x`` ``(heads, head_dim)`` + and ``groups`` rows each of ``B`` and ``C`` ``(state_size,)``, updates + each head's state ``(head_dim, state_size)`` and reads it out: + + dt = softplus(dt[t] + dt_bias) + state = exp(dt * A) * state + dt * outer(x, B) + y[t] = state @ C + D * x + + head ``h`` reading group ``h // (heads // groups)``'s ``B`` and ``C`` + and its ``(dt_bias, A, D)`` from ``head``. ``state`` is the state + before the first row; ``final`` the state after the last valid one. + Float32 throughout, ``y`` rounded once. + + Each of ``columns`` cores takes whole heads, ``SLICE`` rows of a head's + state at a time, and gathers a block of rows' ``x``, ``B``, ``C`` and + ``dt`` for the slice from whole rows of ``xbc``, which one memtile + joins to each row's ``dt`` and every core reads; another joins each + slice's state to its head's ``head``, so a core reads two streams. + The kernel keeps the state from block to block. Scalar. + """ + + test = Testing( + [ + Case(dict(rows=5, heads=2, head_dim=16, state_size=16, columns=1)), + Case(dict(rows=8, heads=4, head_dim=32, state_size=32, groups=2)), + # Granite 4.0-H's decode: a one-row block over a 128-wide state, + # its B and C off the 64-byte line an even block keeps them on. + Case( + dict(rows=1, heads=2, head_dim=16, state_size=128, columns=1), + id="decode", + ), + Case( + dict(rows=3, heads=2, head_dim=16, state_size=64, columns=1), + id="odd_rows", + extensive=True, + ), + Case( + dict(rows=64, heads=16, head_dim=64, state_size=64, groups=2), + bench=True, + ), + # Too many shim BDs at once without reclaiming them. + Case( + dict(rows=2048, heads=8, head_dim=64, state_size=16), + id="reclaim", + extensive=True, + ), + ], + draw=_trained, + ) + + # A long prefill holds more shim BDs than a tile has. + aiecc_flags: ClassVar[tuple[str, ...]] = ("--reclaim-runtime-bds",) + + rows: int = param(array=True) + heads: int = param(array=True) + head_dim: int = param(array=True) + state_size: int = param(array=True) + groups: int = param(default=1, array=True) + columns: int = tunable(domain=widths) + inner: int = param(derive=lambda op: op.heads * op.head_dim, repr=False, array=True) + width: int = param( + derive=lambda op: op.inner + 2 * op.groups * op.state_size, repr=False + ) + # The rows of y one kernel call writes: whole SLICE-row runs that fit L1. + block: int = tunable(repr=False) + slice_state: int = param(derive=lambda op: SLICE * op.state_size, repr=False) + + xbc = In(rows, width, obj_shape=(width,)) + dt = In(rows, heads, obj_shape=(heads,)) + head = In(heads, 3, dtype=np.float32, obj_shape=(3,), per=(columns,)) + state = In( + heads, + head_dim, + state_size, + dtype=np.float32, + obj_shape=(slice_state,), + per=(columns,), + depth=1, + ) + y = Out(rows, inner, obj_shape=(block,), per=(columns,)) + final = Out( + heads, + head_dim, + state_size, + dtype=np.float32, + obj_shape=(slice_state,), + per=(columns,), + depth=1, + ) + valid = per_call(bounds=rows) + count = per_call(derive=lambda op: op.valid) + + def validate(self) -> None: + if self.head_dim % SLICE or self.heads % self.groups or self.heads % 2: + raise ValueError( + f"SelectiveScan: head_dim ({self.head_dim}) must be whole " + f"{SLICE}-row slices, and heads ({self.heads}) whole groups " + f"({self.groups}) and even, so a row's dt after its xbc is " + "whole 4-byte words" + ) + + def resolve(self, dev): + return resolve_rows(resolve_heads(self, dev, self.heads), dev) + + def slice_bytes(self) -> tuple[int, int]: + """Bytes of a slice's (dt_bias, A, D) then state in, and its final out.""" + return 4 * (3 + self.slice_state), 4 * self.slice_state + + def held(self, dev, rows: int) -> int: + """Return the bytes a core of ``dev`` uses at ``rows`` rows per call. + + That is the input row, the block buffer, y, the kernel's data and + stack (its own copy of the state included), and the device's default + stack, which covers the core's own frame. One slice's state and final + state are not counted. + """ + # Only measured, never built, so it stays out of the build's registry. + with compile_context(_iron_kernel_registry=set()): + kernel = self.kernel(rows) + block = 2 * int(np.prod(kernel.arg_shape(0))) + frame = kernel.frame_bytes(resolve_target_arch(dev)) + frame += dev.default_core_stack_bytes + return 2 * (self.width + self.heads) + block + 4 * rows * SLICE + frame + + def kernel(self, rows: int): + return kernels.ssm.selective_scan( + rows=rows, + head_dim=SLICE, + state=self.state_size, + blocks=self.rows // rows, + ) + + def build(self) -> Built: + H, P, N, I = self.heads, self.head_dim, self.state_size, self.inner + T, per_group, heads = self.block // SLICE, H // self.groups, H // self.columns + kernel = self.kernel(T) + f32 = lambda n: np.ndarray[(n,), np.dtype[np.float32]] + bf16 = lambda n: np.ndarray[(n,), np.dtype[bfloat16]] + deep_in, deep_out = depths(self) + # A core takes two streams, a memtile joining each: a row's xbc + # then its dt, one stream every core reads, and a slice's (dt_bias, + # A, D) then its state. + row = ObjectFifo(bf16(self.width + H), depth=1) + parts = row.prod().join( + [0, self.width], obj_types=[self.xbc.obj_type, self.dt.obj_type] + ) + for o, f in zip((self.xbc, self.dt), parts): + o.bind([f.prod()]) + starts, ends = [], ([], []) + for _ in range(self.columns): + start = ObjectFifo(f32(3 + SLICE * N), depth=deep_in) + part = start.prod().join( + [0, 3], obj_types=[self.head.obj_type, self.state.obj_type] + ) + starts.append(start) + for lane, f in zip(ends, part): + lane.append(f.prod()) + for o, lane in zip((self.head, self.state), ends): + o.bind(lane) + of_ys = [ObjectFifo(self.y.obj_type) for _ in range(self.columns)] + of_finals = [ + ObjectFifo(self.final.obj_type, depth=deep_out) for _ in range(self.columns) + ] + self.y.bind([f.cons() for f in of_ys]) + self.final.bind([f.cons() for f in of_finals]) + rtps = [Rtp([self.count]) for _ in range(self.columns)] + + def core_fn(of_row, of_slice, of_y, of_final, scan, block, rtp, first): + valid = rtp.read()[self.count] + i32 = valid.type + zero, size = arith.constant(0, i32), arith.constant(T, i32) + bc, dt = T * SLICE, T * (SLICE + 2 * N) + for q in range_(_slices(self) // heads): + for r in range_(heads): + # Slice q of head r of the core's heads in turn, q and r + # each their own loop counter rather than a flattened + # index's div/mod (AIE2's backend cannot legalize the + # bitreverse Peano's mid-end folds that div/mod into). + h = r + first + p = q * SLICE + h * P + g = (h // per_group) * N + I + start, final = of_slice.acquire(1), of_final.acquire(1) + for j in range_(self.rows // T): + # Gather the block: x, then B and C, then dt, a + # vector of SLICE at a time; xbc first keeps each + # 32-byte aligned. + for t in range_(T): + row = of_row.acquire(1) + block[dt + t] = row[self.width + h] + lane = types.vector(SLICE, types.bf16()) + runs = [(t * SLICE, p)] + [ + ( + bc + t * 2 * N + c * N + n, + g + c * self.groups * N + n, + ) + for c in range(2) + for n in range(0, N, SLICE) + ] + for to, at in runs: + vector.store( + vector.load(lane, row, [at], alignment=32), + block.op, + [to], + alignment=32, + ) + of_row.release(1) + b = arith.index_cast(j, to=i32) + left = valid - b * size + live = arith.minsi(arith.maxsi(left, zero), size) + y = of_y.acquire(1) + scan(block, start, final, y, live, b) + of_y.release(1) + of_slice.release(1) + of_final.release(1) + + workers = [ + Worker( + core_fn, + [ + row.cons(), + starts[k].cons(), + of_ys[k].prod(), + of_finals[k].prod(), + kernel, + Buffer(kernel.arg_types()[0]), + rtps[k], + k * heads, + ], + ) + for k in range(self.columns) + ] + return Built(workers, rtps) + + def sequence(self, rt): + """Every row of ``xbc`` and ``dt`` once a slice, for every core; per + core, its heads' parameters and states, and its slices of ``y`` and + ``final``. + """ + R, H, W, I = self.rows, self.heads, self.width, self.inner + P, SN = self.head_dim, SLICE * self.state_size + S, heads = _slices(self), H // self.columns + per, states, run = S // heads, H * P * self.state_size, S * SN + # A core's heads' states, a slice of each head in turn. + slices = lambda k: TensorAccessPattern( + (states,), k * run, [per, heads, SN], [SN, P * self.state_size, 1] + ) + tg = TaskGroup() + self.xbc.fill(TensorAccessPattern((R, W), 0, [S, R, W], [0, W, 1]), group=tg) + self.dt.fill(TensorAccessPattern((R, H), 0, [S, R, H], [0, H, 1]), group=tg) + for k in range(self.columns): + self.head.fill( + TensorAccessPattern((H, 3), 3 * k * heads, [per, heads, 3], [0, 3, 1]), + lane=k, + group=tg, + ) + self.state.fill(slices(k), lane=k, group=tg) + for k in range(self.columns): + self.y.drain( + TensorAccessPattern( + (R, I), k * heads * P, [per, heads, R, SLICE], [SLICE, P, I, 1] + ), + lane=k, + group=tg, + wait=True, + ) + self.final.drain(slices(k), lane=k, group=tg, wait=True) + tg.finish() + + def tolerance(self): + return Tolerance.relative( + 8e-3, range_frac=1e-3, note="f32 throughout, y rounded once: a bf16 ulp" + ) + + def reference(self, xbc, dt, head, state): + H, P, N, G = self.heads, self.head_dim, self.state_size, self.groups + xbc, dt = np.asarray(xbc, np.float32), np.asarray(dt, np.float32) + dt_bias, A, D = np.asarray(head, np.float32).T + state = np.array(state, np.float32) + y = np.zeros((self.rows, self.inner), np.float32) + for t in range(self.valid): + x = xbc[t, : self.inner].reshape(H, P) + B, C = ( + np.repeat(m.reshape(G, N), H // G, axis=0) + for m in np.split(xbc[t, self.inner :], 2) + ) + step = np.logaddexp(0, dt[t] + dt_bias)[:, None, None] + state = np.exp(step * A[:, None, None]) * state + state += step * x[:, :, None] * B[:, None, :] + y[t] = (np.einsum("hpn,hn->hp", state, C) + D[:, None] * x).reshape(-1) + return y.astype(bfloat16), state diff --git a/iron/operators/state_slices.py b/iron/operators/state_slices.py new file mode 100644 index 000000000..d2a874acd --- /dev/null +++ b/iron/operators/state_slices.py @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""What SelectiveScan and GatedDeltaRule share: a head's state taken +``SLICE`` at a time, a block of rows a kernel call, and the cores resolved +over whole heads. +""" + +from iron.common import Unresolvable + +#: The rows (SelectiveScan) or columns (GatedDeltaRule) of a head's state a +#: kernel call carries. +SLICE = 16 + + +def block_rows(op) -> int: + """The rows a kernel call takes: the most up to 32 that divide + ``op.rows``, a bound on the block buffer's share of a core's memory. + """ + return max(d for d in range(1, 33) if op.rows % d == 0) + + +def resolve_rows(op, dev): + """Return ``op`` with its ``block`` set. + + ``block`` is the most rows, up to ``block_rows``, that divide + ``op.rows`` and leave room in a core for ``op.held`` plus one slice's + state and final state. + + Raises: + Unresolvable: Not even one row fits. + """ + if op.block is not None: + return op + state, final = op.slice_bytes() + mem = dev.core_memory_bytes + rows = next( + ( + t + for t in range(block_rows(op), 0, -1) + if op.rows % t == 0 and op.held(dev, t) + state + final <= mem + ), + None, + ) + if rows is None: + raise Unresolvable( + f"{type(op).__name__}: no block of rows fits a core's {mem} bytes" + ) + return op.replace(block=rows * SLICE) + + +def depths(op) -> tuple[int, int]: + """How many slices' states and finals ``op``'s core holds at once: two + of each, the next streaming during the kernel, where its memory fits. + """ + held = op.held(op.dev, op.block // SLICE) + state, final = op.slice_bytes() + mem = op.dev.core_memory_bytes + deep_in = 2 if held + 2 * state + final <= mem else 1 + deep_out = 2 if held + deep_in * state + 2 * final <= mem else 1 + return deep_in, deep_out + + +def resolve_heads(op, dev, heads: int): + """``op`` on as many cores as divide its ``heads`` and the shim's input + channels feed. + + Raises: + Unresolvable: No count divides the heads. + """ + columns = op.resolve_columns(dev, op.columns, fits=lambda c: heads % c == 0) + if heads % columns: + raise Unresolvable(f"{type(op).__name__}: {heads} heads over {columns} cores") + return op.replace(columns=columns) From 3c9a62dbcfafc735e26243a1bf1801e1704f6f36 Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Wed, 28 Oct 2026 07:23:06 -0600 Subject: [PATCH 11/21] Ship Granite 4.0-H 1B dense only, refusing a checkpoint with experts The package 46a5d875 shipped built GraniteMoE for any config with num_local_experts: Router, then Experts over the checkpoint's stacked expert weights. a0162c7d and 0f0a5406 moved Experts and TopKRouter out with Qwen3-MoE, untested at any shape that runs at a usable rate (an unrelated follow-up); restoring them here only to exercise an MoE size this package stages no checkpoint or test for would be restoring code nothing checks. The shipped 1B is dense: GraniteHybridLayer no longer builds a GraniteMoE, and GraniteHybridConfig refuses a checkpoint with experts outright, rather than silently ignoring num_local_experts. The device-free reference test held an MoE toy alongside the dense one (goldens/toy.npz), checking the graph's reference against Hugging Face's GraniteMoeHybrid at a toy MoE shape. It depends on the same GraniteMoE this drops, so it goes with it, replaced by a test that the config refuses experts; the dense toy's own class and golden are what the shipped 1B is checked against. Co-Authored-By: Claude Sonnet 5 --- iron/applications/granite_hybrid/model.py | 70 +++++------------- .../granite_hybrid/reference/goldens/toy.npz | Bin 40761 -> 0 bytes .../granite_hybrid/reference/test.py | 67 +++++++---------- 3 files changed, 43 insertions(+), 94 deletions(-) delete mode 100644 tests/applications/granite_hybrid/reference/goldens/toy.npz diff --git a/iron/applications/granite_hybrid/model.py b/iron/applications/granite_hybrid/model.py index 9757a13bb..62cf31c44 100644 --- a/iron/applications/granite_hybrid/model.py +++ b/iron/applications/granite_hybrid/model.py @@ -2,20 +2,15 @@ # SPDX-License-Identifier: Apache-2.0 """Granite 4.0-H on the shared decoder (``iron.lm``), its shape read from -its checkpoint's ``config.json``. +its checkpoint's ``config.json``. This package ships the dense sizes +only; a checkpoint with experts is refused (``num_local_experts``). Granite 4.0-H is Granite's layer (its four scalars, ``GraniteConfig``) -with most layers' attention replaced by a Mamba2 mixer (``Mamba``) and its -feed-forward a shared gated MLP (``SharedMLP``), plus a mixture of experts -where the config has experts (``GraniteMoE``). The attention layers rotate -nothing unless ``position_embedding_type`` is ``"rope"``. - -A Mamba2 mixer projects its input to a gate, ``x``, ``B``, ``C`` and a -time step per head; ``x``, ``B`` and ``C`` pass a short causal depthwise -convolution, then a state space scan (``SelectiveScan``); the scan's -output, gated, is RMS-normed and projected back. -The convolution's last rows and the scan's state are the layer's -``Checkpoints`` states, as LFM2's convolution is. +with most layers' attention replaced by a Mamba2 mixer (``Mamba``) and +its feed-forward a shared gated MLP (``SharedMLP``). The attention +layers rotate nothing unless ``position_embedding_type`` is ``"rope"``. +A Mamba2 mixer's convolution and state-space scan (``SelectiveScan``) +carry state between calls as ``Checkpoints``, like LFM2's convolution. """ import dataclasses @@ -31,20 +26,18 @@ Checkpoints, Linear, RMSNorm, - Weights, residual_add, ) from iron.operators.copy import Copy -from iron.operators.experts import Experts -from iron.operators.router import TopKRouter from iron.operators.selective_scan import SelectiveScan @dataclasses.dataclass(frozen=True) class GraniteHybridConfig(GraniteConfig): """``GraniteConfig``, which layers are attention, the Mamba2 mixer's - shape, the shared MLP's width and the experts, ``intermediate_size`` - wide. + shape and the shared MLP's width. A checkpoint with experts + (``num_local_experts``) is refused; this package ships the dense + sizes only. """ # transformers' default; __post_init__ sets None but under "rope". @@ -69,6 +62,11 @@ def __post_init__(self): f"GraniteHybridConfig: {len(self.layer_types)} layer_types for " f"{self.num_hidden_layers} layers" ) + if self.num_local_experts: + raise ValueError( + f"GraniteHybridConfig: num_local_experts {self.num_local_experts}, " + "this package ships the dense sizes only" + ) if self.position_embedding_type != "rope": object.__setattr__(self, "rope_theta", None) @@ -154,47 +152,15 @@ def body(self, x): return self.output_linear(F.silu(gate) * up) -class Router(iron.Graph): - """The experts' router: ``layer``, a linear to a logit per expert.""" - - def __init__(self, c: GraniteHybridConfig): - self.layer = Linear( - c.hidden_size, c.num_local_experts, False, precision=c.precision - ) - - -class GraniteMoE(iron.Graph): - """A mixture of experts: ``router``, then ``Experts`` over the - checkpoint's stacked ``input_linear`` (each expert's gate over its up) - and ``output_linear``. - """ - - def __init__(self, c: GraniteHybridConfig): - X, E, M = c.num_local_experts, c.hidden_size, c.intermediate_size - self.router = Router(c) - self.input_linear = Weights(weight=(X, 2 * M, E)) - self.output_linear = Weights(weight=(X, E, M)) - self.top_k = c.num_experts_per_tok - - def body(self, x): - gate_up, down = self.input_linear.weight, self.output_linear.weight - layer = self.router.layer - logits = F.linear(x, layer.weight, precision=layer.precision) - ids, weights = TopKRouter.step(logits, top_k=self.top_k) - return Experts.step(x, ids, weights, gate_up, down) - - class GraniteHybridLayer(GraniteLayer): """Granite's layer, ``mamba`` in attention's place where the layer is - Mamba2, its feed-forward ``shared_mlp`` plus ``block_sparse_moe`` where - the config has experts. + Mamba2, its feed-forward ``shared_mlp``. """ def __init__(self, c: GraniteHybridConfig, i: int): super().__init__(c, i) del self.mlp self.shared_mlp = SharedMLP(c) - self.block_sparse_moe = GraniteMoE(c) if c.num_local_experts else None self.mamba = None if not c.attends(i): del self.self_attn, self.cache @@ -209,14 +175,12 @@ def body(self, x, call): ) h = self.post_attention_layernorm(x) y = self.shared_mlp(h) - if self.block_sparse_moe is not None: - y = self.block_sparse_moe(h) + y return residual_add(x, y, self.residual_multiplier) class GraniteHybrid(Granite): """Granite 4.0-H on ``config``'s shape and a checkpoint's ``weights``, - from its sharded checkpoint and ``tokenizer.json``. + from its checkpoint and ``tokenizer.json``. """ config = GraniteHybridConfig diff --git a/tests/applications/granite_hybrid/reference/goldens/toy.npz b/tests/applications/granite_hybrid/reference/goldens/toy.npz deleted file mode 100644 index 2c6e56e5b3760b719b9b8129b36bbb5b304e164b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40761 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 diff --git a/tests/applications/granite_hybrid/reference/test.py b/tests/applications/granite_hybrid/reference/test.py index 4f7961755..e59a03725 100644 --- a/tests/applications/granite_hybrid/reference/test.py +++ b/tests/applications/granite_hybrid/reference/test.py @@ -1,11 +1,12 @@ # SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Granite 4.0-H's graph against Hugging Face's GraniteMoeHybrid in float32 -over the same random weights at a toy shape, its logits recorded in -``goldens/``, device-free: the shared decoder's tests (``Decoder``), whose -prompts span several of the toy's 64-row chunks, so the Mamba2 layers' -states carry across chunk boundaries and into decode steps. +"""Granite 4.0-H 1B's graph against Hugging Face's GraniteMoeHybrid in +float32 over the same random weights at a toy shape, its logits recorded +in ``goldens/``, device-free: the shared decoder's tests (``Decoder``), +whose prompts span several of the toy's 64-row chunks, so the Mamba2 +layers' states carry across chunk boundaries and into decode steps. This +package ships the dense sizes only, so the toy has no experts. """ import dataclasses @@ -17,10 +18,10 @@ from iron.applications.granite_hybrid.model import GraniteHybrid, GraniteHybridConfig from iron.tests.support.llama_reference import Decoder -#: Granite 4.0-H's proportions at toy size: three Mamba2 layers of two -#: groups and one attention layer without RoPE, a shared MLP, four experts -#: of which a token takes two, and Granite's scalars; its head untied, as -#: Granite's toy, so the logits are not the embedding of the last token. +#: Granite 4.0-H 1B's proportions at toy size: three Mamba2 layers of two +#: groups and one attention layer without RoPE, a shared MLP and Granite's +#: scalars; its head untied, as Granite's toy, so the logits are not the +#: embedding of the last token. TOY = GraniteHybridConfig( vocab_size=1024, hidden_size=512, @@ -38,7 +39,7 @@ layer_types=("mamba", "attention", "mamba", "mamba"), position_embedding_type="nope", shared_intermediate_size=512, - num_local_experts=4, + num_local_experts=0, num_experts_per_tok=2, mamba_n_heads=16, mamba_d_head=64, @@ -50,8 +51,7 @@ @pytest.fixture(scope="module") def golden(golden): """The toy's golden, its weights first redrawn in place where the - shared draw would leave a layer inert: each stacked expert at its own - matrix's scale, not the whole stack's; the time steps and decays as + shared draw would leave a layer inert: the time steps and decays as Mamba2 initializes them, so a state lasts many positions; and the convolution's bias ten times the draw's, at its taps' scale. """ @@ -59,10 +59,6 @@ def golden(golden): def recorded(name, build, config, weights): rng = np.random.default_rng(0) for layer in weights.model.layers: - if "block_sparse_moe" in vars(layer): - moe = layer.block_sparse_moe - for w in (moe.input_linear.weight, moe.output_linear.weight): - w *= np.sqrt(w.shape[1]) if "mamba" in vars(layer): m, H = layer.mamba, config.mamba_n_heads m.A_log[:] = np.log(rng.uniform(1, 16, H)) @@ -74,21 +70,21 @@ def recorded(name, build, config, weights): return recorded -class TestGraniteHybrid(Decoder): - model, config, model_type, name = GraniteHybrid, TOY, "granitemoehybrid", "toy" - recorded = dict(prefill_chunk=256, num_local_experts=64, mamba_n_heads=64) +class TestGraniteHybridDense(Decoder): + """Granite 4.0-H 1B's kind: no experts, the shared MLP alone.""" + + model, config, model_type, name = GraniteHybrid, TOY, "granitemoehybrid", "dense" + recorded = dict(prefill_chunk=256, mamba_n_heads=64) json = {"mamba_expand": 2} - # A prompt with no near-tie in its logits or its routing: at seed 32 - # every token's chosen experts lead the next by at least 2.4e-2 of - # router logit at every step, so bf16 picks the float32 ones. - seed = 32 + # No router to tie; at seed 32 bf16 leaves float32 at step 5. + seed = 2 @classmethod def names(cls): c = cls.config E, D = c.hidden_size, c.head_dim - S, M = c.shared_intermediate_size, c.intermediate_size - X, H, N = c.num_local_experts, c.mamba_n_heads, c.mamba_d_state + S = c.shared_intermediate_size + H, N = c.mamba_n_heads, c.mamba_d_state I = H * c.mamba_d_head channels = I + 2 * c.mamba_n_groups * N out = { @@ -103,9 +99,6 @@ def names(cls): f"{p}.post_attention_layernorm.weight": (E,), f"{p}.shared_mlp.input_linear.weight": (2 * S, E), f"{p}.shared_mlp.output_linear.weight": (E, S), - f"{p}.block_sparse_moe.router.layer.weight": (X, E), - f"{p}.block_sparse_moe.input_linear.weight": (X, 2 * M, E), - f"{p}.block_sparse_moe.output_linear.weight": (X, E, M), } if c.attends(i): Q, KV = c.num_attention_heads * D, c.num_key_value_heads * D @@ -130,19 +123,6 @@ def names(cls): return out -class TestGraniteHybridDense(TestGraniteHybrid): - """Granite 4.0-H 1B's kind: no experts, the shared MLP alone.""" - - config, name = dataclasses.replace(TOY, num_local_experts=0), "dense" - recorded = dict(prefill_chunk=256, mamba_n_heads=64) - # No router to tie; at seed 32 bf16 leaves float32 at step 5. - seed = 2 - - @classmethod - def names(cls): - return {k: v for k, v in super().names().items() if "block_sparse_moe" not in k} - - def test_a_config_without_rope_reads_back_from_its_json(tmp_path): (tmp_path / "config.json").write_text(json.dumps(TOY.to_json())) assert GraniteHybridConfig.from_checkpoint(tmp_path).to_json() == TOY.to_json() @@ -157,3 +137,8 @@ def test_a_rope_config_without_rope_theta_takes_transformers_default(tmp_path): def test_layer_types_short_of_the_layers_are_refused(): with pytest.raises(ValueError, match="3 layer_types for 4 layers"): dataclasses.replace(TOY, layer_types=TOY.layer_types[:3]) + + +def test_a_config_with_experts_is_refused(): + with pytest.raises(ValueError, match="num_local_experts 4"): + dataclasses.replace(TOY, num_local_experts=4) From 2ca96bb17f6ad9a1ee66a05f188b506b01cc0036 Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Wed, 28 Oct 2026 07:23:10 -0600 Subject: [PATCH 12/21] Free Granite 4.0-H 1B's device buffers when its tests finish 8c25885a gave every model test fixture a model.release() after the module's tests, so the next model's CREATE_BO doesn't meet a full heap behind a reference cycle still awaiting GC. Granite 4.0-H 1B's fixture, restored from before that commit, didn't have it. Co-Authored-By: Claude Sonnet 5 --- tests/applications/granite_hybrid/test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/applications/granite_hybrid/test.py b/tests/applications/granite_hybrid/test.py index cdafa57c4..0d2f50db6 100644 --- a/tests/applications/granite_hybrid/test.py +++ b/tests/applications/granite_hybrid/test.py @@ -35,7 +35,9 @@ def load(request): @pytest.fixture(scope="module") def model(load): - yield load() + loaded = load() + yield loaded + loaded.release() # KL(fp32 CPU || NPU) measured mean 0.0024, p90 0.0048, max 0.0063 over 40 From 1c1271211150b52b7dd15e7e7eea491e0d862bc5 Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Wed, 28 Oct 2026 07:23:18 -0600 Subject: [PATCH 13/21] Bring Granite 4.0-H 1B into the README's model list Its row names the real checkpoint (granite-4.0-h-1b) now staged, in place of the "reference only"/"none yet" row from before it existed. Its own README drops the mixture-of-experts section, which measured Experts at an MoE size this package no longer builds (27870ec0), and its intro and reference/ mentions follow the dense-only package. Co-Authored-By: Claude Sonnet 5 --- README.md | 1 + iron/applications/granite_hybrid/README.md | 18 ++++-------------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 3f0213a41..cb1b0cf41 100755 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ Each model is a package under [iron/applications/](./iron/applications/) over [i |---|---|---| | Llama 3.2 1B, 3B | [llama](./iron/applications/llama/) | `llama3.2-1b`, `llama3.2-3b` | | Granite 4.2 3B | [granite](./iron/applications/granite/) | `granite-4.2-3b` | +| Granite 4.0-H 1B | [granite_hybrid](./iron/applications/granite_hybrid/) | `granite-4.0-h-1b` | | Gemma 4 E2B text decoder | [gemma4](./iron/applications/gemma4/) | `gemma-4-e2b-it` | | EmbeddingGemma 2 | [embedding_gemma2](./iron/applications/embedding_gemma2/) | `embeddinggemma-2` | | Gemma 4 E2B in FastFlowLM | [gemma4_engine](./iron/operators/flm/gemma4_engine/) | `$FLM_MODEL_PATH` (the tests default to `fastflowlm`) | diff --git a/iron/applications/granite_hybrid/README.md b/iron/applications/granite_hybrid/README.md index 11172b886..391ddd9fd 100644 --- a/iron/applications/granite_hybrid/README.md +++ b/iron/applications/granite_hybrid/README.md @@ -7,8 +7,9 @@ SPDX-License-Identifier: Apache-2.0 IBM's Granite 4.0-H, `GraniteHybrid` in `model.py`, whose docstring says how it differs from Granite: Mamba2 mixers in most layers' place of attention, -a shared MLP, and a mixture of experts in the MoE sizes. The shared library -is [AGENTS.md](../../../AGENTS.md#language-models). +and a shared MLP. This package ships the dense 1B; a checkpoint with +experts is refused. The shared library is +[AGENTS.md](../../../AGENTS.md#language-models). The dense 1B runs on the NPU from its real checkpoint: every layer, the Mamba2 scan (`SelectiveScan`) and causal convolution included, one compile @@ -16,7 +17,7 @@ serving every prompt length and its native 131072 positions, the scan's state checkpointed at each 2048-position chunk boundary so a chat turn reruns only its last chunk. `tests/applications/granite_hybrid/test.py` checks it against Hugging Face's float32 model, and `reference/` the graph -at an MoE toy and a dense one, device-free. +at a dense toy, device-free. ## Weights and tokenizer @@ -63,14 +64,3 @@ bitwise identical. The first token takes 2.9 s after a 13-character prompt (a prompt chunk's scan streams all 2048 rows whatever the prompt's length), 3.6 s after 292 tokens and 8.0 s after 2048, and decode runs at 6.7 tokens a second; none of it is tuned. - -## The mixture-of-experts sizes - -[`granite-4.0-h-tiny`](https://huggingface.co/ibm-granite/granite-4.0-h-tiny) -(64 experts of width 512, 6 a token, in all 40 layers, 13.9 GB) builds, but -does not run at a usable rate: `Experts` streams every expert's weights for -each row through one core, 59.8 ms a row a layer at its shape on npu2 -(checked against its numpy reference), so about 2.4 s a decoded token and -80 minutes a 2048-row prompt chunk, and its runtime sequence unrolls every -row's every expert. It waits for a sparse, token-grouped prompt path and a -gathered decode GEMV. From f30d25db268ddc045f1e9782674df22984fa45aa Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Sat, 24 Oct 2026 01:51:50 -0600 Subject: [PATCH 14/21] Count every token a device loop drew in generate's rate generate reads a device loop's draws once the last is drawn: the loop always draws max_tokens, and generate stops at the first eos among them. Its rate counted only the tokens through eos, over the time the loop took to draw all of them. Granite 4.0-H 1B draws eos 8th after the benched 13-character prompt, so its TPS read 1.21 where the device draws 6.7 tokens a second. The nightly trend check would have compared that figure. A Model now says whether generate's draws come at once (draws_at_once: a CausalLM's device loop, a Multimodal's text model's; False otherwise, OnHost too). Where they do, generate counts every token drawn in the time it measures, and keeps returning only those through eos. stream, which reads each token as it is drawn, and draws stepped from the host still stop at eos, so no step runs that otherwise wouldn't. Net: library +31/-5, tests +21. Checked: a new device test, test_granite_4_0_h_1b_rate_counts_the_draws_past_eos, compares generate's rate with stream's over the same early-eos draw: 6.68 vs 6.67 tokens/s (generate read 1.21 before the change, measured with a per-token probe and in the module run). The benched 13-character case reads 6.69 TPS. tests/applications/granite_hybrid/test.py: all 8 other tests pass. Device-free pytest -m "not supported_devices and not extensive" iron/tests/common/test_llama_reference.py iron/tests/common/test_multimodal.py iron/tests/lm iron/tests/infrastructure/test_lm_host.py: 155 passed. collect-only, black, reuse. Co-Authored-By: Claude --- iron/lm/causal.py | 5 +++++ iron/lm/model.py | 27 ++++++++++++++++++----- iron/lm/multimodal.py | 4 ++++ tests/applications/granite_hybrid/test.py | 21 ++++++++++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/iron/lm/causal.py b/iron/lm/causal.py index b3c490e06..c34119f82 100644 --- a/iron/lm/causal.py +++ b/iron/lm/causal.py @@ -432,6 +432,11 @@ def device_loop(self) -> bool: raise RuntimeError(f"{type(self).__name__}: load_versions() first") return self.decode_step.emit is not None + @property + def draws_at_once(self) -> bool: + """A device loop's: ``generate`` reads its draws once the last is drawn.""" + return self.device_loop + def release(self) -> None: """Free the device loops' runs, then the graph's memory.""" self._loops.clear() diff --git a/iron/lm/model.py b/iron/lm/model.py index 23729f549..e64ff2a0b 100644 --- a/iron/lm/model.py +++ b/iron/lm/model.py @@ -225,7 +225,8 @@ def token(self, id: int) -> str | None: @dataclasses.dataclass(frozen=True) class Stats: """A generation's timings: the seconds to the first token, and the - tokens per second after it (NaN for one token). + tokens per second drawn after it (NaN for one token), each token drawn + counted, those past the first ``eos`` too where they were drawn anyway. """ ttft: float @@ -260,6 +261,13 @@ def __init__(self, config: Any, weights: Any) -> None: ... def _draw(self, *args, **kwargs) -> Iterator[int]: ... + @property + def draws_at_once(self) -> bool: + """Whether ``generate`` draws all ``max_tokens`` before the second + arrives, whichever is ``eos``: none of them costs more to read. + """ + return False + @classmethod def load( cls, @@ -500,12 +508,17 @@ def _stream(self, prompt, max_tokens, sampler, inputs, each: bool) -> Iterator: sampler = Sampler(temperature=0) if sampler is None else sampler # The text of drawn[held:] is yet to be shown; drawn[seen:held] is # decoded with it, since a token's text can depend on the one before. - drawn, seen, held = [], 0, 0 + drawn, seen, held, count = [], 0, 0, 0 + # Past eos, tokens drawn at once still count in the time they took. + at_once = not each and self.draws_at_once began = first = last = time.perf_counter() for token in self._draw(tokens, max_tokens, sampler, each, **inputs): last = time.perf_counter() - if not drawn: + count += 1 + if count == 1: first = last + if drawn and drawn[-1] in self.eos: + continue drawn.append(token) if not text: yield token @@ -517,13 +530,13 @@ def _stream(self, prompt, max_tokens, sampler, inputs, each: bool) -> Iterator: if piece: yield piece seen, held = held, len(drawn) - if token in self.eos: + if token in self.eos and not at_once: break if text and held < len(drawn): shown = tokenizer.decode(drawn[seen:held]) if piece := tokenizer.decode(drawn[seen:])[len(shown) :]: yield piece - later = len(drawn) - 1 + later = count - 1 rate = later / (last - first) if later else math.nan self.stats = Stats(first - began, rate) @@ -605,6 +618,10 @@ def logits(self, tokens) -> np.ndarray: ... def reference(self, *tensors, dev=None, **values) -> Any: ... + @property + def draws_at_once(self) -> bool: + return False + def _draw(self, tokens, max_tokens: int, sampler: Sampler, each=True): history = [int(t) for t in tokens] for _ in range(max_tokens): diff --git a/iron/lm/multimodal.py b/iron/lm/multimodal.py index 94829bc4c..fcc7b5757 100644 --- a/iron/lm/multimodal.py +++ b/iron/lm/multimodal.py @@ -247,6 +247,10 @@ def logits(self, tokens, raw: dict[int, tuple]) -> np.ndarray: assert isinstance(self.text, CausalLM), "a causal text model's" return self.text.logits(tokens) + @property + def draws_at_once(self) -> bool: + return self.text.draws_at_once + def _draw( self, tokens, diff --git a/tests/applications/granite_hybrid/test.py b/tests/applications/granite_hybrid/test.py index 0d2f50db6..3d5e08120 100644 --- a/tests/applications/granite_hybrid/test.py +++ b/tests/applications/granite_hybrid/test.py @@ -14,6 +14,7 @@ import pytest from iron.applications.granite_hybrid import GraniteHybrid +from iron.lm.generation import Sampler from iron.lm.testing import ( check_accuracy, check_chat_turn, @@ -69,6 +70,26 @@ def test_granite_4_0_h_1b(model, prompt_len, num_tokens, record_property): check_generation(model, prompt_len, num_tokens, record=record_property) +@pytest.mark.extensive +def test_granite_4_0_h_1b_rate_counts_the_draws_past_eos(model, record_property): + """After 13 characters the model draws eos 8th. generate's device loop + draws all 40 tokens before reading them, so its rate counts all 40; + stream reads each as it is drawn and counts those through eos. The two + rates agree. + """ + tokens = model.prompt(13) + model.forget(0) + drawn = model.generate(tokens, 40, Sampler()) + assert len(drawn) < 40 and drawn[-1] in model.eos + generated = model.stats.tokens_per_s + model.forget(0) + assert list(model.stream(tokens, 40, Sampler())) == drawn + streamed = model.stats.tokens_per_s + record_property("GenerateTPS", generated) + record_property("StreamTPS", streamed) + assert abs(generated / streamed - 1) < 0.25, f"{generated} vs {streamed} tokens/s" + + @pytest.mark.extensive @pytest.mark.bench def test_granite_4_0_h_1b_accuracy(model, record_property): From cba6699b94c30fc1d9cf016f2b9a3195a7a2acae Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Wed, 28 Oct 2026 08:49:32 -0600 Subject: [PATCH 15/21] Record Granite 4.0-H 1B's long_context figures and timeout-table row Measured on npu2 (xcoradaie220), full ELF, five context sizes 4096 to the whole 131072: decode deep in the cache KL 0.0007 to 0.0069, its deepest prompt chunk 4.1e-5 to 0.0052, top-1 agreeing throughout; npu1 still unmeasured. The deepest prompt chunk at the native context, one command: 14.8 s, matching cbf21694's recorded figure, added to the driver-timeout table between Llama 3.2 1B and EmbeddingGemma 2. Co-Authored-By: Claude Sonnet 5 --- README.md | 1 + tests/applications/long_context/test.py | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cb1b0cf41..d122a0ec4 100755 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ If starting from `Ubuntu 24.04` you may need to update the Linux kernel to 6.11+ | Llama 3.2 3B | 131072 | 101 | | Granite 4.2 3B | 131072 | 86 | | Llama 3.2 1B | 131072 | 26 | + | Granite 4.0-H 1B | 131072 | 15 | | EmbeddingGemma 2, each tower and the encoder | its largest input | under 0.7 | On NPU1 a command is one operator, the longest an attention layer of the diff --git a/tests/applications/long_context/test.py b/tests/applications/long_context/test.py index b5a3563c9..79d24103f 100644 --- a/tests/applications/long_context/test.py +++ b/tests/applications/long_context/test.py @@ -92,10 +92,9 @@ def path(self) -> Path: "tokenizer.json", each_step=("npu1", "npu2"), ), - # Granite 4.0-H 1B's decode measured 0.0035 at 131071 and its deepest - # prompt chunk 4.1e-5, one compile at its native 131072. The MoE sizes - # the package also builds stream every expert a row and have no - # checkpoint or test here. + # Granite 4.0-H 1B's decode measured 0.0007 to 0.0069 on npu2, 4k to + # the whole context; its deepest prompt chunk 4.1e-5 to 0.0052; npu1 + # still unmeasured. "granite_4.0_h_1b": Application( GraniteHybrid, "granite-4.0-h-1b", "model.safetensors", "tokenizer.json" ), From 6fa5cf9e5e7e2bb54882d89e9674d9d462a4bf28 Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Wed, 28 Oct 2026 09:19:41 -0600 Subject: [PATCH 16/21] Hold Granite 4.0-H 1B to npu1 too, like Granite 4.2 3B Erika's decision: the dense 1B must run on npu1 (Phoenix), not npu2 alone. 7c34b69a brought Granite 4.2 3B to npu1 the same way: an empty profile (every operator's device defaults), a TestEachStep class (npu1, npu2) holding the per-step generation cases plus a shallower accuracy and determinism check, and long_context's each_step=("npu1", "npu2") so its five-size sweep covers the per-step form too. CI's Phoenix runner covers the real npu1 run; this host is npu2 only. Co-Authored-By: Claude Sonnet 5 --- .../granite_hybrid/profiles/npu1.json | 2 + tests/applications/granite_hybrid/test.py | 37 ++++++++++++++++++- tests/applications/long_context/test.py | 6 ++- 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 iron/applications/granite_hybrid/profiles/npu1.json diff --git a/iron/applications/granite_hybrid/profiles/npu1.json b/iron/applications/granite_hybrid/profiles/npu1.json new file mode 100644 index 000000000..a8509caeb --- /dev/null +++ b/iron/applications/granite_hybrid/profiles/npu1.json @@ -0,0 +1,2 @@ +{"entries": [ +]} diff --git a/tests/applications/granite_hybrid/test.py b/tests/applications/granite_hybrid/test.py index 3d5e08120..2d5aa4374 100644 --- a/tests/applications/granite_hybrid/test.py +++ b/tests/applications/granite_hybrid/test.py @@ -11,6 +11,7 @@ a host without the checkpoint, and ``--max-seq-len`` at another context. """ +import aie.utils as aie_utils import pytest from iron.applications.granite_hybrid import GraniteHybrid @@ -47,7 +48,7 @@ def model(load): MAX_KL = {"Mean": 0.006, "P90": 0.012, "Max": 0.015} -@pytest.mark.parametrize( +GENERATION = pytest.mark.parametrize( "prompt_len,num_tokens", [ pytest.param( @@ -66,6 +67,40 @@ def model(load): f"granite_4_0_h_1b_prompt_{p}_tokens_{n}" for p in (1024, 13) for n in (40, 1) ], ) + + +@pytest.mark.extensive +@pytest.mark.supported_devices("npu1", "npu2") +class TestEachStep: + """Every step its own xclbin dispatch, the host starting each on the + token the last one drew. Its model is built and dropped with the class, + first: after any other test it would be held beside the module's + full-ELF one. + """ + + @pytest.fixture(scope="class") + def model(self, load): + model = load(full_elf=False) + assert not model.device_loop + yield model + model.release() + if aie_utils.DefaultNPURuntime is not None: + aie_utils.DefaultNPURuntime.cleanup() + + @GENERATION + def test_granite_4_0_h_1b_each_step( + self, model, prompt_len, num_tokens, record_property + ): + check_generation(model, prompt_len, num_tokens, record=record_property) + + def test_granite_4_0_h_1b_each_step_accuracy(self, model, record_property): + check_accuracy(model, MAX_KL, 20, 512, record=record_property) + + def test_granite_4_0_h_1b_each_step_determinism(self, model, record_property): + check_determinism(model, 4, 3, 128, record=record_property) + + +@GENERATION def test_granite_4_0_h_1b(model, prompt_len, num_tokens, record_property): check_generation(model, prompt_len, num_tokens, record=record_property) diff --git a/tests/applications/long_context/test.py b/tests/applications/long_context/test.py index 79d24103f..ff10d54b7 100644 --- a/tests/applications/long_context/test.py +++ b/tests/applications/long_context/test.py @@ -96,7 +96,11 @@ def path(self) -> Path: # the whole context; its deepest prompt chunk 4.1e-5 to 0.0052; npu1 # still unmeasured. "granite_4.0_h_1b": Application( - GraniteHybrid, "granite-4.0-h-1b", "model.safetensors", "tokenizer.json" + GraniteHybrid, + "granite-4.0-h-1b", + "model.safetensors", + "tokenizer.json", + each_step=("npu1", "npu2"), ), } From 4cceb35d724094673220efc21d56b447ec0c1a21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:06:20 +0000 Subject: [PATCH 17/21] Fix Granite hybrid recurrent invalidation, scan blocks, CLI, and EOS test Co-authored-by: hunhoffe <54562339+hunhoffe@users.noreply.github.com> --- iron/applications/granite_hybrid/model.py | 5 ++++ iron/lm/session.py | 7 +++-- iron/operators/selective_scan.py | 12 ++++++++ iron/tests/operators/test_rejected_shapes.py | 18 +++++++++++ .../granite_hybrid/reference/test.py | 30 +++++++++++++++++++ tests/applications/granite_hybrid/test.py | 2 ++ 6 files changed, 71 insertions(+), 3 deletions(-) diff --git a/iron/applications/granite_hybrid/model.py b/iron/applications/granite_hybrid/model.py index 62cf31c44..ee3152198 100644 --- a/iron/applications/granite_hybrid/model.py +++ b/iron/applications/granite_hybrid/model.py @@ -26,6 +26,7 @@ Checkpoints, Linear, RMSNorm, + main, residual_add, ) from iron.operators.copy import Copy @@ -186,3 +187,7 @@ class GraniteHybrid(Granite): config = GraniteHybridConfig profile = Path(__file__).with_name("profiles") layer_class = GraniteHybridLayer + + +if __name__ == "__main__": + main(GraniteHybrid) diff --git a/iron/lm/session.py b/iron/lm/session.py index 394c4a149..147ae8e96 100644 --- a/iron/lm/session.py +++ b/iron/lm/session.py @@ -108,10 +108,11 @@ def draw( def forget(self, position: int) -> None: """Hold nothing the caches have from ``position`` on: the next call - runs from there, as a change before it (a placeholder's soft tokens) - would leave them stale. + runs from there or an earlier restart boundary, as a change before + it (a placeholder's soft tokens) would leave them stale. """ - self._seen = self._seen[:position] + held = self._seen[:position].size + self._seen = self._seen[: self.resume(held)] def resume(self, held: int) -> int: """The position calls start from to run the tokens from ``held`` on: diff --git a/iron/operators/selective_scan.py b/iron/operators/selective_scan.py index f49ebe1b7..54931b7ae 100644 --- a/iron/operators/selective_scan.py +++ b/iron/operators/selective_scan.py @@ -147,6 +147,18 @@ def validate(self) -> None: def resolve(self, dev): return resolve_rows(resolve_heads(self, dev, self.heads), dev) + def compatible(self) -> None: + if self.block <= 0 or self.block % SLICE: + raise ValueError( + f"SelectiveScan: block ({self.block}) must be a positive " + f"multiple of {SLICE}" + ) + if self.rows % (self.block // SLICE): + raise ValueError( + f"SelectiveScan: block ({self.block}) must hold a row count " + f"dividing rows ({self.rows})" + ) + def slice_bytes(self) -> tuple[int, int]: """Bytes of a slice's (dt_bias, A, D) then state in, and its final out.""" return 4 * (3 + self.slice_state), 4 * self.slice_state diff --git a/iron/tests/operators/test_rejected_shapes.py b/iron/tests/operators/test_rejected_shapes.py index b34bd27f0..a8af4a4aa 100644 --- a/iron/tests/operators/test_rejected_shapes.py +++ b/iron/tests/operators/test_rejected_shapes.py @@ -42,11 +42,29 @@ from iron.operators.rms_norm import RMSNorm from iron.operators.rope import RoPE from iron.operators.sample import Sample +from iron.operators.selective_scan import SelectiveScan from iron.operators.softmax import Softmax pytestmark = pytest.mark.usefixtures("npu2") +@pytest.mark.parametrize("block", [0, -16, 1, 17, 32, 96]) +@pytest.mark.parametrize("columns", [None, 1]) +def test_selective_scan_rejects_invalid_blocks(block, columns): + with pytest.raises(ValueError, match="block"): + SelectiveScan( + rows=5, heads=2, head_dim=16, state_size=16, columns=columns, block=block + ).resolved() + + +@pytest.mark.parametrize("rows,block", [(5, 16), (5, 80), (8, 32), (8, 128)]) +def test_selective_scan_accepts_blocks_dividing_rows(rows, block): + op = SelectiveScan( + rows=rows, heads=2, head_dim=16, state_size=16, columns=1, block=block + ) + assert op.resolved().block == block + + def test_transfer_size_not_dividing_the_per_channel_share_is_rejected(): """A BD shorter than the ObjectFifo object hangs the device. diff --git a/tests/applications/granite_hybrid/reference/test.py b/tests/applications/granite_hybrid/reference/test.py index e59a03725..31023fdcf 100644 --- a/tests/applications/granite_hybrid/reference/test.py +++ b/tests/applications/granite_hybrid/reference/test.py @@ -11,6 +11,8 @@ import dataclasses import json +import subprocess +import sys import numpy as np import pytest @@ -122,6 +124,34 @@ def names(cls): } return out + @pytest.mark.parametrize("position,start", [(0, 0), (64, 64), (70, 64), (100, 100)]) + def test_forget_restarts_from_a_valid_recurrent_state(self, cpu, position, start): + """A non-boundary forget must not decode from the later state.""" + model = self.host(cpu.config, cpu.weights) + tokens = np.random.default_rng(3).integers(0, cpu.config.vocab_size, 101) + model.logits(tokens[:100]) + model.forget(position) + assert model.session._seen.size == start + resumed = model.logits(tokens[: position + 1]) + model.forget(0) + if start: + model.logits(tokens[:start]) + scratch = model.logits(tokens[: position + 1]) + np.testing.assert_array_equal(resumed, scratch) + + +def test_model_cli_help(): + result = subprocess.run( + [sys.executable, "-m", "iron.applications.granite_hybrid.model", "--help"], + capture_output=True, + text=True, + check=True, + timeout=30, + ) + assert "GraniteHybrid on the NPU" in result.stdout + assert "--random-weights" in result.stdout + assert "path" in result.stdout + def test_a_config_without_rope_reads_back_from_its_json(tmp_path): (tmp_path / "config.json").write_text(json.dumps(TOY.to_json())) diff --git a/tests/applications/granite_hybrid/test.py b/tests/applications/granite_hybrid/test.py index 2d5aa4374..a152ae233 100644 --- a/tests/applications/granite_hybrid/test.py +++ b/tests/applications/granite_hybrid/test.py @@ -112,6 +112,8 @@ def test_granite_4_0_h_1b_rate_counts_the_draws_past_eos(model, record_property) stream reads each as it is drawn and counts those through eos. The two rates agree. """ + if model.random_weights is not None: + pytest.skip("Early EOS is specific to the trained checkpoint") tokens = model.prompt(13) model.forget(0) drawn = model.generate(tokens, 40, Sampler()) From 7457cc3dd69e8be8e78b2b8c8efa6aa6157ecd2a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:41:13 +0000 Subject: [PATCH 18/21] Reject causal convolution chunks shorter than their history Co-authored-by: hunhoffe <54562339+hunhoffe@users.noreply.github.com> --- iron/lm/causal.py | 9 +++++++++ iron/tests/lm/test_modules.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/iron/lm/causal.py b/iron/lm/causal.py index c34119f82..cedc336e1 100644 --- a/iron/lm/causal.py +++ b/iron/lm/causal.py @@ -122,12 +122,21 @@ class CausalConv1d(iron.Graph): ``kernel - 1`` rows before a call are its ``state``, a slot per prompt-chunk boundary; ``CausalLM`` gives every one of a model the scratch ``F.causal_conv1d`` stages a call in (``window``, ``tail``). + + Raises: + ValueError: ``prefill_chunk`` is smaller than ``kernel - 1``, the + history the staging window must hold. """ window: State tail: State def __init__(self, c, channels: int, kernel: int, bias: bool = False): + if c.prefill_chunk < kernel - 1: + raise ValueError( + f"CausalConv1d: prefill_chunk {c.prefill_chunk} is smaller than " + f"kernel - 1 ({kernel - 1})" + ) self.weight = iron.param(channels, 1, kernel) self.bias = iron.param(channels) if bias else None self.state = Checkpoints.of( diff --git a/iron/tests/lm/test_modules.py b/iron/tests/lm/test_modules.py index 92b3d238f..2f4e9eca0 100644 --- a/iron/tests/lm/test_modules.py +++ b/iron/tests/lm/test_modules.py @@ -16,6 +16,7 @@ from iron import Precision from iron.lm import Attention, Embedding, LayerNorm, Linear, RMSNorm from iron.lm.blocks import KVCache, Rope +from iron.lm.causal import CausalConv1d from iron.lm.checkpoint import nest, leaves from iron.lm.golden import HuggingFace from iron.lm.testing import assert_close, check_generation @@ -28,6 +29,21 @@ pytestmark = pytest.mark.usefixtures("npu2") +@pytest.mark.parametrize("chunk,kernel", [(1, 3), (2, 4), (2, 5)]) +def test_causal_conv_refuses_a_chunk_shorter_than_its_history(chunk, kernel): + config = dataclasses.replace(SMALL, prefill_chunk=chunk) + with pytest.raises(ValueError, match="prefill_chunk .* kernel - 1"): + CausalConv1d(config, channels=64, kernel=kernel) + + +@pytest.mark.parametrize("chunk,kernel", [(1, 2), (2, 3), (4, 4), (4, 5), (8, 4)]) +def test_causal_conv_accepts_a_chunk_that_holds_its_history(chunk, kernel): + config = dataclasses.replace(SMALL, prefill_chunk=chunk) + conv = CausalConv1d(config, channels=64, kernel=kernel) + assert conv.state.chunk == chunk + assert conv.state.slots.shape[1:] == (kernel - 1, 64) + + class Block(iron.Graph): def __init__(self): self.embed = Embedding(512, 256) From cb0aca70ad70acd70fa4c500b7a38de71d45fc81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:42:56 +0000 Subject: [PATCH 19/21] Stop generation before decode when the first token is EOS Co-authored-by: hunhoffe <54562339+hunhoffe@users.noreply.github.com> --- iron/lm/model.py | 6 +-- iron/tests/lm/test_generation.py | 67 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 iron/tests/lm/test_generation.py diff --git a/iron/lm/model.py b/iron/lm/model.py index e64ff2a0b..596da0207 100644 --- a/iron/lm/model.py +++ b/iron/lm/model.py @@ -263,8 +263,8 @@ def _draw(self, *args, **kwargs) -> Iterator[int]: ... @property def draws_at_once(self) -> bool: - """Whether ``generate`` draws all ``max_tokens`` before the second - arrives, whichever is ``eos``: none of them costs more to read. + """Whether, after a non-EOS first token, ``generate`` draws the rest + before the second arrives: even those past ``eos`` cost no more to read. """ return False @@ -530,7 +530,7 @@ def _stream(self, prompt, max_tokens, sampler, inputs, each: bool) -> Iterator: if piece: yield piece seen, held = held, len(drawn) - if token in self.eos and not at_once: + if token in self.eos and (count == 1 or not at_once): break if text and held < len(drawn): shown = tokenizer.decode(drawn[seen:held]) diff --git a/iron/tests/lm/test_generation.py b/iron/tests/lm/test_generation.py new file mode 100644 index 000000000..00995dd2f --- /dev/null +++ b/iron/tests/lm/test_generation.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Generation's EOS exits and rate accounting over a device loop, without an NPU.""" + +import math +from types import SimpleNamespace +from unittest.mock import Mock + +import numpy as np +import pytest + +from iron.lm.model import Model +from iron.lm.session import device_draws + + +@pytest.mark.parametrize("each", [False, True], ids=["generate", "stream"]) +@pytest.mark.parametrize( + "eos", [10, 12, None], ids=["first-eos", "later-eos", "no-eos"] +) +@pytest.mark.parametrize("max_tokens", [1, 6]) +def test_device_generation_stops_only_before_undrawn_tokens( + monkeypatch, each, eos, max_tokens +): + clock = [0.0] + monkeypatch.setattr("iron.lm.model.time.perf_counter", lambda: clock[0]) + + def start(*args, **kwargs): + clock[0] += 1.0 + + def steps(n): + for _ in range(n): + clock[0] += 2.0 + yield None + + loop = SimpleNamespace(start=Mock(side_effect=start), steps=Mock(side_effect=steps)) + draws = np.arange(10, 16, dtype=np.int32) + + class DeviceModel(Model): + draws_at_once = True + + def _draw(self, tokens, max_tokens, sampler, each, **inputs): + chunks = [((tokens,), {"position": len(tokens) - 1})] + yield from device_draws(self, chunks, len(tokens), max_tokens, each) + + model = DeviceModel() + model.eos = set() if eos is None else {eos} + model.prompt_chunk = object() + model.drawn = object() + model.decode_step = SimpleNamespace(read=lambda _: draws) + model.loop = Mock(return_value=loop) + tokens = ( + list(model.stream([1], max_tokens)) if each else model.generate([1], max_tokens) + ) + end = max_tokens if eos is None else min(max_tokens, eos - 10 + 1) + assert tokens == list(draws[:end]) + ran = end - 1 if each or eos == 10 else max_tokens - 1 + assert clock[0] == 1.0 + 2.0 * ran + if eos == 10: + loop.steps.assert_not_called() + else: + loop.steps.assert_called_once_with(max_tokens - 1) + assert model.stats.ttft == 1.0 + if ran: + assert model.stats.tokens_per_s == 0.5 + else: + assert math.isnan(model.stats.tokens_per_s) From 6ac9244465cba6576e995062646aeba0a3e82dce Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Wed, 28 Oct 2026 11:38:58 -0600 Subject: [PATCH 20/21] Add Qwen3, Qwen3-Embedding, Qwen3-Reranker and Qwen3-MoE on iron.lm a0162c7d kept #222 to the library, Llama, EmbeddingGemma 2 and Gemma 4, moving eighteen applications to follow-up pull requests. This is the Qwen3 family's: qwen3_embedding and qwen3_reranker build directly on qwen3's Qwen3 class, and qwen3_moe on its Qwen3Layer, so the four travel together. - Qwen3 1.7B/0.6B, Qwen3-Embedding 0.6B and Qwen3-Reranker 0.6B restored unchanged from before a0162c7d, with their tests and toy goldens. - Qwen3-MoE (30B-A3B, reference only) needs two operators a later cleanup commit (0f0a5406) also moved out: restored iron/operators/router.py (TopKRouter) and experts.py (Experts), re-exported from iron/operators/__init__.py. - Restored TopKRouter's two rejected-shape tests in test_rejected_shapes.py. Recreated test_core_streams.py scoped to Experts alone; 0f0a5406's version also covered SelectiveScan and GatedDeltaRule, which belong to lfm2/granite_hybrid/qwen3_5's own follow-up. - README's model table gets its four rows back. Checked: black; device-free tests/applications/{qwen3,qwen3_embedding, qwen3_reranker,qwen3_moe}/reference and test_core_streams.py (20 passed); iron/tests/operators/test_rejected_shapes.py collects clean (134 tests). Co-Authored-By: Claude Sonnet 5 --- README.md | 6 +- iron/applications/qwen3/README.md | 39 ++++ iron/applications/qwen3/__init__.py | 9 + iron/applications/qwen3/model.py | 53 ++++++ iron/applications/qwen3/profiles/npu1.json | 17 ++ iron/applications/qwen3/profiles/npu2.json | 17 ++ iron/applications/qwen3_embedding/__init__.py | 9 + .../qwen3_embedding/hugging_face.py | 26 +++ iron/applications/qwen3_embedding/model.py | 51 ++++++ iron/applications/qwen3_moe/README.md | 22 +++ iron/applications/qwen3_moe/__init__.py | 9 + iron/applications/qwen3_moe/model.py | 168 ++++++++++++++++++ .../applications/qwen3_moe/profiles/npu2.json | 4 + iron/applications/qwen3_reranker/__init__.py | 9 + iron/applications/qwen3_reranker/model.py | 36 ++++ iron/operators/__init__.py | 2 + iron/operators/experts.py | 163 +++++++++++++++++ iron/operators/router.py | 167 +++++++++++++++++ iron/tests/operators/test_core_streams.py | 49 +++++ iron/tests/operators/test_rejected_shapes.py | 12 ++ iron/tests/support/qwen3_moe.py | 56 ++++++ .../qwen3/reference/goldens/toy.npz | Bin 0 -> 40641 bytes tests/applications/qwen3/reference/test.py | 43 +++++ tests/applications/qwen3/test.py | 162 +++++++++++++++++ .../qwen3_embedding/reference/goldens/toy.npz | Bin 0 -> 4556 bytes .../qwen3_embedding/reference/test.py | 60 +++++++ tests/applications/qwen3_embedding/test.py | 67 +++++++ .../qwen3_moe/reference/goldens/toy.npz | Bin 0 -> 40615 bytes .../applications/qwen3_moe/reference/test.py | 40 +++++ tests/applications/qwen3_moe/test.py | 41 +++++ .../qwen3_reranker/reference/goldens/toy.npz | Bin 0 -> 4280 bytes .../qwen3_reranker/reference/test.py | 48 +++++ tests/applications/qwen3_reranker/test.py | 91 ++++++++++ 33 files changed, 1475 insertions(+), 1 deletion(-) create mode 100644 iron/applications/qwen3/README.md create mode 100644 iron/applications/qwen3/__init__.py create mode 100644 iron/applications/qwen3/model.py create mode 100644 iron/applications/qwen3/profiles/npu1.json create mode 100644 iron/applications/qwen3/profiles/npu2.json create mode 100644 iron/applications/qwen3_embedding/__init__.py create mode 100644 iron/applications/qwen3_embedding/hugging_face.py create mode 100644 iron/applications/qwen3_embedding/model.py create mode 100644 iron/applications/qwen3_moe/README.md create mode 100644 iron/applications/qwen3_moe/__init__.py create mode 100644 iron/applications/qwen3_moe/model.py create mode 100644 iron/applications/qwen3_moe/profiles/npu2.json create mode 100644 iron/applications/qwen3_reranker/__init__.py create mode 100644 iron/applications/qwen3_reranker/model.py create mode 100644 iron/operators/experts.py create mode 100644 iron/operators/router.py create mode 100644 iron/tests/operators/test_core_streams.py create mode 100644 iron/tests/support/qwen3_moe.py create mode 100644 tests/applications/qwen3/reference/goldens/toy.npz create mode 100644 tests/applications/qwen3/reference/test.py create mode 100644 tests/applications/qwen3/test.py create mode 100644 tests/applications/qwen3_embedding/reference/goldens/toy.npz create mode 100644 tests/applications/qwen3_embedding/reference/test.py create mode 100644 tests/applications/qwen3_embedding/test.py create mode 100644 tests/applications/qwen3_moe/reference/goldens/toy.npz create mode 100644 tests/applications/qwen3_moe/reference/test.py create mode 100644 tests/applications/qwen3_moe/test.py create mode 100644 tests/applications/qwen3_reranker/reference/goldens/toy.npz create mode 100644 tests/applications/qwen3_reranker/reference/test.py create mode 100644 tests/applications/qwen3_reranker/test.py diff --git a/README.md b/README.md index d122a0ec4..b48b15ead 100755 --- a/README.md +++ b/README.md @@ -155,11 +155,15 @@ Each model is a package under [iron/applications/](./iron/applications/) over [i | Llama 3.2 1B, 3B | [llama](./iron/applications/llama/) | `llama3.2-1b`, `llama3.2-3b` | | Granite 4.2 3B | [granite](./iron/applications/granite/) | `granite-4.2-3b` | | Granite 4.0-H 1B | [granite_hybrid](./iron/applications/granite_hybrid/) | `granite-4.0-h-1b` | +| Qwen3 1.7B, 0.6B | [qwen3](./iron/applications/qwen3/) | `qwen3-1.7b`, `qwen3-0.6b` | +| Qwen3-Reranker 0.6B (`score`) | [qwen3_reranker](./iron/applications/qwen3_reranker/) | `qwen3-reranker-0.6b` | +| Qwen3-Embedding 0.6B (`embed`) | [qwen3_embedding](./iron/applications/qwen3_embedding/) | `qwen3-embedding-0.6b` | | Gemma 4 E2B text decoder | [gemma4](./iron/applications/gemma4/) | `gemma-4-e2b-it` | +| Qwen3-30B-A3B (reference only) | [qwen3_moe](./iron/applications/qwen3_moe/) | `qwen3-30b-a3b` | | EmbeddingGemma 2 | [embedding_gemma2](./iron/applications/embedding_gemma2/) | `embeddinggemma-2` | | Gemma 4 E2B in FastFlowLM | [gemma4_engine](./iron/operators/flm/gemma4_engine/) | `$FLM_MODEL_PATH` (the tests default to `fastflowlm`) | -What the text decoders (Llama, Granite and Gemma 4 above) share: +What the text decoders (Llama, Granite, Qwen3 and Gemma 4 above) share: - **Setup**: `requirements_examples.txt`, as [installing](#installation-linux) says. XRT maps every device buffer locked, and a model's weights and caches are banks of at most 3.5 GiB (`iron.common.image.allocator.BANK`), so `ulimit -l` must cover one bank. diff --git a/iron/applications/qwen3/README.md b/iron/applications/qwen3/README.md new file mode 100644 index 000000000..1fe1735da --- /dev/null +++ b/iron/applications/qwen3/README.md @@ -0,0 +1,39 @@ + + +# Qwen3 1.7B on the NPU + +Qwen3 1.7B and 0.6B, `Qwen3` in `model.py`, whose docstring says how it +differs from Llama: a per-head RMSNorm on q and k before RoPE, and an +unread `lm_head.weight` beside its tied head. How to run, test and tune it is the README's [Models](../../../README.md#models); the shared library is [AGENTS.md](../../../AGENTS.md#language-models). + +## Weights and tokenizer + +From Hugging Face ([`Qwen/Qwen3-1.7B`](https://huggingface.co/Qwen/Qwen3-1.7B), Apache-2.0), +in one directory: `model-00001-of-00002.safetensors`, +`model-00002-of-00002.safetensors`, `model.safetensors.index.json`, +`tokenizer.json` and `config.json`. Qwen3 0.6B is the same model at another shape: its +`model.safetensors`, `tokenizer.json` and `config.json` from +[`Qwen/Qwen3-0.6B`](https://huggingface.co/Qwen/Qwen3-0.6B). + +## NPU1's profile + +Qwen3's `head_dim` is 128, twice Llama 3.2 1B's: MHA's block-size search +picks a smaller block, which at `num_pipelines=1` pushes a prompt chunk's +K/V fill past the DMA descriptor's 64-iteration cap. `profiles/npu1.json` +pins `num_pipelines=4` for the prompt-chunk shape to stay under it. + +Open on NPU1: 1.7B's deepest prompt chunk at its native 40960 positions +differs from the graph's reference by KL 0.148 (`long_context`'s +`test_prompt_deep_in_the_cache`, bound 0.1). It passes at 34816 positions +(4.8e-6), 32768 (2.0e-5) and 16384 (2.1e-6), its deepest decode step +passes at 40960, and the prompt's MHA alone, given its inputs, is as +accurate at 40959 as at 2047. + +## Testing + +`tests/applications/qwen3/` runs 1.7B's checkpoint and 0.6B's, each also +in the each-step form, over a two-chunk prompt and a chat turn, and checks +that the unread `lm_head.weight` is the tied embedding. diff --git a/iron/applications/qwen3/__init__.py b/iron/applications/qwen3/__init__.py new file mode 100644 index 000000000..e678a0600 --- /dev/null +++ b/iron/applications/qwen3/__init__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3 end to end on the NPU; see ``iron/applications/qwen3/README.md``.""" + +from iron.lm.cli import exports + +__all__ = ["Qwen3"] +__getattr__ = exports(__name__, __all__) diff --git a/iron/applications/qwen3/model.py b/iron/applications/qwen3/model.py new file mode 100644 index 000000000..e95130ea9 --- /dev/null +++ b/iron/applications/qwen3/model.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3 1.7B on the shared decoder (``iron.lm``), its shape read from its +checkpoint's ``config.json``. Run it with ``python -m +iron.applications.qwen3.model``. + +Qwen3 is Llama's layer plus a per-head RMSNorm on q and k before RoPE, +``self_attn.q_norm`` and ``k_norm`` (``Qwen3Layer``). Its output +head is the input embedding, tied; its checkpoint also ships +``lm_head.weight``, byte-identical to ``model.embed_tokens.weight``, which +is never read. + +The operators' tunables are the graph's profile, ``profiles/.json``, +keyed by operator shape at Qwen3 1.7B's shape: a decode step's and a +2048-row prompt chunk's, at any ``max_seq_len``, which sizes the caches and +the RoPE table alone. +""" + +import dataclasses +from pathlib import Path + +from iron import Precision, lm +from iron.lm import Attention, CausalLM, DecoderConfig, DecoderLayer + + +@dataclasses.dataclass(frozen=True) +class Qwen3Config(DecoderConfig): + """``DecoderConfig`` and its projections' ``precision``.""" + + precision: Precision = lm.not_json(Precision.MEDIUM, kw_only=True) + + +class Qwen3Layer(DecoderLayer): + """Llama's layer, its attention norming q and k per head.""" + + def __init__(self, c: DecoderConfig, i: int): + super().__init__(c, i, Attention(c, i, norms="qk", o_bias=c.attention_bias)) + + +class Qwen3(CausalLM): + """The Qwen3 decoder, with the shape ``config`` and the checkpoint's + ``weights``. ``Qwen3.load`` builds one from a checkpoint and its + ``tokenizer.json``. + """ + + config = Qwen3Config + profile = Path(__file__).with_name("profiles") + layer_class = Qwen3Layer + + +if __name__ == "__main__": + lm.main(Qwen3) diff --git a/iron/applications/qwen3/profiles/npu1.json b/iron/applications/qwen3/profiles/npu1.json new file mode 100644 index 000000000..1d7e3dda9 --- /dev/null +++ b/iron/applications/qwen3/profiles/npu1.json @@ -0,0 +1,17 @@ +{"entries": [ + {"operator": "RMSNorm", "weighted": true, "rows": 1, "tile_size": 2048, "num_aie_columns": 1}, + {"operator": "RMSNorm", "weighted": true, "rows": 2048, "tile_size": 2048, "num_aie_columns": 4}, + {"operator": "GEMV", "M": 2048, "K": 2048, "num_batches": 1, "num_channels": 2, "tile_size_input": 4, "tile_size_output": 64}, + {"operator": "GEMV", "M": 1024, "K": 2048, "num_batches": 1, "num_channels": 2, "tile_size_input": 4, "tile_size_output": 64}, + {"operator": "MHA", "num_heads": 16, "num_KV_heads": 8, "d": 128, "seq_pad": 1, "num_pipelines": 2}, + {"operator": "MHA", "num_heads": 16, "num_KV_heads": 8, "d": 128, "seq_pad": 2048, "num_pipelines": 4}, + {"operator": "ElementwiseAdd", "size": 2048, "tile_size": 512}, + {"operator": "GEMV", "M": 6144, "K": 2048, "num_batches": 1, "num_channels": 2, "tile_size_input": 4, "tile_size_output": 1024}, + {"operator": "SiLU", "size": 6144, "tile_size": 2048}, + {"operator": "ElementwiseMul", "size": 6144, "tile_size": 2048}, + {"operator": "GEMV", "M": 2048, "K": 6144, "num_batches": 1, "num_channels": 2, "tile_size_input": 1, "tile_size_output": 256}, + {"operator": "GEMV", "M": 151936, "K": 2048, "num_batches": 1, "num_channels": 2, "tile_size_input": 4, "tile_size_output": 32}, + {"operator": "ElementwiseAdd", "size": 4194304, "tile_size": 2048}, + {"operator": "SiLU", "size": 12582912, "tile_size": 4096}, + {"operator": "ElementwiseMul", "size": 12582912, "tile_size": 4096} +]} diff --git a/iron/applications/qwen3/profiles/npu2.json b/iron/applications/qwen3/profiles/npu2.json new file mode 100644 index 000000000..1d7e3dda9 --- /dev/null +++ b/iron/applications/qwen3/profiles/npu2.json @@ -0,0 +1,17 @@ +{"entries": [ + {"operator": "RMSNorm", "weighted": true, "rows": 1, "tile_size": 2048, "num_aie_columns": 1}, + {"operator": "RMSNorm", "weighted": true, "rows": 2048, "tile_size": 2048, "num_aie_columns": 4}, + {"operator": "GEMV", "M": 2048, "K": 2048, "num_batches": 1, "num_channels": 2, "tile_size_input": 4, "tile_size_output": 64}, + {"operator": "GEMV", "M": 1024, "K": 2048, "num_batches": 1, "num_channels": 2, "tile_size_input": 4, "tile_size_output": 64}, + {"operator": "MHA", "num_heads": 16, "num_KV_heads": 8, "d": 128, "seq_pad": 1, "num_pipelines": 2}, + {"operator": "MHA", "num_heads": 16, "num_KV_heads": 8, "d": 128, "seq_pad": 2048, "num_pipelines": 4}, + {"operator": "ElementwiseAdd", "size": 2048, "tile_size": 512}, + {"operator": "GEMV", "M": 6144, "K": 2048, "num_batches": 1, "num_channels": 2, "tile_size_input": 4, "tile_size_output": 1024}, + {"operator": "SiLU", "size": 6144, "tile_size": 2048}, + {"operator": "ElementwiseMul", "size": 6144, "tile_size": 2048}, + {"operator": "GEMV", "M": 2048, "K": 6144, "num_batches": 1, "num_channels": 2, "tile_size_input": 1, "tile_size_output": 256}, + {"operator": "GEMV", "M": 151936, "K": 2048, "num_batches": 1, "num_channels": 2, "tile_size_input": 4, "tile_size_output": 32}, + {"operator": "ElementwiseAdd", "size": 4194304, "tile_size": 2048}, + {"operator": "SiLU", "size": 12582912, "tile_size": 4096}, + {"operator": "ElementwiseMul", "size": 12582912, "tile_size": 4096} +]} diff --git a/iron/applications/qwen3_embedding/__init__.py b/iron/applications/qwen3_embedding/__init__.py new file mode 100644 index 000000000..b2e65ea17 --- /dev/null +++ b/iron/applications/qwen3_embedding/__init__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3-Embedding on the NPU; see ``model.py``.""" + +from iron.lm.cli import exports + +__all__ = ["Qwen3Embedding"] +__getattr__ = exports(__name__, __all__) diff --git a/iron/applications/qwen3_embedding/hugging_face.py b/iron/applications/qwen3_embedding/hugging_face.py new file mode 100644 index 000000000..ae53a320b --- /dev/null +++ b/iron/applications/qwen3_embedding/hugging_face.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Hugging Face's own Qwen3-Embedding in float32, what its golden is +recorded from. It needs torch and transformers, which are not IRON's +requirements, only to record. +""" + +import numpy as np +import torch + +from iron.lm.golden import HuggingFace + + +class LastHidden(HuggingFace): + """transformers' Qwen3 model without its head: the last token's final + hidden state, unit length. + """ + + model_class = "AutoModel" + + def logits(self, tokens) -> np.ndarray: + with torch.no_grad(): + out = self.model(torch.tensor([list(tokens)]), use_cache=False) + last = out.last_hidden_state[0, -1].numpy() + return last / np.linalg.norm(last) diff --git a/iron/applications/qwen3_embedding/model.py b/iron/applications/qwen3_embedding/model.py new file mode 100644 index 000000000..dfcf669a3 --- /dev/null +++ b/iron/applications/qwen3_embedding/model.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3-Embedding on the NPU: Qwen3's decoder without its head, over +``CausalLM``'s prompt chunks and caches, so one compile serves every +length up to its native context. A text's embedding is its last token's +final hidden state, unit length; the tokenizer's end of text closes it, +and a query goes behind its task's instruction. +""" + +import numpy as np + +import iron +from iron.applications.qwen3.model import Qwen3 +from iron.lm import subtree + + +class Qwen3Embedding(Qwen3): + """Qwen3-Embedding on ``config``'s shape from a checkpoint's + ``weights``, Qwen3's ``model`` modules at its root. A call returns the + last row's final hidden state; nothing is drawn. + """ + + # The end-of-text token each input ends in. + end_of_text = 151643 + + def __init__(self, config, weights): + super().__init__(config, subtree(weights, model=weights)) + + @property + def hugging_face(self): + """transformers' Qwen3 model, imported only where a golden is recorded.""" + from .hugging_face import LastHidden + + return LastHidden + + def head(self, x): + return self.model.norm(x) + + def draw(self, logits, call): + return logits, iron.carry(token=call.token, position=call.position + 1) + + def embed(self, text: str, task: str | None = None) -> np.ndarray: + """The unit-length embedding of ``text``, a query behind ``task``'s + instruction where given, else a document, float32. + """ + if task is not None: + text = f"Instruct: {task}\nQuery:{text}" + tokens = [*self.tokenizer.encoding.encode(text), self.end_of_text] + y = self.logits(tokens).astype(np.float32) + return y / np.linalg.norm(y) diff --git a/iron/applications/qwen3_moe/README.md b/iron/applications/qwen3_moe/README.md new file mode 100644 index 000000000..3fcf9979b --- /dev/null +++ b/iron/applications/qwen3_moe/README.md @@ -0,0 +1,22 @@ + + +# Qwen3-30B-A3B on the NPU + +Qwen3-30B-A3B, `Qwen3Moe` in `model.py`, whose docstring says how it +differs from Qwen3: a mixture of 128 experts, 8 a token, in place of the +feed-forward. The shared library is [AGENTS.md](../../../AGENTS.md#language-models). + +The experts' feed-forwards are `Experts`, whose first kernel streams every +expert's weights for each row, too slow to generate with: its test is +the device-free `tests/applications/qwen3_moe/reference/`. Its weights are +61 GB in bf16, every expert held, and stacked into one copy as they load. + +## Weights and tokenizer + +From Hugging Face +([`Qwen/Qwen3-30B-A3B`](https://huggingface.co/Qwen/Qwen3-30B-A3B), +Apache-2.0), in one directory: `model.safetensors.index.json` and its +shards, `tokenizer.json` and `config.json`. diff --git a/iron/applications/qwen3_moe/__init__.py b/iron/applications/qwen3_moe/__init__.py new file mode 100644 index 000000000..b86cfb10a --- /dev/null +++ b/iron/applications/qwen3_moe/__init__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3-MoE on the shared decoder; see ``model.py``.""" + +from iron.lm.cli import exports + +__all__ = ["Qwen3Moe"] +__getattr__ = exports(__name__, __all__) diff --git a/iron/applications/qwen3_moe/model.py b/iron/applications/qwen3_moe/model.py new file mode 100644 index 000000000..174101da6 --- /dev/null +++ b/iron/applications/qwen3_moe/model.py @@ -0,0 +1,168 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3-30B-A3B, a small mixture of experts, on the shared decoder +(``iron.lm``), its shape read from its checkpoint's ``config.json``. + +Qwen3-MoE is Qwen3's layer with its feed-forward a mixture of experts +(``SparseMoE``): a router picks each token's ``num_experts_per_tok`` +experts and weighs them by a softmax over their logits alone, which is +``TopKRouter``'s. Its output head is untied. + +The experts stay in the mapped checkpoint, 58 GB of them at 30B. Between +the router and the experts the host copies the ones a call chose into +states every layer shares (``SparseMoE.stage``), so the device holds only +those and the page cache whichever the host read lately. The staged +experts run as one gated feed-forward, each row's routing weights +scaling its experts' activations and zero at the others'. +""" + +import dataclasses +from pathlib import Path + +import numpy as np + +import iron +import iron.functional as F +from iron import Precision, lm +from iron.applications.qwen3.model import Qwen3Layer +from iron.common.graph.handle import State +from iron.lm import CausalLM, DecoderConfig, GatedMLP, Linear, activation +from iron.operators.router import TopKRouter + + +@dataclasses.dataclass(frozen=True) +class Qwen3MoeConfig(DecoderConfig): + """``DecoderConfig`` and its experts: every layer's feed-forward a + mixture, its chosen experts' weights normalized, as Qwen3-MoE's + checkpoints have them. ``Qwen3MoeConfig`` refuses any other. + """ + + precision: Precision = lm.not_json(Precision.MEDIUM, kw_only=True) + num_experts: int = dataclasses.field(kw_only=True) + num_experts_per_tok: int = dataclasses.field(kw_only=True) + moe_intermediate_size: int = dataclasses.field(kw_only=True) + norm_topk_prob: bool = dataclasses.field(default=False, kw_only=True) + decoder_sparse_step: int = dataclasses.field(default=1, kw_only=True) + mlp_only_layers: tuple[int, ...] = dataclasses.field( + default=(), kw_only=True, metadata={"convert": tuple} + ) + tie_word_embeddings: bool = False + + def __post_init__(self): + super().__post_init__() + if not self.norm_topk_prob: + raise ValueError( + "Qwen3MoeConfig: TopKRouter normalizes the chosen experts' " + "weights; no operator weighs them unnormalized" + ) + if self.decoder_sparse_step != 1 or self.mlp_only_layers: + raise ValueError( + f"Qwen3MoeConfig: decoder_sparse_step {self.decoder_sparse_step} " + f"and mlp_only_layers {self.mlp_only_layers} make some layers " + "dense; here every layer's feed-forward is a mixture" + ) + + +@dataclasses.dataclass(frozen=True) +class Staged: + """The experts a call of ``rows`` rows is staged in, each ``width`` wide + in a slot of its own: ``gate`` and ``up`` stacked as one projection's + rows, ``down`` as one's columns, and ``routed``, each row's routing + weight for each slot over that slot's columns. + """ + + gate: State + up: State + down: State + routed: State + + @classmethod + def of(cls, rows: int, slots: int, width: int, hidden: int) -> "Staged": + n = slots * width + return cls( + iron.state((n, hidden)), + iron.state((n, hidden)), + iron.state((hidden, n)), + iron.state((rows, n)), + ) + + +class SparseMoE(iron.Graph): + """A mixture of experts on ``c``'s shape: ``gate``, the router, and + ``experts``, each a gated feed-forward ``moe_intermediate_size`` wide, + left in the checkpoint. ``Qwen3Moe`` gives every layer's ``staged``, + the experts a call stages, by its rows. + """ + + staged: dict[int, Staged] + + def __init__(self, c: Qwen3MoeConfig): + self.gate = Linear(c.hidden_size, c.num_experts, False, precision=c.precision) + expert = dataclasses.replace(c, intermediate_size=c.moe_intermediate_size) + self.experts = [GatedMLP(expert) for _ in range(c.num_experts)] + self.top_k = c.num_experts_per_tok + self.width = c.moe_intermediate_size + self.act = activation(c.hidden_act) + + def body(self, x): + n, experts = x.shape[0], self.gate.weight.shape[0] + logits = F.linear(x, self.gate.weight, precision=self.gate.precision) + logits = logits.reshape(n, experts) + ids, weights = TopKRouter.step(logits, top_k=self.top_k) + s = self.staged[n] + iron.on_host(self.stage, ids, weights, writes=(s.gate, s.up, s.down, s.routed)) + # A prompt chunk's staged experts are 98304 wide at 30B, past what + # flm.ProjectionGEMM's per-call transfers fit in one descriptor. + p = Precision.HIGHEST + h = self.act(F.linear(x, s.gate, precision=p)) * F.linear(x, s.up, precision=p) + routed = s.routed[: x.valid[0]] if x.valid else s.routed + return F.linear(h * routed.reshape(h.shape), s.down, precision=p) + + def stage(self, ids, weights, gate, up, down, routed) -> None: + """Copy the experts ``ids`` chose into ``gate``, ``up`` and ``down``, a + slot each, and each of ``ids``' rows' ``weights`` into ``routed`` at + its experts' slots, zero at the others. A slot none chose keeps the + expert it held, which no row weighs. + """ + chosen, slots = np.unique(ids, return_inverse=True) + I, n = self.width, len(ids) + for slot, e in enumerate(chosen): + expert, at = self.experts[e], slice(slot * I, (slot + 1) * I) + gate[at] = expert.gate_proj.weight + up[at] = expert.up_proj.weight + down[:, at] = expert.down_proj.weight + routed[:n] = 0 + rows = np.arange(n)[:, None] + routed[:n].reshape(n, -1, I)[rows, slots.reshape(ids.shape)] = weights[ + :, ::2, None + ] + + +class Qwen3MoeLayer(Qwen3Layer): + """Qwen3's layer, its feed-forward ``SparseMoE``.""" + + def __init__(self, c: Qwen3MoeConfig, i: int): + super().__init__(c, i) + self.mlp = SparseMoE(c) + + +class Qwen3Moe(CausalLM): + """Qwen3-MoE on ``config``'s shape and a checkpoint's ``weights``, from + its checkpoint and ``tokenizer.json``. + """ + + config = Qwen3MoeConfig + layer_class = Qwen3MoeLayer + profile = Path(__file__).with_name("profiles") + + def __init__(self, config: Qwen3MoeConfig, weights): + super().__init__(config, weights) + c = config + H, I, K = c.hidden_size, c.moe_intermediate_size, c.num_experts_per_tok + staged = { + rows: Staged.of(rows, min(c.num_experts, rows * K), I, H) + for rows in (1, c.prefill_chunk) + } + for layer in self.layers: + layer.mlp.staged = staged diff --git a/iron/applications/qwen3_moe/profiles/npu2.json b/iron/applications/qwen3_moe/profiles/npu2.json new file mode 100644 index 000000000..e61fd85bc --- /dev/null +++ b/iron/applications/qwen3_moe/profiles/npu2.json @@ -0,0 +1,4 @@ +{"entries": [ + {"operator": "MHA", "num_heads": 32, "num_KV_heads": 4, "d": 128, "seq_pad": 1, "num_pipelines": 4}, + {"operator": "MHA", "num_heads": 32, "num_KV_heads": 4, "d": 128, "seq_pad": 2048, "num_pipelines": 4} +]} diff --git a/iron/applications/qwen3_reranker/__init__.py b/iron/applications/qwen3_reranker/__init__.py new file mode 100644 index 000000000..7cb841a4e --- /dev/null +++ b/iron/applications/qwen3_reranker/__init__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3-Reranker on the NPU; see ``model.py``.""" + +from iron.lm.cli import exports + +__all__ = ["Qwen3Reranker"] +__getattr__ = exports(__name__, __all__) diff --git a/iron/applications/qwen3_reranker/model.py b/iron/applications/qwen3_reranker/model.py new file mode 100644 index 000000000..9924a1562 --- /dev/null +++ b/iron/applications/qwen3_reranker/model.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3-Reranker on the NPU: Qwen3 asked, in the prompt its model card +gives, whether a document meets a query and an instruction; its score is +the probability of "yes" over "no" as the answer's first token. +""" + +import math + +from iron.applications.qwen3.model import Qwen3 + +PROMPT = ( + "<|im_start|>system\nJudge whether the Document meets the requirements " + "based on the Query and the Instruct provided. Note that the answer can " + 'only be "yes" or "no".<|im_end|>\n<|im_start|>user\n' + ": {instruction}\n: {query}\n: {document}" + "<|im_end|>\n<|im_start|>assistant\n\n\n\n\n" +) +INSTRUCTION = ( + "Given a web search query, retrieve relevant passages that answer the query" +) + + +class Qwen3Reranker(Qwen3): + """Qwen3 scoring a document against a query (``score``).""" + + def score(self, query: str, document: str, instruction=INSTRUCTION) -> float: + """The probability that ``document`` meets ``query`` and + ``instruction``. + """ + encode = self.tokenizer.encoding.encode + text = PROMPT.format(instruction=instruction, query=query, document=document) + logits = self.logits(encode(text)) + [yes], [no] = encode("yes"), encode("no") + return 1 / (1 + math.exp(logits[no] - logits[yes])) diff --git a/iron/operators/__init__.py b/iron/operators/__init__.py index f6aae8a54..4ca4ea1f6 100644 --- a/iron/operators/__init__.py +++ b/iron/operators/__init__.py @@ -11,6 +11,7 @@ from .elementwise_add import ElementwiseAdd, RowwiseAdd from .elementwise_mul import ElementwiseMul, RowwiseMul from .emit import Emit +from .experts import Experts from .gelu import GELU from .gemm import GEMM from .gemv import GEMV @@ -24,6 +25,7 @@ from .resample.op import PatchPositions, Resample, ResampleTaps from .rms_norm import RMSNorm from .rope import RoPE +from .router import TopKRouter from .sample import Sample from .selective_scan import SelectiveScan from .sigmoid import Sigmoid diff --git a/iron/operators/experts.py b/iron/operators/experts.py new file mode 100644 index 000000000..38a47e001 --- /dev/null +++ b/iron/operators/experts.py @@ -0,0 +1,163 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Experts: a mixture of experts' feed-forwards, each row through the +experts ``TopKRouter`` chose for it. +""" + +import numpy as np +from aie.dialects.aie import T +from aie.extras.dialects import arith +from aie.helpers.taplib import TensorAccessPattern +from aie.iron import In, ObjectFifo, Out, TaskGroup, Worker +from aie.iron.controlflow import range_ +from aie.iron.kernels import moe +from aie.utils.verify import Tolerance +from aie.utils.compile.jit.params import param, per_call +from ml_dtypes import bfloat16 + +from iron.common import Built, Operator +from iron.common.testing import Case, Testing + +#: The rows and columns of a weight tile, so that at Qwen3's shapes a +#: shim BD repeats an expert's row blocks at most 64 times. +TILE = 32 + + +def _routed(op: "Experts") -> dict: + """Each row's ``top_k`` distinct experts, and weights at a layer's scale.""" + rng = np.random.default_rng(7) + ids = [rng.choice(op.experts, op.top_k, replace=False) for _ in range(op.rows)] + gate_up = rng.standard_normal(op.gate_up.shape) / np.sqrt(op.hidden) + down = rng.standard_normal(op.down.shape) / np.sqrt(op.width) + return dict( + given={ + op.ids: np.array(ids, np.int32), + op.gate_up: gate_up.astype(bfloat16), + op.down: down.astype(bfloat16), + }, + normal=(op.x, op.weights), + ) + + +class Experts(Operator): + """Row ``r`` of ``x``: the sum over ``j < top_k`` of ``weights[r, 2 * j]`` + times expert ``e = ids[r, j]``'s gated feed-forward, ``down[e] @ + (silu(g) * u)`` where ``g`` and ``u`` are the halves of ``gate_up[e] @ + x``, in float32, rounded once. ``ids`` and ``weights`` are + ``TopKRouter``'s; ``gate_up`` and ``down`` stack the experts as + transformers holds them. + + One core, scalar, reading two streams: a row's ``x``, ``weights`` and + ``ids``, which a memtile joins, and every expert's ``gate_up`` then + ``down`` in ``TILE`` x ``TILE`` tiles, streamed for each row. The + kernel skips the tiles of experts the row did not choose. + """ + + test = Testing( + [ + Case(dict(rows=2, hidden=256, width=64, experts=4, top_k=2)), + Case(dict(rows=8, hidden=512, width=256, experts=8, top_k=2), bench=True), + ], + draw=_routed, + ) + + rows: int = param(array=True) + hidden: int = param() + width: int = param(array=True) + experts: int = param(array=True) + top_k: int = param() + weights_width: int = param(derive=lambda op: 2 * op.top_k, repr=False) + gate_up_width: int = param(derive=lambda op: 2 * op.width, repr=False) + tile: int = param(derive=lambda op: TILE * TILE, repr=False) + + x = In(rows, hidden, obj_shape=(hidden,)) + ids = In(rows, top_k, dtype=np.int32, obj_shape=(top_k,)) + weights = In(rows, weights_width, obj_shape=(weights_width,)) + gate_up = In(experts, gate_up_width, hidden, obj_shape=(tile,)) + down = In(experts, hidden, width, obj_shape=(tile,)) + y = Out(rows, hidden, obj_shape=(hidden,)) + # Every row is still computed under a bound. + valid = per_call(bounds=rows) + + def kernel(self): + return moe.experts( + hidden=self.hidden, + width=self.width, + num_experts=self.experts, + top_k=self.top_k, + rows=TILE, + cols=TILE, + ) + + def build(self) -> Built: + H, K = self.hidden, self.top_k + steps = 3 * self.width * H // self.tile + row = ObjectFifo(np.ndarray[(H + 4 * K,), np.dtype[bfloat16]], depth=1) + parts = row.prod().join( + [0, H, H + 2 * K], + obj_types=[o.obj_type for o in (self.x, self.weights, self.ids)], + ) + for o, f in zip((self.x, self.weights, self.ids), parts): + o.bind(f.prod()) + tiles = ObjectFifo(self.gate_up.obj_type) + self.gate_up.bind(tiles.prod()) + self.down.bind(tiles.prod()) + of_y = ObjectFifo(self.y.obj_type) + self.y.bind(of_y.cons()) + + def core_fn(of_row, of_tile, of_y, experts): + for _ in range_(self.rows): + row, y = of_row.acquire(1), of_y.acquire(1) + for step in range_(self.experts * steps): + tile = of_tile.acquire(1) + experts(row, tile, y, arith.index_cast(step, to=T.i32())) + of_tile.release(1) + of_row.release(1) + of_y.release(1) + + worker = Worker(core_fn, [row.cons(), tiles.cons(), of_y.prod(), self.kernel()]) + return Built([worker]) + + def sequence(self, rt): + """A row at a time: its inputs, every expert's tiles, its output.""" + H, I = self.hidden, self.width + + def tiles(e, rows, cols): + """Expert ``e``'s ``rows`` x ``cols`` matrix, tile by tile.""" + return TensorAccessPattern( + (self.experts, rows, cols), + e * rows * cols, + [rows // TILE, cols // TILE, TILE, TILE], + [TILE * cols, TILE, cols, 1], + ) + + for r in range(self.rows): + tg = TaskGroup() + for operand in (self.x, self.weights, self.ids): + operand.fill(operand[r : r + 1], group=tg) + for e in range(self.experts): + expert = TaskGroup() + self.gate_up.fill(tiles(e, 2 * I, H), group=expert) + self.down.fill(tiles(e, H, I), group=expert, wait=True) + expert.finish() + self.y.drain(self.y[r : r + 1], group=tg, wait=True) + tg.finish() + + def tolerance(self): + return Tolerance.relative( + 8e-3, range_frac=1e-3, note="f32 throughout, rounded once: a bf16 ulp" + ) + + def ops(self) -> int: + return 6 * self.rows * self.top_k * self.width * self.hidden + + def reference(self, x, ids, weights, gate_up, down): + x, weights = (np.asarray(a, np.float32) for a in (x, weights)) + y = np.zeros(x.shape, np.float32) + for r, chosen in enumerate(np.asarray(ids)): + for j, e in enumerate(chosen): + g, u = np.split(gate_up[e].astype(np.float32) @ x[r], 2) + h = g / (1 + np.exp(-g)) * u + y[r] += weights[r, 2 * j] * (down[e].astype(np.float32) @ h) + return y.astype(bfloat16) diff --git a/iron/operators/router.py b/iron/operators/router.py new file mode 100644 index 000000000..5827be222 --- /dev/null +++ b/iron/operators/router.py @@ -0,0 +1,167 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""TopKRouter: each row's ``top_k`` experts and their weights, on the device. + +Bit for bit as ``aie.iron.kernels.moe.router_top_k_ref``: the exact top +``top_k`` of a row of bf16 router logits, the lowest indices winning a tie, +and a softmax over the chosen logits. A select core reduces each row to a +``sample_select`` summary and a router core turns it into the ids and the +weights, a pair of cores for each lane of rows. +""" + +import numpy as np +from aie.iron import Buffer, In, ObjectFifo, Out, Worker, ceildiv +from aie.iron.controlflow import range_ +from aie.iron.kernels import moe, sample +from aie.utils.verify import Tolerance +from aie.utils.compile.jit.params import param, per_call, tunable, widths +from ml_dtypes import bfloat16 + +from iron.common import Built, Operator, Rtp, Unresolvable +from iron.common.testing import Case, Testing + + +def _logits(op: "TopKRouter") -> dict: + """Rows of logits, each with ties straddling its ``top_k``-th.""" + rng = np.random.default_rng(13) + logits = rng.normal(0, 3, (op.rows, op.experts)).astype(bfloat16) + for row in logits: + boundary = np.sort(row.astype(np.float32))[::-1][op.top_k - 1] + row[rng.choice(op.experts, min(4, op.experts), replace=False)] = boundary + return dict(given={op.logits: logits}) + + +class TopKRouter(Operator): + """Each of ``rows`` rows of ``experts`` router logits to its ``top_k`` + expert ids and weights: ``weights`` holds a row's ``i``-th weight at + ``2 * i``, a zero after each. + """ + + test = Testing( + [ + Case(dict(experts=32, top_k=8), id="one_row"), + Case(dict(experts=64, top_k=6, rows=16, pairs=4), id="rows", lower=True), + Case(dict(experts=32, top_k=8, rows=256, pairs=8), id="chunk", bench=True), + ], + draw=_logits, + ) + + experts: int = param() + top_k: int = param() + rows: int = param(default=1) + weights_width: int = param(derive=lambda op: 2 * op.top_k, repr=False) + pairs: int = tunable(domain=widths) + + logits = In(rows, experts, obj_shape=(experts,), per=(pairs,)) + ids = Out(rows, top_k, dtype=np.int32, obj_shape=(top_k,), per=(pairs,)) + weights = Out(rows, weights_width, obj_shape=(weights_width,), per=(pairs,)) + valid = per_call(bounds=rows) + count = per_call(derive=lambda op: ceildiv(op.valid, op.pairs)) + + def validate(self) -> None: + if not 1 <= self.top_k <= self.experts: + raise ValueError( + f"TopKRouter: top_k ({self.top_k}) must be from 1 to experts " + f"({self.experts})" + ) + + def resolve(self, dev): + pairs = self.pairs or max( + p for p in range(1, dev.cols + 1) if self.rows % p == 0 + ) + if self.rows % pairs or pairs > dev.cols: + raise Unresolvable( + f"TopKRouter: {self.rows} rows over {pairs} pairs of cores, on a " + f"device of {dev.cols} columns" + ) + return self.replace(pairs=pairs) + + def compatible(self) -> None: + config = self.layout() + config.select() + config.route() + + def select(self): + return sample.sample_select( + slice_size=self.experts, chunk=self.experts, k_max=self.top_k + ) + + def route(self): + return moe.router_top_k(slice_size=self.experts, top_k=self.top_k) + + def build(self) -> Built: + select, route = self.select(), self.route() + words = sample.summary_words(self.experts, self.top_k) + summary_ty = np.ndarray[(words,), np.dtype[np.int32]] + + def select_body(of_x, of_sum, kernel, row, state, rtp): + n = rtp.read()[self.count] + for _ in range_(n): + x, summary = of_x.acquire(1), of_sum.acquire(1) + kernel(x, row, state, summary) + of_x.release(1) + of_sum.release(1) + + def route_body(of_sum, of_ids, of_weights, kernel, rtp): + n = rtp.read()[self.count] + for _ in range_(n): + summary = of_sum.acquire(1) + ids, weights = of_ids.acquire(1), of_weights.acquire(1) + kernel(summary, ids, weights) + of_sum.release(1) + of_ids.release(1) + of_weights.release(1) + + workers, rtps, lanes = [], [], ([], [], []) + for _ in range(self.pairs): + of_x = ObjectFifo(self.logits.obj_type, depth=self.logits.depth) + of_ids = ObjectFifo(self.ids.obj_type, depth=self.ids.depth) + of_weights = ObjectFifo(self.weights.obj_type, depth=self.weights.depth) + of_sum = ObjectFifo(summary_ty, depth=2) + for lane, end in zip( + lanes, (of_x.prod(), of_ids.cons(), of_weights.cons()) + ): + lane.append(end) + # The select kernel reads only the row's top-k: always this one's. + row = Buffer( + np.ndarray[(sample.ROW_WORDS,), np.dtype[np.int32]], + initial_value=sample.draw_row(1.0, self.top_k, 0), + ) + state = Buffer( + np.ndarray[(sample.SELECT_STATE_WORDS,), np.dtype[np.int32]], + initial_value=np.zeros(sample.SELECT_STATE_WORDS, np.int32), + ) + pair = [Rtp([self.count]), Rtp([self.count])] + rtps += pair + workers += [ + Worker( + select_body, + [of_x.cons(), of_sum.prod(), select, row, state, pair[0]], + ), + Worker( + route_body, + [ + of_sum.cons(), + of_ids.prod(), + of_weights.prod(), + route, + pair[1], + ], + ), + ] + for operand, ends in zip((self.logits, self.ids, self.weights), lanes): + operand.bind(ends) + return Built(workers, rtps) + + def ops(self) -> int: + return 0 # a selection and a small softmax: its figure is latency + + def reference(self, logits): + """``(ids, weights)``, ``router_top_k_ref`` row by row.""" + logits = np.asarray(logits).reshape(self.rows, self.experts) + ids, weights = zip(*(moe.router_top_k_ref(row, self.top_k) for row in logits)) + return np.stack(ids), np.stack(weights) + + def tolerance(self) -> Tolerance: + return Tolerance.exact() diff --git a/iron/tests/operators/test_core_streams.py b/iron/tests/operators/test_core_streams.py new file mode 100644 index 000000000..e572d5b5a --- /dev/null +++ b/iron/tests/operators/test_core_streams.py @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Experts' array, built device-free: every operand bound, and no core +reading more streams than its two input DMA channels. +""" + +import inspect + +import pytest +from aie.helpers.util import flatten_fn_args +from aie.iron import Worker, kernels +from aie.iron.dataflow.objectfifo import ObjectFifoHandle + +from iron.operators.experts import Experts + +# The kernel and the parameters its build() passes it, which an older +# mlir-aie's lacks (blocks=, from 7807032). Kept in step with that call by +# hand: a parameter added there belongs here too. +KERNELS = { + Experts: ( + "moe", + "experts", + {"hidden", "width", "num_experts", "top_k", "rows", "cols"}, + ), +} + +CASES = [(cls, case) for cls in (Experts,) for case in cls.test.cases] + + +@pytest.mark.parametrize( + "cls,case", CASES, ids=[f"{c.__name__}-{i}" for i, (c, _) in enumerate(CASES)] +) +def test_each_core_reads_at_most_two_streams(cls, case, npu2): + module, name, params = KERNELS[cls] + kernel = getattr(getattr(kernels, module, None), name, None) + if kernel is None or params - inspect.signature(kernel).parameters.keys(): + pytest.skip(f"mlir-aie has no kernels.{module}.{name} taking {sorted(params)}") + view = cls(**case.kwargs).resolved(npu2).layout() + built = view.build() + for operand in (*view.inputs, *view.outputs): + assert operand.handles + for worker in (o for o in built.objects if isinstance(o, Worker)): + reads = [ + a + for a in flatten_fn_args(worker.fn_args) + if isinstance(a, ObjectFifoHandle) and not a._is_prod + ] + assert len(reads) <= 2 diff --git a/iron/tests/operators/test_rejected_shapes.py b/iron/tests/operators/test_rejected_shapes.py index a8af4a4aa..83bb73c24 100644 --- a/iron/tests/operators/test_rejected_shapes.py +++ b/iron/tests/operators/test_rejected_shapes.py @@ -41,6 +41,7 @@ from iron.operators.resample.op import PatchPositions from iron.operators.rms_norm import RMSNorm from iron.operators.rope import RoPE +from iron.operators.router import TopKRouter from iron.operators.sample import Sample from iron.operators.selective_scan import SelectiveScan from iron.operators.softmax import Softmax @@ -587,6 +588,17 @@ def test_flm_decode_layer_rejects_unknown_configurations(kwargs, match): DecodeLayer(**kwargs) +def test_top_k_router_refuses_more_experts_than_it_routes_between(): + with pytest.raises(ValueError, match="top_k \\(9\\) must be from 1"): + TopKRouter(experts=8, top_k=9) + + +@pytest.mark.parametrize("rows, pairs", [(6, 4), (16, 9)]) +def test_top_k_router_refuses_rows_its_pairs_do_not_split(rows, pairs, npu2): + with pytest.raises(Unresolvable, match="pairs of cores"): + TopKRouter(experts=32, top_k=8, rows=rows, pairs=pairs).resolved(npu2) + + @pytest.mark.parametrize( "make, match", [ diff --git a/iron/tests/support/qwen3_moe.py b/iron/tests/support/qwen3_moe.py new file mode 100644 index 000000000..0942983db --- /dev/null +++ b/iron/tests/support/qwen3_moe.py @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3-MoE at a toy shape, and its checkpoint's tensors, for its reference +and device tests. +""" + +from iron.applications.qwen3_moe.model import Qwen3MoeConfig + +from .llama_model import shapes + +#: Qwen3-MoE's proportions at toy size: GQA, q and k normed, eight experts +#: of which a token takes two, an untied head. +TOY = Qwen3MoeConfig( + vocab_size=1024, + hidden_size=512, + num_hidden_layers=2, + num_attention_heads=8, + num_key_value_heads=2, + head_dim=128, + intermediate_size=1024, + rope_theta=1000000.0, + max_seq_len=256, + prefill_chunk=256, + num_experts=8, + num_experts_per_tok=2, + moe_intermediate_size=256, + norm_topk_prob=True, +) + + +def names(c: Qwen3MoeConfig) -> dict[str, tuple[int, ...]]: + """Qwen3-MoE's tensors: Llama's, q and k normed, each layer's MLP its + router and experts, and an untied head. + """ + out = shapes(c) + out["lm_head.weight"] = (c.vocab_size, c.hidden_size) + E, M, D = c.hidden_size, c.moe_intermediate_size, c.head_dim + assert D is not None, "DecoderConfig derives head_dim" + for i in range(c.num_hidden_layers): + layer = f"model.layers.{i}" + out |= { + f"{layer}.self_attn.q_norm.weight": (D,), + f"{layer}.self_attn.k_norm.weight": (D,), + f"{layer}.mlp.gate.weight": (c.num_experts, E), + } + for p in ("gate_proj", "up_proj", "down_proj"): + del out[f"{layer}.mlp.{p}.weight"] + for j in range(c.num_experts): + expert = f"{layer}.mlp.experts.{j}" + out |= { + f"{expert}.gate_proj.weight": (M, E), + f"{expert}.up_proj.weight": (M, E), + f"{expert}.down_proj.weight": (E, M), + } + return out diff --git a/tests/applications/qwen3/reference/goldens/toy.npz b/tests/applications/qwen3/reference/goldens/toy.npz new file mode 100644 index 0000000000000000000000000000000000000000..cb939abf0bbe962d7ea46a1ba1e4543fed8181c8 GIT binary patch literal 40641 zcmZVlQ;;S+xG-u@+nBa(<7wNrZQHi(p0U!rcCGd8R8l#~K~hN$ zuKP|DWx&AcfBpIe{a+*gWzk?p^7H>MXur^YnVGROnR1z#v$1fQF|#spG1xnLf&4=L z6^Q)b@c)V+(*Li|)p!$FdixO*ey$}Z6DevGrJnHX&Er^uM5iu96Q;=&{+C9k`+JKF zh7?7rRkqss=EAE9t*E(BIJuIzGOmLWg9?a4m`cxJWYxAT zanU^a@tMbs2~d*yxF|T4dwV`13-d?9TuMb+>)*dTQJnJDaCs35D_C`cF+GH5$-6*j z5)lh_HrJIndgnn_ON?V|Ihqxe!nodoJN|+3GSHK)}o~bO}u=R({Of)HgH}BEw*VrRoAm8umy^Xs# zOGUJ>dvA7J7M|ijwoQ4v#X7^w5#w}UR}_ZYcJP*#EX_#;!I`cU#X3qUad!=s z=K{(rBqmB9m3TwzcnbH6hOxvC{zhjqh^aq31D+dJ@R8my#M4oyEJ~pxT;qVO6WDYM zdsdwRoIM!)LX515{(ucVy2gnTldw|JvU;qqmC3NGTfq$SFN`P9hVSL}_a%;gaS+#cWM+O~ zA9xNT!ub#!4JhUo?-n~5Fq~pIU5Oz}X!fl4JptyBF`)v7u=(rv$QJv03tl}pbN6cNEDn)bV{}~`lhIICdqVZt? zB$=L@{+8;jdwh?RUDpc0ly!Yj+Ih|tPG&gCNfDTuN$@4rEGwb*>YZLY6Rqq)Y%dt2OGDnIY*Y84iL;Iuok=<#L~2-AMQy{L)bQa;kJ$W{n&6yM_p7;x(5JsF4b75+bLuc&P73(>v0=}g?_7s(q6|a6EgwVb8 zIVUt__-UeaRi_BWt{}7&giY@$4+;yX@;xP>N6Q}YRX5Lmo6sgnV*?kDL$MjdGz!+=x+q)(P>P}g7`k6(XHVPQoT@!Y zcxq5*4_i{6qm|i+1RUiCtQtikKDd4^v2tyT`eEzN80}BcnNbr%&984d=cuL;%kSk* zbuKiS>jEKNlP@BF|A3Tq`_m=9)g!);mmWCpNI~nT#SY}Kl2P=e0iHGc7HiU0Jt$JF zyH0_9mA|B2sftH+1|m~&jqN1(=D2#1bk@e0miHqXPYxRv$|A#eY{|dAG_00|TqJ24 zfBn{K;Q@w=fsRpTVQKwKgY52>7QOr!{alU!eEtDhRof~YEArB;qmOCjOT%&&4o{V_ z{N0`Mlxi2X+H?iF7#kiO(~{$;lEgYnf7%C;9G<9LOm?a4NAUB2WpjE&)@UvhP&wu{Akd<`gC)S`ITwHcAz6Rz|&d6L7yxwRgWR^MxZG6ghRiB4>*jSqx@KVa%|J$J~-5aHVVR zvba}95CycTaC&hQhWnY=RPZ(7$@xf@SBfO-TmG?`XC8q*?`(D!b`Up-P`gJ3)EQU) z7%!tchUXkBD%AhTY;Dhtx)8kD4`ooYULpn2A(hfzp)y>j!9g`>;l94N`%Ni*Evg6x z4dZ0U`7Dbfqp}FFvMH=mhHT7vJ=m3XMvxtQibYNEb*w@Caf)9c@Nap{18p?!sU$?O zH?~+$#Y)2r^BVEH)v@>6Z?NBsd`L@f>c!?>T>&X7A(NFduNQYbBW=oI7M+ zcyH$_{$#zWy?U;FGD!K<*@hxe-*dy@BFulW#=0OaSJ=d;%n=p)TL`kx z4QRvl90Z{w`oCiN2l(XvK)n1l7$zNidu+{EtH``q`-r*8F;g>e4cey9774=v!O_b=(R6_|rqm6I)cq3^maF!#Mg3*URkvXdgXrL z+a!!X8#dMVH7KGLAdluQfRt6{__88#HW|R)4=Yo1#=Gcf6WiqJ5K~5|lFOkmE`zp0 zCoD`n!$_%sGaV?9jhfHWpL71ib%r{rXukc6h;1h zq=qEUbTzj)-?eui0}HC}xxYozBs*(b*}3i9;(#V#bR?=$e)#-WMVW^O&aL{YAmWAN zdl;~=ia-O5=xwIXZZ~eIB*lCD0PRi3Ez&c_9mA&&ve@wGgEeJhdd9n&%tkOFK2mj= zv*PrZz#aPT27j_QHZ|UHNf&1oJL#?#H1ojms;5PDBZ2GRa|7gk^MSf)Sbf5J@7@{xU;uqZB$P|kQ7I#tPZ(xc|x z#G%Um)Zj^rQ~!as);)-*IgmE+?g=}t}mR)TMNV?+LTh&lD|F47ftLaVXuDXc*sR`xgrXZ>aQ z7v>IK(8hw^>J}v^UMu5>6lTC8Gn4r+YMRl5zRsz@1AJC1pTu_+Q6FtD>hY5cNUtwa zv?fNAFJDCeXEoTg-%uFc((iuX7=&O{H)_X6DMa@3DX7{+r?U0dJYTpDBy$p9Z?hqK z7P{$>T(ERSwk6@q7h33I)~Wu?4bj0LX|N+6aQyFSaB5l=+l|LGLJz|nckci+(Um_c zq;Zhu_Es#Ax-LWhxL`zAZG~Q5P`PW(h-#VwmR6!Tr|lEBFWhDOv)|spp3Ddf^By8C zq{GO<2lm?zZw#_wbC~Xga3(dm0n-a8LOQHJCKH%^#aig+mgS!*?S2-!Plc|I)OQju zuPm=U2ZW96*N8BZbHf-~^hHNciN4mSzX)f3BDa<-*Ez4oSg09}<+_MtrtN_$`s0rG zEPteGmn^Cs-V@wH2SC_6bE5P4Le=xCe<}Y3!U* zMwFh(rnoYqT|#xFa?06#7MA!7eH!w$@$lovA4c9A7`-pJ%&C;d({aYR6w zLH8IE%?SiiWsh{#jftYr6{pE|tXTXx@@9UD#K&##ap?Hlg18;v;U9S&`ZrRP@_*ng^XD*8OwwdA`4EHGH)D{=Y#@^8x7vRi zLA0&>NWETY8su@98n}@)Us5QtD{yi*C8!54{Y2&aVd6KmlB>B?+Q2Sy zH}-eo=M~e*?y{((AALcN1{QTPdel6W1ad7z=m*peD$pjV$wd@2l09_}Ewy-68vi(K@r* zBGu>sWn+}-cA=rFn;+;AwGo}zi@FfthflA?2r>M*O61Rv!K+{hN4ITr)NJI;{Qb5p z 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 literal 0 HcmV?d00001 diff --git a/tests/applications/qwen3/reference/test.py b/tests/applications/qwen3/reference/test.py new file mode 100644 index 000000000..f931c0540 --- /dev/null +++ b/tests/applications/qwen3/reference/test.py @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3's graph against Hugging Face's Qwen3 in float32 over the same +random weights at a toy shape, its logits recorded in ``goldens/``, +device-free: the shared decoder's tests (``Decoder``) with q and k normed +per head. +""" + +from iron.applications.qwen3.model import Qwen3, Qwen3Config +from iron.tests.support.llama_model import shapes +from iron.tests.support.llama_reference import Decoder + +#: Qwen3's proportions at toy size: GQA, a head_dim of its own, a tied head. +TOY = Qwen3Config( + vocab_size=1024, + hidden_size=512, + num_hidden_layers=2, + num_attention_heads=8, + num_key_value_heads=2, + head_dim=128, + intermediate_size=1024, + rope_theta=1000000.0, + max_seq_len=256, + prefill_chunk=256, +) + + +class TestQwen3(Decoder): + model, config, model_type, name = Qwen3, TOY, "qwen3", "toy" + seed = 3 + + @classmethod + def names(cls): + c = cls.config + out = shapes(c) + for i in range(c.num_hidden_layers): + attention = f"model.layers.{i}.self_attn" + out |= { + f"{attention}.q_norm.weight": (c.head_dim,), + f"{attention}.k_norm.weight": (c.head_dim,), + } + return out diff --git a/tests/applications/qwen3/test.py b/tests/applications/qwen3/test.py new file mode 100644 index 000000000..1a1dcd9e6 --- /dev/null +++ b/tests/applications/qwen3/test.py @@ -0,0 +1,162 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3 on the NPU, 1.7B and 0.6B, from the real checkpoints: speed, +accuracy against the float32 reference, determinism, and past one chunk: a +prompt of two and a chat turn (the end of the caches is ``long_context``'s). +Every test runs on each checkpoint, which its id names; each one's model is +compiled and loaded once and every test calls it in-process. + +``--random-weights SEED`` runs it on weights drawn in the checkpoint's +shapes (only its header is read) and random prompts, on a host without the +tokenizer, and ``--max-seq-len`` at another context. +""" + +import aie.utils as aie_utils +import numpy as np +import pytest +from ml_dtypes import bfloat16 + +from iron.applications.qwen3 import Qwen3 +from iron.lm import Checkpoint +from iron.lm.testing import ( + check_accuracy, + check_chat_turn, + check_determinism, + check_device_sampling, + check_generation, + load_from_flags, + require, + weights_dir, +) + +CHECKPOINTS = { + "qwen3_1.7b": weights_dir("qwen3-1.7b") / "model.safetensors.index.json", + "qwen3_0.6b": weights_dir("qwen3-0.6b") / "model.safetensors", +} + +pytestmark = pytest.mark.supported_devices("npu2") + + +@pytest.fixture(scope="module", params=list(CHECKPOINTS), ids=str) +def load(request): + weights = CHECKPOINTS[request.param] + return load_from_flags(request, Qwen3, weights) + + +@pytest.fixture(scope="module") +def model(load): + """The model, compiled and loaded once; the runtime is released after its + checkpoint's last test. + """ + yield load() + if aie_utils.DefaultNPURuntime is not None: + aie_utils.DefaultNPURuntime.cleanup() + + +# KL(fp32 CPU || NPU) over 20 steps of 1.7B measured mean 0.011, p90 0.033, max 0.055. +MAX_KL = {"Mean": 0.03, "P90": 0.08, "Max": 0.2} + + +GENERATION = pytest.mark.parametrize( + "prompt_len,num_tokens", + [ + pytest.param( + p, + n, + # The shortest per PR; the rest nightly. + marks=[ + pytest.mark.bench, + *([] if (p, n) == (13, 1) else [pytest.mark.extensive]), + ], + ) + for p in (1024, 13) + for n in (40, 1) + ], + ids=[f"prompt_{p}_tokens_{n}" for p in (1024, 13) for n in (40, 1)], +) + + +@pytest.mark.extensive +@pytest.mark.supported_devices("npu1", "npu2") +class TestEachStep: + """The form NPU1 runs: each step its own dispatch of one xclbin, the + host drawing each token. Its model is built and dropped with the class, + first: after any other test it would be held beside the module's + full-ELF one. + """ + + @pytest.fixture(scope="class") + def model(self, load): + model = load(full_elf=False) + assert not model.device_loop + yield model + if aie_utils.DefaultNPURuntime is not None: + aie_utils.DefaultNPURuntime.cleanup() + + def test_qwen3_each_step_accuracy(self, model, record_property): + check_accuracy(model, MAX_KL, 20, 256, record=record_property) + + def test_qwen3_each_step_determinism(self, model, record_property): + check_determinism(model, 4, 3, 128, record=record_property) + + +@GENERATION +def test_qwen3(model, prompt_len, num_tokens, record_property): + check_generation(model, prompt_len, num_tokens, record=record_property) + + +# The device draws every token and starts every decode step itself; from the +# same seed its text is the host loop's. The figures are the device loop's. +@pytest.mark.extensive +@pytest.mark.bench +def test_qwen3_device_loop(model, record_property): + check_device_sampling(model, 1024, 100, record=record_property) + + +@pytest.mark.extensive +@pytest.mark.bench +def test_qwen3_accuracy(model, record_property): + check_accuracy(model, MAX_KL, record=record_property) + + +@pytest.mark.extensive +def test_qwen3_determinism(model, record_property): + check_determinism(model, record=record_property) + + +# 12000 characters of prompt.txt are 3262 tokens (confirmed against Qwen3's +# own tokenizer): a full chunk and most of a second, which attends over the +# first's caches. +LONG = 12000 + + +@pytest.mark.extensive +def test_qwen3_accuracy_across_chunks(model, record_property): + """The prompt's two chunks and two decode steps after them, against the + float32 reference over the whole prompt (each step a forward over 3k+ + tokens on the host, so only a few). + """ + assert len(model.prompt(LONG)) > model.config.prefill_chunk + check_accuracy(model, MAX_KL, 3, LONG, record=record_property) + + +@pytest.mark.extensive +def test_qwen3_chat_turn(model, record_property): + """A turn of 1000 tokens reruns the second chunk alone.""" + check_chat_turn(model, LONG, 1000, record=record_property) + + +@pytest.mark.parametrize("name", list(CHECKPOINTS)) +def test_qwen3_lm_head_is_the_tied_embedding(name): + """The real checkpoint's unread ``lm_head.weight`` is byte-identical to + ``model.embed_tokens.weight``. + """ + weights = CHECKPOINTS[name] + require(weights, weights.with_name("config.json")) + tree = Checkpoint(weights).tree() + assert tree.lm_head.weight.dtype == bfloat16 + assert np.array_equal( + tree.lm_head.weight.view(np.uint16), + tree.model.embed_tokens.weight.view(np.uint16), + ) diff --git a/tests/applications/qwen3_embedding/reference/goldens/toy.npz b/tests/applications/qwen3_embedding/reference/goldens/toy.npz new file mode 100644 index 0000000000000000000000000000000000000000..2ec6542f1d0b8e4f97c5875f0c370822cd313456 GIT binary patch literal 4556 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 literal 0 HcmV?d00001 diff --git a/tests/applications/qwen3_embedding/reference/test.py b/tests/applications/qwen3_embedding/reference/test.py new file mode 100644 index 000000000..b791f0201 --- /dev/null +++ b/tests/applications/qwen3_embedding/reference/test.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3-Embedding's graph against Hugging Face's Qwen3 model in float32 +over Qwen3's toy, its last hidden state recorded in ``goldens/``, +device-free: a query behind its instruction, closed by end of text. +""" + +import numpy as np +import pytest + +from iron.applications.qwen3_embedding.model import Qwen3Embedding +from iron.lm import OnHost +from iron.lm.golden import HuggingFace +from iron.tests.support.llama_model import checkpoint +from tests.applications.qwen3.reference import test as qwen3 +from tests.applications.qwen3_reranker.reference.test import Words + +#: The end of text, within the toy's vocab. +EOS = 5 + + +class LastHidden(HuggingFace): + """transformers' Qwen3 model without its head: the last token's final + hidden state. + """ + + model_class = "AutoModel" + + def logits(self, tokens) -> np.ndarray: + import torch + + with torch.no_grad(): + out = self.model(torch.tensor([list(tokens)]), use_cache=False) + return out.last_hidden_state[0, -1].numpy() + + +class Host(OnHost, Qwen3Embedding): + """The embedder, its calls the graph's reference.""" + + profile = None + end_of_text = EOS + + +@pytest.mark.parametrize("task", [None, "Given a web search query"]) +def test_an_embedding_is_the_last_hidden_state_unit_length(npu2, golden, task): + names = {k.removeprefix("model."): v for k, v in qwen3.TestQwen3.names().items()} + weights = checkpoint(qwen3.TOY, names).tree("random") + reference = golden( + "toy", lambda: LastHidden.build("qwen3", qwen3.TOY, weights), qwen3.TOY, weights + ) + model = Host(qwen3.TOY, weights) + model.tokenizer = type("Tokenizer", (), {"encoding": Words()})() + text = "what lights the north sky at night" + got = model.embed(text, task) + query = text if task is None else f"Instruct: {task}\nQuery:{text}" + want = reference.logits([*Words().encode(query), EOS]) + want = want / np.linalg.norm(want) + assert np.isclose(np.linalg.norm(got), 1) + assert got @ want > 0.999, got @ want diff --git a/tests/applications/qwen3_embedding/test.py b/tests/applications/qwen3_embedding/test.py new file mode 100644 index 000000000..43675a972 --- /dev/null +++ b/tests/applications/qwen3_embedding/test.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3-Embedding 0.6B on the NPU against Hugging Face's own model in +float32, from one compile: the unit-length last hidden state of a query +behind its instruction, of a document, and of documents of four prompt +chunks and of the native 32768-token context's last, recorded in +``golden.npz`` beside the checkpoint (``pytest --record-goldens``). +""" + +import aie.utils as aie_utils +import numpy as np +import pytest + +from iron.applications.qwen3_embedding import Qwen3Embedding +from iron.lm.model import prompt_text +from iron.lm.testing import require, weights_dir + +DIRECTORY = weights_dir("qwen3-embedding-0.6b") +TASK = "Given a web search query, retrieve relevant passages that answer the query" + +# Each text, the task it is a query of (None: a document), and the least +# cosine its embedding reaches against transformers'. +TEXTS = { + "query": ("What is the capital of China?", TASK, 0.99), + "document": ( + "The capital of China is Beijing, its seat of government.", + None, + 0.99, + ), + # 7625 tokens and the end of text: four prompt chunks. + "four_chunks": (prompt_text(28000), None, 0.99), + # 32682 tokens and the end of text: the native context's last chunk. + "native_context": (prompt_text(120000), None, 0.99), +} + + +@pytest.fixture(scope="module") +def model(): + require(DIRECTORY / "model.safetensors", DIRECTORY / "tokenizer.json") + yield Qwen3Embedding.load(DIRECTORY) + aie_utils.cleanup_npu_runtime() + + +@pytest.mark.supported_devices("npu1", "npu2") +@pytest.mark.parametrize( + "name", + [ + "query", + "document", + pytest.param("four_chunks", marks=pytest.mark.extensive), + pytest.param("native_context", marks=pytest.mark.extensive), + ], +) +def test_qwen3_embedding_0_6b_accuracy(model, name, record_property): + text, task, min_cosine = TEXTS[name] + query = text if task is None else f"Instruct: {task}\nQuery:{text}" + tokens = [*model.tokenizer.encoding.encode(query), model.end_of_text] + assert len(tokens) <= model.config.max_seq_len + got = model.embed(text, task) + want = model.cpu_reference().logits(tokens) + cosine = float(got @ want) + record_property("Tokens", len(tokens)) + record_property("Cosine", cosine) + np.testing.assert_allclose(np.linalg.norm(got), 1, rtol=1e-3) + assert cosine >= min_cosine, cosine diff --git a/tests/applications/qwen3_moe/reference/goldens/toy.npz b/tests/applications/qwen3_moe/reference/goldens/toy.npz new file mode 100644 index 0000000000000000000000000000000000000000..f5a567250ed62cb3c0e056532393cc2fe9bce212 GIT binary patch literal 40615 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 literal 0 HcmV?d00001 diff --git a/tests/applications/qwen3_moe/reference/test.py b/tests/applications/qwen3_moe/reference/test.py new file mode 100644 index 000000000..484baa32d --- /dev/null +++ b/tests/applications/qwen3_moe/reference/test.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3-MoE's graph against Hugging Face's Qwen3MoE in float32 over the +same random weights at a toy shape, its logits recorded in ``goldens/``, +device-free: the shared decoder's tests (``Decoder``). +""" + +import dataclasses + +import pytest + +from iron.applications.qwen3_moe.model import Qwen3Moe, Qwen3MoeConfig +from iron.tests.support.llama_reference import Decoder +from iron.tests.support.qwen3_moe import TOY, names + + +class TestQwen3Moe(Decoder): + model, config, model_type, name = Qwen3Moe, TOY, "qwen3_moe", "toy" + recorded = dict(num_experts=64) + # One whose bf16 router picks the experts float32 does at every step. + seed = 0 + + @classmethod + def names(cls): + assert isinstance(cls.config, Qwen3MoeConfig), "Qwen3-MoE's shape" + return names(cls.config) + + +@pytest.mark.parametrize( + "change", + [ + dict(norm_topk_prob=False), + dict(decoder_sparse_step=2), + dict(mlp_only_layers=(1,)), + ], +) +def test_unnormalized_weights_or_a_dense_layer_is_refused(change): + with pytest.raises(ValueError, match="Qwen3MoeConfig"): + dataclasses.replace(TOY, **change) diff --git a/tests/applications/qwen3_moe/test.py b/tests/applications/qwen3_moe/test.py new file mode 100644 index 000000000..039acf016 --- /dev/null +++ b/tests/applications/qwen3_moe/test.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3-MoE on the NPU at a toy shape, on drawn weights: a prompt chunk and +decode steps, each call's chosen experts staged by the host between the +router and the experts (``iron.on_host``), held to the graph's own +reference on the host. Needs no checkpoint; the decoders suite runs the +real 30B nightly. +""" + +import dataclasses + +import numpy as np +import pytest + +from iron.applications.qwen3_moe.model import Qwen3Moe +from iron.lm.generation import divergence +from iron.tests.support.llama_model import checkpoint +from iron.tests.support.qwen3_moe import TOY, names + +SEED = 7 +# A chunk's router logits are flm.ProjectionGEMM's (N a multiple of 64), and +# a bounded row of its ids a 16-byte unit: 64 experts, 4 a token, not TOY's 8 and 2. +MOE = dataclasses.replace(TOY, num_experts=64, num_experts_per_tok=4) + +pytestmark = pytest.mark.supported_devices("npu2") + + +def test_paged_experts_follow_the_graph_reference(): + """A 37-token prompt chunk, then three decode steps: each call's next-token + distribution within KL 0.1 of the graph's reference, as long_context's deep + checks hold a decoder to it, its top token the same. + """ + model = Qwen3Moe(MOE, checkpoint(MOE, names(MOE)).tree("random", SEED)) + model.random_weights = SEED + model.load_versions() + reference = model.cpu_reference() + tokens = np.random.default_rng(SEED).integers(0, MOE.vocab_size, 40).tolist() + for n in (37, 38, 39, 40): + kl, top1 = divergence(reference.logits(tokens[:n]), model.logits(tokens[:n])) + assert kl <= 0.1 and top1, f"{n} tokens: KL {kl}, top-1 {top1}" diff --git a/tests/applications/qwen3_reranker/reference/goldens/toy.npz b/tests/applications/qwen3_reranker/reference/goldens/toy.npz new file mode 100644 index 0000000000000000000000000000000000000000..43b693f2ffe4dc112931111352591a1fa011cca6 GIT binary patch literal 4280 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 Sll& list[int]: + return [zlib.crc32(w.encode()) % qwen3.TOY.vocab_size for w in text.split()] + + +def test_the_score_is_yes_over_no_after_the_prompt(npu2, golden): + weights = qwen3.TestQwen3.weights() + reference = golden( + "toy", + lambda: HuggingFace.build("qwen3", qwen3.TOY, weights), + qwen3.TOY, + weights, + ) + model = type("Host", (OnHost, Qwen3Reranker), {"profile": None})(qwen3.TOY, weights) + model.tokenizer = type("Tokenizer", (), {"encoding": Words()})() + query, document = "what lights the north sky", "charged particles from the sun" + got = model.score(query, document) + text = PROMPT.format( + instruction="Given a web search query, retrieve relevant passages " + "that answer the query", + query=query, + document=document, + ) + logits = reference.logits(Words().encode(text)) + [yes], [no] = Words().encode("yes"), Words().encode("no") + want = 1 / (1 + math.exp(logits[no] - logits[yes])) + assert np.isclose(got, want, atol=1e-2), (got, want) diff --git a/tests/applications/qwen3_reranker/test.py b/tests/applications/qwen3_reranker/test.py new file mode 100644 index 000000000..d58edca0c --- /dev/null +++ b/tests/applications/qwen3_reranker/test.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Qwen3-Reranker 0.6B on the NPU, from the real checkpoint: ``score()`` +over its model card's queries and documents, and over a document of two +prompt chunks, each against the score transformers' float32 logits give +(``golden.npz`` beside the checkpoint), from one compile. +""" + +import math + +import aie.utils as aie_utils +import pytest + +from iron.applications.qwen3_reranker import Qwen3Reranker +from iron.applications.qwen3_reranker.model import INSTRUCTION, PROMPT +from iron.lm.generation import divergence +from iron.lm.model import prompt_text +from iron.lm.testing import load_from_flags, weights_dir + +WEIGHTS = weights_dir("qwen3-reranker-0.6b") / "model.safetensors" + +pytestmark = pytest.mark.supported_devices("npu2") + +# The model card's queries and documents. +CAPITAL = "What is the capital of China?" +GRAVITY = "Explain gravity" +BEIJING = "The capital of China is Beijing." +FORCE = ( + "Gravity is a force that attracts two bodies towards each other. It gives " + "weight to physical objects and is responsible for the movement of planets " + "around the sun." +) +# 12000 characters of prompt.txt: the prompt is two chunks. +LONG = prompt_text(12000) + +PAIRS = { + "capital_beijing": (CAPITAL, BEIJING), + "capital_force": (CAPITAL, FORCE), + "gravity_beijing": (GRAVITY, BEIJING), + "gravity_force": (GRAVITY, FORCE), + "gravity_long": (GRAVITY, LONG), +} + +# The score's error, and its input's: |logit("yes") - logit("no")| apart +# from transformers'. The next-token KL beside them is held to Qwen3's. +MAX_SCORE_ERROR = 0.02 +MAX_MARGIN_ERROR = 0.5 +MAX_KL = 0.2 + + +@pytest.fixture(scope="module") +def model(request): + """The model, compiled and loaded once for every pair.""" + yield load_from_flags(request, Qwen3Reranker, WEIGHTS)() + aie_utils.cleanup_npu_runtime() + + +@pytest.fixture(scope="module") +def reference(model): + return model.cpu_reference() + + +def margin(logits, encode) -> float: + """How far "yes" leads "no" as the answer's first token.""" + [yes], [no] = encode("yes"), encode("no") + return float(logits[yes] - logits[no]) + + +@pytest.mark.parametrize("pair", list(PAIRS)) +def test_qwen3_reranker_0_6b_scores_as_transformers( + model, reference, pair, record_property +): + query, document = PAIRS[pair] + encode = model.tokenizer.encoding.encode + tokens = encode( + PROMPT.format(instruction=INSTRUCTION, query=query, document=document) + ) + want = reference.logits(tokens) + expected = 1 / (1 + math.exp(-margin(want, encode))) + got = model.score(query, document) + logits = model.logits(tokens) + error = abs(margin(logits, encode) - margin(want, encode)) + kl, _ = divergence(want, logits) + record_property("Score", got) + record_property("ExpectedScore", expected) + record_property("MarginError", error) + record_property("KL", kl) + assert abs(got - expected) <= MAX_SCORE_ERROR, f"score {got} against {expected}" + assert error <= MAX_MARGIN_ERROR, f"yes over no {error} from transformers'" + assert kl <= MAX_KL, f"KL {kl} > {MAX_KL}" From 51e4833a7dda3aecc46ed638fb92944c381e4112 Mon Sep 17 00:00:00 2001 From: Erika Hunhoff Date: Wed, 28 Oct 2026 15:53:42 -0600 Subject: [PATCH 21/21] Run Qwen3's family in the decoders and long_context suites Restores the decoders and long_context coverage Qwen3, Qwen3-Embedding, Qwen3-Reranker and Qwen3-MoE already have their own device tests for: every other model's checkpoint is held to its whole context, and every full-elf decoder but Gemma 4 is run in the decoders suite's accuracy and determinism checks. - Qwen3-MoE's 30B joins decoders (nightly, its toy test already runs per PR) and long_context, staged=False as its checkpoint isn't on every runner. Its p90 bound is 0.1 (bf16 transformers itself reaches 0.065 on its steps, near-tie experts); the figure is carried from before #222's numeric fixes and not yet re-measured here, so it's not asserted as current. test_determinism skips it: test_host_steps already holds a host step's calls exact, and a full redo is 15 minutes at this scale. - long_context's loaded fixture checked a form by device_loop, which a full ELF with a host step (Qwen3-MoE's paged experts) doesn't have; it now checks the decode step's packaging instead, what the form actually is. - Qwen3 0.6B/1.7B, Qwen3-Embedding and Qwen3-Reranker join long_context. Qwen3-Embedding's row is its final hidden state, held by its cosine to the reference, not KL (check_deep_decode/check_deep_prompt gain cosine=); Application gains deep_cosine and staged. Measured on npu2 (Strix Halo), native context (32768), teacher-forced: Qwen3 0.6B decode KL 0.0020, prompt chunk 0.0021 (39.9s); Qwen3 1.7B (full ELF and each-step alike) decode KL 0.0385, prompt chunk 0.0565 (40.2-40.9s); Qwen3-Embedding decode cosine 0.98611, prompt chunk cosine 0.99859 (31.8s), against its 0.97 bound (twice the decode's distance from 1); Qwen3-Reranker decode KL 0.0212, prompt chunk KL 0.0038 (39.9s), well under DEEP_KL. All ten cases pass. Co-Authored-By: Claude Sonnet 5 --- iron/lm/testing.py | 46 +++++++++++---- tests/applications/decoders/test.py | 27 ++++++++- tests/applications/long_context/test.py | 77 +++++++++++++++++++++++-- 3 files changed, 129 insertions(+), 21 deletions(-) diff --git a/iron/lm/testing.py b/iron/lm/testing.py index 57b747ed0..99854a00f 100644 --- a/iron/lm/testing.py +++ b/iron/lm/testing.py @@ -298,10 +298,14 @@ def check_chat_turn(model, chars: int, turn: int, num_tokens: int = 2, *, record assert not steps, f"the turn's logits differ from scratch's at steps {steps}" -def check_deep_decode(model, position: int, bound: float, chars: int = 1024, *, record): +def check_deep_decode( + model, position: int, bound: float, chars: int = 1024, *, cosine=False, record +): """A decode step at ``position``, deep in the caches, against the graph's own reference (``Graph.reference``), for a context transformers - would take too long over, within ``bound`` of its KL. + would take too long over, within ``bound`` of its KL, or with + ``cosine`` (a model whose row is a hidden state, not logits) at a + cosine of at least ``bound``. """ tokens = prompt(model, chars, 1) token = greedy(model.logits(tokens)) @@ -311,17 +315,16 @@ def check_deep_decode(model, position: int, bound: float, chars: int = 1024, *, got, _ = model(**values) got = got.numpy() expected, _ = model.reference(**values) - kl, top1 = divergence(expected, got) - record("DeepKL", kl) - record("DeepTop1", top1) - assert kl <= bound, f"at {position}: KL {kl} > {bound}" + _hold_deep(expected, got, bound, cosine, "Deep", f"at {position}", record) -def check_deep_prompt(model, rows: int, bound: float, chars: int = 1024, *, record): +def check_deep_prompt( + model, rows: int, bound: float, chars: int = 1024, *, cosine=False, record +): """The deepest prompt chunk (holding position ``max_seq_len - 1``), at most ``rows`` rows of it, against the graph's own reference within - ``bound`` of its KL, every earlier position filled as - ``check_deep_decode`` fills them. + ``bound`` of its KL (or ``cosine``, as ``check_deep_decode`` holds it), + every earlier position filled as ``check_deep_decode`` fills them. """ tokens = prompt(model, chars, 1) model.logits(tokens) @@ -344,10 +347,29 @@ def check_deep_prompt(model, rows: int, bound: float, chars: int = 1024, *, reco record("DeepPromptSeconds", time.perf_counter() - start) got = got.numpy() expected, _ = model.reference(ids, **values) + _hold_deep( + expected, got, bound, cosine, "DeepPrompt", f"the chunk at {begin}", record + ) + + +def _hold_deep( + expected, got, bound: float, cosine: bool, name: str, where: str, record +): + """``got``'s row against the reference's ``expected``: KL within + ``bound``, or with ``cosine`` a cosine of at least ``bound``; recorded + under ``name``. + """ + if cosine: + e = np.asarray(expected, np.float64).reshape(-1) + g = np.asarray(got, np.float64).reshape(-1)[: e.size] + value = float(e @ g / (np.linalg.norm(e) * np.linalg.norm(g))) + record(f"{name}Cosine", value) + assert value >= bound, f"{where}: cosine {value} < {bound}" + return kl, top1 = divergence(expected, got) - record("DeepPromptKL", kl) - record("DeepPromptTop1", top1) - assert kl <= bound, f"the chunk at {begin}: KL {kl} > {bound}" + record(f"{name}KL", kl) + record(f"{name}Top1", top1) + assert kl <= bound, f"{where}: KL {kl} > {bound}" @contextmanager diff --git a/tests/applications/decoders/test.py b/tests/applications/decoders/test.py index cf30ede60..8bc8f2d3c 100644 --- a/tests/applications/decoders/test.py +++ b/tests/applications/decoders/test.py @@ -16,6 +16,7 @@ import pytest from iron.applications.gemma4 import Gemma4 +from iron.applications.qwen3_moe import Qwen3Moe from iron.lm.testing import ( check_accuracy, check_chat_turn, @@ -25,10 +26,19 @@ weights_dir, ) +INDEX = "model.safetensors.index.json" + # Each decoder's class, checkpoint file, and the lengths of the long and the # short prompt it generates from. DECODERS = [ pytest.param((Gemma4, "gemma-4-e2b-it/model.safetensors", (256, 13)), id="gemma4"), + # 30B, its experts paged from the checkpoint: nightly only, a toy one per PR + # (tests/applications/qwen3_moe). + pytest.param( + (Qwen3Moe, f"qwen3-30b-a3b/{INDEX}", (1024, 13)), + id="qwen3_moe", + marks=pytest.mark.extensive, + ), ] pytestmark = pytest.mark.supported_devices("npu2") @@ -40,7 +50,10 @@ def decoder(request): runtime is released before the next decoder loads. """ cls, weights, prompts = request.param - load = load_from_flags(request, cls, weights_dir(weights)) + # CI holds every checkpoint here but the 30B's. + load = load_from_flags( + request, cls, weights_dir(weights), staged=cls is not Qwen3Moe + ) model = load() yield model, prompts model.release() @@ -49,6 +62,10 @@ def decoder(request): # Gemma 4 E2B on npu2 measured mean 0.0055, P90 0.0086, max 0.100 over 40 steps. MAX_KL = {"Mean": 0.03, "P90": 0.08, "Max": 0.2} +# bf16 transformers itself reaches p90 0.065 on Qwen3-MoE's steps: near-tie +# experts. Pre-#222's own figure; its golden is still missing here, so no +# run has re-measured it against the current numeric fixes. +MOE_KL = {**MAX_KL, "P90": 0.1} @pytest.mark.bench @@ -67,11 +84,14 @@ def test_generation(decoder, prompt, num_tokens, record_property): @pytest.mark.extensive @pytest.mark.bench def test_accuracy(decoder, record_property): - check_accuracy(decoder[0], MAX_KL, record=record_property) + bounds = MOE_KL if isinstance(decoder[0], Qwen3Moe) else MAX_KL + check_accuracy(decoder[0], bounds, record=record_property) @pytest.mark.extensive def test_determinism(decoder, record_property): + if isinstance(decoder[0], Qwen3Moe): + pytest.skip("15 min at 30B; test_host_steps holds a host step's calls exact") check_determinism(decoder[0], record=record_property) @@ -88,7 +108,8 @@ def test_accuracy_across_chunks(decoder, record_property): """ model = decoder[0] assert len(model.prompt(LONG)) > model.config.prefill_chunk - check_accuracy(model, MAX_KL, 3, LONG, record=record_property) + bounds = MOE_KL if isinstance(model, Qwen3Moe) else MAX_KL + check_accuracy(model, bounds, 3, LONG, record=record_property) @pytest.mark.extensive diff --git a/tests/applications/long_context/test.py b/tests/applications/long_context/test.py index ff10d54b7..1a1362c6d 100644 --- a/tests/applications/long_context/test.py +++ b/tests/applications/long_context/test.py @@ -24,6 +24,10 @@ from iron.applications.granite import Granite from iron.applications.granite_hybrid import GraniteHybrid from iron.applications.llama import Llama +from iron.applications.qwen3 import Qwen3 +from iron.applications.qwen3_embedding import Qwen3Embedding +from iron.applications.qwen3_moe import Qwen3Moe +from iron.applications.qwen3_reranker import Qwen3Reranker from iron.lm import CausalLM, Checkpoint from iron.lm.testing import ( check_deep_decode, @@ -38,6 +42,9 @@ # decode measured 0.002 to 0.015 just past the prompt, 0.009 to 0.033 at # 16k and 0.030 to 0.038 at 32k: it grows with the keys the step sums over. DEEP_KL = 0.1 +# Qwen3-Embedding's row is its final hidden state, held by its cosine to the +# reference, not its KL. +COSINE = 0.97 @dataclasses.dataclass(frozen=True) @@ -45,7 +52,9 @@ class Application: """A decoder application, a checkpoint its test runs and the forms it is held to here: the full ELF on NPU2 where ``full_elf``, and an xclbin a step on the ``each_step`` devices. CI holds every checkpoint, so one - missing there fails rather than skips. + missing there fails rather than skips, but where not ``staged``. A model + whose row is a hidden state, not logits, is held deep by its cosine, at + least ``deep_cosine``. """ model: type[CausalLM] @@ -55,6 +64,8 @@ class Application: full_elf: bool = True each_step: tuple[str, ...] = () deep_kl: float = DEEP_KL + deep_cosine: float | None = None + staged: bool = True @property def path(self) -> Path: @@ -102,6 +113,46 @@ def path(self) -> Path: "tokenizer.json", each_step=("npu1", "npu2"), ), + # Qwen3's decode measured 0.0020 (0.6B, native 32768) to 0.0385 (1.7B, + # native 32768), its deepest prompt chunk 0.0021 to 0.0565, on npu2. + "qwen3_0.6b": Application( + Qwen3, "qwen3-0.6b", "model.safetensors", "tokenizer.json", staged=False + ), + "qwen3_1.7b": Application( + Qwen3, "qwen3-1.7b", INDEX, "tokenizer.json", each_step=("npu1", "npu2") + ), + # Qwen3-Embedding's row is its final hidden state, held by its cosine: + # measured 0.98611 at its decode step at 32767 and 0.99859 over its + # deepest prompt chunk, the bound twice the decode's distance from 1. + "qwen3_embedding_0.6b": Application( + Qwen3Embedding, + "qwen3-embedding-0.6b", + "model.safetensors", + "tokenizer.json", + deep_cosine=COSINE, + ), + # Qwen3-Reranker's decode measured 0.0212 at 32767, its deepest prompt + # chunk 0.0038, well under DEEP_KL: it uses Qwen3's own head, not a + # cosine-held hidden state. + "qwen3_reranker_0.6b": Application( + Qwen3Reranker, + "qwen3-reranker-0.6b", + "model.safetensors", + "tokenizer.json", + staged=False, + ), + # 30B, its experts paged from the checkpoint; staged as decoders stages + # it. Its decode measured 0.069 at 40959 (top-1 swapped at a near tie, + # pre-#222's numeric fixes, not yet re-verified), its deepest prompt + # chunk 1.5e-5: twice the decode's, rounded up. + "qwen3_moe_30b_a3b": Application( + Qwen3Moe, + "qwen3-30b-a3b", + INDEX, + "tokenizer.json", + staged=False, + deep_kl=0.2, + ), } @@ -128,7 +179,7 @@ def test_nothing_grows_with_the_context(application, version): row per position: its size is affine in the context. """ app = APPLICATIONS[application] - require(app.path, app.path.with_name("config.json")) + require(app.path, app.path.with_name("config.json"), staged=app.staged) config = app.model.default_config(app.path) weights = Checkpoint(app.path).tree("unread") C = config.prefill_chunk @@ -196,14 +247,18 @@ def loaded(request): """ form, context = request.param app = APPLICATIONS[form.removesuffix("_each_step")] - load = load_from_flags(request, app.model, app.path, app.tokenizer) + load = load_from_flags( + request, app.model, app.path, app.tokenizer, staged=app.staged + ) fitted = app.model.fit(app.model.default_config(app.path), app.path).max_seq_len if context is not None and context >= fitted: pytest.skip(f"{app.directory} holds {fitted} positions on this host") each_step = form.endswith("_each_step") sized = {} if context is None else {"max_seq_len": context} model = load(full_elf=False if each_step else None, **sized) - assert model.device_loop is not each_step + # The packaging, not device_loop: a full ELF with a host step (Qwen3-MoE's + # paged experts) is one but does not loop on the device. + assert model.decode_step.plan.full_elf is not each_step yield app, model model.release() if aie_utils.DefaultNPURuntime is not None: @@ -215,7 +270,12 @@ def test_decode_deep_in_the_cache(loaded, record_property): """The deepest step the caches hold, at ``max_seq_len - 1``.""" app, model = loaded position = model.config.max_seq_len - 1 - check_deep_decode(model, position, app.deep_kl, record=record_property) + if app.deep_cosine is None: + check_deep_decode(model, position, app.deep_kl, record=record_property) + else: + check_deep_decode( + model, position, app.deep_cosine, cosine=True, record=record_property + ) @pytest.mark.extensive @@ -225,7 +285,12 @@ def test_prompt_deep_in_the_cache(loaded, record_property): """ app, model = loaded rows = model.config.prefill_chunk - check_deep_prompt(model, rows, app.deep_kl, record=record_property) + if app.deep_cosine is None: + check_deep_prompt(model, rows, app.deep_kl, record=record_property) + else: + check_deep_prompt( + model, rows, app.deep_cosine, cosine=True, record=record_property + ) @pytest.mark.extensive