[ROCm][DSv4.1][Perf] Run the delayed mHC seams through aiter's fused Triton kernel (#58655)
Signed-off-by: Ahmed <Muhammad.Ahmed@amd.com>
Muhammad Ahmed committed
Sep 28, 2026 at 22:19 UTC
0af34418e99b972029b53130c9a9665b4e692536
6 files changed
+324
-16
tests/kernels/mhc/test_mhc_kernels.py
+116
-8
@@ -27,6 +27,7 @@ from vllm.model_executor.layers.mhc import (
27
HAS_AITER_MHC,
28
HAS_AITER_MHC_FUSED,
29
HAS_AITER_MHC_FUSED_NORM,
30
+ HAS_AITER_MHC_FUSED_POST_PRE_DELAYED_RMS_NORM,
31
HAS_AITER_MHC_PRE_NORM,
32
HAS_TILELANG_MHC,
33
MHCFusedPostPreOp,
@@ -1300,18 +1301,109 @@ def test_mhc_pre_delayed_rocm_aiter(num_tokens, carried):
1301
torch.testing.assert_close(actual[2], expected[2], atol=0, rtol=0)
1302
1303
1304
+@pytest.mark.skipif(
1305
+ not (current_platform.is_rocm() and HAS_AITER_MHC_FUSED_POST_PRE_DELAYED_RMS_NORM),
1306
+ reason="AITER fused delayed-seam kernel required (gfx950)",
1307
+)
1308
+@pytest.mark.parametrize("num_tokens", [1, 2, 7, 128, 1024])
1309
+@pytest.mark.parametrize("seam", ["post", "no_post", "identity_pre"])
1310
+def test_mhc_pre_delayed_rocm_aiter_fused_rms_norm(num_tokens, seam):
1311
+ """One fused kernel must match the whole reference seam: the post block
1312
+ (the previous seam's post/comb gates fold ``sublayer_out`` into the
1313
+ streams), the delayed pre block (projection + Sinkhorn producing the next
1314
+ seam's post/comb/pre gates, plus the collapse with the carried pre-mix),
1315
+ and the RMSNorm folded onto the collapse.
1316
+
1317
+ ``num_tokens`` spans the kernel's split-K config buckets. ``seam`` covers
1318
+ the three call shapes the model makes: a regular seam (post folded in), an
1319
+ Engram seam (post already applied), and the draft entry seam
1320
+ (``pre_mix=None`` = identity pre-mix).
1321
+ """
1322
+ set_random_seed(0)
1323
+ hc_mult, hidden_size = 4, 5120
1324
+ residual, fn, hc_scale, hc_base, norm_weight = _rocm_mhc_inputs(
1325
+ num_tokens=num_tokens, hidden_size=hidden_size, hc_mult=hc_mult
1326
+ )
1327
+ rms_eps = hc_pre_eps = hc_sinkhorn_eps = norm_eps = 1e-6
1328
+ args = (
1329
+ residual,
1330
+ fn,
1331
+ hc_scale,
1332
+ hc_base,
1333
+ rms_eps,
1334
+ hc_pre_eps,
1335
+ hc_sinkhorn_eps,
1336
+ 1.0,
1337
+ 20,
1338
+ )
1339
+
1340
+ pre_mix = (
1341
+ None
1342
+ if seam == "identity_pre"
1343
+ else torch.rand(num_tokens, hc_mult, dtype=torch.float32, device=DEVICE) + 0.5
1344
+ )
1345
+ post_kwargs = {}
1346
+ residual_ref = residual
1347
+ if seam == "post":
1348
+ sublayer_out = torch.randn(
1349
+ num_tokens, hidden_size, dtype=torch.bfloat16, device=DEVICE
1350
+ )
1351
+ post_layer_mix = 2 * torch.rand(
1352
+ num_tokens, hc_mult, 1, dtype=torch.float32, device=DEVICE
1353
+ )
1354
+ comb_res_mix = torch.softmax(
1355
+ torch.randn(num_tokens, hc_mult, hc_mult, device=DEVICE), dim=-1
1356
+ )
1357
+ post_kwargs = dict(
1358
+ sublayer_out=sublayer_out,
1359
+ post_layer_mix=post_layer_mix,
1360
+ comb_res_mix=comb_res_mix,
1361
+ )
1362
+ residual_ref = mhc_post_torch(
1363
+ sublayer_out, residual, post_layer_mix, comb_res_mix
1364
+ )
1365
+
1366
+ expected = mhc_pre_delayed_torch(residual_ref, *args[1:], pre_mix=pre_mix)
1367
+ expected_layer_input = F.rms_norm(
1368
+ expected[2], (hidden_size,), norm_weight, norm_eps
1369
+ )
1370
+
1371
+ residual_out, *actual = object.__new__(MHCPreDelayedOp).forward_hip(
1372
+ *args,
1373
+ pre_mix=pre_mix,
1374
+ norm_weight=norm_weight,
1375
+ norm_eps=norm_eps,
1376
+ **post_kwargs,
1377
+ )
1378
+
1379
+ if seam == "post":
1380
+ torch.testing.assert_close(residual_out, residual_ref)
1381
+ else:
1382
+ # No post was requested, so the residual comes straight back.
1383
+ assert residual_out is residual
1384
+ # Gates: post_mix, comb_mix, next_pre_mix (bf16 MFMA vs the fp32 reference).
1385
+ for i in (0, 1, 3):
1386
+ torch.testing.assert_close(actual[i], expected[i], atol=5e-4, rtol=1e-3)
1387
+ # The collapse is bf16 and the kernel projects the once-rounded R', so
1388
+ # near-zero values need an absolute tolerance (same bound as aiter's UT).
1389
+ torch.testing.assert_close(actual[2], expected_layer_input, atol=2e-2, rtol=1e-2)
1390
+
1391
+
1392
@pytest.mark.skipif(
1393
not (current_platform.is_rocm() and HAS_AITER_MHC),
1394
reason="AITER mHC required",
1395
)
1396
def test_mhc_pre_delayed_rocm_aiter_declines_unsupported(monkeypatch):
1308
- """The broadcast seam and a fused norm must not take the AITER path.
1309
-
1310
- Neither is expressible with AITER's pre kernels: the broadcast projects a
1311
- narrower ``x``, and ``mhc_pre_gemm_sqrsum`` folds no RMSNorm. What is
1312
- asserted here is the routing decision, which is what this gate owns; the
1313
- numerics of whichever fallback it lands on are covered by
1314
- ``test_deepseek_v41_mhc_pre_delayed``.
1397
+ """The broadcast seam must not take the AITER path; a fused norm must not
1398
+ take the unfused AITER op.
1399
+
1400
+ The broadcast projects a narrower ``x``, which no AITER pre kernel takes;
1401
+ ``mhc_pre_gemm_sqrsum`` folds no RMSNorm, so a fused norm routes to the
1402
+ fused seam kernel where it exists (gfx950) and away from AITER otherwise.
1403
+ What is asserted here is the routing decision, which is what this gate
1404
+ owns; the numerics of the paths are covered by
1405
+ ``test_deepseek_v41_mhc_pre_delayed`` and
1406
+ ``test_mhc_pre_delayed_rocm_aiter_fused_rms_norm``.
1407
"""
1408
set_random_seed(0)
1409
hc_mult, hidden_size = 4, 5120
@@ -1331,6 +1423,18 @@ def test_mhc_pre_delayed_rocm_aiter_declines_unsupported(monkeypatch):
1423
1424
monkeypatch.setattr(torch.ops.vllm, "mhc_pre_delayed_aiter", spy)
1425
1426
+ fused_op = torch.ops.vllm.mhc_fused_post_pre_delayed_rms_norm_aiter
1427
+ took_fused = False
1428
+
1429
+ def fused_spy(*spy_args, **spy_kwargs):
1430
+ nonlocal took_fused
1431
+ took_fused = True
1432
+ return fused_op(*spy_args, **spy_kwargs)
1433
+
1434
+ monkeypatch.setattr(
1435
+ torch.ops.vllm, "mhc_fused_post_pre_delayed_rms_norm_aiter", fused_spy
1436
+ )
1437
+
1438
x = residual[:, 0].contiguous()
1439
broadcast_fn = fn.view(-1, hc_mult, hidden_size).sum(1)
1440
broadcast_residual = x.unsqueeze(1).expand(-1, hc_mult, -1).contiguous()
@@ -1342,11 +1446,15 @@ def test_mhc_pre_delayed_rocm_aiter_declines_unsupported(monkeypatch):
1446
)
1447
assert residual_out is broadcast_residual
1448
assert not took_aiter, "the broadcast seam must not reach AITER"
1449
+ assert not took_fused, "the broadcast seam must not reach AITER"
1450
for i in range(4):
1451
torch.testing.assert_close(actual[i], expected[i], atol=1e-4, rtol=1e-3)
1452
1453
+ # A fused norm must not reach the unfused AITER op; where the fused seam
1454
+ # kernel exists (gfx950) it routes there instead.
1455
op.forward_hip(*args, norm_weight=norm_weight, norm_eps=1e-6)
1349
- assert not took_aiter, "a fused norm must not reach AITER"
1456
+ assert not took_aiter, "a fused norm must not reach the unfused AITER op"
1457
+ assert took_fused == mhc_layers.HAS_AITER_MHC_FUSED_POST_PRE_DELAYED_RMS_NORM
1458
1459
# Positive control: the same inputs without a norm do take the AITER path,
1460
# so the two declines above are the gate discriminating rather than the
vllm/_aiter_ops.py
+51
@@ -4235,6 +4235,57 @@ class rocm_aiter_ops:
4235
next_pre_mix.view(*outer_shape, hc_mult),
4236
)
4237
4238
+ @staticmethod
4239
+ def mhc_fused_post_pre_delayed_rms_norm(
4240
+ residual: torch.Tensor,
4241
+ fn: torch.Tensor,
4242
+ hc_scale: torch.Tensor,
4243
+ hc_base: torch.Tensor,
4244
+ rms_eps: float,
4245
+ hc_pre_eps: float,
4246
+ hc_sinkhorn_eps: float,
4247
+ hc_post_mult_value: float,
4248
+ sinkhorn_repeat: int,
4249
+ pre_mix: torch.Tensor | None,
4250
+ sublayer_out: torch.Tensor | None,
4251
+ post_layer_mix: torch.Tensor | None,
4252
+ comb_res_mix: torch.Tensor | None,
4253
+ norm_weight: torch.Tensor,
4254
+ norm_eps: float,
4255
+ residual_out: torch.Tensor | None,
4256
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
4257
+ """:meth:`mhc_pre_delayed` with the RMSNorm of the collapse folded in.
4258
+
4259
+ Runs aiter's fused Triton kernel ``mhc_fused_post_pre_delayed_rmsnorm``;
4260
+ the returned ``layer_input`` is already normalised with ``norm_weight`` /
4261
+ ``norm_eps``.
4262
+ """
4263
+ from aiter.ops.triton.fusions.mhc_fused_post_pre_delayed_rmsnorm import (
4264
+ mhc_fused_post_pre_delayed_rmsnorm,
4265
+ )
4266
+
4267
+ _, post_mix, comb_mix, layer_input, next_pre_mix = (
4268
+ mhc_fused_post_pre_delayed_rmsnorm(
4269
+ residual,
4270
+ fn,
4271
+ hc_scale,
4272
+ hc_base,
4273
+ rms_eps,
4274
+ hc_pre_eps,
4275
+ hc_sinkhorn_eps,
4276
+ hc_post_mult_value,
4277
+ sinkhorn_repeat,
4278
+ pre_mix,
4279
+ sublayer_out,
4280
+ post_layer_mix,
4281
+ comb_res_mix,
4282
+ norm_weight,
4283
+ norm_eps,
4284
+ residual_out=residual_out,
4285
+ )
4286
+ )
4287
+ return post_mix, comb_mix, layer_input, next_pre_mix
4288
+
4289
@staticmethod
4290
def mhc_fused_post_pre_delayed_prefers_unfused(num_tokens: int) -> bool:
4291
"""True when AITER's own heuristic favours separate post and pre.
vllm/model_executor/kernels/mhc/__init__.py
+1
@@ -13,6 +13,7 @@ __all__ = [
13
"hc_head_fused_kernel_cuda",
14
"mhc_pre_aiter",
15
"mhc_pre_delayed_aiter",
16
+ "mhc_fused_post_pre_delayed_rms_norm_aiter",
17
"mhc_post_aiter",
18
"mhc_fused_post_pre_aiter",
19
"hc_head_fused_aiter",
vllm/model_executor/kernels/mhc/aiter.py
+79
@@ -207,6 +207,79 @@ def _mhc_pre_delayed_aiter_fake(
207
)
208
209
210
+def mhc_fused_post_pre_delayed_rms_norm_aiter(
211
+ residual: torch.Tensor,
212
+ fn: torch.Tensor,
213
+ hc_scale: torch.Tensor,
214
+ hc_base: torch.Tensor,
215
+ rms_eps: float,
216
+ hc_pre_eps: float,
217
+ hc_sinkhorn_eps: float,
218
+ hc_post_mult_value: float,
219
+ sinkhorn_repeat: int,
220
+ pre_mix: torch.Tensor | None,
221
+ sublayer_out: torch.Tensor | None,
222
+ post_layer_mix: torch.Tensor | None,
223
+ comb_res_mix: torch.Tensor | None,
224
+ norm_weight: torch.Tensor,
225
+ norm_eps: float,
226
+ residual_out: torch.Tensor | None,
227
+) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
228
+ """:func:`mhc_pre_delayed_aiter` with the RMSNorm of the collapse folded in:
229
+ ``layer_input`` comes back normalised with ``norm_weight`` / ``norm_eps``."""
230
+ from vllm._aiter_ops import rocm_aiter_ops
231
+
232
+ return rocm_aiter_ops.mhc_fused_post_pre_delayed_rms_norm(
233
+ residual,
234
+ fn,
235
+ hc_scale,
236
+ hc_base,
237
+ rms_eps,
238
+ hc_pre_eps,
239
+ hc_sinkhorn_eps,
240
+ hc_post_mult_value,
241
+ sinkhorn_repeat,
242
+ pre_mix,
243
+ sublayer_out,
244
+ post_layer_mix,
245
+ comb_res_mix,
246
+ norm_weight,
247
+ norm_eps,
248
+ residual_out,
249
+ )
250
+
251
+
252
+def _mhc_fused_post_pre_delayed_rms_norm_aiter_fake(
253
+ residual: torch.Tensor,
254
+ fn: torch.Tensor,
255
+ hc_scale: torch.Tensor,
256
+ hc_base: torch.Tensor,
257
+ rms_eps: float,
258
+ hc_pre_eps: float,
259
+ hc_sinkhorn_eps: float,
260
+ hc_post_mult_value: float,
261
+ sinkhorn_repeat: int,
262
+ pre_mix: torch.Tensor | None,
263
+ sublayer_out: torch.Tensor | None,
264
+ post_layer_mix: torch.Tensor | None,
265
+ comb_res_mix: torch.Tensor | None,
266
+ norm_weight: torch.Tensor,
267
+ norm_eps: float,
268
+ residual_out: torch.Tensor | None,
269
+) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
270
+ return _mhc_pre_delayed_aiter_fake(
271
+ residual,
272
+ fn,
273
+ hc_scale,
274
+ hc_base,
275
+ rms_eps,
276
+ hc_pre_eps,
277
+ hc_sinkhorn_eps,
278
+ hc_post_mult_value,
279
+ sinkhorn_repeat,
280
+ )
281
+
282
+
283
def mhc_post_aiter(
284
x: torch.Tensor,
285
residual: torch.Tensor,
@@ -335,6 +408,12 @@ direct_register_custom_op(
408
mutates_args=["residual_out"],
409
fake_impl=_mhc_pre_delayed_aiter_fake,
410
)
411
+direct_register_custom_op(
412
+ op_name="mhc_fused_post_pre_delayed_rms_norm_aiter",
413
+ op_func=mhc_fused_post_pre_delayed_rms_norm_aiter,
414
+ mutates_args=["residual_out"],
415
+ fake_impl=_mhc_fused_post_pre_delayed_rms_norm_aiter_fake,
416
+)
417
direct_register_custom_op(
418
op_name="mhc_post_aiter",
419
op_func=mhc_post_aiter,
vllm/model_executor/layers/mhc.py
+48
-4
@@ -59,9 +59,21 @@ def _aiter_mhc_op_accepts_norm(op_name: str) -> bool:
59
return False
60
61
62
+def _has_aiter_mhc_fused_post_pre_delayed_rms_norm() -> bool:
63
+ if not HAS_AITER_MHC:
64
+ return False
65
+ from vllm.platforms.rocm import on_gfx950
66
+
67
+ # The fused delayed seam kernel is only validated on gfx950.
68
+ return on_gfx950()
69
+
70
+
71
HAS_AITER_MHC_FUSED = _has_aiter_mhc_fused()
72
HAS_AITER_MHC_PRE_NORM = _aiter_mhc_op_accepts_norm("mhc_pre")
73
HAS_AITER_MHC_FUSED_NORM = _aiter_mhc_op_accepts_norm("mhc_fused_post_pre")
74
+HAS_AITER_MHC_FUSED_POST_PRE_DELAYED_RMS_NORM = (
75
+ _has_aiter_mhc_fused_post_pre_delayed_rms_norm()
76
+)
77
78
79
def _aiter_mhc_supported(
@@ -388,10 +400,42 @@ class MHCPreDelayedOp(CustomOp):
400
post_layer_mix: torch.Tensor | None = None,
401
comb_res_mix: torch.Tensor | None = None,
402
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
391
- # The AITER delayed path drives mhc_pre_gemm_sqrsum against `residual`
392
- # and folds no RMSNorm, so it cannot serve the model-entry broadcast
393
- # (which projects a narrower `x`) or a fused norm. Both are handled by
394
- # TileLang, or by the reference when TileLang is unavailable.
403
+ # aiter's fused delayed seam: post-mix, gate projection, and the
404
+ # collapse with the carried pre-mix and its RMSNorm. It always applies
405
+ # the norm and projects `residual` itself.
406
+ if (
407
+ HAS_AITER_MHC_FUSED_POST_PRE_DELAYED_RMS_NORM
408
+ and x is None
409
+ and norm_weight is not None
410
+ and _aiter_mhc_supported(residual, norm_weight, supports_norm=True)
411
+ ):
412
+ residual_out = (
413
+ torch.empty_like(residual) if sublayer_out is not None else None
414
+ )
415
+ rest = torch.ops.vllm.mhc_fused_post_pre_delayed_rms_norm_aiter(
416
+ residual,
417
+ fn,
418
+ hc_scale,
419
+ hc_base,
420
+ rms_eps,
421
+ hc_pre_eps,
422
+ hc_sinkhorn_eps,
423
+ hc_post_mult_value,
424
+ sinkhorn_repeat,
425
+ pre_mix,
426
+ sublayer_out,
427
+ post_layer_mix,
428
+ comb_res_mix,
429
+ norm_weight,
430
+ norm_eps,
431
+ residual_out,
432
+ )
433
+ return (residual if residual_out is None else residual_out), *rest
434
+ # The unfused AITER delayed path drives mhc_pre_gemm_sqrsum against
435
+ # `residual` and folds no RMSNorm, so it cannot serve the model-entry
436
+ # broadcast (which projects a narrower `x`) or a fused norm the branch
437
+ # above did not take. Both are handled by TileLang, or by the reference
438
+ # when TileLang is unavailable.
439
if (
440
x is None
441
and norm_weight is None
vllm/models/deepseek_v41/amd/model.py
+29
-4
@@ -22,7 +22,11 @@ from vllm.model_executor.layers.fused_moe import (
22
)
23
from vllm.model_executor.layers.layernorm import RMSNorm
24
from vllm.model_executor.layers.logits_processor import LogitsProcessor
25
-from vllm.model_executor.layers.mhc import MHCPostOp, MHCPreDelayedOp
25
+from vllm.model_executor.layers.mhc import (
26
+ HAS_AITER_MHC_FUSED_POST_PRE_DELAYED_RMS_NORM,
27
+ MHCPostOp,
28
+ MHCPreDelayedOp,
29
+)
30
from vllm.model_executor.layers.quantization import QuantizationConfig
31
from vllm.model_executor.layers.vocab_parallel_embedding import (
32
ParallelLMHead,
@@ -251,6 +255,10 @@ class DeepseekV4DecoderLayer(nn.Module):
255
)
256
self.mhc_pre_delayed = MHCPreDelayedOp()
257
self.mhc_post = MHCPostOp()
258
+ # Where aiter's fused seam kernel runs (gfx950), it folds the following
259
+ # attn_norm / ffn_norm into its collapse, so the separate norms are
260
+ # skipped for the seams it takes.
261
+ self.fuse_seam_norm = HAS_AITER_MHC_FUSED_POST_PRE_DELAYED_RMS_NORM
262
263
@staticmethod
264
def _hc_collapse(x: torch.Tensor, pre_mix: torch.Tensor) -> torch.Tensor:
@@ -269,6 +277,11 @@ class DeepseekV4DecoderLayer(nn.Module):
277
engram_hashes: torch.Tensor | None = None,
278
engram_mask: torch.Tensor | None = None,
279
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
280
+ # Layer 0's attention seam projects the 2-D embedding with the folded
281
+ # hc_attn_fn_broadcast instead of the 4-stream residual with hc_attn_fn.
282
+ # The fused kernel only takes the latter, so that seam keeps the
283
+ # separate attn_norm.
284
+ fuse_attn_norm = self.fuse_seam_norm and not (residual is None and x.dim() == 2)
285
# The reference collapses each sublayer's input with the *previous*
286
# sublayer's pre-mix: attention uses the pre-mix carried in (identity
287
# for the first layer), the FFN uses this layer's attention pre-mix.
@@ -303,6 +316,8 @@ class DeepseekV4DecoderLayer(nn.Module):
316
self.hc_post_alpha,
317
self.hc_sinkhorn_iters,
318
pre_mix=pre_mix,
319
+ norm_weight=self.attn_norm.weight if self.fuse_seam_norm else None,
320
+ norm_eps=self.attn_norm.variance_epsilon,
321
)
322
else:
323
pre_args = (
@@ -328,7 +343,11 @@ class DeepseekV4DecoderLayer(nn.Module):
343
engram_mask,
344
)
345
residual, post_mix, res_mix, x, attn_pre = self.mhc_pre_delayed(
331
- residual, *pre_args, pre_mix=pre_mix
346
+ residual,
347
+ *pre_args,
348
+ pre_mix=pre_mix,
349
+ norm_weight=self.attn_norm.weight if self.fuse_seam_norm else None,
350
+ norm_eps=self.attn_norm.variance_epsilon,
351
)
352
else:
353
(
@@ -344,8 +363,11 @@ class DeepseekV4DecoderLayer(nn.Module):
363
sublayer_out=x,
364
post_layer_mix=post_mix,
365
comb_res_mix=res_mix,
366
+ norm_weight=self.attn_norm.weight if self.fuse_seam_norm else None,
367
+ norm_eps=self.attn_norm.variance_epsilon,
368
)
348
- x = self.attn_norm(x)
369
+ if not fuse_attn_norm:
370
+ x = self.attn_norm(x)
371
372
if self.use_sequence_parallel:
373
x = sp_all_gather(x)[: positions.shape[0]]
@@ -368,8 +390,11 @@ class DeepseekV4DecoderLayer(nn.Module):
390
sublayer_out=x,
391
post_layer_mix=post_mix,
392
comb_res_mix=res_mix,
393
+ norm_weight=self.ffn_norm.weight if self.fuse_seam_norm else None,
394
+ norm_eps=self.ffn_norm.variance_epsilon,
395
)
372
- x = self.ffn_norm(x)
396
+ if not self.fuse_seam_norm:
397
+ x = self.ffn_norm(x)
398
x = self.ffn(x, input_ids)
399
return x, residual, post_mix, res_mix, ffn_pre
400