main
py 590 lines 20.8 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 BatchSpec, create_common_attn_metadata
11 from vllm._custom_ops import cutlass_scaled_fp4_mm, scaled_fp4_quant
12 from vllm.compilation.passes.fusion.matcher_utils import QUANT_OPS
13 from vllm.compilation.passes.fusion.mla_attn_quant_fusion import (
14 MLA_ATTN_OP,
15 MLAAttnQuantFusionPass,
16 )
17 from vllm.compilation.passes.fx_utils import find_op_nodes
18 from vllm.compilation.passes.utility.noop_elimination import NoOpEliminationPass
19 from vllm.compilation.passes.utility.post_cleanup import PostCleanupPass
20 from vllm.config import (
21 AttentionConfig,
22 CacheConfig,
23 CompilationConfig,
24 CompilationMode,
25 ModelConfig,
26 PassConfig,
27 SchedulerConfig,
28 VllmConfig,
29 set_current_vllm_config,
30 )
31 from vllm.forward_context import get_forward_context, set_forward_context
32 from vllm.model_executor.kernels.linear.scaled_mm.cutlass import (
33 CutlassFp8BlockScaledMMKernel,
34 )
35 from vllm.model_executor.layers.attention import MLAAttention
36 from vllm.model_executor.layers.linear import ColumnParallelLinear
37 from vllm.model_executor.layers.quantization.fp8 import Fp8Config
38 from vllm.model_executor.layers.quantization.modelopt import ModelOptNvFp4Config
39 from vllm.model_executor.layers.quantization.utils.quant_utils import (
40 GroupShape,
41 QuantKey,
42 create_fp8_quant_key,
43 kFp8Dynamic128Sym,
44 kFp8StaticTensorSym,
45 kNvfp4Dynamic,
46 )
47 from vllm.platforms import current_platform
48 from vllm.v1.attention.backend import AttentionMetadata
49 from vllm.v1.attention.backends.registry import AttentionBackendEnum
50 from vllm.v1.kv_cache_interface import MLAAttentionSpec
51
52 FP8_DTYPE = current_platform.fp8_dtype()
53 FP4_DTYPE = torch.uint8
54 DEVICE_TYPE = current_platform.device_type
55
56
57 class MLAAttentionQuantPatternModel(torch.nn.Module):
58 """Base model for MLA AttentionQuantPattern fusion."""
59
60 def __init__(
61 self,
62 num_heads: int,
63 qk_nope_head_dim: int,
64 qk_rope_head_dim: int,
65 v_head_dim: int,
66 kv_lora_rank: int,
67 kv_cache_dtype: torch.dtype,
68 device: torch.device,
69 vllm_config: VllmConfig,
70 **kwargs,
71 ):
72 super().__init__()
73 self.num_heads = num_heads
74 self.qk_nope_head_dim = qk_nope_head_dim
75 self.qk_rope_head_dim = qk_rope_head_dim
76 self.qk_head_dim = qk_nope_head_dim + qk_rope_head_dim
77 self.v_head_dim = v_head_dim
78 self.kv_lora_rank = kv_lora_rank
79 self.output_dim = num_heads * v_head_dim
80 self.head_size = kv_lora_rank + qk_rope_head_dim
81 self.kv_cache_dtype = kv_cache_dtype
82 self.device = device
83 self.vllm_config = vllm_config
84 self.dtype = vllm_config.model_config.dtype
85
86 kv_b_proj = ColumnParallelLinear(
87 input_size=kv_lora_rank,
88 output_size=num_heads * (qk_nope_head_dim + v_head_dim),
89 bias=False,
90 prefix="model.layers.0.self_attn.kv_b_proj",
91 ).to(device)
92 kv_b_proj_weight = kwargs.get("kv_b_proj_weight")
93 if kv_b_proj_weight is not None:
94 kv_b_proj.weight.data.copy_(kv_b_proj_weight)
95 else:
96 kv_b_proj.weight.data.normal_()
97
98 # Create MLAAttention
99 self.mla_attn = MLAAttention(
100 num_heads=num_heads,
101 scale=1.0 / (self.qk_head_dim**0.5),
102 qk_nope_head_dim=qk_nope_head_dim,
103 qk_rope_head_dim=qk_rope_head_dim,
104 v_head_dim=v_head_dim,
105 q_lora_rank=None,
106 kv_lora_rank=kv_lora_rank,
107 kv_b_proj=kv_b_proj,
108 cache_config=vllm_config.cache_config,
109 quant_config=self.quant_config,
110 prefix="model.layers.0.self_attn.attn",
111 )
112 self.mla_attn._k_scale = self.mla_attn._k_scale.to(device)
113 self.mla_attn._v_scale = self.mla_attn._v_scale.to(device)
114
115 # Initialize W_UK_T and W_UV from kv_b_proj weights
116 self.mla_attn.process_weights_after_loading(torch.get_default_dtype())
117 self.kv_b_proj_weight = kv_b_proj.weight.data.clone()
118
119 self.block_size = 16
120
121 # Initialize MLA MetadataBuilder
122 self.builder = self.mla_attn.attn_backend.get_builder_cls()(
123 kv_cache_spec=MLAAttentionSpec(
124 block_size=self.block_size,
125 num_kv_heads=1,
126 head_size=self.head_size,
127 dtype=self.kv_cache_dtype,
128 ),
129 layer_names=[self.mla_attn.layer_name],
130 vllm_config=self.vllm_config,
131 device=self.device,
132 )
133
134 def build_attn_metadata(self, batch_size: int) -> AttentionMetadata:
135 """Initialize MLA attention metadata.
136
137 NOTE: Uses decode-only batch (query_len=1 per request). The prefill
138 (forward_mha) path is not separately tested here because it requires
139 FlashAttention availability and different input tensor shapes. The
140 quant logic in forward_impl is identical for both paths — it quantizes
141 the full output[:num_actual_toks] buffer after both forward_mha and
142 forward_mqa have written their results.
143 """
144 batch_spec = BatchSpec(seq_lens=[1] * batch_size, query_lens=[1] * batch_size)
145 common_attn_metadata = create_common_attn_metadata(
146 batch_spec, self.block_size, self.device, arange_block_indices=True
147 )
148
149 max_blocks = (max(batch_spec.seq_lens) + self.block_size - 1) // self.block_size
150 num_blocks = batch_size * max_blocks
151
152 # MLA KV cache is 4D: (num_blocks, num_heads=1, block_size, head_size)
153 kv_cache = torch.zeros(
154 (num_blocks, 1, self.block_size, self.head_size),
155 dtype=self.kv_cache_dtype,
156 device=self.device,
157 )
158
159 self.mla_attn.bind_kv_cache(kv_cache)
160
161 self.attn_metadata = self.builder.build(
162 common_prefix_len=0, common_attn_metadata=common_attn_metadata
163 )
164
165 return self.attn_metadata
166
167
168 class TestMLAAttentionFp8StaticQuantPatternModel(MLAAttentionQuantPatternModel):
169 """Test model for MLA Attention + FP8 static quant fusion."""
170
171 quant_key = kFp8StaticTensorSym
172 quant_config = Fp8Config()
173
174 def __init__(self, *args, **kwargs):
175 super().__init__(*args, **kwargs)
176
177 self.fp8_linear = TestFP8Layer(
178 weight_shape=(self.output_dim, self.output_dim),
179 activation_quant_key=self.quant_key,
180 weight_quant_key=self.quant_key,
181 device=self.device,
182 input_dtype=self.dtype,
183 )
184
185 w = kwargs.get("w")
186 if w is not None:
187 self.fp8_linear.weight = w["weight"]
188 self.fp8_linear.weight_scale = w["wscale"]
189 self.fp8_linear.input_scale = w["scale"]
190
191 self.w = {
192 "weight": self.fp8_linear.weight,
193 "wscale": self.fp8_linear.weight_scale,
194 "scale": self.fp8_linear.input_scale,
195 }
196
197 def forward(
198 self,
199 q: torch.Tensor,
200 kv_c_normed: torch.Tensor,
201 k_pe: torch.Tensor,
202 ):
203 """Forward pass that creates the MLA attention + FP8 quant pattern."""
204 attn_output = self.mla_attn(
205 q,
206 kv_c_normed,
207 k_pe,
208 output_shape=(q.shape[0], self.output_dim),
209 )
210 return self.fp8_linear(attn_output)
211
212
213 class TestMLAAttentionNvfp4QuantPatternModel(MLAAttentionQuantPatternModel):
214 """Test model for MLA Attention + NVFP4 quant fusion."""
215
216 quant_key = kNvfp4Dynamic
217 quant_config = ModelOptNvFp4Config(
218 is_checkpoint_nvfp4_serialized=False,
219 kv_cache_quant_algo=None,
220 exclude_modules=[],
221 )
222
223 def __init__(self, *args, **kwargs):
224 super().__init__(*args, **kwargs)
225
226 self.w = kwargs.get(
227 "w",
228 {
229 "weight": torch.randint(
230 256,
231 (self.output_dim, self.output_dim // 2),
232 dtype=FP4_DTYPE,
233 device=self.device,
234 ),
235 "wscale_swizzled": torch.randn(
236 self.output_dim, self.output_dim // 16
237 ).to(dtype=FP8_DTYPE, device=self.device),
238 "wscale": torch.tensor([500], dtype=torch.float32, device=self.device),
239 "scale": torch.tensor([0.002], dtype=torch.float32, device=self.device),
240 },
241 )
242
243 def forward(
244 self,
245 q: torch.Tensor,
246 kv_c_normed: torch.Tensor,
247 k_pe: torch.Tensor,
248 ):
249 """Forward pass that creates the MLA attention + NVFP4 quant pattern."""
250 attn_output = self.mla_attn(
251 q,
252 kv_c_normed,
253 k_pe,
254 output_shape=(q.shape[0], self.output_dim),
255 )
256 quant_output, output_block_scale = scaled_fp4_quant(
257 attn_output, 1 / self.w["scale"]
258 )
259 return cutlass_scaled_fp4_mm(
260 a=quant_output,
261 b=self.w["weight"],
262 block_scale_a=output_block_scale,
263 block_scale_b=self.w["wscale_swizzled"],
264 alpha=self.w["scale"] * self.w["wscale"],
265 out_dtype=attn_output.dtype,
266 )
267
268
269 class TestMLAAttentionFp8GroupQuantPatternModel(MLAAttentionQuantPatternModel):
270 """Test model for MLA Attention + per-group FP8 (block quant) fusion."""
271
272 quant_key = kFp8Dynamic128Sym
273 quant_config = Fp8Config(
274 weight_block_size=[128, 128],
275 )
276
277 def __init__(self, *args, **kwargs):
278 super().__init__(*args, **kwargs)
279
280 weight_quant_key = create_fp8_quant_key(
281 static=True, group_shape=GroupShape(128, 128)
282 )
283 device = kwargs.get("device", torch.device("cuda:0"))
284
285 # Subclass to set weight_block_size before process_weights_after_loading
286 class _BlockFP8Layer(TestFP8Layer):
287 def __init__(self, *a, **kw):
288 self.weight_block_size = [128, 128]
289 super().__init__(*a, **kw)
290
291 # Force CutlassFp8BlockScaledMMKernel to ensure the graph uses
292 # per_token_group_fp8_quant (not the deepgemm packed variant).
293 self.block_fp8_linear = _BlockFP8Layer(
294 weight_shape=(self.output_dim, self.output_dim),
295 activation_quant_key=self.quant_key,
296 weight_quant_key=weight_quant_key,
297 input_dtype=self.dtype,
298 device=device,
299 force_kernel=CutlassFp8BlockScaledMMKernel,
300 )
301
302 w = kwargs.get("w")
303 if w is not None:
304 self.block_fp8_linear.weight = w["weight"]
305 # Block-wise uses weight_scale_inv, not weight_scale
306 self.block_fp8_linear.weight_scale_inv = w["wscale"]
307
308 self.w = {
309 "weight": self.block_fp8_linear.weight,
310 "wscale": self.block_fp8_linear.weight_scale_inv,
311 }
312
313 def forward(
314 self,
315 q: torch.Tensor,
316 kv_c_normed: torch.Tensor,
317 k_pe: torch.Tensor,
318 ):
319 """Forward pass: MLA attention -> block FP8 linear (group quant)."""
320 attn_output = self.mla_attn(
321 q,
322 kv_c_normed,
323 k_pe,
324 output_shape=(q.shape[0], self.output_dim),
325 )
326 return self.block_fp8_linear(attn_output)
327
328
329 def is_nvfp4_supported():
330 return current_platform.has_device_capability(100)
331
332
333 # MLA test configuration
334 MLA_DIMS: list[tuple[int, int, int, int, int]] = []
335 PATTERN_TEST_MODELS_MLA_FP8: list[tuple[str, type]] = []
336 PATTERN_TEST_MODELS_MLA_GROUP_FP8: list[tuple[str, type]] = []
337 PATTERN_TEST_MODELS_MLA_FP4: list[tuple[str, type]] = []
338 BACKENDS_MLA_FP8: list[AttentionBackendEnum] = []
339 BACKENDS_MLA_FP4: list[AttentionBackendEnum] = []
340
341 if current_platform.is_cuda():
342 # (num_heads, qk_nope_head_dim, qk_rope_head_dim, v_head_dim, kv_lora_rank)
343 MLA_DIMS = [(16, 128, 64, 128, 512)]
344 PATTERN_TEST_MODELS_MLA_FP8 = [
345 (
346 "deepseek-ai/DeepSeek-V2-Lite",
347 TestMLAAttentionFp8StaticQuantPatternModel,
348 )
349 ]
350 PATTERN_TEST_MODELS_MLA_GROUP_FP8 = [
351 (
352 "deepseek-ai/DeepSeek-V3",
353 TestMLAAttentionFp8GroupQuantPatternModel,
354 )
355 ]
356 PATTERN_TEST_MODELS_MLA_FP4 = [
357 (
358 "deepseek-ai/DeepSeek-V2-Lite",
359 TestMLAAttentionNvfp4QuantPatternModel,
360 )
361 ]
362 BACKENDS_MLA_FP8 = [AttentionBackendEnum.TRITON_MLA]
363 BACKENDS_MLA_FP4 = [AttentionBackendEnum.TRITON_MLA]
364 elif current_platform.is_rocm():
365 # ROCm supports the static-FP8 output-quant fusion through AITER MLA.
366 # Per-group FP8 still relies on the CUDA-only CUTLASS block-scaled
367 # kernel, and NVFP4 is NVIDIA-specific.
368 MLA_DIMS = [(16, 128, 64, 128, 512)]
369 PATTERN_TEST_MODELS_MLA_FP8 = [
370 (
371 "deepseek-ai/DeepSeek-V2-Lite",
372 TestMLAAttentionFp8StaticQuantPatternModel,
373 )
374 ]
375 BACKENDS_MLA_FP8 = [AttentionBackendEnum.ROCM_AITER_MLA]
376
377
378 @pytest.mark.parametrize(
379 "num_heads, qk_nope_head_dim, qk_rope_head_dim, v_head_dim, kv_lora_rank",
380 MLA_DIMS,
381 )
382 @pytest.mark.parametrize(
383 "batch_size", [7, 256] if current_platform.is_cuda_alike() else [8]
384 )
385 @pytest.mark.parametrize("dtype", [torch.bfloat16])
386 @pytest.mark.parametrize(
387 "backend, model_name, model_class, custom_ops",
388 list(
389 flat_product(
390 BACKENDS_MLA_FP8,
391 PATTERN_TEST_MODELS_MLA_FP8,
392 ["+quant_fp8", "-quant_fp8"],
393 )
394 )
395 + list(
396 flat_product(
397 BACKENDS_MLA_FP8,
398 PATTERN_TEST_MODELS_MLA_GROUP_FP8,
399 ["+quant_fp8"],
400 )
401 )
402 + list(flat_product(BACKENDS_MLA_FP4, PATTERN_TEST_MODELS_MLA_FP4, [""])),
403 )
404 @pytest.mark.skipif(
405 not current_platform.is_cuda_alike(), reason="Only test ROCm or CUDA"
406 )
407 @pytest.mark.skipif(not current_platform.supports_fp8(), reason="Need FP8")
408 def test_mla_attention_quant_pattern(
409 num_heads: int,
410 qk_nope_head_dim: int,
411 qk_rope_head_dim: int,
412 v_head_dim: int,
413 kv_lora_rank: int,
414 batch_size: int,
415 dtype: torch.dtype,
416 custom_ops: str,
417 model_name: str,
418 model_class: type[MLAAttentionQuantPatternModel],
419 backend: AttentionBackendEnum,
420 dist_init,
421 disable_vllm_compile_cache,
422 workspace_init,
423 ):
424 """Test MLA AttentionQuantPattern fusion pass."""
425 if (
426 model_class is TestMLAAttentionNvfp4QuantPatternModel
427 and not is_nvfp4_supported()
428 ):
429 pytest.skip("NVFP4 is not supported on this GPU (requires SM 100+).")
430
431 custom_ops_list = custom_ops.split(",") if custom_ops else []
432
433 device = torch.device(f"{DEVICE_TYPE}:0")
434 torch.set_default_dtype(dtype)
435 torch.manual_seed(42)
436
437 model_config = ModelConfig(
438 model=model_name,
439 max_model_len=2048,
440 dtype=dtype,
441 )
442 vllm_config = VllmConfig(
443 model_config=model_config,
444 scheduler_config=SchedulerConfig(
445 max_num_seqs=1024,
446 max_model_len=model_config.max_model_len,
447 is_encoder_decoder=model_config.is_encoder_decoder,
448 ),
449 compilation_config=CompilationConfig(
450 mode=CompilationMode.VLLM_COMPILE,
451 custom_ops=custom_ops_list,
452 ),
453 cache_config=CacheConfig(cache_dtype="auto"),
454 attention_config=AttentionConfig(backend=backend),
455 )
456
457 # MLA inputs: q(B, N, qk_head_dim), kv_c_normed(B, L), k_pe(B, 1, R)
458 qk_head_dim = qk_nope_head_dim + qk_rope_head_dim
459 q = torch.randn(batch_size, num_heads, qk_head_dim, dtype=dtype, device=device)
460 kv_c_normed = torch.randn(batch_size, kv_lora_rank, dtype=dtype, device=device)
461 k_pe = torch.randn(batch_size, 1, qk_rope_head_dim, dtype=dtype, device=device)
462
463 # Mark first dimension as dynamic
464 torch._dynamo.mark_dynamic(q, 0)
465 torch._dynamo.mark_dynamic(kv_c_normed, 0)
466 torch._dynamo.mark_dynamic(k_pe, 0)
467
468 # Run model without fusion
469 vllm_config_unfused = copy.deepcopy(vllm_config)
470 with (
471 set_current_vllm_config(vllm_config_unfused),
472 set_forward_context(attn_metadata=None, vllm_config=vllm_config_unfused),
473 ):
474 model_unfused = model_class(
475 num_heads=num_heads,
476 qk_nope_head_dim=qk_nope_head_dim,
477 qk_rope_head_dim=qk_rope_head_dim,
478 v_head_dim=v_head_dim,
479 kv_lora_rank=kv_lora_rank,
480 kv_cache_dtype=dtype,
481 device=device,
482 vllm_config=vllm_config_unfused,
483 )
484 if (
485 model_class is TestMLAAttentionFp8GroupQuantPatternModel
486 and type(model_unfused.block_fp8_linear.kernel)
487 is not CutlassFp8BlockScaledMMKernel
488 ):
489 pytest.skip("CUTLASS FP8 block kernel is not supported on this platform")
490 model_unfused = model_unfused.to(device)
491 # HACK: See #131044
492 result_unfused_0 = model_unfused(q, kv_c_normed, k_pe) # noqa: F841
493
494 forward_ctx = get_forward_context()
495 forward_ctx.attn_metadata = model_unfused.build_attn_metadata(batch_size)
496
497 compiled_unfused = torch.compile(model_unfused, fullgraph=True)
498 result_unfused = compiled_unfused(q, kv_c_normed, k_pe)
499
500 # Run model with attn fusion enabled
501 vllm_config.compilation_config.pass_config = PassConfig(
502 fuse_attn_quant=True, eliminate_noops=True
503 )
504 with (
505 set_current_vllm_config(vllm_config),
506 set_forward_context(attn_metadata=None, vllm_config=vllm_config),
507 ):
508 model_fused = model_class(
509 num_heads=num_heads,
510 qk_nope_head_dim=qk_nope_head_dim,
511 qk_rope_head_dim=qk_rope_head_dim,
512 v_head_dim=v_head_dim,
513 kv_lora_rank=kv_lora_rank,
514 kv_cache_dtype=dtype,
515 device=device,
516 vllm_config=vllm_config,
517 w=model_unfused.w,
518 kv_b_proj_weight=model_unfused.kv_b_proj_weight,
519 )
520 model_fused = model_fused.to(device)
521
522 forward_ctx = get_forward_context()
523 forward_ctx.attn_metadata = model_fused.build_attn_metadata(batch_size)
524
525 # Create test backend with fusion passes
526 noop_pass = NoOpEliminationPass(vllm_config)
527 attn_pass = LazyInitPass(MLAAttnQuantFusionPass, vllm_config)
528 cleanup_pass = PostCleanupPass(vllm_config)
529
530 test_backend = TestBackend(noop_pass, attn_pass, cleanup_pass)
531 # HACK: See https://github.com/vllm-project/vllm/issues/31044
532 result_fused_0 = model_fused(q, kv_c_normed, k_pe) # noqa: F841
533
534 compiled_fused = torch.compile(
535 model_fused, backend=test_backend, fullgraph=True
536 )
537
538 result_fused = compiled_fused(q, kv_c_normed, k_pe)
539
540 # Check attn fusion support
541 quant_key: QuantKey = model_class.quant_key
542 attn_fusion_supported = [
543 layer.impl.fused_output_quant_supported(quant_key)
544 for key, layer in vllm_config.compilation_config.static_forward_context.items()
545 if isinstance(layer, MLAAttention)
546 ]
547 assert sum(attn_fusion_supported) == len(attn_fusion_supported), (
548 "All MLA layers should support attention fusion"
549 )
550
551 # Check quantization ops in the graph
552 is_per_group = quant_key.scale.group_shape.is_per_group()
553 quant_op = (
554 torch.ops.aten.reciprocal
555 if "-quant_fp8" in custom_ops_list
556 else QUANT_OPS[quant_key]
557 )
558 test_backend.check_before_ops([quant_op], fully_replaced=is_per_group)
559
560 assert attn_pass.pass_.matched_count == sum(attn_fusion_supported)
561
562 # Check MLA attention ops in the graph
563 attn_nodes_pre = list(find_op_nodes(MLA_ATTN_OP, test_backend.graph_pre_pass))
564 attn_nodes_post = list(find_op_nodes(MLA_ATTN_OP, test_backend.graph_post_pass))
565
566 assert len(attn_nodes_pre) > 0, "Should have MLA attention nodes before fusion"
567 assert len(attn_nodes_pre) == len(attn_nodes_post), (
568 "Should have same number of MLA attention nodes before and after fusion"
569 )
570
571 # Before fusion: neither scale should be set
572 assert attn_nodes_pre[0].kwargs.get("output_scale") is None
573 assert attn_nodes_pre[0].kwargs.get("output_block_scale") is None
574
575 # After fusion: derive expected scale presence from quant_key properties.
576 # - output_scale: present for static quant or non-FP8 (NVFP4 carries input_scale)
577 # - output_block_scale: present when quant uses per-group/block scaling
578 has_output_scale = attn_nodes_post[0].kwargs.get("output_scale") is not None
579 has_block_scale = attn_nodes_post[0].kwargs.get("output_block_scale") is not None
580
581 expects_output_scale = quant_key.scale.static or quant_key.dtype != FP8_DTYPE
582 assert has_output_scale == expects_output_scale, (
583 f"output_scale: expected present={expects_output_scale}, got {has_output_scale}"
584 )
585 assert has_block_scale == is_per_group, (
586 f"output_block_scale: expected present={is_per_group}, got {has_block_scale}"
587 )
588
589 # Check numerical correctness
590 torch.testing.assert_close(result_unfused, result_fused, atol=1e-2, rtol=1e-2)