[Model]Fix MiniMaxM2ForCausalLM perf regression (#45935)
Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>
Jee Jee Li committed
Jun 22, 2026 at 00:28 UTC
745bba5ea8fa17dd6ae3751daf43c3d1bd8522df
2 files changed
+215
-16
tests/kernels/core/test_minimax_reduce_rms.py
+59
-3
@@ -10,8 +10,12 @@ from torch.multiprocessing import spawn
10
from tests.kernels.utils import opcheck
11
from tests.utils import ensure_current_vllm_config, init_test_distributed_environment
12
from vllm.distributed import cleanup_dist_env_and_memory
13
-from vllm.model_executor.layers.minimax_rms_norm import MiniMaxText01RMSNormTP
13
+from vllm.model_executor.layers.minimax_rms_norm import (
14
+ MiniMaxText01RMSNormTP,
15
+ rms_norm_tp,
16
+)
17
from vllm.platforms import current_platform
18
+from vllm.triton_utils import HAS_TRITON
19
from vllm.utils.network_utils import get_open_port
20
from vllm.utils.torch_utils import set_random_seed
21
@@ -54,8 +58,19 @@ def _worker_forward_qk(
58
torch.manual_seed(seed + 1000 + local_rank)
59
qkv = torch.randn(num_tokens, hq + hk + hk, dtype=dtype, device="cuda")
60
57
- q_ref, k_ref, v_ref = qkv.clone().split([hq, hk, hk], dim=-1)
58
- ref_q, ref_k = MiniMaxText01RMSNormTP.forward_qk(q_norm, k_norm, q_ref, k_ref)
61
+ # Reference: eager all-reduce path. ``forward_qk`` no longer all-reduces
62
+ # the variance (it is the tp==1 / already-reduced building block), so the
63
+ # multi-rank reference must use the eager path that performs the global
64
+ # variance all-reduce, matching the fused kernel below.
65
+ ref_q, ref_k = rms_norm_tp._minimax_qk_norm_tp_eager(
66
+ qkv.clone(),
67
+ q_norm.weight,
68
+ k_norm.weight,
69
+ hq,
70
+ hk,
71
+ world_size,
72
+ eps,
73
+ )
74
75
# Set up Lamport workspace.
76
from vllm.distributed.parallel_state import get_tp_group
@@ -150,3 +165,44 @@ def test_minimax_reduce_rms_qk(
165
nprocs=world_size,
166
join=True,
167
)
168
+
169
+
170
+@pytest.mark.skipif(
171
+ not current_platform.is_cuda() or not HAS_TRITON,
172
+ reason="CUDA and Triton required",
173
+)
174
+@pytest.mark.parametrize("num_tokens", [1, 7, 128, 333, 2049])
175
+@pytest.mark.parametrize("hidden_dims", [(3072, 512), (768, 256), (3000, 500)])
176
+@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
177
+@pytest.mark.parametrize("tp_world", [1, 4, 8])
178
+@pytest.mark.parametrize("eps", [1e-6])
179
+@pytest.mark.parametrize("seed", [42])
180
+def test_minimax_qk_norm_triton_fallback(
181
+ monkeypatch, num_tokens, hidden_dims, dtype, tp_world, eps, seed
182
+):
183
+ """Single-GPU check: Triton fallback kernels vs the pure-torch reference.
184
+
185
+ The all-reduce is a TP communication barrier, so it is monkeypatched to
186
+ identity here; both the Triton path and the reference see the same
187
+ (patched) reduction. This validates the kernel math and the folded
188
+ ``/ tp_world`` scaling without needing multiple ranks -- ``hidden_dims``
189
+ are the per-rank q/k segment widths.
190
+ """
191
+ monkeypatch.setattr(rms_norm_tp, "_all_reduce_variance", lambda v: v)
192
+
193
+ q_size, kv_size = hidden_dims
194
+ device = "cuda"
195
+ torch.manual_seed(seed)
196
+ qkv = torch.randn(num_tokens, q_size + 2 * kv_size, dtype=dtype, device=device)
197
+ q_weight = torch.randn(q_size, dtype=dtype, device=device)
198
+ k_weight = torch.randn(kv_size, dtype=dtype, device=device)
199
+
200
+ q_triton, k_triton = rms_norm_tp._minimax_qk_norm_tp_fallback(
201
+ qkv, q_weight, k_weight, q_size, kv_size, 0, tp_world, eps
202
+ )
203
+ q_ref, k_ref = rms_norm_tp._minimax_qk_norm_tp_eager(
204
+ qkv, q_weight, k_weight, q_size, kv_size, tp_world, eps
205
+ )
206
+
207
+ torch.testing.assert_close(q_triton, q_ref, atol=3e-2, rtol=3e-2)
208
+ torch.testing.assert_close(k_triton, k_ref, atol=3e-2, rtol=3e-2)
vllm/model_executor/layers/minimax_rms_norm/rms_norm_tp.py
+156
-13
@@ -14,7 +14,7 @@ from vllm.distributed.parallel_state import (
14
)
15
from vllm.logger import init_logger
16
from vllm.model_executor.custom_op import CustomOp
17
-from vllm.platforms import current_platform
17
+from vllm.triton_utils import HAS_TRITON, tl, triton
18
from vllm.utils.torch_utils import direct_register_custom_op
19
20
logger = init_logger(__name__)
@@ -40,32 +40,171 @@ def _all_reduce_variance(var: torch.Tensor) -> torch.Tensor:
40
return tensor_model_parallel_all_reduce(var.flatten()).view_as(var)
41
42
43
-@torch.compile(backend=current_platform.simple_compile_backend, dynamic=True)
44
-def _minimax_qk_norm_fallback(
43
+@triton.jit
44
+def _minimax_qk_var_kernel(
45
+ qkv_ptr, # [num_tokens, hidden], 16-bit activations
46
+ var_ptr, # [num_tokens, 2], fp32
47
+ row_stride, # element stride between tokens in qkv
48
+ q_size: tl.constexpr, # constant per deployment -> loops unroll, mask elides
49
+ kv_size: tl.constexpr,
50
+ BLOCK: tl.constexpr,
51
+):
52
+ """TP-pre stage: per-token mean-of-squares for the q and k segments.
53
+
54
+ Accumulates in fp32 while reading the 16-bit qkv in place, so no fp32
55
+ copy of q/k is materialized. ``var[:, 0]`` is the q variance and
56
+ ``var[:, 1]`` the k variance; both are the local-shard means, ready for
57
+ the all-reduce that follows.
58
+ """
59
+ token = tl.program_id(0)
60
+ base = qkv_ptr + token * row_stride
61
+
62
+ q_acc = 0.0
63
+ for off in range(0, q_size, BLOCK):
64
+ idx = off + tl.arange(0, BLOCK)
65
+ mask = idx < q_size
66
+ x = tl.load(base + idx, mask=mask, other=0.0).to(tl.float32)
67
+ q_acc += tl.sum(x * x, axis=0)
68
+
69
+ k_acc = 0.0
70
+ for off in range(0, kv_size, BLOCK):
71
+ idx = off + tl.arange(0, BLOCK)
72
+ mask = idx < kv_size
73
+ x = tl.load(base + q_size + idx, mask=mask, other=0.0).to(tl.float32)
74
+ k_acc += tl.sum(x * x, axis=0)
75
+
76
+ tl.store(var_ptr + token * 2 + 0, q_acc / q_size)
77
+ tl.store(var_ptr + token * 2 + 1, k_acc / kv_size)
78
+
79
+
80
+@triton.jit
81
+def _minimax_rms_apply_kernel(
82
+ qkv_ptr, # [num_tokens, hidden]
83
+ var_ptr, # [num_tokens, 2], fp32, all-reduced sum of per-shard means
84
+ q_w_ptr, # [q_size], q per-channel weight
85
+ k_w_ptr, # [kv_size], k per-channel weight
86
+ q_out_ptr, # [num_tokens, q_size], contiguous
87
+ k_out_ptr, # [num_tokens, kv_size], contiguous
88
+ row_stride, # element stride between tokens in qkv
89
+ q_size: tl.constexpr, # constant per deployment -> loops unroll, mask elides
90
+ kv_size: tl.constexpr,
91
+ tp_world: tl.constexpr, # folds the post-all-reduce /tp_world into rsqrt
92
+ eps: tl.constexpr,
93
+ BLOCK: tl.constexpr,
94
+):
95
+ """TP-post stage: ``x * rsqrt(var / tp_world + eps) * weight``.
96
+
97
+ A single program normalizes both the q and k segments of one token, so q
98
+ and k share one launch instead of two. The all-reduce yields the sum of
99
+ per-shard means, so the ``/ tp_world`` that recovers the global
100
+ mean-of-squares is folded into the ``rsqrt`` here rather than run as a
101
+ separate elementwise pass over the ``[num_tokens, 2]`` variance tensor.
102
+ """
103
+ token = tl.program_id(0)
104
+ base = qkv_ptr + token * row_stride
105
+
106
+ q_inv = tl.rsqrt(tl.load(var_ptr + token * 2 + 0) / tp_world + eps)
107
+ q_out_row = q_out_ptr + token * q_size
108
+ for off in range(0, q_size, BLOCK):
109
+ idx = off + tl.arange(0, BLOCK)
110
+ mask = idx < q_size
111
+ x = tl.load(base + idx, mask=mask, other=0.0).to(tl.float32)
112
+ w = tl.load(q_w_ptr + idx, mask=mask, other=0.0).to(tl.float32)
113
+ y = x * q_inv * w
114
+ tl.store(q_out_row + idx, y.to(q_out_ptr.dtype.element_ty), mask=mask)
115
+
116
+ k_inv = tl.rsqrt(tl.load(var_ptr + token * 2 + 1) / tp_world + eps)
117
+ k_out_row = k_out_ptr + token * kv_size
118
+ for off in range(0, kv_size, BLOCK):
119
+ idx = off + tl.arange(0, BLOCK)
120
+ mask = idx < kv_size
121
+ x = tl.load(base + q_size + idx, mask=mask, other=0.0).to(tl.float32)
122
+ w = tl.load(k_w_ptr + idx, mask=mask, other=0.0).to(tl.float32)
123
+ y = x * k_inv * w
124
+ tl.store(k_out_row + idx, y.to(k_out_ptr.dtype.element_ty), mask=mask)
125
+
126
+
127
+def _minimax_qk_norm_tp_eager(
128
qkv: torch.Tensor,
129
q_weight: torch.Tensor,
130
k_weight: torch.Tensor,
131
q_size: int,
132
kv_size: int,
50
- tp_rank: int,
133
tp_world: int,
134
eps: float,
135
) -> tuple[torch.Tensor, torch.Tensor]:
136
+ """Pure-torch reference path used when Triton is unavailable."""
137
q, k, _ = qkv.split([q_size, kv_size, kv_size], dim=-1)
138
orig_dtype = q.dtype
139
q = q.to(torch.float32)
140
k = k.to(torch.float32)
141
q_var = q.pow(2).mean(dim=-1, keepdim=True)
142
k_var = k.pow(2).mean(dim=-1, keepdim=True)
60
- if tp_world > 1:
61
- qk_var = torch.cat([q_var, k_var], dim=-1)
62
- qk_var = _all_reduce_variance(qk_var) / tp_world
63
- q_var, k_var = qk_var.chunk(2, dim=-1)
143
+
144
+ qk_var = torch.cat([q_var, k_var], dim=-1)
145
+ qk_var = _all_reduce_variance(qk_var) / tp_world
146
+ q_var, k_var = qk_var.chunk(2, dim=-1)
147
q = q * torch.rsqrt(q_var + eps) * q_weight
148
k = k * torch.rsqrt(k_var + eps) * k_weight
149
return q.to(orig_dtype), k.to(orig_dtype)
150
151
152
+def _minimax_qk_norm_tp_fallback(
153
+ qkv: torch.Tensor,
154
+ q_weight: torch.Tensor,
155
+ k_weight: torch.Tensor,
156
+ q_size: int,
157
+ kv_size: int,
158
+ tp_rank: int,
159
+ tp_world: int,
160
+ eps: float,
161
+) -> tuple[torch.Tensor, torch.Tensor]:
162
+ """All-reduce + QK RMSNorm without the Lamport fused kernel.
163
+
164
+ The all-reduce is a TP communication barrier and cannot live inside a
165
+ single kernel, so the eager-torch path is split into two Triton kernels
166
+ around it: a variance reduction before the all-reduce and a normalize
167
+ after. Compared to the ``torch.compile`` path this avoids materializing
168
+ fp32 copies of q/k and the ``cat``/``chunk`` temporaries.
169
+ """
170
+ if not HAS_TRITON:
171
+ return _minimax_qk_norm_tp_eager(
172
+ qkv, q_weight, k_weight, q_size, kv_size, tp_world, eps
173
+ )
174
+
175
+ num_tokens = qkv.shape[0]
176
+ row_stride = qkv.stride(0)
177
+ BLOCK = 1024
178
+ grid = (num_tokens,)
179
+
180
+ qk_var = torch.empty(num_tokens, 2, dtype=torch.float32, device=qkv.device)
181
+ _minimax_qk_var_kernel[grid](
182
+ qkv, qk_var, row_stride, q_size=q_size, kv_size=kv_size, BLOCK=BLOCK
183
+ )
184
+
185
+ # All-reduce sums the per-shard means; the /tp_world that turns this back
186
+ # into the global mean is folded into the apply kernel's rsqrt below.
187
+ qk_var = _all_reduce_variance(qk_var)
188
+
189
+ q_out = torch.empty(num_tokens, q_size, dtype=qkv.dtype, device=qkv.device)
190
+ k_out = torch.empty(num_tokens, kv_size, dtype=qkv.dtype, device=qkv.device)
191
+ _minimax_rms_apply_kernel[grid](
192
+ qkv,
193
+ qk_var,
194
+ q_weight,
195
+ k_weight,
196
+ q_out,
197
+ k_out,
198
+ row_stride,
199
+ q_size=q_size,
200
+ kv_size=kv_size,
201
+ tp_world=tp_world,
202
+ eps=eps,
203
+ BLOCK=BLOCK,
204
+ )
205
+ return q_out, k_out
206
+
207
+
208
def _minimax_qk_norm_fusion(
209
qkv: torch.Tensor,
210
q_weight: torch.Tensor,
@@ -96,7 +235,7 @@ def _minimax_qk_norm_fusion(
235
tp_world,
236
eps,
237
)
99
- return _minimax_qk_norm_fallback(
238
+ return _minimax_qk_norm_tp_fallback(
239
qkv, q_weight, k_weight, q_size, kv_size, tp_rank, tp_world, eps
240
)
241
@@ -231,10 +370,7 @@ class MiniMaxText01RMSNormTP(CustomOp):
370
k = k.to(torch.float32)
371
q_var = q.pow(2).mean(dim=-1, keepdim=True)
372
k_var = k.pow(2).mean(dim=-1, keepdim=True)
234
- if q_norm.tp_world > 1:
235
- qk_var = torch.cat([q_var, k_var], dim=-1)
236
- qk_var = _all_reduce_variance(qk_var) / q_norm.tp_world
237
- q_var, k_var = qk_var.chunk(2, dim=-1)
373
+
374
q = q * torch.rsqrt(q_var + q_norm.variance_epsilon) * q_norm.weight
375
k = k * torch.rsqrt(k_var + k_norm.variance_epsilon) * k_norm.weight
376
q = q.to(orig_dtype)
@@ -250,7 +386,14 @@ class MiniMaxText01RMSNormTP(CustomOp):
386
kv_size: int,
387
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
388
assert qkv.ndim == 2
389
+
390
assert q_norm.variance_epsilon == k_norm.variance_epsilon
391
+ # Case 0: tp_size=1
392
+ if get_tensor_model_parallel_world_size() == 1:
393
+ q, k, v = qkv.split([q_size, kv_size, kv_size], dim=-1)
394
+ q, k = MiniMaxText01RMSNormTP.forward_qk(q_norm, k_norm, q, k)
395
+ return q, k, v
396
+ # Case : tp_size>1
397
q, k = torch.ops.vllm.minimax_qk_norm_fusion(
398
qkv,
399
q_norm.weight,