[ROCm][Perf] Extend QK-norm/RoPE/KV-cache fusion to MRoPE (#50212)

Signed-off-by: Vorapol Assavasangthong <Vorapol.Assavasangthong@amd.com> Co-authored-by: Santosh Hiremath <Santosh.Hiremath@amd.com> Co-authored-by: Douglas Lehr <91553416+dllehr-amd@users.noreply.github.com>

vorapolsiloai committed Sep 23, 2026 at 19:11 UTC b715699df45f091325b307c0f5ac874010fbb23e
11 files changed +1314 -118
tests/compile/fusions_e2e/common.py
+4 -1
@@ -128,8 +128,11 @@ FUSION_LOG_PATTERNS: dict[str, re.Pattern] = {
128 ),
129 "rms_quant_fusion": re.compile(r"rms_quant_fusion.py:\d+] Replaced (\d+) patterns"),
130 "act_quant_fusion": re.compile(r"act_quant_fusion.py:\d+] Replaced (\d+) patterns"),
131 + # The combined pass can consume these sites before the standalone pass.
132 "norm_rope_fusion": re.compile(
132 - r"qk_norm_rope_fusion.py:\d+] Fused QK Norm\+RoPE on (\d+) sites"
133 + r"(?:qk_norm_rope_fusion.py:\d+] Fused QK Norm\+RoPE on|"
134 + r"qk_norm_rope_kvcache_fusion.py:\d+] "
135 + r"QK-Norm\+RoPE(?:/MRoPE)?\+KVCache fusion: replaced) (\d+)"
136 ),
137 "attn_quant_fusion": re.compile(
138 r"attn_quant_fusion.py:\d+] Fused quant onto (\d+) attention nodes"
tests/compile/fusions_e2e/conftest.py
+15 -2
@@ -182,7 +182,11 @@ def run_e2e_fusion_test(monkeypatch, caplog_mp_spawn):
182 # TODO: Remove log counting in unit tests
183 # once all matchers implement VllmFusionPatternMatcherPass
184 n_expected = tp_size * num_ranges_activated
185 - if match_name not in ("attn_quant_fusion", "act_quant_fusion"):
185 + if match_name not in (
186 + "attn_quant_fusion",
187 + "act_quant_fusion",
188 + "norm_rope_fusion",
189 + ):
190 assert len(log_matches) == n_expected, (
191 f"Could not find {n_expected} {match_name} "
192 f"(found {len(log_matches)}) in:\n {log_holder.text}"
@@ -190,7 +194,16 @@ def run_e2e_fusion_test(monkeypatch, caplog_mp_spawn):
194
195 expected_matches = getattr(matches, match_name)
196
193 - if match_name == "rms_quant_fusion" and "ar_rms_fusion" in matches_check:
197 + if match_name == "norm_rope_fusion":
198 + # Opaque LayerName lets the combined pass consume short-range
199 + # sites before the standalone pass; count either owner.
200 + assert len(log_matches) >= n_expected
201 + assert all(m in (0, expected_matches) for m in log_matches)
202 + assert sum(log_matches) == expected_matches * n_expected, (
203 + f"{match_name} expected {expected_matches * n_expected} "
204 + f"sites across both passes, found: {log_matches}"
205 + )
206 + elif match_name == "rms_quant_fusion" and "ar_rms_fusion" in matches_check:
207 # AR+rms+quant takes precedence over rms+quant if activated.
208 # That means we get full matching where ar+rms+quant was not
209 # activated, and less where it was (only the smallest range).
tests/compile/passes/test_rocm_aiter_qk_norm_rope_kvcache_fusion.py
+393 -31
@@ -8,6 +8,7 @@ import pytest
8 import torch
9 from packaging.version import Version
10
11 +import vllm.compilation.passes.fusion.qk_norm_rope_kvcache_fusion as fusion_module
12 import vllm.config
13 from tests.compile.backend import TestBackend
14 from tests.v1.attention.utils import (
@@ -15,8 +16,12 @@ from tests.v1.attention.utils import (
16 create_common_attn_metadata,
17 dense_kv_cache_views,
18 )
18 -from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops
19 -from vllm.compilation.passes.fusion.matcher_utils import ROTARY_OP
19 +from vllm._aiter_ops import (
20 + IS_AITER_FOUND,
21 + is_aiter_found_and_supported,
22 + rocm_aiter_ops,
23 +)
24 +from vllm.compilation.passes.fusion.matcher_utils import MROPE_OP, ROTARY_OP
25 from vllm.compilation.passes.fusion.qk_norm_rope_kvcache_fusion import (
26 QkNormRopeKvCacheFusionPass,
27 )
@@ -37,8 +42,12 @@ from vllm.config import (
42 from vllm.forward_context import get_forward_context, set_forward_context
43 from vllm.model_executor.layers.attention import Attention
44 from vllm.model_executor.layers.layernorm import RMSNorm
45 +from vllm.model_executor.layers.quantization.quark.quark import QuarkConfig
46 +from vllm.model_executor.layers.quantization.utils.quant_utils import GroupShape
47 from vllm.model_executor.layers.rotary_embedding import RotaryEmbedding
48 +from vllm.model_executor.layers.rotary_embedding.mrope import MRotaryEmbedding
49 from vllm.platforms import current_platform
50 +from vllm.utils.torch_utils import _USE_LAYERNAME, _encode_layer_name
51 from vllm.v1.attention.backend import (
52 AttentionBackend,
53 CommonAttentionMetadata,
@@ -94,6 +103,7 @@ class QKNormRoPEKVCacheTestModel(torch.nn.Module):
103 device: torch.device,
104 rotary_dim: int | None = None,
105 prefix: str = "model.layers.0.self_attn.attn",
106 + expected_query_quant_group_shape: GroupShape | None = None,
107 ):
108 super().__init__()
109 self.num_heads = num_heads
@@ -133,6 +143,9 @@ class QKNormRoPEKVCacheTestModel(torch.nn.Module):
143 attn_backend=attn_backend.get_class(),
144 )
145 self.attn_backend: type[AttentionBackend] = self.attn.get_attn_backend()
146 + if expected_query_quant_group_shape is not None:
147 + assert self.attn.query_quant is not None
148 + assert self.attn.query_quant.group_shape == expected_query_quant_group_shape
149 assert not self.attn_backend.forward_includes_kv_cache_update, (
150 f"Attention backend {self.attn_backend} does not support "
151 "fuse_qk_norm_rope_kvcache."
@@ -227,18 +240,15 @@ class QKNormRoPEKVCacheTestModel(torch.nn.Module):
240 self.kv_cache_dtype != self.dtype
241 and self.attn.impl.supports_quant_query_input
242 ):
230 - q_fp8 = torch.empty_like(q, dtype=FP8_DTYPE)
231 - torch.ops.vllm.rocm_aiter_per_tensor_quant(
232 - q_fp8, q, self.attn._q_scale, False
233 - )
234 - q = q_fp8
243 + assert self.attn.query_quant is not None
244 + q, _ = self.attn.query_quant(q, self.attn._q_scale)
245
246 # Final views + KV cache update
247 q = q.view(-1, self.num_heads, self.head_size)
248 k = k.view(-1, self.num_kv_heads, self.head_size)
249 v = v.view(-1, self.num_kv_heads, self.head_size)
250 kv_cache_dummy_dep = torch.ops.vllm.unified_kv_cache_update(
241 - k, v, self.layer_name
251 + k, v, _encode_layer_name(self.layer_name)
252 )
253 return q, k, v, kv_cache_dummy_dep
254
@@ -260,6 +270,64 @@ class QKNormRoPEKVCacheTestModel(torch.nn.Module):
270 return [torch.ops.vllm.fused_qk_norm_rope_and_unified_kv_cache_update.default]
271
272
273 +class QKNormMRoPEKVCacheTestModel(QKNormRoPEKVCacheTestModel):
274 + def __init__(
275 + self,
276 + *,
277 + mrope_section: tuple[int, int, int],
278 + mrope_interleaved: bool,
279 + **kwargs,
280 + ):
281 + super().__init__(**kwargs)
282 + self.rotary_emb = MRotaryEmbedding(
283 + self.head_size,
284 + rotary_dim=self.rotary_dim,
285 + max_position_embeddings=4096,
286 + base=10000,
287 + is_neox_style=self.is_neox,
288 + dtype=self.dtype,
289 + mrope_section=list(mrope_section),
290 + mrope_interleaved=mrope_interleaved,
291 + )
292 + self.enable_rope_custom_op = self.rotary_emb.enabled()
293 +
294 + def forward(
295 + self, qkv: torch.Tensor, positions: torch.Tensor
296 + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
297 + q, k, v, kv_cache_dummy_dep = super().forward(qkv, positions)
298 + output = torch.empty(
299 + q.shape[0],
300 + self.num_heads * self.head_size,
301 + device=q.device,
302 + dtype=self.dtype,
303 + )
304 + torch.ops.vllm.unified_attention_with_output(
305 + q,
306 + k,
307 + v,
308 + output,
309 + _encode_layer_name(self.layer_name),
310 + kv_cache_dummy_dep=kv_cache_dummy_dep,
311 + )
312 + # Production decoder attention consumes K from the cache rather than
313 + # its direct K argument. Returning `output` keeps that production user
314 + # in the graph without exposing K to an unsupported consumer.
315 + return q, output, v, kv_cache_dummy_dep
316 +
317 + def ops_in_model_before(self) -> list[torch._ops.OpOverload]:
318 + return [MROPE_OP, torch.ops.vllm.unified_kv_cache_update.default]
319 +
320 + def ops_in_model_after(self) -> list[torch._ops.OpOverload]:
321 + return [torch.ops.vllm.fused_qk_norm_mrope_and_unified_kv_cache_update.default]
322 +
323 +
324 +class QKNormMRoPEObservableKTestModel(QKNormMRoPEKVCacheTestModel):
325 + def forward(
326 + self, qkv: torch.Tensor, positions: torch.Tensor
327 + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
328 + return QKNormRoPEKVCacheTestModel.forward(self, qkv, positions)
329 +
330 +
331 def _run_qk_norm_rope_kvcache_fusion_test(
332 *,
333 attn_backend: AttentionBackendEnum,
@@ -278,18 +346,33 @@ def _run_qk_norm_rope_kvcache_fusion_test(
346 rms_norm_eps: float,
347 custom_op: str,
348 monkeypatch: pytest.MonkeyPatch,
349 + mrope_section: tuple[int, int, int] | None = None,
350 + mrope_interleaved: bool = False,
351 + observe_mrope_k: bool = False,
352 + expect_fusion: bool = True,
353 + use_quark_scalar_query_scale: bool = False,
354 ) -> None:
355 device = os.environ.get("VLLM_TEST_CUDA_DEVICE", "cuda")
356 torch.set_default_device(device)
357 torch.set_default_dtype(dtype)
358 torch.manual_seed(0)
359
360 + # Set AITER toggles before VllmConfig initialization enables env caching.
361 + monkeypatch.setenv("VLLM_ROCM_USE_AITER", "1")
362 + monkeypatch.setenv("VLLM_ROCM_USE_AITER_LINEAR", "1")
363 + rocm_aiter_ops.refresh_env_variables()
364 +
365 vllm_config = VllmConfig(
366 model_config=ModelConfig(dtype=dtype),
367 cache_config=CacheConfig(
368 block_size=block_size,
369 cache_dtype=kv_cache_dtype,
370 ),
371 + quant_config=(
372 + QuarkConfig(quant_config={"exclude": []})
373 + if use_quark_scalar_query_scale
374 + else None
375 + ),
376 compilation_config=CompilationConfig(
377 mode=CompilationMode.VLLM_COMPILE,
378 custom_ops=[custom_op],
@@ -302,6 +385,7 @@ def _run_qk_norm_rope_kvcache_fusion_test(
385
386 with vllm.config.set_current_vllm_config(vllm_config), monkeypatch.context() as m:
387 m.setenv("VLLM_ROCM_USE_AITER", "1")
388 + m.setenv("VLLM_ROCM_USE_AITER_LINEAR", "1")
389 m.setenv(
390 "VLLM_ROCM_USE_AITER_TRITON_ROPE",
391 "1" if enable_aiter_triton_rope else "0",
@@ -309,18 +393,49 @@ def _run_qk_norm_rope_kvcache_fusion_test(
393 m.setenv("VLLM_ROCM_SHUFFLE_KV_CACHE_LAYOUT", use_shuffle_kv_layout)
394 rocm_aiter_ops.refresh_env_variables()
395
312 - model = QKNormRoPEKVCacheTestModel(
313 - vllm_config=vllm_config,
314 - attn_backend=attn_backend,
315 - num_heads=num_heads,
316 - num_kv_heads=num_kv_heads,
317 - head_size=head_size,
318 - rotary_dim=rotary_dim,
319 - is_neox=is_neox,
320 - rms_norm_eps=rms_norm_eps,
321 - dtype=dtype,
322 - device=torch.get_default_device(),
323 - )
396 + expected_query_quant_group_shape = None
397 + if use_quark_scalar_query_scale:
398 + expected_query_quant_group_shape = (
399 + GroupShape(-1, num_heads * head_size)
400 + if num_kv_heads == 1
401 + else GroupShape.PER_TENSOR
402 + )
403 +
404 + model_kwargs = {
405 + "vllm_config": vllm_config,
406 + "attn_backend": attn_backend,
407 + "num_heads": num_heads,
408 + "num_kv_heads": num_kv_heads,
409 + "head_size": head_size,
410 + "rotary_dim": rotary_dim,
411 + "is_neox": is_neox,
412 + "rms_norm_eps": rms_norm_eps,
413 + "dtype": dtype,
414 + "device": torch.get_default_device(),
415 + "expected_query_quant_group_shape": expected_query_quant_group_shape,
416 + }
417 + if mrope_section is None:
418 + model = QKNormRoPEKVCacheTestModel(**model_kwargs)
419 + else:
420 + alternate_section = (8, 8, 8)
421 + m.setattr(
422 + fusion_module,
423 + "_discover_mrope_configs",
424 + lambda _: (
425 + (mrope_section, mrope_interleaved),
426 + (alternate_section, not mrope_interleaved),
427 + ),
428 + )
429 + model_cls = (
430 + QKNormMRoPEObservableKTestModel
431 + if observe_mrope_k
432 + else QKNormMRoPEKVCacheTestModel
433 + )
434 + model = model_cls(
435 + **model_kwargs,
436 + mrope_section=mrope_section,
437 + mrope_interleaved=mrope_interleaved,
438 + )
439
440 fusion_pass = QkNormRopeKvCacheFusionPass(vllm_config)
441 passes = [
@@ -337,7 +452,12 @@ def _run_qk_norm_rope_kvcache_fusion_test(
452 num_heads * head_size + 2 * num_kv_heads * head_size,
453 dtype=dtype,
454 )
340 - pos = torch.arange(num_tokens, dtype=torch.long)
455 + if mrope_section is None:
456 + pos = torch.arange(num_tokens, dtype=torch.long)
457 + else:
458 + # Real Qwen-VL position IDs can be a strided [3, N] view.
459 + pos = torch.randint(0, 4096, (3, 2 * num_tokens), dtype=torch.long)[:, ::2]
460 + assert not pos.is_contiguous()
461
462 qkv_unfused = qkv.clone()
463 pos_unfused = pos.clone()
@@ -356,7 +476,7 @@ def _run_qk_norm_rope_kvcache_fusion_test(
476
477 # Run fused (compiled) forward
478 torch._dynamo.mark_dynamic(qkv, 0)
359 - torch._dynamo.mark_dynamic(pos, 0)
479 + torch._dynamo.mark_dynamic(pos, 0 if mrope_section is None else 1)
480 with set_forward_context(None, vllm_config):
481 model_fused = torch.compile(model, backend=backend)
482 forward_context = get_forward_context()
@@ -369,14 +489,33 @@ def _run_qk_norm_rope_kvcache_fusion_test(
489 kv_cache_fused = attn_layer.kv_cache
490 del dummy
491
372 - assert fusion_pass.matched_count == 1
492 + assert fusion_pass.matched_count == int(expect_fusion)
493
374 - backend.check_before_ops(model.ops_in_model_before())
375 - backend.check_after_ops(model.ops_in_model_after())
494 + if expect_fusion:
495 + backend.check_before_ops(model.ops_in_model_before())
496 + backend.check_after_ops(model.ops_in_model_after())
497 + else:
498 + for op in model.ops_in_model_before():
499 + assert backend.op_count(op, before=True) > 0
500 + assert backend.op_count(op) > 0
501 +
502 + if use_quark_scalar_query_scale:
503 + expected_quant_op = (
504 + torch.ops.vllm.rocm_aiter_per_tensor_quant.default
505 + if num_kv_heads != 1
506 + else torch.ops._C.static_scaled_fp8_quant.default
507 + )
508 + assert backend.op_count(expected_quant_op, before=True) > 0
509 + # Query quantization remains separate after the prologue fusion.
510 + assert backend.op_count(expected_quant_op) > 0
511
512 # Sweep-backed (18.2k pts, PR #42749): native-rope ref worst 7.7e-3 -> 1e-2;
513 # AITER-triton-rope ref is itself approximate (plateau 1.28e-2) -> 2e-2.
379 - ATOL, RTOL = (2e-2, 2e-2) if enable_aiter_triton_rope else (1e-2, 1e-2)
514 + ATOL, RTOL = (
515 + (2e-2, 2e-2)
516 + if enable_aiter_triton_rope or mrope_section is not None
517 + else (1e-2, 1e-2)
518 + )
519 is_fp8_cache = model.kv_cache_dtype != dtype
520
521 if q_fused.dtype == FP8_DTYPE:
@@ -387,7 +526,7 @@ def _run_qk_norm_rope_kvcache_fusion_test(
526 else:
527 torch.testing.assert_close(q_unfused, q_fused, atol=ATOL, rtol=RTOL)
528
390 - if not is_fp8_cache:
529 + if not expect_fusion or (not is_fp8_cache and mrope_section is None):
530 # The AITER PTS kernel populates k_out only for non-FP8 caches.
531 # With FP8, the kernel writes quantized K directly to the cache
532 # and may leave k_out uninitialised. In production this is fine
@@ -397,15 +536,16 @@ def _run_qk_norm_rope_kvcache_fusion_test(
536 # Should be bit exact since no processing had been done on v for both paths
537 torch.testing.assert_close(v_unfused, v_fused, atol=0.0, rtol=0.0)
538
400 - # fp8 vs triton-rope ref requires loosening tolerance to 1.25e-1.
401 - if is_fp8_cache and enable_aiter_triton_rope:
539 + # Fused and unfused arithmetic can straddle an FP8 rounding boundary.
540 + # Allow one E4M3 quantization step while still comparing every block.
541 + if is_fp8_cache:
542 cache_atol = cache_rtol = 1.25e-1
543 else:
544 cache_atol, cache_rtol = ATOL, RTOL
545
546 torch.testing.assert_close(
407 - kv_cache_unfused[0].float(),
408 - kv_cache_fused[0].float(),
547 + kv_cache_unfused.float(),
548 + kv_cache_fused.float(),
549 atol=cache_atol,
550 rtol=cache_rtol,
551 )
@@ -494,3 +634,225 @@ def test_qk_norm_rope_kvcache_fusion(
634 custom_op=custom_op,
635 monkeypatch=monkeypatch,
636 )
637 +
638 +
639 +@pytest.mark.parametrize(
640 + "mrope_section",
641 + [
642 + pytest.param((16, 24, 24), id="full_rotary"),
643 + pytest.param((4, 6, 6), id="partial_rotary"),
644 + ],
645 +)
646 +@pytest.mark.parametrize("mrope_interleaved", [False, True])
647 +@pytest.mark.parametrize("is_neox", [False, True])
648 +@pytest.mark.parametrize(
649 + "kv_layout",
650 + [
651 + pytest.param(KVCacheLayout.LBHNC, id="head_major"),
652 + pytest.param(KVCacheLayout.LBNHC, id="token_major"),
653 + pytest.param(KVCacheLayout.LHBNC, id="block_major"),
654 + ],
655 +)
656 +@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8"])
657 +@pytest.mark.skipif(
658 + not IS_AITER_FOUND,
659 + reason="Requires AITER",
660 +)
661 +def test_qk_norm_mrope_kvcache_fusion(
662 + mrope_section: tuple[int, int, int],
663 + mrope_interleaved: bool,
664 + is_neox: bool,
665 + kv_layout: KVCacheLayout,
666 + kv_cache_dtype: str,
667 + monkeypatch: pytest.MonkeyPatch,
668 +):
669 + _run_qk_norm_rope_kvcache_fusion_test(
670 + attn_backend=AttentionBackendEnum.ROCM_AITER_UNIFIED_ATTN,
671 + enable_aiter_triton_rope=False,
672 + num_tokens=5,
673 + num_heads=8,
674 + num_kv_heads=2,
675 + head_size=128,
676 + rotary_dim=2 * sum(mrope_section),
677 + block_size=16,
678 + is_neox=is_neox,
679 + use_shuffle_kv_layout="0",
680 + kv_layout=kv_layout,
681 + dtype=torch.bfloat16,
682 + kv_cache_dtype=kv_cache_dtype,
683 + rms_norm_eps=1e-6,
684 + custom_op="+rotary_embedding",
685 + monkeypatch=monkeypatch,
686 + mrope_section=mrope_section,
687 + mrope_interleaved=mrope_interleaved,
688 + )
689 +
690 +
691 +@pytest.mark.parametrize(
692 + ("num_heads", "num_kv_heads"),
693 + [(32, 2), (16, 1), (8, 1)],
694 + ids=["tp2", "tp4", "tp8"],
695 +)
696 +def test_qk_norm_mrope_kvcache_fusion_quark_scalar_query_scale(
697 + num_heads: int,
698 + num_kv_heads: int,
699 + monkeypatch: pytest.MonkeyPatch,
700 +):
701 + _run_qk_norm_rope_kvcache_fusion_test(
702 + attn_backend=AttentionBackendEnum.ROCM_AITER_UNIFIED_ATTN,
703 + enable_aiter_triton_rope=False,
704 + num_tokens=5,
705 + num_heads=num_heads,
706 + num_kv_heads=num_kv_heads,
707 + head_size=128,
708 + rotary_dim=128,
709 + block_size=16,
710 + is_neox=True,
711 + use_shuffle_kv_layout="0",
712 + kv_layout=KVCacheLayout.LBHNC,
713 + dtype=torch.bfloat16,
714 + kv_cache_dtype="fp8",
715 + rms_norm_eps=1e-6,
716 + custom_op="+rotary_embedding",
717 + monkeypatch=monkeypatch,
718 + mrope_section=(24, 20, 20),
719 + mrope_interleaved=True,
720 + use_quark_scalar_query_scale=True,
721 + )
722 +
723 +
724 +def test_mrope_fusion_rejects_observable_k(monkeypatch: pytest.MonkeyPatch):
725 + _run_qk_norm_rope_kvcache_fusion_test(
726 + attn_backend=AttentionBackendEnum.ROCM_AITER_UNIFIED_ATTN,
727 + enable_aiter_triton_rope=False,
728 + num_tokens=5,
729 + num_heads=8,
730 + num_kv_heads=2,
731 + head_size=128,
732 + rotary_dim=128,
733 + block_size=16,
734 + is_neox=True,
735 + use_shuffle_kv_layout="0",
736 + kv_layout=KVCacheLayout.LBHNC,
737 + dtype=torch.bfloat16,
738 + kv_cache_dtype="auto",
739 + rms_norm_eps=1e-6,
740 + custom_op="+rotary_embedding",
741 + monkeypatch=monkeypatch,
742 + mrope_section=(16, 24, 24),
743 + observe_mrope_k=True,
744 + expect_fusion=False,
745 + )
746 +
747 +
748 +def test_mrope_fusion_rejects_unaligned_rotary_dim(
749 + monkeypatch: pytest.MonkeyPatch,
750 +):
751 + _run_qk_norm_rope_kvcache_fusion_test(
752 + attn_backend=AttentionBackendEnum.ROCM_AITER_UNIFIED_ATTN,
753 + enable_aiter_triton_rope=False,
754 + num_tokens=5,
755 + num_heads=8,
756 + num_kv_heads=2,
757 + head_size=128,
758 + rotary_dim=6,
759 + block_size=16,
760 + is_neox=True,
761 + use_shuffle_kv_layout="0",
762 + kv_layout=KVCacheLayout.LBHNC,
763 + dtype=torch.bfloat16,
764 + kv_cache_dtype="auto",
765 + rms_norm_eps=1e-6,
766 + custom_op="+rotary_embedding",
767 + monkeypatch=monkeypatch,
768 + mrope_section=(1, 1, 1),
769 + expect_fusion=False,
770 + )
771 +
772 +
773 +@pytest.mark.skipif(not _USE_LAYERNAME, reason="requires opaque LayerName")
774 +def test_opaque_layer_name_patterns_register_once(monkeypatch: pytest.MonkeyPatch):
775 + device = os.environ.get("VLLM_TEST_CUDA_DEVICE", "cuda")
776 + vllm_config = VllmConfig(
777 + model_config=ModelConfig(dtype=torch.bfloat16),
778 + cache_config=CacheConfig(block_size=16, cache_dtype="fp8_e4m3"),
779 + compilation_config=CompilationConfig(
780 + mode=CompilationMode.VLLM_COMPILE,
781 + custom_ops=["+rotary_embedding"],
782 + pass_config=PassConfig(fuse_qk_norm_rope_kvcache=True),
783 + ),
784 + )
785 +
786 + with vllm.config.set_current_vllm_config(vllm_config), monkeypatch.context() as m:
787 + m.setenv("VLLM_ROCM_USE_AITER", "1")
788 + rocm_aiter_ops.refresh_env_variables()
789 + model_kwargs = {
790 + "vllm_config": vllm_config,
791 + "attn_backend": AttentionBackendEnum.ROCM_AITER_UNIFIED_ATTN,
792 + "num_heads": 8,
793 + "num_kv_heads": 2,
794 + "head_size": 128,
795 + "is_neox": True,
796 + "rms_norm_eps": 1e-6,
797 + "dtype": torch.bfloat16,
798 + "device": torch.device(device),
799 + }
800 + models = [
801 + QKNormRoPEKVCacheTestModel(
802 + **model_kwargs,
803 + prefix=f"model.layers.{index}.self_attn.attn",
804 + )
805 + for index in range(2)
806 + ]
807 +
808 + # Opaque layer names are wildcards in the pattern graph. Registering
809 + # once per physical layer raises "Duplicate pattern" on torch >= 2.11.
810 + fusion_pass = QkNormRopeKvCacheFusionPass(vllm_config)
811 +
812 + assert models
813 + assert fusion_pass.patterns
814 +
815 +
816 +def _call_fused_mrope_impl(q_out: torch.Tensor) -> None:
817 + device = q_out.device
818 + fusion_module.fused_qk_norm_mrope_and_unified_kv_cache_update_impl(
819 + q_out=q_out,
820 + qkv=torch.zeros(2, 16, device=device),
821 + positions=torch.arange(2, device=device),
822 + q_weight=torch.ones(4, device=device),
823 + k_weight=torch.ones(4, device=device),
824 + rms_norm_eps=1e-6,
825 + cos_sin_cache=torch.zeros(16, 4, device=device),
826 + is_neox=True,
827 + mrope_section_t=1,
828 + mrope_section_h=1,
829 + mrope_section_w=0,
830 + is_interleaved=False,
831 + rotary_dim=4,
832 + layer_name="model.layers.0.self_attn.attn",
833 + )
834 +
835 +
836 +def test_mrope_profiling_pass_initializes_query(monkeypatch: pytest.MonkeyPatch):
837 + monkeypatch.setattr(
838 + fusion_module,
839 + "get_attention_context",
840 + lambda _: (None, None, None, None),
841 + )
842 + q_out = torch.full((2, 8), torch.nan, device="cpu")
843 +
844 + _call_fused_mrope_impl(q_out)
845 +
846 + torch.testing.assert_close(q_out, torch.zeros_like(q_out))
847 +
848 +
849 +def test_mrope_attention_context_failure_propagates(
850 + monkeypatch: pytest.MonkeyPatch,
851 +):
852 + def fail_context(_):
853 + raise RuntimeError("missing attention context")
854 +
855 + monkeypatch.setattr(fusion_module, "get_attention_context", fail_context)
856 +
857 + with pytest.raises(RuntimeError, match="missing attention context"):
858 + _call_fused_mrope_impl(torch.empty(2, 8, device="cpu"))
vllm/_aiter_ops.py
+109
@@ -3362,6 +3362,115 @@ class rocm_aiter_ops:
3362 rotary_dim=kernel_rotary_dim,
3363 )
3364
3365 + @staticmethod
3366 + def fused_qk_norm_mrope_and_cache(
3367 + qkv: torch.Tensor,
3368 + q_weight: torch.Tensor,
3369 + k_weight: torch.Tensor,
3370 + cos_sin_cache: torch.Tensor,
3371 + positions: torch.Tensor,
3372 + num_heads_q: int,
3373 + num_heads_k: int,
3374 + head_dim: int,
3375 + is_neox: bool,
3376 + mrope_section: list[int],
3377 + is_interleaved: bool,
3378 + rms_norm_eps: float,
3379 + q_out: torch.Tensor,
3380 + k_cache: torch.Tensor,
3381 + v_cache: torch.Tensor,
3382 + slot_mapping: torch.Tensor,
3383 + k_scale: torch.Tensor,
3384 + v_scale: torch.Tensor,
3385 + block_size: int,
3386 + x: int,
3387 + rotary_dim: int = 0,
3388 + ) -> None:
3389 + from aiter.ops.fused_qk_norm_mrope_cache_quant import (
3390 + fused_qk_norm_mrope_3d_cache_pts_quant_shuffle,
3391 + )
3392 +
3393 + fused_qk_norm_mrope_3d_cache_pts_quant_shuffle(
3394 + qkv,
3395 + q_weight,
3396 + k_weight,
3397 + cos_sin_cache,
3398 + positions,
3399 + qkv.size(0),
3400 + num_heads_q,
3401 + num_heads_k,
3402 + num_heads_k,
3403 + head_dim,
3404 + is_neox,
3405 + mrope_section,
3406 + is_interleaved,
3407 + rms_norm_eps,
3408 + q_out,
3409 + k_cache,
3410 + v_cache,
3411 + slot_mapping,
3412 + k_scale,
3413 + v_scale,
3414 + None,
3415 + None,
3416 + False,
3417 + False,
3418 + block_size,
3419 + x,
3420 + rotary_dim,
3421 + )
3422 +
3423 + @staticmethod
3424 + def do_qk_norm_mrope_kvcache_update(
3425 + qkv: torch.Tensor,
3426 + q_weight: torch.Tensor,
3427 + k_weight: torch.Tensor,
3428 + cos_sin_cache: torch.Tensor,
3429 + positions: torch.Tensor,
3430 + num_heads_q: int,
3431 + num_heads_k: int,
3432 + head_dim: int,
3433 + is_neox: bool,
3434 + mrope_section: list[int],
3435 + is_interleaved: bool,
3436 + rms_norm_eps: float,
3437 + q_out: torch.Tensor,
3438 + key_cache: torch.Tensor,
3439 + value_cache: torch.Tensor,
3440 + slot_mapping: torch.Tensor,
3441 + k_scale: torch.Tensor,
3442 + v_scale: torch.Tensor,
3443 + kv_cache_dtype: str,
3444 + rotary_dim: int = 0,
3445 + ) -> None:
3446 + if kv_cache_dtype.startswith("fp8"):
3447 + key_cache = key_cache.view(current_platform.fp8_dtype())
3448 + value_cache = value_cache.view(current_platform.fp8_dtype())
3449 +
3450 + rocm_aiter_ops.fused_qk_norm_mrope_and_cache(
3451 + qkv=qkv,
3452 + q_weight=q_weight,
3453 + k_weight=k_weight,
3454 + cos_sin_cache=cos_sin_cache,
3455 + positions=positions,
3456 + num_heads_q=num_heads_q,
3457 + num_heads_k=num_heads_k,
3458 + head_dim=head_dim,
3459 + is_neox=is_neox,
3460 + mrope_section=mrope_section,
3461 + is_interleaved=is_interleaved,
3462 + rms_norm_eps=rms_norm_eps,
3463 + q_out=q_out,
3464 + k_cache=key_cache,
3465 + v_cache=value_cache,
3466 + slot_mapping=slot_mapping,
3467 + k_scale=k_scale,
3468 + v_scale=v_scale,
3469 + block_size=key_cache.shape[1],
3470 + x=16 // key_cache.element_size(),
3471 + rotary_dim=rotary_dim,
3472 + )
3473 +
3474 @staticmethod
3475 def triton_rope_and_cache(
3476 query: torch.Tensor,
vllm/compilation/passes/fusion/matcher_utils.py
+69
@@ -31,6 +31,7 @@ from vllm.platforms import current_platform
31
32 ROTARY_OP = torch.ops._C.rotary_embedding.default
33 FLASHINFER_ROTARY_OP = torch.ops.vllm.flashinfer_rotary_embedding.default
34 +MROPE_OP = torch.ops.vllm.mrope.default
35
36 QUANT_OPS: dict[QuantKey, OpOverload] = {
37 kFp8StaticTensorSym: torch.ops._C.static_scaled_fp8_quant.default, # noqa: E501
@@ -162,6 +163,74 @@ class MatcherRotaryEmbedding(MatcherCustomOp):
163 return result
164
165
166 +class MatcherMRotaryEmbedding(MatcherCustomOp):
167 + def __init__(
168 + self,
169 + is_neox: bool,
170 + head_size: int,
171 + rotary_dim: int,
172 + num_heads: int,
173 + num_kv_heads: int,
174 + mrope_section: tuple[int, int, int],
175 + mrope_interleaved: bool,
176 + enabled: bool | None = None,
177 + ) -> None:
178 + if enabled is None:
179 + enabled = RotaryEmbedding.enabled()
180 +
181 + super().__init__(enabled)
182 + self.is_neox = is_neox
183 + self.head_size = head_size
184 + self.rotary_dim = rotary_dim
185 + self.num_heads = num_heads
186 + self.num_kv_heads = num_kv_heads
187 + self.q_size = self.num_heads * self.head_size
188 + self.kv_size = self.num_kv_heads * self.head_size
189 + self.mrope_section = mrope_section
190 + self.mrope_interleaved = mrope_interleaved
191 +
192 + def inputs(self) -> list[torch.Tensor]:
193 + positions = self.empty_int64(3, 5)
194 + query = self.empty(5, self.q_size)
195 + key = self.empty(5, self.kv_size)
196 + cos_sin_cache = self.empty(4096, self.rotary_dim)
197 + return [positions, query, key, cos_sin_cache]
198 +
199 + def forward_custom(
200 + self,
201 + positions: torch.Tensor,
202 + query: torch.Tensor,
203 + key: torch.Tensor,
204 + cos_sin_cache: torch.Tensor,
205 + ) -> tuple[torch.Tensor, torch.Tensor]:
206 + result = auto_functionalized(
207 + MROPE_OP,
208 + positions=positions,
209 + query=query,
210 + key=key,
211 + cos_sin_cache=cos_sin_cache,
212 + head_size=self.head_size,
213 + rotary_dim=self.rotary_dim,
214 + mrope_section_t=self.mrope_section[0],
215 + mrope_section_h=self.mrope_section[1],
216 + mrope_section_w=self.mrope_section[2],
217 + mrope_interleaved=self.mrope_interleaved,
218 + is_neox_style=self.is_neox,
219 + )
220 + return result[1], result[2]
221 +
222 + def forward_native(
223 + self,
224 + positions: torch.Tensor,
225 + query: torch.Tensor,
226 + key: torch.Tensor,
227 + cos_sin_cache: torch.Tensor,
228 + ) -> tuple[torch.Tensor, torch.Tensor]:
229 + raise NotImplementedError(
230 + "MRoPE fusion requires the rotary_embedding custom op"
231 + )
232 +
233 +
234 class MatcherRMSNormGated(MatcherCustomOp):
235 """Matches RMSNormGated with norm_before_gate=True and group_size=None."""
236
vllm/compilation/passes/fusion/qk_norm_rope_kvcache_fusion.py
+564 -78
@@ -2,7 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import inspect
5 -from collections.abc import Callable
5 +from collections.abc import Callable, Mapping
6 from typing import ParamSpec
7
8 import torch
@@ -19,12 +19,19 @@ from vllm.model_executor.layers.attention.attention import (
19 Attention,
20 get_attention_context,
21 )
22 +from vllm.model_executor.layers.quantization.utils.quant_utils import GroupShape
23 from vllm.platforms import current_platform
23 -from vllm.utils.torch_utils import direct_register_custom_op
24 +from vllm.utils.torch_utils import (
25 + _USE_LAYERNAME,
26 + LayerNameType,
27 + _encode_layer_name,
28 + _resolve_layer_name,
29 + direct_register_custom_op,
30 +)
31
32 from ..inductor_pass import enable_fake_mode
33 from ..vllm_inductor_pass import VllmInductorPass, VllmPatternMatcherPass
27 -from .matcher_utils import MatcherRotaryEmbedding
34 +from .matcher_utils import MROPE_OP, MatcherMRotaryEmbedding, MatcherRotaryEmbedding
35 from .rms_quant_fusion import empty_bf16, empty_fp32, empty_i64
36
37 logger = init_logger(__name__)
@@ -51,8 +58,9 @@ def fused_qk_norm_rope_and_unified_kv_cache_update_impl(
58 rms_norm_eps: float,
59 cos_sin_cache: torch.Tensor,
60 is_neox: bool,
54 - layer_name: str = "",
61 + layer_name: LayerNameType,
62 ) -> torch.Tensor:
63 + layer_name = _resolve_layer_name(layer_name)
64 _, attn_layer, kv_cache, layer_slot_mapping = get_attention_context(layer_name)
65 if layer_slot_mapping is not None:
66 attn_layer.impl.do_qk_norm_rope_kvcache_update(
@@ -87,7 +95,7 @@ def fused_qk_norm_rope_and_unified_kv_cache_update_fake(
95 rms_norm_eps: float,
96 cos_sin_cache: torch.Tensor,
97 is_neox: bool,
90 - layer_name: str = "",
98 + layer_name: LayerNameType,
99 ) -> torch.Tensor:
100 return torch.empty(0, device=qkv.device, dtype=qkv.dtype)
101
@@ -100,6 +108,76 @@ direct_register_custom_op(
108 )
109
110
111 +def fused_qk_norm_mrope_and_unified_kv_cache_update_impl(
112 + q_out: torch.Tensor,
113 + qkv: torch.Tensor,
114 + positions: torch.Tensor,
115 + q_weight: torch.Tensor,
116 + k_weight: torch.Tensor,
117 + rms_norm_eps: float,
118 + cos_sin_cache: torch.Tensor,
119 + is_neox: bool,
120 + mrope_section_t: int,
121 + mrope_section_h: int,
122 + mrope_section_w: int,
123 + is_interleaved: bool,
124 + rotary_dim: int,
125 + layer_name: LayerNameType,
126 +) -> torch.Tensor:
127 + layer_name = _resolve_layer_name(layer_name)
128 + _, attn_layer, kv_cache, layer_slot_mapping = get_attention_context(layer_name)
129 + if layer_slot_mapping is not None:
130 + if positions.ndim == 1:
131 + positions = positions.unsqueeze(0).expand(3, -1).contiguous()
132 + attn_layer.impl.do_qk_norm_mrope_kvcache_update(
133 + attn_layer,
134 + qkv,
135 + q_out,
136 + positions,
137 + q_weight,
138 + k_weight,
139 + rms_norm_eps,
140 + cos_sin_cache,
141 + is_neox,
142 + (mrope_section_t, mrope_section_h, mrope_section_w),
143 + is_interleaved,
144 + rotary_dim,
145 + kv_cache,
146 + layer_slot_mapping,
147 + )
148 + else:
149 + q_out.zero_()
150 +
151 + return torch.empty(0, device=qkv.device, dtype=qkv.dtype)
152 +
153 +
154 +def fused_qk_norm_mrope_and_unified_kv_cache_update_fake(
155 + q_out: torch.Tensor,
156 + qkv: torch.Tensor,
157 + positions: torch.Tensor,
158 + q_weight: torch.Tensor,
159 + k_weight: torch.Tensor,
160 + rms_norm_eps: float,
161 + cos_sin_cache: torch.Tensor,
162 + is_neox: bool,
163 + mrope_section_t: int,
164 + mrope_section_h: int,
165 + mrope_section_w: int,
166 + is_interleaved: bool,
167 + rotary_dim: int,
168 + layer_name: LayerNameType,
169 +) -> torch.Tensor:
170 + return torch.empty(0, device=qkv.device, dtype=qkv.dtype)
171 +
172 +
173 +direct_register_custom_op(
174 + op_name="fused_qk_norm_mrope_and_unified_kv_cache_update",
175 + op_func=fused_qk_norm_mrope_and_unified_kv_cache_update_impl,
176 + mutates_args=["q_out"],
177 + fake_impl=fused_qk_norm_mrope_and_unified_kv_cache_update_fake,
178 +)
179 +
180 +
181 # ---------------------------------------------------------------------------
182 # Pattern: QK-norm + RoPE + unified_kv_cache_update
183 # ---------------------------------------------------------------------------
@@ -142,19 +220,27 @@ class QkNormRopeKvCachePattern:
220 self.eps = eps
221 self.is_neox = is_neox
222 self.quant_query = quant_query
223 + self.encoded_layer_name = _encode_layer_name(self.layer_name)
224 + self.query_quant_group_shape = (
225 + layer.query_quant.group_shape
226 + if layer.query_quant is not None
227 + else GroupShape.PER_TENSOR
228 + )
229
230 self.q_size = self.num_heads * self.head_size
231 self.k_size = self.num_kv_heads * self.head_size
232 self.v_size = self.num_kv_heads * self.head_size_v
233
150 - self.rope_matcher = MatcherRotaryEmbedding(
151 - is_neox=is_neox,
152 - head_size=self.head_size,
153 - num_heads=self.num_heads,
154 - num_kv_heads=self.num_kv_heads,
234 + self.rope_matcher: MatcherRotaryEmbedding | MatcherMRotaryEmbedding = (
235 + MatcherRotaryEmbedding(
236 + is_neox=is_neox,
237 + head_size=self.head_size,
238 + num_heads=self.num_heads,
239 + num_kv_heads=self.num_kv_heads,
240 + )
241 )
242
157 - def get_inputs(self) -> list[torch.Tensor]:
243 + def get_inputs(self) -> list:
244 T = 5
245 L = 4096
246 qkv = empty_bf16(T, self.q_size + self.k_size + self.v_size)
@@ -166,8 +252,13 @@ class QkNormRopeKvCachePattern:
252 if self.quant_query:
253 q_scale = empty_fp32(1)
254 inputs += [q_scale]
255 + if _USE_LAYERNAME:
256 + inputs.append(self.encoded_layer_name)
257 return inputs
258
259 + def _get_layer_name(self, layer_name: LayerNameType | None) -> LayerNameType:
260 + return self.encoded_layer_name if layer_name is None else layer_name
261 +
262 def pattern_non_fp8_quant_query(
263 self,
264 qkv: torch.Tensor,
@@ -175,6 +266,7 @@ class QkNormRopeKvCachePattern:
266 q_weight: torch.Tensor,
267 k_weight: torch.Tensor,
268 cos_sin_cache: torch.Tensor,
269 + layer_name: LayerNameType | None = None,
270 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
271 q, k, v = qkv.split([self.q_size, self.k_size, self.v_size], dim=-1)
272 q_by_head = q.view(-1, self.q_size // self.head_size, self.head_size)
@@ -190,7 +282,9 @@ class QkNormRopeKvCachePattern:
282 q_rope = q_rope.view(-1, self.num_heads, self.head_size)
283 k_rope = k_rope.view(-1, self.num_kv_heads, self.head_size)
284 v = v.view(-1, self.num_kv_heads, self.head_size_v)
193 - dummy = torch.ops.vllm.unified_kv_cache_update(k_rope, v, self.layer_name)
285 + dummy = torch.ops.vllm.unified_kv_cache_update(
286 + k_rope, v, self._get_layer_name(layer_name)
287 + )
288 return dummy, q_rope, k_rope, v
289
290 def replacement_non_fp8_quant_query(
@@ -200,6 +294,7 @@ class QkNormRopeKvCachePattern:
294 q_weight: torch.Tensor,
295 k_weight: torch.Tensor,
296 cos_sin_cache: torch.Tensor,
297 + layer_name: LayerNameType | None = None,
298 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
299 q_out = torch.empty(
300 qkv.shape[0],
@@ -228,10 +323,49 @@ class QkNormRopeKvCachePattern:
323 rms_norm_eps=self.eps,
324 cos_sin_cache=cos_sin_cache,
325 is_neox=self.is_neox,
231 - layer_name=self.layer_name,
326 + layer_name=self._get_layer_name(layer_name),
327 )
328 return results[0], results[1], results[2], v
329
330 + def _quantize_query(
331 + self,
332 + query: torch.Tensor,
333 + q_scale: torch.Tensor,
334 + ) -> tuple[torch.Tensor, torch.Tensor | None]:
335 + if self.query_quant_group_shape.is_per_tensor():
336 + q_out = torch.empty_like(query, dtype=current_platform.fp8_dtype())
337 + q_quant = auto_functionalized(
338 + torch.ops.vllm.rocm_aiter_per_tensor_quant.default,
339 + out=q_out,
340 + x=query,
341 + scale=q_scale,
342 + is_dynamic=False,
343 + )
344 + # The AITER op's schema marks scale mutable, so preserve its
345 + # functionalized write-back as an output of the matched region.
346 + return q_quant[1], q_quant[2]
347 +
348 + # QuantFP8.forward_hip falls back to the generic static op for the
349 + # historical per-head shape (-1, block_size). This also covers the
350 + # degenerate one-local-KV-head case where a scalar scale is represented
351 + # as (-1, q_size), rather than changing Attention's classification.
352 + q_out = torch.empty(
353 + query.shape,
354 + device=query.device,
355 + dtype=current_platform.fp8_dtype(),
356 + )
357 + q_quant = auto_functionalized(
358 + torch.ops._C.static_scaled_fp8_quant.default,
359 + result=q_out,
360 + input=query,
361 + scale=q_scale,
362 + group_shape=[
363 + self.query_quant_group_shape.row,
364 + self.query_quant_group_shape.col,
365 + ],
366 + )
367 + return q_quant[1], None
368 +
369 def pattern_fp8_quant_query(
370 self,
371 qkv: torch.Tensor,
@@ -240,7 +374,8 @@ class QkNormRopeKvCachePattern:
374 k_weight: torch.Tensor,
375 cos_sin_cache: torch.Tensor,
376 q_scale: torch.Tensor,
243 - ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
377 + layer_name: LayerNameType | None = None,
378 + ) -> tuple[torch.Tensor, ...]:
379 q, k, v = qkv.split([self.q_size, self.k_size, self.v_size], dim=-1)
380 q_by_head = q.view(-1, self.q_size // self.head_size, self.head_size)
381 q_normed = vllm.ir.ops.rms_norm(q_by_head, q_weight, self.eps)
@@ -251,24 +386,15 @@ class QkNormRopeKvCachePattern:
386 k_flat = k_normed.view(-1, self.k_size)
387
388 q_rope, k_rope = self.rope_matcher(positions, q_flat, k_flat, cos_sin_cache)
254 - # Match the quant-query op Attention.forward inserts (fp8 KV + UNIFIED).
255 - # Explicit auto_functionalized (out=[1]) keeps the quant node in the pattern.
256 - q_out = torch.empty_like(q_rope, dtype=current_platform.fp8_dtype())
257 - q_quant = auto_functionalized(
258 - torch.ops.vllm.rocm_aiter_per_tensor_quant.default,
259 - out=q_out,
260 - x=q_rope,
261 - scale=q_scale,
262 - is_dynamic=False,
263 - )
264 - # `scale` is mutable: its copy_ write-back to _q_scale bumps the mutation
265 - # region, so keep q flat (a reshape lands past the barrier and won't match).
266 - q_rope_fp8 = q_quant[1]
267 - q_scale_out = q_quant[2]
389 + q_rope_fp8, q_scale_out = self._quantize_query(q_rope, q_scale)
390
391 k_rope = k_rope.view(-1, self.num_kv_heads, self.head_size)
392 v = v.view(-1, self.num_kv_heads, self.head_size_v)
271 - dummy = torch.ops.vllm.unified_kv_cache_update(k_rope, v, self.layer_name)
393 + dummy = torch.ops.vllm.unified_kv_cache_update(
394 + k_rope, v, self._get_layer_name(layer_name)
395 + )
396 + if q_scale_out is None:
397 + return dummy, q_rope_fp8, k_rope, v
398 return dummy, q_rope_fp8, k_rope, v, q_scale_out
399
400 def replacement_fp8_quant_query(
@@ -279,7 +405,8 @@ class QkNormRopeKvCachePattern:
405 k_weight: torch.Tensor,
406 cos_sin_cache: torch.Tensor,
407 q_scale: torch.Tensor,
282 - ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
408 + layer_name: LayerNameType | None = None,
409 + ) -> tuple[torch.Tensor, ...]:
410 q_out = torch.empty(
411 qkv.shape[0],
412 self.num_heads,
@@ -307,22 +434,14 @@ class QkNormRopeKvCachePattern:
434 rms_norm_eps=self.eps,
435 cos_sin_cache=cos_sin_cache,
436 is_neox=self.is_neox,
310 - layer_name=self.layer_name,
437 + layer_name=self._get_layer_name(layer_name),
438 )
312 - # Re-apply the quant on the kernel's bf16 q_out; fused op does not quant q.
313 - # Same explicit auto_functionalized form as the pattern: [1] = quantized
314 - # q, [2] = scale (returned so the buffer-writeback use is preserved).
439 + # Re-apply the same quant form on the kernel's bf16 q_out; the fused
440 + # operation does not quantize Q.
441 q_fp8_flat = results[1].view(-1, self.q_size)
316 - q_fp8_out = torch.empty_like(q_fp8_flat, dtype=current_platform.fp8_dtype())
317 - q_requant = auto_functionalized(
318 - torch.ops.vllm.rocm_aiter_per_tensor_quant.default,
319 - out=q_fp8_out,
320 - x=q_fp8_flat,
321 - scale=q_scale,
322 - is_dynamic=False,
323 - )
324 - q_fp8 = q_requant[1] # flat to mirror the pattern (see note above)
325 - q_scale_out = q_requant[2]
442 + q_fp8, q_scale_out = self._quantize_query(q_fp8_flat, q_scale)
443 + if q_scale_out is None:
444 + return results[0], q_fp8, results[2], v
445 return results[0], q_fp8, results[2], v, q_scale_out
446
447 @staticmethod
@@ -345,6 +464,9 @@ class QkNormRopeKvCachePattern:
464
465 view_to_reshape(gm)
466
467 + def _extra_check(self, match: pm.Match) -> bool:
468 + return True
469 +
470 def _register(self, pattern, replacement, pm_pass) -> None:
471 trace_fn = QkNormRopeKvCachePattern.wrap_trace_fn(
472 pm.fwd_only,
@@ -370,40 +492,338 @@ class QkNormRopeKvCachePattern:
492 inputs,
493 trace_fn,
494 pm_pass,
495 + extra_check=self._extra_check,
496 search_fn_pattern=search_fn_pattern,
497 )
498
499 def register(self, pm_pass: PatternMatcherPass) -> None:
500 # make_fx counts `self` in bound-method code params; wrap as plain fns.
378 - # Distinct names per branch so mypy doesn't see one name, two signatures.
501 if self.quant_query:
380 -
381 - def pattern_q(qkv, positions, q_weight, k_weight, cos_sin_cache, q_scale):
382 - return self.pattern_fp8_quant_query(
502 + if _USE_LAYERNAME:
503 +
504 + def pattern_q_with_layer(
505 + qkv,
506 + positions,
507 + q_weight,
508 + k_weight,
509 + cos_sin_cache,
510 + q_scale,
511 + layer_name,
512 + ):
513 + return self.pattern_fp8_quant_query(
514 + qkv,
515 + positions,
516 + q_weight,
517 + k_weight,
518 + cos_sin_cache,
519 + q_scale,
520 + layer_name,
521 + )
522 +
523 + def replacement_q_with_layer(
524 + qkv,
525 + positions,
526 + q_weight,
527 + k_weight,
528 + cos_sin_cache,
529 + q_scale,
530 + layer_name,
531 + ):
532 + return self.replacement_fp8_quant_query(
533 + qkv,
534 + positions,
535 + q_weight,
536 + k_weight,
537 + cos_sin_cache,
538 + q_scale,
539 + layer_name,
540 + )
541 +
542 + self._register(pattern_q_with_layer, replacement_q_with_layer, pm_pass)
543 + else:
544 +
545 + def pattern_q_without_layer(
546 qkv, positions, q_weight, k_weight, cos_sin_cache, q_scale
384 - )
547 + ):
548 + return self.pattern_fp8_quant_query(
549 + qkv, positions, q_weight, k_weight, cos_sin_cache, q_scale
550 + )
551
386 - def replacement_q(
387 - qkv, positions, q_weight, k_weight, cos_sin_cache, q_scale
388 - ):
389 - return self.replacement_fp8_quant_query(
552 + def replacement_q_without_layer(
553 qkv, positions, q_weight, k_weight, cos_sin_cache, q_scale
391 - )
554 + ):
555 + return self.replacement_fp8_quant_query(
556 + qkv, positions, q_weight, k_weight, cos_sin_cache, q_scale
557 + )
558
393 - self._register(pattern_q, replacement_q, pm_pass)
559 + self._register(
560 + pattern_q_without_layer, replacement_q_without_layer, pm_pass
561 + )
562 else:
563 + if _USE_LAYERNAME:
564 +
565 + def pattern_noq_with_layer(
566 + qkv,
567 + positions,
568 + q_weight,
569 + k_weight,
570 + cos_sin_cache,
571 + layer_name,
572 + ):
573 + return self.pattern_non_fp8_quant_query(
574 + qkv,
575 + positions,
576 + q_weight,
577 + k_weight,
578 + cos_sin_cache,
579 + layer_name,
580 + )
581 +
582 + def replacement_noq_with_layer(
583 + qkv,
584 + positions,
585 + q_weight,
586 + k_weight,
587 + cos_sin_cache,
588 + layer_name,
589 + ):
590 + return self.replacement_non_fp8_quant_query(
591 + qkv,
592 + positions,
593 + q_weight,
594 + k_weight,
595 + cos_sin_cache,
596 + layer_name,
597 + )
598 +
599 + self._register(
600 + pattern_noq_with_layer, replacement_noq_with_layer, pm_pass
601 + )
602 + else:
603
396 - def pattern_noq(qkv, positions, q_weight, k_weight, cos_sin_cache):
397 - return self.pattern_non_fp8_quant_query(
604 + def pattern_noq_without_layer(
605 qkv, positions, q_weight, k_weight, cos_sin_cache
399 - )
606 + ):
607 + return self.pattern_non_fp8_quant_query(
608 + qkv, positions, q_weight, k_weight, cos_sin_cache
609 + )
610
401 - def replacement_noq(qkv, positions, q_weight, k_weight, cos_sin_cache):
402 - return self.replacement_non_fp8_quant_query(
611 + def replacement_noq_without_layer(
612 qkv, positions, q_weight, k_weight, cos_sin_cache
613 + ):
614 + return self.replacement_non_fp8_quant_query(
615 + qkv, positions, q_weight, k_weight, cos_sin_cache
616 + )
617 +
618 + self._register(
619 + pattern_noq_without_layer,
620 + replacement_noq_without_layer,
621 + pm_pass,
622 )
623
406 - self._register(pattern_noq, replacement_noq, pm_pass)
624 +
625 +class QkNormMRopeKvCachePattern(QkNormRopeKvCachePattern):
626 + FUSED_OP = torch.ops.vllm.fused_qk_norm_mrope_and_unified_kv_cache_update.default
627 +
628 + def __init__(
629 + self,
630 + layer: Attention,
631 + eps: float,
632 + is_neox: bool,
633 + quant_query: bool,
634 + rotary_dim: int,
635 + mrope_section: tuple[int, int, int],
636 + is_interleaved: bool,
637 + ) -> None:
638 + super().__init__(
639 + layer=layer,
640 + eps=eps,
641 + is_neox=is_neox,
642 + quant_query=quant_query,
643 + )
644 + self.rotary_dim = rotary_dim
645 + self.mrope_section = mrope_section
646 + self.is_interleaved = is_interleaved
647 + self.rope_matcher = MatcherMRotaryEmbedding(
648 + is_neox=is_neox,
649 + head_size=self.head_size,
650 + rotary_dim=rotary_dim,
651 + num_heads=self.num_heads,
652 + num_kv_heads=self.num_kv_heads,
653 + mrope_section=mrope_section,
654 + mrope_interleaved=is_interleaved,
655 + )
656 +
657 + def get_inputs(self) -> list[torch.Tensor]:
658 + T = 5
659 + L = 4096
660 + qkv = empty_bf16(T, self.q_size + self.k_size + self.v_size)
661 + positions = empty_i64(3, T)
662 + q_weight = empty_bf16(1, self.head_size)
663 + k_weight = empty_bf16(1, self.head_size)
664 + cos_sin_cache = empty_bf16(L, self.rotary_dim)
665 + inputs = [qkv, positions, q_weight, k_weight, cos_sin_cache]
666 + if self.quant_query:
667 + inputs.append(empty_fp32(1))
668 + if _USE_LAYERNAME:
669 + inputs.append(self.encoded_layer_name)
670 + return inputs
671 +
672 + def _unfused_kv_views(self, qkv: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
673 + # The supported backend reads K/V from the cache after the fused write.
674 + # Keep view-compatible placeholders for the attention custom-op ABI.
675 + _, k, v = qkv.split([self.q_size, self.k_size, self.v_size], dim=-1)
676 + k = k.view(qkv.shape[0], self.num_kv_heads, self.head_size)
677 + v = v.view(qkv.shape[0], self.num_kv_heads, self.head_size_v)
678 + return k, v
679 +
680 + def _fused_results(
681 + self,
682 + qkv: torch.Tensor,
683 + positions: torch.Tensor,
684 + q_weight: torch.Tensor,
685 + k_weight: torch.Tensor,
686 + cos_sin_cache: torch.Tensor,
687 + layer_name: LayerNameType | None,
688 + ) -> tuple[torch.Tensor, ...]:
689 + q_out = torch.empty(
690 + qkv.shape[0],
691 + self.num_heads,
692 + self.head_size,
693 + device=qkv.device,
694 + dtype=qkv.dtype,
695 + )
696 + return auto_functionalized(
697 + self.FUSED_OP,
698 + q_out=q_out,
699 + qkv=qkv,
700 + positions=positions,
701 + q_weight=q_weight,
702 + k_weight=k_weight,
703 + rms_norm_eps=self.eps,
704 + cos_sin_cache=cos_sin_cache,
705 + is_neox=self.is_neox,
706 + mrope_section_t=self.mrope_section[0],
707 + mrope_section_h=self.mrope_section[1],
708 + mrope_section_w=self.mrope_section[2],
709 + is_interleaved=self.is_interleaved,
710 + rotary_dim=self.rotary_dim,
711 + layer_name=self._get_layer_name(layer_name),
712 + )
713 +
714 + def replacement_non_fp8_quant_query(
715 + self,
716 + qkv: torch.Tensor,
717 + positions: torch.Tensor,
718 + q_weight: torch.Tensor,
719 + k_weight: torch.Tensor,
720 + cos_sin_cache: torch.Tensor,
721 + layer_name: LayerNameType | None = None,
722 + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
723 + k, v = self._unfused_kv_views(qkv)
724 + results = self._fused_results(
725 + qkv, positions, q_weight, k_weight, cos_sin_cache, layer_name
726 + )
727 + return results[0], results[1], k, v
728 +
729 + def replacement_fp8_quant_query(
730 + self,
731 + qkv: torch.Tensor,
732 + positions: torch.Tensor,
733 + q_weight: torch.Tensor,
734 + k_weight: torch.Tensor,
735 + cos_sin_cache: torch.Tensor,
736 + q_scale: torch.Tensor,
737 + layer_name: LayerNameType | None = None,
738 + ) -> tuple[torch.Tensor, ...]:
739 + k, v = self._unfused_kv_views(qkv)
740 + results = self._fused_results(
741 + qkv, positions, q_weight, k_weight, cos_sin_cache, layer_name
742 + )
743 + q_flat = results[1].view(-1, self.q_size)
744 + q_fp8, q_scale_out = self._quantize_query(q_flat, q_scale)
745 + if q_scale_out is None:
746 + return results[0], q_fp8, k, v
747 + return results[0], q_fp8, k, v, q_scale_out
748 +
749 + def _extra_check(self, match: pm.Match) -> bool:
750 + expected = {
751 + "head_size": self.head_size,
752 + "rotary_dim": self.rotary_dim,
753 + "mrope_section_t": self.mrope_section[0],
754 + "mrope_section_h": self.mrope_section[1],
755 + "mrope_section_w": self.mrope_section[2],
756 + "mrope_interleaved": self.is_interleaved,
757 + "is_neox_style": self.is_neox,
758 + }
759 + mrope_node = next(
760 + (node for node in match.nodes if node.args and node.args[0] == MROPE_OP),
761 + None,
762 + )
763 + if mrope_node is None or any(
764 + mrope_node.kwargs.get(name) != value for name, value in expected.items()
765 + ):
766 + return False
767 +
768 + # The fused AITER MRoPE entrypoint writes rotated K directly to the
769 + # cache and does not expose a BF16 K result. Returning the raw K view is
770 + # valid only for decoder unified attention, whose backend consumes K
771 + # from that cache. Reject graphs that observe K in any other way.
772 + output_nodes = match.output_nodes()
773 + if len(output_nodes) < 3 or output_nodes[2] is None:
774 + return False
775 + matched_nodes = set(match.nodes)
776 + attention_op = torch.ops.vllm.unified_attention_with_output.default
777 +
778 + def is_unified_attention_user(user: fx.Node) -> bool:
779 + return user.op == "call_function" and (
780 + user.target == attention_op
781 + or (user.args and user.args[0] == attention_op)
782 + )
783 +
784 + return all(
785 + user in matched_nodes or is_unified_attention_user(user)
786 + for user in output_nodes[2].users
787 + )
788 +
789 +
790 +def _discover_mrope_configs(
791 + config: VllmConfig,
792 +) -> tuple[tuple[tuple[int, int, int], bool], ...]:
793 + model_config = config.model_config
794 + hf_configs = (
795 + getattr(model_config, "hf_text_config", None),
796 + getattr(model_config, "hf_config", None),
797 + )
798 + configs: set[tuple[tuple[int, int, int], bool]] = set()
799 + for hf_config in hf_configs:
800 + if hf_config is None:
801 + continue
802 + rope_parameters = getattr(hf_config, "rope_parameters", None)
803 + if rope_parameters is None:
804 + rope_parameters = getattr(hf_config, "rope_scaling", None)
805 + if not isinstance(rope_parameters, Mapping):
806 + continue
807 + section = rope_parameters.get("mrope_section")
808 + if not isinstance(section, (list, tuple)) or len(section) != 3:
809 + continue
810 + try:
811 + parsed_section = (
812 + int(section[0]),
813 + int(section[1]),
814 + int(section[2]),
815 + )
816 + except (TypeError, ValueError):
817 + continue
818 + if any(value <= 0 for value in parsed_section):
819 + continue
820 + configs.add(
821 + (
822 + parsed_section,
823 + bool(rope_parameters.get("mrope_interleaved", False)),
824 + )
825 + )
826 + return tuple(sorted(configs))
827
828
829 # ---------------------------------------------------------------------------
@@ -412,7 +832,7 @@ class QkNormRopeKvCachePattern:
832
833
834 class QkNormRopeKvCacheFusionPass(VllmPatternMatcherPass):
415 - """Fuse QK-norm + RoPE + KV cache update into a single AITER HIP kernel.
835 + """Fuse QK-norm + RoPE/MRoPE + KV cache update into an AITER HIP kernel.
836
837 Supersedes both QKNormRoPEFusionPass and RopeKVCacheFusionPass for
838 attention layers that support the combined operation, eliminating two
@@ -429,22 +849,33 @@ class QkNormRopeKvCacheFusionPass(VllmPatternMatcherPass):
849
850 cc = config.compilation_config
851 self.max_token_num = cc.pass_config.rope_kvcache_fusion_max_token_num
852 + self.mrope_configs = (
853 + _discover_mrope_configs(config)
854 + if cc.is_custom_op_enabled("rotary_embedding")
855 + else ()
856 + )
857
858 dtype = config.model_config.dtype
859 if dtype not in (torch.bfloat16, torch.float16):
860 logger.warning_once(
436 - "QK Norm+RoPE+KVCache fusion not enabled: unsupported dtype %s", dtype
861 + "QK Norm+RoPE/MRoPE+KVCache fusion not enabled: unsupported dtype %s",
862 + dtype,
863 )
864 return
865
866 attn_layers = get_layers_from_vllm_config(config, Attention)
867
868 for _, layer in attn_layers.items():
443 - if not layer.impl.fused_qk_norm_rope_kvcache_supported():
869 + supports_rope = layer.impl.fused_qk_norm_rope_kvcache_supported()
870 + supports_mrope = (
871 + bool(self.mrope_configs)
872 + and layer.impl.fused_qk_norm_mrope_kvcache_supported()
873 + )
874 + if not supports_rope and not supports_mrope:
875 continue
876 if layer.head_size not in SUPPORTED_FUSED_QK_NORM_ROPE_KVCACHE_HEAD_DIMS:
877 logger.warning_once(
447 - "QK Norm+RoPE+KVCache fusion not enabled for a layer: "
878 + "QK Norm+RoPE/MRoPE+KVCache fusion not enabled for a layer: "
879 "head_size=%d is not supported by the "
880 "fused_qk_norm_rope_cache_pts_quant_shuffle kernel "
881 "(supported: %s). Falling back to the unfused path.",
@@ -455,22 +886,71 @@ class QkNormRopeKvCacheFusionPass(VllmPatternMatcherPass):
886 if layer.head_size_v != layer.head_size:
887 # The fused kernel uses a single head_dim for q/k/v.
888 logger.warning_once(
458 - "QK Norm+RoPE+KVCache fusion not enabled for a layer: "
889 + "QK Norm+RoPE/MRoPE+KVCache fusion not enabled for a layer: "
890 "head_size_v=%d differs from head_size=%d, which the fused "
891 "kernel does not support. Falling back to the unfused path.",
892 layer.head_size_v,
893 layer.head_size,
894 )
895 continue
465 - for epsilon in [1e-5, 1e-6]:
466 - for neox in [True, False]:
467 - for quant_q in [False, True]:
468 - QkNormRopeKvCachePattern(
469 - layer=layer,
470 - eps=epsilon,
471 - is_neox=neox,
472 - quant_query=quant_q,
473 - ).register(self.patterns)
896 + if supports_rope:
897 + for epsilon in [1e-5, 1e-6]:
898 + for neox in [True, False]:
899 + for quant_q in [False, True]:
900 + QkNormRopeKvCachePattern(
901 + layer=layer,
902 + eps=epsilon,
903 + is_neox=neox,
904 + quant_query=quant_q,
905 + ).register(self.patterns)
906 +
907 + if supports_mrope:
908 + for section, is_interleaved in self.mrope_configs:
909 + rotary_dim = 2 * sum(section)
910 + if rotary_dim > layer.head_size:
911 + logger.warning_once(
912 + "QK Norm+MRoPE+KVCache fusion not enabled for a "
913 + "layer: rotary_dim=%d exceeds head_size=%d.",
914 + rotary_dim,
915 + layer.head_size,
916 + )
917 + continue
918 + vector_width = layer.head_size // 32
919 + for epsilon in [1e-5, 1e-6]:
920 + for neox in [True, False]:
921 + # Each warp lane processes `vector_width` adjacent
922 + # values. NeoX additionally exchanges vectors
923 + # across the half-rotary boundary.
924 + rotary_alignment = vector_width * (2 if neox else 1)
925 + if rotary_dim % rotary_alignment != 0:
926 + logger.warning_once(
927 + "QK Norm+MRoPE+KVCache fusion not enabled "
928 + "for a layer/style: rotary_dim=%d must be "
929 + "divisible by %d for head_size=%d and "
930 + "is_neox=%s.",
931 + rotary_dim,
932 + rotary_alignment,
933 + layer.head_size,
934 + neox,
935 + )
936 + continue
937 + for quant_q in [False, True]:
938 + QkNormMRopeKvCachePattern(
939 + layer=layer,
940 + eps=epsilon,
941 + is_neox=neox,
942 + quant_query=quant_q,
943 + rotary_dim=rotary_dim,
944 + mrope_section=section,
945 + is_interleaved=is_interleaved,
946 + ).register(self.patterns)
947 +
948 + # Opaque LayerName is a pattern wildcard on torch >= 2.11, so
949 + # homogeneous attention layers produce duplicate search patterns.
950 + # As in the other LayerName-aware fusion passes, one supported
951 + # layer registers all shape/style variants for the model.
952 + if _USE_LAYERNAME:
953 + break
954
955 self.dump_patterns(config, self.patterns)
956
@@ -478,8 +958,8 @@ class QkNormRopeKvCacheFusionPass(VllmPatternMatcherPass):
958 def __call__(self, graph: fx.Graph) -> None:
959 self.matched_count = self.patterns.apply(graph)
960 logger.info(
481 - "QK-Norm+RoPE+KVCache fusion: replaced %s pattern(s) "
482 - "with AITER fused_qk_norm_rope_cache_pts_quant_shuffle",
961 + "QK-Norm+RoPE/MRoPE+KVCache fusion: replaced %s pattern(s) "
962 + "with an AITER fused attention-prologue kernel",
963 self.matched_count,
964 )
965
@@ -487,4 +967,10 @@ class QkNormRopeKvCacheFusionPass(VllmPatternMatcherPass):
967 return compile_range.end <= self.max_token_num
968
969 def uuid(self) -> str:
490 - return VllmInductorPass.hash_source(self, QkNormRopeKvCachePattern)
970 + return VllmInductorPass.hash_source(
971 + self,
972 + QkNormRopeKvCachePattern,
973 + QkNormMRopeKvCachePattern,
974 + _discover_mrope_configs,
975 + repr(self.mrope_configs),
976 + )
vllm/config/compilation.py
+4 -4
@@ -154,15 +154,15 @@ class PassConfig:
154 fuse_rope_kvcache: bool = None # type: ignore[assignment]
155 """Fuse the QK rope + KV cache ops."""
156 fuse_qk_norm_rope_kvcache: bool = Field(default=None) # type: ignore[assignment]
157 - """Fuse QK RMSNorm + RoPE + KV cache update into a single AITER HIP
157 + """Fuse QK RMSNorm + RoPE/MRoPE + KV cache update into an AITER HIP
158 kernel. Supersedes both enable_qk_norm_rope_fusion and fuse_rope_kvcache
159 - for layers that support it. Auto-enabled at O1+ on ROCm for models
160 - with QK-norm (e.g. Qwen3-MoE)."""
159 + for layers that support it. Auto-enabled at O2+ on ROCm for models
160 + with QK-norm (e.g. Qwen3-MoE and Qwen3-VL-class architectures)."""
161
162 rope_kvcache_fusion_max_token_num: int = 256
163 """The threshold for ROCm AITER RoPE+KVCache fusion e.g. for small batch decode.
164 Larger batch sizes e.g. during prefill will use the unfused kernels.
165 - Also applies to the fused QK-Norm+RoPE+KVCache pass.
165 + Also applies to the fused QK-Norm+RoPE/MRoPE+KVCache pass.
166 """
167
168 fi_allreduce_fusion_max_size_mb: float | None = None
vllm/config/vllm.py
+1 -1
@@ -242,7 +242,7 @@ def enable_mla_dual_rms_norm_fusion(cfg: "VllmConfig") -> bool:
242
243
244 def enable_qk_norm_rope_kvcache(cfg: "VllmConfig") -> bool:
245 - """Enable fused QK-norm + RoPE + KV cache update on ROCm with AITER."""
245 + """Enable fused QK-norm + RoPE/MRoPE + KV cache update with AITER."""
246 from vllm._aiter_ops import rocm_aiter_ops
247
248 if not rocm_aiter_ops.is_enabled():
vllm/model_executor/layers/rotary_embedding/mrope.py
+86
@@ -7,6 +7,7 @@ import torch
7
8 from vllm.platforms import current_platform
9 from vllm.triton_utils import tl, triton
10 +from vllm.utils.torch_utils import direct_register_custom_op
11
12 from .base import RotaryEmbeddingBase
13 from .yarn_scaling_rope import YaRNScalingRotaryEmbedding, yarn_get_mscale
@@ -240,6 +241,62 @@ def apply_interleaved_rope(x: torch.Tensor, mrope_section: list[int]) -> torch.T
241 return torch.where(is_width, x[2], result)
242
243
244 +def _mrope_apply(
245 + positions: torch.Tensor,
246 + query: torch.Tensor,
247 + key: torch.Tensor,
248 + cos_sin_cache: torch.Tensor,
249 + head_size: int,
250 + rotary_dim: int,
251 + mrope_section_t: int,
252 + mrope_section_h: int,
253 + mrope_section_w: int,
254 + mrope_interleaved: bool,
255 + is_neox_style: bool,
256 +) -> None:
257 + cos_sin = cos_sin_cache[positions]
258 + cos, sin = cos_sin.chunk(2, dim=-1)
259 + query_out, key_out = triton_mrope(
260 + query,
261 + key,
262 + cos,
263 + sin,
264 + [mrope_section_t, mrope_section_h, mrope_section_w],
265 + head_size,
266 + rotary_dim,
267 + mrope_interleaved,
268 + is_neox_style,
269 + )
270 + if query_out.data_ptr() != query.data_ptr():
271 + query.copy_(query_out.view_as(query))
272 + if key_out.data_ptr() != key.data_ptr():
273 + key.copy_(key_out.view_as(key))
274 +
275 +
276 +def _mrope_apply_fake(
277 + positions: torch.Tensor,
278 + query: torch.Tensor,
279 + key: torch.Tensor,
280 + cos_sin_cache: torch.Tensor,
281 + head_size: int,
282 + rotary_dim: int,
283 + mrope_section_t: int,
284 + mrope_section_h: int,
285 + mrope_section_w: int,
286 + mrope_interleaved: bool,
287 + is_neox_style: bool,
288 +) -> None:
289 + return
290 +
291 +
292 +direct_register_custom_op(
293 + op_name="mrope",
294 + op_func=_mrope_apply,
295 + mutates_args=["query", "key"],
296 + fake_impl=_mrope_apply_fake,
297 +)
298 +
299 +
300 class MRotaryEmbedding(RotaryEmbeddingBase):
301 """Rotary Embedding with Multimodal Sections."""
302
@@ -426,6 +483,35 @@ class MRotaryEmbedding(RotaryEmbeddingBase):
483 key = torch.cat((key_rot, key_pass), dim=-1).reshape(key_shape)
484 return query, key
485
486 + def forward_hip(
487 + self,
488 + positions: torch.Tensor,
489 + query: torch.Tensor,
490 + key: torch.Tensor | None = None,
491 + offsets: torch.Tensor | None = None,
492 + ) -> tuple[torch.Tensor, torch.Tensor | None]:
493 + if positions.ndim == 2:
494 + assert key is not None
495 + assert self.mrope_section
496 + query_shape = query.shape
497 + key_shape = key.shape
498 + cos_sin_cache = self._match_cos_sin_cache_dtype(query)
499 + torch.ops.vllm.mrope(
500 + positions,
501 + query,
502 + key,
503 + cos_sin_cache,
504 + self.head_size,
505 + self.rotary_dim,
506 + self.mrope_section[0],
507 + self.mrope_section[1],
508 + self.mrope_section[2],
509 + self.mrope_interleaved,
510 + self.is_neox_style,
511 + )
512 + return query.reshape(query_shape), key.reshape(key_shape)
513 + return super().forward_hip(positions, query, key)
514 +
515 def forward_cpu(
516 self,
517 positions: torch.Tensor,
vllm/v1/attention/backend.py
+24
@@ -926,6 +926,10 @@ class AttentionImpl(AttentionImplBase[T], Generic[T]):
926 """
927 return False
928
929 + def fused_qk_norm_mrope_kvcache_supported(self):
930 + """Whether this implementation supports fused QKNorm+MRoPE+KVCache."""
931 + return False
932 +
933 def fused_rope_kvcache_supported(self):
934 """Does this attention implementation support RoPE+KVCache fusion.
935 This is used by the RopeKVCacheFusionPass to only fuse the RoPE ops
@@ -955,6 +959,26 @@ class AttentionImpl(AttentionImplBase[T], Generic[T]):
959 """
960 raise NotImplementedError
961
962 + def do_qk_norm_mrope_kvcache_update(
963 + self,
964 + layer: AttentionLayer,
965 + qkv: torch.Tensor,
966 + q_out: torch.Tensor,
967 + positions: torch.Tensor,
968 + q_weight: torch.Tensor,
969 + k_weight: torch.Tensor,
970 + rms_norm_eps: float,
971 + cos_sin_cache: torch.Tensor,
972 + is_neox: bool,
973 + mrope_section: tuple[int, int, int],
974 + is_interleaved: bool,
975 + rotary_dim: int,
976 + kv_cache: torch.Tensor,
977 + layer_slot_mapping: torch.Tensor,
978 + ):
979 + """Apply QK-norm and MRoPE, then write K/V to the cache."""
980 + raise NotImplementedError
981 +
982 def do_rope_and_kv_cache_update(
983 self,
984 layer: AttentionLayer,
vllm/v1/attention/backends/rocm_aiter_unified_attn.py
+45 -1
@@ -7,7 +7,7 @@ from typing import ClassVar
7 import torch
8
9 from vllm import _custom_ops as ops
10 -from vllm._aiter_ops import rocm_aiter_ops
10 +from vllm._aiter_ops import IS_AITER_FOUND, rocm_aiter_ops
11 from vllm.config import get_current_vllm_config_or_none
12 from vllm.config.cache import CacheDType
13 from vllm.logger import init_logger
@@ -359,6 +359,9 @@ class RocmAiterUnifiedAttentionImpl(RocmAttentionImpl):
359 def fused_qk_norm_rope_kvcache_supported(self):
360 return rocm_aiter_ops.is_enabled()
361
362 + def fused_qk_norm_mrope_kvcache_supported(self) -> bool:
363 + return IS_AITER_FOUND
364 +
365 def do_qk_norm_rope_kvcache_update(
366 self,
367 layer: AttentionLayer,
@@ -397,6 +400,47 @@ class RocmAiterUnifiedAttentionImpl(RocmAttentionImpl):
400 use_shuffle_layout=False,
401 )
402
403 + def do_qk_norm_mrope_kvcache_update(
404 + self,
405 + layer: AttentionLayer,
406 + qkv: torch.Tensor,
407 + q_out: torch.Tensor,
408 + positions: torch.Tensor,
409 + q_weight: torch.Tensor,
410 + k_weight: torch.Tensor,
411 + rms_norm_eps: float,
412 + cos_sin_cache: torch.Tensor,
413 + is_neox: bool,
414 + mrope_section: tuple[int, int, int],
415 + is_interleaved: bool,
416 + rotary_dim: int,
417 + kv_cache: torch.Tensor,
418 + layer_slot_mapping: torch.Tensor,
419 + ):
420 + key_cache, value_cache = self._split_kv_cache(kv_cache)
421 + rocm_aiter_ops.do_qk_norm_mrope_kvcache_update(
422 + qkv=qkv,
423 + q_weight=q_weight,
424 + k_weight=k_weight,
425 + cos_sin_cache=cos_sin_cache,
426 + positions=positions,
427 + num_heads_q=self.num_heads,
428 + num_heads_k=self.num_kv_heads,
429 + head_dim=self.head_size,
430 + is_neox=is_neox,
431 + mrope_section=list(mrope_section),
432 + is_interleaved=is_interleaved,
433 + rms_norm_eps=rms_norm_eps,
434 + q_out=q_out,
435 + key_cache=key_cache,
436 + value_cache=value_cache,
437 + slot_mapping=layer_slot_mapping,
438 + k_scale=layer._k_scale_cpu,
439 + v_scale=layer._v_scale_cpu,
440 + kv_cache_dtype=self.kv_cache_dtype,
441 + rotary_dim=rotary_dim,
442 + )
443 +
444 def do_rope_and_kv_cache_update(
445 self,
446 layer: AttentionLayer,