Skip to content

Commit 5f04dee

Browse files
committed
feat(attention_mla): optional sigmoid output gate (mla_use_output_gate)
Add optional sigmoid output gate to MLA attention layer controlled by mla_use_output_gate configuration.
1 parent 1af3969 commit 5f04dee

2 files changed

Lines changed: 325 additions & 3 deletions

File tree

‎src/maxtext/layers/attention_mla.py‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -522,13 +522,18 @@ def __init__(
522522
rope_factor: float = 40.0, # rotary embedding factor
523523
name: str | None = None,
524524
rngs: Optional[nnx.Rngs] = None,
525+
mla_use_output_gate: bool = False,
525526
):
526527
"""Initializes the MLA module.
527528
528529
Args:
529530
config: The model configuration.
530531
... and other configuration parameters for MLA attention.
532+
is_nope_layer: If True, the rotary transform is skipped for the `qk_rope_head_dim`
533+
slices of the query and key (the slices are still projected and attended over).
531534
rngs: The random number generators for initialization, passed by the nnx.to_linen wrapper.
535+
mla_use_output_gate: If True, adds a per-head sigmoid output gate before the output
536+
projection: `out = out_proj(attn * sigmoid(g_proj(inputs_q)))`.
532537
"""
533538
base_kv_cache = config.attention != "paged" and config.mla_naive_kvcache
534539

@@ -543,6 +548,7 @@ def __init__(
543548
self.original_max_position_embeddings = original_max_position_embeddings
544549
self.mscale = mscale
545550
self.rope_factor = rope_factor
551+
self.use_output_gate = mla_use_output_gate
546552

547553
self.qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
548554

@@ -765,6 +771,23 @@ def _init_projections(self, inputs_q_shape: Tuple, inputs_kv_shape: Tuple) -> No
765771
mscale = 0.1 * self.mscale * math.log(self.rope_factor) + 1.0
766772
self.softmax_scale = self.softmax_scale * mscale * mscale
767773

774+
if self.use_output_gate:
775+
self.g_proj = DenseGeneral(
776+
in_features_shape=self.config.emb_dim,
777+
out_features_shape=(self.num_query_heads, self.v_head_dim),
778+
axis=-1,
779+
kernel_init=self.kernel_init,
780+
kernel_axes=("embed_attn", "q_heads", "kv"),
781+
dtype=self.dtype,
782+
weight_dtype=self.weight_dtype,
783+
quant=self.quant,
784+
matmul_precision=self.config.matmul_precision,
785+
shard_mode=self.config.shard_mode,
786+
rngs=self.rngs,
787+
)
788+
else:
789+
self.g_proj = None
790+
768791
self.out = self.init_out_w(output_dim=inputs_q_shape[-1])
769792

770793
def _attention_op_num_kv_heads(self) -> int:
@@ -866,7 +889,10 @@ def mla_query_projection(
866889
# Partial RoPE: Split into non-positional and rotary parts.
867890
# last dimension: qk_nope_head_dim, qk_rope_head_dim
868891
q_nope = self._maybe_shard_with_logical(q_nope, query_logical_name)
869-
q_pe = self.apply_rotary_embedding(q_pe, inputs_positions=inputs_positions)
892+
# NoPE layers (e.g. Kimi-K3) still carry a `qk_rope_head_dim` slice through the
893+
# projections, but never apply the rotary transform to it.
894+
if not self.is_nope_layer:
895+
q_pe = self.apply_rotary_embedding(q_pe, inputs_positions=inputs_positions)
870896
q_pe = self._maybe_shard_with_logical(q_pe, query_logical_name)
871897
# Query projection is scaled by self.softmax_scale to be consistent MaxText implementation.
872898
# DeepSeek v3 was doing it in attention score computation.
@@ -1024,9 +1050,10 @@ def mla_kv_projection(self, inputs: Array, inputs_positions: Array, decoder_segm
10241050
low_rank_main, low_rank_rope = jnp.split(low_rank, [self.kv_lora_rank], axis=-1)
10251051
low_rank_main = self.kv_norm(low_rank_main)
10261052
low_rank_main = checkpoint_name(low_rank_main, "mla_kv")
1027-
# Apply rotary embedding to key_rope.
1053+
# Apply rotary embedding to key_rope (skipped entirely for NoPE layers).
10281054
key_rope = jnp.expand_dims(low_rank_rope, axis=2)
1029-
key_rope = self.apply_rotary_embedding(key_rope, inputs_positions=inputs_positions)
1055+
if not self.is_nope_layer:
1056+
key_rope = self.apply_rotary_embedding(key_rope, inputs_positions=inputs_positions)
10301057

10311058
key, value = self._build_key_value(low_rank_main, key_rope, model_mode)
10321059
cached_values = [None, None]
@@ -1300,6 +1327,12 @@ def __call__(
13001327
out = self._maybe_shard_with_logical(out, self.out_axis_names)
13011328
out = jax.ad_checkpoint.checkpoint_name(out, "attention_out")
13021329

1330+
if self.use_output_gate:
1331+
# Matches the HF reference: the sigmoid is computed in the activation dtype.
1332+
g = jax.nn.sigmoid(self.g_proj(inputs_q))
1333+
g = self._maybe_shard_with_logical(g, self.out_axis_names)
1334+
out = out * g
1335+
13031336
out_sharding = create_sharding(self.mesh, out_logical_name)
13041337
out = self.out_projection(out, out_sharding=out_sharding)
13051338
out = checkpoint_name(out, "out_proj")
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
# Copyright 2023-2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for the MLA sigmoid output gate and NoPE (skip-RoPE) support used by Kimi-K3.
16+
17+
The hermetic tests run everywhere. The HF parity test additionally needs torch,
18+
transformers, einops and the untracked Kimi-K3 HF reference checkout, and skips
19+
cleanly when any of them is missing.
20+
"""
21+
22+
import contextlib
23+
import importlib.util
24+
import sys
25+
import unittest
26+
27+
from flax import nnx
28+
import jax.numpy as jnp
29+
from jax.sharding import Mesh
30+
import numpy as np
31+
32+
from maxtext.configs import pyconfig
33+
from maxtext.layers import attention_mla
34+
from maxtext.utils import maxtext_utils
35+
from tests.utils.kimi_k3_reference import KIMI_K3_REFERENCE_DIR, requires_kimi_k3_reference
36+
from tests.utils.test_helpers import get_test_config_path
37+
38+
_HF_DEPS_AVAILABLE = all(importlib.util.find_spec(m) is not None for m in ("torch", "transformers", "einops"))
39+
40+
# Small dims: parity does not need production sizes.
41+
BATCH = 2
42+
SEQ_LEN = 8
43+
HIDDEN = 256
44+
NUM_HEADS = 4
45+
QK_NOPE_HEAD_DIM = 32
46+
QK_ROPE_HEAD_DIM = 16
47+
V_HEAD_DIM = 32
48+
Q_LORA_RANK = 64
49+
KV_LORA_RANK = 32
50+
RMS_NORM_EPS = 1e-5
51+
52+
# Shared submodules of the MLA layer (everything except the optional g_proj).
53+
_SHARED_SUBMODULES = ("wq_a", "q_norm", "wq_b", "wkv_a", "kv_norm", "wkv_b", "out")
54+
55+
56+
def _make_config():
57+
return pyconfig.initialize(
58+
[None, get_test_config_path()],
59+
run_name="kimi_k3_mla_gate_nope_test",
60+
model_name="default",
61+
attention="dot_product",
62+
attention_type="mla",
63+
base_emb_dim=HIDDEN,
64+
emb_dim=HIDDEN,
65+
num_query_heads=NUM_HEADS,
66+
num_kv_heads=NUM_HEADS,
67+
head_dim=V_HEAD_DIM,
68+
q_lora_rank=Q_LORA_RANK,
69+
kv_lora_rank=KV_LORA_RANK,
70+
qk_nope_head_dim=QK_NOPE_HEAD_DIM,
71+
qk_rope_head_dim=QK_ROPE_HEAD_DIM,
72+
v_head_dim=V_HEAD_DIM,
73+
mla_naive_kvcache=True,
74+
dtype="float32",
75+
weight_dtype="float32",
76+
matmul_precision="highest",
77+
normalization_layer_epsilon=RMS_NORM_EPS,
78+
max_target_length=SEQ_LEN,
79+
max_prefill_predict_length=SEQ_LEN,
80+
per_device_batch_size=BATCH,
81+
rope_max_timescale=500000.0,
82+
max_position_embeddings=1048576,
83+
original_max_position_embeddings=1048576,
84+
enable_checkpointing=False,
85+
)
86+
87+
88+
def _make_mla(cfg, mesh, *, use_output_gate, is_nope_layer, seed=0):
89+
return attention_mla.MLA(
90+
config=cfg,
91+
num_query_heads=NUM_HEADS,
92+
num_kv_heads=NUM_HEADS,
93+
head_dim=V_HEAD_DIM,
94+
inputs_q_shape=(BATCH, SEQ_LEN, HIDDEN),
95+
inputs_kv_shape=(BATCH, SEQ_LEN, HIDDEN),
96+
max_target_length=SEQ_LEN,
97+
max_prefill_predict_length=SEQ_LEN,
98+
mesh=mesh,
99+
attention_kernel="dot_product",
100+
dtype=jnp.float32,
101+
weight_dtype=jnp.float32,
102+
attention_type="mla",
103+
is_nope_layer=is_nope_layer,
104+
mla_use_output_gate=use_output_gate,
105+
q_lora_rank=Q_LORA_RANK,
106+
kv_lora_rank=KV_LORA_RANK,
107+
qk_nope_head_dim=QK_NOPE_HEAD_DIM,
108+
qk_rope_head_dim=QK_ROPE_HEAD_DIM,
109+
v_head_dim=V_HEAD_DIM,
110+
max_position_embeddings=1048576,
111+
original_max_position_embeddings=1048576,
112+
mscale=1.0,
113+
rope_factor=40.0,
114+
rngs=nnx.Rngs(params=seed, dropout=seed),
115+
)
116+
117+
118+
def _copy_shared_weights(src, dst):
119+
for name in _SHARED_SUBMODULES:
120+
nnx.update(getattr(dst, name), nnx.state(getattr(src, name)))
121+
122+
123+
def _run(mla, x, positions):
124+
segment_ids = jnp.ones((BATCH, SEQ_LEN), dtype=jnp.int32)
125+
out, _ = mla(
126+
inputs_q=x,
127+
inputs_kv=x,
128+
inputs_positions=positions,
129+
decoder_segment_ids=segment_ids,
130+
model_mode="train",
131+
)
132+
return np.asarray(out)
133+
134+
135+
def _arange_positions():
136+
return jnp.broadcast_to(jnp.arange(SEQ_LEN, dtype=jnp.int32), (BATCH, SEQ_LEN))
137+
138+
139+
class MlaGateNopeTest(unittest.TestCase):
140+
"""Hermetic numerical tests for the MLA output gate and NoPE path."""
141+
142+
def setUp(self):
143+
super().setUp()
144+
self.cfg = _make_config()
145+
self.mesh = Mesh(maxtext_utils.create_device_mesh(self.cfg), self.cfg.mesh_axes)
146+
self.x = jnp.asarray(np.random.default_rng(0).standard_normal((BATCH, SEQ_LEN, HIDDEN)), dtype=jnp.float32)
147+
148+
def test_gate_disabled_by_default(self):
149+
mla = _make_mla(self.cfg, self.mesh, use_output_gate=False, is_nope_layer=False)
150+
self.assertIsNone(mla.g_proj)
151+
152+
def test_zero_gate_weights_halve_output(self):
153+
"""With g_proj == 0, sigmoid(0) = 0.5 and o_proj is linear, so the output is exactly halved."""
154+
ungated = _make_mla(self.cfg, self.mesh, use_output_gate=False, is_nope_layer=True)
155+
gated = _make_mla(self.cfg, self.mesh, use_output_gate=True, is_nope_layer=True)
156+
_copy_shared_weights(ungated, gated)
157+
gated.g_proj.kernel[...] = jnp.zeros_like(gated.g_proj.kernel[...])
158+
self.assertEqual(gated.g_proj.kernel[...].shape, (HIDDEN, NUM_HEADS, V_HEAD_DIM))
159+
160+
positions = _arange_positions()
161+
np.testing.assert_allclose(
162+
_run(gated, self.x, positions), 0.5 * _run(ungated, self.x, positions), rtol=1e-5, atol=1e-6
163+
)
164+
165+
def test_nope_layer_skips_rope_at_nonzero_positions(self):
166+
"""NoPE output is position-independent and equals the RoPE output at position 0 (where RoPE is identity)."""
167+
rope = _make_mla(self.cfg, self.mesh, use_output_gate=True, is_nope_layer=False)
168+
nope = _make_mla(self.cfg, self.mesh, use_output_gate=True, is_nope_layer=True)
169+
_copy_shared_weights(rope, nope)
170+
nnx.update(nope.g_proj, nnx.state(rope.g_proj))
171+
172+
positions = _arange_positions()
173+
zeros = jnp.zeros_like(positions)
174+
nope_out = _run(nope, self.x, positions)
175+
176+
np.testing.assert_allclose(nope_out, _run(nope, self.x, positions + 100), rtol=1e-5, atol=1e-6)
177+
np.testing.assert_allclose(nope_out, _run(rope, self.x, zeros), rtol=1e-5, atol=1e-6)
178+
# Sanity check: with RoPE enabled, non-zero positions do change the output.
179+
self.assertGreater(np.max(np.abs(_run(rope, self.x, positions) - nope_out)), 1e-3)
180+
181+
182+
@contextlib.contextmanager
183+
def _hf_reference_modules():
184+
"""Imports the HF reference modules, restoring sys.path / sys.modules afterwards."""
185+
saved_modules = set(sys.modules)
186+
sys.path.insert(0, KIMI_K3_REFERENCE_DIR)
187+
try:
188+
from configuration_kimi_k3 import KimiLinearConfig # pylint: disable=import-outside-toplevel,import-error
189+
from modeling_kimi_linear import KimiMLAAttention # pylint: disable=import-outside-toplevel,import-error
190+
191+
yield KimiLinearConfig, KimiMLAAttention
192+
finally:
193+
sys.path.remove(KIMI_K3_REFERENCE_DIR)
194+
for name in ("configuration_kimi_k3", "modeling_kimi_linear"):
195+
if name not in saved_modules:
196+
sys.modules.pop(name, None)
197+
198+
199+
@unittest.skipUnless(_HF_DEPS_AVAILABLE, "torch, transformers and einops are required for HF parity")
200+
@requires_kimi_k3_reference
201+
class MlaGateNopeVsReferenceTest(unittest.TestCase):
202+
"""Parity of gated NoPE MLA against the HF `KimiMLAAttention` reference."""
203+
204+
layer_prefix = "params-decoder-layers_3-self_attention"
205+
206+
def test_gated_nope_mla_parity(self):
207+
import torch # pylint: disable=import-outside-toplevel
208+
from tests.utils.kimi_k3_conversion_utils import ( # pylint: disable=import-outside-toplevel
209+
convert_pytorch_module_to_maxtext_params,
210+
)
211+
212+
with _hf_reference_modules() as (kimi_linear_config, kimi_mla_attention):
213+
pt_config = kimi_linear_config(
214+
hidden_size=HIDDEN,
215+
num_attention_heads=NUM_HEADS,
216+
num_key_value_heads=NUM_HEADS,
217+
q_lora_rank=Q_LORA_RANK,
218+
kv_lora_rank=KV_LORA_RANK,
219+
qk_nope_head_dim=QK_NOPE_HEAD_DIM,
220+
qk_rope_head_dim=QK_ROPE_HEAD_DIM,
221+
v_head_dim=V_HEAD_DIM,
222+
mla_use_nope=True,
223+
mla_use_output_gate=True,
224+
rms_norm_eps=RMS_NORM_EPS,
225+
)
226+
pt_config._attn_implementation = "eager" # pylint: disable=protected-access
227+
torch.manual_seed(42)
228+
pt_mla = kimi_mla_attention(pt_config, layer_idx=3).eval()
229+
# Non-trivial RMSNorm scales so the norm mapping is exercised too.
230+
with torch.no_grad():
231+
pt_mla.q_a_layernorm.weight.uniform_(0.5, 1.5)
232+
pt_mla.kv_a_layernorm.weight.uniform_(0.5, 1.5)
233+
234+
pt_input = torch.randn(BATCH, SEQ_LEN, HIDDEN, dtype=torch.float32)
235+
causal_mask = torch.triu(torch.full((BATCH, 1, SEQ_LEN, SEQ_LEN), float("-inf")), diagonal=1)
236+
with torch.no_grad():
237+
pt_out = pt_mla(pt_input, attention_mask=causal_mask)
238+
pt_out = (pt_out[0] if isinstance(pt_out, tuple) else pt_out).numpy()
239+
240+
# Flat dict consumed by PARAM_MAPPING / HOOK_FNS["kimi-k3"]; layer 3 is the full-attention (MLA) layer.
241+
mapping_config = {
242+
"num_hidden_layers": 4,
243+
"first_k_dense_replace": 4,
244+
"full_attn_layers": [3],
245+
"hidden_size": HIDDEN,
246+
"num_attention_heads": NUM_HEADS,
247+
"num_key_value_heads": NUM_HEADS,
248+
"q_lora_rank": Q_LORA_RANK,
249+
"kv_lora_rank": KV_LORA_RANK,
250+
"qk_nope_head_dim": QK_NOPE_HEAD_DIM,
251+
"qk_rope_head_dim": QK_ROPE_HEAD_DIM,
252+
"v_head_dim": V_HEAD_DIM,
253+
"rms_norm_eps": RMS_NORM_EPS,
254+
}
255+
params = convert_pytorch_module_to_maxtext_params(
256+
pt_mla.state_dict(), mapping_config, None, prefix_filter=self.layer_prefix
257+
)
258+
expected = {
259+
"wq_a": "kernel",
260+
"q_norm": "scale",
261+
"wq_b": "kernel",
262+
"wkv_a": "kernel",
263+
"kv_norm": "scale",
264+
"wkv_b": "kernel",
265+
"g_proj": "kernel",
266+
"out": "kernel",
267+
}
268+
missing = [
269+
f"{self.layer_prefix}-{m}-{p}" for m, p in expected.items() if f"{self.layer_prefix}-{m}-{p}" not in params
270+
]
271+
self.assertFalse(missing, f"Unconverted params: {missing}")
272+
273+
cfg = _make_config()
274+
mesh = Mesh(maxtext_utils.create_device_mesh(cfg), cfg.mesh_axes)
275+
nope = _make_mla(cfg, mesh, use_output_gate=True, is_nope_layer=True)
276+
rope = _make_mla(cfg, mesh, use_output_gate=True, is_nope_layer=False)
277+
for mla in (nope, rope):
278+
for module, param in expected.items():
279+
getattr(getattr(mla, module), param)[...] = jnp.asarray(params[f"{self.layer_prefix}-{module}-{param}"])
280+
281+
x = jnp.asarray(pt_input.numpy())
282+
positions = _arange_positions()
283+
np.testing.assert_allclose(_run(nope, x, positions), pt_out, rtol=1e-4, atol=1e-4)
284+
# Negative check: applying RoPE at non-zero positions must diverge from the NoPE reference.
285+
self.assertGreater(np.max(np.abs(_run(rope, x, positions) - pt_out)), 1e-3)
286+
287+
288+
if __name__ == "__main__":
289+
unittest.main()

0 commit comments

Comments
 (0)