Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3db2653
Fix fp16 GQA export emitting fp32 packed weights in fold passes
titaiwangms Jun 1, 2026
87abd27
Strip dead pre-pack weights in FoldConcatInitializersPass
titaiwangms Jun 1, 2026
4392d32
test(fold-concat): name the live-packed-result invariant in DCE assert
titaiwangms Jun 1, 2026
fbfd4cd
test(fold-concat): assert survived packed-QKV values are exact after DCE
titaiwangms Jun 1, 2026
d4db4c2
test(fold-concat): gate packed-QKV values through serialize→reload
titaiwangms Jun 2, 2026
a444699
test(fold-concat): add mean|abs| degeneracy assert + poison negative …
titaiwangms Jun 2, 2026
8d842d0
test: use IR-native ir.save instead of onnx.save in _fold_concat_test
titaiwangms Jun 3, 2026
c784a3e
test(dtype): assert warning fires on declared/const_value dtype disag…
titaiwangms Jun 1, 2026
f18b339
test(fp16): add e2e regression test guarding df203cc fold-pass dtype …
titaiwangms Jun 3, 2026
a079efe
test(fp16): make fp16 fold e2e fixture function-scoped for hermetic, …
titaiwangms Jun 4, 2026
fad723f
fix(tasks): stamp explicit present KV-cache output shapes for GQA
titaiwangms Jun 1, 2026
109a7c4
docs: add fp16/GQA ONNX-export gotchas skill + fp16 GQA fold-fix chan…
titaiwangms Jun 5, 2026
786aab7
style: apply lintrunner (ruff format + D205) to salvaged tests
titaiwangms Jun 5, 2026
285a246
docs: re-add GQA present-KV changelog entry + de-anchor SKILL.md from…
titaiwangms Jun 5, 2026
28578a3
fix(kv-cache): fail-closed on partial present-shape parameter sets
titaiwangms Jun 2, 2026
3b0fa13
Fail closed on contradictory initializer dtype (PR #351 review)
titaiwangms Jun 12, 2026
72cee28
Merge branch 'main' into fix/gqa-fp16-fold
titaiwangms Jun 12, 2026
3361f5d
Fix D205 docstring lint on test_raises_on_dtype_contradiction
titaiwangms Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions .agents/skills/mobius-onnx-export-gotchas/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
---
name: mobius-onnx-export-gotchas
description: Use when building/exporting ONNX models with the `mobius build` CLI (especially Phi-3 / Phi-3.5 or any model with `--execution-provider cuda` GQA fusion and/or `--static-cache`). Covers the current CLI syntax, the dtype flag values, the GQA-vs-static-cache interaction, how to verify fp16 GQA exports load in onnxruntime (the historical packed-QKV FLOAT32 load bug is fixed by the fp16 GQA fold-fix), and why fp16 GQA exports need VALUE-based weight checks (corr≈1.0 / norm), not just initializer count/dtype, to catch silently-zeroed packed-QKV weights.
---

# mobius ONNX export gotchas

## 1. CLI syntax (editable repo differs from older docs)
`mobius build` requires `--model <hf_id>` and takes the **output dir as a POSITIONAL** arg.
There is **no `-o` flag for `build`** (`-o` exists only on `build-gguf`).

```bash
mobius build --model microsoft/Phi-3.5-mini-instruct \
--dtype f16 --execution-provider cuda \
--external-data onnx --trust-remote-code \
/path/to/output_dir
```

- `--dtype` choices: `f16`/`float16`, `bf16`/`bfloat16`, `f32`/`float32`. **`fp16` is INVALID.**
- `--execution-provider` is an alias of `--ep`. `cuda` + fp16/bf16 triggers GQA fusion;
`default` keeps plain ONNX `Attention`.

## 2. `--static-cache` is incompatible with GQA fusion
`--static-cache` wraps each attention with `TensorScatter` (in-place KV cache for the **ONNX Attention**
op). That breaks the pattern the GQA rewrite matches, so combining
`--execution-provider cuda --static-cache` yields **0 GroupQueryAttention + N Attention + 2N TensorScatter**
(mobius prints: "GQA fusion expected … but found 0 GroupQueryAttention and N Attention nodes").

- **GQA model:** `--execution-provider cuda` **alone**. GQA's shared KV buffer
(`past_present_share_buffer`) is enabled at **runtime** via IO-binding past & present to the same
OrtValue — NOT via `--static-cache`.
- **ONNX-Attention + in-place cache:** `--execution-provider default --static-cache --max-seq-len N`.

## 3. FIXED: fp16 GQA export previously left packed-QKV weights as FLOAT32 → model wouldn't load
**Status: fixed (the fp16 GQA fold-fix).** Native fp16 Phi-3.5 GQA export now loads directly in the ORT
CUDA EP with **no manual post-cast** (32 GroupQueryAttention nodes, all-fp16 initializers). If you are on
that commit or later, you should not hit this — skip to the verification snippet below. The history is
kept here because old artifacts exported before the fix still carry fp32 packed weights.

### Symptom (pre-fix)
For an fp16 GQA export, a folded per-layer packed QKV weight
(`..q_proj.weight__k_proj.weight__v_proj.weight__axis_0__concat`) was emitted as **FLOAT32**, while its
MatMul's other input was fp16. onnxruntime then rejected the model at load on both CPU and CUDA EPs:

```
Type Error: Type parameter (T) of Optype (MatMul) bound to different types
(tensor(float16) and tensor(float)) in node (node_MatMul_*)
```

You'd also see at save time: `The value type for shape [H, 3H] is not known. Skipping serialization`.

### Root cause
`_cast_module_dtype` casts module params to fp16, but the resulting initializer `Value`s lose their
declared `.dtype` (it becomes `None`) while their `const_value` stays fp16. The fold passes
`FoldConcatInitializersPass` (`src/mobius/_passes/_fold_concat.py`) and `FoldTransposedInitializerPass`
(`src/mobius/_passes/_fold_transpose.py`) then defaulted the folded initializer's dtype to `FLOAT`,
serializing the packed QKV / transposed weights as fp32.

### The fix
A shared helper `initializer_dtype()` (`src/mobius/_passes/_dtype_utils.py`) resolves the effective dtype
from the declared type, **falling back to `const_value` when the type annotation was dropped** (preferring
the data dtype and warning on stale-metadata disagreement). Both fold passes use it to stamp the correct
dtype on the new initializer's `TensorType` and `LazyTensor`, and `FoldConcatInitializersPass` now also
skips folding before weights are loaded (mirroring `FoldTransposedInitializerPass`). A regression test
loads the fp16 GQA export in the ORT CPU EP to lock this in.

### Verify (still worth running on any fp16 build)
```python
import onnx
m = onnx.load("model.onnx", load_external_data=False)
fp32 = [i.name for i in m.graph.initializer if i.data_type == onnx.TensorProto.FLOAT]
print(len(fp32), "FLOAT32 initializers (should be 0 for fp16)")
```

### Convention (prevents the whole class from reappearing)
Any pass that **materializes a new initializer** must resolve its dtype via
`initializer_dtype()` (`src/mobius/_passes/_dtype_utils.py`), **never** `value.dtype or ir.DataType.FLOAT`.
The bug class originates in `_cast_module_dtype` dropping a `Value`'s declared `.dtype` (→ `None`) while its
`const_value` stays fp16; a bare `.dtype or FLOAT` fallback then silently mis-types the result as fp32. Fold
passes (`_fold_concat.py`, `_fold_transpose.py`) already follow this; mirror it in any future
initializer-producing pass. Siblings still reading `.dtype` directly remain exposed — a follow-up should
grep `_passes/` for `.dtype or ir.DataType` and consider re-stamping the type in `_cast_module_dtype` to kill
the class at source.

### Salvaging a stale pre-fix artifact (only if re-exporting is not an option)
Prefer re-exporting on the fixed code. If you must repair an old model, cast its FLOAT32 initializers to
fp16 and re-save. **Gotcha when re-saving with external data:** if you save with `location="X.data"` and
then rename the file, the references inside `model.onnx` still point to `X.data`. Either save directly
with `location="model.onnx.data"`, or rewrite each initializer's `external_data` `location` entry.

```python
import onnx, numpy as np
from onnx import numpy_helper, TensorProto
m = onnx.load("model.onnx", load_external_data=True)
for init in m.graph.initializer:
if init.data_type == TensorProto.FLOAT:
arr = numpy_helper.to_array(init).astype(np.float16)
init.CopyFrom(numpy_helper.from_array(arr, init.name))
onnx.save(m, "model.onnx", save_as_external_data=True, all_tensors_to_one_file=True,
location="model.onnx.data", size_threshold=1024, convert_attribute=False)
```

## 4. Always validate the export in ORT before profiling
Load the model on `CUDAExecutionProvider` and run one prefill + one decode `session.run`. Confirm:
(a) the expected attention op (`com.microsoft::GroupQueryAttention` vs `ai.onnx::Attention`),
(b) finite fp16 logits, (c) no FLOAT32 initializers for an fp16 build.

These checks are **necessary but NOT sufficient** for a fp16 GQA export — see §5. A model can pass all
three and still have silently-zeroed packed-QKV weights.

## 5. Verifying a fp16 GQA export: use VALUE-based weight checks, NOT initializer count/dtype
**A fp16 GQA export can be all-fp16, right-count, and still all-zeros — only a corr≈1.0 / norm≈126 VALUE
check on the packed QKV proves the weights are real.**

### Symptom
The GQA model loads cleanly (32 `GroupQueryAttention` nodes, all-fp16, finite logits) but generates
garbage (e.g. `holdou_(...artersarters`). Prefill logits come out ~3× the reference scale, with
`max|Δlogit|` ~50+ versus the reference.

### Root cause
The packed-QKV initializer is `Transpose(Concat(q, k, v, axis=0))`. If the fold passes
(`FoldConcatInitializersPass` / `FoldTransposedInitializerPass`) leave the packed-Concat output dtype
UNKNOWN / defaulted-to-fp32 while the data is fp16, the serializer **skips** it and it loads as
**near-zero** — the weights are silently dead. (This is the §3 failure mode; the upstream fix in
The fp16 GQA fold-fix stamps the fp16 dtype at the fold-pass source. A post-hoc cast is NOT a fix — it re-corrupts.)

### Why count/dtype checks fail (the trap)
The BROKEN export and the FIXED export can have the **same initializer count and the same fp16/fp32 dtype
ratio**, so neither is a validity signal. Worse, the fp16-init count is **not even stable across fixes**
— on Phi-3.5 it moved from ~293 down to ~197 (an unstripped intermediate carries the packed-QKV plus the
now-dead unpacked q/k/v source initializers; a safe dead-weight strip then removes the ~96 dead pre-pack
inits), with no bearing on correctness. Note the OLD broken export was *also* 197 fp16, so even a "right"
final count proves nothing. Counting initializers or checking "0 fp32 / all fp16" does **not** distinguish a
healthy model from a zeroed-weight one. §4(c) alone will pass a dead model. **Never gate on the count;
use the VALUE gate below.**

### Canonical verification (load-bearing, not optional)
VALUE-based per-slice check on each packed-QKV initializer against its source q/k/v weights:
- per-slice correlation **≈ 1.000** (broken ≈ 0.000), AND
- packed-QKV L2 norm **≈ 126.6** at layer 0 / mean(|abs|) **≈ 0.015** (broken ≈ 0.80 / ≈ 5e-6).

> ⚠️ **Use mean-of-ABS or norm — NEVER the signed mean.** The good model's *signed* mean is ~2.6e-6
> (near zero, because the weights are symmetric ±), which coincidentally looks just like the broken
> model's mean(|abs|) ~5e-6. Checking signed mean would **falsely flag the good model as broken** — this
> exact confusion has already caused a false alarm in this crew. Valid discriminators: mean(|abs|)
> (good ≈ 0.015 vs broken ≈ 5e-6) or L2 norm (good ≈ 126.6 vs broken ≈ 0.80).

Plus an end-to-end next-token greedy-argmax parity check vs the `attn_dynamic` reference (expect
**~19–20 / 20**). Isolated single-token divergences are fp16 dead-ties (reference top1−top2 gap = 0.0000),
not bugs. Optional hardening: assert **0 unused initializers** and that all N packed-QKV initializers are
present, to catch dead-weight OVER-stripping.

QA's `gqa_weight_integrity_gate.py` (`--self-check --strip-audit --scan-all`, per-layer corr/norm)
implements exactly this gate.

## 6. FIXED: GQA `present.*` KV-cache outputs declared the wrong `head_dim`
**Status: fixed (the GQA present-KV shape fix).** A native fp16 GQA export now declares
`present.{i}.{key,value}` with the correct `head_dim`, symmetric to its `past_key_values.{i}.*` inputs.

### Symptom (pre-fix)
The graph **output** `present.{i}.key/value` declared the wrong `head_dim` (e.g. `32` instead of the real
`96` on Phi-3.5) while the matching `past_key_values.{i}.*` **input** was correct (`96`). At load ORT logged
(once per key+value per layer — 64 on Phi-3.5):

```
[W ...MergeShapeInfo] Error merging shape info for output. 'present.0.key'
source:{-1,32,-1,96} target:{-1,32,-1,32}. Falling back to lenient merge.
```

Runtime still produced correct (96-wide) arrays via lenient merge, but any consumer that **trusts declared
shapes** (e.g. `onnxruntime-genai`) would see inconsistent past-vs-present KV cache types.

### Root cause
`GroupQueryAttention`'s contrib-op shape inference mis-derives the present `head_dim` (it does **not**
reproduce on the plain `Attention` op, which infers correctly). `_register_kv_cache_outputs`
(`src/mobius/tasks/_cache_utils.py`) added the present outputs with **no explicit shape**, so the buggy
inference won.

### The fix
`_register_kv_cache_outputs` now opt-in **stamps** `present.{i}.{key,value}` shape+dtype symmetric to the
past inputs when the caller passes `batch`/`num_kv_heads`/`key_head_dim`/`value_head_dim`/`total_seq_len`/
`dtype` (wired from `_causal_lm.py`). Omitting them preserves inference-only behavior, so the other ~10
callers are unaffected. The stamp survives `SymbolicShapeInferencePass` (policy `refine` only tightens
unknown dims; it won't replace a concrete `96` with a conflicting `32`).

### Verify
```python
import onnx
m = onnx.load("model.onnx", load_external_data=False)
d = lambda vi: [(x.dim_param or x.dim_value) for x in vi.type.tensor_type.shape.dim]
o = {v.name: v for v in m.graph.output}
print("present.0.key:", d(o["present.0.key"])) # head_dim must equal the past input's (e.g. 96, NOT 32)
```

### Known remaining (separate, pre-existing, harmless)
ORT still logs ~32 `Error merging shape info ... source:{-1,-1,3072} target:{-1,-1,1024}` warnings on the
GQA op's **internal hidden-state output** value_info (`v_*.GroupQueryAttention_*_0`, `1024`=32×32 vs the
correct `3072`=32×96). That value is **not** a declared graph I/O — runtime is correct and `onnxruntime-genai`
does not trust it — so it does not bite shape-trusting consumers the way the present-output bug did. Tracked
as a follow-up in the GQA rewrite emission path (not the KV-cache output path).
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### KV-cache present-shape: fail-closed on partial parameter sets

#### Fixed

- `_register_kv_cache_outputs` now **raises `ValueError`** when given a partial
set of present-shape parameters (1–5 of the six `batch`, `num_kv_heads`,
`key_head_dim`, `value_head_dim`, `total_seq_len`, `dtype`) instead of logging
a warning and proceeding. A partial set is always a wiring slip with no
legitimate use; the previous fail-open shipped a structurally-wrong model
(mis-derived `GroupQueryAttention` present `head_dim`) with only a log line.
Passing all six (stamp) or none (infer) is unaffected. (closes #341)
Comment thread
titaiwangms marked this conversation as resolved.

---

### fp16 GQA Export Fix

#### Fixed

- Native fp16 GroupQueryAttention exports (e.g. `microsoft/Phi-3.5-mini-instruct`
with `--dtype f16 --execution-provider cuda`) no longer emit fp32 packed-QKV /
transposed weights. Previously the fold passes (`FoldConcatInitializersPass`,
`FoldTransposedInitializerPass`) defaulted a folded initializer's dtype to
`FLOAT` when the source `Value`'s declared type had been dropped during fp16
casting, producing a model onnxruntime rejected at load with a
`MatMul` type-parameter error (`tensor(float16)` vs `tensor(float)`) on both
CPU and CUDA EPs. A new `mobius._passes._dtype_utils.initializer_dtype()`
helper now resolves the effective dtype from `const_value` when the type
annotation is missing, so fp16 GQA models load directly with no manual
post-cast.

---

### GQA Present KV-Cache Shape Fix

#### Fixed

- GroupQueryAttention exports now declare correct `present.{i}.key` /
`present.{i}.value` graph-output shapes and dtype. The GQA contrib op's shape
inference mis-derived the present KV `head_dim` (e.g. 32 instead of 96 on
`microsoft/Phi-3.5-mini-instruct`), so the present KV-cache outputs declared a
`head_dim` inconsistent with the (correct) `past_key_values` inputs. ORT logged
`Error merging shape info ... lenient merge` (64 warnings on Phi-3.5) and any
consumer that chains `present` → `past` and trusts declared shapes (e.g.
`onnxruntime-genai`) saw mismatched past-vs-present KV cache types. This is a
metadata / declared-shape correction only — runtime numerics are unchanged
(weights byte-identical, next-token parity 20/20). `_register_kv_cache_outputs`
now stamps the present KV outputs symmetric to the past inputs. Affects
GQA-fusion packed-QKV exports (Phi-3.5, Llama-3.2, Qwen2, Mistral, Phi-3-GQA).

---

### WebGPU Shape Op Support

#### Changed
Expand Down
54 changes: 54 additions & 0 deletions src/mobius/_passes/_dtype_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

"""Shared dtype helpers for graph passes that materialize new initializers.

Passes such as :class:`~mobius._passes.FoldConcatInitializersPass` and
:class:`~mobius._passes.FoldTransposedInitializerPass` pre-compute new
initializers from existing ones. They must stamp the *correct* dtype on the
result, otherwise an fp16 model can silently end up with fp32 weights that
onnxruntime rejects at load time (a MatMul binding fp16 and fp32 to the same
type parameter ``T``).
"""

from __future__ import annotations

import onnx_ir as ir


def initializer_dtype(value: ir.Value) -> ir.DataType | None:
"""Return the effective dtype of an initializer ``value``.

Uses the value's declared ``type`` dtype, but falls back to the dtype of its
``const_value`` when the type annotation is missing.

Graph building can drop the declared ``type`` on an initializer while its
actual tensor data (``const_value``) still carries the correct dtype. In
that situation, defaulting to ``ir.DataType.FLOAT`` would emit fp32 weights
into an otherwise fp16 model. Reading the dtype from ``const_value`` keeps
folded initializers consistent with the weights they are derived from.

When both a declared type and ``const_value`` are present but **disagree**,
this raises :class:`ValueError`: a declared type that contradicts the
serialized data is corrupt metadata with no legitimate use, so fail closed
(consistent with the fail-closed contract elsewhere in the export pipeline)
rather than silently picking one and shipping a structurally-wrong model.
The declared-is-``None`` fallback — the path this helper exists for — is
unaffected and never raises.

Returns ``None`` only when neither the declared type nor ``const_value`` is
available; callers decide on a final fallback.
"""
declared = value.dtype
const_dtype = value.const_value.dtype if value.const_value is not None else None

if declared is not None and const_dtype is not None and declared != const_dtype:
raise ValueError(
f"Initializer {value.name!r} declares dtype {declared} but its "
f"const_value data is {const_dtype}. A declared type that "
f"contradicts the serialized data indicates corrupt initializer "
f"metadata."
)
if declared is not None:
return declared
return const_dtype
53 changes: 53 additions & 0 deletions src/mobius/_passes/_dtype_utils_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

"""Tests for the shared initializer dtype helper."""

from __future__ import annotations

import numpy as np
import onnx_ir as ir
import pytest

from mobius._passes._dtype_utils import initializer_dtype


def _value(dtype: ir.DataType | None, const: np.ndarray | None) -> ir.Value:
Comment thread
titaiwangms marked this conversation as resolved.
# Built via the ir.Value constructor rather than the ir.val() factory: these
# fixtures deliberately construct degenerate initializers (a dropped declared
# type alongside a const_value, and a declared type that contradicts the
# const_value dtype) that ir.val() validates against and refuses to build.
return ir.Value(
name="w",
type=ir.TensorType(dtype) if dtype is not None else None,
const_value=ir.tensor(const) if const is not None else None,
)


class TestInitializerDtype:
def test_uses_declared_dtype_when_present(self):
v = _value(ir.DataType.FLOAT16, np.ones((2,), np.float16))
assert initializer_dtype(v) == ir.DataType.FLOAT16

def test_falls_back_to_const_value_when_declared_missing(self):
"""The core fix: a dropped declared type must not hide the real dtype."""
v = _value(None, np.ones((2,), np.float16))
assert v.dtype is None
assert initializer_dtype(v) == ir.DataType.FLOAT16

def test_raises_on_dtype_contradiction(self):
"""Reject values whose declared dtype contradicts the serialized data.

Such a value is corrupt metadata and must fail closed rather than
silently pick one dtype.
"""
v = _value(ir.DataType.FLOAT, np.ones((2,), np.float16))
with pytest.raises(ValueError) as excinfo:
initializer_dtype(v)
message = str(excinfo.value)
assert "FLOAT" in message and "FLOAT16" in message
assert "w" in message

def test_returns_none_when_nothing_available(self):
v = _value(None, None)
assert initializer_dtype(v) is None
Loading
Loading