[Model][Hardware][AMD]: Part 1/2 -> Enable e2e QK Norm + RoPE + KV Cache runtime fusion for Qwen3-30B-A3B on ROCM_AITER_FA, and ROCM_AITER_UNIFIED_ATTN (#42749)
Signed-off-by: Jack Hu <Jack.Hu@amd.com> Co-authored-by: Douglas Lehr <91553416+dllehr-amd@users.noreply.github.com>
Jack Hu committed
Jul 16, 2026 at 18:39 UTC
2cab53ddeea3851a083beff8c192f068c4518b67
15 files changed
+1361
-4
tests/compile/passes/test_rocm_aiter_qk_norm_rope_kvcache_fusion.py
new
+478
@@ -0,0 +1,478 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import os
5
+
6
+import pytest
7
+import torch
8
+
9
+import vllm.config
10
+from tests.compile.backend import TestBackend
11
+from tests.v1.attention.utils import BatchSpec, create_common_attn_metadata
12
+from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops
13
+from vllm.compilation.passes.fusion.matcher_utils import ROTARY_OP
14
+from vllm.compilation.passes.fusion.qk_norm_rope_kvcache_fusion import (
15
+ QkNormRopeKvCacheFusionPass,
16
+)
17
+from vllm.compilation.passes.utility.noop_elimination import NoOpEliminationPass
18
+from vllm.compilation.passes.utility.post_cleanup import PostCleanupPass
19
+from vllm.compilation.passes.utility.scatter_split_replace import (
20
+ ScatterSplitReplacementPass,
21
+)
22
+from vllm.compilation.passes.utility.split_coalescing import SplitCoalescingPass
23
+from vllm.config import (
24
+ CacheConfig,
25
+ CompilationConfig,
26
+ CompilationMode,
27
+ ModelConfig,
28
+ PassConfig,
29
+ VllmConfig,
30
+)
31
+from vllm.forward_context import get_forward_context, set_forward_context
32
+from vllm.model_executor.layers.attention import Attention
33
+from vllm.model_executor.layers.layernorm import RMSNorm
34
+from vllm.model_executor.layers.rotary_embedding import RotaryEmbedding
35
+from vllm.platforms import current_platform
36
+from vllm.v1.attention.backend import (
37
+ AttentionBackend,
38
+ CommonAttentionMetadata,
39
+)
40
+from vllm.v1.attention.backends.registry import AttentionBackendEnum
41
+from vllm.v1.kv_cache_interface import AttentionSpec
42
+
43
+INDEX_SELECT_OP = torch.ops.aten.index.Tensor
44
+FP8_DTYPE = current_platform.fp8_dtype()
45
+
46
+
47
+class QKNormRoPEKVCacheTestModel(torch.nn.Module):
48
+ """Minimal model that reproduces the QK-norm + RoPE + KV cache update
49
+ pattern matched by QkNormRopeKvCacheFusionPass:
50
+
51
+ q, k, v = split(qkv)
52
+ q = rms_norm(q.view(heads, dim), q_weight).view(flat)
53
+ k = rms_norm(k.view(heads, dim), k_weight).view(flat)
54
+ q, k = rotary_emb(positions, q, k)
55
+ q = q.view(num_heads, head_dim)
56
+ k = k.view(num_kv_heads, head_dim)
57
+ v = v.view(num_kv_heads, head_dim)
58
+ dummy = unified_kv_cache_update(k, v, layer_name)
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ vllm_config: VllmConfig,
64
+ attn_backend: AttentionBackendEnum,
65
+ num_heads: int,
66
+ num_kv_heads: int,
67
+ head_size: int,
68
+ is_neox: bool,
69
+ rms_norm_eps: float,
70
+ dtype: torch.dtype,
71
+ device: torch.device,
72
+ rotary_dim: int | None = None,
73
+ prefix: str = "model.layers.0.self_attn.attn",
74
+ ):
75
+ super().__init__()
76
+ self.num_heads = num_heads
77
+ self.num_kv_heads = num_kv_heads
78
+ self.head_size = head_size
79
+ self.rotary_dim = rotary_dim if rotary_dim is not None else head_size
80
+ self.block_size = vllm_config.cache_config.block_size
81
+ self.q_size = num_heads * head_size
82
+ self.kv_size = num_kv_heads * head_size
83
+ self.is_neox = is_neox
84
+ self.dtype = dtype
85
+ self.device = device
86
+ self.layer_name = prefix
87
+
88
+ self.q_norm = RMSNorm(head_size, eps=rms_norm_eps)
89
+ self.k_norm = RMSNorm(head_size, eps=rms_norm_eps)
90
+
91
+ self.rotary_emb = RotaryEmbedding(
92
+ head_size,
93
+ rotary_dim=self.rotary_dim,
94
+ max_position_embeddings=4096,
95
+ base=10000,
96
+ is_neox_style=is_neox,
97
+ dtype=self.dtype,
98
+ )
99
+
100
+ self.enable_rope_custom_op = self.rotary_emb.enabled()
101
+
102
+ self.attn = Attention(
103
+ num_heads=num_heads,
104
+ head_size=head_size,
105
+ scale=1.0 / head_size**0.5,
106
+ num_kv_heads=num_kv_heads,
107
+ cache_config=vllm_config.cache_config,
108
+ quant_config=vllm_config.quant_config,
109
+ prefix=prefix,
110
+ attn_backend=attn_backend.get_class(),
111
+ )
112
+ self.attn_backend: type[AttentionBackend] = self.attn.get_attn_backend()
113
+ assert not self.attn_backend.forward_includes_kv_cache_update, (
114
+ f"Attention backend {self.attn_backend} does not support "
115
+ "fuse_qk_norm_rope_kvcache."
116
+ )
117
+ kv_cache_dtype_str = vllm_config.cache_config.cache_dtype
118
+ self.kv_cache_dtype = (
119
+ FP8_DTYPE if kv_cache_dtype_str.startswith("fp8") else self.dtype
120
+ )
121
+
122
+ if self.kv_cache_dtype != self.dtype:
123
+ self.attn._k_scale = torch.tensor(1.0, dtype=torch.float32, device=device)
124
+ self.attn._v_scale = torch.tensor(1.0, dtype=torch.float32, device=device)
125
+ self.attn._k_scale_float = 1.0
126
+ self.attn._v_scale_float = 1.0
127
+ else:
128
+ self.attn._k_scale = self.attn._k_scale.to(device)
129
+ self.attn._v_scale = self.attn._v_scale.to(device)
130
+
131
+ self.builder = self.attn.attn_backend.get_builder_cls()(
132
+ kv_cache_spec=AttentionSpec(
133
+ block_size=self.block_size,
134
+ num_kv_heads=self.num_kv_heads,
135
+ head_size=head_size,
136
+ dtype=self.kv_cache_dtype,
137
+ ),
138
+ layer_names=[self.attn.layer_name],
139
+ vllm_config=vllm_config,
140
+ device=device,
141
+ )
142
+
143
+ def build_attn_metadata(
144
+ self, batch_size: int, kv_stride_order: tuple[int, ...] | None = None
145
+ ) -> CommonAttentionMetadata:
146
+ batch_spec = BatchSpec(seq_lens=[1] * batch_size, query_lens=[1] * batch_size)
147
+ common_attn_metadata = create_common_attn_metadata(
148
+ batch_spec, self.block_size, self.device, arange_block_indices=True
149
+ )
150
+
151
+ max_blocks = (max(batch_spec.seq_lens) + self.block_size - 1) // self.block_size
152
+ num_blocks = batch_size * max_blocks
153
+
154
+ attn_backend = self.attn.attn_backend
155
+ kv_cache_shape = attn_backend.get_kv_cache_shape(
156
+ num_blocks, self.block_size, self.num_kv_heads, self.head_size
157
+ )
158
+ # Caller can force a physical layout; else use the backend's.
159
+ if kv_stride_order is None:
160
+ try:
161
+ kv_stride_order = attn_backend.get_kv_cache_stride_order()
162
+ except (AttributeError, NotImplementedError):
163
+ kv_stride_order = tuple(range(len(kv_cache_shape)))
164
+
165
+ kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_stride_order)
166
+ inv_order = [kv_stride_order.index(i) for i in range(len(kv_stride_order))]
167
+
168
+ raw_tensor = torch.zeros(
169
+ 2 * num_blocks * self.block_size * self.num_kv_heads * self.head_size,
170
+ dtype=self.kv_cache_dtype,
171
+ device=self.device,
172
+ )
173
+ raw_tensor = raw_tensor.view(kv_cache_shape)
174
+ kv_cache = raw_tensor.permute(*inv_order)
175
+
176
+ # Store as a bare tensor (not wrapped in a list) to match production
177
+ # `bind_kv_cache` behavior. `get_attention_context` returns this
178
+ # attribute directly to the fused/unfused `do_kv_cache_update` impls,
179
+ # which call `kv_cache.unbind(0)` and therefore require a tensor.
180
+ self.attn.kv_cache = kv_cache
181
+
182
+ attn_metadata = self.builder.build(
183
+ common_prefix_len=0, common_attn_metadata=common_attn_metadata
184
+ )
185
+
186
+ return attn_metadata
187
+
188
+ def forward(
189
+ self, qkv: torch.Tensor, positions: torch.Tensor
190
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
191
+ qkv = qkv.clone()
192
+ q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
193
+
194
+ # QK-norm: RMSNorm on per-head Q and K
195
+ q = q.view(-1, self.num_heads, self.head_size)
196
+ q = self.q_norm(q)
197
+ q = q.view(-1, self.q_size)
198
+
199
+ k = k.view(-1, self.num_kv_heads, self.head_size)
200
+ k = self.k_norm(k)
201
+ k = k.view(-1, self.kv_size)
202
+
203
+ # RoPE
204
+ q, k = self.rotary_emb(positions, q, k)
205
+
206
+ # Mirror Attention.forward: quant-query impls consume an fp8 q.
207
+ if (
208
+ self.kv_cache_dtype != self.dtype
209
+ and self.attn.impl.supports_quant_query_input
210
+ ):
211
+ q_fp8 = torch.empty_like(q, dtype=FP8_DTYPE)
212
+ torch.ops.vllm.rocm_aiter_per_tensor_quant(
213
+ q_fp8, q, self.attn._q_scale, False
214
+ )
215
+ q = q_fp8
216
+
217
+ # Final views + KV cache update
218
+ q = q.view(-1, self.num_heads, self.head_size)
219
+ k = k.view(-1, self.num_kv_heads, self.head_size)
220
+ v = v.view(-1, self.num_kv_heads, self.head_size)
221
+ kv_cache_dummy_dep = torch.ops.vllm.unified_kv_cache_update(
222
+ k, v, self.layer_name
223
+ )
224
+ return q, k, v, kv_cache_dummy_dep
225
+
226
+ def ops_in_model_before(self) -> list[torch._ops.OpOverload]:
227
+ ops: list[torch._ops.OpOverload] = []
228
+ # RoPE is not yet IR-migrated, so its custom op still surfaces
229
+ # directly in the graph based on `enable_rope_custom_op`.
230
+ if self.enable_rope_custom_op:
231
+ if rocm_aiter_ops.is_triton_rotary_embed_enabled():
232
+ ops.append(torch.ops.vllm.rocm_aiter_triton_rotary_embedding.default)
233
+ else:
234
+ ops.append(ROTARY_OP)
235
+ else:
236
+ ops.append(INDEX_SELECT_OP)
237
+ ops.append(torch.ops.vllm.unified_kv_cache_update.default)
238
+ return ops
239
+
240
+ def ops_in_model_after(self) -> list[torch._ops.OpOverload]:
241
+ return [torch.ops.vllm.fused_qk_norm_rope_and_unified_kv_cache_update.default]
242
+
243
+
244
+def _run_qk_norm_rope_kvcache_fusion_test(
245
+ *,
246
+ attn_backend: AttentionBackendEnum,
247
+ enable_aiter_triton_rope: bool,
248
+ num_tokens: int,
249
+ num_heads: int,
250
+ num_kv_heads: int,
251
+ head_size: int,
252
+ rotary_dim: int,
253
+ block_size: int,
254
+ is_neox: bool,
255
+ use_shuffle_kv_layout: str,
256
+ kv_stride_order: tuple[int, ...],
257
+ dtype: torch.dtype,
258
+ kv_cache_dtype: str,
259
+ rms_norm_eps: float,
260
+ custom_op: str,
261
+ monkeypatch: pytest.MonkeyPatch,
262
+) -> None:
263
+ device = os.environ.get("VLLM_TEST_CUDA_DEVICE", "cuda")
264
+ torch.set_default_device(device)
265
+ torch.set_default_dtype(dtype)
266
+ torch.manual_seed(0)
267
+
268
+ vllm_config = VllmConfig(
269
+ model_config=ModelConfig(dtype=dtype),
270
+ cache_config=CacheConfig(
271
+ block_size=block_size,
272
+ cache_dtype=kv_cache_dtype,
273
+ ),
274
+ compilation_config=CompilationConfig(
275
+ mode=CompilationMode.VLLM_COMPILE,
276
+ custom_ops=[custom_op],
277
+ pass_config=PassConfig(
278
+ fuse_qk_norm_rope_kvcache=True,
279
+ eliminate_noops=True,
280
+ ),
281
+ ),
282
+ )
283
+
284
+ with vllm.config.set_current_vllm_config(vllm_config), monkeypatch.context() as m:
285
+ m.setenv("VLLM_ROCM_USE_AITER", "1")
286
+ m.setenv(
287
+ "VLLM_ROCM_USE_AITER_TRITON_ROPE",
288
+ "1" if enable_aiter_triton_rope else "0",
289
+ )
290
+ m.setenv("VLLM_ROCM_SHUFFLE_KV_CACHE_LAYOUT", use_shuffle_kv_layout)
291
+ rocm_aiter_ops.refresh_env_variables()
292
+
293
+ model = QKNormRoPEKVCacheTestModel(
294
+ vllm_config=vllm_config,
295
+ attn_backend=attn_backend,
296
+ num_heads=num_heads,
297
+ num_kv_heads=num_kv_heads,
298
+ head_size=head_size,
299
+ rotary_dim=rotary_dim,
300
+ is_neox=is_neox,
301
+ rms_norm_eps=rms_norm_eps,
302
+ dtype=dtype,
303
+ device=torch.get_default_device(),
304
+ )
305
+
306
+ fusion_pass = QkNormRopeKvCacheFusionPass(vllm_config)
307
+ passes = [
308
+ NoOpEliminationPass(vllm_config),
309
+ SplitCoalescingPass(vllm_config),
310
+ ScatterSplitReplacementPass(vllm_config),
311
+ fusion_pass,
312
+ PostCleanupPass(vllm_config),
313
+ ]
314
+ backend = TestBackend(*passes)
315
+
316
+ qkv = torch.randn(
317
+ num_tokens,
318
+ num_heads * head_size + 2 * num_kv_heads * head_size,
319
+ dtype=dtype,
320
+ )
321
+ pos = torch.arange(num_tokens, dtype=torch.long)
322
+
323
+ qkv_unfused = qkv.clone()
324
+ pos_unfused = pos.clone()
325
+
326
+ # Run unfused (eager) forward
327
+ with set_forward_context(None, vllm_config):
328
+ forward_context = get_forward_context()
329
+ attn_metadata = model.build_attn_metadata(num_tokens, kv_stride_order)
330
+ forward_context.slot_mapping = {
331
+ model.layer_name: attn_metadata.slot_mapping
332
+ }
333
+ q_unfused, k_unfused, v_unfused, dummy = model(qkv_unfused, pos_unfused)
334
+ attn_layer = forward_context.no_compile_layers[model.layer_name]
335
+ kv_cache_unfused = attn_layer.kv_cache
336
+ del dummy
337
+
338
+ # Run fused (compiled) forward
339
+ torch._dynamo.mark_dynamic(qkv, 0)
340
+ torch._dynamo.mark_dynamic(pos, 0)
341
+ with set_forward_context(None, vllm_config):
342
+ model_fused = torch.compile(model, backend=backend)
343
+ forward_context = get_forward_context()
344
+ attn_metadata = model_fused.build_attn_metadata(num_tokens, kv_stride_order)
345
+ forward_context.slot_mapping = {
346
+ model.layer_name: attn_metadata.slot_mapping
347
+ }
348
+ q_fused, k_fused, v_fused, dummy = model_fused(qkv, pos)
349
+ attn_layer = forward_context.no_compile_layers[model.layer_name]
350
+ kv_cache_fused = attn_layer.kv_cache
351
+ del dummy
352
+
353
+ assert fusion_pass.matched_count == 1
354
+
355
+ backend.check_before_ops(model.ops_in_model_before())
356
+ backend.check_after_ops(model.ops_in_model_after())
357
+
358
+ # Sweep-backed (18.2k pts, PR #42749): native-rope ref worst 7.7e-3 -> 1e-2;
359
+ # AITER-triton-rope ref is itself approximate (plateau 1.28e-2) -> 2e-2.
360
+ ATOL, RTOL = (2e-2, 2e-2) if enable_aiter_triton_rope else (1e-2, 1e-2)
361
+ is_fp8_cache = model.kv_cache_dtype != dtype
362
+
363
+ if q_fused.dtype == FP8_DTYPE:
364
+ # Quant-query path: both q are fp8; compare dequant within 1 fp8 ULP.
365
+ torch.testing.assert_close(
366
+ q_unfused.float(), q_fused.float(), atol=1.25e-1, rtol=1.25e-1
367
+ )
368
+ else:
369
+ torch.testing.assert_close(q_unfused, q_fused, atol=ATOL, rtol=RTOL)
370
+
371
+ if not is_fp8_cache:
372
+ # The AITER PTS kernel populates k_out only for non-FP8 caches.
373
+ # With FP8, the kernel writes quantized K directly to the cache
374
+ # and may leave k_out uninitialised. In production this is fine
375
+ # because downstream attention reads K from the cache.
376
+ torch.testing.assert_close(k_unfused, k_fused, atol=ATOL, rtol=RTOL)
377
+
378
+ # Should be bit exact since no processing had been done on v for both paths
379
+ torch.testing.assert_close(v_unfused, v_fused, atol=0.0, rtol=0.0)
380
+
381
+ # fp8 vs triton-rope ref requires loosening tolerance to 1.25e-1.
382
+ if is_fp8_cache and enable_aiter_triton_rope:
383
+ cache_atol = cache_rtol = 1.25e-1
384
+ else:
385
+ cache_atol, cache_rtol = ATOL, RTOL
386
+
387
+ torch.testing.assert_close(
388
+ kv_cache_unfused[0].float(),
389
+ kv_cache_fused[0].float(),
390
+ atol=cache_atol,
391
+ rtol=cache_rtol,
392
+ )
393
+
394
+
395
+_FUSION_CONFIGS = [
396
+ # Full rotary, both neox styles (the original coverage).
397
+ pytest.param(64, 8, 64, 64, True, id="full-neox"),
398
+ pytest.param(64, 8, 64, 64, False, id="full-non_neox"),
399
+ # GLM-4.5/4.6/4.7 (glm4_moe.py:275 partial_rotary_factor=0.5, neox-style)
400
+ pytest.param(32, 8, 128, 64, True, id="glm4_moe"),
401
+ # GLM-4 dense (glm4.py:97,124 partial_rotary_factor=0.5, non-neox)
402
+ pytest.param(32, 2, 128, 64, False, id="glm4_dense"),
403
+ # Moondream3-style small head (head_size=64, rotary_dim=32)
404
+ pytest.param(16, 2, 64, 32, True, id="partial_small_head"),
405
+]
406
+
407
+
408
+@pytest.mark.parametrize(
409
+ "num_heads, num_kv_heads, head_size, rotary_dim, is_neox",
410
+ _FUSION_CONFIGS,
411
+)
412
+@pytest.mark.parametrize(
413
+ "attn_backend",
414
+ [
415
+ AttentionBackendEnum.ROCM_AITER_UNIFIED_ATTN,
416
+ AttentionBackendEnum.ROCM_AITER_FA,
417
+ ],
418
+)
419
+@pytest.mark.parametrize("num_tokens", [5, 16, 2048])
420
+@pytest.mark.parametrize("use_shuffle_kv_layout", ["1", "0"])
421
+@pytest.mark.parametrize(
422
+ "kv_stride_order",
423
+ [
424
+ pytest.param((0, 1, 2, 3, 4), id="block_first"),
425
+ pytest.param((1, 0, 2, 3, 4), id="kv_first"),
426
+ ],
427
+)
428
+@pytest.mark.parametrize("enable_aiter_triton_rope", [True, False])
429
+@pytest.mark.parametrize("block_size", [16, 32, 64])
430
+@pytest.mark.parametrize("dtype", [torch.bfloat16])
431
+@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8"])
432
+@pytest.mark.parametrize("rms_norm_eps", [1e-5, 1e-6])
433
+@pytest.mark.parametrize("custom_op", ["+rotary_embedding", "+rms_norm"])
434
+@pytest.mark.skipif(
435
+ not is_aiter_found_and_supported(),
436
+ reason="Only test on ROCm with AITER installed and supported",
437
+)
438
+def test_qk_norm_rope_kvcache_fusion(
439
+ num_tokens: int,
440
+ num_heads: int,
441
+ num_kv_heads: int,
442
+ head_size: int,
443
+ rotary_dim: int,
444
+ is_neox: bool,
445
+ attn_backend: AttentionBackendEnum,
446
+ enable_aiter_triton_rope: bool,
447
+ use_shuffle_kv_layout: str,
448
+ kv_stride_order: tuple[int, ...],
449
+ block_size: int,
450
+ dtype: torch.dtype,
451
+ kv_cache_dtype: str,
452
+ rms_norm_eps: float,
453
+ custom_op: str,
454
+ monkeypatch: pytest.MonkeyPatch,
455
+):
456
+ if (
457
+ attn_backend == AttentionBackendEnum.ROCM_AITER_UNIFIED_ATTN
458
+ and use_shuffle_kv_layout == "1"
459
+ ):
460
+ pytest.skip("ROCM_AITER_UNIFIED_ATTN is NHD-only; shuffle env is ignored")
461
+ _run_qk_norm_rope_kvcache_fusion_test(
462
+ attn_backend=attn_backend,
463
+ enable_aiter_triton_rope=enable_aiter_triton_rope,
464
+ num_tokens=num_tokens,
465
+ num_heads=num_heads,
466
+ num_kv_heads=num_kv_heads,
467
+ head_size=head_size,
468
+ rotary_dim=rotary_dim,
469
+ block_size=block_size,
470
+ is_neox=is_neox,
471
+ use_shuffle_kv_layout=use_shuffle_kv_layout,
472
+ kv_stride_order=kv_stride_order,
473
+ dtype=dtype,
474
+ kv_cache_dtype=kv_cache_dtype,
475
+ rms_norm_eps=rms_norm_eps,
476
+ custom_op=custom_op,
477
+ monkeypatch=monkeypatch,
478
+ )
tests/quantization/test_fp8.py
+41
@@ -17,6 +17,10 @@ from vllm.config.model import ModelConfig
17
from vllm.model_executor.kernels.linear.scaled_mm import (
18
MarlinFP8ScaledMMLinearKernel,
19
)
20
+from vllm.model_executor.layers.attention.attention import (
21
+ Attention,
22
+ set_default_quant_scales,
23
+)
24
from vllm.model_executor.layers.fused_moe import FusedMoE
25
from vllm.model_executor.layers.quantization.fp8 import (
26
Fp8Config,
@@ -24,6 +28,7 @@ from vllm.model_executor.layers.quantization.fp8 import (
28
Fp8LinearMethod,
29
Fp8MoEMethod,
30
)
31
+from vllm.model_executor.layers.quantization.kv_cache import BaseKVCacheMethod
32
from vllm.model_executor.layers.quantization.online.fp8 import (
33
Fp8PerTensorOnlineLinearMethod,
34
)
@@ -422,6 +427,42 @@ def test_fp8_reloading(
427
method.process_weights_after_loading(layer)
428
429
430
+@pytest.mark.parametrize("source", ["checkpoint", "runtime_calc"])
431
+def test_kv_cache_scale_sync_to_host_copies(source):
432
+ """Test device-to-host sync of the k/v quantization scales, for both the
433
+ checkpoint-load and runtime-calc paths that produce them.
434
+ """
435
+ layer = torch.nn.Module()
436
+ set_default_quant_scales(layer, register_buffer=True)
437
+ layer.kv_cache_dtype = "fp8"
438
+
439
+ if source == "checkpoint":
440
+ # Scales come from the checkpoint, so runtime calc is disabled.
441
+ layer.calculate_kv_scales = False
442
+ method = BaseKVCacheMethod(quant_config=None)
443
+ method.create_weights(layer)
444
+ # 0.3 stays != 1.0 even after the fp8_fnuz x2 rescale.
445
+ checkpoint_scale = torch.tensor(0.3, dtype=torch.float32)
446
+ layer.k_scale.weight_loader(layer.k_scale, checkpoint_scale)
447
+ layer.v_scale.weight_loader(layer.v_scale, checkpoint_scale)
448
+ method.process_weights_after_loading(layer)
449
+ else:
450
+ # First forward computes distinct, non-unity scales from live k/v.
451
+ layer.calculate_kv_scales = True
452
+ query = torch.full((4, 8), 10.0)
453
+ key = torch.full((4, 8), 60.0)
454
+ value = torch.full((4, 8), 50.0)
455
+ Attention.calc_kv_scales(layer, query, key, value)
456
+
457
+ assert layer._k_scale_float != 1.0
458
+ assert layer._v_scale_float != 1.0
459
+ # Host copy must mirror both the float and the device scale tensor.
460
+ assert layer._k_scale_cpu.item() == pytest.approx(layer._k_scale_float)
461
+ assert layer._v_scale_cpu.item() == pytest.approx(layer._v_scale_float)
462
+ assert layer._k_scale_cpu.item() == pytest.approx(layer._k_scale.item())
463
+ assert layer._v_scale_cpu.item() == pytest.approx(layer._v_scale.item())
464
+
465
+
466
@pytest.mark.skipif(
467
not is_quant_method_supported("fp8"),
468
reason="FP8 is not supported on this GPU type.",
vllm/_aiter_ops.py
+122
@@ -2458,6 +2458,128 @@ class rocm_aiter_ops:
2458
gemm_afp4wfp4(x_q, weight, x_s, weight_scale.T, out_dtype, y)
2459
return y
2460
2461
+ @staticmethod
2462
+ def fused_qk_norm_rope_and_cache(
2463
+ qkv: torch.Tensor,
2464
+ q_weight: torch.Tensor,
2465
+ k_weight: torch.Tensor,
2466
+ cos_sin_cache: torch.Tensor,
2467
+ positions: torch.Tensor,
2468
+ num_heads_q: int,
2469
+ num_heads_k: int,
2470
+ num_heads_v: int,
2471
+ head_dim: int,
2472
+ is_neox: bool,
2473
+ rms_norm_eps: float,
2474
+ q_out: torch.Tensor,
2475
+ k_cache: torch.Tensor,
2476
+ v_cache: torch.Tensor,
2477
+ slot_mapping: torch.Tensor,
2478
+ k_scale: torch.Tensor,
2479
+ v_scale: torch.Tensor,
2480
+ k_out: torch.Tensor | None,
2481
+ v_out: torch.Tensor | None,
2482
+ return_kv: bool,
2483
+ use_shuffle_layout: bool,
2484
+ block_size: int,
2485
+ x: int,
2486
+ rotary_dim: int = 0,
2487
+ ):
2488
+ from aiter.ops.fused_qk_norm_rope_cache_quant import (
2489
+ fused_qk_norm_rope_cache_pts_quant_shuffle,
2490
+ )
2491
+
2492
+ fused_qk_norm_rope_cache_pts_quant_shuffle(
2493
+ qkv,
2494
+ q_weight,
2495
+ k_weight,
2496
+ cos_sin_cache,
2497
+ positions,
2498
+ qkv.size(0),
2499
+ num_heads_q,
2500
+ num_heads_k,
2501
+ num_heads_v,
2502
+ head_dim,
2503
+ is_neox,
2504
+ rms_norm_eps,
2505
+ q_out,
2506
+ k_cache,
2507
+ v_cache,
2508
+ slot_mapping,
2509
+ k_scale,
2510
+ v_scale,
2511
+ k_out,
2512
+ v_out,
2513
+ return_kv,
2514
+ use_shuffle_layout,
2515
+ block_size,
2516
+ x,
2517
+ rotary_dim,
2518
+ )
2519
+
2520
+ @staticmethod
2521
+ def do_qk_norm_rope_kvcache_update(
2522
+ qkv: torch.Tensor,
2523
+ q_weight: torch.Tensor,
2524
+ k_weight: torch.Tensor,
2525
+ cos_sin_cache: torch.Tensor,
2526
+ positions: torch.Tensor,
2527
+ num_heads_q: int,
2528
+ num_heads_k: int,
2529
+ head_dim: int,
2530
+ is_neox: bool,
2531
+ rms_norm_eps: float,
2532
+ q_out: torch.Tensor,
2533
+ k_out: torch.Tensor,
2534
+ key_cache: torch.Tensor,
2535
+ value_cache: torch.Tensor,
2536
+ slot_mapping: torch.Tensor,
2537
+ k_scale: torch.Tensor,
2538
+ v_scale: torch.Tensor,
2539
+ kv_cache_dtype: str,
2540
+ use_shuffle_layout: bool,
2541
+ ) -> None:
2542
+ """Run the fused QK-norm+RoPE+KV-cache op on already-split k/v caches.
2543
+
2544
+ Shared by the AITER FA and unified-attention impls. The caller splits
2545
+ kv_cache, since the unbind dim depends on the layout (e.g. the unified
2546
+ encoder-decoder path is K/V-first), and passes use_shuffle_layout
2547
+ (unified reads NHD and must pass False).
2548
+ """
2549
+ if kv_cache_dtype.startswith("fp8"):
2550
+ key_cache = key_cache.view(current_platform.fp8_dtype())
2551
+ value_cache = value_cache.view(current_platform.fp8_dtype())
2552
+ # Partial-rotary support (e.g. GLM-4.7 applies rotary to only a prefix
2553
+ # of each head's channel dim).
2554
+ rotary_dim = cos_sin_cache.shape[-1]
2555
+ kernel_rotary_dim = 0 if rotary_dim == head_dim else rotary_dim
2556
+ rocm_aiter_ops.fused_qk_norm_rope_and_cache(
2557
+ qkv=qkv,
2558
+ q_weight=q_weight,
2559
+ k_weight=k_weight,
2560
+ cos_sin_cache=cos_sin_cache,
2561
+ positions=positions,
2562
+ num_heads_q=num_heads_q,
2563
+ num_heads_k=num_heads_k,
2564
+ num_heads_v=num_heads_k,
2565
+ head_dim=head_dim,
2566
+ is_neox=is_neox,
2567
+ rms_norm_eps=rms_norm_eps,
2568
+ q_out=q_out,
2569
+ k_cache=key_cache,
2570
+ v_cache=value_cache,
2571
+ slot_mapping=slot_mapping,
2572
+ k_scale=k_scale,
2573
+ v_scale=v_scale,
2574
+ k_out=k_out,
2575
+ v_out=None,
2576
+ return_kv=True,
2577
+ use_shuffle_layout=use_shuffle_layout,
2578
+ block_size=key_cache.shape[1],
2579
+ x=16 // key_cache.element_size(),
2580
+ rotary_dim=kernel_rotary_dim,
2581
+ )
2582
+
2583
@staticmethod
2584
def triton_rope_and_cache(
2585
query: torch.Tensor,
vllm/compilation/passes/fusion/qk_norm_rope_fusion.py
+22
-1
@@ -25,6 +25,11 @@ logger = init_logger(__name__)
25
26
FUSED_QK_ROPE_OP = torch.ops._C.fused_qk_norm_rope.default
27
28
+# Head dimensions supported by csrc/fused_qknorm_rope_kernel.cu's
29
+# launchFusedQKNormRope and launchFusedQKNormRopeNTokenHeads dispatchers.
30
+# Keep in sync with the switch statements in that file.
31
+SUPPORTED_FUSED_QK_NORM_ROPE_HEAD_DIMS: tuple[int, ...] = (64, 128, 256)
32
+
33
P = ParamSpec("P")
34
35
@@ -190,7 +195,12 @@ class QkNormRopePattern:
195
196
197
class QKNormRoPEFusionPass(VllmPatternMatcherPass):
193
- """Fuse Q/K RMSNorm + RoPE into fused_qk_norm_rope when the custom op exists."""
198
+ """Fuse Q/K RMSNorm + RoPE into fused_qk_norm_rope when the custom op exists.
199
+
200
+ Registers patterns for both standard vLLM ops and ROCm AITER ops
201
+ (when AITER is enabled), so the fusion fires regardless of which
202
+ RMSNorm/RoPE implementation the graph uses.
203
+ """
204
205
@enable_fake_mode
206
def __init__(self, config: VllmConfig) -> None:
@@ -216,6 +226,17 @@ class QKNormRoPEFusionPass(VllmPatternMatcherPass):
226
)
227
return
228
229
+ for layer in attn_layers.values():
230
+ if layer.head_size not in SUPPORTED_FUSED_QK_NORM_ROPE_HEAD_DIMS:
231
+ logger.warning_once(
232
+ "QK Norm+RoPE fusion not enabled: layer head_size=%d is not "
233
+ "supported by fused_qk_norm_rope kernel (supported: %s). "
234
+ "Falling back to unfused QK norm + RoPE path.",
235
+ layer.head_size,
236
+ SUPPORTED_FUSED_QK_NORM_ROPE_HEAD_DIMS,
237
+ )
238
+ return
239
+
240
self._attention_geometries = tuple(
241
sorted(
242
{
vllm/compilation/passes/fusion/qk_norm_rope_kvcache_fusion.py
new
+492
@@ -0,0 +1,492 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import inspect
5
+from collections.abc import Callable
6
+from typing import ParamSpec
7
+
8
+import torch
9
+import torch._inductor.pattern_matcher as pm
10
+from torch import fx
11
+from torch._higher_order_ops.auto_functionalize import auto_functionalized
12
+from torch._inductor.pattern_matcher import PatternMatcherPass
13
+
14
+import vllm.ir.ops
15
+from vllm.config import VllmConfig, get_layers_from_vllm_config
16
+from vllm.config.utils import Range
17
+from vllm.logger import init_logger
18
+from vllm.model_executor.layers.attention.attention import (
19
+ Attention,
20
+ get_attention_context,
21
+)
22
+from vllm.platforms import current_platform
23
+from vllm.utils.torch_utils import direct_register_custom_op
24
+
25
+from ..inductor_pass import enable_fake_mode
26
+from ..vllm_inductor_pass import VllmInductorPass, VllmPatternMatcherPass
27
+from .matcher_utils import MatcherRotaryEmbedding
28
+from .rms_quant_fusion import empty_bf16, empty_fp32, empty_i64
29
+
30
+logger = init_logger(__name__)
31
+
32
+P = ParamSpec("P")
33
+
34
+# Head sizes the fused kernel fused_qk_norm_rope_cache_pts_quant_shuffle() supports
35
+# Other sizes hard-abort, so skip those layers.
36
+SUPPORTED_FUSED_QK_NORM_ROPE_KVCACHE_HEAD_DIMS: tuple[int, ...] = (64, 128, 256)
37
+
38
+
39
+# ---------------------------------------------------------------------------
40
+# Custom op: fused QK-norm + RoPE + KV cache update
41
+# ---------------------------------------------------------------------------
42
+
43
+
44
+def fused_qk_norm_rope_and_unified_kv_cache_update_impl(
45
+ q_out: torch.Tensor,
46
+ k_out: torch.Tensor,
47
+ qkv: torch.Tensor,
48
+ positions: torch.Tensor,
49
+ q_weight: torch.Tensor,
50
+ k_weight: torch.Tensor,
51
+ rms_norm_eps: float,
52
+ cos_sin_cache: torch.Tensor,
53
+ is_neox: bool,
54
+ layer_name: str = "",
55
+) -> torch.Tensor:
56
+ _, attn_layer, kv_cache, layer_slot_mapping = get_attention_context(layer_name)
57
+ if layer_slot_mapping is not None:
58
+ attn_layer.impl.do_qk_norm_rope_kvcache_update(
59
+ attn_layer,
60
+ qkv,
61
+ q_out,
62
+ k_out,
63
+ positions,
64
+ q_weight,
65
+ k_weight,
66
+ rms_norm_eps,
67
+ cos_sin_cache,
68
+ is_neox,
69
+ kv_cache,
70
+ layer_slot_mapping,
71
+ )
72
+ else:
73
+ # Profiling/dummy run: define q_out/k_out (consumed by attention).
74
+ q_out.zero_()
75
+ k_out.zero_()
76
+
77
+ return torch.empty(0, device=qkv.device, dtype=qkv.dtype)
78
+
79
+
80
+def fused_qk_norm_rope_and_unified_kv_cache_update_fake(
81
+ q_out: torch.Tensor,
82
+ k_out: torch.Tensor,
83
+ qkv: torch.Tensor,
84
+ positions: torch.Tensor,
85
+ q_weight: torch.Tensor,
86
+ k_weight: torch.Tensor,
87
+ rms_norm_eps: float,
88
+ cos_sin_cache: torch.Tensor,
89
+ is_neox: bool,
90
+ layer_name: str = "",
91
+) -> torch.Tensor:
92
+ return torch.empty(0, device=qkv.device, dtype=qkv.dtype)
93
+
94
+
95
+direct_register_custom_op(
96
+ op_name="fused_qk_norm_rope_and_unified_kv_cache_update",
97
+ op_func=fused_qk_norm_rope_and_unified_kv_cache_update_impl,
98
+ mutates_args=["q_out", "k_out"],
99
+ fake_impl=fused_qk_norm_rope_and_unified_kv_cache_update_fake,
100
+)
101
+
102
+
103
+# ---------------------------------------------------------------------------
104
+# Pattern: QK-norm + RoPE + unified_kv_cache_update
105
+# ---------------------------------------------------------------------------
106
+
107
+
108
+class QkNormRopeKvCachePattern:
109
+ """
110
+ Match the unfused sequence:
111
+ q, k, v = split(qkv, ...)
112
+ q = rms_norm(q.view(heads), q_weight).view(flat)
113
+ k = rms_norm(k.view(heads), k_weight).view(flat)
114
+ q, k = rotary_embedding(positions, q, k, cos_sin_cache, is_neox)
115
+ q = q.view(num_heads, head_dim)
116
+ k = k.view(num_kv_heads, head_dim)
117
+ v = v.view(num_kv_heads, head_dim)
118
+ dummy = unified_kv_cache_update(k, v, layer_name)
119
+
120
+ Replace with:
121
+ q_out = empty(...)
122
+ k_out = empty(...)
123
+ dummy = fused_qk_norm_rope_and_unified_kv_cache_update(
124
+ q_out, k_out, qkv, positions, q_weight, k_weight,
125
+ eps, cos_sin_cache, is_neox, layer_name)
126
+ v = split(qkv, ...)[2].view(num_kv_heads, head_dim)
127
+ """
128
+
129
+ FUSED_OP = torch.ops.vllm.fused_qk_norm_rope_and_unified_kv_cache_update.default
130
+
131
+ def __init__(
132
+ self,
133
+ layer: Attention,
134
+ eps: float,
135
+ is_neox: bool,
136
+ quant_query: bool,
137
+ ) -> None:
138
+ self.layer_name = layer.layer_name
139
+ self.num_heads = layer.num_heads
140
+ self.num_kv_heads = layer.num_kv_heads
141
+ self.head_size = layer.head_size
142
+ self.head_size_v = layer.head_size_v
143
+ self.eps = eps
144
+ self.is_neox = is_neox
145
+ self.quant_query = quant_query
146
+
147
+ self.q_size = self.num_heads * self.head_size
148
+ self.k_size = self.num_kv_heads * self.head_size
149
+ self.v_size = self.num_kv_heads * self.head_size_v
150
+
151
+ self.rope_matcher = MatcherRotaryEmbedding(
152
+ is_neox=is_neox,
153
+ head_size=self.head_size,
154
+ num_heads=self.num_heads,
155
+ num_kv_heads=self.num_kv_heads,
156
+ )
157
+
158
+ def get_inputs(self) -> list[torch.Tensor]:
159
+ T = 5
160
+ L = 4096
161
+ qkv = empty_bf16(T, self.q_size + self.k_size + self.v_size)
162
+ positions = empty_i64(T)
163
+ q_weight = empty_bf16(1, self.head_size)
164
+ k_weight = empty_bf16(1, self.head_size)
165
+ cos_sin_cache = empty_bf16(L, self.head_size)
166
+ inputs = [qkv, positions, q_weight, k_weight, cos_sin_cache]
167
+ if self.quant_query:
168
+ q_scale = empty_fp32(1)
169
+ inputs += [q_scale]
170
+ return inputs
171
+
172
+ def pattern_non_fp8_quant_query(
173
+ self,
174
+ qkv: torch.Tensor,
175
+ positions: torch.Tensor,
176
+ q_weight: torch.Tensor,
177
+ k_weight: torch.Tensor,
178
+ cos_sin_cache: torch.Tensor,
179
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
180
+ q, k, v = qkv.split([self.q_size, self.k_size, self.v_size], dim=-1)
181
+ q_by_head = q.view(-1, self.q_size // self.head_size, self.head_size)
182
+ q_normed = vllm.ir.ops.rms_norm(q_by_head, q_weight, self.eps)
183
+ q_flat = q_normed.view(-1, self.q_size)
184
+
185
+ k_by_head = k.view(-1, self.k_size // self.head_size, self.head_size)
186
+ k_normed = vllm.ir.ops.rms_norm(k_by_head, k_weight, self.eps)
187
+ k_flat = k_normed.view(-1, self.k_size)
188
+
189
+ q_rope, k_rope = self.rope_matcher(positions, q_flat, k_flat, cos_sin_cache)
190
+
191
+ q_rope = q_rope.view(-1, self.num_heads, self.head_size)
192
+ k_rope = k_rope.view(-1, self.num_kv_heads, self.head_size)
193
+ v = v.view(-1, self.num_kv_heads, self.head_size_v)
194
+ dummy = torch.ops.vllm.unified_kv_cache_update(k_rope, v, self.layer_name)
195
+ return dummy, q_rope, k_rope, v
196
+
197
+ def replacement_non_fp8_quant_query(
198
+ self,
199
+ qkv: torch.Tensor,
200
+ positions: torch.Tensor,
201
+ q_weight: torch.Tensor,
202
+ k_weight: torch.Tensor,
203
+ cos_sin_cache: torch.Tensor,
204
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
205
+ q_out = torch.empty(
206
+ qkv.shape[0],
207
+ self.num_heads,
208
+ self.head_size,
209
+ device=qkv.device,
210
+ dtype=qkv.dtype,
211
+ )
212
+ k_out = torch.empty(
213
+ qkv.shape[0],
214
+ self.num_kv_heads,
215
+ self.head_size,
216
+ device=qkv.device,
217
+ dtype=qkv.dtype,
218
+ )
219
+ _, _, v = qkv.split([self.q_size, self.k_size, self.v_size], dim=-1)
220
+ v = v.view(qkv.shape[0], self.num_kv_heads, self.head_size_v)
221
+ results = auto_functionalized(
222
+ self.FUSED_OP,
223
+ q_out=q_out,
224
+ k_out=k_out,
225
+ qkv=qkv,
226
+ positions=positions,
227
+ q_weight=q_weight,
228
+ k_weight=k_weight,
229
+ rms_norm_eps=self.eps,
230
+ cos_sin_cache=cos_sin_cache,
231
+ is_neox=self.is_neox,
232
+ layer_name=self.layer_name,
233
+ )
234
+ return results[0], results[1], results[2], v
235
+
236
+ def pattern_fp8_quant_query(
237
+ self,
238
+ qkv: torch.Tensor,
239
+ positions: torch.Tensor,
240
+ q_weight: torch.Tensor,
241
+ k_weight: torch.Tensor,
242
+ cos_sin_cache: torch.Tensor,
243
+ q_scale: torch.Tensor,
244
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
245
+ q, k, v = qkv.split([self.q_size, self.k_size, self.v_size], dim=-1)
246
+ q_by_head = q.view(-1, self.q_size // self.head_size, self.head_size)
247
+ q_normed = vllm.ir.ops.rms_norm(q_by_head, q_weight, self.eps)
248
+ q_flat = q_normed.view(-1, self.q_size)
249
+
250
+ k_by_head = k.view(-1, self.k_size // self.head_size, self.head_size)
251
+ k_normed = vllm.ir.ops.rms_norm(k_by_head, k_weight, self.eps)
252
+ k_flat = k_normed.view(-1, self.k_size)
253
+
254
+ q_rope, k_rope = self.rope_matcher(positions, q_flat, k_flat, cos_sin_cache)
255
+ # Match the quant-query op Attention.forward inserts (fp8 KV + UNIFIED).
256
+ # Explicit auto_functionalized (out=[1]) keeps the quant node in the pattern.
257
+ q_out = torch.empty_like(q_rope, dtype=current_platform.fp8_dtype())
258
+ q_quant = auto_functionalized(
259
+ torch.ops.vllm.rocm_aiter_per_tensor_quant.default,
260
+ out=q_out,
261
+ x=q_rope,
262
+ scale=q_scale,
263
+ is_dynamic=False,
264
+ )
265
+ # `scale` is mutable: its copy_ write-back to _q_scale bumps the mutation
266
+ # region, so keep q flat (a reshape lands past the barrier and won't match).
267
+ q_rope_fp8 = q_quant[1]
268
+ q_scale_out = q_quant[2]
269
+
270
+ k_rope = k_rope.view(-1, self.num_kv_heads, self.head_size)
271
+ v = v.view(-1, self.num_kv_heads, self.head_size_v)
272
+ dummy = torch.ops.vllm.unified_kv_cache_update(k_rope, v, self.layer_name)
273
+ return dummy, q_rope_fp8, k_rope, v, q_scale_out
274
+
275
+ def replacement_fp8_quant_query(
276
+ self,
277
+ qkv: torch.Tensor,
278
+ positions: torch.Tensor,
279
+ q_weight: torch.Tensor,
280
+ k_weight: torch.Tensor,
281
+ cos_sin_cache: torch.Tensor,
282
+ q_scale: torch.Tensor,
283
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
284
+ q_out = torch.empty(
285
+ qkv.shape[0],
286
+ self.num_heads,
287
+ self.head_size,
288
+ device=qkv.device,
289
+ dtype=qkv.dtype,
290
+ )
291
+ k_out = torch.empty(
292
+ qkv.shape[0],
293
+ self.num_kv_heads,
294
+ self.head_size,
295
+ device=qkv.device,
296
+ dtype=qkv.dtype,
297
+ )
298
+ _, _, v = qkv.split([self.q_size, self.k_size, self.v_size], dim=-1)
299
+ v = v.view(qkv.shape[0], self.num_kv_heads, self.head_size_v)
300
+ results = auto_functionalized(
301
+ self.FUSED_OP,
302
+ q_out=q_out,
303
+ k_out=k_out,
304
+ qkv=qkv,
305
+ positions=positions,
306
+ q_weight=q_weight,
307
+ k_weight=k_weight,
308
+ rms_norm_eps=self.eps,
309
+ cos_sin_cache=cos_sin_cache,
310
+ is_neox=self.is_neox,
311
+ layer_name=self.layer_name,
312
+ )
313
+ # Re-apply the quant on the kernel's bf16 q_out; fused op does not quant q.
314
+ # Same explicit auto_functionalized form as the pattern: [1] = quantized
315
+ # q, [2] = scale (returned so the buffer-writeback use is preserved).
316
+ q_fp8_flat = results[1].view(-1, self.q_size)
317
+ q_fp8_out = torch.empty_like(q_fp8_flat, dtype=current_platform.fp8_dtype())
318
+ q_requant = auto_functionalized(
319
+ torch.ops.vllm.rocm_aiter_per_tensor_quant.default,
320
+ out=q_fp8_out,
321
+ x=q_fp8_flat,
322
+ scale=q_scale,
323
+ is_dynamic=False,
324
+ )
325
+ q_fp8 = q_requant[1] # flat to mirror the pattern (see note above)
326
+ q_scale_out = q_requant[2]
327
+ return results[0], q_fp8, results[2], v, q_scale_out
328
+
329
+ @staticmethod
330
+ def wrap_trace_fn(
331
+ trace_fn: Callable[P, fx.GraphModule],
332
+ *process_fx_fns: Callable[[fx.GraphModule], None],
333
+ ) -> Callable[P, fx.GraphModule]:
334
+ def wrapped(*args: P.args, **kwargs: P.kwargs) -> fx.GraphModule:
335
+ gm = trace_fn(*args, **kwargs)
336
+ for process_fx in process_fx_fns:
337
+ process_fx(gm)
338
+
339
+ return gm
340
+
341
+ return wrapped
342
+
343
+ @staticmethod
344
+ def fx_view_to_reshape(gm: torch.fx.GraphModule) -> None:
345
+ from torch._inductor.fx_passes.post_grad import view_to_reshape
346
+
347
+ view_to_reshape(gm)
348
+
349
+ def _register(self, pattern, replacement, pm_pass) -> None:
350
+ trace_fn = QkNormRopeKvCachePattern.wrap_trace_fn(
351
+ pm.fwd_only,
352
+ QkNormRopeKvCachePattern.fx_view_to_reshape,
353
+ )
354
+
355
+ # Pre-build the search pattern with `ignore_types=(int, torch.SymInt)`
356
+ # and pass it via `search_fn_pattern=` so torch skips both of its
357
+ # internal `fx_to_pattern` calls and treats dynamic-shape SymInts as
358
+ # wildcards.
359
+ inputs = self.get_inputs()
360
+ argnames = [*inspect.signature(pattern).parameters.keys()]
361
+ search_gm = trace_fn(pattern, inputs)
362
+ search_fn_pattern = pm.fx_to_pattern(
363
+ search_gm,
364
+ ignore_types=(int, torch.SymInt),
365
+ argnames=argnames,
366
+ )
367
+
368
+ pm.register_replacement(
369
+ pattern,
370
+ replacement,
371
+ inputs,
372
+ trace_fn,
373
+ pm_pass,
374
+ search_fn_pattern=search_fn_pattern,
375
+ )
376
+
377
+ def register(self, pm_pass: PatternMatcherPass) -> None:
378
+ # make_fx counts `self` in bound-method code params; wrap as plain fns.
379
+ # Distinct names per branch so mypy doesn't see one name, two signatures.
380
+ if self.quant_query:
381
+
382
+ def pattern_q(qkv, positions, q_weight, k_weight, cos_sin_cache, q_scale):
383
+ return self.pattern_fp8_quant_query(
384
+ qkv, positions, q_weight, k_weight, cos_sin_cache, q_scale
385
+ )
386
+
387
+ def replacement_q(
388
+ qkv, positions, q_weight, k_weight, cos_sin_cache, q_scale
389
+ ):
390
+ return self.replacement_fp8_quant_query(
391
+ qkv, positions, q_weight, k_weight, cos_sin_cache, q_scale
392
+ )
393
+
394
+ self._register(pattern_q, replacement_q, pm_pass)
395
+ else:
396
+
397
+ def pattern_noq(qkv, positions, q_weight, k_weight, cos_sin_cache):
398
+ return self.pattern_non_fp8_quant_query(
399
+ qkv, positions, q_weight, k_weight, cos_sin_cache
400
+ )
401
+
402
+ def replacement_noq(qkv, positions, q_weight, k_weight, cos_sin_cache):
403
+ return self.replacement_non_fp8_quant_query(
404
+ qkv, positions, q_weight, k_weight, cos_sin_cache
405
+ )
406
+
407
+ self._register(pattern_noq, replacement_noq, pm_pass)
408
+
409
+
410
+# ---------------------------------------------------------------------------
411
+# Pass class
412
+# ---------------------------------------------------------------------------
413
+
414
+
415
+class QkNormRopeKvCacheFusionPass(VllmPatternMatcherPass):
416
+ """
417
+ Fuse QK-norm + RoPE + KV cache update into a single AITER HIP kernel.
418
+
419
+ Supersedes both QKNormRoPEFusionPass and RopeKVCacheFusionPass for
420
+ attention layers that support the combined operation, eliminating two
421
+ separate kernel launches and the intermediate memory traffic.
422
+ """
423
+
424
+ @enable_fake_mode
425
+ def __init__(self, config: VllmConfig) -> None:
426
+ super().__init__(config)
427
+
428
+ self.patterns: PatternMatcherPass = PatternMatcherPass(
429
+ pass_name="qk_norm_rope_kvcache_fusion_pass"
430
+ )
431
+
432
+ cc = config.compilation_config
433
+ self.max_token_num = cc.pass_config.rope_kvcache_fusion_max_token_num
434
+
435
+ dtype = config.model_config.dtype
436
+ if dtype not in (torch.bfloat16, torch.float16):
437
+ logger.warning_once(
438
+ "QK Norm+RoPE+KVCache fusion not enabled: unsupported dtype %s", dtype
439
+ )
440
+ return
441
+
442
+ attn_layers = get_layers_from_vllm_config(config, Attention)
443
+
444
+ for _, layer in attn_layers.items():
445
+ if not layer.impl.fused_qk_norm_rope_kvcache_supported():
446
+ continue
447
+ if layer.head_size not in SUPPORTED_FUSED_QK_NORM_ROPE_KVCACHE_HEAD_DIMS:
448
+ logger.warning_once(
449
+ "QK Norm+RoPE+KVCache fusion not enabled for a layer: "
450
+ "head_size=%d is not supported by the "
451
+ "fused_qk_norm_rope_cache_pts_quant_shuffle kernel "
452
+ "(supported: %s). Falling back to the unfused path.",
453
+ layer.head_size,
454
+ SUPPORTED_FUSED_QK_NORM_ROPE_KVCACHE_HEAD_DIMS,
455
+ )
456
+ continue
457
+ if layer.head_size_v != layer.head_size:
458
+ # The fused kernel uses a single head_dim for q/k/v.
459
+ logger.warning_once(
460
+ "QK Norm+RoPE+KVCache fusion not enabled for a layer: "
461
+ "head_size_v=%d differs from head_size=%d, which the fused "
462
+ "kernel does not support. Falling back to the unfused path.",
463
+ layer.head_size_v,
464
+ layer.head_size,
465
+ )
466
+ continue
467
+ for epsilon in [1e-5, 1e-6]:
468
+ for neox in [True, False]:
469
+ for quant_q in [False, True]:
470
+ QkNormRopeKvCachePattern(
471
+ layer=layer,
472
+ eps=epsilon,
473
+ is_neox=neox,
474
+ quant_query=quant_q,
475
+ ).register(self.patterns)
476
+
477
+ self.dump_patterns(config, self.patterns)
478
+
479
+ @VllmInductorPass.time_and_log
480
+ def __call__(self, graph: fx.Graph) -> None:
481
+ self.matched_count = self.patterns.apply(graph)
482
+ logger.info(
483
+ "QK-Norm+RoPE+KVCache fusion: replaced %s pattern(s) "
484
+ "with AITER fused_qk_norm_rope_cache_pts_quant_shuffle",
485
+ self.matched_count,
486
+ )
487
+
488
+ def is_applicable_for_range(self, compile_range: Range) -> bool:
489
+ return compile_range.end <= self.max_token_num
490
+
491
+ def uuid(self) -> str:
492
+ return VllmInductorPass.hash_source(self, QkNormRopeKvCachePattern)
vllm/compilation/passes/pass_manager.py
+6
@@ -38,6 +38,7 @@ if current_platform.is_cuda_alike():
38
from .fusion.mla_attn_quant_fusion import MLAAttnQuantFusionPass
39
from .fusion.mla_rope_kvcache_cat_fusion import MLARoPEKVCacheCatFusionPass
40
from .fusion.qk_norm_rope_fusion import QKNormRoPEFusionPass
41
+ from .fusion.qk_norm_rope_kvcache_fusion import QkNormRopeKvCacheFusionPass
42
from .fusion.rms_quant_fusion import RMSNormQuantFusionPass
43
from .fusion.rope_kvcache_fusion import RopeKVCacheFusionPass
44
from .utility.scatter_split_replace import ScatterSplitReplacementPass
@@ -171,6 +172,11 @@ class PostGradPassManager(CustomGraphPass): # type: ignore[misc]
172
if rocm_aiter_ops.is_enabled():
173
self.passes += [RocmAiterSiluMulFp8GroupQuantFusionPass(config)]
174
175
+ if self.pass_config.fuse_qk_norm_rope_kvcache:
176
+ self.passes += [SplitCoalescingPass(config)]
177
+ self.passes += [ScatterSplitReplacementPass(config)]
178
+ self.passes += [QkNormRopeKvCacheFusionPass(config)]
179
+
180
if (
181
self.pass_config.fuse_mla_dual_rms_norm
182
and rocm_aiter_ops.is_enabled()
vllm/config/compilation.py
+36
-2
@@ -146,10 +146,16 @@ class PassConfig:
146
"""Fuse paired q/kv RMS norms in MLA attention."""
147
fuse_rope_kvcache: bool = None # type: ignore[assignment]
148
"""Fuse the QK rope + KV cache ops."""
149
+ fuse_qk_norm_rope_kvcache: bool = Field(default=None) # type: ignore[assignment]
150
+ """Fuse QK RMSNorm + RoPE + KV cache update into a single AITER HIP
151
+ kernel. Supersedes both enable_qk_norm_rope_fusion and fuse_rope_kvcache
152
+ for layers that support it. Auto-enabled at O1+ on ROCm for models
153
+ with QK-norm (e.g. Qwen3-MoE)."""
154
155
rope_kvcache_fusion_max_token_num: int = 256
156
"""The threshold for ROCm AITER RoPE+KVCache fusion e.g. for small batch decode.
157
Larger batch sizes e.g. during prefill will use the unfused kernels.
158
+ Also applies to the fused QK-Norm+RoPE+KVCache pass.
159
"""
160
161
fi_allreduce_fusion_max_size_mb: float | None = None
@@ -228,6 +234,8 @@ class PassConfig:
234
"fuse_act_padding",
235
"fuse_mla_dual_rms_norm",
236
"fuse_rope_kvcache",
237
+ "fuse_qk_norm_rope_kvcache",
238
+ "enable_qk_norm_rope_fusion",
239
"fuse_rope_kvcache_cat_mla",
240
mode="wrap",
241
)
@@ -288,6 +296,12 @@ class PassConfig:
296
"The fusion will be disabled."
297
)
298
self.fuse_rope_kvcache = False
299
+ if self.fuse_qk_norm_rope_kvcache and not current_platform.is_rocm():
300
+ logger.warning_once(
301
+ "QK-Norm+RoPE+KVCache fusion requires ROCm with AITER. "
302
+ "The fusion will be disabled."
303
+ )
304
+ self.fuse_qk_norm_rope_kvcache = False
305
if self.fuse_rope_kvcache_cat_mla and not current_platform.is_cuda_alike():
306
logger.warning_once(
307
"MLA KV cache update with RoPE fusion enabled but the "
@@ -302,10 +316,13 @@ class PassConfig:
316
after all defaults are finalized.
317
TODO also log the compile ranges for which this is enabled.
318
"""
319
+ fusion_prefixes = ("fuse_", "enable_")
320
enabled_fusions = [
306
- f.name[len("fuse_") :]
321
+ f.name[len(prefix) :]
322
for f in fields(self) # type: ignore[arg-type]
308
- if getattr(self, f.name) and f.name.startswith("fuse_")
323
+ if getattr(self, f.name)
324
+ for prefix in fusion_prefixes
325
+ if f.name.startswith(prefix)
326
]
327
328
if enabled_fusions:
@@ -947,6 +964,7 @@ class CompilationConfig:
964
# TODO(zhuhaoran): support rope native forward match and remove this.
965
# Linked issue: https://github.com/vllm-project/vllm/issues/28042
966
self.custom_ops.append("+rotary_embedding")
967
+
968
if (
969
self.pass_config.fuse_rope_kvcache
970
and "+rotary_embedding" not in self.custom_ops
@@ -955,6 +973,12 @@ class CompilationConfig:
973
# Linked issue: https://github.com/vllm-project/vllm/issues/28042
974
self.custom_ops.append("+rotary_embedding")
975
976
+ if (
977
+ self.pass_config.fuse_qk_norm_rope_kvcache
978
+ and "+rotary_embedding" not in self.custom_ops
979
+ ):
980
+ self.custom_ops.append("+rotary_embedding")
981
+
982
if (
983
is_torch_equal_or_newer("2.9.0.dev")
984
and "combo_kernels" not in self.inductor_compile_config
@@ -1137,6 +1161,16 @@ class CompilationConfig:
1161
"to enable RoPE+KV cache fusion."
1162
)
1163
self.pass_config.fuse_rope_kvcache = False
1164
+ if self.pass_config.fuse_qk_norm_rope_kvcache:
1165
+ logger.warning_once(
1166
+ "fuse_qk_norm_rope_kvcache is enabled, but "
1167
+ "splitting_ops is None and Inductor graph partition "
1168
+ "is not enabled. Disabling fuse_qk_norm_rope_kvcache. "
1169
+ "Please either set splitting_ops to an empty list [] "
1170
+ "or set use_inductor_graph_partition to True "
1171
+ "to enable QK-Norm+RoPE+KV cache fusion."
1172
+ )
1173
+ self.pass_config.fuse_qk_norm_rope_kvcache = False
1174
self.splitting_ops.append("vllm::unified_kv_cache_update")
1175
self.splitting_ops.append("vllm::unified_mla_kv_cache_update")
1176
vllm/config/vllm.py
+32
@@ -193,6 +193,15 @@ def enable_mla_dual_rms_norm_fusion(cfg: "VllmConfig") -> bool:
193
return rocm_aiter_ops.is_enabled() and check_aiter_fused_qk_rmsnorm()
194
195
196
+def enable_qk_norm_rope_kvcache(cfg: "VllmConfig") -> bool:
197
+ """Enable fused QK-norm + RoPE + KV cache update on ROCm with AITER."""
198
+ from vllm._aiter_ops import rocm_aiter_ops
199
+
200
+ if not rocm_aiter_ops.is_enabled():
201
+ return False
202
+ return cfg.compilation_config.is_custom_op_enabled("rotary_embedding")
203
+
204
+
205
OPTIMIZATION_LEVEL_00 = {
206
"compilation_config": {
207
"pass_config": {
@@ -205,6 +214,8 @@ OPTIMIZATION_LEVEL_00 = {
214
"fuse_act_padding": False,
215
"fuse_mla_dual_rms_norm": False,
216
"fuse_rope_kvcache": False,
217
+ "fuse_qk_norm_rope_kvcache": False,
218
+ "enable_qk_norm_rope_fusion": False,
219
"fuse_rope_kvcache_cat_mla": False,
220
},
221
"cudagraph_mode": CUDAGraphMode.NONE,
@@ -226,6 +237,8 @@ OPTIMIZATION_LEVEL_01 = {
237
"fuse_act_padding": enable_norm_pad_fusion,
238
"fuse_mla_dual_rms_norm": enable_mla_dual_rms_norm_fusion,
239
"fuse_rope_kvcache": False,
240
+ "fuse_qk_norm_rope_kvcache": False,
241
+ "enable_qk_norm_rope_fusion": False,
242
"fuse_rope_kvcache_cat_mla": False,
243
},
244
"cudagraph_mode": CUDAGraphMode.PIECEWISE,
@@ -247,6 +260,8 @@ OPTIMIZATION_LEVEL_02 = {
260
"fuse_act_padding": enable_norm_pad_fusion,
261
"fuse_mla_dual_rms_norm": enable_mla_dual_rms_norm_fusion,
262
"fuse_rope_kvcache": enable_rope_kvcache_fusion,
263
+ "fuse_qk_norm_rope_kvcache": enable_qk_norm_rope_kvcache,
264
+ "enable_qk_norm_rope_fusion": False,
265
"fuse_rope_kvcache_cat_mla": enable_rope_kvcache_mla_fusion,
266
},
267
"cudagraph_mode": CUDAGraphMode.FULL_AND_PIECEWISE,
@@ -268,6 +283,8 @@ OPTIMIZATION_LEVEL_03 = {
283
"fuse_act_padding": enable_norm_pad_fusion,
284
"fuse_mla_dual_rms_norm": enable_mla_dual_rms_norm_fusion,
285
"fuse_rope_kvcache": enable_rope_kvcache_fusion,
286
+ "fuse_qk_norm_rope_kvcache": enable_qk_norm_rope_kvcache,
287
+ "enable_qk_norm_rope_fusion": False,
288
"fuse_rope_kvcache_cat_mla": enable_rope_kvcache_mla_fusion,
289
},
290
"cudagraph_mode": CUDAGraphMode.FULL_AND_PIECEWISE,
@@ -1971,6 +1988,21 @@ class VllmConfig:
1988
compile_range_end,
1989
)
1990
1991
+ if compilation_config.pass_config.fuse_qk_norm_rope_kvcache:
1992
+ max_token_num = (
1993
+ compilation_config.pass_config.rope_kvcache_fusion_max_token_num
1994
+ )
1995
+ if max_token_num is not None:
1996
+ if compile_range_end is not None and max_token_num < compile_range_end:
1997
+ computed_compile_ranges_endpoints.append(max_token_num)
1998
+ else:
1999
+ logger.debug(
2000
+ "Max num batched tokens below qk_norm+rope+kvcache "
2001
+ "fusion threshold, fusion enabled for "
2002
+ "num_tokens <= %d.",
2003
+ compile_range_end,
2004
+ )
2005
+
2006
if compilation_config.compile_ranges_endpoints is not None:
2007
for x in compilation_config.compile_ranges_endpoints:
2008
assert isinstance(x, int)
vllm/model_executor/layers/attention/attention.py
+5
-1
@@ -136,10 +136,12 @@ def set_default_quant_scales(layer: nn.Module, register_buffer: bool = False) ->
136
137
# We also keep q/k/v_scale on host (cpu) memory for attention
138
# backends that require the scales to be on host instead of on device.
139
- # e.g. Flashinfer
139
+ # e.g. Flashinfer & AITER
140
layer._q_scale_float = 1.0
141
layer._k_scale_float = 1.0
142
layer._v_scale_float = 1.0
143
+ layer._k_scale_cpu = torch.tensor(1.0, dtype=torch.float32)
144
+ layer._v_scale_cpu = torch.tensor(1.0, dtype=torch.float32)
145
layer._prob_scale_float = 1.0
146
147
# Initialize q/k/v range constants used by calc_kv_scales
@@ -586,6 +588,8 @@ class Attention(nn.Module, AttentionLayerBase):
588
self._q_scale_float = self._q_scale.item()
589
self._k_scale_float = self._k_scale.item()
590
self._v_scale_float = self._v_scale.item()
591
+ self._k_scale_cpu.fill_(self._k_scale_float)
592
+ self._v_scale_cpu.fill_(self._v_scale_float)
593
# We only calculate the scales once
594
self.calculate_kv_scales = False
595
vllm/model_executor/layers/attention/mla_attention.py
+2
@@ -1024,6 +1024,8 @@ class MLAAttention(nn.Module, AttentionLayerBase):
1024
self._q_scale_float = self._q_scale.item()
1025
self._k_scale_float = self._k_scale.item()
1026
self._v_scale_float = self._v_scale.item()
1027
+ self._k_scale_cpu.fill_(self._k_scale_float)
1028
+ self._v_scale_cpu.fill_(self._v_scale_float)
1029
self.calculate_kv_scales = False
1030
1031
def get_attn_backend(self) -> type[AttentionBackend]:
vllm/model_executor/layers/quantization/compressed_tensors/compressed_tensors.py
+4
@@ -1200,6 +1200,10 @@ class CompressedTensorsKVCacheMethod(BaseKVCacheMethod):
1200
layer._v_scale_float = _to_scalar(layer.v_scale)
1201
layer._q_scale_float = _to_scalar(layer.q_scale)
1202
1203
+ # Sync host (cpu) scale copies read by AITER fused kernels.
1204
+ layer._k_scale_cpu.fill_(layer._k_scale_float)
1205
+ layer._v_scale_cpu.fill_(layer._v_scale_float)
1206
+
1207
# Discard all placeholders.
1208
del layer.k_scale
1209
del layer.v_scale
vllm/model_executor/layers/quantization/kv_cache.py
+3
@@ -144,6 +144,9 @@ class BaseKVCacheMethod(QuantizeMethodBase):
144
layer._v_scale.copy_(v_scale)
145
layer._k_scale_float = k_scale
146
layer._v_scale_float = v_scale
147
+ # Sync host (cpu) scale copies read by AITER fused kernels.
148
+ layer._k_scale_cpu.fill_(k_scale)
149
+ layer._v_scale_cpu.fill_(v_scale)
150
if k_scale == 1.0 and v_scale == 1.0 and "e5m2" not in layer.kv_cache_dtype:
151
logger.warning_once(
152
"Using KV cache scaling factor 1.0 for fp8_e4m3. "
vllm/v1/attention/backend.py
+33
@@ -757,7 +757,9 @@ class AttentionMetadataBuilder(ABC, Generic[M]):
757
class AttentionLayer(Protocol):
758
_q_scale: torch.Tensor
759
_k_scale: torch.Tensor
760
+ _k_scale_cpu: torch.Tensor
761
_v_scale: torch.Tensor
762
+ _v_scale_cpu: torch.Tensor
763
_q_scale_float: float
764
_k_scale_float: float
765
_v_scale_float: float
@@ -923,6 +925,14 @@ class AttentionImpl(AttentionImplBase[T], Generic[T]):
925
"""
926
return False
927
928
+ def fused_qk_norm_rope_kvcache_supported(self):
929
+ """
930
+ Does this attention implementation support fused QKNorm+RoPE+KVCache fusion.
931
+ This is used by the QkNormRopeKvCachePattern to only fuse the QKNorm ops
932
+ with the RoPE ops and the KV cache update for implementations that support it.
933
+ """
934
+ return False
935
+
936
def fused_rope_kvcache_supported(self):
937
"""
938
Does this attention implementation support RoPE+KVCache fusion.
@@ -931,6 +941,29 @@ class AttentionImpl(AttentionImplBase[T], Generic[T]):
941
"""
942
return False
943
944
+ def do_qk_norm_rope_kvcache_update(
945
+ self,
946
+ layer: AttentionLayer,
947
+ qkv: torch.Tensor,
948
+ q_out: torch.Tensor,
949
+ k_out: torch.Tensor,
950
+ positions: torch.Tensor,
951
+ q_weight: torch.Tensor,
952
+ k_weight: torch.Tensor,
953
+ rms_norm_eps: float,
954
+ cos_sin_cache: torch.Tensor,
955
+ is_neox: bool,
956
+ kv_cache: torch.Tensor,
957
+ layer_slot_mapping: torch.Tensor,
958
+ ):
959
+ """
960
+ If `fused_qk_norm_rope_kvcache_supported` returns True, this method
961
+ will be called by the fused custom op. Applies QK-norm + RoPE and
962
+ writes K/V to the KV cache. Results are written to the pre-allocated
963
+ q_out and k_out tensors; V is split from QKV at the graph level.
964
+ """
965
+ raise NotImplementedError
966
+
967
def do_rope_and_kv_cache_update(
968
self,
969
layer: AttentionLayer,
vllm/v1/attention/backends/rocm_aiter_fa.py
+41
@@ -1449,6 +1449,47 @@ class AiterFlashAttentionImpl(AttentionImpl):
1449
and not rocm_aiter_ops.is_shuffle_kv_cache_enabled()
1450
)
1451
1452
+ def fused_qk_norm_rope_kvcache_supported(self):
1453
+ return rocm_aiter_ops.is_enabled()
1454
+
1455
+ def do_qk_norm_rope_kvcache_update(
1456
+ self,
1457
+ layer: AttentionLayer,
1458
+ qkv: torch.Tensor,
1459
+ q_out: torch.Tensor,
1460
+ k_out: torch.Tensor,
1461
+ positions: torch.Tensor,
1462
+ q_weight: torch.Tensor,
1463
+ k_weight: torch.Tensor,
1464
+ rms_norm_eps: float,
1465
+ cos_sin_cache: torch.Tensor,
1466
+ is_neox: bool,
1467
+ kv_cache: torch.Tensor,
1468
+ layer_slot_mapping: torch.Tensor,
1469
+ ):
1470
+ key_cache, value_cache = kv_cache.unbind(1)
1471
+ rocm_aiter_ops.do_qk_norm_rope_kvcache_update(
1472
+ qkv=qkv,
1473
+ q_weight=q_weight,
1474
+ k_weight=k_weight,
1475
+ cos_sin_cache=cos_sin_cache,
1476
+ positions=positions,
1477
+ num_heads_q=self.num_heads,
1478
+ num_heads_k=self.num_kv_heads,
1479
+ head_dim=self.head_size,
1480
+ is_neox=is_neox,
1481
+ rms_norm_eps=rms_norm_eps,
1482
+ q_out=q_out,
1483
+ k_out=k_out,
1484
+ key_cache=key_cache,
1485
+ value_cache=value_cache,
1486
+ slot_mapping=layer_slot_mapping,
1487
+ k_scale=layer._k_scale_cpu,
1488
+ v_scale=layer._v_scale_cpu,
1489
+ kv_cache_dtype=self.kv_cache_dtype,
1490
+ use_shuffle_layout=rocm_aiter_ops.is_shuffle_kv_cache_enabled(),
1491
+ )
1492
+
1493
def do_rope_and_kv_cache_update(
1494
self,
1495
layer: AttentionLayer,
vllm/v1/attention/backends/rocm_aiter_unified_attn.py
+44
@@ -311,6 +311,50 @@ class RocmAiterUnifiedAttentionImpl(RocmAttentionImpl):
311
def fused_rope_kvcache_supported(self):
312
return rocm_aiter_ops.is_enabled()
313
314
+ def fused_qk_norm_rope_kvcache_supported(self):
315
+ return rocm_aiter_ops.is_enabled()
316
+
317
+ def do_qk_norm_rope_kvcache_update(
318
+ self,
319
+ layer: AttentionLayer,
320
+ qkv: torch.Tensor,
321
+ q_out: torch.Tensor,
322
+ k_out: torch.Tensor,
323
+ positions: torch.Tensor,
324
+ q_weight: torch.Tensor,
325
+ k_weight: torch.Tensor,
326
+ rms_norm_eps: float,
327
+ cos_sin_cache: torch.Tensor,
328
+ is_neox: bool,
329
+ kv_cache: torch.Tensor,
330
+ layer_slot_mapping: torch.Tensor,
331
+ ):
332
+ # _split_kv_cache picks the unbind dim per layout (incl. the K/V-first
333
+ # encoder-decoder path). unified reads NHD, so never write the shuffle
334
+ # layout here.
335
+ key_cache, value_cache = self._split_kv_cache(kv_cache)
336
+ rocm_aiter_ops.do_qk_norm_rope_kvcache_update(
337
+ qkv=qkv,
338
+ q_weight=q_weight,
339
+ k_weight=k_weight,
340
+ cos_sin_cache=cos_sin_cache,
341
+ positions=positions,
342
+ num_heads_q=self.num_heads,
343
+ num_heads_k=self.num_kv_heads,
344
+ head_dim=self.head_size,
345
+ is_neox=is_neox,
346
+ rms_norm_eps=rms_norm_eps,
347
+ q_out=q_out,
348
+ k_out=k_out,
349
+ key_cache=key_cache,
350
+ value_cache=value_cache,
351
+ slot_mapping=layer_slot_mapping,
352
+ k_scale=layer._k_scale_cpu,
353
+ v_scale=layer._v_scale_cpu,
354
+ kv_cache_dtype=self.kv_cache_dtype,
355
+ use_shuffle_layout=False,
356
+ )
357
+
358
def do_rope_and_kv_cache_update(
359
self,
360
layer: AttentionLayer,