Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
2 changes: 2 additions & 0 deletions src/mobius/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"GatedRMSNorm",
"Gemma3MultiModalProjector",
"GroupNorm",
"GQAContext",
"INT64_MAX",
"InputMixer",
"JambaSelectiveScan",
Expand Down Expand Up @@ -109,6 +110,7 @@
from mobius.components._activations import SiLU, get_activation
from mobius.components._attention import (
Attention,
GQAContext,
Qwen35Attention,
StaticCacheState,
)
Expand Down
109 changes: 105 additions & 4 deletions src/mobius/components/_attention.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
from __future__ import annotations

import math
from typing import TYPE_CHECKING, NamedTuple
from typing import NamedTuple

import onnx_ir as ir
from onnxscript import nn
from onnxscript._internal import builder

Expand All @@ -14,8 +15,38 @@
from mobius.components._rms_norm import OffsetRMSNorm, RMSNorm
from mobius.components._rotary_embedding import apply_rotary_pos_emb

if TYPE_CHECKING:
import onnx_ir as ir

class GQAContext(NamedTuple):
"""Context for direct ``com.microsoft::GroupQueryAttention`` emission.

Created once per graph by :class:`~mobius.models.base.TextModel` when the
active EP (from :func:`~mobius._build_context.ep_capabilities`) supports
GQA for the current build dtype. Passed through DecoderLayer as the
``attention_bias`` argument so that :class:`Attention` can detect it and
emit ``GroupQueryAttention`` directly instead of the generic
``Attention + RotaryEmbedding`` sequence.

Using this context skips the post-hoc
: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).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to rewrite for 3D mRoPE? What's the difference? @copilot

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Gathering cos/sin for each of the three dimensions separately
  2. Blending them per-channel using Where nodes controlled by h_mask/w_mask arrays

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).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kunal-vaishnavi does this sound reasonable?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot would it be a good idea to just set do_rotary=0 here? What's missing?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Different payload in context: GQAContext currently carries cos_cache/sin_cache (full tables [max_seq, rotary_dim]) specifically for do_rotary=1 — GQA indexes them internally. For do_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 extending GQAContext or creating a separate context type).

  2. Apply RoPE inside _forward_gqa(): When do_rotary=0, apply_rotary_pos_emb() must be called on Q/K before emitting the GroupQueryAttention node (no cos/sin inputs to GQA in this mode). This branch doesn't exist in _forward_gqa() yet.

  3. Remove the _MRopeBase guard in TextModel.forward(), or replace it with a condition that chooses do_rotary=0 for _MRopeBase vs do_rotary=1 for standard BaseRope.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's do it in a separate PR.


Fields:
seqlens_k: Per-batch actual KV sequence length ``[batch]`` INT32.
Computed as ``ReduceSum(attention_mask, axis=1) - 1``.
Comment thread
justinchuby marked this conversation as resolved.
Outdated
total_seq_len: Scalar total sequence length INT32.
Computed as ``Shape(attention_mask)[1]``.
cos_cache: Full cosine RoPE table ``[max_seq_len, rotary_dim]`` FLOAT.
Taken directly from the model's ``rotary_emb.cos_cache`` parameter.
sin_cache: Full sine RoPE table ``[max_seq_len, rotary_dim]`` FLOAT.
Taken directly from the model's ``rotary_emb.sin_cache`` parameter.
"""

seqlens_k: ir.Value
total_seq_len: ir.Value
cos_cache: ir.Value
sin_cache: ir.Value


class StaticCacheState(NamedTuple):
Expand Down Expand Up @@ -187,6 +218,8 @@ def __init__(
else int(self.head_dim * config.partial_rotary_factor)
)
self._rope_interleave = config.rope_interleave
# Gemma2-style logit soft-capping; 0.0 means disabled.
self._softcap = getattr(config, "attn_logit_softcapping", 0.0) or 0.0

self.q_proj = linear_class(
self.hidden_size,
Expand Down Expand Up @@ -231,7 +264,7 @@ def forward(
self,
op: builder.OpBuilder,
hidden_states: ir.Value,
attention_bias: ir.Value | None,
attention_bias: ir.Value | GQAContext | None,
position_embeddings: tuple | None = None,
past_key_value: tuple | None = None,
static_cache: StaticCacheState | None = None,
Expand All @@ -254,6 +287,17 @@ def forward(
query_states = op.Reshape(query_states, [0, 0, -1])
key_states = op.Reshape(key_states, [0, 0, -1])

# Direct GroupQueryAttention path: skip external RoPE, fuse everything.
if isinstance(attention_bias, GQAContext):
return self._forward_gqa(
op,
query_states,
key_states,
value_states,
attention_bias,
past_key_value,
)

# Apply rotary position embeddings (skip when not provided)
if position_embeddings is not None:
query_states = apply_rotary_pos_emb(
Expand Down Expand Up @@ -290,6 +334,63 @@ def forward(
attn_output = self.o_proj(op, attn_output)
return attn_output, (present_key, present_value)

def _forward_gqa(
self,
op: builder.OpBuilder,
query_states: ir.Value,
key_states: ir.Value,
value_states: ir.Value,
gqa_ctx: GQAContext,
past_key_value: tuple | None,
):
"""Emit ``com.microsoft::GroupQueryAttention`` directly.

Called from :meth:`forward` when ``attention_bias`` is a
:class:`GQAContext`. Bypasses the external
:class:`~mobius.components._rotary_embedding.RotaryEmbeddingBase`
forward pass and the post-hoc
:class:`~mobius.rewrite_rules._group_query_attention.RotaryAttentionToGQA`
rewrite rule; RoPE is handled by the ``do_rotary=1`` attribute instead.

Returns ``(attn_output, (present_key, present_value))`` in the same
shape as the standard :meth:`forward` path.
"""
past_key = past_key_value[0] if past_key_value is not None else None
past_value = past_key_value[1] if past_key_value is not None else None

gqa_attrs: dict = {
"num_heads": self.num_attention_heads,
"kv_num_heads": self.num_key_value_heads,
"scale": self.scaling,
"do_rotary": 1,
"rotary_interleaved": int(self._rope_interleave),
}
if self._softcap:
gqa_attrs["softcap"] = self._softcap
if self.rotary_embedding_dim:
# Partial RoPE: only rotate the first rotary_embedding_dim elements.
gqa_attrs["rotary_embedding_dim"] = self.rotary_embedding_dim

# Emit GroupQueryAttention: RoPE + attention + KV cache in one op.
# Outputs: (attn_output [B, S, hidden], present_key, present_value)
attn_out, present_key, present_value = op.GroupQueryAttention(
query_states, # [B, S, num_heads * head_dim]
key_states, # [B, S, kv_heads * head_dim]
value_states, # [B, S, kv_heads * head_dim]
past_key, # [B, kv_heads, past_S, head_dim] or None
past_value, # [B, kv_heads, past_S, head_dim] or None
gqa_ctx.seqlens_k, # [B] INT32
gqa_ctx.total_seq_len, # scalar INT32
gqa_ctx.cos_cache, # [max_seq, rotary_dim]
gqa_ctx.sin_cache, # [max_seq, rotary_dim]
_domain="com.microsoft",
_outputs=3,
**gqa_attrs,
)

attn_out = self.o_proj(op, attn_out)
return attn_out, (present_key, present_value)


class Qwen35Attention(nn.Module):
"""Multi-head attention with output gating for Qwen3.5.
Expand Down
127 changes: 127 additions & 0 deletions src/mobius/components/_attention_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import onnx_ir as ir
import pytest

from mobius._testing import (
Expand Down Expand Up @@ -186,3 +187,129 @@ def test_forward_builds_graph(self):
# Should have Attention op + Sigmoid (for gate) + Mul (output gating)
assert count_op_type(graph, "Attention") >= 1
assert count_op_type(graph, "Sigmoid") >= 1


class TestGQAContextDispatch:
"""Tests for the GQAContext direct GroupQueryAttention emission path."""

def test_gqa_context_emits_group_query_attention(self):
"""When attention_bias is a GQAContext, Attention emits GroupQueryAttention directly."""
from mobius.components._attention import GQAContext

config = make_config()
attn = Attention(config)
builder, op, graph = create_test_builder()

hidden = create_test_input(builder, "hidden", [1, 8, 64])
past_key = create_test_input(builder, "past_key", [1, 2, 4, 16])
past_value = create_test_input(builder, "past_value", [1, 2, 4, 16])
seqlens_k = create_test_input(builder, "seqlens_k", [1], dtype=ir.DataType.INT32)
total_seq_len = create_test_input(
builder, "total_seq_len", [], dtype=ir.DataType.INT32
)
cos_cache = create_test_input(builder, "cos_cache", [32, 16])
sin_cache = create_test_input(builder, "sin_cache", [32, 16])

gqa_ctx = GQAContext(
seqlens_k=seqlens_k,
total_seq_len=total_seq_len,
cos_cache=cos_cache,
sin_cache=sin_cache,
)

output, (pk, pv) = attn(
op, hidden, attention_bias=gqa_ctx, past_key_value=(past_key, past_value)
)
builder._adapt_outputs([output, pk, pv])

# Direct path: GroupQueryAttention instead of ONNX Attention
assert count_op_type(graph, "GroupQueryAttention") >= 1
assert count_op_type(graph, "Attention") == 0

def test_gqa_context_respects_rotary_interleaved(self):
"""rotary_interleaved attribute is set from config.rope_interleave."""
from mobius.components._attention import GQAContext

config = make_config(rope_interleave=True)
attn = Attention(config)
builder, op, graph = create_test_builder()

hidden = create_test_input(builder, "hidden", [1, 8, 64])
past_key = create_test_input(builder, "past_key", [1, 2, 4, 16])
past_value = create_test_input(builder, "past_value", [1, 2, 4, 16])
seqlens_k = create_test_input(builder, "seqlens_k", [1], dtype=ir.DataType.INT32)
total_seq_len = create_test_input(
builder, "total_seq_len", [], dtype=ir.DataType.INT32
)
cos_cache = create_test_input(builder, "cos_cache", [32, 16])
sin_cache = create_test_input(builder, "sin_cache", [32, 16])

gqa_ctx = GQAContext(seqlens_k, total_seq_len, cos_cache, sin_cache)

output, _ = attn(
op, hidden, attention_bias=gqa_ctx, past_key_value=(past_key, past_value)
)
builder._adapt_outputs([output])

gqa_node = next(n for n in graph if n.op_type == "GroupQueryAttention")
assert gqa_node.attributes["rotary_interleaved"].value == 1

def test_standard_attention_when_no_gqa_context(self):
"""Without GQAContext, standard ONNX Attention is emitted."""
config = make_config()
attn = Attention(config)
builder, op, graph = create_test_builder()

hidden = create_test_input(builder, "hidden", [1, 8, 64])
bias = create_test_input(builder, "bias", [1, 4, 8, 8])

output, _ = attn(op, hidden, attention_bias=bias)
builder._adapt_outputs([output])

assert count_op_type(graph, "Attention") >= 1
assert count_op_type(graph, "GroupQueryAttention") == 0

def test_build_with_cuda_ep_emits_gqa_directly(self):
"""build_from_module with CUDA EP and float16 config emits GroupQueryAttention directly."""
import onnx_ir as ir

from mobius._builder import build_from_module
from mobius._registry import registry
from mobius.rewrite_rules._testing_utils import count_ops

config = make_config(
dtype=ir.DataType.FLOAT16,
max_position_embeddings=128,
rope_type="default",
rope_theta=10000.0,
)
pkg = build_from_module(
registry.get("llama")(config),
config,
execution_provider="cuda",
)
ops = count_ops(pkg["model"])
# Direct generation: each layer should have a GroupQueryAttention node
assert ops.get("GroupQueryAttention", 0) == config.num_hidden_layers
# Standard ONNX Attention should not appear
assert ops.get("Attention", 0) == 0

def test_build_with_default_ep_uses_standard_attention(self):
"""build_from_module with default EP keeps standard ONNX Attention (no GQA)."""
from mobius._builder import build_from_module
from mobius._registry import registry
from mobius.rewrite_rules._testing_utils import count_ops

config = make_config(
max_position_embeddings=128,
rope_type="default",
rope_theta=10000.0,
)
pkg = build_from_module(
registry.get("llama")(config),
config,
execution_provider="default",
)
ops = count_ops(pkg["model"])
assert ops.get("GroupQueryAttention", 0) == 0
assert ops.get("Attention", 0) == config.num_hidden_layers
Loading
Loading