A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 5, 14:45 UTC
[mla] Support fused FP8/NVFP4 output quantization in MLA attention (#35792) (#36205)
Signed-off-by: Carl You <4531192+carlyou@users.noreply.github.com> Signed-off-by: Carl Y <4531192+carlyou@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Carl Y committed
Apr 2, 2026 at 18:16 UTC
1f5ec2889c4176593c2fedae3b33b6244c996d29
12 files changed
+928
-17
.buildkite/test_areas/compile.yaml
+2
@@ -72,6 +72,7 @@ steps:
72
- vllm/v1/attention/backends/flashinfer.py
73
- vllm/compilation/ # TODO(luka) limit to vllm/compilation/passes
74
- tests/compile/passes/test_fusion_attn.py
75
+ - tests/compile/passes/test_mla_attn_quant_fusion.py
76
- tests/compile/passes/test_silu_mul_quant_fusion.py
77
- tests/compile/passes/distributed/test_fusion_all_reduce.py
78
- tests/compile/fullgraph/test_full_graph.py
@@ -79,6 +80,7 @@ steps:
80
# b200 runners are limited, so we limit the tests to the minimum set only supported on Blackwell
81
- nvidia-smi
82
- pytest -v -s tests/compile/passes/test_fusion_attn.py -k FLASHINFER
83
+ - pytest -v -s tests/compile/passes/test_mla_attn_quant_fusion.py
84
- pytest -v -s tests/compile/passes/test_silu_mul_quant_fusion.py
85
# this runner has 2 GPUs available even though num_devices=2 is not set
86
- pytest -v -s tests/compile/passes/distributed/test_fusion_all_reduce.py
docs/design/fusions.md
+18
-2
@@ -22,6 +22,7 @@ or just on the low or high end.
22
| ------------------------------------------------------------------------------ | ---------------------------- | ---------------------------------------------- | ------------------------------ | ------------------ | --------- | ------------ |
23
| [AllReduce + RMSNorm](#allreduce--rmsnorm-fuse_allreduce_rms) | `fuse_allreduce_rms` | All-reduce → RMSNorm (+residual_add) (→ quant) | O2 (Hopper/Blackwell + TP > 1) | 5-20% | No | Low |
24
| [Attention + Quant](#attention--quantization-fuse_attn_quant) | `fuse_attn_quant` | Attention output → FP8/NVFP4 quant | Off by default | 3-7% | Yes | Always |
25
+| [MLA Attention + Quant](#attention--quantization-fuse_attn_quant) | `fuse_attn_quant` | MLA Attention output → FP8/NVFP4 quant | Off by default | TBD | Yes | Always |
26
| [RoPE + KV-Cache Update](#rope--kv-cache-update-fuse_rope_kvcache) | `fuse_rope_kvcache` | Rotary embedding → KV cache write | O2 (ROCm/AITER only) | 2-4% | No | Low |
27
| [QK Norm + RoPE](#qk-norm--rope-enable_qk_norm_rope_fusion) | `enable_qk_norm_rope_fusion` | Q/K RMSNorm → rotary embedding | Off by default | 2-3% | No | Low |
28
| [Sequence Parallelism](#sequence-parallelism-enable_sp) | `enable_sp` | AllReduce → ReduceScatter + AllGather | Off by default | Prereq for AsyncTP | Yes | High |
@@ -40,6 +41,7 @@ The table below lists the quantization schemes supported by each fusion on each
41
| ---------------------------- | ---------------------------------------- | ---------------------------------------- | ---------------------------------------- | ------------- | ---------------------------------------- |
42
| `fuse_allreduce_rms` | FP16/BF16, FP8 static, NVFP4 | FP16/BF16, FP8 static | — | — | — |
43
| `fuse_attn_quant`\* | FP8 static\*, NVFP4\* | FP8 static\* | FP8 static\* | — | FP8 static\* |
44
+| `fuse_attn_quant` (MLA)\* | FP8 static\*, NVFP4\* | FP8 static\* | FP8 static\* | — | FP8 static(untested)\* |
45
| `fuse_rope_kvcache` | — | — | — | — | FP16/BF16 |
46
| `enable_qk_norm_rope_fusion` | FP16/BF16 | FP16/BF16 | FP16/BF16† | FP16/BF16† | — |
47
| `enable_sp` | FP16/BF16, FP8 static† | FP16/BF16, FP8 static | FP16/BF16† | FP16/BF16† | — |
@@ -129,7 +131,8 @@ on SM90/SM100) and configurable via `PassConfig.fi_allreduce_fusion_max_size_mb`
131
explicitly. It requires the full model graph to be visible (Inductor partition or `splitting_ops=[]`).
132
133
**What it fuses.** Fuses the attention output quantization directly after the attention computation,
132
-eliminating a full-precision memory round-trip of the attention output. Patterns covered:
134
+eliminating a full-precision memory round-trip of the attention output. This fusion supports both
135
+standard `Attention` and `MLAAttention` (used by DeepSeek-V2/V3/R1 models). Patterns covered:
136
137
`Attention → FP8 static quant`:
138
@@ -142,11 +145,24 @@ eliminating a full-precision memory round-trip of the attention output. Patterns
145
146
- `FLASHINFER`: CUDA sm100+ with FlashInfer installed
147
148
+`MLAAttention → FP8 static quant` / `MLAAttention → NVFP4 dynamic quant`:
149
+
150
+The MLA fusion operates at the graph level on the `unified_mla_attention_with_output` op and works
151
+with all MLA decode and prefill backend combinations. Unlike standard `Attention` backends (where
152
+the kernel writes FP8 output directly), no MLA prefill or decode backend currently supports direct
153
+FP8/FP4 output. The fusion writes to an intermediate buffer and quantizes in a separate step, so
154
+there is no memory round-trip elimination yet.
155
+
156
+!!! info
157
+ The MLA attention fusion is not expected to yield a measurable speedup yet.
158
+ This will improve once MLA prefill/decode kernels support direct FP8/FP4 output.
159
+
160
Other attention backends do not support fused output quantization yet.
161
162
**Code locations.**
163
149
-- Pass: [`vllm/compilation/passes/fusion/attn_quant_fusion.py`](https://github.com/vllm-project/vllm/blob/main/vllm/compilation/passes/fusion/attn_quant_fusion.py)
164
+- Pass (Attention): [`vllm/compilation/passes/fusion/attn_quant_fusion.py`](https://github.com/vllm-project/vllm/blob/main/vllm/compilation/passes/fusion/attn_quant_fusion.py)
165
+- Pass (MLAAttention): [`vllm/compilation/passes/fusion/mla_attn_quant_fusion.py`](https://github.com/vllm-project/vllm/blob/main/vllm/compilation/passes/fusion/mla_attn_quant_fusion.py)
166
- Attention backends: [`vllm/v1/attention/backends/`](https://github.com/vllm-project/vllm/blob/main/vllm/v1/attention/backends/)
167
168
### RoPE + KV-Cache Update (`fuse_rope_kvcache`)
tests/compile/fusions_e2e/conftest.py
+9
-3
@@ -84,10 +84,14 @@ def run_e2e_fusion_test(monkeypatch, caplog_mp_spawn):
84
rocm_aiter_ops.refresh_env_variables()
85
86
# Filter here to reduce code duplication
87
+ backend_name = attn_backend.backend.name.lower()
88
requires_mla = "deepseek" in model_name.lower()
88
- is_mla = "mla" in attn_backend.backend.name.lower()
89
+ is_mla = "mla" in backend_name
90
+ # DeepSeek V3.2 uses sparse MLA
91
+ requires_sparse = "v3.2" in model_name.lower()
92
+ is_sparse = "sparse" in backend_name
93
90
- if requires_mla != is_mla:
94
+ if requires_mla != is_mla or requires_sparse != is_sparse:
95
pytest.skip(
96
f"Incompatible model '{model_name}' and "
97
f"attention backend '{attn_backend.backend.name}'"
@@ -231,7 +235,9 @@ def run_e2e_fusion_test(monkeypatch, caplog_mp_spawn):
235
)
236
237
elif match_name == "attn_quant_fusion":
234
- actual_match = match_table.get(match_name, 0)
238
+ actual_match = match_table.get(
239
+ "attn_quant_fusion", 0
240
+ ) + match_table.get("mla_attn_quant_fusion", 0)
241
assert actual_match == expected_matches * n_expected, (
242
f"Could not find {expected_matches * n_expected} "
243
f"{match_name} (found {actual_match})."
tests/compile/fusions_e2e/models.py
+32
-1
@@ -58,6 +58,15 @@ TRITON_MLA_ATTN = pytest.param(
58
id="TRITON_MLA",
59
)
60
61
+FLASHMLA_SPARSE_ATTN = pytest.param(
62
+ AttentionBackendCase(backend=AttentionBackendEnum.FLASHMLA_SPARSE),
63
+ id="FLASHMLA_SPARSE",
64
+ marks=pytest.mark.skipif(
65
+ not is_blackwell(),
66
+ reason="FlashMLA Sparse requires Blackwell",
67
+ ),
68
+)
69
+
70
# Models
71
llama3_8b = ModelFusionInfo(
72
model_name="meta-llama/Llama-3.1-8B-Instruct",
@@ -141,6 +150,18 @@ qwen3_a3b_fp8 = ModelFusionInfo(
150
),
151
)
152
153
+deepseek_coder_v2_lite_fp8 = ModelFusionInfo(
154
+ model_name="RedHatAI/DeepSeek-Coder-V2-Lite-Instruct-FP8",
155
+ matches=lambda n_layers: Matches(
156
+ # first_k_dense_replace=1; MoE hides most rms+quant sites
157
+ rms_quant_fusion=1,
158
+ act_quant_fusion=min(1, n_layers), # dense layers only
159
+ # MLA attn + static FP8 quant
160
+ attn_quant_fusion=n_layers,
161
+ ar_rms_fusion=n_layers * 2 + 1,
162
+ ),
163
+)
164
+
165
deepseek_v3_fp8 = ModelFusionInfo(
166
model_name="deepseek-ai/DeepSeek-V3",
167
matches=lambda n_layers: Matches(
@@ -152,7 +173,7 @@ deepseek_v3_fp8 = ModelFusionInfo(
173
rms_quant_fusion=n_layers * 2 + min(3, n_layers), # add for 3 dense layers
174
# silu+block quant
175
act_quant_fusion=min(3, n_layers), # dense layers only
155
- # MLA attn + quant not supported yet:
176
+ # MLA attn + per-group FP8 quant not supported yet:
177
# https://github.com/vllm-project/vllm/issues/35792
178
attn_quant_fusion=0,
179
ar_rms_fusion=n_layers * 2 + 1,
@@ -162,6 +183,16 @@ deepseek_v3_fp8 = ModelFusionInfo(
183
),
184
)
185
186
+deepseek_v32_fp4 = ModelFusionInfo(
187
+ model_name="nvidia/DeepSeek-V3.2-NVFP4",
188
+ matches=lambda n_layers: Matches(
189
+ rms_quant_fusion=0,
190
+ act_quant_fusion=0,
191
+ attn_quant_fusion=n_layers,
192
+ ar_rms_fusion=n_layers * 2 + 1,
193
+ ),
194
+)
195
+
196
gpt_oss_20b = ModelFusionInfo(
197
model_name="openai/gpt-oss-20b",
198
matches=lambda n_layers: Matches(
tests/compile/fusions_e2e/test_tp1_quant.py
+9
-2
@@ -18,11 +18,14 @@ from .common import (
18
from .models import (
19
FLASHINFER_ATTN,
20
FLASHINFER_MLA_ATTN,
21
+ FLASHMLA_SPARSE_ATTN,
22
ROCM_AITER_UNIFIED_ATTN,
23
ROCM_ATTN,
24
TRITON_ATTN,
25
TRITON_MLA_ATTN,
26
+ deepseek_coder_v2_lite_fp8,
27
deepseek_v3_fp8,
28
+ deepseek_v32_fp4,
29
llama3_8b_fp4,
30
llama3_8b_fp8,
31
llama4_scout_fp4,
@@ -37,6 +40,7 @@ from .models import (
40
(*llama3_8b_fp8, False),
41
(*qwen3_a3b_fp8, False),
42
(*qwen3_a3b_fp8, True),
43
+ (*deepseek_coder_v2_lite_fp8, False),
44
(*deepseek_v3_fp8, False),
45
(*deepseek_v3_fp8, True),
46
pytest.param(
@@ -144,9 +148,12 @@ def test_tp1_fp8_fusions(
148
149
@pytest.mark.parametrize(
150
"model_name, matches_fn, model_kwargs, hf_overrides",
147
- [llama3_8b_fp4, llama4_scout_fp4],
151
+ [llama3_8b_fp4, llama4_scout_fp4, deepseek_v32_fp4],
152
+)
153
+@pytest.mark.parametrize(
154
+ "attn_backend",
155
+ [FLASHINFER_ATTN, FLASHMLA_SPARSE_ATTN],
156
)
149
-@pytest.mark.parametrize("attn_backend", [FLASHINFER_ATTN])
157
@pytest.mark.parametrize("n_layers", [6])
158
@pytest.mark.parametrize("custom_ops", custom_ops_combos("rms_norm"))
159
@pytest.mark.parametrize("inductor_graph_partition", INDUCTOR_GRAPH_PARTITION)
tests/compile/fusions_e2e/test_tp2_ar_rms.py
+15
-3
@@ -18,8 +18,11 @@ from .common import (
18
from .models import (
19
FLASHINFER_ATTN,
20
FLASHINFER_MLA_ATTN,
21
+ FLASHMLA_SPARSE_ATTN,
22
TRITON_ATTN,
23
+ deepseek_coder_v2_lite_fp8,
24
deepseek_v3_fp8,
25
+ deepseek_v32_fp4,
26
gpt_oss_20b,
27
llama3_8b,
28
llama3_8b_fp4,
@@ -37,7 +40,13 @@ pytestmark = pytest.mark.skipif(not current_platform.is_cuda(), reason="Only tes
40
@pytest.mark.parametrize(
41
"model_name, matches_fn, model_kwargs, hf_overrides",
42
# qwen3 & dsv3 should still fuse AR+rms even though group quant is not yet supported
40
- [llama3_8b_fp8, llama4_scout_fp8, qwen3_a3b_fp8, deepseek_v3_fp8],
43
+ [
44
+ llama3_8b_fp8,
45
+ llama4_scout_fp8,
46
+ qwen3_a3b_fp8,
47
+ deepseek_coder_v2_lite_fp8,
48
+ deepseek_v3_fp8,
49
+ ],
50
)
51
@pytest.mark.parametrize(
52
"attn_backend", [TRITON_ATTN, FLASHINFER_ATTN, FLASHINFER_MLA_ATTN]
@@ -104,9 +113,12 @@ def test_tp2_ar_rms_fp8_fusions(
113
@multi_gpu_test(num_gpus=2)
114
@pytest.mark.parametrize(
115
"model_name, matches_fn, model_kwargs, hf_overrides",
107
- [llama3_8b_fp4, llama4_scout_fp4],
116
+ [llama3_8b_fp4, llama4_scout_fp4, deepseek_v32_fp4],
117
+)
118
+@pytest.mark.parametrize(
119
+ "attn_backend",
120
+ [FLASHINFER_ATTN, FLASHMLA_SPARSE_ATTN],
121
)
109
-@pytest.mark.parametrize("attn_backend", [FLASHINFER_ATTN])
122
@pytest.mark.parametrize("n_layers", [4])
123
@pytest.mark.parametrize("custom_ops", custom_ops_combos("rms_norm"))
124
@pytest.mark.parametrize("inductor_graph_partition", INDUCTOR_GRAPH_PARTITION)
tests/compile/passes/test_mla_attn_quant_fusion.py
new
+508
@@ -0,0 +1,508 @@
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.layers.attention import MLAAttention
33
+from vllm.model_executor.layers.linear import ColumnParallelLinear
34
+from vllm.model_executor.layers.quantization.fp8 import Fp8Config
35
+from vllm.model_executor.layers.quantization.modelopt import ModelOptNvFp4Config
36
+from vllm.model_executor.layers.quantization.utils.quant_utils import (
37
+ QuantKey,
38
+ kFp8StaticTensorSym,
39
+ kNvfp4Dynamic,
40
+)
41
+from vllm.platforms import current_platform
42
+from vllm.v1.attention.backend import AttentionMetadata
43
+from vllm.v1.attention.backends.registry import AttentionBackendEnum
44
+from vllm.v1.kv_cache_interface import MLAAttentionSpec
45
+
46
+FP8_DTYPE = current_platform.fp8_dtype()
47
+FP4_DTYPE = torch.uint8
48
+
49
+
50
+class MLAAttentionQuantPatternModel(torch.nn.Module):
51
+ """Base model for MLA AttentionQuantPattern fusion."""
52
+
53
+ def __init__(
54
+ self,
55
+ num_heads: int,
56
+ qk_nope_head_dim: int,
57
+ qk_rope_head_dim: int,
58
+ v_head_dim: int,
59
+ kv_lora_rank: int,
60
+ kv_cache_dtype: torch.dtype,
61
+ device: torch.device,
62
+ vllm_config: VllmConfig,
63
+ **kwargs,
64
+ ):
65
+ super().__init__()
66
+ self.num_heads = num_heads
67
+ self.qk_nope_head_dim = qk_nope_head_dim
68
+ self.qk_rope_head_dim = qk_rope_head_dim
69
+ self.qk_head_dim = qk_nope_head_dim + qk_rope_head_dim
70
+ self.v_head_dim = v_head_dim
71
+ self.kv_lora_rank = kv_lora_rank
72
+ self.output_dim = num_heads * v_head_dim
73
+ self.head_size = kv_lora_rank + qk_rope_head_dim
74
+ self.kv_cache_dtype = kv_cache_dtype
75
+ self.device = device
76
+ self.vllm_config = vllm_config
77
+
78
+ # Create kv_b_proj (ColumnParallelLinear) on device.
79
+ # Reuse weights from prior model instance when available, because
80
+ # ColumnParallelLinear may get NaN from recycled CUDA memory after
81
+ # torch.compile runs in the same process.
82
+ kv_b_proj = ColumnParallelLinear(
83
+ input_size=kv_lora_rank,
84
+ output_size=num_heads * (qk_nope_head_dim + v_head_dim),
85
+ bias=False,
86
+ prefix="model.layers.0.self_attn.kv_b_proj",
87
+ ).to(device)
88
+ kv_b_proj_weight = kwargs.get("kv_b_proj_weight")
89
+ if kv_b_proj_weight is not None:
90
+ kv_b_proj.weight.data.copy_(kv_b_proj_weight)
91
+ elif kv_b_proj.weight.data.isnan().any():
92
+ # Sanitize NaN from recycled CUDA memory
93
+ kv_b_proj.weight.data.normal_()
94
+
95
+ # Create MLAAttention
96
+ self.mla_attn = MLAAttention(
97
+ num_heads=num_heads,
98
+ scale=1.0 / (self.qk_head_dim**0.5),
99
+ qk_nope_head_dim=qk_nope_head_dim,
100
+ qk_rope_head_dim=qk_rope_head_dim,
101
+ v_head_dim=v_head_dim,
102
+ q_lora_rank=None,
103
+ kv_lora_rank=kv_lora_rank,
104
+ kv_b_proj=kv_b_proj,
105
+ cache_config=vllm_config.cache_config,
106
+ quant_config=self.quant_config,
107
+ prefix="model.layers.0.self_attn.attn",
108
+ )
109
+ self.mla_attn._k_scale = self.mla_attn._k_scale.to(device)
110
+ self.mla_attn._v_scale = self.mla_attn._v_scale.to(device)
111
+
112
+ # Initialize W_UK_T and W_UV from kv_b_proj weights
113
+ self.mla_attn.process_weights_after_loading(torch.get_default_dtype())
114
+ self.kv_b_proj_weight = kv_b_proj.weight.data.clone()
115
+
116
+ self.block_size = 16
117
+
118
+ # Initialize MLA MetadataBuilder
119
+ self.builder = self.mla_attn.attn_backend.get_builder_cls()(
120
+ kv_cache_spec=MLAAttentionSpec(
121
+ block_size=self.block_size,
122
+ num_kv_heads=1,
123
+ head_size=self.head_size,
124
+ dtype=self.kv_cache_dtype,
125
+ ),
126
+ layer_names=[self.mla_attn.layer_name],
127
+ vllm_config=self.vllm_config,
128
+ device=self.device,
129
+ )
130
+
131
+ def build_attn_metadata(self, batch_size: int) -> AttentionMetadata:
132
+ """Initialize MLA attention metadata.
133
+
134
+ NOTE: Uses decode-only batch (query_len=1 per request). The prefill
135
+ (forward_mha) path is not separately tested here because it requires
136
+ FlashAttention availability and different input tensor shapes. The
137
+ quant logic in forward_impl is identical for both paths — it quantizes
138
+ the full output[:num_actual_toks] buffer after both forward_mha and
139
+ forward_mqa have written their results.
140
+ """
141
+
142
+ batch_spec = BatchSpec(seq_lens=[1] * batch_size, query_lens=[1] * batch_size)
143
+ common_attn_metadata = create_common_attn_metadata(
144
+ batch_spec, self.block_size, self.device, arange_block_indices=True
145
+ )
146
+
147
+ max_blocks = (max(batch_spec.seq_lens) + self.block_size - 1) // self.block_size
148
+ num_blocks = batch_size * max_blocks
149
+
150
+ # MLA KV cache is 3D: (num_blocks, block_size, head_size)
151
+ attn_backend = self.mla_attn.attn_backend
152
+ kv_cache_shape = attn_backend.get_kv_cache_shape(
153
+ num_blocks, self.block_size, 1, self.head_size
154
+ )
155
+ try:
156
+ kv_cache_stride_order = attn_backend.get_kv_cache_stride_order()
157
+ except (AttributeError, NotImplementedError):
158
+ kv_cache_stride_order = tuple(range(len(kv_cache_shape)))
159
+
160
+ ordered_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
161
+ inv_order = [
162
+ kv_cache_stride_order.index(i) for i in range(len(kv_cache_stride_order))
163
+ ]
164
+
165
+ raw_tensor = torch.zeros(
166
+ ordered_shape, dtype=self.kv_cache_dtype, device=self.device
167
+ )
168
+ kv_cache = raw_tensor.permute(*inv_order)
169
+
170
+ self.mla_attn.kv_cache = kv_cache
171
+
172
+ self.attn_metadata = self.builder.build(
173
+ common_prefix_len=0, common_attn_metadata=common_attn_metadata
174
+ )
175
+
176
+ return self.attn_metadata
177
+
178
+
179
+class TestMLAAttentionFp8StaticQuantPatternModel(MLAAttentionQuantPatternModel):
180
+ """Test model for MLA Attention + FP8 static quant fusion."""
181
+
182
+ quant_key = kFp8StaticTensorSym
183
+ quant_config = Fp8Config()
184
+
185
+ def __init__(self, *args, **kwargs):
186
+ super().__init__(*args, **kwargs)
187
+
188
+ self.fp8_linear = TestFP8Layer(
189
+ weight_shape=(self.output_dim, self.output_dim),
190
+ activation_quant_key=self.quant_key,
191
+ weight_quant_key=self.quant_key,
192
+ device=self.device,
193
+ )
194
+
195
+ w = kwargs.get("w")
196
+ if w is not None:
197
+ self.fp8_linear.weight = w["weight"]
198
+ self.fp8_linear.weight_scale = w["wscale"]
199
+ self.fp8_linear.input_scale = w["scale"]
200
+
201
+ self.w = {
202
+ "weight": self.fp8_linear.weight,
203
+ "wscale": self.fp8_linear.weight_scale,
204
+ "scale": self.fp8_linear.input_scale,
205
+ }
206
+
207
+ def forward(
208
+ self,
209
+ q: torch.Tensor,
210
+ kv_c_normed: torch.Tensor,
211
+ k_pe: torch.Tensor,
212
+ ):
213
+ """Forward pass that creates the MLA attention + FP8 quant pattern."""
214
+ attn_output = self.mla_attn(
215
+ q,
216
+ kv_c_normed,
217
+ k_pe,
218
+ output_shape=(q.shape[0], self.output_dim),
219
+ )
220
+ return self.fp8_linear(attn_output)
221
+
222
+
223
+class TestMLAAttentionNvfp4QuantPatternModel(MLAAttentionQuantPatternModel):
224
+ """Test model for MLA Attention + NVFP4 quant fusion."""
225
+
226
+ quant_key = kNvfp4Dynamic
227
+ quant_config = ModelOptNvFp4Config(
228
+ is_checkpoint_nvfp4_serialized=False,
229
+ kv_cache_quant_algo=None,
230
+ exclude_modules=[],
231
+ )
232
+
233
+ def __init__(self, *args, **kwargs):
234
+ super().__init__(*args, **kwargs)
235
+
236
+ self.w = kwargs.get(
237
+ "w",
238
+ {
239
+ "weight": torch.randint(
240
+ 256,
241
+ (self.output_dim, self.output_dim // 2),
242
+ dtype=FP4_DTYPE,
243
+ device=self.device,
244
+ ),
245
+ "wscale_swizzled": torch.randn(
246
+ self.output_dim, self.output_dim // 16
247
+ ).to(dtype=FP8_DTYPE, device=self.device),
248
+ "wscale": torch.tensor([500], dtype=torch.float32, device=self.device),
249
+ "scale": torch.tensor([0.002], dtype=torch.float32, device=self.device),
250
+ },
251
+ )
252
+
253
+ def forward(
254
+ self,
255
+ q: torch.Tensor,
256
+ kv_c_normed: torch.Tensor,
257
+ k_pe: torch.Tensor,
258
+ ):
259
+ """Forward pass that creates the MLA attention + NVFP4 quant pattern."""
260
+ attn_output = self.mla_attn(
261
+ q,
262
+ kv_c_normed,
263
+ k_pe,
264
+ output_shape=(q.shape[0], self.output_dim),
265
+ )
266
+ quant_output, output_block_scale = scaled_fp4_quant(
267
+ attn_output, 1 / self.w["scale"]
268
+ )
269
+ return cutlass_scaled_fp4_mm(
270
+ a=quant_output,
271
+ b=self.w["weight"],
272
+ block_scale_a=output_block_scale,
273
+ block_scale_b=self.w["wscale_swizzled"],
274
+ alpha=self.w["scale"] * self.w["wscale"],
275
+ out_dtype=attn_output.dtype,
276
+ )
277
+
278
+
279
+def is_nvfp4_supported():
280
+ return current_platform.has_device_capability(100)
281
+
282
+
283
+# MLA test configuration
284
+MLA_DIMS: list[tuple[int, int, int, int, int]] = []
285
+PATTERN_TEST_MODELS_MLA_FP8: list[tuple[str, type]] = []
286
+PATTERN_TEST_MODELS_MLA_FP4: list[tuple[str, type]] = []
287
+BACKENDS_MLA_FP8: list[AttentionBackendEnum] = []
288
+BACKENDS_MLA_FP4: list[AttentionBackendEnum] = []
289
+
290
+if current_platform.is_cuda():
291
+ # (num_heads, qk_nope_head_dim, qk_rope_head_dim, v_head_dim, kv_lora_rank)
292
+ MLA_DIMS = [(16, 128, 64, 128, 512)]
293
+ PATTERN_TEST_MODELS_MLA_FP8 = [
294
+ (
295
+ "deepseek-ai/DeepSeek-V2-Lite",
296
+ TestMLAAttentionFp8StaticQuantPatternModel,
297
+ )
298
+ ]
299
+ PATTERN_TEST_MODELS_MLA_FP4 = [
300
+ (
301
+ "deepseek-ai/DeepSeek-V2-Lite",
302
+ TestMLAAttentionNvfp4QuantPatternModel,
303
+ )
304
+ ]
305
+ BACKENDS_MLA_FP8 = [AttentionBackendEnum.TRITON_MLA]
306
+ BACKENDS_MLA_FP4 = [AttentionBackendEnum.TRITON_MLA]
307
+
308
+
309
+@pytest.mark.parametrize(
310
+ "num_heads, qk_nope_head_dim, qk_rope_head_dim, v_head_dim, kv_lora_rank",
311
+ MLA_DIMS,
312
+)
313
+@pytest.mark.parametrize("batch_size", [7, 256] if current_platform.is_cuda() else [8])
314
+@pytest.mark.parametrize("dtype", [torch.bfloat16])
315
+@pytest.mark.parametrize(
316
+ "backend, model_name, model_class, custom_ops",
317
+ list(
318
+ flat_product(
319
+ BACKENDS_MLA_FP8,
320
+ PATTERN_TEST_MODELS_MLA_FP8,
321
+ ["+quant_fp8", "-quant_fp8"],
322
+ )
323
+ )
324
+ + list(flat_product(BACKENDS_MLA_FP4, PATTERN_TEST_MODELS_MLA_FP4, [""])),
325
+)
326
+@pytest.mark.skipif(
327
+ not current_platform.is_cuda_alike(), reason="Only test ROCm or CUDA"
328
+)
329
+@pytest.mark.skipif(not current_platform.supports_fp8(), reason="Need FP8")
330
+def test_mla_attention_quant_pattern(
331
+ num_heads: int,
332
+ qk_nope_head_dim: int,
333
+ qk_rope_head_dim: int,
334
+ v_head_dim: int,
335
+ kv_lora_rank: int,
336
+ batch_size: int,
337
+ dtype: torch.dtype,
338
+ custom_ops: str,
339
+ model_name: str,
340
+ model_class: type[MLAAttentionQuantPatternModel],
341
+ backend: AttentionBackendEnum,
342
+ dist_init,
343
+ monkeypatch,
344
+ use_fresh_inductor_cache,
345
+):
346
+ """Test MLA AttentionQuantPattern fusion pass"""
347
+ if (
348
+ model_class is TestMLAAttentionNvfp4QuantPatternModel
349
+ and not is_nvfp4_supported()
350
+ ):
351
+ pytest.skip("NVFP4 is not supported on this GPU (requires SM 100+).")
352
+
353
+ monkeypatch.setenv("VLLM_DISABLE_COMPILE_CACHE", "1")
354
+
355
+ custom_ops_list = custom_ops.split(",") if custom_ops else []
356
+
357
+ device = torch.device("cuda:0")
358
+ torch.set_default_dtype(dtype)
359
+ torch.manual_seed(42)
360
+
361
+ model_config = ModelConfig(
362
+ model=model_name,
363
+ max_model_len=2048,
364
+ dtype=dtype,
365
+ )
366
+ vllm_config = VllmConfig(
367
+ model_config=model_config,
368
+ scheduler_config=SchedulerConfig(
369
+ max_num_seqs=1024,
370
+ max_model_len=model_config.max_model_len,
371
+ is_encoder_decoder=model_config.is_encoder_decoder,
372
+ ),
373
+ compilation_config=CompilationConfig(
374
+ mode=CompilationMode.VLLM_COMPILE,
375
+ custom_ops=custom_ops_list,
376
+ ),
377
+ cache_config=CacheConfig(cache_dtype="auto"),
378
+ attention_config=AttentionConfig(backend=backend),
379
+ )
380
+
381
+ # MLA inputs: q(B, N, qk_head_dim), kv_c_normed(B, L), k_pe(B, 1, R)
382
+ qk_head_dim = qk_nope_head_dim + qk_rope_head_dim
383
+ q = torch.randn(batch_size, num_heads, qk_head_dim, dtype=dtype, device=device)
384
+ kv_c_normed = torch.randn(batch_size, kv_lora_rank, dtype=dtype, device=device)
385
+ k_pe = torch.randn(batch_size, 1, qk_rope_head_dim, dtype=dtype, device=device)
386
+
387
+ # Mark first dimension as dynamic
388
+ torch._dynamo.mark_dynamic(q, 0)
389
+ torch._dynamo.mark_dynamic(kv_c_normed, 0)
390
+ torch._dynamo.mark_dynamic(k_pe, 0)
391
+
392
+ # Run model without fusion
393
+ vllm_config_unfused = copy.deepcopy(vllm_config)
394
+ with (
395
+ set_current_vllm_config(vllm_config_unfused),
396
+ set_forward_context(attn_metadata=None, vllm_config=vllm_config_unfused),
397
+ ):
398
+ model_unfused = model_class(
399
+ num_heads=num_heads,
400
+ qk_nope_head_dim=qk_nope_head_dim,
401
+ qk_rope_head_dim=qk_rope_head_dim,
402
+ v_head_dim=v_head_dim,
403
+ kv_lora_rank=kv_lora_rank,
404
+ kv_cache_dtype=dtype,
405
+ device=device,
406
+ vllm_config=vllm_config_unfused,
407
+ )
408
+ model_unfused = model_unfused.to(device)
409
+ # HACK: See #131044
410
+ result_unfused_0 = model_unfused(q, kv_c_normed, k_pe) # noqa: F841
411
+
412
+ forward_ctx = get_forward_context()
413
+ forward_ctx.attn_metadata = model_unfused.build_attn_metadata(batch_size)
414
+
415
+ compiled_unfused = torch.compile(model_unfused, fullgraph=True)
416
+ result_unfused = compiled_unfused(q, kv_c_normed, k_pe)
417
+
418
+ # Run model with attn fusion enabled
419
+ vllm_config.compilation_config.pass_config = PassConfig(
420
+ fuse_attn_quant=True, eliminate_noops=True
421
+ )
422
+ with (
423
+ set_current_vllm_config(vllm_config),
424
+ set_forward_context(attn_metadata=None, vllm_config=vllm_config),
425
+ ):
426
+ model_fused = model_class(
427
+ num_heads=num_heads,
428
+ qk_nope_head_dim=qk_nope_head_dim,
429
+ qk_rope_head_dim=qk_rope_head_dim,
430
+ v_head_dim=v_head_dim,
431
+ kv_lora_rank=kv_lora_rank,
432
+ kv_cache_dtype=dtype,
433
+ device=device,
434
+ vllm_config=vllm_config,
435
+ w=model_unfused.w,
436
+ kv_b_proj_weight=model_unfused.kv_b_proj_weight,
437
+ )
438
+ model_fused = model_fused.to(device)
439
+
440
+ forward_ctx = get_forward_context()
441
+ forward_ctx.attn_metadata = model_fused.build_attn_metadata(batch_size)
442
+
443
+ # Create test backend with fusion passes
444
+ noop_pass = NoOpEliminationPass(vllm_config)
445
+ attn_pass = LazyInitPass(MLAAttnQuantFusionPass, vllm_config)
446
+ cleanup_pass = PostCleanupPass(vllm_config)
447
+
448
+ test_backend = TestBackend(noop_pass, attn_pass, cleanup_pass)
449
+ # HACK: See https://github.com/vllm-project/vllm/issues/31044
450
+ result_fused_0 = model_fused(q, kv_c_normed, k_pe) # noqa: F841
451
+
452
+ compiled_fused = torch.compile(
453
+ model_fused, backend=test_backend, fullgraph=True
454
+ )
455
+
456
+ result_fused = compiled_fused(q, kv_c_normed, k_pe)
457
+
458
+ # Check attn fusion support
459
+ quant_key: QuantKey = model_class.quant_key
460
+ attn_fusion_supported = [
461
+ layer.impl.fused_output_quant_supported(quant_key)
462
+ for key, layer in vllm_config.compilation_config.static_forward_context.items()
463
+ if isinstance(layer, MLAAttention)
464
+ ]
465
+ assert sum(attn_fusion_supported) == len(attn_fusion_supported), (
466
+ "All MLA layers should support attention fusion"
467
+ )
468
+
469
+ # Check quantization ops in the graph
470
+ quant_op = (
471
+ torch.ops.aten.reciprocal
472
+ if "-quant_fp8" in custom_ops_list
473
+ else QUANT_OPS[quant_key]
474
+ )
475
+ test_backend.check_before_ops([quant_op], fully_replaced=quant_key is kNvfp4Dynamic)
476
+
477
+ assert attn_pass.pass_.matched_count == sum(attn_fusion_supported)
478
+
479
+ # Check MLA attention ops in the graph
480
+ attn_nodes_pre = list(find_op_nodes(MLA_ATTN_OP, test_backend.graph_pre_pass))
481
+ attn_nodes_post = list(find_op_nodes(MLA_ATTN_OP, test_backend.graph_post_pass))
482
+
483
+ assert len(attn_nodes_pre) > 0, "Should have MLA attention nodes before fusion"
484
+ assert len(attn_nodes_pre) == len(attn_nodes_post), (
485
+ "Should have same number of MLA attention nodes before and after fusion"
486
+ )
487
+ assert attn_nodes_pre[0].kwargs.get("output_scale") is None, (
488
+ "MLA attention should not have output_scale before fusion"
489
+ )
490
+ assert attn_nodes_post[0].kwargs.get("output_scale") is not None, (
491
+ "MLA attention should have output_scale after fusion"
492
+ )
493
+
494
+ assert attn_nodes_pre[0].kwargs.get("output_block_scale") is None, (
495
+ "MLA attention should not have output_block_scale before fusion"
496
+ )
497
+
498
+ if quant_key.dtype == FP8_DTYPE:
499
+ assert attn_nodes_post[0].kwargs.get("output_block_scale") is None, (
500
+ "MLA attention should not have output_block_scale after FP8 fusion"
501
+ )
502
+ elif quant_key.dtype == FP4_DTYPE:
503
+ assert attn_nodes_post[0].kwargs.get("output_block_scale") is not None, (
504
+ "MLA attention should have output_block_scale after FP4 fusion"
505
+ )
506
+
507
+ # Check numerical correctness
508
+ torch.testing.assert_close(result_unfused, result_fused, atol=1e-2, rtol=1e-2)
vllm/compilation/passes/fusion/mla_attn_quant_fusion.py
new
+262
@@ -0,0 +1,262 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from collections.abc import Callable
5
+
6
+import torch
7
+from torch._higher_order_ops.auto_functionalize import auto_functionalized
8
+
9
+from vllm._custom_ops import create_fp4_output_tensors
10
+from vllm.config import VllmConfig, get_layers_from_vllm_config
11
+from vllm.logger import init_logger
12
+from vllm.model_executor.layers.attention.mla_attention import MLAAttention
13
+from vllm.model_executor.layers.quantization.utils.quant_utils import (
14
+ kFp8StaticTensorSym,
15
+ kNvfp4Dynamic,
16
+)
17
+from vllm.platforms import current_platform
18
+
19
+from ..vllm_inductor_pass import VllmFusionPatternMatcherPass, VllmPatternReplacement
20
+from .matcher_utils import MatcherQuantFP8
21
+from .rms_quant_fusion import QUANT_OPS
22
+
23
+logger = init_logger(__name__)
24
+
25
+FP8_DTYPE = current_platform.fp8_dtype()
26
+FP4_DTYPE = torch.uint8
27
+
28
+MLA_ATTN_OP = torch.ops.vllm.unified_mla_attention_with_output.default
29
+
30
+
31
+class MLAAttnFp8StaticQuantPattern(VllmPatternReplacement[..., torch.Tensor]):
32
+ """
33
+ Fusion for MLA Attention+Fp8StaticQuant.
34
+
35
+ Matches the pattern: MLA attention -> static FP8 quant, and replaces
36
+ it with MLA attention(output_scale=scale, output=fp8_buffer).
37
+ """
38
+
39
+ def __init__(self, layer: MLAAttention, dtype: torch.dtype) -> None:
40
+ self._layer_name = layer.layer_name
41
+ self._num_heads = layer.num_heads
42
+ self._v_head_dim = layer.v_head_dim
43
+ self._kv_lora_rank = layer.kv_lora_rank
44
+ self._qk_rope_head_dim = layer.qk_rope_head_dim
45
+ self._qk_head_dim = layer.qk_nope_head_dim + layer.qk_rope_head_dim
46
+ self._output_dim = layer.num_heads * layer.v_head_dim
47
+ self._dtype = dtype
48
+ self._quant_matcher = MatcherQuantFP8(kFp8StaticTensorSym)
49
+
50
+ @property
51
+ def pattern(self) -> Callable[..., torch.Tensor]:
52
+ def _pattern(
53
+ q: torch.Tensor,
54
+ kv_c_normed: torch.Tensor,
55
+ k_pe: torch.Tensor,
56
+ output_attn: torch.Tensor,
57
+ scale: torch.Tensor,
58
+ kv_cache_dummy_dep: torch.Tensor,
59
+ ) -> torch.Tensor:
60
+ at1 = auto_functionalized(
61
+ MLA_ATTN_OP,
62
+ q=q,
63
+ kv_c_normed=kv_c_normed,
64
+ k_pe=k_pe,
65
+ output=output_attn,
66
+ layer_name=self._layer_name,
67
+ output_scale=None,
68
+ output_block_scale=None,
69
+ kv_cache_dummy_dep=kv_cache_dummy_dep,
70
+ )
71
+ # MLA output is already 2D (T, N*V), no reshape needed
72
+ return self._quant_matcher(at1[1], scale)[0]
73
+
74
+ return _pattern
75
+
76
+ @property
77
+ def replacement(self) -> Callable[..., torch.Tensor]:
78
+ def _replacement(
79
+ q: torch.Tensor,
80
+ kv_c_normed: torch.Tensor,
81
+ k_pe: torch.Tensor,
82
+ output_attn: torch.Tensor,
83
+ scale: torch.Tensor,
84
+ kv_cache_dummy_dep: torch.Tensor,
85
+ ) -> torch.Tensor:
86
+ # MLA output in quant_dtype
87
+ output_attn = torch.empty(
88
+ [q.shape[0], self._output_dim],
89
+ dtype=FP8_DTYPE,
90
+ device=q.device,
91
+ )
92
+ at1 = auto_functionalized(
93
+ MLA_ATTN_OP,
94
+ q=q,
95
+ kv_c_normed=kv_c_normed,
96
+ k_pe=k_pe,
97
+ output=output_attn,
98
+ layer_name=self._layer_name,
99
+ output_scale=scale,
100
+ output_block_scale=None,
101
+ kv_cache_dummy_dep=kv_cache_dummy_dep,
102
+ )
103
+ return at1[1]
104
+
105
+ return _replacement
106
+
107
+ def get_inputs(self) -> list[torch.Tensor]:
108
+ return [
109
+ self.empty(5, self._num_heads, self._qk_head_dim, dtype=self._dtype),
110
+ self.empty(5, self._kv_lora_rank, dtype=self._dtype),
111
+ self.empty(5, 1, self._qk_rope_head_dim, dtype=self._dtype),
112
+ self.empty(5, self._output_dim, dtype=self._dtype),
113
+ self.empty_fp32(1, 1),
114
+ self.empty(0, dtype=self._dtype),
115
+ ]
116
+
117
+
118
+class MLAAttnNvfp4QuantPattern(
119
+ VllmPatternReplacement[..., tuple[torch.Tensor, torch.Tensor]]
120
+):
121
+ """
122
+ Fusion for MLA Attention+Nvfp4Quant.
123
+
124
+ Matches the pattern: MLA attention -> NVFP4 quant, and replaces
125
+ it with MLA attention(output_scale=scale, output_block_scale=block_scale,
126
+ output=fp4_buffer).
127
+ """
128
+
129
+ def __init__(self, layer: MLAAttention, dtype: torch.dtype) -> None:
130
+ self._layer_name = layer.layer_name
131
+ self._num_heads = layer.num_heads
132
+ self._v_head_dim = layer.v_head_dim
133
+ self._kv_lora_rank = layer.kv_lora_rank
134
+ self._qk_rope_head_dim = layer.qk_rope_head_dim
135
+ self._qk_head_dim = layer.qk_nope_head_dim + layer.qk_rope_head_dim
136
+ self._output_dim = layer.num_heads * layer.v_head_dim
137
+ self._dtype = dtype
138
+ self._QUANT_OP = QUANT_OPS[kNvfp4Dynamic]
139
+
140
+ @property
141
+ def pattern(
142
+ self,
143
+ ) -> Callable[..., tuple[torch.Tensor, torch.Tensor]]:
144
+ def _pattern(
145
+ q: torch.Tensor,
146
+ kv_c_normed: torch.Tensor,
147
+ k_pe: torch.Tensor,
148
+ output_attn: torch.Tensor,
149
+ input_scale: torch.Tensor,
150
+ kv_cache_dummy_dep: torch.Tensor,
151
+ ) -> tuple[torch.Tensor, torch.Tensor]:
152
+ at1 = auto_functionalized(
153
+ MLA_ATTN_OP,
154
+ q=q,
155
+ kv_c_normed=kv_c_normed,
156
+ k_pe=k_pe,
157
+ output=output_attn,
158
+ layer_name=self._layer_name,
159
+ output_scale=None,
160
+ output_block_scale=None,
161
+ kv_cache_dummy_dep=kv_cache_dummy_dep,
162
+ )
163
+ # Replicate what scaled_fp4_quant() does: allocate output
164
+ # tensors inline then call the .out variant.
165
+ output_quant, output_scale = create_fp4_output_tensors(
166
+ at1[1].shape[0], at1[1].shape[1], at1[1].device, True
167
+ )
168
+ at2 = auto_functionalized(
169
+ self._QUANT_OP,
170
+ input=at1[1],
171
+ input_scale=input_scale,
172
+ is_sf_swizzled_layout=True,
173
+ output=output_quant,
174
+ output_scale=output_scale,
175
+ )
176
+ output_scale_view = torch.ops.aten.view.dtype(at2[2], FP8_DTYPE)
177
+ return at2[1], output_scale_view
178
+
179
+ return _pattern
180
+
181
+ @property
182
+ def replacement(
183
+ self,
184
+ ) -> Callable[..., tuple[torch.Tensor, torch.Tensor]]:
185
+ def _replacement(
186
+ q: torch.Tensor,
187
+ kv_c_normed: torch.Tensor,
188
+ k_pe: torch.Tensor,
189
+ output_attn: torch.Tensor,
190
+ input_scale: torch.Tensor,
191
+ kv_cache_dummy_dep: torch.Tensor,
192
+ ) -> tuple[torch.Tensor, torch.Tensor]:
193
+ # MLA output in quant_dtype (FP4 packed as uint8)
194
+ output_attn = torch.empty(
195
+ [q.shape[0], self._output_dim // 2],
196
+ dtype=FP4_DTYPE,
197
+ device=q.device,
198
+ )
199
+ # attention output block scale
200
+ output_scale = create_fp4_output_tensors(
201
+ q.shape[0], self._output_dim, q.device, True
202
+ )[1]
203
+ output_scale_view = torch.ops.aten.view.dtype(output_scale, FP8_DTYPE)
204
+ at2 = auto_functionalized(
205
+ MLA_ATTN_OP,
206
+ q=q,
207
+ kv_c_normed=kv_c_normed,
208
+ k_pe=k_pe,
209
+ output=output_attn,
210
+ layer_name=self._layer_name,
211
+ output_scale=input_scale,
212
+ output_block_scale=output_scale_view,
213
+ kv_cache_dummy_dep=kv_cache_dummy_dep,
214
+ )
215
+ return at2[1], at2[2]
216
+
217
+ return _replacement
218
+
219
+ def get_inputs(self) -> list[torch.Tensor]:
220
+ return [
221
+ self.empty(5, self._num_heads, self._qk_head_dim, dtype=self._dtype),
222
+ self.empty(5, self._kv_lora_rank, dtype=self._dtype),
223
+ self.empty(5, 1, self._qk_rope_head_dim, dtype=self._dtype),
224
+ self.empty(5, self._output_dim, dtype=self._dtype),
225
+ self.empty_fp32(1, 1),
226
+ self.empty(0, dtype=self._dtype),
227
+ ]
228
+
229
+
230
+class MLAAttnQuantFusionPass(VllmFusionPatternMatcherPass):
231
+ """
232
+ This pass fuses post-attention quantization onto MLA attention if supported.
233
+
234
+ It uses the pattern matcher and matches each MLA layer manually, as strings
235
+ cannot be wildcarded. This also lets us check support on attention layers
236
+ upon registration instead of during pattern matching.
237
+ """
238
+
239
+ def __init__(self, config: VllmConfig) -> None:
240
+ super().__init__(config, "mla_attn_quant_fusion")
241
+
242
+ dtype = config.model_config.dtype
243
+ layers = list(get_layers_from_vllm_config(config, MLAAttention).values())
244
+
245
+ if len(layers) == 0:
246
+ logger.warning(
247
+ "MLA attention + quant fusion is enabled, but no MLA "
248
+ "attention layers were found in "
249
+ "CompilationConfig.static_forward_context "
250
+ "so no fusion patterns were registered."
251
+ )
252
+
253
+ for layer in layers:
254
+ if layer.impl.fused_output_quant_supported(kFp8StaticTensorSym):
255
+ self.register(MLAAttnFp8StaticQuantPattern(layer, dtype))
256
+
257
+ if current_platform.is_cuda() and hasattr(torch.ops._C, "scaled_fp4_quant"):
258
+ for layer in layers:
259
+ if layer.impl.fused_output_quant_supported(kNvfp4Dynamic):
260
+ self.register(MLAAttnNvfp4QuantPattern(layer, dtype))
261
+
262
+ self.dump_patterns(config, self.pm_pass)
vllm/compilation/passes/pass_manager.py
+2
@@ -27,6 +27,7 @@ if rocm_aiter_ops.is_enabled():
27
if current_platform.is_cuda_alike():
28
from .fusion.act_quant_fusion import ActivationQuantFusionPass
29
from .fusion.attn_quant_fusion import AttnQuantFusionPass
30
+ from .fusion.mla_attn_quant_fusion import MLAAttnQuantFusionPass
31
from .fusion.qk_norm_rope_fusion import QKNormRoPEFusionPass
32
from .fusion.rms_quant_fusion import RMSNormQuantFusionPass
33
from .fusion.rope_kvcache_fusion import RopeKVCacheFusionPass
@@ -157,6 +158,7 @@ class PostGradPassManager(CustomGraphPass): # type: ignore[misc]
158
159
if self.pass_config.fuse_attn_quant:
160
self.passes += [AttnQuantFusionPass(config)]
161
+ self.passes += [MLAAttnQuantFusionPass(config)]
162
163
if self.pass_config.enable_qk_norm_rope_fusion:
164
self.passes += [SplitCoalescingPass(config)]
vllm/config/compilation.py
+1
-1
@@ -121,7 +121,7 @@ class PassConfig:
121
fuse_act_quant: bool = None # type: ignore[assignment]
122
"""Fuse the custom SiluMul + quant ops."""
123
fuse_attn_quant: bool = None # type: ignore[assignment]
124
- """Fuse the custom attention + quant ops."""
124
+ """Fuse the custom Attention and MLAAttention + quant ops."""
125
eliminate_noops: bool = Field(default=True)
126
"""Eliminate no-op ops."""
127
enable_sp: bool = None # type: ignore[assignment]
vllm/model_executor/layers/attention/mla_attention.py
+49
-5
@@ -449,6 +449,11 @@ class MLAAttention(nn.Module, AttentionLayerBase):
449
group_shape=GroupShape.PER_TENSOR,
450
compile_native=True,
451
)
452
+ self._quant_fp8_op = QuantFP8(
453
+ static=True,
454
+ group_shape=GroupShape.PER_TENSOR,
455
+ compile_native=True,
456
+ )
457
458
@property
459
def chunked_prefill_workspace_size(self) -> int:
@@ -545,9 +550,19 @@ class MLAAttention(nn.Module, AttentionLayerBase):
550
) -> torch.Tensor:
551
assert output is not None, "Output tensor must be provided."
552
548
- if output_scale is not None or output_block_scale is not None:
549
- raise NotImplementedError(
550
- "fused output quantization is not yet supported for MLA"
553
+ use_quant = output_scale is not None or output_block_scale is not None
554
+ if use_quant:
555
+ # The fusion pass has allocated output with quantized dtype
556
+ # (FP8 or uint8 for FP4). We can't write into it directly,
557
+ # so we swap in a temp buffer for computation, then quantize
558
+ # into the real output at the end.
559
+ # NOTE(carlyou): this is temporary until kernels support fp8 output
560
+ quant_output = output
561
+ output = torch.empty(
562
+ output.shape[0],
563
+ self.num_heads * self.v_head_dim,
564
+ dtype=q.dtype,
565
+ device=output.device,
566
)
567
568
if attn_metadata is None:
@@ -567,6 +582,8 @@ class MLAAttention(nn.Module, AttentionLayerBase):
582
# The zero fill is required when used with DP + EP
583
# to ensure all ranks within a DP group compute the
584
# same expert outputs.
585
+ if use_quant:
586
+ return quant_output.fill_(0)
587
return output.fill_(0)
588
589
if self.impl.dcp_world_size == -1:
@@ -706,6 +723,21 @@ class MLAAttention(nn.Module, AttentionLayerBase):
723
724
# v_up projection
725
self._v_up_proj(attn_out, out=mqa_output_slice)
726
+
727
+ if use_quant:
728
+ # Quantize the BF16 computation result into the quantized output
729
+ actual = output[:num_actual_toks]
730
+ if output_block_scale is not None:
731
+ # NVFP4: two FP4 values packed into one uint8
732
+ fp4_data, fp4_scales = ops.scaled_fp4_quant(actual, output_scale)
733
+ quant_output[:num_actual_toks].copy_(fp4_data)
734
+ output_block_scale.copy_(fp4_scales)
735
+ else:
736
+ # Static FP8 quantization
737
+ fp8_data, _ = self._quant_fp8_op(actual, output_scale)
738
+ quant_output[:num_actual_toks].copy_(fp8_data)
739
+ return quant_output
740
+
741
return output_padded
742
743
def process_weights_after_loading(self, act_dtype: torch.dtype):
@@ -2069,6 +2101,14 @@ class MLACommonImpl(MLAAttentionImpl[M], Generic[M]):
2101
understand this class
2102
"""
2103
2104
+ def fused_output_quant_supported(self, quant_key):
2105
+ from vllm.model_executor.layers.quantization.utils.quant_utils import (
2106
+ kFp8StaticTensorSym,
2107
+ kNvfp4Dynamic,
2108
+ )
2109
+
2110
+ return quant_key in (kFp8StaticTensorSym, kNvfp4Dynamic)
2111
+
2112
def __init__(
2113
self,
2114
num_heads: int,
@@ -2513,8 +2553,12 @@ class MLACommonImpl(MLAAttentionImpl[M], Generic[M]):
2553
if hasattr(self.kv_b_proj, "weight")
2554
else self.kv_b_proj.params_dtype
2555
)
2516
- if use_fp8_prefill or _kv_b_proj_w_dtype != current_platform.fp8_dtype():
2517
- kv_c_normed = kv_c_normed.to(_kv_b_proj_w_dtype)
2556
+ # For NVFP4, weights are packed uint8 — keep input in model dtype
2557
+ # since the NVFP4 linear layer quantizes internally.
2558
+ if (
2559
+ use_fp8_prefill or _kv_b_proj_w_dtype != current_platform.fp8_dtype()
2560
+ ) and _kv_b_proj_w_dtype != torch.uint8:
2561
+ kv_c_normed = kv_c_normed.to(self.kv_b_proj.weight.dtype)
2562
2563
k_pe = workspace[:toks][..., self.kv_lora_rank :].unsqueeze(1)
2564
kv_nope = self.kv_b_proj(kv_c_normed)[0].view(
vllm/v1/attention/backend.py
+21
@@ -10,6 +10,11 @@ import numpy as np
10
import torch
11
from typing_extensions import deprecated
12
13
+from vllm.model_executor.layers.quantization.utils.quant_utils import (
14
+ kFp8StaticTensorSym,
15
+ kNvfp4Dynamic,
16
+)
17
+
18
if TYPE_CHECKING:
19
from vllm.config import VllmConfig
20
from vllm.config.cache import CacheDType
@@ -873,6 +878,14 @@ class MLAAttentionImpl(AttentionImplBase[T], Generic[T]):
878
"""MQA-style decode forward pass."""
879
raise NotImplementedError
880
881
+ def fused_output_quant_supported(self, quant_key: "QuantKey"):
882
+ """
883
+ Does this attention implementation support fused output quantization.
884
+ Since MLA quantization is done manually in forward_impl (common code),
885
+ all MLA backends support it by default.
886
+ """
887
+ return quant_key in (kFp8StaticTensorSym, kNvfp4Dynamic)
888
+
889
def do_kv_cache_update(
890
self,
891
kv_c_normed: torch.Tensor,
@@ -903,6 +916,14 @@ class SparseMLAAttentionImpl(AttentionImplBase[T], Generic[T]):
916
They do not support prefill (MHA-style) attention.
917
"""
918
919
+ def fused_output_quant_supported(self, quant_key: "QuantKey"):
920
+ """
921
+ Does this attention implementation support fused output quantization.
922
+ Since MLA quantization is done manually in forward_impl (common code),
923
+ all MLA backends support it by default.
924
+ """
925
+ return quant_key in (kFp8StaticTensorSym, kNvfp4Dynamic)
926
+
927
@abstractmethod
928
def __init__(
929
self,