[Fusion] Manual `ActivationQuantFusionPass` initial application (#51415)

Signed-off-by: mgoin <mgoin64@gmail.com> Signed-off-by: ElizaWszola <ewszola@redhat.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: ElizaWszola <ewszola@redhat.com>

Misha Goin committed Sep 3, 2026 at 13:37 UTC b7624069ff023043b39ba4ea67ae5f6045556819
10 files changed +740 -19
tests/compile/fusions_e2e/common.py
+39
@@ -43,6 +43,45 @@ is_blackwell = lambda: current_platform.is_device_capability_family(100)
43 """Are we running on Blackwell, a lot of tests depend on it"""
44
45
46 +def nvfp4_kernel_exposes_input_quant_key() -> bool:
47 + """Check if the NVFP4 kernel selected on this platform exposes input_quant_key.
48 +
49 + FlashInferCuteDslNvFp4LinearKernel does not expose input_quant_key() due to
50 + layout incompatibility between the manual fusion kernel (silu_and_mul_nvfp4_quant)
51 + output format and the cutedsl backend's expected input format.
52 +
53 + FlashInferCutlassNvFp4LinearKernel does expose input_quant_key() and supports
54 + manual fusion.
55 + """
56 + if not current_platform.is_cuda():
57 + return False
58 +
59 + try:
60 + from vllm.model_executor.kernels.linear.nvfp4.flashinfer import (
61 + FlashInferCuteDslNvFp4LinearKernel,
62 + )
63 +
64 + is_supported, _ = FlashInferCuteDslNvFp4LinearKernel.is_supported()
65 + if is_supported:
66 + return False
67 + except ImportError:
68 + pass
69 +
70 + try:
71 + from vllm.model_executor.kernels.linear.nvfp4.flashinfer import (
72 + FlashInferCutlassNvFp4LinearKernel,
73 + )
74 +
75 + is_supported, _ = FlashInferCutlassNvFp4LinearKernel.is_supported()
76 + if is_supported:
77 + return True
78 + except ImportError:
79 + pass
80 +
81 + # Fallback: assume no manual fusion support
82 + return False
83 +
84 +
85 def custom_ops_combos(*custom_ops: str) -> Iterable[str]:
86 """Generate all combinations of custom ops for parametrization."""
87 custom_ops_lists = [[f"-{op}", f"+{op}"] for op in custom_ops]
tests/compile/fusions_e2e/test_tp1_quant.py
+10 -5
@@ -14,6 +14,7 @@ from .common import (
14 Matches,
15 custom_ops_combos,
16 is_blackwell,
17 + nvfp4_kernel_exposes_input_quant_key,
18 )
19 from .models import (
20 FLASHINFER_ATTN,
@@ -77,7 +78,6 @@ def test_tp1_fp8_fusions(
78 inductor_graph_partition: bool,
79 use_deepgemm: bool,
80 run_e2e_fusion_test,
80 - monkeypatch,
81 ):
82 if use_deepgemm and not current_platform.is_cuda():
83 pytest.skip("DeepGemm only supported on CUDA")
@@ -109,7 +109,7 @@ def test_tp1_fp8_fusions(
109 custom_ops=custom_ops.split(","),
110 pass_config=PassConfig(
111 fuse_norm_quant=True,
112 - fuse_act_quant=True,
112 + fuse_act_quant=False,
113 fuse_attn_quant=True,
114 enable_qk_norm_rope_fusion=True,
115 ),
@@ -119,7 +119,6 @@ def test_tp1_fp8_fusions(
119
120 matches_check = [
121 "rms_quant_fusion",
122 - "act_quant_fusion",
122 "norm_rope_fusion",
123 "attn_quant_fusion",
124 ]
@@ -168,6 +167,12 @@ def test_tp1_fp4_fusions(
167 inductor_graph_partition: bool,
168 run_e2e_fusion_test,
169 ):
170 + if nvfp4_kernel_exposes_input_quant_key():
171 + pytest.skip(
172 + "NVFP4 kernel exposes input_quant_key; manual fusion fires "
173 + "instead of compiler pass-based fusion"
174 + )
175 +
176 matches = matches_fn(n_layers)
177
178 # Reduce size of model and skip weight loading time
@@ -181,13 +186,13 @@ def test_tp1_fp4_fusions(
186 custom_ops=custom_ops.split(","),
187 pass_config=PassConfig(
188 fuse_norm_quant=True,
184 - fuse_act_quant=True,
189 + fuse_act_quant=False,
190 fuse_attn_quant=True,
191 enable_qk_norm_rope_fusion=True,
192 ),
193 )
194
190 - matches_check = ["act_quant_fusion", "attn_quant_fusion", "norm_rope_fusion"]
195 + matches_check = ["attn_quant_fusion", "norm_rope_fusion"]
196
197 run_e2e_fusion_test(
198 model_name,
tests/compile/fusions_e2e/test_tp2_ar_rms.py
+9 -6
@@ -14,6 +14,7 @@ from .common import (
14 Matches,
15 custom_ops_combos,
16 is_blackwell,
17 + nvfp4_kernel_exposes_input_quant_key,
18 )
19 from .models import (
20 FLASHINFER_ATTN,
@@ -68,7 +69,6 @@ def test_tp2_ar_rms_fp8_fusions(
69 custom_ops: str,
70 inductor_graph_partition: bool,
71 run_e2e_fusion_test,
71 - monkeypatch,
72 ):
73 matches = matches_fn(n_layers)
74
@@ -89,7 +89,7 @@ def test_tp2_ar_rms_fp8_fusions(
89 custom_ops=custom_ops.split(","),
90 pass_config=PassConfig(
91 fuse_norm_quant=True,
92 - fuse_act_quant=True,
92 + fuse_act_quant=False,
93 fuse_attn_quant=True,
94 enable_qk_norm_rope_fusion=True,
95 fuse_allreduce_rms=True,
@@ -98,7 +98,6 @@ def test_tp2_ar_rms_fp8_fusions(
98
99 matches_check = [
100 "rms_quant_fusion",
101 - "act_quant_fusion",
101 "norm_rope_fusion",
102 "attn_quant_fusion",
103 "ar_rms_fusion",
@@ -139,8 +138,13 @@ def test_tp2_ar_rms_fp4_fusions(
138 custom_ops: str,
139 inductor_graph_partition: bool,
140 run_e2e_fusion_test,
142 - monkeypatch,
141 ):
142 + if nvfp4_kernel_exposes_input_quant_key():
143 + pytest.skip(
144 + "NVFP4 kernel exposes input_quant_key; manual fusion fires "
145 + "instead of compiler pass-based fusion"
146 + )
147 +
148 matches = matches_fn(n_layers)
149
150 # Reduce size of model and skip weight loading time
@@ -154,14 +158,13 @@ def test_tp2_ar_rms_fp4_fusions(
158 use_inductor_graph_partition=inductor_graph_partition,
159 custom_ops=custom_ops.split(","),
160 pass_config=PassConfig(
157 - fuse_act_quant=True,
161 + fuse_act_quant=False,
162 fuse_attn_quant=True,
163 fuse_allreduce_rms=True,
164 ),
165 )
166
167 matches_check = [
164 - "act_quant_fusion",
168 "attn_quant_fusion",
169 "ar_rms_fusion",
170 ]
tests/compile/fusions_e2e/test_tp2_async_tp.py
+10 -6
@@ -14,6 +14,7 @@ from .common import (
14 Matches,
15 custom_ops_combos,
16 is_blackwell,
17 + nvfp4_kernel_exposes_input_quant_key,
18 )
19 from .models import (
20 FLASHINFER_ATTN,
@@ -61,7 +62,7 @@ def test_tp2_async_tp_fp8_fusions(
62 custom_ops=custom_ops.split(","),
63 pass_config=PassConfig(
64 fuse_norm_quant=True,
64 - fuse_act_quant=True,
65 + fuse_act_quant=False,
66 fuse_attn_quant=True,
67 enable_qk_norm_rope_fusion=True,
68 enable_sp=True,
@@ -74,7 +75,6 @@ def test_tp2_async_tp_fp8_fusions(
75
76 matches_check = [
77 "rms_quant_fusion",
77 - "act_quant_fusion",
78 "norm_rope_fusion",
79 "attn_quant_fusion",
80 "sequence_parallel",
@@ -114,6 +114,12 @@ def test_tp2_async_tp_nvfp4_fusions(
114 inductor_graph_partition: bool,
115 run_e2e_fusion_test,
116 ):
117 + if nvfp4_kernel_exposes_input_quant_key():
118 + pytest.skip(
119 + "NVFP4 kernel exposes input_quant_key; manual fusion fires "
120 + "instead of compiler pass-based fusion"
121 + )
122 +
123 # NVFP4 currently wires the all-gather + GEMM path only.
124 matches = matches_fn(n_layers)._replace(async_tp=n_layers * 2)
125
@@ -127,7 +133,7 @@ def test_tp2_async_tp_nvfp4_fusions(
133 use_inductor_graph_partition=inductor_graph_partition,
134 custom_ops=custom_ops.split(","),
135 pass_config=PassConfig(
130 - fuse_act_quant=True,
136 + fuse_act_quant=False,
137 fuse_attn_quant=True,
138 enable_sp=True,
139 fuse_gemm_comms=True,
@@ -138,7 +144,6 @@ def test_tp2_async_tp_nvfp4_fusions(
144 )
145
146 matches_check = [
141 - "act_quant_fusion",
147 "attn_quant_fusion",
148 "sequence_parallel",
149 "async_tp",
@@ -245,7 +250,7 @@ def test_tp2_sp_ar_rms_fp8_fusions(
250 custom_ops=custom_ops.split(","),
251 pass_config=PassConfig(
252 fuse_norm_quant=True,
248 - fuse_act_quant=True,
253 + fuse_act_quant=False,
254 fuse_attn_quant=True,
255 enable_qk_norm_rope_fusion=True,
256 enable_sp=True,
@@ -258,7 +263,6 @@ def test_tp2_sp_ar_rms_fp8_fusions(
263
264 matches_check = [
265 "rms_quant_fusion",
261 - "act_quant_fusion",
266 "norm_rope_fusion",
267 "attn_quant_fusion",
268 "ar_rms_fusion",
tests/compile/passes/test_silu_mul_quant_manual_fusion.py new
+348
@@ -0,0 +1,348 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""
4 +Tests for manual fusion via maybe_fused_act_quant.
5 +
6 +Tests all fusion paths in _FUSED_ACT_QUANT:
7 +- kFp8StaticTensorSym: all platforms
8 +- kFp8Dynamic128Sym: CUDA only
9 +- kNvfp4Dynamic: CUDA SM100+ only
10 +"""
11 +
12 +import pytest
13 +import torch
14 +
15 +import vllm.envs as envs
16 +from tests.utils import TestFP8Layer
17 +from vllm.config import (
18 + CompilationConfig,
19 + VllmConfig,
20 + set_current_vllm_config,
21 +)
22 +from vllm.model_executor.kernels.linear import (
23 + CutlassFP8ScaledMMLinearKernel,
24 + FlashInferFP8ScaledMMLinearKernel,
25 + FP8ScaledMMLinearKernel,
26 + PerTensorTorchFP8ScaledMMLinearKernel,
27 + ROCmFP8ScaledMMLinearKernel,
28 +)
29 +from vllm.model_executor.layers.activation import SiluAndMul
30 +from vllm.model_executor.layers.fusion.fused_act_quant import (
31 + _FUSED_ACT_QUANT,
32 + maybe_fused_act_quant,
33 +)
34 +from vllm.model_executor.layers.fusion.quant_activation import (
35 + QuantizedActivation,
36 + expose_input_quant_key,
37 +)
38 +from vllm.model_executor.layers.quantization.utils.quant_utils import (
39 + kFp8Dynamic128Sym,
40 + kFp8StaticTensorSym,
41 + kNvfp4Dynamic,
42 +)
43 +from vllm.platforms import current_platform
44 +
45 +
46 +# Mock linear layer for testing fusion paths that don't have real kernel support
47 +class MockLinearForFusion(torch.nn.Module):
48 + """Mock linear layer that exposes input_quant_key for fusion testing."""
49 +
50 + def __init__(self, quant_key, input_scale=None, input_global_scale=None):
51 + super().__init__()
52 + self.input_quant_key = quant_key
53 + if input_scale is not None:
54 + self.input_scale = input_scale
55 + if input_global_scale is not None:
56 + self.input_global_scale = input_global_scale
57 +
58 +
59 +ROCM_KERNELS = [ROCmFP8ScaledMMLinearKernel, PerTensorTorchFP8ScaledMMLinearKernel]
60 +CUDA_KERNELS = [
61 + FlashInferFP8ScaledMMLinearKernel,
62 + CutlassFP8ScaledMMLinearKernel,
63 + PerTensorTorchFP8ScaledMMLinearKernel,
64 +]
65 +TEST_KERNELS = ROCM_KERNELS if current_platform.is_rocm() else CUDA_KERNELS
66 +
67 +
68 +@pytest.mark.parametrize("num_tokens", [32, 64])
69 +@pytest.mark.parametrize("hidden_size", [128, 256])
70 +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
71 +@pytest.mark.parametrize("force_kernel", TEST_KERNELS)
72 +@pytest.mark.skipif(
73 + envs.VLLM_TARGET_DEVICE not in ["cuda", "rocm"], reason="Only test on CUDA and ROCm"
74 +)
75 +def test_manual_fusion_fp8_static_with_linear(
76 + num_tokens: int,
77 + hidden_size: int,
78 + dtype: torch.dtype,
79 + force_kernel: FP8ScaledMMLinearKernel,
80 +):
81 + """Test manual fusion with real FP8 linear layer (kFp8StaticTensorSym).
82 +
83 + This is an end-to-end test that verifies the full flow:
84 + unfused (silu_and_mul -> in-kernel quant) vs fused (silu_and_mul_quant).
85 + """
86 + torch.set_default_device("cuda")
87 + torch.set_default_dtype(dtype)
88 +
89 + x = torch.rand(num_tokens, hidden_size * 2)
90 +
91 + config = VllmConfig(
92 + compilation_config=CompilationConfig(custom_ops=["none"]),
93 + )
94 +
95 + with set_current_vllm_config(config):
96 + silu_and_mul = SiluAndMul()
97 + fp8_linear = TestFP8Layer(
98 + weight_shape=(hidden_size, hidden_size),
99 + activation_quant_key=kFp8StaticTensorSym,
100 + weight_quant_key=kFp8StaticTensorSym,
101 + force_kernel=force_kernel,
102 + input_dtype=dtype,
103 + )
104 +
105 + # Run without fusion: silu_and_mul returns plain tensor
106 + y_unfused = maybe_fused_act_quant(silu_and_mul, x, fp8_linear)
107 + assert isinstance(y_unfused, torch.Tensor)
108 + result_unfused = fp8_linear(y_unfused)
109 +
110 + # Enable fusion
111 + expose_input_quant_key(fp8_linear, fp8_linear.kernel)
112 +
113 + if not hasattr(fp8_linear, "input_quant_key"):
114 + pytest.skip(
115 + f"Kernel {force_kernel.__name__} doesn't support input_quant_key"
116 + )
117 +
118 + # Run with fusion: silu_and_mul returns QuantizedActivation
119 + y_fused = maybe_fused_act_quant(silu_and_mul, x, fp8_linear)
120 + assert isinstance(y_fused, QuantizedActivation)
121 + assert y_fused.quant_key == kFp8StaticTensorSym
122 + assert y_fused.data.dtype == current_platform.fp8_dtype()
123 + assert y_fused.data.shape == (num_tokens, hidden_size)
124 + assert y_fused.orig_dtype == dtype
125 + assert y_fused.orig_shape == (num_tokens, hidden_size)
126 +
127 + result_fused = fp8_linear(y_fused)
128 +
129 + torch.testing.assert_close(
130 + result_fused.to(dtype=dtype),
131 + result_unfused.to(dtype=dtype),
132 + atol=5e-2,
133 + rtol=5e-2,
134 + )
135 +
136 +
137 +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
138 +@pytest.mark.skipif(
139 + not current_platform.is_cuda_alike(), reason="Dynamic block quant CUDA only"
140 +)
141 +@pytest.mark.skipif(
142 + envs.VLLM_TARGET_DEVICE not in ["cuda", "rocm"], reason="Only test on CUDA and ROCm"
143 +)
144 +def test_manual_fusion_fp8_dynamic_128(dtype: torch.dtype):
145 + """Test kFp8Dynamic128Sym fusion path (group_size=128).
146 +
147 + Compares fused (silu_and_mul_per_block_quant) vs unfused (silu_and_mul)
148 + by dequantizing the fused result and comparing with unfused.
149 + """
150 + if (SiluAndMul, kFp8Dynamic128Sym) not in _FUSED_ACT_QUANT:
151 + pytest.skip("kFp8Dynamic128Sym fusion not available")
152 +
153 + torch.set_default_device("cuda")
154 + torch.set_default_dtype(dtype)
155 +
156 + # hidden_size must be divisible by group_size (128)
157 + num_tokens, hidden_size = 32, 256
158 + group_size = 128
159 + x = torch.rand(num_tokens, hidden_size * 2)
160 +
161 + config = VllmConfig(
162 + compilation_config=CompilationConfig(custom_ops=["none"]),
163 + )
164 +
165 + with set_current_vllm_config(config):
166 + silu_and_mul = SiluAndMul()
167 +
168 + # Unfused path: just apply silu_and_mul
169 + mock_linear_no_key = torch.nn.Linear(hidden_size, hidden_size)
170 + result_unfused = maybe_fused_act_quant(silu_and_mul, x, mock_linear_no_key)
171 + assert isinstance(result_unfused, torch.Tensor)
172 +
173 + # Fused path: apply silu_and_mul + per-block quantization
174 + mock_linear_with_key = MockLinearForFusion(kFp8Dynamic128Sym)
175 + result_fused = maybe_fused_act_quant(silu_and_mul, x, mock_linear_with_key)
176 +
177 + # Verify fused result structure
178 + assert isinstance(result_fused, QuantizedActivation)
179 + assert result_fused.quant_key == kFp8Dynamic128Sym
180 + assert result_fused.data.dtype == current_platform.fp8_dtype()
181 + assert result_fused.data.shape == (num_tokens, hidden_size)
182 + assert result_fused.orig_dtype == dtype
183 +
184 + # Check scale shape
185 + expected_num_groups = hidden_size // group_size
186 + assert result_fused.scale.shape == (num_tokens, expected_num_groups)
187 +
188 + # Dequantize fused result and compare with unfused
189 + # Per-block dequant: data * scale (broadcast scale across group)
190 + dequant_data = result_fused.data.to(dtype).view(
191 + num_tokens, expected_num_groups, group_size
192 + )
193 + scales_expanded = result_fused.scale.unsqueeze(
194 + -1
195 + ) # (num_tokens, num_groups, 1)
196 + dequant_result = (
197 + (dequant_data * scales_expanded).view(num_tokens, hidden_size).to(dtype)
198 + )
199 +
200 + torch.testing.assert_close(
201 + dequant_result,
202 + result_unfused,
203 + atol=5e-2,
204 + rtol=5e-2,
205 + )
206 +
207 +
208 +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
209 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="NVFP4 CUDA only")
210 +@pytest.mark.skipif(
211 + not current_platform.has_device_capability(100), reason="NVFP4 requires SM100+"
212 +)
213 +@pytest.mark.skipif(
214 + envs.VLLM_TARGET_DEVICE not in ["cuda", "rocm"], reason="Only test on CUDA and ROCm"
215 +)
216 +def test_manual_fusion_nvfp4_dynamic(dtype: torch.dtype):
217 + """Test kNvfp4Dynamic fusion path.
218 +
219 + Compares fused (silu_and_mul_nvfp4_quant) vs unfused (silu_and_mul)
220 + by dequantizing the fused result and comparing with unfused.
221 + """
222 + if (SiluAndMul, kNvfp4Dynamic) not in _FUSED_ACT_QUANT:
223 + pytest.skip("kNvfp4Dynamic fusion not available")
224 +
225 + from tests.kernels.quantization.nvfp4_utils import dequantize_nvfp4_to_dtype
226 +
227 + torch.set_default_device("cuda")
228 + torch.set_default_dtype(dtype)
229 +
230 + # NVFP4 requires hidden_size divisible by 16 (block size) and by 2 (packing).
231 + # M (num_tokens) must be >= 128 for the 128x4 swizzled scale layout.
232 + num_tokens, hidden_size = 128, 256
233 + x = torch.rand(num_tokens, hidden_size * 2)
234 + input_global_scale = torch.tensor([1.0], dtype=torch.float32, device="cuda")
235 +
236 + config = VllmConfig(
237 + compilation_config=CompilationConfig(custom_ops=["none"]),
238 + )
239 +
240 + with set_current_vllm_config(config):
241 + silu_and_mul = SiluAndMul()
242 +
243 + # Unfused path: just apply silu_and_mul
244 + mock_linear_no_key = torch.nn.Linear(hidden_size, hidden_size)
245 + result_unfused = maybe_fused_act_quant(silu_and_mul, x, mock_linear_no_key)
246 + assert isinstance(result_unfused, torch.Tensor)
247 +
248 + # Fused path: apply silu_and_mul + NVFP4 quantization
249 + mock_linear_with_key = MockLinearForFusion(
250 + kNvfp4Dynamic, input_global_scale=input_global_scale
251 + )
252 + result_fused = maybe_fused_act_quant(silu_and_mul, x, mock_linear_with_key)
253 +
254 + # Verify fused result structure
255 + assert isinstance(result_fused, QuantizedActivation)
256 + assert result_fused.quant_key == kNvfp4Dynamic
257 + # NVFP4 packs 2 values into 1 byte
258 + assert result_fused.data.dtype == torch.uint8
259 + assert result_fused.data.shape == (num_tokens, hidden_size // 2)
260 + assert result_fused.orig_dtype == dtype
261 + assert result_fused.orig_shape == (num_tokens, hidden_size)
262 +
263 + # Dequantize fused result and compare with unfused
264 + dequant_result = dequantize_nvfp4_to_dtype(
265 + tensor_fp4=result_fused.data,
266 + tensor_sf=result_fused.scale,
267 + global_scale=input_global_scale,
268 + dtype=dtype,
269 + device="cuda",
270 + block_size=16,
271 + is_sf_128x4_layout=True,
272 + )
273 +
274 + torch.testing.assert_close(
275 + dequant_result,
276 + result_unfused,
277 + atol=3e-1,
278 + rtol=3e-1,
279 + )
280 +
281 +
282 +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
283 +@pytest.mark.skipif(
284 + envs.VLLM_TARGET_DEVICE not in ["cuda", "rocm"], reason="Only test on CUDA and ROCm"
285 +)
286 +def test_manual_fusion_fallback_no_key(dtype: torch.dtype):
287 + """Test that maybe_fused_act_quant falls back when no input_quant_key."""
288 + torch.set_default_device("cuda")
289 + torch.set_default_dtype(dtype)
290 +
291 + x = torch.rand(32, 256)
292 +
293 + config = VllmConfig(
294 + compilation_config=CompilationConfig(custom_ops=["none"]),
295 + )
296 +
297 + with set_current_vllm_config(config):
298 + silu_and_mul = SiluAndMul()
299 + # Linear without input_quant_key attribute
300 + mock_linear = torch.nn.Linear(128, 128)
301 +
302 + result = maybe_fused_act_quant(silu_and_mul, x, mock_linear)
303 +
304 + # Should fall back to plain silu_and_mul
305 + assert isinstance(result, torch.Tensor)
306 + assert result.shape == (32, 128)
307 + assert result.dtype == dtype
308 +
309 +
310 +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
311 +@pytest.mark.skipif(
312 + envs.VLLM_TARGET_DEVICE not in ["cuda", "rocm"], reason="Only test on CUDA and ROCm"
313 +)
314 +def test_manual_fusion_fallback_unsupported_key(dtype: torch.dtype):
315 + """Test that maybe_fused_act_quant falls back for unsupported quant keys."""
316 + from vllm.model_executor.layers.quantization.utils.quant_utils import (
317 + GroupShape,
318 + QuantKey,
319 + ScaleDesc,
320 + )
321 +
322 + torch.set_default_device("cuda")
323 + torch.set_default_dtype(dtype)
324 +
325 + x = torch.rand(32, 256)
326 +
327 + config = VllmConfig(
328 + compilation_config=CompilationConfig(custom_ops=["none"]),
329 + )
330 +
331 + with set_current_vllm_config(config):
332 + # Create an unsupported quant key
333 + unsupported_key = QuantKey(
334 + dtype=torch.int8,
335 + scale=ScaleDesc(
336 + dtype=torch.float32, static=False, group_shape=GroupShape(1, 1)
337 + ),
338 + )
339 +
340 + silu_and_mul = SiluAndMul()
341 + mock_linear = MockLinearForFusion(unsupported_key)
342 +
343 + result = maybe_fused_act_quant(silu_and_mul, x, mock_linear)
344 +
345 + # Should fall back to plain silu_and_mul since key not in _FUSED_ACT_QUANT
346 + assert isinstance(result, torch.Tensor)
347 + assert result.shape == (32, 128)
348 + assert result.dtype == dtype
tests/fusion/test_quant_activation_contract.py
+4
@@ -25,6 +25,9 @@ from vllm.model_executor.kernels.linear.scaled_mm.cutlass import (
25 from vllm.model_executor.kernels.linear.scaled_mm.flashinfer import (
26 FlashInferFP8ScaledMMLinearKernel,
27 )
28 +from vllm.model_executor.kernels.linear.scaled_mm.pytorch import (
29 + PerTensorTorchFP8ScaledMMLinearKernel,
30 +)
31 from vllm.model_executor.kernels.linear.scaled_mm.ScaledMMLinearKernel import (
32 FP8ScaledMMLinearLayerConfig,
33 Int8ScaledMMLinearKernel,
@@ -46,6 +49,7 @@ SUPPORTING = {
49 CutlassFP8ScaledMMLinearKernel,
50 FlashInferFP8ScaledMMLinearKernel,
51 FlashInferCutlassNvFp4LinearKernel,
52 + PerTensorTorchFP8ScaledMMLinearKernel,
53 }
54
55
tests/kernels/test_fused_quant_activation.py
+147
@@ -2,10 +2,20 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import pytest
4 import torch
5 +import torch.nn.functional as F
6
7 import vllm._custom_ops as ops
8 from tests.kernels.utils import opcheck
9 from vllm.model_executor.layers.activation import SiluAndMul
10 +from vllm.model_executor.layers.fusion.fused_act_quant import maybe_fused_act_quant
11 +from vllm.model_executor.layers.fusion.quant_activation import QuantizedActivation
12 +from vllm.model_executor.layers.quantization.utils.fp8_utils import (
13 + per_token_group_quant_fp8,
14 +)
15 +from vllm.model_executor.layers.quantization.utils.quant_utils import (
16 + kFp8Dynamic128Sym,
17 + kFp8StaticTensorSym,
18 +)
19 from vllm.platforms import current_platform
20 from vllm.utils.torch_utils import set_random_seed
21
@@ -69,3 +79,140 @@ def test_silu_and_mul(
79 ref_out.to(dtype=torch.float32), ops_out.to(dtype=torch.float32)
80 )
81 opcheck(torch.ops._C.silu_and_mul_quant, (ops_out, x, scale))
82 +
83 +
84 +# ---------------------------------------------------------------------------
85 +# Tests for maybe_fused_act_quant interface
86 +# ---------------------------------------------------------------------------
87 +
88 +
89 +class MockLinearFp8Static(torch.nn.Module):
90 + """Mock linear layer advertising kFp8StaticTensorSym."""
91 +
92 + def __init__(self, input_scale: torch.Tensor):
93 + super().__init__()
94 + self.input_quant_key = kFp8StaticTensorSym
95 + self.input_scale = input_scale
96 +
97 +
98 +class MockLinearFp8Dynamic128(torch.nn.Module):
99 + """Mock linear layer advertising kFp8Dynamic128Sym."""
100 +
101 + def __init__(self):
102 + super().__init__()
103 + self.input_quant_key = kFp8Dynamic128Sym
104 +
105 +
106 +class MockLinearNoQuant(torch.nn.Module):
107 + """Mock linear layer with no input_quant_key (no fusion)."""
108 +
109 + pass
110 +
111 +
112 +@pytest.mark.parametrize("num_tokens", [1, 16, 128])
113 +@pytest.mark.parametrize("hidden_size", [128, 512, 1024])
114 +@pytest.mark.parametrize("dtype", DTYPES)
115 +@torch.inference_mode()
116 +def test_maybe_fused_act_quant_fp8_static(
117 + default_vllm_config,
118 + num_tokens: int,
119 + hidden_size: int,
120 + dtype: torch.dtype,
121 +) -> None:
122 + """Test maybe_fused_act_quant with FP8 static per-tensor quantization."""
123 + device = "cuda:0"
124 + torch.set_default_device(device)
125 +
126 + act_fn = SiluAndMul()
127 + scale = torch.tensor([0.5], device=device, dtype=torch.float32)
128 + linear = MockLinearFp8Static(scale)
129 +
130 + x = torch.randn(num_tokens, hidden_size * 2, dtype=dtype, device=device)
131 + result = maybe_fused_act_quant(act_fn, x, linear)
132 +
133 + assert isinstance(result, QuantizedActivation)
134 + assert result.quant_key == kFp8StaticTensorSym
135 + assert result.data.dtype == current_platform.fp8_dtype()
136 + assert result.orig_dtype == dtype
137 + assert result.orig_shape == (num_tokens, hidden_size)
138 +
139 + ref_out = ref_impl(act_fn, x, scale)
140 + torch.testing.assert_close(result.data.to(torch.float32), ref_out.to(torch.float32))
141 +
142 +
143 +@pytest.mark.parametrize("num_tokens", [1, 16, 128])
144 +@pytest.mark.parametrize("hidden_size", [128, 512, 1024])
145 +@pytest.mark.parametrize("dtype", DTYPES)
146 +@torch.inference_mode()
147 +def test_maybe_fused_act_quant_fp8_dynamic_block(
148 + default_vllm_config,
149 + num_tokens: int,
150 + hidden_size: int,
151 + dtype: torch.dtype,
152 +) -> None:
153 + """Test maybe_fused_act_quant with FP8 dynamic per-block quantization."""
154 + group_size = 128 # We only support 128 for now
155 +
156 + device = "cuda:0"
157 + torch.set_default_device(device)
158 +
159 + act_fn = SiluAndMul()
160 + linear = MockLinearFp8Dynamic128()
161 +
162 + scale = 1 / hidden_size
163 + x = torch.randn(num_tokens, hidden_size * 2, dtype=dtype, device=device) * scale
164 + result = maybe_fused_act_quant(act_fn, x, linear)
165 +
166 + assert isinstance(result, QuantizedActivation)
167 + assert result.quant_key == kFp8Dynamic128Sym
168 + assert result.data.dtype == current_platform.fp8_dtype()
169 + assert result.orig_dtype == dtype
170 + assert result.orig_shape == (num_tokens, hidden_size)
171 +
172 + num_groups = hidden_size // group_size
173 + assert result.scale.shape == (num_tokens, num_groups)
174 +
175 + gate, up = x.split(hidden_size, dim=-1)
176 + silu_out = F.silu(gate) * up
177 + ref_out, ref_scales = per_token_group_quant_fp8(
178 + silu_out, group_size=group_size, use_ue8m0=False
179 + )
180 +
181 + torch.testing.assert_close(result.scale, ref_scales, rtol=1e-5, atol=1e-5)
182 +
183 + ref_deq = ref_out.to(torch.float32) * ref_scales.repeat_interleave(
184 + group_size, dim=1
185 + )
186 + result_deq = result.data.to(torch.float32) * result.scale.repeat_interleave(
187 + group_size, dim=1
188 + )
189 + torch.testing.assert_close(ref_deq, result_deq, atol=5e-2, rtol=5e-2)
190 +
191 +
192 +@pytest.mark.parametrize("num_tokens", [1, 16, 128])
193 +@pytest.mark.parametrize("hidden_size", [128, 512])
194 +@pytest.mark.parametrize("dtype", DTYPES)
195 +@torch.inference_mode()
196 +def test_maybe_fused_act_quant_fallback(
197 + default_vllm_config,
198 + num_tokens: int,
199 + hidden_size: int,
200 + dtype: torch.dtype,
201 +) -> None:
202 + """Test maybe_fused_act_quant falls back when no input_quant_key."""
203 + device = "cuda:0"
204 + torch.set_default_device(device)
205 +
206 + act_fn = SiluAndMul()
207 + linear = MockLinearNoQuant()
208 + x = torch.randn(num_tokens, hidden_size * 2, dtype=dtype, device=device)
209 +
210 + result = maybe_fused_act_quant(act_fn, x, linear)
211 +
212 + assert isinstance(result, torch.Tensor)
213 + assert not isinstance(result, QuantizedActivation)
214 + assert result.dtype == dtype
215 + assert result.shape == (num_tokens, hidden_size)
216 +
217 + ref_out = act_fn(x)
218 + torch.testing.assert_close(result, ref_out)
vllm/model_executor/kernels/linear/scaled_mm/pytorch.py
+10 -1
@@ -7,7 +7,11 @@ import torch
7
8 from vllm.config import CompilationMode, get_current_vllm_config
9 from vllm.model_executor.layers.quantization.input_quant_fp8 import QuantFP8
10 -from vllm.model_executor.layers.quantization.utils.quant_utils import GroupShape
10 +from vllm.model_executor.layers.quantization.utils.quant_utils import (
11 + GroupShape,
12 + QuantKey,
13 + kFp8StaticTensorSym,
14 +)
15 from vllm.platforms import current_platform
16
17 from .BlockScaledMMLinearKernel import Fp8BlockScaledMMLinearKernel
@@ -88,6 +92,11 @@ class PerTensorTorchFP8ScaledMMLinearKernel(TorchFP8ScaledMMLinearKernel):
92 return False, "requires per tensor activation and weight scales."
93 return True, None
94
95 + def input_quant_key(self) -> QuantKey | None:
96 + if self.config.activation_quant_key == kFp8StaticTensorSym:
97 + return kFp8StaticTensorSym
98 + return None
99 +
100 def apply_scaled_mm(
101 self,
102 *,
vllm/model_executor/layers/fusion/fused_act_quant.py new
+161
@@ -0,0 +1,161 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""
4 +Producer side of the QuantizedActivation contract for activation layers.
5 +
6 +Given an activation module and the downstream linear it feeds, fuse the
7 +activation with that linear's input quantization into a single kernel when the
8 +linear advertises a consumable input_quant_key (see quant_activation.py).
9 +Falls back to the plain activation when nothing matches, so a model forward can
10 +always call maybe_fused_act_quant unconditionally.
11 +
12 +This is the manual-fusion counterpart to ActivationQuantFusionPass: when fusion
13 +fires here the silu_and_mul pattern is already consumed, so the compiler pass
14 +finds nothing to rewrite and the two never double-fuse.
15 +"""
16 +
17 +from collections.abc import Callable
18 +
19 +import torch
20 +
21 +from vllm.model_executor.layers.activation import SiluAndMul
22 +from vllm.model_executor.layers.fusion.quant_activation import QuantizedActivation
23 +from vllm.model_executor.layers.linear import LinearBase
24 +from vllm.model_executor.layers.quantization.utils.quant_utils import (
25 + QuantKey,
26 + kFp8Dynamic128Sym,
27 + kFp8StaticTensorSym,
28 + kNvfp4Dynamic,
29 +)
30 +from vllm.platforms import current_platform
31 +
32 +FP8_DTYPE = current_platform.fp8_dtype()
33 +FP4_DTYPE = torch.uint8
34 +
35 +
36 +def _silu_and_mul_fp8_static(
37 + x: torch.Tensor, linear: LinearBase
38 +) -> QuantizedActivation:
39 + """SiluAndMul + FP8 static per-tensor quantization."""
40 + d = x.shape[-1] // 2
41 + out_shape = x.shape[:-1] + (d,)
42 + result = torch.empty(out_shape, dtype=FP8_DTYPE, device=x.device)
43 + # TODO(mgoin): read the consumer scale via the contract instead of reaching
44 + # into the kernel-specific input_scale attribute.
45 + scale = linear.input_scale
46 + torch.ops._C.silu_and_mul_quant(result, x, scale)
47 + return QuantizedActivation(
48 + data=result,
49 + scale=scale,
50 + orig_dtype=x.dtype,
51 + orig_shape=out_shape,
52 + quant_key=kFp8StaticTensorSym,
53 + )
54 +
55 +
56 +def _silu_and_mul_fp8_dynamic_block(
57 + x: torch.Tensor, linear: LinearBase, group_size: int, quant_key: QuantKey
58 +) -> QuantizedActivation:
59 + """SiluAndMul + FP8 dynamic per-block quantization."""
60 + assert x.ndim == 2, f"Input must be 2D [batch, hidden*2], got {x.shape}"
61 +
62 + d = x.shape[-1] // 2
63 + out_shape = x.shape[:-1] + (d,)
64 + num_tokens = x.shape[0]
65 + num_groups = d // group_size
66 +
67 + result = torch.empty((num_tokens, d), dtype=FP8_DTYPE, device=x.device)
68 + scales = torch.empty((num_tokens, num_groups), dtype=torch.float32, device=x.device)
69 +
70 + torch.ops._C.silu_and_mul_per_block_quant(
71 + out=result,
72 + input=x,
73 + scales=scales,
74 + group_size=group_size,
75 + scale_ub=None,
76 + is_scale_transposed=False,
77 + )
78 +
79 + return QuantizedActivation(
80 + data=result.view(out_shape),
81 + scale=scales.view(out_shape[:-1] + (num_groups,)),
82 + orig_dtype=x.dtype,
83 + orig_shape=out_shape,
84 + quant_key=quant_key,
85 + )
86 +
87 +
88 +def _silu_and_mul_fp8_dynamic_128(
89 + x: torch.Tensor, linear: LinearBase
90 +) -> QuantizedActivation:
91 + """SiluAndMul + FP8 dynamic per-group (group=128) quantization."""
92 + return _silu_and_mul_fp8_dynamic_block(x, linear, 128, kFp8Dynamic128Sym)
93 +
94 +
95 +def _silu_and_mul_nvfp4_dynamic(
96 + x: torch.Tensor, linear: LinearBase
97 +) -> QuantizedActivation:
98 + """SiluAndMul + NVFP4 dynamic quantization."""
99 + assert x.ndim == 2, f"Input must be 2D [batch, hidden*2], got {x.shape}"
100 +
101 + d = x.shape[-1] // 2
102 + out_shape = x.shape[:-1] + (d,)
103 + num_tokens = x.shape[0]
104 +
105 + # NVFP4 packs 2 values into 1 byte
106 + result = torch.empty((num_tokens, d // 2), dtype=FP4_DTYPE, device=x.device)
107 +
108 + # Block scale output shape: swizzled layout for tensor cores
109 + # Each group of 16 elements shares one FP8 scale
110 + num_k_tiles = (d + 63) // 64
111 + block_scale = torch.empty(
112 + (num_tokens, num_k_tiles * 4), dtype=FP8_DTYPE, device=x.device
113 + )
114 +
115 + input_global_scale = getattr(linear, "input_global_scale", None)
116 + assert input_global_scale is not None, (
117 + "input_global_scale is required for NVFP4 quantization"
118 + )
119 +
120 + torch.ops._C.silu_and_mul_nvfp4_quant(result, block_scale, x, input_global_scale)
121 +
122 + return QuantizedActivation(
123 + data=result.view(out_shape[:-1] + (d // 2,)),
124 + scale=block_scale,
125 + orig_dtype=x.dtype,
126 + orig_shape=out_shape,
127 + quant_key=kNvfp4Dynamic,
128 + )
129 +
130 +
131 +# (activation module type, consumer input_quant_key) -> fused producer.
132 +# Mirrors ActivationQuantFusionPass.FUSED_OPS; add a row to migrate a scheme.
133 +_FUSED_ACT_QUANT: dict[tuple[type, QuantKey], Callable] = {
134 + (SiluAndMul, kFp8StaticTensorSym): _silu_and_mul_fp8_static,
135 +}
136 +
137 +# Add CUDA-specific entries for dynamic block quantization
138 +if current_platform.is_cuda_alike():
139 + _FUSED_ACT_QUANT[(SiluAndMul, kFp8Dynamic128Sym)] = _silu_and_mul_fp8_dynamic_128
140 +
141 +# Add NVFP4 if supported (requires SM100+)
142 +if current_platform.is_cuda() and hasattr(torch.ops._C, "silu_and_mul_nvfp4_quant"):
143 + _FUSED_ACT_QUANT[(SiluAndMul, kNvfp4Dynamic)] = _silu_and_mul_nvfp4_dynamic
144 +
145 +
146 +def maybe_fused_act_quant(
147 + act_fn: torch.nn.Module,
148 + x: torch.Tensor,
149 + linear: LinearBase,
150 +) -> "torch.Tensor | QuantizedActivation":
151 + """Apply act_fn, fusing the downstream linear's input quant when possible.
152 +
153 + Returns a QuantizedActivation when a fused kernel matches
154 + (act_fn, linear.input_quant_key), else the plain activated tensor.
155 + """
156 + key = getattr(linear, "input_quant_key", None)
157 + if key is not None:
158 + producer = _FUSED_ACT_QUANT.get((type(act_fn), key))
159 + if producer is not None:
160 + return producer(x, linear)
161 + return act_fn(x)
vllm/model_executor/models/llama.py
+2 -1
@@ -39,6 +39,7 @@ from vllm.model_executor.layers.attention import (
39 Attention,
40 EncoderOnlyAttention,
41 )
42 +from vllm.model_executor.layers.fusion.fused_act_quant import maybe_fused_act_quant
43 from vllm.model_executor.layers.layernorm import RMSNorm
44 from vllm.model_executor.layers.linear import (
45 MergedColumnParallelLinear,
@@ -114,7 +115,7 @@ class LlamaMLP(nn.Module):
115
116 def forward(self, x):
117 x, _ = self.gate_up_proj(x)
117 - x = self.act_fn(x)
118 + x = maybe_fused_act_quant(self.act_fn, x, self.down_proj)
119 x, _ = self.down_proj(x)
120 return x
121