main
py 582 lines 21.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import copy
4
5 import pytest
6 import torch._dynamo
7
8 from tests.compile.backend import LazyInitPass, TestBackend
9 from tests.utils import TestFP8Layer, flat_product
10 from tests.v1.attention.utils import (
11 BatchSpec,
12 create_common_attn_metadata,
13 dense_kv_cache_views,
14 )
15 from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops
16 from vllm._custom_ops import cutlass_scaled_fp4_mm, scaled_fp4_quant
17 from vllm.compilation.passes.fusion.attn_quant_fusion import (
18 ATTN_OP,
19 AttnQuantFusionPass,
20 )
21 from vllm.compilation.passes.fusion.matcher_utils import QUANT_OPS
22 from vllm.compilation.passes.fx_utils import find_op_nodes
23 from vllm.compilation.passes.utility.noop_elimination import NoOpEliminationPass
24 from vllm.compilation.passes.utility.post_cleanup import PostCleanupPass
25 from vllm.config import (
26 AttentionConfig,
27 CacheConfig,
28 CompilationConfig,
29 CompilationMode,
30 ModelConfig,
31 PassConfig,
32 SchedulerConfig,
33 VllmConfig,
34 set_current_vllm_config,
35 )
36 from vllm.config.cache import CacheDType
37 from vllm.forward_context import get_forward_context, set_forward_context
38 from vllm.model_executor.layers.attention import Attention
39 from vllm.model_executor.layers.quantization.utils.quant_utils import (
40 QuantKey,
41 kFp8StaticTensorSym,
42 kNvfp4Dynamic,
43 )
44 from vllm.platforms import current_platform
45 from vllm.utils.flashinfer import has_flashinfer
46 from vllm.v1.attention.backend import AttentionMetadata
47 from vllm.v1.attention.backends.registry import AttentionBackendEnum
48 from vllm.v1.attention.backends.utils import (
49 get_supported_kv_cache_layouts,
50 resolve_kv_cache_layout,
51 )
52 from vllm.v1.kv_cache_interface import (
53 AttentionSpec,
54 get_kv_quant_mode,
55 )
56
57 DEVICE_TYPE = current_platform.device_type
58 FP8_DTYPE = current_platform.fp8_dtype()
59 FP4_DTYPE = torch.uint8
60
61
62 class AttentionQuantPatternModel(torch.nn.Module):
63 """Base model for AttentionQuantPattern fusion."""
64
65 def __init__(
66 self,
67 num_qo_heads: int,
68 num_kv_heads: int,
69 head_size: int,
70 device: torch.device,
71 vllm_config: VllmConfig,
72 block_size: int,
73 **kwargs,
74 ):
75 super().__init__()
76 self.num_qo_heads = num_qo_heads
77 self.num_kv_heads = num_kv_heads
78 self.head_size = head_size
79 self.device = device
80 self.vllm_config = vllm_config
81 self.dtype = vllm_config.model_config.dtype
82
83 self.attn = Attention(
84 num_heads=self.num_qo_heads,
85 head_size=self.head_size,
86 scale=1.0 / (self.head_size**0.5),
87 num_kv_heads=self.num_kv_heads,
88 cache_config=vllm_config.cache_config,
89 prefix="model.layers.0.self_attn.attn",
90 )
91 self.attn._k_scale = self.attn._k_scale.to(device)
92 self.attn._v_scale = self.attn._v_scale.to(device)
93
94 self.block_size = block_size
95
96 # Initialize attn MetadataBuilder (match Attention.get_kv_cache_spec)
97 self.builder = self.attn.attn_backend.get_builder_cls()(
98 kv_cache_spec=AttentionSpec(
99 block_size=self.block_size,
100 num_kv_heads=self.num_kv_heads,
101 head_size=self.head_size,
102 dtype=self.attn.kv_cache_torch_dtype,
103 kv_quant_mode=get_kv_quant_mode(self.attn.kv_cache_dtype),
104 ),
105 layer_names=[self.attn.layer_name],
106 vllm_config=self.vllm_config,
107 device=self.device,
108 )
109
110 def build_attn_metadata(self, batch_size: int) -> AttentionMetadata:
111 """Initialize attention metadata."""
112 # TODO (Rohan138) reuse utils from vllm/v1/worker/gpu/attn_utils.py
113
114 # Create common attn metadata
115 batch_spec = BatchSpec(seq_lens=[1] * batch_size, query_lens=[1] * batch_size)
116 common_attn_metadata = create_common_attn_metadata(
117 batch_spec, self.block_size, self.device, arange_block_indices=True
118 )
119
120 max_blocks = (max(batch_spec.seq_lens) + self.block_size - 1) // self.block_size
121 num_blocks = batch_size * max_blocks
122
123 spec = self.attn.attn_backend.customize_spec(
124 AttentionSpec(
125 block_size=self.block_size,
126 num_kv_heads=self.num_kv_heads,
127 head_size=self.head_size,
128 dtype=self.attn.kv_cache_torch_dtype,
129 kv_quant_mode=get_kv_quant_mode(self.attn.kv_cache_dtype),
130 )
131 )
132 supported = get_supported_kv_cache_layouts([self.attn.attn_backend])
133 layout = resolve_kv_cache_layout(
134 self.vllm_config, [[m.name for m in supported]]
135 )
136 raw_tensor = torch.zeros(
137 num_blocks * spec.page_size_bytes,
138 dtype=torch.int8,
139 device=self.device,
140 )
141 kv_cache = dense_kv_cache_views(
142 raw_tensor,
143 spec,
144 num_blocks,
145 num_layers=1,
146 layout=layout,
147 )[0]
148
149 self.attn.kv_cache = kv_cache
150
151 # Build attn metadata
152 self.attn_metadata = self.builder.build(
153 common_prefix_len=0, common_attn_metadata=common_attn_metadata
154 )
155
156 return self.attn_metadata
157
158
159 class TestAttentionFp8StaticQuantPatternModel(AttentionQuantPatternModel):
160 """Test model for AttentionFp8StaticQuantPattern fusion."""
161
162 quant_key = kFp8StaticTensorSym
163
164 def __init__(self, *args, **kwargs):
165 super().__init__(*args, **kwargs)
166
167 hidden_size = self.num_qo_heads * self.head_size
168 self.fp8_linear = TestFP8Layer(
169 weight_shape=(hidden_size, hidden_size),
170 activation_quant_key=self.quant_key,
171 weight_quant_key=self.quant_key,
172 device=self.device,
173 input_dtype=self.dtype,
174 )
175
176 w = kwargs.get("w")
177 if w is not None:
178 self.fp8_linear.weight = w["weight"]
179 self.fp8_linear.weight_scale = w["wscale"]
180 self.fp8_linear.input_scale = w["scale"]
181
182 self.w = {
183 "weight": self.fp8_linear.weight,
184 "wscale": self.fp8_linear.weight_scale,
185 "scale": self.fp8_linear.input_scale,
186 }
187
188 def forward(self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor):
189 """Forward pass that creates the pattern to be fused."""
190 attn_output = self.attn(q, k, v)
191 return self.fp8_linear(attn_output)
192
193
194 class TestAttentionAiterFp8StaticQuantPatternModel(
195 TestAttentionFp8StaticQuantPatternModel
196 ):
197 aiter_quant = True
198
199 def __init__(self, *args, **kwargs):
200 super().__init__(*args, **kwargs)
201 self.fp8_linear.kernel.quant_fp8.use_aiter = True
202 assert self.fp8_linear.input_scale is not None
203 self.fp8_linear.input_scale.fill_(0.125)
204
205
206 class TestAttentionNvfp4QuantPatternModel(AttentionQuantPatternModel):
207 """Test model for AttentionNvfp4QuantPattern fusion."""
208
209 quant_key = kNvfp4Dynamic
210
211 def __init__(self, *args, **kwargs):
212 super().__init__(*args, **kwargs)
213
214 hidden_size = self.num_qo_heads * self.head_size
215 self.w = kwargs.get(
216 "w",
217 {
218 "weight": torch.randint(
219 256,
220 (hidden_size, hidden_size // 2),
221 dtype=FP4_DTYPE,
222 device=self.device,
223 ),
224 "wscale_swizzled": torch.randn(hidden_size, hidden_size // 16).to(
225 dtype=FP8_DTYPE, device=self.device
226 ),
227 "wscale": torch.tensor([500], dtype=torch.float32, device=self.device),
228 "scale": torch.tensor([0.002], dtype=torch.float32, device=self.device),
229 },
230 )
231
232 def forward(self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor):
233 """Forward pass that creates the pattern to be fused."""
234 attn_output = self.attn(q, k, v)
235 quant_output, output_block_scale = scaled_fp4_quant(
236 attn_output, 1 / self.w["scale"]
237 )
238 return cutlass_scaled_fp4_mm(
239 a=quant_output,
240 b=self.w["weight"],
241 block_scale_a=output_block_scale,
242 block_scale_b=self.w["wscale_swizzled"],
243 alpha=self.w["scale"] * self.w["wscale"],
244 out_dtype=attn_output.dtype,
245 )
246
247
248 PATTERN_TEST_MODELS_FP8: list[tuple[str, type]] = []
249 PATTERN_TEST_MODELS_FP4: list[tuple[str, type]] = []
250 HEADS: list[tuple[int, int]] = []
251 BACKENDS_FP8: list[AttentionBackendEnum] = []
252 BACKENDS_FP4: list[AttentionBackendEnum] = []
253
254 if current_platform.is_cuda():
255 HEADS = [(64, 8), (40, 8)]
256 PATTERN_TEST_MODELS_FP8 = [
257 (
258 "RedHatAI/Meta-Llama-3.1-8B-FP8",
259 TestAttentionFp8StaticQuantPatternModel,
260 )
261 ]
262 PATTERN_TEST_MODELS_FP4 = [
263 (
264 "nvidia/Llama-3.1-8B-Instruct-NVFP4",
265 TestAttentionNvfp4QuantPatternModel,
266 )
267 ]
268 BACKENDS_FP8 = [AttentionBackendEnum.TRITON_ATTN, AttentionBackendEnum.FLASHINFER]
269 BACKENDS_FP4 = [AttentionBackendEnum.FLASHINFER]
270
271 elif current_platform.is_rocm():
272 HEADS = [(32, 8), (40, 8)]
273 PATTERN_TEST_MODELS_FP8 = [
274 ("amd/Llama-3.1-8B-Instruct-FP8-KV", TestAttentionFp8StaticQuantPatternModel)
275 ]
276 BACKENDS_FP8 = [
277 AttentionBackendEnum.ROCM_AITER_UNIFIED_ATTN,
278 AttentionBackendEnum.ROCM_ATTN,
279 AttentionBackendEnum.TRITON_ATTN,
280 ]
281
282
283 @pytest.mark.parametrize("num_qo_heads, num_kv_heads", HEADS)
284 @pytest.mark.parametrize("head_size", [128])
285 @pytest.mark.parametrize(
286 "batch_size", [7, 256, 533] if current_platform.is_cuda() else [8]
287 )
288 @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
289 @pytest.mark.parametrize("kv_cache_dtype", ["fp8", "nvfp4"])
290 @pytest.mark.parametrize(
291 "backend, model_name, model_class, custom_ops",
292 # Test attention+quant_fp8 fusion with custom and torch impls of QuantFP8
293 list(
294 flat_product(
295 BACKENDS_FP8, PATTERN_TEST_MODELS_FP8, ["+quant_fp8", "-quant_fp8"]
296 )
297 )
298 # quant_fp4 only has the custom impl
299 + list(flat_product(BACKENDS_FP4, PATTERN_TEST_MODELS_FP4, [""])),
300 )
301 @pytest.mark.skipif(
302 not current_platform.is_cuda_alike(), reason="Only test ROCm or CUDA"
303 )
304 @pytest.mark.skipif(not current_platform.supports_fp8(), reason="Need FP8")
305 def test_attention_quant_pattern(
306 num_qo_heads: int,
307 num_kv_heads: int,
308 head_size: int,
309 batch_size: int,
310 dtype: torch.dtype,
311 kv_cache_dtype: CacheDType,
312 custom_ops: str,
313 model_name: str,
314 model_class: type[AttentionQuantPatternModel],
315 backend: AttentionBackendEnum,
316 dist_init,
317 disable_vllm_compile_cache,
318 ):
319 """Test AttentionStaticQuantPattern fusion pass."""
320 if backend == AttentionBackendEnum.FLASHINFER and (
321 not current_platform.is_device_capability((10, 0)) or not has_flashinfer()
322 ):
323 # This also captures the FP4 case
324 pytest.skip("FlashInfer attn fusion requires Blackwell and flashinfer")
325 if kv_cache_dtype == "nvfp4" and backend != AttentionBackendEnum.FLASHINFER:
326 pytest.skip("NVFP4 KV cache is only supported by FlashInfer")
327
328 # trtllm-gen has no NVFP4-KV kernel with NVFP4 output, so with an NVFP4 KV
329 # cache the FP4 output fusion must be rejected: the standalone
330 # scaled_fp4_quant stays in the graph and the result is unchanged. FP8 output
331 # fusion with NVFP4 KV is supported.
332 expect_fusion = not (
333 kv_cache_dtype == "nvfp4" and model_class.quant_key is kNvfp4Dynamic
334 )
335
336 custom_ops_list = custom_ops.split(",") if custom_ops else []
337
338 device = torch.device(f"{DEVICE_TYPE}:0")
339 torch.set_default_dtype(dtype)
340 torch.manual_seed(42)
341
342 backend_cls = backend.get_class()
343 block_size = backend_cls.get_preferred_block_size(16)
344
345 model_config = ModelConfig(
346 model=model_name,
347 max_model_len=2048,
348 dtype=dtype,
349 )
350 vllm_config = VllmConfig(
351 model_config=model_config,
352 scheduler_config=SchedulerConfig(
353 max_num_seqs=1024,
354 max_model_len=model_config.max_model_len,
355 is_encoder_decoder=model_config.is_encoder_decoder,
356 ),
357 compilation_config=CompilationConfig(
358 mode=CompilationMode.VLLM_COMPILE,
359 custom_ops=custom_ops_list,
360 ),
361 cache_config=CacheConfig(cache_dtype=kv_cache_dtype),
362 attention_config=AttentionConfig(backend=backend),
363 )
364
365 # Create test inputs
366 q = torch.randn(batch_size, num_qo_heads * head_size, dtype=dtype, device=device)
367 k = torch.randn(batch_size, num_kv_heads * head_size, dtype=dtype, device=device)
368 v = torch.randn(batch_size, num_kv_heads * head_size, dtype=dtype, device=device)
369
370 # Mark first dimension as dynamic for realistic testing
371 torch._dynamo.mark_dynamic(q, 0)
372 torch._dynamo.mark_dynamic(k, 0)
373 torch._dynamo.mark_dynamic(v, 0)
374
375 # Run model directly without compilation and fusion
376 vllm_config_unfused = copy.deepcopy(vllm_config)
377 with (
378 set_current_vllm_config(vllm_config_unfused),
379 set_forward_context(attn_metadata=None, vllm_config=vllm_config_unfused),
380 ):
381 model_unfused = model_class(
382 num_qo_heads=num_qo_heads,
383 num_kv_heads=num_kv_heads,
384 head_size=head_size,
385 device=device,
386 vllm_config=vllm_config_unfused,
387 block_size=block_size,
388 )
389 model_unfused = model_unfused.to(device)
390 result_unfused_0 = model_unfused(q, k, v) # noqa: F841 HACK: See #131044
391
392 forward_ctx = get_forward_context()
393 forward_ctx.attn_metadata = model_unfused.build_attn_metadata(batch_size)
394
395 # Run model directly without fusion
396 # Still compile so query QuantFP8 has closer numerics
397 compiled_unfused = torch.compile(model_unfused, fullgraph=True)
398 result_unfused = compiled_unfused(q, k, v)
399
400 # Run model with attn fusion enabled
401 vllm_config.compilation_config.pass_config = PassConfig(
402 fuse_attn_quant=True, eliminate_noops=True
403 )
404 with (
405 set_current_vllm_config(vllm_config),
406 set_forward_context(attn_metadata=None, vllm_config=vllm_config),
407 ):
408 model_fused = model_class(
409 num_qo_heads=num_qo_heads,
410 num_kv_heads=num_kv_heads,
411 head_size=head_size,
412 device=device,
413 vllm_config=vllm_config,
414 w=model_unfused.w,
415 block_size=block_size,
416 )
417 model_fused = model_fused.to(device)
418
419 forward_ctx = get_forward_context()
420 forward_ctx.attn_metadata = model_fused.build_attn_metadata(batch_size)
421
422 # Create test backend with fusion passes enabled
423 noop_pass = NoOpEliminationPass(vllm_config)
424 attn_pass = LazyInitPass(AttnQuantFusionPass, vllm_config)
425 cleanup_pass = PostCleanupPass(vllm_config)
426
427 test_backend = TestBackend(noop_pass, attn_pass, cleanup_pass)
428 # HACK: See https://github.com/vllm-project/vllm/issues/31044
429 result_fused_0 = model_fused(q, k, v) # noqa: F841
430
431 # Compile model with fusion enabled
432 compiled_fused = torch.compile(
433 model_fused, backend=test_backend, fullgraph=True
434 )
435 assert compiled_fused.attn._o_scale_float is None
436
437 result_fused = compiled_fused(q, k, v)
438
439 if backend == AttentionBackendEnum.FLASHINFER and expect_fusion:
440 # With the Flashinfer backend after the 1st round of the forward
441 # pass, output quant scale should be loaded into the attn layer's
442 # _o_scale_float, the 2nd round should reuse the loaded
443 # _o_scale_float
444 assert compiled_fused.attn._o_scale_float is not None
445 result_fused_2 = compiled_fused(q, k, v)
446
447 assert compiled_fused.attn._o_scale_float is not None
448
449 torch.testing.assert_close(
450 result_unfused, result_fused_2, atol=1e-2, rtol=1e-2
451 )
452 elif not expect_fusion:
453 assert compiled_fused.attn._o_scale_float is None
454
455 # Check attn fusion support
456 quant_key: QuantKey = model_class.quant_key
457 attn_fusion_supported = [
458 layer.impl.fused_output_quant_supported(quant_key)
459 for key, layer in vllm_config.compilation_config.static_forward_context.items()
460 ]
461 if expect_fusion:
462 assert sum(attn_fusion_supported) == len(attn_fusion_supported), (
463 "All layers should support attention fusion"
464 )
465 else:
466 assert sum(attn_fusion_supported) == 0, (
467 "No layer should support attention fusion"
468 )
469
470 # Check quantization ops in the graph before and after fusion
471 if "-quant_fp8" in custom_ops_list:
472 quant_op = torch.ops.aten.reciprocal
473 elif getattr(model_class, "aiter_quant", False):
474 quant_op = torch.ops.vllm.rocm_aiter_per_tensor_quant.default
475 else:
476 quant_op = QUANT_OPS[quant_key]
477
478 if expect_fusion:
479 # Note: for fp8, fully_replaced=False because query quant ops remain in
480 # graph. Only output quant ops are fused into attention.
481 test_backend.check_before_ops(
482 [quant_op], fully_replaced=quant_key is kNvfp4Dynamic
483 )
484 else:
485 # Fusion rejected: the standalone output quant must survive untouched.
486 num_pre = test_backend.op_count(quant_op, before=True)
487 assert num_pre > 0, f"Op {quant_op} not found in pre-pass graph"
488 assert test_backend.op_count(quant_op) == num_pre, (
489 "Output quant op should remain when fusion is rejected"
490 )
491
492 # access the underlying `AttnQuantFusionPass` on the `LazyInitPass`
493 assert attn_pass.pass_.matched_count == sum(attn_fusion_supported)
494
495 # Check attention ops in the graph before and after fusion
496 attn_nodes_pre = list(find_op_nodes(ATTN_OP, test_backend.graph_pre_pass))
497 attn_nodes_post = list(find_op_nodes(ATTN_OP, test_backend.graph_post_pass))
498
499 assert len(attn_nodes_pre) > 0, "Should have attention nodes before fusion"
500 assert len(attn_nodes_pre) == len(attn_nodes_post), (
501 "Should have same number of attention nodes before and after fusion"
502 )
503 assert attn_nodes_pre[0].kwargs.get("output_scale") is None, (
504 "Attention should not have output_scale before fusion"
505 )
506 if expect_fusion:
507 assert attn_nodes_post[0].kwargs.get("output_scale") is not None, (
508 "Attention should have output_scale after fusion"
509 )
510 else:
511 assert attn_nodes_post[0].kwargs.get("output_scale") is None, (
512 "Attention should not have output_scale when fusion is rejected"
513 )
514
515 assert attn_nodes_pre[0].kwargs.get("output_block_scale") is None, (
516 "Attention should not have output_block_scale before fusion"
517 )
518
519 kv_cache_dummy_dep_pre_is_none = (
520 attn_nodes_pre[0].kwargs.get("kv_cache_dummy_dep") is None
521 )
522 kv_cache_dummy_dep_post_is_none = (
523 attn_nodes_post[0].kwargs.get("kv_cache_dummy_dep") is None
524 )
525 assert not (kv_cache_dummy_dep_pre_is_none ^ kv_cache_dummy_dep_post_is_none), (
526 "The kv_cache_dummy_dep should be consistent before and after fusion"
527 )
528
529 if quant_key.dtype == FP8_DTYPE or not expect_fusion:
530 assert attn_nodes_post[0].kwargs.get("output_block_scale") is None, (
531 "Attention should not have output_block_scale unless FP4 fusion happened"
532 )
533 elif quant_key.dtype == FP4_DTYPE:
534 assert attn_nodes_post[0].kwargs.get("output_block_scale") is not None, (
535 "Attention should have output_block_scale after FP4 fusion"
536 )
537
538 # Check that results are close
539 torch.testing.assert_close(result_unfused, result_fused, atol=1e-2, rtol=1e-2)
540 if getattr(model_class, "aiter_quant", False):
541 # Query quantization remains; exactly the output quant op is removed.
542 assert test_backend.op_count(quant_op, before=True) == (
543 test_backend.op_count(quant_op) + 1
544 )
545 for model in (model_unfused, model_fused):
546 torch.testing.assert_close(
547 model.fp8_linear.input_scale,
548 torch.full_like(model.fp8_linear.input_scale, 0.125),
549 atol=0,
550 rtol=0,
551 )
552
553
554 @pytest.mark.skipif(
555 not current_platform.is_rocm() or not is_aiter_found_and_supported(),
556 reason="Requires ROCm AITER attention and quantization",
557 )
558 @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
559 def test_rocm_aiter_static_quant_attention(
560 dtype, dist_init, disable_vllm_compile_cache, monkeypatch
561 ):
562 """Fuse the actual AITER op without changing its static scale or outputs."""
563 try:
564 with monkeypatch.context() as patch:
565 patch.setenv("VLLM_ROCM_USE_AITER", "1")
566 rocm_aiter_ops.refresh_env_variables()
567 test_attention_quant_pattern(
568 num_qo_heads=32,
569 num_kv_heads=8,
570 head_size=128,
571 batch_size=8,
572 dtype=dtype,
573 kv_cache_dtype="fp8",
574 custom_ops="+quant_fp8",
575 model_name="amd/Llama-3.1-8B-Instruct-FP8-KV",
576 model_class=TestAttentionAiterFp8StaticQuantPatternModel,
577 backend=AttentionBackendEnum.ROCM_AITER_UNIFIED_ATTN,
578 dist_init=dist_init,
579 disable_vllm_compile_cache=disable_vllm_compile_cache,
580 )
581 finally:
582 rocm_aiter_ops.refresh_env_variables()