[Perf][Kernel][Quantization] Fuse ReLU2 with static FP8 activation quantization (#53793)
Signed-off-by: mgoin <mgoin64@gmail.com> Signed-off-by: ElizaWszola <ewszola@redhat.com> Signed-off-by: snordmann <snordmann@nvidia.com> Co-authored-by: mgoin <mgoin64@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: ElizaWszola <ewszola@redhat.com> Co-authored-by: OpenAI Codex <noreply@openai.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Samuel Nordmann committed
Sep 14, 2026 at 19:26 UTC
03dc26e639e1927cda51d7b62639a11a354e1c0d
12 files changed
+459
-32
tests/compile/passes/test_silu_mul_quant_manual_fusion.py
+6
-5
@@ -34,6 +34,7 @@ from vllm.model_executor.layers.fusion.fused_act_quant import (
34
from vllm.model_executor.layers.fusion.quant_activation import (
35
QuantizedActivation,
36
expose_input_quant_key,
37
+ get_input_quant_key,
38
)
39
from vllm.model_executor.layers.quantization.utils.quant_utils import (
40
kFp8Dynamic128Sym,
@@ -45,7 +46,7 @@ from vllm.platforms import current_platform
46
47
# Mock linear layer for testing fusion paths that don't have real kernel support
48
class MockLinearForFusion(torch.nn.Module):
48
- """Mock linear layer that exposes input_quant_key for fusion testing."""
49
+ """Mock linear layer that exposes an input key for fusion testing."""
50
51
def __init__(
52
self,
@@ -55,7 +56,7 @@ class MockLinearForFusion(torch.nn.Module):
56
input_global_scale_inv=None,
57
):
58
super().__init__()
58
- self.input_quant_key = quant_key
59
+ self._input_quant_key = quant_key
60
if input_scale is not None:
61
self.input_scale = input_scale
62
if input_global_scale is not None:
@@ -118,7 +119,7 @@ def test_manual_fusion_fp8_static_with_linear(
119
# Enable fusion
120
expose_input_quant_key(fp8_linear, fp8_linear.kernel)
121
121
- if not hasattr(fp8_linear, "input_quant_key"):
122
+ if get_input_quant_key(fp8_linear) is None:
123
pytest.skip(
124
f"Kernel {force_kernel.__name__} doesn't support input_quant_key"
125
)
@@ -315,7 +316,7 @@ def test_manual_fusion_nvfp4_dynamic(
316
envs.VLLM_TARGET_DEVICE not in ["cuda", "rocm"], reason="Only test on CUDA and ROCm"
317
)
318
def test_manual_fusion_fallback_no_key(dtype: torch.dtype):
318
- """Test that maybe_fused_act_quant falls back when no input_quant_key."""
319
+ """Test that maybe_fused_act_quant falls back without an input key."""
320
torch.set_default_device("cuda")
321
torch.set_default_dtype(dtype)
322
@@ -327,7 +328,7 @@ def test_manual_fusion_fallback_no_key(dtype: torch.dtype):
328
329
with set_current_vllm_config(config):
330
silu_and_mul = SiluAndMul()
330
- # Linear without input_quant_key attribute
331
+ # Linear without an input quantization key
332
mock_linear = torch.nn.Linear(128, 128)
333
334
result = maybe_fused_act_quant(silu_and_mul, x, mock_linear)
tests/fusion/test_quant_activation_contract.py
+3
-2
@@ -42,6 +42,7 @@ from vllm.model_executor.layers.fusion.quant_activation import (
42
QuantizedActivation,
43
as_quantized_activation,
44
expose_input_quant_key,
45
+ get_input_quant_key,
46
)
47
from vllm.model_executor.layers.quantization.utils.quant_utils import (
48
kFp8StaticTensorSym,
@@ -118,13 +119,13 @@ def test_bridge_marks_supporting_and_skips_others():
119
supported = _probe(FlashInferCutlassNvFp4LinearKernel)
120
layer = torch.nn.Module()
121
expose_input_quant_key(layer, supported)
121
- assert layer.input_quant_key == kNvfp4Dynamic
122
+ assert get_input_quant_key(layer) == kNvfp4Dynamic
123
124
unsupported = _probe(FlashInferTrtllmNvFp4LinearKernel)
125
assert unsupported.input_quant_key() is None
126
layer = torch.nn.Module()
127
expose_input_quant_key(layer, unsupported)
127
- assert not hasattr(layer, "input_quant_key")
128
+ assert get_input_quant_key(layer) is None
129
130
131
def test_as_quantized_activation_validates_key():
tests/kernels/test_fused_quant_activation.py
+26
-4
@@ -91,7 +91,7 @@ class MockLinearFp8Static(torch.nn.Module):
91
92
def __init__(self, input_scale: torch.Tensor):
93
super().__init__()
94
- self.input_quant_key = kFp8StaticTensorSym
94
+ self._input_quant_key = kFp8StaticTensorSym
95
self.input_scale = input_scale
96
97
@@ -100,15 +100,21 @@ class MockLinearFp8Dynamic128(torch.nn.Module):
100
101
def __init__(self):
102
super().__init__()
103
- self.input_quant_key = kFp8Dynamic128Sym
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)."""
107
+ """Mock linear layer with no input quantization key."""
108
109
pass
110
111
112
+class MockLinearRequiresUnquantized(MockLinearFp8Static):
113
+ """Mock consumer that needs the original activation for another branch."""
114
+
115
+ requires_unquantized_input = True
116
+
117
+
118
@pytest.mark.parametrize("num_tokens", [1, 16, 128])
119
@pytest.mark.parametrize("hidden_size", [128, 512, 1024])
120
@pytest.mark.parametrize("dtype", DTYPES)
@@ -199,7 +205,7 @@ def test_maybe_fused_act_quant_fallback(
205
hidden_size: int,
206
dtype: torch.dtype,
207
) -> None:
202
- """Test maybe_fused_act_quant falls back when no input_quant_key."""
208
+ """Test maybe_fused_act_quant falls back without an input quantization key."""
209
device = "cuda:0"
210
torch.set_default_device(device)
211
@@ -216,3 +222,19 @@ def test_maybe_fused_act_quant_fallback(
222
223
ref_out = act_fn(x)
224
torch.testing.assert_close(result, ref_out)
225
+
226
+
227
+@torch.inference_mode()
228
+def test_maybe_fused_act_quant_preserves_required_unquantized_input(
229
+ default_vllm_config,
230
+) -> None:
231
+ device = "cuda:0"
232
+ act_fn = SiluAndMul()
233
+ scale = torch.tensor([0.5], device=device, dtype=torch.float32)
234
+ linear = MockLinearRequiresUnquantized(scale)
235
+ x = torch.randn(17, 256, dtype=torch.bfloat16, device=device)
236
+
237
+ result = maybe_fused_act_quant(act_fn, x, linear)
238
+
239
+ assert isinstance(result, torch.Tensor)
240
+ torch.testing.assert_close(result, act_fn(x))
tests/kernels/test_relu2_fp8_quant.py
new
+229
@@ -0,0 +1,229 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import pytest
5
+import torch
6
+
7
+from vllm.config import CompilationConfig, VllmConfig, set_current_vllm_config
8
+from vllm.config.vllm import OptimizationLevel
9
+from vllm.model_executor.layers.activation import ReLUSquaredActivation
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.input_quant_fp8 import QuantFP8
13
+from vllm.model_executor.layers.quantization.utils.quant_utils import (
14
+ GroupShape,
15
+ kFp8StaticTensorSym,
16
+)
17
+from vllm.platforms import current_platform
18
+
19
+requires_sm90 = pytest.mark.skipif(
20
+ not (current_platform.is_cuda() and current_platform.has_device_capability(90)),
21
+ reason="This test requires SM90 or newer",
22
+)
23
+
24
+
25
+class _StaticFp8Linear(torch.nn.Module):
26
+ def __init__(self, input_scale: torch.Tensor) -> None:
27
+ super().__init__()
28
+ self._input_quant_key = kFp8StaticTensorSym
29
+ self.input_scale = input_scale
30
+
31
+
32
+def _assert_fp8_bitwise_equal(actual: torch.Tensor, expected: torch.Tensor) -> None:
33
+ assert actual.dtype == expected.dtype == current_platform.fp8_dtype()
34
+ torch.testing.assert_close(
35
+ actual.view(torch.uint8), expected.view(torch.uint8), rtol=0, atol=0
36
+ )
37
+
38
+
39
+def _assert_quantized_activation(
40
+ result: QuantizedActivation, x: torch.Tensor, scale: torch.Tensor
41
+) -> None:
42
+ assert result.scale is scale
43
+ assert result.orig_dtype == x.dtype
44
+ assert result.orig_shape == x.shape
45
+ assert result.quant_key == kFp8StaticTensorSym
46
+
47
+
48
+@pytest.fixture(scope="module")
49
+def o2_relu2_fp8_ops():
50
+ config = VllmConfig(
51
+ optimization_level=OptimizationLevel.O2,
52
+ compilation_config=CompilationConfig(custom_ops=["none"]),
53
+ )
54
+ with set_current_vllm_config(config):
55
+ yield (
56
+ ReLUSquaredActivation(),
57
+ QuantFP8(static=True, group_shape=GroupShape.PER_TENSOR),
58
+ )
59
+
60
+
61
+@requires_sm90
62
+@pytest.mark.parametrize("shape", [(1, 1), (1, 16), (17, 5120), (128, 10240)])
63
+@torch.inference_mode()
64
+def test_relu2_static_fp8_quant(o2_relu2_fp8_ops, shape) -> None:
65
+ torch.manual_seed(0)
66
+ x = torch.randn(shape, device="cuda", dtype=torch.bfloat16)
67
+ scale = torch.tensor(0.1495535671710968, device="cuda", dtype=torch.float32)
68
+
69
+ relu2, quant_fp8 = o2_relu2_fp8_ops
70
+ expected, _ = quant_fp8(relu2(x), scale)
71
+ result = maybe_fused_act_quant(relu2, x, _StaticFp8Linear(scale))
72
+
73
+ assert isinstance(result, QuantizedActivation)
74
+ _assert_quantized_activation(result, x, scale)
75
+ _assert_fp8_bitwise_equal(result.data, expected)
76
+
77
+
78
+@requires_sm90
79
+@pytest.mark.parametrize(
80
+ "enabled_ops",
81
+ [(), ("relu2",), ("quant_fp8",), ("relu2", "quant_fp8")],
82
+)
83
+@torch.inference_mode()
84
+def test_relu2_static_fp8_quant_ignores_custom_op_dispatch(enabled_ops) -> None:
85
+ custom_ops = ["none", *(f"+{op}" for op in enabled_ops)]
86
+ config = VllmConfig(
87
+ optimization_level=OptimizationLevel.O2,
88
+ compilation_config=CompilationConfig(custom_ops=custom_ops),
89
+ )
90
+ with set_current_vllm_config(config):
91
+ torch.manual_seed(0)
92
+ relu2 = ReLUSquaredActivation()
93
+ quant_fp8 = QuantFP8(static=True, group_shape=GroupShape.PER_TENSOR)
94
+ x = torch.randn((17, 5120), device="cuda", dtype=torch.bfloat16)
95
+ scale = torch.tensor(0.03125, device="cuda", dtype=torch.float32)
96
+
97
+ expected, _ = quant_fp8(relu2(x), scale)
98
+ result = maybe_fused_act_quant(relu2, x, _StaticFp8Linear(scale))
99
+
100
+ assert isinstance(result, QuantizedActivation)
101
+ _assert_quantized_activation(result, x, scale)
102
+ _assert_fp8_bitwise_equal(result.data, expected)
103
+
104
+
105
+@requires_sm90
106
+@torch.inference_mode()
107
+def test_relu2_static_fp8_quant_without_compilation() -> None:
108
+ config = VllmConfig(optimization_level=OptimizationLevel.O0)
109
+ with set_current_vllm_config(config):
110
+ torch.manual_seed(0)
111
+ relu2 = ReLUSquaredActivation()
112
+ quant_fp8 = QuantFP8(static=True, group_shape=GroupShape.PER_TENSOR)
113
+ x = torch.randn((17, 5120), device="cuda", dtype=torch.bfloat16)
114
+ scale = torch.tensor(0.03125, device="cuda", dtype=torch.float32)
115
+
116
+ expected, _ = quant_fp8(relu2(x), scale)
117
+ result = maybe_fused_act_quant(relu2, x, _StaticFp8Linear(scale))
118
+
119
+ assert isinstance(result, QuantizedActivation)
120
+ _assert_quantized_activation(result, x, scale)
121
+ _assert_fp8_bitwise_equal(result.data, expected)
122
+
123
+
124
+@requires_sm90
125
+@pytest.mark.parametrize(
126
+ "scale_value",
127
+ [
128
+ 0.00435965,
129
+ 0.007149832788854837,
130
+ 0.03125,
131
+ 0.0714285746216774,
132
+ 0.1495535671710968,
133
+ 0.1517857164144516,
134
+ 6.03571415,
135
+ ],
136
+)
137
+@torch.inference_mode()
138
+def test_relu2_static_fp8_quant_all_bf16_values(o2_relu2_fp8_ops, scale_value) -> None:
139
+ bits = torch.arange(1 << 16, dtype=torch.int32).to(torch.uint16)
140
+ x = bits.view(torch.bfloat16).reshape(1, -1).to("cuda")
141
+ scale = torch.tensor(scale_value, device="cuda", dtype=torch.float32)
142
+
143
+ relu2, quant_fp8 = o2_relu2_fp8_ops
144
+ expected, _ = quant_fp8(relu2(x), scale)
145
+ result = maybe_fused_act_quant(relu2, x, _StaticFp8Linear(scale))
146
+
147
+ assert isinstance(result, QuantizedActivation)
148
+ _assert_quantized_activation(result, x, scale)
149
+ _assert_fp8_bitwise_equal(result.data, expected)
150
+
151
+
152
+@requires_sm90
153
+@torch.inference_mode()
154
+def test_relu2_static_fp8_quant_empty(o2_relu2_fp8_ops) -> None:
155
+ x = torch.empty(0, device="cuda", dtype=torch.bfloat16)
156
+ scale = torch.tensor(0.03125, device="cuda", dtype=torch.float32)
157
+
158
+ relu2, _ = o2_relu2_fp8_ops
159
+ result = maybe_fused_act_quant(relu2, x, _StaticFp8Linear(scale))
160
+
161
+ assert isinstance(result, QuantizedActivation)
162
+ _assert_quantized_activation(result, x, scale)
163
+ assert result.data.shape == x.shape
164
+ assert result.data.dtype == current_platform.fp8_dtype()
165
+
166
+
167
+@requires_sm90
168
+@pytest.mark.parametrize("unsupported", ["dtype", "layout", "scale"])
169
+@torch.inference_mode()
170
+def test_relu2_static_fp8_quant_falls_back(o2_relu2_fp8_ops, unsupported: str) -> None:
171
+ x = torch.randn((17, 32), device="cuda", dtype=torch.bfloat16)
172
+ scale = torch.tensor(0.03125, device="cuda", dtype=torch.float32)
173
+ if unsupported == "dtype":
174
+ x = x.to(torch.float16)
175
+ elif unsupported == "layout":
176
+ x = x.T
177
+ else:
178
+ scale = scale.to(torch.float16)
179
+
180
+ relu2, _ = o2_relu2_fp8_ops
181
+ expected = relu2(x)
182
+ result = maybe_fused_act_quant(relu2, x, _StaticFp8Linear(scale))
183
+
184
+ assert isinstance(result, torch.Tensor)
185
+ assert not isinstance(result, QuantizedActivation)
186
+ torch.testing.assert_close(result, expected, rtol=0, atol=0)
187
+
188
+
189
+@requires_sm90
190
+@torch.inference_mode()
191
+def test_relu2_static_fp8_quant_torch_compile_dynamic(
192
+ o2_relu2_fp8_ops,
193
+) -> None:
194
+ torch.manual_seed(1)
195
+ scale = torch.tensor(0.00435965, device="cuda", dtype=torch.float32)
196
+ relu2, quant_fp8 = o2_relu2_fp8_ops
197
+ linear = _StaticFp8Linear(scale)
198
+
199
+ def fused(x: torch.Tensor) -> torch.Tensor:
200
+ result = maybe_fused_act_quant(relu2, x, linear)
201
+ assert isinstance(result, QuantizedActivation)
202
+ return result.data
203
+
204
+ compile_count = 0
205
+
206
+ def counting_inductor_backend(gm, example_inputs):
207
+ nonlocal compile_count
208
+ compile_count += 1
209
+ return torch._inductor.compile(gm, example_inputs)
210
+
211
+ torch._dynamo.reset()
212
+ try:
213
+ x = torch.randn((17, 5120), device="cuda", dtype=torch.bfloat16)
214
+ torch._dynamo.mark_dynamic(x, 0)
215
+ compiled_fused = torch.compile(
216
+ fused, backend=counting_inductor_backend, fullgraph=True
217
+ )
218
+
219
+ for candidate in (
220
+ x,
221
+ torch.randn((31, 5120), device="cuda", dtype=torch.bfloat16),
222
+ torch.randn((63, 5120), device="cuda", dtype=torch.bfloat16),
223
+ ):
224
+ expected, _ = quant_fp8(relu2(candidate), scale)
225
+ _assert_fp8_bitwise_equal(compiled_fused(candidate), expected)
226
+
227
+ assert compile_count == 1
228
+ finally:
229
+ torch._dynamo.reset()
tests/lora/test_layers.py
+10
@@ -31,6 +31,9 @@ from vllm.lora.layers import (
31
)
32
from vllm.lora.lora_weights import LoRALayerWeights, PackedLoRALayerWeights
33
from vllm.lora.punica_wrapper import get_punica_wrapper
34
+from vllm.model_executor.layers.fusion.quant_activation import (
35
+ get_input_quant_key,
36
+)
37
from vllm.model_executor.layers.linear import (
38
ColumnParallelLinear,
39
MergedColumnParallelLinear,
@@ -56,6 +59,13 @@ TOLERANCES = {
59
torch.bfloat16: (3e-2, 2e-2),
60
}
61
62
+
63
+def test_lora_linear_requires_unquantized_input() -> None:
64
+ layer = RowParallelLinearWithLoRA.__new__(RowParallelLinearWithLoRA)
65
+ layer._input_quant_key = object()
66
+ assert get_input_quant_key(layer) is None
67
+
68
+
69
pytestmark = [
70
pytest.mark.skipif(
71
not (
tests/model_executor/test_nemotron_h_quantization.py
+27
@@ -3,6 +3,8 @@
3
4
from unittest.mock import Mock, patch
5
6
+import torch
7
+
8
9
def test_nemotron_h_lm_head_receives_quant_config():
10
from vllm.model_executor.models.nemotron_h import NemotronHForCausalLM
@@ -32,3 +34,28 @@ def test_nemotron_h_lm_head_receives_quant_config():
34
MockLMHead.assert_called_once()
35
call_kwargs = MockLMHead.call_args.kwargs
36
assert call_kwargs["quant_config"] is mock_quant_config
37
+
38
+
39
+def test_relu2_fp8_fusion_uses_registry():
40
+ from vllm.model_executor.models.nemotron_h import NemotronHMLP
41
+
42
+ projected = torch.empty((1, 1), dtype=torch.bfloat16)
43
+ fused = Mock()
44
+ act_fn = Mock()
45
+ down_proj = Mock(side_effect=lambda x: (x, None))
46
+
47
+ mlp = NemotronHMLP.__new__(NemotronHMLP)
48
+ torch.nn.Module.__init__(mlp)
49
+ mlp.up_proj = Mock(return_value=(projected, None))
50
+ mlp.down_proj = down_proj
51
+ mlp.act_fn = act_fn
52
+
53
+ with patch(
54
+ "vllm.model_executor.models.nemotron_h.maybe_fused_act_quant",
55
+ return_value=fused,
56
+ ) as maybe_fused:
57
+ result = mlp(Mock())
58
+
59
+ maybe_fused.assert_called_once_with(act_fn, projected, down_proj)
60
+ act_fn.assert_not_called()
61
+ assert result is fused
vllm/lora/layers/base_linear.py
+4
@@ -68,6 +68,10 @@ if envs.VLLM_LORA_ENABLE_DUAL_STREAM:
68
69
70
class BaseLinearLayerWithLoRA(BaseLayerWithLoRA):
71
+ # The adapter branch consumes the original activation, independently of
72
+ # whether the wrapped base layer can consume a pre-quantized activation.
73
+ requires_unquantized_input = True
74
+
75
def __init__(self, base_layer: LinearBase):
76
super().__init__()
77
vllm/model_executor/layers/fusion/fused_act_quant.py
+54
-12
@@ -5,21 +5,27 @@ 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).
8
+linear advertises a consumable input quantization 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.
13
+fires here, the activation and quantization are already consumed, so a compiler
14
+pass cannot fuse the same boundary again.
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
21
+from vllm.model_executor.layers.activation import ReLUSquaredActivation, SiluAndMul
22
+from vllm.model_executor.layers.fusion.quant_activation import (
23
+ QuantizedActivation,
24
+ get_input_quant_key,
25
+)
26
+from vllm.model_executor.layers.fusion.relu2_fp8_quant import (
27
+ relu_squared_static_fp8_quant,
28
+)
29
from vllm.model_executor.layers.linear import LinearBase
30
from vllm.model_executor.layers.quantization.utils.quant_utils import (
31
QuantKey,
@@ -139,12 +145,46 @@ def _silu_and_mul_nvfp4_dynamic(
145
)
146
147
142
-# (activation module type, consumer input_quant_key) -> fused producer.
143
-# Mirrors ActivationQuantFusionPass.FUSED_OPS; add a row to migrate a scheme.
148
+# (activation module type, consumer input quantization key) -> fused producer.
149
+# Add a row for each supported manual producer.
150
_FUSED_ACT_QUANT: dict[tuple[type, QuantKey], Callable] = {
151
(SiluAndMul, kFp8StaticTensorSym): _silu_and_mul_fp8_static,
152
}
153
154
+
155
+def _relu_squared_static_fp8_quant_supported(
156
+ act_fn: torch.nn.Module,
157
+ x: torch.Tensor,
158
+ linear: LinearBase,
159
+) -> bool:
160
+ """Return whether the ReLU2 static-FP8 producer can consume this input."""
161
+ scale = getattr(linear, "input_scale", None)
162
+ return (
163
+ isinstance(act_fn, ReLUSquaredActivation)
164
+ and x.is_cuda
165
+ and x.dtype == torch.bfloat16
166
+ and x.is_contiguous()
167
+ and isinstance(scale, torch.Tensor)
168
+ and scale.dtype == torch.float32
169
+ and scale.device == x.device
170
+ and scale.numel() == 1
171
+ )
172
+
173
+
174
+# Optional per-entry predicates preserve the unconditional fallback contract
175
+# for producers whose supported inputs are narrower than their registry key.
176
+_FUSED_ACT_QUANT_SUPPORT: dict[
177
+ tuple[type, QuantKey], Callable[[torch.nn.Module, torch.Tensor, LinearBase], bool]
178
+] = {}
179
+
180
+# This path is validated on SM90 and newer CUDA devices.
181
+if current_platform.is_cuda() and current_platform.has_device_capability(90):
182
+ _RELU2_STATIC_FP8_KEY = (ReLUSquaredActivation, kFp8StaticTensorSym)
183
+ _FUSED_ACT_QUANT[_RELU2_STATIC_FP8_KEY] = relu_squared_static_fp8_quant
184
+ _FUSED_ACT_QUANT_SUPPORT[_RELU2_STATIC_FP8_KEY] = (
185
+ _relu_squared_static_fp8_quant_supported
186
+ )
187
+
188
# Add CUDA-specific entries for dynamic block quantization
189
if current_platform.is_cuda_alike():
190
_FUSED_ACT_QUANT[(SiluAndMul, kFp8Dynamic128Sym)] = _silu_and_mul_fp8_dynamic_128
@@ -161,12 +201,14 @@ def maybe_fused_act_quant(
201
) -> "torch.Tensor | QuantizedActivation":
202
"""Apply act_fn, fusing the downstream linear's input quant when possible.
203
164
- Returns a QuantizedActivation when a fused kernel matches
165
- (act_fn, linear.input_quant_key), else the plain activated tensor.
204
+ Returns a QuantizedActivation when a fused kernel matches the activation and
205
+ the consumer's effective input quantization key, else the plain activation.
206
"""
167
- key = getattr(linear, "input_quant_key", None)
207
+ key = get_input_quant_key(linear)
208
if key is not None:
169
- producer = _FUSED_ACT_QUANT.get((type(act_fn), key))
170
- if producer is not None:
209
+ registry_key = (type(act_fn), key)
210
+ producer = _FUSED_ACT_QUANT.get(registry_key)
211
+ support = _FUSED_ACT_QUANT_SUPPORT.get(registry_key)
212
+ if producer is not None and (support is None or support(act_fn, x, linear)):
213
return producer(x, linear)
214
return act_fn(x)
vllm/model_executor/layers/fusion/quant_activation.py
+15
-7
@@ -5,7 +5,9 @@ A QuantizedActivation is a pre-quantized activation produced by a fused kernel
5
and consumed directly by a linear layer, letting the layer skip its own input
6
quantization. A linear advertises the key its kernel can consume via
7
expose_input_quant_key; the kernel validates and reads the activation via
8
-as_quantized_activation.
8
+as_quantized_activation. Producers query the effective capability through
9
+get_input_quant_key, which hides the key when another consumer branch needs the
10
+original activation.
11
"""
12
13
from dataclasses import dataclass, replace
@@ -46,12 +48,11 @@ class QuantizedActivation:
48
49
50
def expose_input_quant_key(layer: torch.nn.Module, kernel) -> None:
49
- """Advertise the kernel's pre-quantized input key on the layer, if any.
51
+ """Store the kernel's pre-quantized input key on the layer, if any.
52
51
- This is the bridge from a kernel's input_quant_key() to the
52
- layer.input_quant_key attribute that fusion call sites read. The attribute
53
- is left unset when the kernel quantizes its own input, so non-supporting
54
- backends never receive a QuantizedActivation.
53
+ This is the bridge from a kernel's input_quant_key() to the layer capability
54
+ that fusion call sites read through get_input_quant_key. The raw key is left
55
+ unset when the kernel quantizes its own input.
56
57
TODO(mgoin): Producers also need the consumer's quantization scales (e.g.
58
static input scale, global scale). Expose those here as well so producers
@@ -59,7 +60,14 @@ def expose_input_quant_key(layer: torch.nn.Module, kernel) -> None:
60
"""
61
key = kernel.input_quant_key()
62
if key is not None:
62
- layer.input_quant_key = key
63
+ layer._input_quant_key = key
64
+
65
+
66
+def get_input_quant_key(layer: torch.nn.Module) -> QuantKey | None:
67
+ """Return the pre-quantized input key the complete layer can consume."""
68
+ if getattr(layer, "requires_unquantized_input", False):
69
+ return None
70
+ return getattr(layer, "_input_quant_key", None)
71
72
73
def as_quantized_activation(
vllm/model_executor/layers/fusion/relu2_fp8_quant.py
new
+76
@@ -0,0 +1,76 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import torch
5
+
6
+from vllm.model_executor.layers.fusion.quant_activation import QuantizedActivation
7
+from vllm.model_executor.layers.linear import LinearBase
8
+from vllm.model_executor.layers.quantization.utils.quant_utils import (
9
+ get_fp8_min_max,
10
+ kFp8StaticTensorSym,
11
+)
12
+from vllm.platforms import current_platform
13
+from vllm.triton_utils import tl, triton
14
+
15
+_FP8_MIN, _FP8_MAX = get_fp8_min_max()
16
+
17
+
18
+@triton.jit
19
+def _relu_squared_static_fp8_quant_kernel(
20
+ x_ptr,
21
+ scale_ptr,
22
+ output_ptr,
23
+ num_elements,
24
+ FP8_MIN: tl.constexpr,
25
+ FP8_MAX: tl.constexpr,
26
+ BLOCK_SIZE: tl.constexpr,
27
+):
28
+ offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
29
+ mask = offsets < num_elements
30
+ x = tl.load(x_ptr + offsets, mask=mask, other=0.0).to(tl.float32)
31
+ input_is_nan = x != x
32
+ relu = tl.maximum(x, 0.0)
33
+ # Preserve the standalone ReLU2 kernel's BF16 output boundary.
34
+ activated = (relu * relu).to(tl.bfloat16).to(tl.float32)
35
+ # Match O2's Inductor-compiled QuantFP8 reciprocal-then-multiply.
36
+ quantized = activated * (1.0 / tl.load(scale_ptr))
37
+ quantized = tl.maximum(tl.minimum(quantized, FP8_MAX), FP8_MIN)
38
+ # Match the O2 native chain by preserving NaN through the FP8 conversion.
39
+ quantized = tl.where(input_is_nan, x, quantized)
40
+ tl.store(output_ptr + offsets, quantized, mask=mask)
41
+
42
+
43
+def relu_squared_static_fp8_quant(
44
+ x: torch.Tensor, linear: LinearBase
45
+) -> QuantizedActivation:
46
+ """BF16 ReLU2 followed by static per-tensor FP8 quantization."""
47
+ assert x.dtype == torch.bfloat16
48
+ assert x.is_contiguous()
49
+ scale = linear.input_scale
50
+ assert scale.dtype == torch.float32
51
+ assert x.device == scale.device
52
+ assert scale.numel() == 1
53
+
54
+ output = torch.empty_like(x, dtype=current_platform.fp8_dtype())
55
+ if x.numel() != 0:
56
+ block_size = min(triton.next_power_of_2(x.shape[-1]), 2048)
57
+ num_warps = min(max(block_size // 256, 1), 4)
58
+ grid = lambda meta: (triton.cdiv(x.numel(), meta["BLOCK_SIZE"]),)
59
+ _relu_squared_static_fp8_quant_kernel[grid](
60
+ x,
61
+ scale,
62
+ output,
63
+ x.numel(),
64
+ FP8_MIN=_FP8_MIN,
65
+ FP8_MAX=_FP8_MAX,
66
+ BLOCK_SIZE=block_size,
67
+ num_warps=num_warps,
68
+ )
69
+
70
+ return QuantizedActivation(
71
+ data=output,
72
+ scale=scale,
73
+ orig_dtype=x.dtype,
74
+ orig_shape=x.shape,
75
+ quant_key=kFp8StaticTensorSym,
76
+ )
vllm/model_executor/layers/linear.py
+2
@@ -252,6 +252,8 @@ class LinearBase(PluggableLayer):
252
disable_tp: If true, tensor parallelism will be disabled for this layer.
253
"""
254
255
+ requires_unquantized_input = False
256
+
257
def __init__(
258
self,
259
input_size: int,
vllm/model_executor/models/nemotron_h.py
+7
-2
@@ -25,7 +25,11 @@ import torch
25
from torch import nn
26
27
from vllm.compilation.decorators import support_torch_compile
28
-from vllm.config import CacheConfig, ModelConfig, VllmConfig
28
+from vllm.config import (
29
+ CacheConfig,
30
+ ModelConfig,
31
+ VllmConfig,
32
+)
33
from vllm.config.parallel import ParallelConfig
34
from vllm.distributed import get_ep_group, get_tensor_model_parallel_world_size
35
from vllm.distributed.communication_op import tensor_model_parallel_all_gather
@@ -38,6 +42,7 @@ from vllm.model_executor.layers.fused_moe import (
42
activation_without_mul,
43
fused_moe_make_expert_params_mapping,
44
)
45
+from vllm.model_executor.layers.fusion.fused_act_quant import maybe_fused_act_quant
46
from vllm.model_executor.layers.layernorm import RMSNorm
47
from vllm.model_executor.layers.linear import (
48
ColumnParallelLinear,
@@ -118,7 +123,7 @@ class NemotronHMLP(nn.Module):
123
124
def forward(self, x: torch.Tensor):
125
x, _ = self.up_proj(x)
121
- x = self.act_fn(x)
126
+ x = maybe_fused_act_quant(self.act_fn, x, self.down_proj)
127
x, _ = self.down_proj(x)
128
return x
129