main
py 976 lines 34.7 KB
Raw
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, Mapping
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.model_executor.layers.quantization.utils.quant_utils import GroupShape
23 from vllm.platforms import current_platform
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
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__)
38
39 P = ParamSpec("P")
40
41 # Head sizes the fused kernel fused_qk_norm_rope_cache_pts_quant_shuffle() supports
42 # Other sizes hard-abort, so skip those layers.
43 SUPPORTED_FUSED_QK_NORM_ROPE_KVCACHE_HEAD_DIMS: tuple[int, ...] = (64, 128, 256)
44
45
46 # ---------------------------------------------------------------------------
47 # Custom op: fused QK-norm + RoPE + KV cache update
48 # ---------------------------------------------------------------------------
49
50
51 def fused_qk_norm_rope_and_unified_kv_cache_update_impl(
52 q_out: torch.Tensor,
53 k_out: torch.Tensor,
54 qkv: torch.Tensor,
55 positions: torch.Tensor,
56 q_weight: torch.Tensor,
57 k_weight: torch.Tensor,
58 rms_norm_eps: float,
59 cos_sin_cache: torch.Tensor,
60 is_neox: bool,
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(
67 attn_layer,
68 qkv,
69 q_out,
70 k_out,
71 positions,
72 q_weight,
73 k_weight,
74 rms_norm_eps,
75 cos_sin_cache,
76 is_neox,
77 kv_cache,
78 layer_slot_mapping,
79 )
80 else:
81 # Profiling/dummy run: define q_out/k_out (consumed by attention).
82 q_out.zero_()
83 k_out.zero_()
84
85 return torch.empty(0, device=qkv.device, dtype=qkv.dtype)
86
87
88 def fused_qk_norm_rope_and_unified_kv_cache_update_fake(
89 q_out: torch.Tensor,
90 k_out: torch.Tensor,
91 qkv: torch.Tensor,
92 positions: torch.Tensor,
93 q_weight: torch.Tensor,
94 k_weight: torch.Tensor,
95 rms_norm_eps: float,
96 cos_sin_cache: torch.Tensor,
97 is_neox: bool,
98 layer_name: LayerNameType,
99 ) -> torch.Tensor:
100 return torch.empty(0, device=qkv.device, dtype=qkv.dtype)
101
102
103 direct_register_custom_op(
104 op_name="fused_qk_norm_rope_and_unified_kv_cache_update",
105 op_func=fused_qk_norm_rope_and_unified_kv_cache_update_impl,
106 mutates_args=["q_out", "k_out"],
107 fake_impl=fused_qk_norm_rope_and_unified_kv_cache_update_fake,
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 # ---------------------------------------------------------------------------
184
185
186 class QkNormRopeKvCachePattern:
187 """Match the unfused sequence:
188 q, k, v = split(qkv, ...)
189 q = rms_norm(q.view(heads), q_weight).view(flat)
190 k = rms_norm(k.view(heads), k_weight).view(flat)
191 q, k = rotary_embedding(positions, q, k, cos_sin_cache, is_neox)
192 q = q.view(num_heads, head_dim)
193 k = k.view(num_kv_heads, head_dim)
194 v = v.view(num_kv_heads, head_dim)
195 dummy = unified_kv_cache_update(k, v, layer_name)
196
197 Replace with:
198 q_out = empty(...)
199 k_out = empty(...)
200 dummy = fused_qk_norm_rope_and_unified_kv_cache_update(
201 q_out, k_out, qkv, positions, q_weight, k_weight,
202 eps, cos_sin_cache, is_neox, layer_name)
203 v = split(qkv, ...)[2].view(num_kv_heads, head_dim)
204 """
205
206 FUSED_OP = torch.ops.vllm.fused_qk_norm_rope_and_unified_kv_cache_update.default
207
208 def __init__(
209 self,
210 layer: Attention,
211 eps: float,
212 is_neox: bool,
213 quant_query: bool,
214 ) -> None:
215 self.layer_name = layer.layer_name
216 self.num_heads = layer.num_heads
217 self.num_kv_heads = layer.num_kv_heads
218 self.head_size = layer.head_size
219 self.head_size_v = layer.head_size_v
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
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
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)
247 positions = empty_i64(T)
248 q_weight = empty_bf16(1, self.head_size)
249 k_weight = empty_bf16(1, self.head_size)
250 cos_sin_cache = empty_bf16(L, self.head_size)
251 inputs = [qkv, positions, q_weight, k_weight, cos_sin_cache]
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,
265 positions: torch.Tensor,
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)
273 q_normed = vllm.ir.ops.rms_norm(q_by_head, q_weight, self.eps)
274 q_flat = q_normed.view(-1, self.q_size)
275
276 k_by_head = k.view(-1, self.k_size // self.head_size, self.head_size)
277 k_normed = vllm.ir.ops.rms_norm(k_by_head, k_weight, self.eps)
278 k_flat = k_normed.view(-1, self.k_size)
279
280 q_rope, k_rope = self.rope_matcher(positions, q_flat, k_flat, cos_sin_cache)
281
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)
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(
291 self,
292 qkv: torch.Tensor,
293 positions: torch.Tensor,
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],
301 self.num_heads,
302 self.head_size,
303 device=qkv.device,
304 dtype=qkv.dtype,
305 )
306 k_out = torch.empty(
307 qkv.shape[0],
308 self.num_kv_heads,
309 self.head_size,
310 device=qkv.device,
311 dtype=qkv.dtype,
312 )
313 _, _, v = qkv.split([self.q_size, self.k_size, self.v_size], dim=-1)
314 v = v.view(qkv.shape[0], self.num_kv_heads, self.head_size_v)
315 results = auto_functionalized(
316 self.FUSED_OP,
317 q_out=q_out,
318 k_out=k_out,
319 qkv=qkv,
320 positions=positions,
321 q_weight=q_weight,
322 k_weight=k_weight,
323 rms_norm_eps=self.eps,
324 cos_sin_cache=cos_sin_cache,
325 is_neox=self.is_neox,
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,
372 positions: torch.Tensor,
373 q_weight: torch.Tensor,
374 k_weight: torch.Tensor,
375 cos_sin_cache: torch.Tensor,
376 q_scale: 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)
382 q_flat = q_normed.view(-1, self.q_size)
383
384 k_by_head = k.view(-1, self.k_size // self.head_size, self.head_size)
385 k_normed = vllm.ir.ops.rms_norm(k_by_head, k_weight, self.eps)
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)
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)
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(
401 self,
402 qkv: torch.Tensor,
403 positions: torch.Tensor,
404 q_weight: torch.Tensor,
405 k_weight: torch.Tensor,
406 cos_sin_cache: torch.Tensor,
407 q_scale: 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,
413 self.head_size,
414 device=qkv.device,
415 dtype=qkv.dtype,
416 )
417 k_out = torch.empty(
418 qkv.shape[0],
419 self.num_kv_heads,
420 self.head_size,
421 device=qkv.device,
422 dtype=qkv.dtype,
423 )
424 _, _, v = qkv.split([self.q_size, self.k_size, self.v_size], dim=-1)
425 v = v.view(qkv.shape[0], self.num_kv_heads, self.head_size_v)
426 results = auto_functionalized(
427 self.FUSED_OP,
428 q_out=q_out,
429 k_out=k_out,
430 qkv=qkv,
431 positions=positions,
432 q_weight=q_weight,
433 k_weight=k_weight,
434 rms_norm_eps=self.eps,
435 cos_sin_cache=cos_sin_cache,
436 is_neox=self.is_neox,
437 layer_name=self._get_layer_name(layer_name),
438 )
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)
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
448 def wrap_trace_fn(
449 trace_fn: Callable[P, fx.GraphModule],
450 *process_fx_fns: Callable[[fx.GraphModule], None],
451 ) -> Callable[P, fx.GraphModule]:
452 def wrapped(*args: P.args, **kwargs: P.kwargs) -> fx.GraphModule:
453 gm = trace_fn(*args, **kwargs)
454 for process_fx in process_fx_fns:
455 process_fx(gm)
456
457 return gm
458
459 return wrapped
460
461 @staticmethod
462 def fx_view_to_reshape(gm: torch.fx.GraphModule) -> None:
463 from torch._inductor.fx_passes.post_grad import view_to_reshape
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,
473 QkNormRopeKvCachePattern.fx_view_to_reshape,
474 )
475
476 # Pre-build the search pattern with `ignore_types=(int, torch.SymInt)`
477 # and pass it via `search_fn_pattern=` so torch skips both of its
478 # internal `fx_to_pattern` calls and treats dynamic-shape SymInts as
479 # wildcards.
480 inputs = self.get_inputs()
481 argnames = [*inspect.signature(pattern).parameters.keys()]
482 search_gm = trace_fn(pattern, inputs)
483 search_fn_pattern = pm.fx_to_pattern(
484 search_gm,
485 ignore_types=(int, torch.SymInt),
486 argnames=argnames,
487 )
488
489 pm.register_replacement(
490 pattern,
491 replacement,
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.
501 if self.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
547 ):
548 return self.pattern_fp8_quant_query(
549 qkv, positions, q_weight, k_weight, cos_sin_cache, q_scale
550 )
551
552 def replacement_q_without_layer(
553 qkv, positions, q_weight, k_weight, cos_sin_cache, q_scale
554 ):
555 return self.replacement_fp8_quant_query(
556 qkv, positions, q_weight, k_weight, cos_sin_cache, q_scale
557 )
558
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
604 def pattern_noq_without_layer(
605 qkv, positions, q_weight, k_weight, cos_sin_cache
606 ):
607 return self.pattern_non_fp8_quant_query(
608 qkv, positions, q_weight, k_weight, cos_sin_cache
609 )
610
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
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 # ---------------------------------------------------------------------------
830 # Pass class
831 # ---------------------------------------------------------------------------
832
833
834 class QkNormRopeKvCacheFusionPass(VllmPatternMatcherPass):
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
839 separate kernel launches and the intermediate memory traffic.
840 """
841
842 @enable_fake_mode
843 def __init__(self, config: VllmConfig) -> None:
844 super().__init__(config)
845
846 self.patterns: PatternMatcherPass = PatternMatcherPass(
847 pass_name="qk_norm_rope_kvcache_fusion_pass"
848 )
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(
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():
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(
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.",
882 layer.head_size,
883 SUPPORTED_FUSED_QK_NORM_ROPE_KVCACHE_HEAD_DIMS,
884 )
885 continue
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(
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
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
957 @VllmInductorPass.time_and_log
958 def __call__(self, graph: fx.Graph) -> None:
959 self.matched_count = self.patterns.apply(graph)
960 logger.info(
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
966 def is_applicable_for_range(self, compile_range: Range) -> bool:
967 return compile_range.end <= self.max_token_num
968
969 def uuid(self) -> str:
970 return VllmInductorPass.hash_source(
971 self,
972 QkNormRopeKvCachePattern,
973 QkNormMRopeKvCachePattern,
974 _discover_mrope_configs,
975 repr(self.mrope_configs),
976 )