Fix Phi-3.5 ONNX Attention (static-cache) + GQA fp16 export bugs - #328
Fix Phi-3.5 ONNX Attention (static-cache) + GQA fp16 export bugs#328titaiwangms wants to merge 40 commits into
Conversation
CLI syntax (--model + positional out dir, f16 not fp16), GQA fusion vs --static-cache incompatibility, and the fp16 GQA packed-QKV FLOAT32 bug that makes exports fail to load in onnxruntime (with detect + fix). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…4 Attention mobius emitted static-cache Attention with is_causal=1 + nonpad_kv_seqlen, which the opset-24 ONNX Attention CUDA kernel rejects when S_q != total_kv with no past_key (causal_cross_no_past guard). With a pre-allocated max_seq_len cache this fires in BOTH prefill and decode -> NOT_IMPLEMENTED at runtime. Fix per ORT guidance: set is_causal=0 and pass an explicit 4D bool causal mask [B,1,S_q,max_seq] built from write_indices (keep j <= write_indices[b]+t). Keeps nonpad_kv_seqlen to select the external-cache kernel path. New helper create_static_cache_causal_mask in _common.py. Tests: 5 CPU value-level mask tests, updated/added 3 static-cache graph tests, and a new e2e CUDA prefill+decode regression test (tests/static_cache_decode_test.py). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…sh/XQA Follow-up to the is_causal=0 fix per architect review. An always-present attn_mask disables Flash Attention in ORT (the kernel selection checks attn_mask != nullptr by pointer, not content), so a single always-masked static-cache Attention forced single-token DECODE onto the memory-efficient path -- not apples-to-apples with the GQA variant's Flash/XQA decode, which contaminates the headline decode profiling metric. Phase-split the static-cache attention behind an If keyed on Shape(query)[1]>1: - multi-token step (S_q>1, prefill / chunked / speculative): explicit causal mask -> memory-efficient path (unavoidable; static buffer makes K_seq=total so Flash prefill is guard-blocked regardless, and it is the amortized path). - single-token decode (S_q==1): omit attn_mask; nonpad_kv_seqlen alone bounds attention to the valid prefix -> stays on Flash/XQA. New helper _attend_over_static_cache builds the two-branch If (reusing rename_subgraph_values for SSA-safe subgraphs). Validated through the full build_from_module optimize pipeline + a CUDA prefill+decode run. Tests: recurse into If subgraphs for op assertions; add phase-split mask-presence and If-structure tests; upgrade the e2e regression to build_from_module so the real export pipeline is exercised. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per readability review: make explicit that the attn_mask presence routes to the *slower* memory-efficient path, and that the phase split exists precisely to pay that Flash->MEA cost only on prefill, never on the per-token decode hot path. Doc-only; no behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When building fp16 models, _cast_module_dtype casts params to fp16 but the resulting initializer Values lose their declared .dtype (None) while const_value stays fp16. FoldConcatInitializersPass and FoldTransposedInitializerPass then defaulted the folded initializer's dtype to FLOAT, serializing the packed QKV / transposed weights as fp32. ORT rejected the model with a fp16/fp32 MatMul type-parameter error on both CPU and CUDA EPs, breaking GQA export. - Add shared helper _dtype_utils.initializer_dtype() that resolves the effective dtype from the declared type, falling back to const_value when the type annotation was dropped; prefers the data dtype and warns on stale-metadata disagreement. - Use it in both fold passes to stamp the correct dtype on the new initializer's TensorType and LazyTensor. - Guard FoldConcatInitializersPass against folding before weights load (mirrors FoldTransposedInitializerPass). - Add regression tests, including an end-to-end ORT CPU-EP load test that reproduces the original MatMul fp16/fp32 failure without the fix. Verified end-to-end: native fp16 Phi-3.5 GQA export now loads in ORT CUDA EP with no manual post-cast (32 GroupQueryAttention nodes, all fp16 initializers). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ty) guard Code review asked for an e2e guard that the decode phase stays Flash-eligible. ORT 1.27's Python profiler emits only op-level Node events (no CUDA Kernel events), so the internal Flash-vs-MEA kernel choice is not observable from end_profiling(). Instead assert the deterministic structural precondition that governs Flash eligibility: profile an fp16 decode + prefill on CUDA and assert the executed decode-branch Attention carries NO attn_mask input (rank-4) while prefill does, per layer. This catches a regression that rewires the mask onto the hot decode path and silently forces it onto the memory-efficient kernel. Builder now parametrized by dtype + profiling; existing fp32 runnability test unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per hardened Code+Critical review spec: instead of counting masked/maskless Attention nodes globally (which could pass vacuously), locate the If nodes directly and inspect BOTH branch subgraphs. Assert (a) one If per layer exists -- a regression to a single unconditional-mask Attention (no If) now fails the If-count assertion -- and (b) then-branch (prefill) Attention carries the GreaterOrEqual causal mask while else-branch (decode) omits attn_mask (Flash-eligibility precondition). Catches missing-If, single-sided, and inverted-mask regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Section 3 of the mobius-onnx-export-gotchas skill is no longer a live bug. df203cc fixed fp16 GQA exports emitting fp32 packed-QKV/transposed weights (fold passes now stamp the correct dtype via _dtype_utils.initializer_dtype). Rewrote section 3 from a BUG+manual-post-cast workaround to a FIXED note with corrected root cause (fold passes, not the PackQKV rewrite), kept the FLOAT32 verification snippet, and demoted the post-cast script to a fallback for stale pre-fix artifacts. Updated the skill frontmatter and added a CHANGELOG entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
FoldConcatInitializersPass removed the QKV-pack Concat node with `graph.remove(node)`, which detaches the node from the graph's node list but NOT from its input Values. The folded q/k/v source initializers kept a stale use pointing at the removed Concat, so the downstream RemoveUnusedNodesPass (run by fold_initializers_after_weights) treated them as live and left them in the graph. For fp16 Phi-3.5 GQA that serialized 96 orphaned pre-pack q/k/v_proj weights (~1.8 GB) into the exported model. Use `graph.remove(node, safe=True)` at both removal sites so the node detaches from its inputs, clearing the source initializers' use lists. The existing RemoveUnusedNodesPass then strips the dead pre-pack weights as part of the proper export — no post-hoc patch needed. FoldTransposedInitializerPass already does this; this aligns FoldConcat. Add a regression test asserting the source initializers are detached (zero uses) after folding and removed by RemoveUnusedNodesPass. Verified end-to-end: native fp16 Phi-3.5 GQA export drops from 8.9 GB to 7.2 GB (199 initializers, 0 unused), all fp16, loads + runs on ORT CUDA EP. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…kernel proof) The reviewers + architect required proof that the phase split actually keeps decode on Flash at runtime (structural maskless-ness is necessary but not the proof). ORT 1.27's Python profiler exposes no kernel name, but the opset-24 LLM Attention kernel logs its choice at VERBOSE via the default logger (attention.cc: "ONNX Attention: using Flash Attention" / "... Memory Efficient Attention"). New fp16 CUDA test raises the default logger to VERBOSE, redirects fd 2 around decode + prefill runs, and asserts decode (q_seq=1) selects Flash on every layer while prefill (q_seq>1) selects Memory-Efficient. A mask wired onto decode flips it to MEA and fails this test. Kept the structural maskless test too (robust to log-format changes). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The phase-split branch builder relies on a specific ordering of rename_subgraph_values -> pin attn_output.name -> append to branch.outputs. Per architect review, reordering these would either rename the pinned output or expose it to renaming before it is protected. Make the invariant explicit so future edits don't silently break SSA / If wiring. Comment-only; no behavior change (11/11 static-cache graph tests pass). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…uard) Adds a semantic regression guard closing code-review MINOR-3: the existing e2e tests prove decode runs, stays maskless, and selects Flash, but not that the nonpad_kv_seqlen bound is actually applied. This test poisons every cache slot at/beyond nonpad with large garbage and asserts decode logits are bit-identical to the clean-cache decode — proving out-of-range keys are never attended (fp16, CUDA). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…reement Completes the initializer_dtype contract test (readability nit from 9abb0595): verify the documented 'stale type metadata' warning is actually emitted when an initializer's declared dtype disagrees with its const_value, not just that const_value wins the return value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The decode-on-Flash guard previously ran only at the tiny default head_dim=16,
proving the phase-split wiring but not that ORT's Flash kernel accepts
Phi-3.5's production head_dim=96 (fp16) on the target GPU — the open question
gating variant#2's decode-on-Flash premise. Parametrize the kernel-selection
test over head_dim in {16, 96} (heads/hidden scaled to stay tiny) so it
empirically confirms decode selects Flash and prefill Memory-Efficient at the
real model's head dimension. Verified on A100 (SM80): both head_dims pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per readability review: the helper mutates process-global state (default logger severity + fd 2). It restores in finally, so it is safe under the sequential/xdist-multiprocess way these tests run, but document that it is not safe to call from threads sharing the process. Comment-only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per code review: the decode-on-Flash kernel-name proof reads ORT's internal VERBOSE attention.cc selection log, an unstable contract. Skip it on any ORT version != 1.27.x (the validated version) with a message telling maintainers to re-validate the log strings and bump _VALIDATED_ORT_VERSION, or demote the proof to the profiling harness. This makes an ORT bump self-announce as a skip at the moment the assumption is invalidated, instead of a confusing regex-miss assertion failure. The deterministic structural maskless guard is intentionally NOT gated — it stays the version-robust backstop. Still runs/passes on 1.27.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…n 5) A fp16 GQA export can be all-fp16, right-count, and still all-zeros: if the fold passes leave the packed-QKV Concat output dtype unknown/fp32 while the data is fp16, the serializer skips it and it loads as near-zero. Count/dtype checks (section 4c) do NOT catch this — broken and fixed builds can share the same 197-fp16 initializer profile. Document the canonical VALUE-based gate: per-slice packed-QKV correlation ~= 1.0 (broken ~= 0) and L2 norm ~= 126.6 at layer 0, plus end-to-end greedy-argmax parity vs attn_dynamic (~19-20/20). Cross-link from section 4 and update frontmatter. Per QA @b5d02a20. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GroupQueryAttention's contrib-op shape inference mis-derives the present
KV head_dim (32 instead of 96), so present.{i}.key/value graph outputs
declared the wrong head_dim while past_key_values inputs were correct.
ORT logged 'Error merging shape info ... lenient merge' (64 warnings on
Phi-3.5) and any consumer trusting declared shapes (e.g. onnxruntime-genai)
would see inconsistent past-vs-present KV cache types.
_register_kv_cache_outputs now accepts optional batch/num_kv_heads/
key_head_dim/value_head_dim/total_seq_len/dtype; when all provided it
stamps present.{i}.{key,value} symmetric to the past inputs before
add_output. Opt-in: omitting them preserves inference-only behavior for
the other callers. _causal_lm wires concrete values through.
Verified on a real Phi-3.5 GQA export: present.0.key now
[batch,32,past_sequence_len + sequence_len,96]; the 64 present-KV merge
warnings are eliminated; weights byte-identical (corr 1.0 x32); next-token
parity vs attn_dynamic 20/20 identical.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Document the GQA present.{i}.key/value head_dim metadata bug and its fix
(cf6c5c4): symptom (64 'lenient merge' warnings, declared head_dim 32 vs
past's 96), root cause (GroupQueryAttention contrib-op shape inference),
the opt-in shape-stamping fix, a verify snippet, and the separate
pre-existing internal GQA-hidden-output value_info warning (1024 vs 3072)
that remains as a follow-up.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…lt footgun Per critical-reviewer note: any future pass materializing an initializer should resolve dtype via initializer_dtype() instead of 'value.dtype or ir.DataType.FLOAT', since _cast_module_dtype drops the declared .dtype while keeping the fp16 const_value. Documents the convention and the follow-up to grep _passes/ for siblings / re-stamp at source in _cast_module_dtype. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…caveat Per QA @b5d02a20 re-measured live on canonical phi35_gqa: L0 packed-QKV mean(|abs|) is 0.01505 (was 0.013). Add a critical caveat: the good model's SIGNED mean is ~2.6e-6 (weights are symmetric +/-), coincidentally resembling the broken model's mean|abs| ~5e-6, so the discriminator must be mean-of-abs or norm, never signed mean -- signed mean falsely flags the good model and already caused a crew false alarm. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…oss fixes Per Secretary's definitive broadcast: the fp16-init count is not just equal between broken/fixed builds, it is unstable across fixes (Phi-3.5 shifted ~197 -> ~293 with dead-weight stripping / fold changes) and carries no correctness signal. Replace the fixed '197' example with the moving range and state explicitly: never gate on count, use the VALUE gate (corr/norm + parity). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a failure message to the 'packed concat survives DCE' assertion so a future regression self-describes the invariant (live packed-QKV result must not be stripped) instead of failing bare. Readability-review nit on 71e84b3. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Strengthen the live-weight guard from name-only to value-equality: compare the survived packed initializer's const_value against the expected concatenation, so a future DCE that mutates (not just drops) retained tensors is caught. Code-review nit MINOR-2 on 71e84b3. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
The author of this PR, titaiwangms, is not an activated member of this organization on Codecov. |
Existing fold-pass tests assert the packed value in memory and that ORT can load+run the folded model, but none compare the *serialized* packed-QKV weight to its source q/k/v projections. The original garbage-export bug (df203cc) corrupted bytes at serialization — fp16 data written under a defaulted FLOAT32 dtype — which an in-memory const_value check cannot see and a load+run check misses (the model still loads and emits a right-shaped fp16 output). Add a value gate that round-trips through the production save path (ir.save with external data, like the real fp16 export's model.onnx + model.onnx.data), reloads, and asserts the packed weight matches its sources per-slice (Pearson corr >= 0.99, norm rel_err <= 2%, plus exact fp16 equality) and that ORT inference matches a numpy reference. This converts the manual QA weight-integrity discriminator (corr=1.0/norm~126) into a CI guard against a numerically-corrupt pack that still has the right count and dtype. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🏗️ Architecture Diff
falcon / model — 1 change(s)Op summary: 66 → 66 nodes No op-sequence changes. Interface changes:
gemma2 / model — 1 change(s)Op summary: 107 → 107 nodes No op-sequence changes. Interface changes:
gemma4_text / model — 1 change(s)Op summary: 129 → 129 nodes No op-sequence changes. Interface changes:
gpt2 / model — 1 change(s)Op summary: 53 → 53 nodes No op-sequence changes. Interface changes:
llama / model — 1 change(s)Op summary: 61 → 61 nodes No op-sequence changes. Interface changes:
llama (static-cache) / model — 14 change(s)Op summary: 58 → 68 nodes --- base
+++ head
@@ -12,7 +12,12 @@
RotaryEmbedding
TensorScatter
TensorScatter
-Attention
+Shape
+Constant
+Squeeze
+Constant
+Greater
+If
Transpose
MatMul
Add
@@ -38,7 +43,12 @@
RotaryEmbedding
TensorScatter
TensorScatter
-Attention
+Shape
+Constant
+Squeeze
+Constant
+Greater
+If
Transpose
MatMul
AddAdded nodes:
Removed nodes:
phi3 / model — 1 change(s)Op summary: 59 → 59 nodes No op-sequence changes. Interface changes:
phi3 (static-cache) / model — 20 change(s)Op summary: 56 → 66 nodes --- base
+++ head
@@ -12,7 +12,12 @@
RotaryEmbedding
TensorScatter
TensorScatter
-Attention
+Shape
+Constant
+Squeeze
+Constant
+Greater
+If
Transpose
MatMul
Add
@@ -37,7 +42,12 @@
RotaryEmbedding
TensorScatter
TensorScatter
-Attention
+Shape
+Constant
+Squeeze
+Constant
+Greater
+If
Transpose
MatMul
AddAdded nodes:
Removed nodes:
Connectivity changes:
qwen / model — 1 change(s)Op summary: 61 → 61 nodes No op-sequence changes. Interface changes:
qwen (static-cache) / model — 14 change(s)Op summary: 58 → 68 nodes --- base
+++ head
@@ -12,7 +12,12 @@
RotaryEmbedding
TensorScatter
TensorScatter
-Attention
+Shape
+Constant
+Squeeze
+Constant
+Greater
+If
Transpose
MatMul
Add
@@ -38,7 +43,12 @@
RotaryEmbedding
TensorScatter
TensorScatter
-Attention
+Shape
+Constant
+Squeeze
+Constant
+Greater
+If
Transpose
MatMul
AddAdded nodes:
Removed nodes:
qwen2 / model — 1 change(s)Op summary: 61 → 61 nodes No op-sequence changes. Interface changes:
qwen2 (static-cache) / model — 14 change(s)Op summary: 58 → 68 nodes --- base
+++ head
@@ -12,7 +12,12 @@
RotaryEmbedding
TensorScatter
TensorScatter
-Attention
+Shape
+Constant
+Squeeze
+Constant
+Greater
+If
Transpose
MatMul
Add
@@ -38,7 +43,12 @@
RotaryEmbedding
TensorScatter
TensorScatter
-Attention
+Shape
+Constant
+Squeeze
+Constant
+Greater
+If
Transpose
MatMul
AddAdded nodes:
Removed nodes:
qwen2_moe / model — 1 change(s)Op summary: 224 → 224 nodes No op-sequence changes. Interface changes:
qwen2_moe (static-cache) / model — 24 change(s)Op summary: 214 → 224 nodes --- base
+++ head
@@ -15,7 +15,12 @@
RotaryEmbedding
TensorScatter
TensorScatter
-Attention
+Shape
+Constant
+Squeeze
+Constant
+Greater
+If
Transpose
MatMul
Add
@@ -119,7 +124,12 @@
RotaryEmbedding
TensorScatter
TensorScatter
-Attention
+Shape
+Constant
+Squeeze
+Constant
+Greater
+If
Transpose
MatMul
AddAdded nodes:
Removed nodes:
Modified attributes:
Connectivity changes:
qwen3 / model — 1 change(s)Op summary: 73 → 73 nodes No op-sequence changes. Interface changes:
qwen3 (static-cache) / model — 15 change(s)Op summary: 70 → 80 nodes --- base
+++ head
@@ -18,7 +18,12 @@
RotaryEmbedding
TensorScatter
TensorScatter
-Attention
+Shape
+Constant
+Squeeze
+Constant
+Greater
+If
Transpose
MatMul
Add
@@ -50,7 +55,12 @@
RotaryEmbedding
TensorScatter
TensorScatter
-Attention
+Shape
+Constant
+Squeeze
+Constant
+Greater
+If
Transpose
MatMul
AddAdded nodes:
Removed nodes:
Connectivity changes:
qwen3_moe / model — 1 change(s)Op summary: 202 → 202 nodes No op-sequence changes. Interface changes:
qwen3_moe (static-cache) / model — 20 change(s)Op summary: 192 → 202 nodes --- base
+++ head
@@ -18,7 +18,12 @@
RotaryEmbedding
TensorScatter
TensorScatter
-Attention
+Shape
+Constant
+Squeeze
+Constant
+Greater
+If
Transpose
MatMul
Add
@@ -111,7 +116,12 @@
RotaryEmbedding
TensorScatter
TensorScatter
-Attention
+Shape
+Constant
+Squeeze
+Constant
+Greater
+If
Transpose
MatMul
AddAdded nodes:
Removed nodes:
Modified attributes:
Connectivity changes:
Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed) |
…control Strengthen the packed-QKV serialize→reload value gate per QA/code-review follow-up: - Add a per-slice mean|abs| >= 1e-3 non-degeneracy assert. mean of ABSOLUTE values (not signed mean) is the robust discriminator for the near-zero 'unserialized' failure mode: symmetric fp16 weights have a signed mean ~1e-6 that is indistinguishable from a broken tensor, and corr is undefined (nan) for a zero-variance slice. mean|abs| separates healthy (~0.0x) from broken (~1e-6) cleanly. - Add test_value_gate_catches_corrupted_packed_slice: a negative control that zeroes the K slice, round-trips through serialize→reload, and asserts the discriminators flag it (and survive the round-trip) while the untouched Q/V slices still read healthy. Proves the value gate actually has teeth, so a future change cannot silently neuter the asserts and stay green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per Secretary + @69ff092d read-only verification: the count goes 293 -> 197, not 197 -> 293. 293 = unstripped intermediate (packed-QKV plus ~96 dead unpacked q/k/v source inits); the safe dead-weight strip removes the dead pre-pack inits -> 197 (canonical published model). Also note the old broken export was likewise 197 fp16, so even a correct final count proves nothing. Conclusion (never gate on count, use the VALUE gate) is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The present-shape stamp in _register_kv_cache_outputs is all-or-nothing: all six params stamp the explicit GQA present.* type, none opts out to inference. A partial set silently fell back to the known-wrong inference path (the exact head_dim mis-derivation cf6c5c4 fixes), which is almost always a caller wiring slip rather than an intentional opt-out. Emit a logger.warning naming the missing parameters when a strict subset is provided, so the slip is loud rather than silent. Behavior is otherwise unchanged (still falls back to inference); document the all-or-nothing contract in the docstring. Tests assert the partial path warns + names the omitted params, and that the zero-param opt-out stays silent. Addresses readability-review nit (9abb0595) on cf6c5c4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses two export/runtime correctness gaps in mobius’ ONNX attention stack: (1) static-cache (TensorScatter) Attention on opset-24 CUDA where is_causal=1 is rejected and decode must remain Flash-eligible, and (2) fp16 GQA export issues where folded packed-QKV / transposed initializers could end up with incorrect/unknown dtype metadata and/or prevent dead-weight stripping, plus explicit present-KV metadata stamping to avoid shape-inference mismatches.
Changes:
- Add a phase-split static-cache attention path (prefill masked vs decode maskless) and comprehensive structural + CUDA runtime regression tests to guard kernel routing and correctness.
- Introduce a shared
initializer_dtype()helper and wire it into fold passes to preserve fp16 dtype through folded initializers; add regression tests including serialization round-trips and ORT loadability. - Stamp explicit
present.*KV-cache output shapes/dtypes (opt-in) symmetric to past inputs to work around GQA contrib-op shape inference; add targeted tests and update changelog/docs.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/static_cache_decode_test.py | New CUDA end-to-end regression suite for static-cache Attention (runnability, maskless decode, Flash vs MEA kernel selection, and nonpad bounds semantics). |
| tests/build_graph_test.py | Adds recursive node-walk + fail-closed structural assertions ensuring static-cache Attention is behind per-layer If with masked prefill / maskless decode and is_causal=0. |
| src/mobius/tasks/_task_test.py | Adds a graph-level test ensuring present.* KV outputs match past-input metadata (dims + dtype). |
| src/mobius/tasks/_causal_lm.py | Computes explicit key/value head dims and stamps present KV output metadata via _register_kv_cache_outputs(...) to avoid GQA present head-dim mismatches. |
| src/mobius/tasks/_cache_utils.py | Extends _register_kv_cache_outputs with optional all-or-nothing explicit present shape/dtype stamping + warning on partial parameter sets. |
| src/mobius/tasks/_cache_utils_test.py | New unit tests covering present KV stamping behavior (override wrong inference, opt-out silence, partial-params warning, naming). |
| src/mobius/components/_common.py | Adds create_static_cache_causal_mask() producing a 4D per-batch causal mask for static-cache attention. |
| src/mobius/components/_common_test.py | Adds value-level CPU tests validating the static-cache causal mask structure and semantics. |
| src/mobius/components/_attention.py | Introduces _attend_over_static_cache() and updates static-cache attention path to is_causal=0 with If phase split (prefill masked / decode maskless) to preserve decode Flash routing. |
| src/mobius/_passes/_fold_transpose.py | Uses initializer_dtype() to ensure folded-transpose initializers preserve fp16 dtype when declared dtype metadata is missing. |
| src/mobius/_passes/_fold_transpose_test.py | Adds regression test for dtype resolution when declared dtype is missing but const_value is fp16. |
| src/mobius/_passes/_fold_concat.py | Uses initializer_dtype(), skips folding when weights aren’t loaded, and removes Concat nodes with safe=True so DCE can prune dead pre-pack weights. |
| src/mobius/_passes/_fold_concat_test.py | Adds multiple regression tests for dtype preservation, DCE detachment, ORT loadability, and serialization value integrity (note: currently introduces an onnx dependency). |
| src/mobius/_passes/_dtype_utils.py | New shared helper initializer_dtype() for pass authors to resolve effective initializer dtype from declared metadata and/or const_value. |
| src/mobius/_passes/_dtype_utils_test.py | Tests for initializer_dtype() behavior including disagreement warning and missing metadata fallback. |
| CHANGELOG.md | Documents the fp16 GQA folded-initializer dtype fix and its user-visible impact. |
| .agents/skills/mobius-onnx-export-gotchas/SKILL.md | Adds an internal skill doc summarizing ONNX export pitfalls and verification guidance for fp16 GQA and static-cache. |
Review synthesis (4-reviewer fan-out: readability, code, critical, deep)Overall: Solid, well-tested change. Core math/spec was verified against the installed opset-24 Major1. Decode is maskless and trusts 2. Ragged batched prefill can attend to padded cache slots — 3. Partial present-shape stamp fails open into the known-wrong path — Minor
Resolved by tie-breaker: one reviewer argued Nits (readability)
PraisePhase-split-maskless-decode is the right perf design; the 4D mask correctly handles per-batch 🤖 Generated by a multi-agent review fan-out (readability + code + critical + deep reviewers). |
| ### 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. | ||
|
|
There was a problem hiding this comment.
When these are fixed, do we still need them in the skill?
There was a problem hiding this comment.
Fair point — mostly yes, but they should be reframed. A skill's job is to prevent future regressions, not to track the status of a fixed bug, so rather than delete these we'd split them:
- Compress the bug-narrative: the 'Status: FIXED as of
<commit>' banners and pre-fix symptom traces shrink to a one-line historical pointer (they retain a little value for interpreting pre-fix artifacts, but that's it). - Keep + promote the convention: the rule that any pass materializing an initializer must use
initializer_dtype()(nevervalue.dtype or FLOAT) is the actual regression guard — it still applies and even flags sibling passes that aren't yet covered. - Keep the value-based weight-equivalence check (correlation/norm, not dtype/count) as enduring methodology, and the 'known remaining' item (still unfixed).
Alternatively, if you'd rather the skill hold only live gotchas, we could relocate the durable conventions into a CONTRIBUTING/_passes doc and trim the skill accordingly — happy to go either way.
Resolves the Copilot review finding flagging a CONTRIBUTING.md "zero protobuf operations" violation in a test file. Replace the onnx.save(ir.to_proto(model), ...) call with the IR-native ir.save(model, model_path) pattern already used elsewhere in the same file, and drop the now-unused `import onnx`. Behavior is unchanged: the test still writes the model and loads it in ORT, asserting the fp16 result shape/dtype and the fp16/fp32 MatMul type-mismatch regression guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed the CONTRIBUTING.md "zero protobuf operations" review finding in New SHA: What changed (test-only, behavior-preserving):
The test still writes the model and loads it in ONNX Runtime, asserting the fp16 result shape/dtype and the fp16/fp32 |
| branch.outputs.append(attn_output) | ||
| return branch | ||
|
|
||
| prefill_branch = _build_attention_branch("static_cache_prefill", use_causal_mask=True) |
There was a problem hiding this comment.
What does this mean for performance? Should we really look into having separate prefill and decode models?
There was a problem hiding this comment.
Good question — we profiled this, and the phase-split If is intentional and net-positive, which is why we don't think separate prefill/decode models are needed.
What the If does: If(Greater(seq_len, 1)) routes per step — prefill takes the explicit-causal-mask branch (MEA kernel), decode takes the maskless branch (Flash). ORT runs only the taken branch and the predicate is a host-side scalar compare, so it's not a per-token GPU cost. That's the same prefill/decode specialization two separate models would give, but in one graph.
Is it worth it? Yes — measured. Our control (static cache with the mask forced on, i.e. no split) is the slowest variant at every length (the phase-split is ~27% faster at 4096; equivalently the always-masked control is ~37% slower). The maskless-Flash decode the split unlocks is the source of a long-context win: with the static KV buffer sized to the real max context, static Attention decode crosses over and beats the contrib GroupQueryAttention op at long context (~0.90x at 4096, ~0.85x at 8192 on Phi-3.5-mini MHA; reproduced on a true-GQA model, Llama-3.2-1B, at 0.857x @8192). Honest caveat: this depends on buffer sizing — over-provisioning the static buffer erases the win, and the crossover point is model/buffer-dependent (interpolated N*~2.7K on Phi, ~2K on Llama), so it's not 'static always wins past 4K.' GQA still wins short context (<=2048 on Phi, <=512 on Llama).
Why one graph, not two models: prefill and decode share the same pre-allocated key_cache/value_cache buffers, updated in place via TensorScatter at write_indices (0 for prefill, current position for decode). One graph preserves that in-place KV-cache I/O contract — you hand the same OrtValues straight from prefill into each decode step. Two models would duplicate weights (~2x) and force cross-session buffer re-binding, for no kernel-level benefit. (A fuller decode-latency write-up is forthcoming.)
…test Addresses review feedback: replace the hand-rolled recursive _walk_nodes helper with onnx_ir Graph.all_nodes() (coverage-equivalent — recurses into If subgraphs), and use the typed .as_graph() accessor instead of .value for the If then/else branches. Bump onnx_ir floor to >=0.1.2 (all_nodes added in 0.1.2). Node set asserted by the tests is unchanged; TestBuildStaticCacheGraph 11/11 pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
I think we need to update the architectural diff to look at subgraphs. can be separate. |
justinchuby
left a comment
There was a problem hiding this comment.
LGTM overall. Would need some more eyes preferably.
The runtime-CUDA decode tests (tests/static_cache_decode_test.py) verify the is_causal=0 phase-split actually runs on GPU (decode stays maskless->Flash, no NOT_IMPLEMENTED). They previously ran in NO CI job: the CPU unit job collects them but they skipif-skip without a CUDAExecutionProvider, and no GPU job invoked them. Add an explicit pytest step to the existing per-PR A10 'Integration (fast)' job (a marker alone would not collect them: that job's step is path-scoped to integration_test.py with a -k allowlist). Closes the runtime-CUDA coverage gap for the static-cache export path. Refs #329 for the remaining numerical-parity coverage follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…hangelog The Compare-results gate flagged a Blocker regression because static-cache num_nodes rose (llama/qwen2 58->68, phi3 56->66, +10 each). Those +10 nodes are the INTENDED per-layer If(Greater(seq_len,1)) + prefill-mask phase-split introduced by this PR, not a regression. Pin the exact (baseline,current) node counts in benchmark_compare.EXPECTED_CHANGES so only this precise, self-cleaning transition is waived (post-merge base=68 no longer matches; a future 68->78 still blocks). Add regression + display-key-binding guards. Also add CHANGELOG entries for the phase-split export and the GQA present-KV shape fix (cf6c5c4). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Performance Comparison
|
…retention Adds an end-to-end regression test (src/mobius/_passes/_fold_dtype_e2e_test.py) that drives the real fp16 export path (build_from_module + apply_weights) and asserts packed/transposed initializers keep FLOAT16 through the fold passes, guarding the df203cc fix at the export level (the existing unit/pass coverage only exercises hand-built single-pass graphs). Guards BOTH df203cc mechanisms: * FoldConcat/FoldTranspose output-type stamping — the realistic fp16 GQA PackQKV export (MatMul(hidden, Transpose(Concat(W_q,W_k,W_v)))) whose Concat output carries no declared dtype. * initializer_dtype() const_value fallback — reproduced by dropping the declared type on the packed-QKV Concat inputs so the fallback is the only thing keeping the folded weights fp16. Includes a serialize->reload-with-external-data round-trip (ir.save + ir.load, model.onnx + model.onnx.data) asserting the reloaded weights are FLOAT16 with bytes intact — the ground-truth check for the serialize-time fp16-under-fp32 corruption that an in-memory const_value.numpy() can miss. 3-way revert proof: HEAD/fix -> all pass; full df203cc^ revert -> all fail; fallback-only revert (initializer_dtype call-sites, type-stamp kept) -> only the dropped-declared-dtype test fails (pinning the const_value fallback specifically). Fully synthetic (no HF download, no GPU, no ORT execution) to fit the per-PR CI tier. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…xdist-safe runs The fp16_export fixture was module-scoped and shared across the realistic-export tests. The serialize-roundtrip test calls ir.save(external_data=...) on that shared model; on some onnx_ir versions ir.save offloads initializer const_values to external tensors in place, which can leak mutated/externalized state into the other tests that read the same model. Under pytest-xdist the tests' execution order is not guaranteed, so this cross-test contamination is order-dependent and can flake (a folded weight intermittently observed as FLOAT instead of FLOAT16, falsely reporting a df203cc regression). Switching the fixture to function scope gives each test a fresh, hermetic build, eliminating the cross-test state dependence across all onnx_ir versions at negligible cost (the synthetic model is tiny). No change to test coverage or assertions; the four df203cc guards are unchanged. Verified post-change: 40 test4-alone + 40 full-file serial + 32 full-file xdist(-n4) fresh-process runs, 0 failures; ruff clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…changes are visible The Architecture-Diff tooling collapsed GRAPH-typed attributes (If then_branch / else_branch, Loop / Scan bodies) to a bare type string and never recursed into them. As a result the per-layer static-cache phase-split introduced by PR #328 -- an If(Greater(seq_len, 1)) selecting a prefill (masked) vs decode (Flash) attention path -- was completely invisible to the Architecture Diff CI: the top-level op sequence is unchanged (the If node is present on both sides), so the only signal lives inside the branch subgraphs that were being discarded. This recurses GRAPH and GRAPHS attributes into nested canonical forms so subgraph node structure participates in the comparison, reusing canonicalize_graph (inner node/value names are ignored the same way top-level ones are). diff_graphs gains a dedicated subgraph_structure_change record (MODERATE severity) for structurally significant subgraph deltas -- a node/branch added, removed, rewired, or a subgraph interface change -- while a pure inner-attribute tweak stays changed_attrs (MINOR). Structural significance propagates upward through nested subgraphs (e.g. an If inside an If). The nested diff detail is surfaced in the report (e.g. "then_branch: node[0] Concat: axis: 0 -> 1"). Additive and backward-compatible: non-GRAPH attributes are unchanged and the arch_diff.py consumer (which reads only op_sequence / node counts / the changes list) is unaffected. Adds 16 regression tests covering subgraph recursion, the structural-vs-minor severity boundary (incl. nested), GRAPHS-plural, op-swap no-double-count, and the readable fallback path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…under Developer Tooling Adds an Internal / Developer Tooling subsection so the arch-diff recursion change folded in from #330 is not orphaned in an export-correctness PR. The tool now recurses into If/Loop/Scan subgraphs (with a subgraph_structure_change MODERATE severity) and is landed here because this PR introduces the first control-flow/subgraph change (the static-cache per-layer phase-split) the old top-level-only diff could not see into. Developer-tooling only; no exported-graph or runtime impact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… not in-branch changes The previous wording over-claimed that #328 introduces a change "the old top-level diff could not see". The old diff DOES report the newly-added per-layer If phase-split nodes. Reword to the accurate future-proofing framing: #328 adds the first control-flow subgraph structure; once those If nodes exist, FUTURE changes inside their branches would be invisible to the old (subgraph-collapsing) diff, so the recursion is landed now to keep in-branch changes visible going forward. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Closing this PR — it is superseded by #340. Why: #328's static-cache attention used a per-layer What carries forward in #340: all of this PR's Thanks to everyone who reviewed here — continuing in #340. |
Fix opset-24 Attention GQA export (phase-split decode) + long-context decode win
Branch:
fix/phi35-onnx-attention-gqa-export· Head:23564ffPart 1 — Correctness (what this PR ships)
This PR makes the opset-24 ONNX
Attentionexport actually run on the CUDA EP with anexternal KV-cache, and brings it to numerical parity with the HF reference. Four fixes:
(a) Phase-split decode:
is_causal=0+ explicit causal mask +If-opBroken path: the opset-24
AttentionCUDA kernel returnsNOT_IMPLEMENTEDforis_causal=1combined with an external (pre-allocated) KV-cache — so the natural"causal Attention" export simply will not load/run on CUDA.
Fix: export
is_causal=0and supply causality explicitly, split by phase behind anIfop keyed on whether we are prefilling or decoding:token attending to all cached keys, so no mask tensor is needed.
This sidesteps the unimplemented kernel path and, as a bonus, gives the decode branch the
fast maskless Flash kernel (the source of the Part 2 performance win).
(b)
df203cc— GQA fp16 dtype fixGQA op received a dtype-mismatched input under fp16 export; this corrects the dtype so the
grouped-query path produces correct fp16 results (precondition for parity).
(c)
cf6c5c4— present-KV metadataFixes the present/updated KV-cache output metadata so the exported graph advertises the
correct cache tensors (shapes/names) for the runtime to thread through decode steps.
(d)
71e84b3— lean stripStrips dead/unused graph structure left over from the export, reducing the model to the
nodes actually exercised.
Parity result
attn_dynamic ↔ gqaPASS, 20/20 tokens, no divergence(
parity_gqa_canonical_199.json).gqa,attn_static,attn_static_mea) vs theattn_dynamicreference (parity_llama.json).Both models reproduce the HF reference token stream (19–20/20 vs reference across the
variant set), so the export is numerically correct, not just runnable.
Part 2 — Measured value (why the phase-split is worth it)
The phase-split decode path is not just a workaround — it delivers a long-context decode
performance win: the static-cache Attention decode crosses over and beats the GQA op at
long context.
Phi-3.5-mini — MHA, 32Q/32KV, head_dim 96 (QA-signed, safe to publish)
Per-token decode latency, median ms (source:
profile_4variant_matrix.csv,profile_extended_8320.csv):Static crosses 1.00 between seq 2048 and 4096 and peaks at 0.850× @8192 (8320 matched
buffer). GQA wins only short context (≤512), where the static
TensorScatterbuffer-taxdominates.
Llama-3.2-1B — true GQA, 32Q/8KV (group-4), head_dim 64 (QA-signed @b5d02a20)
Per-token decode latency, median ms (source:
profile_llama_matrix.csv):The crossover holds on true GQA: 0.857× @8192 is nearly identical to Phi MHA's 0.850×,
and it arrives ~one band earlier (between seq 512 and 2048 vs Phi's 2048–4096) because
Llama-1B's absolute decode latencies are ~3× smaller, so the fixed buffer-tax amortizes
sooner. This proves the win is architecture-robust — across MHA and true-GQA, and
across two head_dims (96 and 64) — even though GQA's structural 4× KV-cache reduction is in
play. (The ~2048 crossover is conservative: static's ≤4096 cells run on the over-provisioned
4160 buffer paying the full TensorScatter tax; right-sizing the buffer would push it earlier.)
Mechanism — a fixed-cost vs grows-with-context tradeoff (not a kernel-quality gap)
The crossover is not "one variant uses a better kernel." CUPTI confirms both the static
and GQA decode paths run genuine Flash kernels (
flash_fwd[_splitkv]). So the difference ispurely in how each path's cost scales with context:
(Llama 3.569 → 4.131 ms). Per step it Flash-reads a fixed-size, pre-allocated KV buffer and
writes the new token with a constant ~2.2 ms
TensorScattercache update — neither costdepends on the actual sequence length, so the curve is nearly flat.
read scales with the actual seqlen, so per-step cost rises as context grows.
dominates and GQA wins (≤512). Long context → GQA's grown read-cost overtakes static's flat
line and ONNX Attention wins (0.857× @8192). The flat-vs-rising geometry is why the win
widens with context rather than being a single lucky point.
cache mechanics but decodes on the MEA kernel instead of maskless Flash — so its failure to
cross isolates the phase-split maskless-Flash-on-decode branch as the active ingredient
behind the win, not the static cache alone.
(buffer ≫ real context), so they pay the full
TensorScatterbuffer-tax. Right-sizing thebuffer to real context would reduce that tax and push the crossover even earlier — the
reported ~2048 Llama crossover is therefore a conservative lower bound.
Export recipes — how to generate each attention variant (and which to pick)
All three shippable variants come from the same
mobius buildinvocation differing only in--execution-providerand--static-cache(flag strings verified against this session's exportprovenance, owner-attested @798f0e27):
attn_static--ep default --static-cache --max-seq-len {N}Attention+ phase-splitIfTensorScatter)gqa--ep cuda(no--static-cache)com.microsoft::GroupQueryAttentionflash_fwd_splitkv; @128 = non-splitflash_fwd)attn_dynamic--ep default(no--static-cache)Attention(dynamic shapes)Key takeaway: the
--static-cacheflag is what unlocks the long-context crossover win — itenables the phase-split → maskless-Flash decode on a fixed buffer. Pick
attn_staticforlong-context decode,
gqafor short-context or when you want true KV-reduction, andattn_dynamicfor maximum portability.Honest tradeoffs
phase-split
If-op structure (the 13-node prefill mask-build branch + the maskless decodebranch). That structure is exactly what buys the crossover — it routes decode to the fast
maskless Flash kernel. It is a deliberate correctness+performance feature, not accidental
expansion.
will trip on this +17%. Recommend re-baselining the expected node count (or granting a waiver)
with a note pointing at the phase-split rationale.
Testing / validation
attn_dynamic ↔ gqaPASS 20/20; Llama-3.2-1B 20/20 all variantsvs
attn_dynamicreference. Token streams match the HF reference.confirmed —
static/gqa= Flash (flash_fwd_splitkv);mea/dynamic= MEA(
attention_kernel_batched_impl). No silent fallback, so the normalized ratios are valid.Precision note:
gqadecode @128 routes to the non-splitflash_fwd_kernel(standardshort-KV Flash heuristic), switching to
flash_fwd_splitkvat seq ≥ 512 — still a genuineFlash kernel, and @128 is not crossover-critical (gqa wins there), so the conclusion is
unaffected.
PERF_REPORT.md); Llama-3.2-1B matrixQA-signed @b5d02a20 (PASS — five-item reconciliation: ratios 0.962/0.931/0.857 exact,
crossover @2048 robust with static p90 < gqa median at every seq ≥ 2048, kernel provenance
fail-closed, parity 20/20, phase-split delta).
Sources
/datadisks/disk1/titaiwang/phi35_bench/results/—MATRIX_REPORT.md,profile_4variant_matrix.csv,profile_extended_8320.csv,parity_gqa_canonical_199.json./datadisks/disk1/titaiwang/llama32_bench/results/—MATRIX_REPORT_LLAMA.md,profile_llama_matrix.csv,parity_llama.json.PERF_REPORT.md(Phi-3.5 decode-latency report, published),GQA_GENERALIZATION_REPORT.md(two-model generalization, QA-signed).23564ff(+df203ccpacked-QKV/dtype fix).