feat: emit GroupQueryAttention directly in Attention when EP supports it - #134
Conversation
Add GQAContext NamedTuple to _attention.py and a _forward_gqa() method to the Attention class. When attention_bias is a GQAContext, Attention emits com.microsoft::GroupQueryAttention directly (do_rotary=1) instead of the generic ONNX Attention + external RotaryEmbedding pair. TextModel.forward() detects EP support at build time via ep_capabilities() and get_build_dtype(). When the EP supports GQA for the build dtype (e.g. cuda/f16, cpu/f32) and the model uses a standard BaseRope, it builds a GQAContext from the rotary_emb.cos_cache / sin_cache parameters and seqlens_k / total_seq_len derived from attention_mask, then passes the GQAContext as attention_bias to all decoder layers. The RotaryAttentionToGQA rewrite rule is kept as a fallback for models with non-standard RoPE (Qwen3.5 mRoPE, ChatGLM, etc.) and for DML EP (supports_fused_rope=False). Key implementation details: - self.rotary_emb(op, position_ids) is still called in GQA mode to realize cos_cache / sin_cache as ONNX graph initializers; the returned gathered embeddings are discarded (dead nodes removed by optimization). - GQAContext exported from components.__init__ for use by external code. - softcap support: reads attn_logit_softcapping from config (Gemma2). - rotary_embedding_dim support: partial RoPE models respected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
Performance Comparison
|
🏗️ Architecture Diff
falcon / model — 2 change(s)Op summary: 66 → 66 nodes No op-sequence changes. Modified attributes:
gpt2 / model — 2 change(s)Op summary: 53 → 53 nodes No op-sequence changes. Modified attributes:
llama / model — 2 change(s)Op summary: 61 → 61 nodes No op-sequence changes. Modified attributes:
llama (static-cache) / model — 2 change(s)Op summary: 58 → 58 nodes No op-sequence changes. Modified attributes:
phi3 / model — 2 change(s)Op summary: 61 → 61 nodes No op-sequence changes. Modified attributes:
phi3 (static-cache) / model — 2 change(s)Op summary: 58 → 58 nodes No op-sequence changes. Modified attributes:
qwen / model — 2 change(s)Op summary: 61 → 61 nodes No op-sequence changes. Modified attributes:
qwen (static-cache) / model — 2 change(s)Op summary: 58 → 58 nodes No op-sequence changes. Modified attributes:
qwen2 / model — 2 change(s)Op summary: 61 → 61 nodes No op-sequence changes. Modified attributes:
qwen2 (static-cache) / model — 2 change(s)Op summary: 58 → 58 nodes No op-sequence changes. Modified attributes:
qwen2_moe / model — 2 change(s)Op summary: 224 → 224 nodes No op-sequence changes. Modified attributes:
qwen2_moe (static-cache) / model — 2 change(s)Op summary: 214 → 214 nodes No op-sequence changes. Modified attributes:
qwen3 / model — 2 change(s)Op summary: 73 → 73 nodes No op-sequence changes. Modified attributes:
qwen3 (static-cache) / model — 2 change(s)Op summary: 70 → 70 nodes No op-sequence changes. Modified attributes:
qwen3_5_moe (hybrid-text-generation) / model — 1 change(s)Op summary: 275 → 275 nodes No op-sequence changes. Modified attributes:
qwen3_5_text (hybrid-text-generation) / model — 1 change(s)Op summary: 129 → 129 nodes No op-sequence changes. Modified attributes:
qwen3_5_vl (hybrid-qwen-vl) / decoder — 1 change(s)Op summary: 149 → 149 nodes No op-sequence changes. Modified attributes:
qwen3_moe / model — 2 change(s)Op summary: 202 → 202 nodes No op-sequence changes. Modified attributes:
qwen3_moe (static-cache) / model — 2 change(s)Op summary: 192 → 192 nodes No op-sequence changes. Modified attributes:
qwen3_next (hybrid-text-generation) / model — 1 change(s)Op summary: 585 → 585 nodes No op-sequence changes. Modified attributes:
Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed) |
There was a problem hiding this comment.
Pull request overview
This PR updates the core transformer graph-construction path to emit com.microsoft::GroupQueryAttention directly (when the active execution provider supports it), reducing reliance on the post-hoc RotaryAttentionToGQA rewrite rule while keeping it as a fallback.
Changes:
- Add a
GQAContextbundle and dispatch logic soAttention.forward()can emitGroupQueryAttentiondirectly when given that context. - Add EP-capability-based gating in
TextModel.forward()to construct and threadGQAContext(including RoPE cache initializers and sequence-length scalars). - Add component- and build-level tests covering the new dispatch behavior and EP-dependent selection.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/mobius/models/base.py |
Adds EP-driven dispatch to build and pass GQAContext through the decoder stack for direct GQA emission. |
src/mobius/components/_attention.py |
Introduces GQAContext and implements the direct GroupQueryAttention emission path in Attention. |
src/mobius/components/_attention_test.py |
Adds tests validating GQAContext dispatch and EP-dependent graph construction results. |
src/mobius/components/__init__.py |
Exports GQAContext in the public components API. |
1. MRoPE guard: add 'not isinstance(self.rotary_emb, _MRopeBase)' to the
use_gqa condition in TextModel.forward(). ChunkedMRope (Qwen2.5-VL) and
InterleavedMRope (Qwen3-VL) both inherit from _MRopeBase and use 3D
position_ids; GroupQueryAttention do_rotary=1 only implements 1D RoPE,
so those models would silently produce wrong outputs on CUDA+float16.
They fall through to the RotaryAttentionToGQA rewrite rule instead.
2. softcap forwarded: add softcap parameter to _apply_attention() and pass
it to both the static-cache and dynamic-cache op.Attention() calls.
Gemma2 on DML/WebGPU/default EPs was silently losing attn_logit_softcap.
3. Test cos_cache shape: fix [32,16] → [32,8]. rotary_dim = head_dim/2 = 8
since inv_freq = arange(0, head_dim, 2) / head_dim has head_dim/2 entries.
4. base.py comments: expand use_gqa condition comment to explain each guard
(attention_mask, gqa_dtypes, supports_fused_rope, BaseRope/_MRopeBase).
Add comment explaining why position_embeddings=None in GQA mode.
5. New tests:
- test_mrope_model_does_not_use_direct_gqa: ChunkedMRope model on CUDA+f16
still goes through the rewrite rule (not direct GQA).
- test_direct_gqa_and_rewrite_rule_produce_same_structure: verifies that
CPU-EP direct path and default-EP+rewrite-rule path both produce the
same GQA node count with no leftover Attention nodes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
| :class:`~mobius.rewrite_rules._group_query_attention.RotaryAttentionToGQA` | ||
| rewrite rule for models that use the standard :class:`TextModel` backbone. | ||
| The rewrite rule remains as a fallback for models with non-standard RoPE | ||
| (e.g. Qwen3.5 with 3D mRoPE). |
There was a problem hiding this comment.
Why do we need to rewrite for 3D mRoPE? What's the difference? @copilot
There was a problem hiding this comment.
The GroupQueryAttention kernel's do_rotary=1 mode expects a simple 1D cos/sin lookup table [max_seq, rotary_dim] and a scalar position per token — it gathers one vector per position from those tables internally.
3D mRoPE (_MRopeBase subclasses: ChunkedMRope for Qwen2.5-VL, InterleavedMRope for Qwen3-VL) uses position_ids of shape (3, batch, seq_len) with three separate coordinates per token (temporal T, height H, width W). The RoPE is computed by:
- Gathering cos/sin for each of the three dimensions separately
- Blending them per-channel using
Wherenodes controlled byh_mask/w_maskarrays
The resulting per-token, per-channel cos/sin embeddings can't be expressed as a single lookup table that GQA's do_rotary=1 understands — the "table" is dynamically mixed rather than a simple positional index.
So for 3D mRoPE the direct GQA path is skipped, and the RotaryAttentionToGQA / AttentionToGQA rewrite rules handle these models instead, using do_rotary=0 (Q/K already have RoPE pre-applied before entering GQA).
There was a problem hiding this comment.
@copilot would it be a good idea to just set do_rotary=0 here? What's missing?
There was a problem hiding this comment.
Yes, do_rotary=0 could work for 3D mRoPE at build time — the AttentionToGQA rewrite rule already does exactly this today. Making it work directly in the build path is feasible but requires a few pieces that are currently missing:
-
Different payload in context:
GQAContextcurrently carriescos_cache/sin_cache(full tables[max_seq, rotary_dim]) specifically fordo_rotary=1— GQA indexes them internally. Fordo_rotary=0, we need the already-gathered, per-token(cos, sin)tensors(batch, seq, rotary_dim)produced by_MRopeBase.forward(), not raw tables. These are a completely different shape/purpose and would need to be plumbed through (either by extendingGQAContextor creating a separate context type). -
Apply RoPE inside
_forward_gqa(): Whendo_rotary=0,apply_rotary_pos_emb()must be called on Q/K before emitting theGroupQueryAttentionnode (no cos/sin inputs to GQA in this mode). This branch doesn't exist in_forward_gqa()yet. -
Remove the
_MRopeBaseguard inTextModel.forward(), or replace it with a condition that choosesdo_rotary=0for_MRopeBasevsdo_rotary=1for standardBaseRope.
Worth noting: the supports_fused_rope guard would also need revisiting — do_rotary=0 doesn't need fused RoPE inside the GQA kernel, so it could be applied even on EPs where supports_fused_rope=False (as long as dtype in caps.gqa_dtypes).
Happy to add this if you'd like to extend the PR. It would fully eliminate AttentionToGQA as a fallback for mRoPE models on GQA-capable EPs.
There was a problem hiding this comment.
Let's do it in a separate PR.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
Document the proven pattern from Gemma 4 implementation (PR #134 GQA pattern applied to MoE): EP capability check, full pre-topk router_probs requirement, CastLike after MoE output to preserve dtype, preprocess_weights stacking, and loop-fallback when EP doesn't support fused MoE. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
Summary
Eliminates the post-hoc
RotaryAttentionToGQArewrite rule as the primary path for GQA-capable EPs, replacing it with directcom.microsoft::GroupQueryAttentionemission at graph construction time.What changed
New:
GQAContextNamedTuple (components/_attention.py)A typed bundle of per-graph scalars passed through the decoder stack as
attention_bias:seqlens_k: per-batch KV sequence length[batch]INT32total_seq_len: scalar INT32cos_cache/sin_cache: full RoPE tables (not gathered slices)Modified:
Attention.forward()Checks
isinstance(attention_bias, GQAContext)at the top and dispatches to_forward_gqa()which emitsGroupQueryAttentiondirectly withdo_rotary=1, bypassing the externalRotaryEmbeddingBase.forward()+apply_rotary_pos_emb()path.Modified:
TextModel.forward()(models/base.py)Adds an EP-driven dispatch at graph construction time:
When True: calls
self.rotary_emb(op, position_ids)to realizecos_cache/sin_cacheas ONNX initializers (discards result), then buildsGQAContextwith the raw parameter tensors and computesseqlens_k/total_seq_lenfromattention_mask.What stays unchanged
RotaryAttentionToGQArewrite rule — kept as fallback for:_MRopeBase3D mRoPE (isinstance(..., BaseRope)is True butsupports_fused_rope=Falseon affected EPs excludes it)supports_fused_rope=False)TextModel(VLMs, Qwen3.5, etc.)position_idsremains in the graph inputs for consistency with the rewrite-rule path (also leavesposition_idsas a dead input post-optimization in both paths)Tests
Five new tests in
components/_attention_test.py::TestGQAContextDispatch:test_gqa_context_emits_group_query_attention: component-level, verifies GQA is emittedtest_gqa_context_respects_rotary_interleaved: checksrotary_interleavedattributetest_standard_attention_when_no_gqa_context: standard path unaffectedtest_build_with_cuda_ep_emits_gqa_directly: CUDA EP + f16 → GQA present, Attention absenttest_build_with_default_ep_uses_standard_attention: default EP → standard AttentionAll 2327 tests pass.