[ROCm][DSV4.1][Perf] Fold the mHC post step into the delayed pre projection (#56513)

Signed-off-by: Fangzhou Ai <fangzhou@semianalysis.ai> Signed-off-by: Fangzhou Ai <fangzhou.ai@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>

Fangzhou Ai committed Sep 14, 2026 at 17:44 UTC 00972dfd72988942138a7a6089eaee08580210b8
5 files changed +346 -64
tests/kernels/test_mhc_kernels.py
+76 -2
@@ -983,8 +983,12 @@ def test_mhc_pre_delayed_rocm_aiter(num_tokens, carried):
983 )
984
985 expected = mhc_pre_delayed_torch(*args, pre_mix=pre_mix)
986 - actual = object.__new__(MHCPreDelayedOp).forward_hip(*args, pre_mix=pre_mix)
986 + residual_out, *actual = object.__new__(MHCPreDelayedOp).forward_hip(
987 + *args, pre_mix=pre_mix
988 + )
989
990 + # No post was requested, so the residual comes straight back.
991 + assert residual_out is residual
992 for i in (0, 1, 3):
993 torch.testing.assert_close(actual[i], expected[i], atol=1e-4, rtol=1e-3)
994 # The collapse is the same FP32 multiply-and-sum in both paths.
@@ -1026,7 +1030,12 @@ def test_mhc_pre_delayed_rocm_aiter_declines_unsupported(monkeypatch):
1030 broadcast_fn = fn.view(-1, hc_mult, hidden_size).sum(1)
1031 broadcast_residual = x.unsqueeze(1).expand(-1, hc_mult, -1).contiguous()
1032 expected = mhc_pre_delayed_torch(broadcast_residual, broadcast_fn, *args[2:], x=x)
1029 - actual = op.forward_hip(broadcast_residual, broadcast_fn, *args[2:], x=x)
1033 + # forward_hip leads with the residual; no post was requested here, so it is
1034 + # the caller's own tensor and the reference's four outputs follow it.
1035 + residual_out, *actual = op.forward_hip(
1036 + broadcast_residual, broadcast_fn, *args[2:], x=x
1037 + )
1038 + assert residual_out is broadcast_residual
1039 assert not took_aiter, "the broadcast seam must not reach AITER"
1040 for i in range(4):
1041 torch.testing.assert_close(actual[i], expected[i], atol=1e-4, rtol=1e-3)
@@ -1268,3 +1277,68 @@ def test_deepseek_v4_mhc_broadcast_refit_refreshes_in_place(monkeypatch):
1277 assert layer.hc_attn_fn_broadcast is buffer
1278 expected = layer.hc_attn_fn.detach().view(-1, 2, 8).sum(dim=1)
1279 assert torch.equal(layer.hc_attn_fn_broadcast, expected)
1280 +
1281 +
1282 +@pytest.mark.skipif(
1283 + not (current_platform.is_rocm() and HAS_AITER_MHC),
1284 + reason="AITER mHC required",
1285 +)
1286 +@pytest.mark.parametrize("num_tokens", [1, 2, 7, 128])
1287 +@pytest.mark.parametrize("carried", [False, True])
1288 +def test_mhc_fused_post_pre_delayed_rocm_aiter(
1289 + num_tokens, carried, default_vllm_config
1290 +):
1291 + """Folding the post into the pre projection must match doing them apart.
1292 +
1293 + The residual is bfloat16, so the two accumulation orders are allowed to
1294 + land a rounding step apart; the tolerance here is one bfloat16 ULP.
1295 + """
1296 + set_random_seed(0)
1297 + hc_mult, hidden_size = 4, 5120
1298 + residual, fn, hc_scale, hc_base, _ = _rocm_mhc_inputs(
1299 + num_tokens=num_tokens, hidden_size=hidden_size, hc_mult=hc_mult
1300 + )
1301 + x = torch.randn((num_tokens, hidden_size), dtype=torch.bfloat16, device=DEVICE)
1302 + post_layer_mix = (
1303 + torch.rand(num_tokens, hc_mult, 1, dtype=torch.float32, device=DEVICE) + 0.5
1304 + )
1305 + comb_res_mix = (
1306 + torch.rand(num_tokens, hc_mult, hc_mult, dtype=torch.float32, device=DEVICE)
1307 + + 0.2
1308 + )
1309 + pre_mix = (
1310 + torch.rand(num_tokens, hc_mult, dtype=torch.float32, device=DEVICE) + 0.5
1311 + if carried
1312 + else None
1313 + )
1314 + rms_eps = hc_pre_eps = hc_sinkhorn_eps = 1e-6
1315 + pre_args = (fn, hc_scale, hc_base, rms_eps, hc_pre_eps, hc_sinkhorn_eps, 1.0, 20)
1316 +
1317 + next_residual = mhc_post_torch(x, residual, post_layer_mix, comb_res_mix)
1318 + expected = (
1319 + next_residual,
1320 + *mhc_pre_delayed_torch(next_residual, *pre_args, pre_mix=pre_mix),
1321 + )
1322 + actual = MHCPreDelayedOp().forward_hip(
1323 + residual,
1324 + *pre_args,
1325 + pre_mix=pre_mix,
1326 + sublayer_out=x,
1327 + post_layer_mix=post_layer_mix,
1328 + comb_res_mix=comb_res_mix,
1329 + )
1330 +
1331 + for got, want in zip(actual, expected, strict=True):
1332 + torch.testing.assert_close(got, want.reshape(got.shape), atol=2e-2, rtol=8e-3)
1333 +
1334 +
1335 +@pytest.mark.skipif(
1336 + not (current_platform.is_rocm() and HAS_AITER_MHC),
1337 + reason="AITER mHC required",
1338 +)
1339 +def test_mhc_fused_post_pre_delayed_falls_back_for_large_batches():
1340 + """AITER's heuristic hands large batches back to the unfused path."""
1341 + from vllm._aiter_ops import rocm_aiter_ops
1342 +
1343 + assert not rocm_aiter_ops.mhc_fused_post_pre_delayed_prefers_unfused(1)
1344 + assert rocm_aiter_ops.mhc_fused_post_pre_delayed_prefers_unfused(1 << 20)
vllm/_aiter_ops.py
+138 -37
@@ -1676,6 +1676,75 @@ def _rocm_aiter_fp8_attn_fake(
1676 )
1677
1678
1679 +def _mhc_delayed_pre_tail(
1680 + gemm_out: torch.Tensor,
1681 + sqrsum: torch.Tensor,
1682 + residual: torch.Tensor,
1683 + hc_scale: torch.Tensor,
1684 + hc_base: torch.Tensor,
1685 + pre_mix: torch.Tensor | None,
1686 + rms_eps: float,
1687 + hc_pre_eps: float,
1688 + hc_sinkhorn_eps: float,
1689 + hc_post_mult_value: float,
1690 + sinkhorn_repeat: int,
1691 +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
1692 + """Finish a delayed mHC pre once AITER has produced the projection.
1693 +
1694 + Shared by the plain and the post-fused entry points, which differ only in
1695 + which kernel computes ``gemm_out`` / ``sqrsum`` and the residual they
1696 + project. ``mhc_pre_big_fuse`` also writes a collapse against its own
1697 + pre-mix, which the delayed formulation cannot use and so discards.
1698 + Returns ``(post_mix, comb_mix, layer_input, next_pre_mix)``.
1699 + """
1700 + from aiter.ops.mhc import mhc_pre_big_fuse
1701 +
1702 + num_tokens, hc_mult, hidden_size = residual.shape
1703 + device = residual.device
1704 + with torch.device(device):
1705 + post_mix = torch.empty(
1706 + num_tokens, hc_mult, 1, dtype=torch.float32, device=device
1707 + )
1708 + comb_mix = torch.empty(
1709 + num_tokens, hc_mult, hc_mult, dtype=torch.float32, device=device
1710 + )
1711 + unused_collapse = torch.empty(
1712 + num_tokens, hidden_size, dtype=torch.bfloat16, device=device
1713 + )
1714 + mhc_pre_big_fuse(
1715 + post_mix,
1716 + comb_mix,
1717 + unused_collapse,
1718 + gemm_out,
1719 + sqrsum,
1720 + hc_scale,
1721 + hc_base,
1722 + residual,
1723 + rms_eps,
1724 + hc_pre_eps,
1725 + hc_sinkhorn_eps,
1726 + hc_post_mult_value,
1727 + sinkhorn_repeat,
1728 + )
1729 +
1730 + next_pre_mix = torch.ops.vllm.mhc_pre_mix_triton(
1731 + gemm_out,
1732 + sqrsum,
1733 + hc_scale,
1734 + hc_base,
1735 + hc_mult,
1736 + hc_mult * hidden_size,
1737 + rms_eps,
1738 + hc_pre_eps,
1739 + )
1740 + if pre_mix is None:
1741 + # Model entry selects residual stream zero.
1742 + layer_input = residual[:, 0]
1743 + else:
1744 + layer_input = torch.ops.vllm.hc_collapse_triton(residual, pre_mix)
1745 + return post_mix, comb_mix, layer_input, next_pre_mix
1746 +
1747 +
1748 # Global flag to ensure ops are registered only once
1749 _OPS_REGISTERED = False
1750
@@ -3611,6 +3680,10 @@ class rocm_aiter_ops:
3680 hc_post_mult_value: float,
3681 sinkhorn_repeat: int,
3682 pre_mix: torch.Tensor | None = None,
3683 + sublayer_out: torch.Tensor | None = None,
3684 + post_layer_mix: torch.Tensor | None = None,
3685 + comb_res_mix: torch.Tensor | None = None,
3686 + residual_out: torch.Tensor | None = None,
3687 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
3688 """mHC pre using the pre-mix carried from the previous sublayer.
3689
@@ -3625,6 +3698,15 @@ class rocm_aiter_ops:
3698 that redundant store is the price of not having a native delayed
3699 kernel, and is small next to the ~140 launches it replaces.
3700
3701 + Passing ``sublayer_out`` / ``post_layer_mix`` / ``comb_res_mix``
3702 + also applies the preceding post block. AITER then projects the new
3703 + residual in the same kernel that computes it
3704 + (``mhc_fused_post_pre_gemm_sqrsum``), which saves one launch; every
3705 + later stage is identical either way. That new residual is written to
3706 + ``residual_out``, which the caller must supply in that case; it is an
3707 + output buffer rather than a return value so the op never has to hand
3708 + back one of its own inputs on the unfused path.
3709 +
3710 Returns:
3711 post_mix: shape (..., hc_mult, 1), dtype torch.float32
3712 comb_mix: shape (..., hc_mult, hc_mult), dtype torch.float32
@@ -3632,8 +3714,9 @@ class rocm_aiter_ops:
3714 next_pre_mix: shape (..., hc_mult), dtype torch.float32
3715 """
3716 from aiter.ops.mhc import (
3717 + get_mhc_fused_post_pre_config,
3718 get_mhc_pre_splitk,
3636 - mhc_pre_big_fuse,
3719 + mhc_fused_post_pre_gemm_sqrsum,
3720 mhc_pre_gemm_sqrsum,
3721 )
3722
@@ -3666,9 +3749,16 @@ class rocm_aiter_ops:
3749
3750 pre_mix_flat = None if pre_mix is None else pre_mix.view(-1, hc_mult)
3751
3752 + fuse_post = sublayer_out is not None
3753 # AITER's Python wrappers allocate without explicit device arguments.
3754 with torch.device(device):
3671 - splitk, tile_k = get_mhc_pre_splitk(num_tokens, hc_hidden_size)
3755 + if fuse_post:
3756 + # Keyed on the per-stream K, i.e. hidden_size, not hc_mult * it.
3757 + splitk, tile_m, tile_n, tile_k = get_mhc_fused_post_pre_config(
3758 + num_tokens, hidden_size
3759 + )
3760 + else:
3761 + splitk, tile_k = get_mhc_pre_splitk(num_tokens, hc_hidden_size)
3762 # AITER pads the GEMM output to a multiple of 32 columns.
3763 gemm_pad = torch.empty(
3764 splitk,
@@ -3679,50 +3769,46 @@ class rocm_aiter_ops:
3769 )
3770 gemm_out = gemm_pad[:, :, :hc_mult3]
3771 sqrsum = torch.empty(splitk, num_tokens, dtype=torch.float32, device=device)
3682 - mhc_pre_gemm_sqrsum(gemm_out, sqrsum, residual_flat, fn, tile_k, 0)
3683 -
3684 - post_mix = torch.empty(
3685 - num_tokens, hc_mult, 1, dtype=torch.float32, device=device
3686 - )
3687 - comb_mix = torch.empty(
3688 - num_tokens, hc_mult, hc_mult, dtype=torch.float32, device=device
3689 - )
3690 - unused_collapse = torch.empty(
3691 - num_tokens, hidden_size, dtype=torch.bfloat16, device=device
3692 - )
3693 - mhc_pre_big_fuse(
3694 - post_mix,
3695 - comb_mix,
3696 - unused_collapse,
3697 - gemm_out,
3698 - sqrsum,
3699 - hc_scale,
3700 - hc_base,
3701 - residual_flat,
3702 - rms_eps,
3703 - hc_pre_eps,
3704 - hc_sinkhorn_eps,
3705 - hc_post_mult_value,
3706 - sinkhorn_repeat,
3707 - )
3772 + if fuse_post:
3773 + # Restated for the type checker, which cannot narrow the
3774 + # optional arguments through the fuse_post flag.
3775 + assert sublayer_out is not None
3776 + assert post_layer_mix is not None and comb_res_mix is not None
3777 + assert residual_out is not None
3778 + projected = residual_out.view(-1, hc_mult, hidden_size)
3779 + mhc_fused_post_pre_gemm_sqrsum(
3780 + gemm_out,
3781 + sqrsum,
3782 + projected,
3783 + sublayer_out.view(-1, hidden_size),
3784 + residual_flat,
3785 + # The kernel wants the post mix squeezed to (tokens, hc_mult).
3786 + post_layer_mix.view(-1, hc_mult, 1).squeeze(-1),
3787 + comb_res_mix.view(-1, hc_mult, hc_mult),
3788 + fn,
3789 + tile_m,
3790 + tile_n,
3791 + tile_k,
3792 + 0,
3793 + )
3794 + else:
3795 + projected = residual_flat
3796 + mhc_pre_gemm_sqrsum(gemm_out, sqrsum, projected, fn, tile_k, 0)
3797
3709 - next_pre_mix = torch.ops.vllm.mhc_pre_mix_triton(
3798 + post_mix, comb_mix, layer_input, next_pre_mix = _mhc_delayed_pre_tail(
3799 gemm_out,
3800 sqrsum,
3801 + projected,
3802 hc_scale,
3803 hc_base,
3714 - hc_mult,
3715 - hc_hidden_size,
3804 + pre_mix_flat,
3805 rms_eps,
3806 hc_pre_eps,
3807 + hc_sinkhorn_eps,
3808 + hc_post_mult_value,
3809 + sinkhorn_repeat,
3810 )
3811
3720 - if pre_mix_flat is None:
3721 - # Model entry selects residual stream zero.
3722 - layer_input = residual_flat[:, 0]
3723 - else:
3724 - layer_input = torch.ops.vllm.hc_collapse_triton(residual_flat, pre_mix_flat)
3725 -
3812 return (
3813 post_mix.view(*outer_shape, hc_mult, 1),
3814 comb_mix.view(*outer_shape, hc_mult, hc_mult),
@@ -3730,6 +3816,21 @@ class rocm_aiter_ops:
3816 next_pre_mix.view(*outer_shape, hc_mult),
3817 )
3818
3819 + @staticmethod
3820 + def mhc_fused_post_pre_delayed_prefers_unfused(num_tokens: int) -> bool:
3821 + """True when AITER's own heuristic favours separate post and pre.
3822 +
3823 + Mirrors the ``fused_m_upper_bound`` table in ``aiter.ops.mhc``: the
3824 + fused GEMM wins for decode-sized batches and loses for large ones.
3825 + """
3826 + from aiter.jit.utils.chip_info import get_gfx_runtime
3827 +
3828 + return num_tokens >= {
3829 + "gfx950": 1024,
3830 + "gfx942": 128,
3831 + "gfx1250": 1024,
3832 + }.get(get_gfx_runtime(), 1024)
3833 +
3834 @staticmethod
3835 def hc_head(
3836 hs_flat: torch.Tensor,
vllm/model_executor/kernels/mhc/aiter.py
+20 -1
@@ -115,6 +115,10 @@ def mhc_pre_delayed_aiter(
115 hc_post_mult_value: float,
116 sinkhorn_repeat: int,
117 pre_mix: torch.Tensor | None = None,
118 + sublayer_out: torch.Tensor | None = None,
119 + post_layer_mix: torch.Tensor | None = None,
120 + comb_res_mix: torch.Tensor | None = None,
121 + residual_out: torch.Tensor | None = None,
122 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
123 """mHC pre with the pre-mix carried in from the previous sublayer.
124
@@ -134,6 +138,13 @@ def mhc_pre_delayed_aiter(
138 sinkhorn_repeat: number of sinkhorn iterations
139 pre_mix: shape (..., hc_mult) from the previous sublayer, or None at
140 model entry to select residual stream zero.
141 + sublayer_out: attention or FFN output, shape (..., hidden_size). When
142 + given with the two mixes below, the preceding post block is
143 + applied here so AITER can fold it into the pre projection.
144 + post_layer_mix: shape (..., hc_mult, 1), post gate for that block.
145 + comb_res_mix: shape (..., hc_mult, hc_mult), residual comb for it.
146 + residual_out: shape (..., hc_mult, hidden_size), written with the post
147 + block's new residual. Required exactly when the post is folded in.
148
149 Returns:
150 post_mix: shape (..., hc_mult, 1), dtype torch.float32
@@ -156,6 +167,10 @@ def mhc_pre_delayed_aiter(
167 hc_post_mult_value,
168 sinkhorn_repeat,
169 pre_mix,
170 + sublayer_out,
171 + post_layer_mix,
172 + comb_res_mix,
173 + residual_out,
174 )
175
176
@@ -170,6 +185,10 @@ def _mhc_pre_delayed_aiter_fake(
185 hc_post_mult_value: float,
186 sinkhorn_repeat: int,
187 pre_mix: torch.Tensor | None = None,
188 + sublayer_out: torch.Tensor | None = None,
189 + post_layer_mix: torch.Tensor | None = None,
190 + comb_res_mix: torch.Tensor | None = None,
191 + residual_out: torch.Tensor | None = None,
192 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
193 hc_mult = residual.shape[-2]
194 hidden_size = residual.shape[-1]
@@ -313,7 +332,7 @@ direct_register_custom_op(
332 direct_register_custom_op(
333 op_name="mhc_pre_delayed_aiter",
334 op_func=mhc_pre_delayed_aiter,
316 - mutates_args=[],
335 + mutates_args=["residual_out"],
336 fake_impl=_mhc_pre_delayed_aiter_fake,
337 )
338 direct_register_custom_op(
vllm/model_executor/layers/mhc.py
+75 -7
@@ -299,7 +299,13 @@ class MHCPreDelayedOp(CustomOp):
299
300 Same gates as :class:`MHCPreOp`, but the stream collapse applies the
301 caller's ``pre_mix`` and this sublayer's pre-mix is returned for the next
302 - sublayer seam. Returns post_mix, comb_mix, layer_input, next_pre_mix.
302 + sublayer seam.
303 +
304 + Passing ``sublayer_out`` / ``post_layer_mix`` / ``comb_res_mix`` also
305 + applies the preceding post block, which lets AITER fold it into the pre
306 + projection and saves a launch. Returns residual, post_mix, comb_mix,
307 + layer_input, next_pre_mix; ``residual`` is the caller's own tensor when
308 + no post is requested.
309 """
310
311 # --8<-- [end:mhc_pre_delayed]
@@ -307,6 +313,24 @@ class MHCPreDelayedOp(CustomOp):
313 def enabled(cls) -> bool:
314 return True
315
316 + def __init__(self) -> None:
317 + super().__init__()
318 + # Built here, not lazily: CustomOp resolves dispatch against the
319 + # current vLLM config, which is only set during model construction.
320 + self._post = MHCPostOp()
321 +
322 + def _maybe_post(
323 + self,
324 + residual: torch.Tensor,
325 + sublayer_out: torch.Tensor | None,
326 + post_layer_mix: torch.Tensor | None,
327 + comb_res_mix: torch.Tensor | None,
328 + ) -> torch.Tensor:
329 + if sublayer_out is None:
330 + return residual
331 + assert post_layer_mix is not None and comb_res_mix is not None
332 + return self._post(sublayer_out, residual, post_layer_mix, comb_res_mix)
333 +
334 def forward_cuda(
335 self,
336 residual: torch.Tensor,
@@ -322,8 +346,14 @@ class MHCPreDelayedOp(CustomOp):
346 x: torch.Tensor | None = None,
347 norm_weight: torch.Tensor | None = None,
348 norm_eps: float = 1e-6,
325 - ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
326 - return torch.ops.vllm.mhc_pre_delayed_tilelang(
349 + sublayer_out: torch.Tensor | None = None,
350 + post_layer_mix: torch.Tensor | None = None,
351 + comb_res_mix: torch.Tensor | None = None,
352 + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
353 + residual = self._maybe_post(
354 + residual, sublayer_out, post_layer_mix, comb_res_mix
355 + )
356 + return residual, *torch.ops.vllm.mhc_pre_delayed_tilelang(
357 residual,
358 fn,
359 hc_scale,
@@ -354,7 +384,10 @@ class MHCPreDelayedOp(CustomOp):
384 x: torch.Tensor | None = None,
385 norm_weight: torch.Tensor | None = None,
386 norm_eps: float = 1e-6,
357 - ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
387 + sublayer_out: torch.Tensor | None = None,
388 + post_layer_mix: torch.Tensor | None = None,
389 + comb_res_mix: torch.Tensor | None = None,
390 + ) -> 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
@@ -364,7 +397,26 @@ class MHCPreDelayedOp(CustomOp):
397 and norm_weight is None
398 and _aiter_mhc_supported(residual, None, supports_norm=False)
399 ):
367 - return torch.ops.vllm.mhc_pre_delayed_aiter(
400 + from vllm._aiter_ops import rocm_aiter_ops
401 +
402 + num_tokens = residual.numel() // (residual.shape[-1] * residual.shape[-2])
403 + # Folding the post in only pays while the residual still fits in
404 + # cache; past that AITER's heuristic wants the separate kernels,
405 + # which can ask for a non-temporal store and a large-m split-k.
406 + if sublayer_out is not None and (
407 + rocm_aiter_ops.mhc_fused_post_pre_delayed_prefers_unfused(num_tokens)
408 + ):
409 + residual = self._maybe_post(
410 + residual, sublayer_out, post_layer_mix, comb_res_mix
411 + )
412 + sublayer_out = None
413 + # The op writes the folded post's residual into this buffer rather
414 + # than returning it, since on the unfused path it has none of its
415 + # own and an op must not hand back one of its inputs.
416 + residual_out = (
417 + torch.empty_like(residual) if sublayer_out is not None else None
418 + )
419 + rest = torch.ops.vllm.mhc_pre_delayed_aiter(
420 residual,
421 fn,
422 hc_scale,
@@ -375,9 +427,17 @@ class MHCPreDelayedOp(CustomOp):
427 hc_post_mult_value,
428 sinkhorn_repeat,
429 pre_mix,
430 + sublayer_out,
431 + post_layer_mix,
432 + comb_res_mix,
433 + residual_out,
434 )
435 + return (residual if residual_out is None else residual_out), *rest
436 + residual = self._maybe_post(
437 + residual, sublayer_out, post_layer_mix, comb_res_mix
438 + )
439 if HAS_TILELANG_MHC:
380 - return torch.ops.vllm.mhc_pre_delayed_tilelang(
440 + return residual, *torch.ops.vllm.mhc_pre_delayed_tilelang(
441 residual,
442 fn,
443 hc_scale,
@@ -392,6 +452,7 @@ class MHCPreDelayedOp(CustomOp):
452 norm_weight,
453 norm_eps,
454 )
455 + # The post is already applied, so it must not be requested again.
456 return self.forward_native(
457 residual,
458 fn,
@@ -423,7 +484,13 @@ class MHCPreDelayedOp(CustomOp):
484 x: torch.Tensor | None = None,
485 norm_weight: torch.Tensor | None = None,
486 norm_eps: float = 1e-6,
426 - ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
487 + sublayer_out: torch.Tensor | None = None,
488 + post_layer_mix: torch.Tensor | None = None,
489 + comb_res_mix: torch.Tensor | None = None,
490 + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
491 + residual = self._maybe_post(
492 + residual, sublayer_out, post_layer_mix, comb_res_mix
493 + )
494 post_mix, comb_mix, layer_input, next_pre_mix = (
495 mhc_kernels.mhc_pre_delayed_torch(
496 residual,
@@ -440,6 +507,7 @@ class MHCPreDelayedOp(CustomOp):
507 )
508 )
509 return (
510 + residual,
511 post_mix,
512 comb_mix,
513 _apply_mhc_norm(layer_input, norm_weight, norm_eps),
vllm/models/deepseek_v41/amd/model.py
+37 -17
@@ -274,7 +274,7 @@ class DeepseekV4DecoderLayer(nn.Module):
274 # copies and the identity pre-mix selects copy 0.
275 assert self.hc_attn_fn_broadcast is not None
276 residual = x.unsqueeze(1).expand(-1, self.hc_mult, -1).contiguous()
277 - post_mix, res_mix, x, attn_pre = self.mhc_pre_delayed(
277 + residual, post_mix, res_mix, x, attn_pre = self.mhc_pre_delayed(
278 residual,
279 self.hc_attn_fn_broadcast,
280 self.hc_attn_scale,
@@ -288,7 +288,7 @@ class DeepseekV4DecoderLayer(nn.Module):
288 )
289 else:
290 residual = x
291 - post_mix, res_mix, x, attn_pre = self.mhc_pre_delayed(
291 + residual, post_mix, res_mix, x, attn_pre = self.mhc_pre_delayed(
292 residual,
293 self.hc_attn_fn,
294 self.hc_attn_scale,
@@ -301,18 +301,7 @@ class DeepseekV4DecoderLayer(nn.Module):
301 pre_mix=pre_mix,
302 )
303 else:
304 - residual = self.mhc_post(x, residual, post_mix, res_mix)
305 - if self.engram is not None and engram_hashes is not None:
306 - # Engram injection happens between the previous sublayer's
307 - # post and this block's pre, on the full hc stream, so the
308 - # mix coefficients see the injected stream.
309 - residual = self.engram(
310 - residual,
311 - engram_hashes[:, self.engram.layer_hash_index],
312 - engram_mask,
313 - )
314 - post_mix, res_mix, x, attn_pre = self.mhc_pre_delayed(
315 - residual,
304 + pre_args = (
305 self.hc_attn_fn,
306 self.hc_attn_scale,
307 self.hc_attn_base,
@@ -321,8 +310,37 @@ class DeepseekV4DecoderLayer(nn.Module):
310 self.hc_eps,
311 self.hc_post_alpha,
312 self.hc_sinkhorn_iters,
324 - pre_mix=pre_mix,
313 )
314 + if self.engram is not None and engram_hashes is not None:
315 + # Engram injection happens between the previous sublayer's
316 + # post and this block's pre, on the full hc stream, so the
317 + # mix coefficients see the injected stream. That read of the
318 + # residual is what stops the post from fusing into the pre
319 + # here, unlike the attention seam below.
320 + residual = self.mhc_post(x, residual, post_mix, res_mix)
321 + residual = self.engram(
322 + residual,
323 + engram_hashes[:, self.engram.layer_hash_index],
324 + engram_mask,
325 + )
326 + residual, post_mix, res_mix, x, attn_pre = self.mhc_pre_delayed(
327 + residual, *pre_args, pre_mix=pre_mix
328 + )
329 + else:
330 + (
331 + residual,
332 + post_mix,
333 + res_mix,
334 + x,
335 + attn_pre,
336 + ) = self.mhc_pre_delayed(
337 + residual,
338 + *pre_args,
339 + pre_mix=pre_mix,
340 + sublayer_out=x,
341 + post_layer_mix=post_mix,
342 + comb_res_mix=res_mix,
343 + )
344 x = self.attn_norm(x)
345
346 if self.use_sequence_parallel:
@@ -332,8 +350,7 @@ class DeepseekV4DecoderLayer(nn.Module):
350 if self.use_sequence_parallel:
351 x = sp_reduce_scatter(x)
352
335 - residual = self.mhc_post(x, residual, post_mix, res_mix)
336 - post_mix, res_mix, x, ffn_pre = self.mhc_pre_delayed(
353 + residual, post_mix, res_mix, x, ffn_pre = self.mhc_pre_delayed(
354 residual,
355 self.hc_ffn_fn,
356 self.hc_ffn_scale,
@@ -344,6 +361,9 @@ class DeepseekV4DecoderLayer(nn.Module):
361 self.hc_post_alpha,
362 self.hc_sinkhorn_iters,
363 pre_mix=attn_pre,
364 + sublayer_out=x,
365 + post_layer_mix=post_mix,
366 + comb_res_mix=res_mix,
367 )
368 x = self.ffn_norm(x)
369 x = self.ffn(x, input_ids)