Skip to content

Commit d17c8e2

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. TAG=agy CONV=67891ee2-c466-4bf5-a353-bce763286f24
1 parent f341ad9 commit d17c8e2

4 files changed

Lines changed: 313 additions & 3 deletions

File tree

‎src/maxtext/configs/base.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,7 @@ use_post_ffw_norm: false
506506
v_norm_with_scale: true
507507
qk_norm_with_scale: true
508508
mla_naive_kvcache: true
509+
mla_use_output_gate: false
509510

510511
# Gemma 4 small (E2B / E4B) specific config fields.
511512
# When `hidden_size_per_layer_input` is positive, each decoder layer adds an

‎src/maxtext/configs/types.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,7 @@ class MlaAttention(BaseModel):
834834
qk_nope_head_dim: NonNegativeInt = Field(128, description="Dimension for non-RoPE part of QK heads in MLA.")
835835
qk_rope_head_dim: NonNegativeInt = Field(64, description="Dimension for RoPE part of QK heads in MLA.")
836836
v_head_dim: NonNegativeInt = Field(128, description="Dimension of V heads in MLA.")
837+
mla_use_output_gate: bool = Field(False, description="Whether to use output gate in MLA attention.")
837838
use_sliced_mla_proj: bool = Field(
838839
False,
839840
description=(

‎src/maxtext/layers/attention_mla.py‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,7 @@ def __init__(
519519
rope_factor: float = 40.0, # rotary embedding factor
520520
name: str | None = None,
521521
rngs: Optional[nnx.Rngs] = None,
522+
mla_use_output_gate: bool = False,
522523
):
523524
"""Initializes the MLA module.
524525
@@ -540,6 +541,7 @@ def __init__(
540541
self.original_max_position_embeddings = original_max_position_embeddings
541542
self.mscale = mscale
542543
self.rope_factor = rope_factor
544+
self.use_output_gate = mla_use_output_gate or getattr(config, "mla_use_output_gate", False)
543545

544546
self.qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
545547

@@ -757,6 +759,23 @@ def _init_projections(self, inputs_q_shape: Tuple, inputs_kv_shape: Tuple) -> No
757759
mscale = 0.1 * self.mscale * math.log(self.rope_factor) + 1.0
758760
self.softmax_scale = self.softmax_scale * mscale * mscale
759761

762+
if self.use_output_gate:
763+
self.g_proj = DenseGeneral(
764+
in_features_shape=self.config.emb_dim,
765+
out_features_shape=(self.num_query_heads, self.v_head_dim),
766+
axis=-1,
767+
kernel_init=self.kernel_init,
768+
kernel_axes=("embed", "q_heads", "kv"),
769+
dtype=self.dtype,
770+
weight_dtype=self.weight_dtype,
771+
quant=self.quant,
772+
matmul_precision=self.config.matmul_precision,
773+
shard_mode=self.config.shard_mode,
774+
rngs=self.rngs,
775+
)
776+
else:
777+
self.g_proj = None
778+
760779
self.out = self.init_out_w(output_dim=inputs_q_shape[-1])
761780

762781
def _attention_op_num_kv_heads(self) -> int:
@@ -858,7 +877,10 @@ def mla_query_projection(
858877
# Partial RoPE: Split into non-positional and rotary parts.
859878
# last dimension: qk_nope_head_dim, qk_rope_head_dim
860879
q_nope = self._maybe_shard_with_logical(q_nope, query_logical_name)
861-
q_pe = self.apply_rotary_embedding(q_pe, inputs_positions=inputs_positions)
880+
# NoPE layers (e.g. Kimi-K3) still carry a `qk_rope_head_dim` slice through the
881+
# projections, but never apply the rotary transform to it.
882+
if not self.is_nope_layer:
883+
q_pe = self.apply_rotary_embedding(q_pe, inputs_positions=inputs_positions)
862884
q_pe = self._maybe_shard_with_logical(q_pe, query_logical_name)
863885
# Query projection is scaled by self.softmax_scale to be consistent MaxText implementation.
864886
# DeepSeek v3 was doing it in attention score computation.
@@ -1012,9 +1034,10 @@ def mla_kv_projection(self, inputs: Array, inputs_positions: Array, decoder_segm
10121034
low_rank_main, low_rank_rope = jnp.split(low_rank, [self.kv_lora_rank], axis=-1)
10131035
low_rank_main = self.kv_norm(low_rank_main)
10141036
low_rank_main = checkpoint_name(low_rank_main, "mla_kv")
1015-
# Apply rotary embedding to key_rope.
1037+
# Apply rotary embedding to key_rope (skipped entirely for NoPE layers).
10161038
key_rope = jnp.expand_dims(low_rank_rope, axis=2)
1017-
key_rope = self.apply_rotary_embedding(key_rope, inputs_positions=inputs_positions)
1039+
if not self.is_nope_layer:
1040+
key_rope = self.apply_rotary_embedding(key_rope, inputs_positions=inputs_positions)
10181041

10191042
key, value = self._build_key_value(low_rank_main, key_rope, model_mode)
10201043
cached_values = [None, None]
@@ -1288,7 +1311,16 @@ def __call__(
12881311
out = self._maybe_shard_with_logical(out, self.out_axis_names)
12891312
out = jax.ad_checkpoint.checkpoint_name(out, "attention_out")
12901313

1314+
if self.use_output_gate and self.g_proj is not None:
1315+
g = self.g_proj(inputs_q)
1316+
g = jax.nn.sigmoid(g)
1317+
out = out * g
1318+
out = jax.ad_checkpoint.checkpoint_name(out, "gated_attention_out")
1319+
12911320
out_sharding = create_sharding(self.mesh, out_logical_name)
12921321
out = self.out_projection(out, out_sharding=out_sharding)
12931322
out = checkpoint_name(out, "out_proj")
12941323
return out, kv_cache
1324+
1325+
1326+
KimiMLAAttention = MLA

‎tests/unit/test_kimi_k3_g2_mla.py‎

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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+
"""Unit tests for Kimi-K3 Group 2: Gated Multi-Head Latent Attention (MLA)."""
16+
17+
import os
18+
import sys
19+
import unittest
20+
from flax import nnx
21+
import jax.numpy as jnp
22+
from jax.sharding import Mesh
23+
import numpy as np
24+
import torch
25+
26+
from maxtext.checkpoint_conversion.utils.param_mapping import PARAM_MAPPING, HOOK_FNS
27+
from maxtext.configs import pyconfig
28+
from maxtext.layers import attention_mla
29+
from maxtext.utils import maxtext_utils
30+
from tests.utils.test_helpers import get_test_config_path
31+
from tests.utils.kimi_k3_reference import requires_kimi_k3_reference
32+
33+
34+
def convert_pytorch_module_to_maxtext_params(pt_state_dict, hf_config, maxtext_config, prefix_filter=None):
35+
"""Converts PyTorch state dict tensors to MaxText param arrays using Kimi-K3 mappings and hooks."""
36+
mapping_fn = PARAM_MAPPING["kimi-k3"]
37+
hook_fn_factory = HOOK_FNS["kimi-k3"]
38+
39+
param_map = mapping_fn(hf_config, maxtext_config, scan_layers=False)
40+
hook_map = hook_fn_factory(hf_config, maxtext_config, scan_layers=False, saving_to_hf=False)
41+
42+
converted_params = {}
43+
for mt_key, hf_target in param_map.items():
44+
if prefix_filter and not mt_key.startswith(prefix_filter):
45+
continue
46+
47+
if isinstance(hf_target, str):
48+
matching_key = None
49+
for k in pt_state_dict.keys():
50+
if hf_target.endswith(k):
51+
matching_key = k
52+
break
53+
54+
if matching_key in pt_state_dict:
55+
tensor_np = pt_state_dict[matching_key].detach().cpu().float().numpy()
56+
if mt_key in hook_map:
57+
tensor_np = hook_map[mt_key](tensor_np)
58+
converted_params[mt_key] = tensor_np
59+
60+
return converted_params
61+
62+
63+
class KimiK3G2MLATest(unittest.TestCase):
64+
"""Unit tests for Kimi-K3 Gated MLA Attention layer."""
65+
66+
def setUp(self):
67+
super().setUp()
68+
self.batch_size = 2
69+
self.seq_len = 16
70+
self.hidden_size = 7168
71+
self.num_heads = 96
72+
self.head_dim = 128
73+
self.qk_rope_head_dim = 64
74+
self.q_lora_rank = 1536
75+
self.kv_lora_rank = 512
76+
self.rms_norm_eps = 1e-5
77+
78+
self.hf_config = {
79+
"text_config": {
80+
"num_hidden_layers": 4,
81+
"hidden_size": self.hidden_size,
82+
"num_attention_heads": self.num_heads,
83+
"num_key_value_heads": self.num_heads,
84+
"q_lora_rank": self.q_lora_rank,
85+
"kv_lora_rank": self.kv_lora_rank,
86+
"qk_nope_head_dim": self.head_dim,
87+
"qk_rope_head_dim": self.qk_rope_head_dim,
88+
"v_head_dim": self.head_dim,
89+
"rms_norm_eps": self.rms_norm_eps,
90+
"mla_use_output_gate": True,
91+
"linear_attn_config": {
92+
"num_layers": 4,
93+
"layer_indices": [0, 1, 2],
94+
},
95+
}
96+
}
97+
98+
@requires_kimi_k3_reference
99+
def test_gated_mla_numerical_parity(self):
100+
"""Tests numerical parity between MaxText MLA and PyTorch reference KimiMLAAttention."""
101+
ref_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../kimi-k3-hf-reference"))
102+
if ref_dir not in sys.path:
103+
sys.path.insert(0, ref_dir)
104+
from configuration_kimi_k3 import KimiLinearConfig # pylint: disable=import-outside-toplevel
105+
from modeling_kimi_linear import KimiMLAAttention # pylint: disable=import-outside-toplevel
106+
107+
# 1. PyTorch Reference Setup
108+
pt_config = KimiLinearConfig(
109+
hidden_size=self.hidden_size,
110+
num_attention_heads=self.num_heads,
111+
num_key_value_heads=self.num_heads,
112+
q_lora_rank=self.q_lora_rank,
113+
kv_lora_rank=self.kv_lora_rank,
114+
qk_nope_head_dim=self.head_dim,
115+
qk_rope_head_dim=self.qk_rope_head_dim,
116+
v_head_dim=self.head_dim,
117+
mla_use_nope=True,
118+
mla_use_output_gate=True,
119+
rms_norm_eps=self.rms_norm_eps,
120+
)
121+
pt_config._attn_implementation = "eager" # pylint: disable=protected-access
122+
pt_mla = KimiMLAAttention(pt_config, layer_idx=3)
123+
pt_mla.eval()
124+
125+
# 2. PyTorch Forward Pass
126+
torch.manual_seed(42)
127+
pt_input = torch.randn(self.batch_size, self.seq_len, self.hidden_size, dtype=torch.float32)
128+
causal_mask = torch.triu(
129+
torch.full((self.batch_size, 1, self.seq_len, self.seq_len), float("-inf"), dtype=torch.float32),
130+
diagonal=1,
131+
)
132+
with torch.no_grad():
133+
pt_out = pt_mla(pt_input, attention_mask=causal_mask)
134+
if isinstance(pt_out, tuple):
135+
pt_out = pt_out[0]
136+
137+
# 3. Convert PyTorch weights to MaxText params
138+
converted_params = convert_pytorch_module_to_maxtext_params(
139+
pt_mla.state_dict(),
140+
self.hf_config,
141+
None,
142+
prefix_filter="params-decoder-layers_3-self_attention",
143+
)
144+
145+
# 4. MaxText MLA Setup
146+
cfg = pyconfig.initialize(
147+
[None, get_test_config_path()],
148+
run_name="kimi_k3_mla_test",
149+
model_name="default",
150+
attention="dot_product",
151+
attention_type="mla",
152+
base_emb_dim=self.hidden_size,
153+
emb_dim=self.hidden_size,
154+
num_query_heads=self.num_heads,
155+
num_kv_heads=self.num_heads,
156+
head_dim=self.head_dim,
157+
q_lora_rank=self.q_lora_rank,
158+
kv_lora_rank=self.kv_lora_rank,
159+
qk_nope_head_dim=self.head_dim,
160+
qk_rope_head_dim=self.qk_rope_head_dim,
161+
v_head_dim=self.head_dim,
162+
mla_use_output_gate=True,
163+
mla_naive_kvcache=True,
164+
dtype="float32",
165+
weight_dtype="float32",
166+
matmul_precision="highest",
167+
normalization_layer_epsilon=self.rms_norm_eps,
168+
max_target_length=self.seq_len,
169+
max_prefill_predict_length=self.seq_len,
170+
global_batch_size_to_train_on=self.batch_size,
171+
per_device_batch_size=self.batch_size,
172+
rope_max_timescale=500000.0,
173+
max_position_embeddings=1048576,
174+
original_max_position_embeddings=1048576,
175+
enable_checkpointing=False,
176+
)
177+
178+
devices_array = maxtext_utils.create_device_mesh(cfg)
179+
mesh = Mesh(devices_array, cfg.mesh_axes)
180+
nnx_rng = nnx.Rngs(params=0, dropout=0)
181+
182+
jax_mla = attention_mla.MLA(
183+
config=cfg,
184+
num_query_heads=self.num_heads,
185+
num_kv_heads=self.num_heads,
186+
head_dim=self.head_dim,
187+
inputs_q_shape=(self.batch_size, self.seq_len, self.hidden_size),
188+
inputs_kv_shape=(self.batch_size, self.seq_len, self.hidden_size),
189+
max_target_length=self.seq_len,
190+
max_prefill_predict_length=self.seq_len,
191+
mesh=mesh,
192+
attention_kernel="dot_product",
193+
dtype=jnp.float32,
194+
weight_dtype=jnp.float32,
195+
attention_type="mla",
196+
mla_use_output_gate=True,
197+
q_lora_rank=self.q_lora_rank,
198+
kv_lora_rank=self.kv_lora_rank,
199+
qk_nope_head_dim=self.head_dim,
200+
qk_rope_head_dim=self.qk_rope_head_dim,
201+
v_head_dim=self.head_dim,
202+
max_position_embeddings=1048576,
203+
original_max_position_embeddings=1048576,
204+
mscale=1.0,
205+
rope_factor=40.0,
206+
rngs=nnx_rng,
207+
)
208+
209+
# 5. Populate MaxText MLA weights with converted parameters
210+
jax_mla.wq_a.kernel.value = jnp.asarray(converted_params["params-decoder-layers_3-self_attention-wq_a-kernel"])
211+
jax_mla.q_norm.scale.value = jnp.asarray(converted_params["params-decoder-layers_3-self_attention-q_norm-scale"])
212+
jax_mla.wq_b.kernel.value = jnp.asarray(converted_params["params-decoder-layers_3-self_attention-wq_b-kernel"])
213+
jax_mla.wkv_a.kernel.value = jnp.asarray(converted_params["params-decoder-layers_3-self_attention-wkv_a-kernel"])
214+
jax_mla.kv_norm.scale.value = jnp.asarray(converted_params["params-decoder-layers_3-self_attention-kv_norm-scale"])
215+
jax_mla.wkv_b.kernel.value = jnp.asarray(converted_params["params-decoder-layers_3-self_attention-wkv_b-kernel"])
216+
jax_mla.g_proj.kernel.value = jnp.asarray(converted_params["params-decoder-layers_3-self_attention-g_proj-kernel"])
217+
jax_mla.out.kernel.value = jnp.asarray(converted_params["params-decoder-layers_3-self_attention-out-kernel"])
218+
219+
# 6. MaxText Forward Pass
220+
x_jax = jnp.asarray(pt_input.detach().cpu().numpy())
221+
positions = jnp.zeros((self.batch_size, self.seq_len), dtype=jnp.int32)
222+
decoder_segment_ids = jnp.zeros((self.batch_size, self.seq_len), dtype=jnp.int32)
223+
224+
jax_out, _ = jax_mla(
225+
inputs_q=x_jax,
226+
inputs_kv=x_jax,
227+
inputs_positions=positions,
228+
decoder_segment_ids=decoder_segment_ids,
229+
model_mode="train",
230+
)
231+
232+
# 7. Numerical Parity Assertion
233+
np.testing.assert_allclose(
234+
np.asarray(jax_out),
235+
pt_out.detach().cpu().numpy(),
236+
rtol=1e-4,
237+
atol=1e-4,
238+
)
239+
240+
def test_param_mapping_shapes(self):
241+
"""Verifies that Group 2 parameter mappings and transformation hooks produce correct MaxText shapes."""
242+
hooks = HOOK_FNS["kimi-k3"](self.hf_config, None, scan_layers=False, saving_to_hf=False)
243+
244+
# Test wq_a: [1536, 7168] -> [7168, 1536]
245+
pt_wq_a = np.zeros((self.q_lora_rank, self.hidden_size), dtype=np.float32)
246+
mt_wq_a = hooks["params-decoder-layers_3-self_attention-wq_a-kernel"](pt_wq_a)
247+
self.assertEqual(mt_wq_a.shape, (self.hidden_size, self.q_lora_rank))
248+
249+
# Test wq_b: [96 * 192, 1536] -> [1536, 96, 192]
250+
pt_wq_b = np.zeros((self.num_heads * (self.head_dim + self.qk_rope_head_dim), self.q_lora_rank), dtype=np.float32)
251+
mt_wq_b = hooks["params-decoder-layers_3-self_attention-wq_b-kernel"](pt_wq_b)
252+
self.assertEqual(mt_wq_b.shape, (self.q_lora_rank, self.num_heads, self.head_dim + self.qk_rope_head_dim))
253+
254+
# Test wkv_a: [512 + 64, 7168] -> [7168, 576]
255+
pt_wkv_a = np.zeros((self.kv_lora_rank + self.qk_rope_head_dim, self.hidden_size), dtype=np.float32)
256+
mt_wkv_a = hooks["params-decoder-layers_3-self_attention-wkv_a-kernel"](pt_wkv_a)
257+
self.assertEqual(mt_wkv_a.shape, (self.hidden_size, self.kv_lora_rank + self.qk_rope_head_dim))
258+
259+
# Test wkv_b: [96 * (128 + 128), 512] -> [512, 96, 256]
260+
pt_wkv_b = np.zeros((self.num_heads * (self.head_dim + self.head_dim), self.kv_lora_rank), dtype=np.float32)
261+
mt_wkv_b = hooks["params-decoder-layers_3-self_attention-wkv_b-kernel"](pt_wkv_b)
262+
self.assertEqual(mt_wkv_b.shape, (self.kv_lora_rank, self.num_heads, self.head_dim + self.head_dim))
263+
264+
# Test g_proj: [96 * 128, 7168] -> [7168, 96, 128]
265+
pt_g_proj = np.zeros((self.num_heads * self.head_dim, self.hidden_size), dtype=np.float32)
266+
mt_g_proj = hooks["params-decoder-layers_3-self_attention-g_proj-kernel"](pt_g_proj)
267+
self.assertEqual(mt_g_proj.shape, (self.hidden_size, self.num_heads, self.head_dim))
268+
269+
# Test out: [7168, 96 * 128] -> [96, 128, 7168]
270+
pt_out = np.zeros((self.hidden_size, self.num_heads * self.head_dim), dtype=np.float32)
271+
mt_out = hooks["params-decoder-layers_3-self_attention-out-kernel"](pt_out)
272+
self.assertEqual(mt_out.shape, (self.num_heads, self.head_dim, self.hidden_size))
273+
274+
275+
if __name__ == "__main__":
276+
unittest.main()

0 commit comments

Comments
 (0)