Skip to content

Commit f6798a1

Browse files
committed
feat(v4): gated_attention block — direct re-export of mlx-lm Qwen3NextAttention
The 25% softmax slot in the Qwen3-Next / Qwen3.5 / Qwen3.6 hybrid stack (3 GDN + 1 Gated Attention per block) is Qwen3NextAttention from upstream mlx-lm. It has four features that distinguish it from vanilla SDPA: - asymmetric GQA (num_attention_heads != num_key_value_heads) - per-head RMSNorm on Q and K (post-projection, Qwen3-Next-specific) - partial RoPE (partial_rotary_factor controls fraction of head_dim) - sigmoid output gate split from a 2x-sized q_proj output Under the hood it calls mx.fast.scaled_dot_product_attention (Apple MPS fused softmax SDPA) — there is no faster Metal path on M-series, so re-implementing it adds nothing. Per the user instruction: import directly from mlx-lm, do not vendor or rewrite. This commit: - cppmega_v4/nn/gated_attention.py: GatedAttentionBlock wrapper + GatedAttentionConfig. Builds the minimal Qwen3Next ModelArgs the upstream class reads at __init__ and instantiates Qwen3NextAttention directly. The wrapper exposes `self.attn` so weight loaders that walk sub-parameter names (q_proj / k_proj / v_proj / o_proj / q_norm / k_norm) find them at the expected paths. - cppmega_v4/models/unified_superblock_v4.py: register "gated_attention" -> _build_gated_attention in BLOCK_BUILDERS so UnifiedSuperblock specs can use the Qwen 3.6 / Qwen3-Next attention slot directly. - tests/v4/test_gated_attention.py: 8 tests — register check, direct- import-from-mlx-lm assertion, forward-shape, 2x q_proj gate split, asymmetric GQA, Q/K norm presence, unified-superblock round-trip, Qwen 3.6-sized config (24 Q / 4 KV / head_dim=128 ratio). Suite: 379 passed / 0 skipped (was 371).
1 parent 15fdc5d commit f6798a1

3 files changed

Lines changed: 261 additions & 0 deletions

File tree

cppmega_v4/models/unified_superblock_v4.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,34 @@ def _build_mla(hidden_size: int, params: dict) -> nn.Module:
137137
return MLABlock(cfg)
138138

139139

140+
def _build_gated_attention(hidden_size: int, params: dict) -> nn.Module:
141+
"""Qwen3-Next / Qwen3.5 / Qwen3.6 Gated Attention.
142+
143+
Directly re-exports ``mlx_lm.models.qwen3_next.Qwen3NextAttention`` via
144+
``cppmega_v4.nn.gated_attention.GatedAttentionBlock`` — no vendoring,
145+
no re-implementation. Under the hood: ``mx.fast.scaled_dot_product_attention``
146+
(Apple MPS) + sigmoid output gate + partial RoPE + asymmetric GQA +
147+
Q/K RMSNorm. See cppmega_v4/nn/gated_attention.py for the wrapper.
148+
"""
149+
from cppmega_v4.nn.gated_attention import GatedAttentionBlock, GatedAttentionConfig
150+
cfg = GatedAttentionConfig(
151+
hidden_size=hidden_size,
152+
num_attention_heads=params.get("num_attention_heads", max(1, hidden_size // 64)),
153+
num_key_value_heads=params.get(
154+
"num_key_value_heads",
155+
max(1, params.get("num_attention_heads", max(1, hidden_size // 64)) // 8),
156+
),
157+
head_dim=params.get("head_dim", 64),
158+
rms_norm_eps=params.get("rms_norm_eps", 1e-6),
159+
rope_theta=params.get("rope_theta", 1_000_000.0),
160+
partial_rotary_factor=params.get("partial_rotary_factor", 0.25),
161+
max_position_embeddings=params.get("max_position_embeddings", 262_144),
162+
attention_bias=params.get("attention_bias", False),
163+
rope_scaling=params.get("rope_scaling"),
164+
)
165+
return GatedAttentionBlock(cfg)
166+
167+
140168
def _build_attention(hidden_size: int, params: dict) -> nn.Module:
141169
"""Standard multi-head self-attention (causal). Used for `attention`."""
142170
num_heads = params.get("num_heads", max(1, hidden_size // 64))
@@ -261,6 +289,11 @@ def __call__(self, x):
261289
"kda": _build_kda,
262290
"moe": _build_moe,
263291
"attention": _build_attention,
292+
# gated_attention = Qwen3-Next / Qwen3.6 25%-softmax slot. Direct
293+
# re-export of mlx_lm.models.qwen3_next.Qwen3NextAttention; output gate
294+
# + partial RoPE + asymmetric GQA + Q/K RMSNorm. Underlying compute is
295+
# mx.fast.scaled_dot_product_attention (Apple MPS Metal SDPA).
296+
"gated_attention": _build_gated_attention,
264297
# mla = V3-style with LoRA Q + LoRA KV + RoPE on pe-only split.
265298
# mla_absorb = same block, prefers absorb fast-path at decode.
266299
"mla": _build_mla,

cppmega_v4/nn/gated_attention.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Gated Attention — direct re-export of mlx-lm's Qwen3NextAttention.
2+
3+
mlx-lm already ships the production Gated Attention used by Qwen3-Next /
4+
Qwen3.5 / Qwen3.6 (the 25% softmax slot in their hybrid 3:1 GDN+Attn
5+
stack). It implements all four features that distinguish Gated Attention
6+
from vanilla SDPA:
7+
8+
- asymmetric GQA (num_attention_heads != num_key_value_heads)
9+
- per-head RMSNorm on Q and K (post-projection)
10+
- partial RoPE (partial_rotary_factor controls the fraction of head_dim)
11+
- sigmoid output gate: ``self.o_proj(SDPA(q, k, v) * sigmoid(gate))``
12+
where ``gate`` is split from a 2x-sized q_proj output
13+
14+
Under the hood it calls ``mx.fast.scaled_dot_product_attention`` (the
15+
Apple MPS fused softmax SDPA kernel) — there is no faster Metal path on
16+
M-series, so wrapping it ourselves would add nothing. We import the
17+
upstream module verbatim and just expose it via our V4 block-builder
18+
interface.
19+
20+
Upstream reference:
21+
mlx_lm/models/qwen3_next.py::Qwen3NextAttention
22+
"""
23+
24+
from __future__ import annotations
25+
26+
from dataclasses import dataclass
27+
from typing import Any, Optional
28+
29+
import mlx.core as mx
30+
import mlx.nn as nn
31+
32+
# Direct upstream import — no vendoring, no re-implementation. The mlx-lm
33+
# Qwen3-Next module is part of the installed package; we use it as-is.
34+
from mlx_lm.models.qwen3_next import (
35+
ModelArgs as _Qwen3NextModelArgs,
36+
Qwen3NextAttention as _UpstreamGatedAttention,
37+
)
38+
39+
40+
@dataclass(frozen=True)
41+
class GatedAttentionConfig:
42+
"""Configuration for the Gated Attention block.
43+
44+
Defaults match the Qwen3-Next / Qwen3.6 attention slot:
45+
num_attention_heads=16, num_key_value_heads=2 (8:1 GQA),
46+
head_dim=128, partial_rotary_factor=0.25.
47+
"""
48+
49+
hidden_size: int
50+
num_attention_heads: int = 16
51+
num_key_value_heads: int = 2
52+
head_dim: int = 128
53+
rms_norm_eps: float = 1e-6
54+
rope_theta: float = 1_000_000.0
55+
partial_rotary_factor: float = 0.25
56+
max_position_embeddings: int = 262_144
57+
attention_bias: bool = False
58+
rope_scaling: Optional[dict] = None
59+
60+
61+
def _build_upstream_args(cfg: GatedAttentionConfig) -> _Qwen3NextModelArgs:
62+
"""Build the minimal mlx-lm ModelArgs that Qwen3NextAttention reads.
63+
64+
Qwen3NextAttention.__init__ only touches the attention-related fields
65+
of ModelArgs; the linear-attn / MoE / norm-vocab fields are unused at
66+
instantiation time but required by the @dataclass schema. Fill them
67+
with neutral defaults so the dataclass constructor accepts.
68+
"""
69+
return _Qwen3NextModelArgs(
70+
model_type="qwen3_next",
71+
hidden_size=cfg.hidden_size,
72+
num_hidden_layers=1,
73+
intermediate_size=cfg.hidden_size * 4,
74+
num_attention_heads=cfg.num_attention_heads,
75+
# Linear-attn fields (unused by Qwen3NextAttention)
76+
linear_num_value_heads=cfg.num_attention_heads,
77+
linear_num_key_heads=cfg.num_attention_heads,
78+
linear_key_head_dim=cfg.head_dim,
79+
linear_value_head_dim=cfg.head_dim,
80+
linear_conv_kernel_dim=4,
81+
# MoE fields (unused by Qwen3NextAttention)
82+
num_experts=1,
83+
num_experts_per_tok=1,
84+
decoder_sparse_step=1,
85+
shared_expert_intermediate_size=cfg.hidden_size,
86+
mlp_only_layers=[],
87+
moe_intermediate_size=cfg.hidden_size,
88+
# Norm / vocab (unused for this block but required by schema)
89+
rms_norm_eps=cfg.rms_norm_eps,
90+
vocab_size=1,
91+
# Attention-specific
92+
num_key_value_heads=cfg.num_key_value_heads,
93+
rope_theta=cfg.rope_theta,
94+
partial_rotary_factor=cfg.partial_rotary_factor,
95+
max_position_embeddings=cfg.max_position_embeddings,
96+
head_dim=cfg.head_dim,
97+
attention_bias=cfg.attention_bias,
98+
rope_scaling=cfg.rope_scaling,
99+
)
100+
101+
102+
class GatedAttentionBlock(nn.Module):
103+
"""Thin V4-block wrapper around mlx-lm's Qwen3NextAttention.
104+
105+
Exposes ``self.attn`` so weight loaders that walk ``Qwen3NextAttention``
106+
sub-parameter names (q_proj/k_proj/v_proj/o_proj/q_norm/k_norm) find
107+
them at the expected paths.
108+
"""
109+
110+
def __init__(self, cfg: GatedAttentionConfig):
111+
super().__init__()
112+
self.cfg = cfg
113+
self.attn = _UpstreamGatedAttention(_build_upstream_args(cfg))
114+
115+
def __call__(
116+
self,
117+
x: mx.array,
118+
mask: Optional[mx.array] = None,
119+
cache: Optional[Any] = None,
120+
) -> mx.array:
121+
return self.attn(x, mask=mask, cache=cache)
122+
123+
124+
__all__ = [
125+
"GatedAttentionBlock",
126+
"GatedAttentionConfig",
127+
]

tests/v4/test_gated_attention.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Tests for Gated Attention block (mlx-lm Qwen3NextAttention re-export)."""
2+
3+
from __future__ import annotations
4+
5+
import mlx.core as mx
6+
import numpy as np
7+
import pytest
8+
9+
from cppmega_v4.nn.gated_attention import GatedAttentionBlock, GatedAttentionConfig
10+
from cppmega_v4.models.unified_superblock_v4 import BLOCK_BUILDERS
11+
12+
13+
def test_block_registers_in_block_builders():
14+
assert "gated_attention" in BLOCK_BUILDERS
15+
16+
17+
def test_direct_import_from_mlx_lm():
18+
"""The wrapper must use mlx-lm's actual Qwen3NextAttention, not a fork."""
19+
from mlx_lm.models.qwen3_next import Qwen3NextAttention as _Upstream
20+
cfg = GatedAttentionConfig(hidden_size=128, num_attention_heads=4,
21+
num_key_value_heads=2, head_dim=32)
22+
block = GatedAttentionBlock(cfg)
23+
assert isinstance(block.attn, _Upstream)
24+
25+
26+
def test_forward_shape_preserved():
27+
cfg = GatedAttentionConfig(
28+
hidden_size=256, num_attention_heads=8, num_key_value_heads=2, head_dim=32,
29+
)
30+
block = GatedAttentionBlock(cfg)
31+
x = mx.random.normal((2, 16, 256))
32+
y = block(x)
33+
mx.eval(y)
34+
assert y.shape == x.shape
35+
36+
37+
def test_qproj_is_2x_for_gate_split():
38+
"""q_proj outputs 2x size so half can be split off as the sigmoid gate."""
39+
cfg = GatedAttentionConfig(
40+
hidden_size=128, num_attention_heads=4, num_key_value_heads=1, head_dim=32,
41+
)
42+
block = GatedAttentionBlock(cfg)
43+
# weight shape is [out, in] in mlx
44+
assert block.attn.q_proj.weight.shape == (4 * 32 * 2, 128)
45+
46+
47+
def test_asymmetric_gqa():
48+
"""Gated Attention uses extreme GQA (e.g. 6:1 in Qwen 3.6)."""
49+
cfg = GatedAttentionConfig(
50+
hidden_size=512, num_attention_heads=24, num_key_value_heads=4, head_dim=128,
51+
)
52+
block = GatedAttentionBlock(cfg)
53+
assert block.attn.q_proj.weight.shape == (24 * 128 * 2, 512)
54+
assert block.attn.k_proj.weight.shape == (4 * 128, 512)
55+
assert block.attn.v_proj.weight.shape == (4 * 128, 512)
56+
57+
58+
def test_q_and_k_norm_present():
59+
cfg = GatedAttentionConfig(hidden_size=128, num_attention_heads=4,
60+
num_key_value_heads=1, head_dim=32)
61+
block = GatedAttentionBlock(cfg)
62+
assert hasattr(block.attn, "q_norm")
63+
assert hasattr(block.attn, "k_norm")
64+
65+
66+
def test_unified_superblock_builder_round_trip():
67+
"""Building via BLOCK_BUILDERS produces a working module."""
68+
builder = BLOCK_BUILDERS["gated_attention"]
69+
block = builder(
70+
hidden_size=128,
71+
params={
72+
"num_attention_heads": 4,
73+
"num_key_value_heads": 2,
74+
"head_dim": 32,
75+
},
76+
)
77+
x = mx.random.normal((1, 8, 128))
78+
y = block(x)
79+
mx.eval(y)
80+
assert y.shape == x.shape
81+
assert not bool(mx.any(mx.isnan(y)).item())
82+
83+
84+
def test_qwen36_attention_slot_shape():
85+
"""Qwen 3.6-27B Gated Attention slot: 24 Q / 4 KV / head_dim=128 / hidden=5120.
86+
87+
Use shrunk dims so the test is fast but ratios match the production
88+
config (8:1 GQA, 2x q_proj for the gate split).
89+
"""
90+
cfg = GatedAttentionConfig(
91+
hidden_size=512, # /10 of 5120
92+
num_attention_heads=24,
93+
num_key_value_heads=4, # 6:1 GQA ratio
94+
head_dim=64, # /2 of 128
95+
partial_rotary_factor=0.25,
96+
)
97+
block = GatedAttentionBlock(cfg)
98+
x = mx.random.normal((1, 32, 512))
99+
y = block(x)
100+
mx.eval(y)
101+
assert y.shape == x.shape

0 commit comments

Comments
 (0)