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