Skip to content

Commit c448d4f

Browse files
justinchubyCopilotCopilot
authored
feat: emit GroupQueryAttention directly in Attention when EP supports it (#134)
## Summary Eliminates the post-hoc `RotaryAttentionToGQA` rewrite rule as the primary path for GQA-capable EPs, replacing it with direct `com.microsoft::GroupQueryAttention` emission at graph construction time. ## What changed ### New: `GQAContext` NamedTuple (`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]` INT32 - `total_seq_len`: scalar INT32 - `cos_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 emits `GroupQueryAttention` directly with `do_rotary=1`, bypassing the external `RotaryEmbeddingBase.forward()` + `apply_rotary_pos_emb()` path. ### Modified: `TextModel.forward()` (`models/base.py`) Adds an EP-driven dispatch at graph construction time: ```python caps = ep_capabilities() use_gqa = ( attention_mask is not None and get_build_dtype() in caps.gqa_dtypes and caps.supports_fused_rope and isinstance(self.rotary_emb, BaseRope) ) ``` When True: calls `self.rotary_emb(op, position_ids)` to realize `cos_cache`/`sin_cache` as ONNX initializers (discards result), then builds `GQAContext` with the raw parameter tensors and computes `seqlens_k`/`total_seq_len` from `attention_mask`. ## What stays unchanged - `RotaryAttentionToGQA` rewrite rule — kept as fallback for: - Qwen3.5 with `_MRopeBase` 3D mRoPE (`isinstance(..., BaseRope)` is True but `supports_fused_rope=False` on affected EPs excludes it) - DML EP (`supports_fused_rope=False`) - Any model not using `TextModel` (VLMs, Qwen3.5, etc.) - Graph I/O: `position_ids` remains in the graph inputs for consistency with the rewrite-rule path (also leaves `position_ids` as 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 emitted - `test_gqa_context_respects_rotary_interleaved`: checks `rotary_interleaved` attribute - `test_standard_attention_when_no_gqa_context`: standard path unaffected - `test_build_with_cuda_ep_emits_gqa_directly`: CUDA EP + f16 → GQA present, Attention absent - `test_build_with_default_ep_uses_standard_attention`: default EP → standard Attention All 2327 tests pass. --------- Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent e6b658d commit c448d4f

4 files changed

Lines changed: 404 additions & 23 deletions

File tree

src/mobius/components/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"GatedRMSNorm",
3535
"Gemma3MultiModalProjector",
3636
"GroupNorm",
37+
"GQAContext",
3738
"INT64_MAX",
3839
"InputMixer",
3940
"JambaSelectiveScan",
@@ -109,6 +110,7 @@
109110
from mobius.components._activations import SiLU, get_activation
110111
from mobius.components._attention import (
111112
Attention,
113+
GQAContext,
112114
Qwen35Attention,
113115
StaticCacheState,
114116
)

src/mobius/components/_attention.py

Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
from __future__ import annotations
55

66
import math
7-
from typing import TYPE_CHECKING, NamedTuple
7+
from typing import NamedTuple
88

9+
import onnx_ir as ir
910
from onnxscript import nn
1011
from onnxscript._internal import builder
1112

@@ -14,8 +15,39 @@
1415
from mobius.components._rms_norm import OffsetRMSNorm, RMSNorm
1516
from mobius.components._rotary_embedding import apply_rotary_pos_emb
1617

17-
if TYPE_CHECKING:
18-
import onnx_ir as ir
18+
19+
class GQAContext(NamedTuple):
20+
"""Context for direct ``com.microsoft::GroupQueryAttention`` emission.
21+
22+
Created once per graph by :class:`~mobius.models.base.TextModel` when the
23+
active EP (from :func:`~mobius._build_context.ep_capabilities`) supports
24+
GQA for the current build dtype. Passed through DecoderLayer as the
25+
``attention_bias`` argument so that :class:`Attention` can detect it and
26+
emit ``GroupQueryAttention`` directly instead of the generic
27+
``Attention + RotaryEmbedding`` sequence.
28+
29+
Using this context skips the post-hoc
30+
:class:`~mobius.rewrite_rules._group_query_attention.RotaryAttentionToGQA`
31+
rewrite rule for models that use the standard :class:`TextModel` backbone.
32+
The rewrite rule remains as a fallback for models with non-standard RoPE
33+
(e.g. Qwen3.5 with 3D mRoPE).
34+
35+
Fields:
36+
seqlens_k: Per-batch last valid KV index ``[batch]`` INT32.
37+
Computed as ``ReduceSum(attention_mask, axis=1) - 1``; this is a
38+
0-based index into the valid KV tokens, not the KV length itself.
39+
total_seq_len: Scalar total sequence length INT32.
40+
Computed as ``Shape(attention_mask)[1]``.
41+
cos_cache: Full cosine RoPE table ``[max_seq_len, rotary_dim]`` FLOAT.
42+
Taken directly from the model's ``rotary_emb.cos_cache`` parameter.
43+
sin_cache: Full sine RoPE table ``[max_seq_len, rotary_dim]`` FLOAT.
44+
Taken directly from the model's ``rotary_emb.sin_cache`` parameter.
45+
"""
46+
47+
seqlens_k: ir.Value
48+
total_seq_len: ir.Value
49+
cos_cache: ir.Value
50+
sin_cache: ir.Value
1951

2052

2153
class StaticCacheState(NamedTuple):
@@ -51,6 +83,7 @@ def _apply_attention(
5183
num_attention_heads: int,
5284
num_key_value_heads: int,
5385
scale: float,
86+
softcap: float = 0.0,
5487
static_cache: StaticCacheState | None = None,
5588
) -> tuple[ir.Value, ir.Value, ir.Value]:
5689
"""Apply the ONNX Attention op with internal or static KV cache.
@@ -125,6 +158,7 @@ def _apply_attention(
125158
q_num_heads=num_attention_heads,
126159
kv_num_heads=num_key_value_heads,
127160
scale=scale,
161+
softcap=softcap,
128162
is_causal=1,
129163
_outputs=3,
130164
)
@@ -144,6 +178,7 @@ def _apply_attention(
144178
q_num_heads=num_attention_heads,
145179
kv_num_heads=num_key_value_heads,
146180
scale=scale,
181+
softcap=softcap,
147182
is_causal=1,
148183
_outputs=3,
149184
)
@@ -187,6 +222,8 @@ def __init__(
187222
else int(self.head_dim * config.partial_rotary_factor)
188223
)
189224
self._rope_interleave = config.rope_interleave
225+
# Gemma2-style logit soft-capping; 0.0 means disabled.
226+
self._softcap = getattr(config, "attn_logit_softcapping", 0.0) or 0.0
190227

191228
self.q_proj = linear_class(
192229
self.hidden_size,
@@ -231,7 +268,7 @@ def forward(
231268
self,
232269
op: builder.OpBuilder,
233270
hidden_states: ir.Value,
234-
attention_bias: ir.Value | None,
271+
attention_bias: ir.Value | GQAContext | None,
235272
position_embeddings: tuple | None = None,
236273
past_key_value: tuple | None = None,
237274
static_cache: StaticCacheState | None = None,
@@ -254,6 +291,17 @@ def forward(
254291
query_states = op.Reshape(query_states, [0, 0, -1])
255292
key_states = op.Reshape(key_states, [0, 0, -1])
256293

294+
# Direct GroupQueryAttention path: skip external RoPE, fuse everything.
295+
if isinstance(attention_bias, GQAContext):
296+
return self._forward_gqa(
297+
op,
298+
query_states,
299+
key_states,
300+
value_states,
301+
attention_bias,
302+
past_key_value,
303+
)
304+
257305
# Apply rotary position embeddings (skip when not provided)
258306
if position_embeddings is not None:
259307
query_states = apply_rotary_pos_emb(
@@ -284,12 +332,70 @@ def forward(
284332
num_attention_heads=self.num_attention_heads,
285333
num_key_value_heads=self.num_key_value_heads,
286334
scale=self.scaling,
335+
softcap=self._softcap,
287336
static_cache=static_cache,
288337
)
289338

290339
attn_output = self.o_proj(op, attn_output)
291340
return attn_output, (present_key, present_value)
292341

342+
def _forward_gqa(
343+
self,
344+
op: builder.OpBuilder,
345+
query_states: ir.Value,
346+
key_states: ir.Value,
347+
value_states: ir.Value,
348+
gqa_ctx: GQAContext,
349+
past_key_value: tuple | None,
350+
):
351+
"""Emit ``com.microsoft::GroupQueryAttention`` directly.
352+
353+
Called from :meth:`forward` when ``attention_bias`` is a
354+
:class:`GQAContext`. Bypasses the external
355+
:class:`~mobius.components._rotary_embedding.RotaryEmbeddingBase`
356+
forward pass and the post-hoc
357+
:class:`~mobius.rewrite_rules._group_query_attention.RotaryAttentionToGQA`
358+
rewrite rule; RoPE is handled by the ``do_rotary=1`` attribute instead.
359+
360+
Returns ``(attn_output, (present_key, present_value))`` in the same
361+
shape as the standard :meth:`forward` path.
362+
"""
363+
past_key = past_key_value[0] if past_key_value is not None else None
364+
past_value = past_key_value[1] if past_key_value is not None else None
365+
366+
gqa_attrs: dict = {
367+
"num_heads": self.num_attention_heads,
368+
"kv_num_heads": self.num_key_value_heads,
369+
"scale": self.scaling,
370+
"do_rotary": 1,
371+
"rotary_interleaved": int(self._rope_interleave),
372+
}
373+
if self._softcap:
374+
gqa_attrs["softcap"] = self._softcap
375+
if self.rotary_embedding_dim:
376+
# Partial RoPE: only rotate the first rotary_embedding_dim elements.
377+
gqa_attrs["rotary_embedding_dim"] = self.rotary_embedding_dim
378+
379+
# Emit GroupQueryAttention: RoPE + attention + KV cache in one op.
380+
# Outputs: (attn_output [B, S, hidden], present_key, present_value)
381+
attn_out, present_key, present_value = op.GroupQueryAttention(
382+
query_states, # [B, S, num_heads * head_dim]
383+
key_states, # [B, S, kv_heads * head_dim]
384+
value_states, # [B, S, kv_heads * head_dim]
385+
past_key, # [B, kv_heads, past_S, head_dim] or None
386+
past_value, # [B, kv_heads, past_S, head_dim] or None
387+
gqa_ctx.seqlens_k, # [B] INT32
388+
gqa_ctx.total_seq_len, # scalar INT32
389+
gqa_ctx.cos_cache, # [max_seq, rotary_dim]
390+
gqa_ctx.sin_cache, # [max_seq, rotary_dim]
391+
_domain="com.microsoft",
392+
_outputs=3,
393+
**gqa_attrs,
394+
)
395+
396+
attn_out = self.o_proj(op, attn_out)
397+
return attn_out, (present_key, present_value)
398+
293399

294400
class Qwen35Attention(nn.Module):
295401
"""Multi-head attention with output gating for Qwen3.5.

0 commit comments

Comments
 (0)