[Online quantization] Add online MXFP4 quantization support (#49347)

Signed-off-by: Felix Marty <Felix.Marty@amd.com>

fxmarty-amd committed Aug 7, 2026 at 21:39 UTC 3518110f2f4532c4605d8b1ea2a9bdc7d61b9f04
18 files changed +1423 -35
docs/features/quantization/online.md
+15
@@ -20,6 +20,16 @@ llm = LLM("meta-llama/Llama-3.1-8B", quantization="fp8_per_block")
20
21 # MXFP8 quantization for weights and activations
22 llm = LLM("meta-llama/Llama-3.1-8B", quantization="mxfp8")
23 +
24 +# MXFP4 weight; activation quantization depends on the `linear_backend` picked
25 +llm = LLM("meta-llama/Llama-3.1-8B", quantization="mxfp4")
26 +
27 +# MXFP4 MOE-only weight and activation quantization
28 +llm = LLM(
29 + "Qwen/Qwen3.5-35B-A3B",
30 + quantization="mxfp4",
31 + quantization_config={"linear": {"activation": None, "weight": None}}
32 +)
33 ```
34
35 Or with the CLI:
@@ -28,6 +38,10 @@ Or with the CLI:
38 vllm serve meta-llama/Llama-3.1-8B --quantization fp8_per_tensor
39 vllm serve meta-llama/Llama-3.1-8B --quantization fp8_per_block
40 vllm serve meta-llama/Llama-3.1-8B --quantization mxfp8
41 +vllm serve meta-llama/Llama-3.1-8B --quantization mxfp4
42 +
43 +vllm serve Qwen/Qwen3.5-35B-A3B --quantization mxfp4 \
44 + --quantization-config '{"linear":{"activation":null,"weight":null}}'
45 ```
46
47 ## Supported Schemes
@@ -37,6 +51,7 @@ vllm serve meta-llama/Llama-3.1-8B --quantization mxfp8
51 | `fp8_per_tensor` | fp8_e4m3 data, fp32 per-tensor scale | fp8_e4m3 data, fp32 per-tensor scale | On some GPUs (Ada, Hopper) linear activations use per-token scaling for better performance |
52 | `fp8_per_block` | fp8_e4m3 data, fp32 per-128x128-block scale | fp8_e4m3 data, fp32 per-1x128-block scale | |
53 | `mxfp8` | fp8_e4m3 data, e8m0 per-1x32-block scale | fp8_e4m3 data, e8m0 per-1x32-block scale | Requires SM 100+ (Blackwell or newer) for w8a8, other GPUs use a w8a16 fallback |
54 +| `mxfp4` | fp4_e2m1 data, e8m0 per-1x32-block scale ([OCP MX specs](https://www.opencompute.org/documents/ocp-microscaling-formats-mx-v1-0-spec-final-pdf)) | - linear: fp4_e2m1 data, e8m0 per-1x32-block scale in some backends, or BF16. <br> - MOE: fp4_e2m1 data, e8m0 per-1x32-block scale. | Linear MXFP4 backend is auto-selected per platform, not enforcing activation dtype. Some use BF16 activation. Use `--linear-backend` to pin one (e.g. `--linear-backend flashinfer`). |
55
56 ## Advanced Configuration
57
tests/evals/gsm8k/configs/Qwen3-30B-A3B-MXFP4-AITER-TP2-online.yaml new
+15
@@ -0,0 +1,15 @@
1 +model_name: "Qwen/Qwen3-30B-A3B"
2 +accuracy_threshold: 0.89
3 +tolerance: 0.03
4 +num_questions: 1319
5 +num_fewshot: 5
6 +server_args: >-
7 + --max-model-len 4096
8 + --tensor-parallel-size 2
9 + --gpu-memory-utilization 0.35
10 + --moe-backend aiter
11 + --quantization mxfp4
12 + --quantization-config '{"linear":{"activation":null,"weight":null}}'
13 +env:
14 + VLLM_ROCM_USE_AITER: "1"
15 + ENABLE_CK: "0" # Avoid AITER CK-based MHA JIT compilation to save time
tests/evals/gsm8k/configs/Qwen3-30B-A3B-MXFP4-AITER-TP2.yaml new
+14
@@ -0,0 +1,14 @@
1 +# Note: this model was produced from Qwen3-30B-A3B (BF16)
2 +model_name: "amd-quark/Qwen3-30B-A3B-MXFP4"
3 +accuracy_threshold: 0.89
4 +tolerance: 0.03
5 +num_questions: 1319
6 +num_fewshot: 5
7 +server_args: >-
8 + --max-model-len 4096
9 + --tensor-parallel-size 2
10 + --gpu-memory-utilization 0.35
11 + --moe-backend aiter
12 +env:
13 + VLLM_ROCM_USE_AITER: "1"
14 + ENABLE_CK: "0" # Avoid AITER CK-based MHA JIT compilation to save time
tests/evals/gsm8k/configs/Qwen3.5-35B-A3B-MXFP4-AITER-TP2.yaml
+1
@@ -1,3 +1,4 @@
1 +# NOTE: this model was produced from Qwen/Qwen3.5-35B-A3B-FP8
2 model_name: "amd/Qwen3.5-35B-A3B-MXFP4"
3 accuracy_threshold: 0.89
4 tolerance: 0.03
tests/evals/gsm8k/configs/Qwen3.5-35B-A3B-MXFP4-EMU-TP2.yaml
+1
@@ -1,3 +1,4 @@
1 +# NOTE: this model was produced from Qwen/Qwen3.5-35B-A3B-FP8
2 model_name: "amd/Qwen3.5-35B-A3B-MXFP4"
3 accuracy_threshold: 0.89
4 tolerance: 0.03
tests/kernels/quantization/test_mxfp4_kernel_selection.py
+14 -2
@@ -114,9 +114,15 @@ def test_weight_only_kernels_reject_non_mxfp4_activation(kernel_cls):
114 "activation_quant_key",
115 [None, kMxfp4Dynamic, kMxfp6E3M2Dynamic, kMxfp6E2M3Dynamic],
116 )
117 -def test_emulation_kernel_accepts_any_config(activation_quant_key):
117 +def test_emulation_kernel_accepts_any_config(activation_quant_key, monkeypatch):
118 """EmulationMxfp4LinearKernel is the universal fallback: it must accept
119 every supported activation format."""
120 + # `EmulationMxfp4LinearKernel.can_implement` gates on `has_quark()`,
121 + # which we are not testing here.
122 + monkeypatch.setattr(
123 + "vllm.model_executor.kernels.linear.mxfp4.emulation.has_quark",
124 + lambda: True,
125 + )
126 config = MxFp4LinearLayerConfig(activation_quant_key=activation_quant_key)
127 with patch(
128 "vllm.model_executor.kernels.linear._get_linear_backend",
@@ -126,9 +132,15 @@ def test_emulation_kernel_accepts_any_config(activation_quant_key):
132 assert can_implement, reason
133
134
129 -def test_emulation_kernel_derives_quant_dequant_func_from_config():
135 +def test_emulation_kernel_derives_quant_dequant_func_from_config(monkeypatch):
136 """quant_dequant_func must be derived purely from the config's activation
137 QuantKey, not set externally."""
138 + # `EmulationMxfp4LinearKernel.can_implement` gates on `has_quark()`,
139 + # which we are not testing here.
140 + monkeypatch.setattr(
141 + "vllm.model_executor.kernels.linear.mxfp4.emulation.has_quark",
142 + lambda: True,
143 + )
144 with patch(
145 "vllm.model_executor.kernels.linear._get_linear_backend",
146 return_value="emulation",
tests/quantization/test_online.py
+34 -3
@@ -14,6 +14,7 @@ from tests.quantization.utils import (
14 is_quant_method_supported,
15 )
16 from vllm import _custom_ops as ops
17 +from vllm._aiter_ops import rocm_aiter_ops
18 from vllm._custom_ops import scaled_fp4_quant
19 from vllm.model_executor.layers.linear import UnquantizedLinearMethod
20 from vllm.model_executor.layers.quantization.online.fp8 import (
@@ -27,6 +28,10 @@ from vllm.model_executor.layers.quantization.online.fp8 import (
28 _is_tp_sharded,
29 )
30 from vllm.model_executor.layers.quantization.online.int8 import Int8OnlineMoEMethod
31 +from vllm.model_executor.layers.quantization.online.mxfp4 import (
32 + Mxfp4OnlineLinearMethod,
33 + Mxfp4OnlineMoEMethod,
34 +)
35 from vllm.model_executor.layers.quantization.online.nvfp4 import (
36 Nvfp4OnlineMoEMethod,
37 _quantize_moe_weight_to_nvfp4,
@@ -40,6 +45,17 @@ from vllm.model_executor.layers.quantization.utils.quant_utils import (
45 from vllm.platforms import current_platform
46 from vllm.utils.flashinfer import has_flashinfer_trtllm_fused_moe
47
48 +if current_platform.is_rocm():
49 + from vllm.platforms.rocm import on_gfx942, on_gfx950
50 +else:
51 +
52 + def on_gfx950() -> bool:
53 + return False
54 +
55 + def on_gfx942() -> bool:
56 + return False
57 +
58 +
59 DEVICE = current_platform.device_type
60
61
@@ -84,6 +100,12 @@ DEVICE = current_platform.device_type
100 Fp8PerTensorOnlineLinearMethod,
101 Fp8PerTensorOnlineMoEMethod,
102 ),
103 + (
104 + "mxfp4",
105 + None,
106 + Mxfp4OnlineLinearMethod,
107 + Mxfp4OnlineMoEMethod,
108 + ),
109 ],
110 )
111 @pytest.mark.parametrize(
@@ -106,8 +128,14 @@ def test_online_quantization(
128 Does not test performance, peak memory usage, etc.
129 """
130
109 - if use_rocm_aiter:
110 - monkeypatch.setenv("VLLM_ROCM_USE_AITER", "1")
131 + # TODO: Relax this condition once there is a native MXFP4_MXFP4
132 + # linear/moe backend supported on cuda.
133 + if quant_scheme == "mxfp4" and not (on_gfx950() or on_gfx942()):
134 + pytest.skip("mxfp4 online quantization is only tested on AMD gfx942, gfx950.")
135 +
136 + if current_platform.is_rocm():
137 + monkeypatch.setenv("VLLM_ROCM_USE_AITER", "1" if use_rocm_aiter else "0")
138 + rocm_aiter_ops.refresh_env_variables()
139
140 if current_platform.is_xpu() and quant_scheme == "fp8_per_block":
141 pytest.skip("Skip test for online fp8_per_block on XPU platform.")
@@ -144,7 +172,10 @@ def test_online_quantization(
172 if moe is not None:
173 assert isinstance(moe._quant_method, expected_moe_cls)
174
147 - if current_platform.is_cuda() or current_platform.is_xpu():
175 + if quant_scheme == "mxfp4":
176 + # Packed e2m1 values, two per byte.
177 + assert o_proj.weight.dtype == torch.uint8
178 + elif current_platform.is_cuda() or current_platform.is_xpu():
179 assert o_proj.weight.dtype == torch.float8_e4m3fn
180 elif current_platform.is_rocm():
181 assert o_proj.weight.dtype == current_platform.fp8_dtype()
tests/quantization/test_online_mxfp4.py new
+606
@@ -0,0 +1,606 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Correctness tests for the online MXFP4 weight quantization backends.
4 +
5 +Each backend (Triton, aiter, XPU) quantizes a synthetic bf16/fp16
6 +tensor to MXFP4, the result is dequantized back to bf16/fp16, and compared
7 +against the pure-torch reference in `reference_mxfp4.py`.
8 +"""
9 +
10 +import pytest
11 +import torch
12 +
13 +from vllm.config.model import ModelConfig
14 +from vllm.model_executor.kernels.linear import _POSSIBLE_MXFP4_KERNELS
15 +from vllm.model_executor.kernels.linear.mxfp4.aiter import (
16 + AiterMxfp4LinearKernel,
17 +)
18 +from vllm.model_executor.layers.fused_moe import FusedMoEFactory
19 +from vllm.model_executor.layers.fused_moe.routed_experts import RoutedExperts
20 +from vllm.model_executor.layers.quantization.online.mxfp4 import (
21 + Mxfp4OnlineLinearMethod,
22 + Mxfp4OnlineMoEMethod,
23 +)
24 +from vllm.model_executor.layers.quantization.quark.quark_moe import (
25 + QuarkOCP_MX_MoEMethod,
26 +)
27 +from vllm.model_executor.layers.quantization.quark.schemes.quark_ocp_mx import (
28 + QuarkOCP_MX,
29 +)
30 +from vllm.model_executor.layers.quantization.quark.utils import (
31 + quark_quantize_weight_to_mxfp4,
32 +)
33 +from vllm.model_executor.layers.quantization.utils.mxfp4_utils import (
34 + downcast_to_mxfp,
35 + mxfp4_quantize,
36 + quant_dequant_mxfp4,
37 +)
38 +from vllm.model_executor.model_loader.weight_utils import default_weight_loader
39 +from vllm.model_executor.utils import replace_parameter
40 +from vllm.platforms import PlatformEnum, current_platform
41 +
42 +from .reference_mxfp4 import dq_mxfp4_torch, qdq_mxfp4_torch
43 +
44 +
45 +def fix_negative_zeros(tensor: torch.Tensor) -> torch.Tensor:
46 + """Normalize FP4 e2m1 negative-zero codewords (0b1000) to positive zero
47 + (0b0000) in a packed uint8 tensor (two e2m1 values per byte, low nibble
48 + first).
49 +
50 + -0.0 and +0.0 dequantize to the same float value, but different MXFP4
51 + quantization backends disagree on which one they emit for values that
52 + round to zero magnitude. This normalizes both packed representations so
53 + tensors from different backends can be compared with `torch.equal`.
54 + """
55 + assert tensor.dtype == torch.uint8, (
56 + f"Expected a torch.uint8 tensor, got {tensor.dtype}"
57 + )
58 +
59 + low_nibble = tensor & 0x0F
60 + high_nibble = tensor & 0xF0
61 +
62 + low_nibble = torch.where(
63 + low_nibble == 0x08, torch.zeros_like(low_nibble), low_nibble
64 + )
65 + high_nibble = torch.where(
66 + high_nibble == 0x80, torch.zeros_like(high_nibble), high_nibble
67 + )
68 +
69 + return low_nibble | high_nibble
70 +
71 +
72 +def assert_quantized_weights_equal(
73 + checkpoint_layer: torch.nn.Module,
74 + online_layer: torch.nn.Module,
75 + keys: tuple[str, ...],
76 + packed_weight_keys: tuple[str, ...],
77 +) -> None:
78 + """Assert the checkpoint and online paths produced byte-identical weights.
79 +
80 + `packed_weight_keys` lists the entries holding packed FP4 codewords, which
81 + need negative-zero normalization before comparison.
82 + """
83 + for key in keys:
84 + checkpoint_tensor = getattr(checkpoint_layer, key).view(torch.uint8)
85 + online_tensor = getattr(online_layer, key).view(torch.uint8)
86 +
87 + # NOTE: AMD Quark checkpoints use exclusively **positive** zeros,
88 + # while other mxfp4_quantize implementations from mxfp4_utils.py may not.
89 + if key in packed_weight_keys:
90 + checkpoint_tensor = fix_negative_zeros(checkpoint_tensor)
91 + online_tensor = fix_negative_zeros(online_tensor)
92 +
93 + assert checkpoint_tensor.shape == online_tensor.shape
94 + assert checkpoint_tensor.dtype == online_tensor.dtype
95 + num_mismatched = (checkpoint_tensor != online_tensor).sum().item()
96 + total = checkpoint_tensor.numel()
97 +
98 + assert torch.equal(checkpoint_tensor, online_tensor), (
99 + f"{key}: {num_mismatched}/{total} "
100 + f"({100 * num_mismatched / total:.4f}%) mismatched bytes"
101 + )
102 +
103 +
104 +def _skip_reason_if_unavailable(backend: str, dtype: torch.dtype) -> str | None:
105 + """Return a skip reason if `backend` cannot run on the current host."""
106 + if backend == "triton":
107 + if not (current_platform.is_cuda() or current_platform.is_rocm()):
108 + return "Triton MXFP4 kernel requires a CUDA or ROCm GPU."
109 + return None
110 + if backend == "aiter":
111 + from vllm._aiter_ops import is_aiter_found_and_supported
112 +
113 + if not is_aiter_found_and_supported():
114 + return "aiter is not available/supported on this platform."
115 + if dtype != torch.bfloat16:
116 + return "aiter's dynamic_mxfp4_quant only supports bfloat16 input."
117 + return None
118 + if backend == "xpu":
119 + if not current_platform.is_xpu():
120 + return "not on XPU platform."
121 + return None
122 + if backend == "quark":
123 + try:
124 + import quark.torch.kernel.mx # noqa: F401
125 + except ImportError:
126 + return "amd-quark is not installed."
127 + return None
128 + raise ValueError(f"Unknown backend {backend}")
129 +
130 +
131 +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
132 +@pytest.mark.parametrize("backend", ["triton", "aiter", "xpu", "quark"])
133 +def test_mxfp4_quantization_correctness(backend: str, dtype: torch.dtype):
134 + """
135 + Tests that the different implementations of mxfp4_quantize
136 + in mxfp4_utils.py all match.
137 + """
138 + skip_reason = _skip_reason_if_unavailable(backend, dtype)
139 + if skip_reason is not None:
140 + pytest.skip(skip_reason)
141 +
142 + torch.manual_seed(3)
143 +
144 + num_rows = 64
145 + hidden_size = 32 * 32 # multiple 32-element MXFP4 blocks
146 + device = current_platform.device_type
147 +
148 + x = (torch.rand(num_rows, hidden_size, dtype=dtype, device=device) - 0.5) * 2
149 + # Vary the magnitude block-to-block so several scale exponents are
150 + # exercised, rather than a single one for the whole tensor.
151 + scalings = [2.3, 0.03, 7.3, 0.1, 0.004, 17.3, 1e4, 1e-4]
152 + for i in range(hidden_size // 32):
153 + x[:, i * 32 : (i + 1) * 32] *= scalings[i % len(scalings)]
154 +
155 + if backend == "triton":
156 + x_fp4, x_scale, _ = downcast_to_mxfp(x, axis=-1)
157 + elif backend == "aiter":
158 + x_fp4, x_scale = quark_quantize_weight_to_mxfp4(x)
159 + elif backend == "xpu":
160 + # TODO: enable this test on XPU
161 + pytest.skip("xpu mxfp4 quantization match to reference is untested")
162 + # from vllm.model_executor.layers.quantization.utils.mxfp4_utils import (
163 + # xpu_mxfp4_quantize,
164 + # )
165 +
166 + # x_fp4, x_scale = xpu_mxfp4_quantize(x)
167 + elif backend == "quark":
168 + result = quant_dequant_mxfp4(x, scale_calculation_mode="even")
169 + else:
170 + raise ValueError(f"Unknown backend {backend}")
171 +
172 + if backend != "quark":
173 + result = dq_mxfp4_torch(x_fp4, x_scale, x.dtype)
174 + reference = qdq_mxfp4_torch(x, scale_calculation_mode="even")
175 +
176 + assert torch.equal(result, reference)
177 +
178 +
179 +@pytest.mark.skipif(
180 + not current_platform.is_cuda_alike(), reason="Only tested on ROCm/CUDA."
181 +)
182 +@pytest.mark.parametrize("tp_size", [2, 4, 8])
183 +def test_online_mxfp4_tp_weight_quant_matches_unsharded(tp_size: int):
184 + """TP-aligned MXFP4 shards match slices of unsharded quantization."""
185 + torch.manual_seed(3)
186 +
187 + device = current_platform.device_type
188 + hidden_size = 1024
189 + intermediate_size = 512
190 +
191 + dense_weight = torch.randn(
192 + hidden_size, intermediate_size, dtype=torch.bfloat16, device=device
193 + )
194 + w13_weight = torch.randn(
195 + 2,
196 + 2 * intermediate_size,
197 + hidden_size,
198 + dtype=torch.bfloat16,
199 + device=device,
200 + )
201 + w2_weight = torch.randn(
202 + 2,
203 + hidden_size,
204 + intermediate_size,
205 + dtype=torch.bfloat16,
206 + device=device,
207 + )
208 +
209 + dense_quantized, dense_scales = mxfp4_quantize(dense_weight)
210 + w13_quantized, w13_scales = mxfp4_quantize(w13_weight)
211 + w2_quantized, w2_scales = mxfp4_quantize(w2_weight)
212 +
213 + for tp_rank in range(tp_size):
214 + output_per_partition = hidden_size // tp_size
215 + input_per_partition = intermediate_size // tp_size
216 + output_start = tp_rank * output_per_partition
217 + input_start = tp_rank * input_per_partition
218 +
219 + column_quantized, column_scales = mxfp4_quantize(
220 + dense_weight.narrow(0, output_start, output_per_partition)
221 + )
222 + assert torch.equal(
223 + column_quantized,
224 + dense_quantized.narrow(0, output_start, output_per_partition),
225 + )
226 + assert torch.equal(
227 + column_scales,
228 + dense_scales.narrow(0, output_start, output_per_partition),
229 + )
230 +
231 + row_quantized, row_scales = mxfp4_quantize(
232 + dense_weight.narrow(1, input_start, input_per_partition)
233 + )
234 + assert torch.equal(
235 + row_quantized,
236 + dense_quantized.narrow(1, input_start // 2, input_per_partition // 2),
237 + )
238 + assert torch.equal(
239 + row_scales,
240 + dense_scales.narrow(1, input_start // 32, input_per_partition // 32),
241 + )
242 +
243 + w13_quantized_shard, w13_scales_shard = mxfp4_quantize(
244 + w13_weight.narrow(1, 2 * input_start, 2 * input_per_partition)
245 + )
246 + assert torch.equal(
247 + w13_quantized_shard,
248 + w13_quantized.narrow(1, 2 * input_start, 2 * input_per_partition),
249 + )
250 + assert torch.equal(
251 + w13_scales_shard,
252 + w13_scales.narrow(1, 2 * input_start, 2 * input_per_partition),
253 + )
254 +
255 + w2_quantized_shard, w2_scales_shard = mxfp4_quantize(
256 + w2_weight.narrow(2, input_start, input_per_partition)
257 + )
258 + assert torch.equal(
259 + w2_quantized_shard,
260 + w2_quantized.narrow(2, input_start // 2, input_per_partition // 2),
261 + )
262 + assert torch.equal(
263 + w2_scales_shard,
264 + w2_scales.narrow(2, input_start // 32, input_per_partition // 32),
265 + )
266 +
267 +
268 +@pytest.mark.skipif(
269 + not current_platform.is_cuda_alike(), reason="Only tested on ROCm/CUDA."
270 +)
271 +@pytest.mark.parametrize("moe_backend", ["aiter", "emulation"])
272 +@pytest.mark.parametrize(
273 + "unpadded_hidden_size,unpadded_intermediate_size",
274 + [
275 + pytest.param(256, 256, id="no_padding"),
276 + # Not multiples of the MXFP4 block size (32), so that every backend --
277 + # including emulation, whose round-up granularity is 32 -- has to pad.
278 + pytest.param(240, 208, id="padding"),
279 + ],
280 +)
281 +def test_online_mxfp4_moe_matches_quark(
282 + moe_backend: str,
283 + unpadded_hidden_size: int,
284 + unpadded_intermediate_size: int,
285 + default_vllm_config,
286 + dist_init,
287 + monkeypatch,
288 +):
289 + """
290 + Ensures `Mxfp4OnlineMoEMethod` (online quantization)
291 + and `QuarkOCP_MX_MoEMethod` (AMD Quark checkpoints) produce the same weights,
292 + with same MOE backend used.
293 +
294 + The `padding` case additionally covers `maybe_roundup_sizes`: the online
295 + path allocates its bf16 weights with `torch.empty_strided` and the loader
296 + only writes the unpadded slice, so the padding is filled with NaN here to
297 + reproduce that uninitialized state. It must be zeroed before quantization
298 + for the two paths to agree, since Quark allocates its padding with
299 + `torch.zeros`.
300 + """
301 + if moe_backend == "aiter":
302 + from vllm._aiter_ops import rocm_aiter_ops
303 +
304 + monkeypatch.setenv("VLLM_ROCM_USE_AITER", "1")
305 + monkeypatch.setenv("VLLM_ROCM_USE_AITER_MOE", "1")
306 + monkeypatch.setattr("vllm.platforms.rocm.on_gfx950", lambda: True)
307 + rocm_aiter_ops.refresh_env_variables()
308 +
309 + if current_platform.is_cuda():
310 + pytest.skip(
311 + "mxfp4_backend == Mxfp4MoeBackend.AITER_MXFP4_MXFP4 requires "
312 + "rocm_aiter_ops in weight conversion, not compatible on cuda"
313 + )
314 + elif moe_backend == "emulation":
315 + # `OCP_MXQuantizationEmulationTritonExperts.is_supported_config` gates
316 + # on `has_quark()`, but its weight processing does not require it.
317 + monkeypatch.setattr(
318 + "vllm.model_executor.layers.fused_moe.experts."
319 + "ocp_mx_emulation_moe.has_quark",
320 + lambda: True,
321 + )
322 +
323 + default_vllm_config.model_config = ModelConfig()
324 +
325 + num_experts = 4
326 + device = current_platform.device_type
327 +
328 + def make_layer(prefix: str) -> RoutedExperts:
329 + runner = FusedMoEFactory(
330 + num_experts=num_experts,
331 + top_k=2,
332 + hidden_size=unpadded_hidden_size,
333 + intermediate_size=unpadded_intermediate_size,
334 + prefix=prefix,
335 + )
336 + layer = runner.routed_experts
337 + layer.moe_config.moe_backend = moe_backend
338 + return layer
339 +
340 + # `create_weights` implementations use plain `torch.zeros`/`torch.randn`
341 + # with no explicit device, so without this context they would default
342 + # to CPU and diverge from the (GPU) tensors produced by
343 + # `mxfp4_quantize`/`replace_parameter`.
344 + with torch.device(device):
345 + checkpoint_layer = make_layer("checkpoint_layer")
346 + online_layer = make_layer("online_layer")
347 +
348 + checkpoint_method = QuarkOCP_MX_MoEMethod(
349 + weight_config={"qscheme": "per_group", "dtype": "fp4"},
350 + input_config={"dtype": "fp4", "is_dynamic": True},
351 + moe=checkpoint_layer.moe_config,
352 + )
353 + online_method = Mxfp4OnlineMoEMethod(layer=online_layer)
354 +
355 + # `RoutedExperts.__init__` applies this round-up in production; these
356 + # layers are built without a quant config, so it is applied explicitly.
357 + # Both methods must agree on the padded sizes.
358 + roundup_arguments = dict(
359 + hidden_size=unpadded_hidden_size,
360 + intermediate_size_per_partition=unpadded_intermediate_size,
361 + act_dtype=torch.bfloat16,
362 + moe_parallel_config=online_layer.moe_config.moe_parallel_config,
363 + )
364 + hidden_size, intermediate_size = online_method.maybe_roundup_sizes(
365 + **roundup_arguments
366 + )
367 + assert (
368 + hidden_size,
369 + intermediate_size,
370 + ) == checkpoint_method.maybe_roundup_sizes(**roundup_arguments)
371 +
372 + if unpadded_hidden_size == 240:
373 + # Padded case.
374 + assert hidden_size > unpadded_hidden_size
375 + assert intermediate_size > unpadded_intermediate_size
376 +
377 + for layer in (checkpoint_layer, online_layer):
378 + assert layer.moe_config.hidden_dim_unpadded == unpadded_hidden_size
379 + assert (
380 + layer.moe_config.intermediate_size_per_partition_unpadded
381 + == unpadded_intermediate_size
382 + )
383 + layer.moe_config.hidden_dim = hidden_size
384 + layer.moe_config.intermediate_size_per_partition = intermediate_size
385 +
386 + gate_up_weight = torch.randn(
387 + num_experts,
388 + 2 * unpadded_intermediate_size,
389 + unpadded_hidden_size,
390 + dtype=torch.bfloat16,
391 + )
392 + down_weight = torch.randn(
393 + num_experts,
394 + unpadded_hidden_size,
395 + unpadded_intermediate_size,
396 + dtype=torch.bfloat16,
397 + )
398 +
399 + scalings = [2.3, 0.03, 7.3, 0.1, 0.004, 17.3, 1e4, 1e-4]
400 + for i, start in enumerate(range(0, unpadded_hidden_size, 32)):
401 + gate_up_weight[..., start : start + 32] *= scalings[i % len(scalings)]
402 + for i, start in enumerate(range(0, unpadded_intermediate_size, 32)):
403 + down_weight[..., start : start + 32] *= scalings[i % len(scalings)]
404 +
405 + def scatter_into_padded(
406 + gate_up_weight: torch.Tensor,
407 + down_weight: torch.Tensor,
408 + padding_value: float,
409 + ) -> tuple[torch.Tensor, torch.Tensor]:
410 + """Place the source weights into buffers of the padded size, the
411 + way the weight loader writes only the unpadded slice of a larger
412 + allocation."""
413 + w13 = torch.full(
414 + (num_experts, 2 * intermediate_size, hidden_size),
415 + padding_value,
416 + dtype=torch.bfloat16,
417 + )
418 + w2 = torch.full(
419 + (num_experts, hidden_size, intermediate_size),
420 + padding_value,
421 + dtype=torch.bfloat16,
422 + )
423 + # w13 stacks gate and up, each padded to `intermediate_size`.
424 + w13[:, :unpadded_intermediate_size, :unpadded_hidden_size] = gate_up_weight[
425 + :, :unpadded_intermediate_size, :
426 + ]
427 + w13[
428 + :,
429 + intermediate_size : intermediate_size + unpadded_intermediate_size,
430 + :unpadded_hidden_size,
431 + ] = gate_up_weight[:, unpadded_intermediate_size:, :]
432 + w2[:, :unpadded_hidden_size, :unpadded_intermediate_size] = down_weight
433 + return w13, w2
434 +
435 + # Checkpoint path: pre-quantize the source weights to MXFP4 with the
436 + # ~same RTN recipe a real Quark checkpoint, then feed the
437 + # already-packed tensors into `QuarkOCP_MX_MoEMethod`. Quark's
438 + # `create_weights` allocates with `torch.zeros`, so its padding is zero.
439 + checkpoint_w13, checkpoint_w2 = scatter_into_padded(
440 + gate_up_weight, down_weight, padding_value=0.0
441 + )
442 + checkpoint_w13, checkpoint_w13_scale = mxfp4_quantize(checkpoint_w13)
443 + checkpoint_w2, checkpoint_w2_scale = mxfp4_quantize(checkpoint_w2)
444 +
445 + checkpoint_method.create_weights(
446 + layer=checkpoint_layer,
447 + num_experts=num_experts,
448 + hidden_size=hidden_size,
449 + intermediate_size_per_partition=intermediate_size,
450 + params_dtype=torch.bfloat16,
451 + )
452 + checkpoint_layer.w13_weight.data.copy_(checkpoint_w13)
453 + checkpoint_layer.w2_weight.data.copy_(checkpoint_w2)
454 + checkpoint_layer.w13_weight_scale.data.copy_(checkpoint_w13_scale)
455 + checkpoint_layer.w2_weight_scale.data.copy_(checkpoint_w2_scale)
456 + checkpoint_method.process_weights_after_loading(checkpoint_layer)
457 +
458 + # Online path: feed the *same* raw bf16 source weights and let
459 + # `Mxfp4OnlineMoEMethod` quantize them during
460 + # `process_weights_after_loading`. The padding starts out
461 + # uninitialized, standing in for the materialized meta tensor.
462 + online_w13, online_w2 = scatter_into_padded(
463 + gate_up_weight, down_weight, padding_value=float("nan")
464 + )
465 +
466 + online_method.create_weights(
467 + layer=online_layer,
468 + num_experts=num_experts,
469 + hidden_size=hidden_size,
470 + intermediate_size_per_partition=intermediate_size,
471 + params_dtype=torch.bfloat16,
472 + )
473 + replace_parameter(online_layer, "w13_weight", online_w13)
474 + replace_parameter(online_layer, "w2_weight", online_w2)
475 + online_method.process_weights_after_loading(online_layer)
476 +
477 + assert checkpoint_method.mxfp4_backend == online_method.mxfp4_backend
478 +
479 + assert_quantized_weights_equal(
480 + checkpoint_layer,
481 + online_layer,
482 + keys=("w13_weight", "w13_weight_scale", "w2_weight", "w2_weight_scale"),
483 + packed_weight_keys=("w13_weight", "w2_weight"),
484 + )
485 +
486 +
487 +@pytest.mark.skipif(
488 + not current_platform.is_cuda_alike(), reason="Only tested on ROCm/CUDA."
489 +)
490 +@pytest.mark.parametrize("linear_backend", ["emulation", "aiter", "marlin"])
491 +def test_online_mxfp4_dense_matches_quark(
492 + linear_backend: str, default_vllm_config, dist_init, monkeypatch
493 +):
494 + """
495 + Ensures `Mxfp4OnlineLinearMethod` (online quantization)
496 + and `QuarkOCP_MX` (AMD Quark checkpoints) produce the same weights,
497 + with same linear backend used.
498 + """
499 + if linear_backend == "marlin" and not current_platform.is_cuda():
500 + # `MarlinMxFp4LinearKernel.process_weights_after_loading` is CUDA-only
501 + pytest.skip(
502 + "MarlinMxFp4LinearKernel.process_weights_after_loading is CUDA-only."
503 + )
504 +
505 + if linear_backend == "aiter":
506 + # `AiterMxfp4LinearKernel` is registered only for ROCm in
507 + # `_POSSIBLE_MXFP4_KERNELS`, and `is_supported` gates on
508 + # `current_platform.supports_mx()`. Force it onto the CUDA
509 + # kernel list and bypass the platform gate so this backend can
510 + # be exercised on CUDA hosts too.
511 +
512 + monkeypatch.setattr(
513 + AiterMxfp4LinearKernel,
514 + "is_supported",
515 + classmethod(lambda cls, compute_capability=None: (True, None)),
516 + )
517 + monkeypatch.setitem(
518 + _POSSIBLE_MXFP4_KERNELS,
519 + PlatformEnum.CUDA,
520 + [
521 + AiterMxfp4LinearKernel,
522 + *_POSSIBLE_MXFP4_KERNELS.get(PlatformEnum.CUDA, []),
523 + ],
524 + )
525 + elif linear_backend == "emulation":
526 + # `EmulationMxfp4LinearKernel.can_implement` gates on `has_quark()`,
527 + # EmulationMxfp4LinearKernel.process_weights_after_loading does not require it.
528 + monkeypatch.setattr(
529 + "vllm.model_executor.kernels.linear.mxfp4.emulation.has_quark",
530 + lambda: True,
531 + )
532 +
533 + default_vllm_config.model_config = ModelConfig()
534 + default_vllm_config.kernel_config.linear_backend = linear_backend
535 +
536 + input_size = 256
537 + output_size = 128
538 + device = current_platform.device_type
539 +
540 + # `create_weights` implementations use plain `torch.empty` with no explicit
541 + # device, so without this context they would default to CPU and diverge
542 + # from the (GPU) tensors produced by `mxfp4_quantize`/`replace_parameter`.
543 + with torch.device(device):
544 + checkpoint_layer = torch.nn.Module()
545 + online_layer = torch.nn.Module()
546 + for layer in (checkpoint_layer, online_layer):
547 + # `LinearBase` subclasses normally set these attributes on the
548 + # layer before `create_weights` is called; the marlin kernel's
549 + # `process_weights_after_loading` reads them directly off the
550 + # layer, so a bare `torch.nn.Module` needs them set explicitly.
551 + layer.input_size = input_size
552 + layer.output_size = output_size
553 + layer.input_size_per_partition = input_size
554 + layer.output_size_per_partition = output_size
555 + layer.params_dtype = torch.bfloat16
556 +
557 + weight = torch.randn(output_size, input_size, dtype=torch.bfloat16)
558 +
559 + scalings = [2.3, 0.03, 7.3, 0.1, 0.004, 17.3, 1e4, 1e-4]
560 + for i in range(input_size // 32):
561 + weight[:, i * 32 : (i + 1) * 32] *= scalings[i % len(scalings)]
562 +
563 + # Checkpoint path: pre-quantize the source weight to MXFP4 with the
564 + # ~same RTN recipe as a real Quark checkpoint, then feed the
565 + # already-packed tensors into `QuarkOCP_MX`.
566 + checkpoint_weight, checkpoint_weight_scale = mxfp4_quantize(weight)
567 +
568 + checkpoint_scheme = QuarkOCP_MX(
569 + weight_quant_spec={"qscheme": "per_group", "dtype": "fp4"},
570 + input_quant_spec={"dtype": "fp4", "is_dynamic": True},
571 + )
572 + checkpoint_scheme.create_weights(
573 + layer=checkpoint_layer,
574 + output_partition_sizes=[output_size],
575 + input_size_per_partition=input_size,
576 + params_dtype=torch.bfloat16,
577 + weight_loader=default_weight_loader,
578 + )
579 + checkpoint_layer.weight.data.copy_(checkpoint_weight)
580 + checkpoint_layer.weight_scale.data.copy_(checkpoint_weight_scale)
581 + checkpoint_scheme.process_weights_after_loading(checkpoint_layer)
582 +
583 + # Online path: feed the *same* raw bf16 source weight and let
584 + # `Mxfp4OnlineLinearMethod` quantize it during
585 + # `process_weights_after_loading`.
586 + online_method = Mxfp4OnlineLinearMethod()
587 + online_method.create_weights(
588 + layer=online_layer,
589 + input_size_per_partition=input_size,
590 + output_partition_sizes=[output_size],
591 + input_size=input_size,
592 + output_size=output_size,
593 + params_dtype=torch.bfloat16,
594 + weight_loader=default_weight_loader,
595 + )
596 + replace_parameter(online_layer, "weight", weight.clone())
597 + online_method.process_weights_after_loading(online_layer)
598 +
599 + assert type(checkpoint_scheme.ocp_mx_linear) is type(online_method.kernel)
600 +
601 + assert_quantized_weights_equal(
602 + checkpoint_layer,
603 + online_layer,
604 + keys=("weight", "weight_scale"),
605 + packed_weight_keys=("weight",),
606 + )
vllm/config/quantization.py
+5
@@ -17,6 +17,7 @@ from vllm.model_executor.layers.quantization.utils.quant_utils import (
17 kFp8StaticTensorSym,
18 kInt8StaticChannelSym,
19 kMxfp4Dynamic,
20 + kMxfp4Static,
21 kMxfp8Dynamic,
22 kNvfp4Static,
23 )
@@ -131,6 +132,10 @@ _ONLINE_SHORTHANDS: dict[str, QuantizationConfigArgs] = {
132 linear=QuantSpec(weight=kMxfp8Dynamic),
133 moe=QuantSpec(weight=kMxfp8Dynamic),
134 ),
135 + "mxfp4": QuantizationConfigArgs(
136 + linear=QuantSpec(weight=kMxfp4Static),
137 + moe=QuantSpec(weight=kMxfp4Static),
138 + ),
139 # INT8 weight-only on MoE; linear stays unquantized (no `linear` field).
140 "int8_per_channel_weight_only": QuantizationConfigArgs(
141 moe=QuantSpec(weight=kInt8StaticChannelSym),
vllm/model_executor/kernels/linear/mxfp4/emulation.py
+4
@@ -23,6 +23,7 @@ from vllm.model_executor.layers.quantization.utils.quant_utils import (
23 kMxfp6E3M2Dynamic,
24 )
25 from vllm.platforms import current_platform
26 +from vllm.utils.import_utils import has_quark
27
28 from .base import MxFp4LinearKernel, MxFp4LinearLayerConfig
29
@@ -68,6 +69,9 @@ class EmulationMxfp4LinearKernel(MxFp4LinearKernel):
69 ):
70 return False, "only supports MXFP4 or MXFP6 or unquantized activations"
71
72 + if not has_quark():
73 + return False, "amd-quark package not available"
74 +
75 if (
76 current_platform.is_rocm()
77 and current_platform.supports_mx()
vllm/model_executor/layers/fused_moe/experts/ocp_mx_emulation_moe.py
+23
@@ -26,7 +26,11 @@ from vllm.model_executor.layers.quantization.utils.mxfp6_utils import dequant_mx
26 from vllm.model_executor.layers.quantization.utils.ocp_mx_utils import (
27 OCP_MX_Scheme,
28 )
29 +from vllm.model_executor.layers.quantization.utils.quant_utils import (
30 + QuantKey,
31 +)
32 from vllm.platforms import current_platform
33 +from vllm.utils.import_utils import has_quark
34
35 logger = init_logger(__name__)
36
@@ -92,6 +96,25 @@ class OCP_MXQuantizationEmulationTritonExperts(TritonExperts):
96 ]:
97 self._quant_dtype = current_platform.fp8_dtype()
98
99 + @staticmethod
100 + def is_supported_config(
101 + cls: type["mk.FusedMoEExperts"],
102 + moe_config: FusedMoEConfig,
103 + weight_key: QuantKey | None,
104 + activation_key: QuantKey | None,
105 + activation_format: mk.FusedMoEActivationFormat,
106 + ) -> tuple[bool, str | None]:
107 + if not has_quark():
108 + return False, "kernel requires amd-quark package"
109 +
110 + return TritonExperts.is_supported_config(
111 + cls=cls,
112 + moe_config=moe_config,
113 + weight_key=weight_key,
114 + activation_key=activation_key,
115 + activation_format=activation_format,
116 + )
117 +
118 @property
119 def quant_dtype(self) -> torch.dtype | str | None:
120 return self._quant_dtype
vllm/model_executor/layers/fused_moe/oracle/mxfp4.py
+15
@@ -1650,6 +1650,21 @@ def convert_weight_to_mxfp4_moe_kernel_format(
1650 w13_bias,
1651 w2_bias,
1652 )
1653 + elif mxfp4_backend in (
1654 + Mxfp4MoeBackend.AITER_MXFP4_MXFP4,
1655 + Mxfp4MoeBackend.AITER_MXFP4_FP8,
1656 + ):
1657 + return convert_gpt_oss_weight_to_mxfp4_moe_kernel_format(
1658 + mxfp4_backend=mxfp4_backend,
1659 + layer=layer,
1660 + w13_weight=w13_weight,
1661 + w2_weight=w2_weight,
1662 + w13_weight_scale=w13_weight_scale,
1663 + w2_weight_scale=w2_weight_scale,
1664 + w13_bias=w13_bias,
1665 + w2_bias=w2_bias,
1666 + _cache_permute_indices=_cache_permute_indices,
1667 + )
1668 else:
1669 raise ValueError(
1670 f"Unsupported mxfp4_backend for Mxfp4MoEMethod: {mxfp4_backend}. "
vllm/model_executor/layers/quantization/online/base.py
+7
@@ -36,6 +36,10 @@ from vllm.model_executor.layers.quantization.online.fp8 import (
36 from vllm.model_executor.layers.quantization.online.int8 import (
37 Int8OnlineMoEMethod,
38 )
39 +from vllm.model_executor.layers.quantization.online.mxfp4 import (
40 + Mxfp4OnlineLinearMethod,
41 + Mxfp4OnlineMoEMethod,
42 +)
43 from vllm.model_executor.layers.quantization.online.mxfp8 import (
44 Mxfp8OnlineLinearMethod,
45 Mxfp8OnlineMoEMethod,
@@ -49,6 +53,7 @@ from vllm.model_executor.layers.quantization.utils.quant_utils import (
53 kFp8StaticChannelSym,
54 kFp8StaticTensorSym,
55 kInt8StaticChannelSym,
56 + kMxfp4Static,
57 kMxfp8Dynamic,
58 kNvfp4Static,
59 )
@@ -64,6 +69,7 @@ _ONLINE_LINEAR_METHODS: dict[QuantKey, type] = {
69 kFp8Static128BlockSym: Fp8PerBlockOnlineLinearMethod,
70 kFp8StaticChannelSym: Fp8PtpcOnlineLinearMethod,
71 kMxfp8Dynamic: Mxfp8OnlineLinearMethod,
72 + kMxfp4Static: Mxfp4OnlineLinearMethod,
73 }
74
75 _ONLINE_MOE_METHODS: dict[QuantKey, type] = {
@@ -71,6 +77,7 @@ _ONLINE_MOE_METHODS: dict[QuantKey, type] = {
77 kFp8Static128BlockSym: Fp8PerBlockOnlineMoEMethod,
78 kFp8StaticChannelSym: Fp8PtpcOnlineMoEMethod,
79 kMxfp8Dynamic: Mxfp8OnlineMoEMethod,
80 + kMxfp4Static: Mxfp4OnlineMoEMethod,
81 kInt8StaticChannelSym: Int8OnlineMoEMethod,
82 kNvfp4Static: Nvfp4OnlineMoEMethod,
83 }
vllm/model_executor/layers/quantization/online/fp8.py
-30
@@ -639,36 +639,6 @@ class Fp8PerBlockOnlineMoEMethod(_Fp8OnlineMoEBase):
639 round_up(intermediate_size_per_partition, block_size),
640 )
641
642 - def _zero_padding(self, layer: Module) -> None:
643 - hidden_size = layer.moe_config.hidden_dim_unpadded
644 - intermediate_size = layer.moe_config.intermediate_size_per_partition_unpadded
645 -
646 - w13_shard = layer.w13_weight.shape[1] // self.moe.w13_num_shards
647 - if w13_shard > intermediate_size:
648 - for shard in range(self.moe.w13_num_shards):
649 - start = shard * w13_shard + intermediate_size
650 - layer.w13_weight[:, start : (shard + 1) * w13_shard, :] = 0
651 - if layer.w13_weight.shape[2] > hidden_size:
652 - layer.w13_weight[:, :, hidden_size:] = 0
653 -
654 - if layer.w2_weight.shape[1] > hidden_size:
655 - layer.w2_weight[:, hidden_size:, :] = 0
656 - if layer.w2_weight.shape[2] > intermediate_size:
657 - layer.w2_weight[:, :, intermediate_size:] = 0
658 -
659 - if getattr(layer, "w13_bias", None) is not None:
660 - w13_bias_shard = layer.w13_bias.shape[1] // self.moe.w13_num_shards
661 - if w13_bias_shard > intermediate_size:
662 - for shard in range(self.moe.w13_num_shards):
663 - start = shard * w13_bias_shard + intermediate_size
664 - layer.w13_bias[:, start : (shard + 1) * w13_bias_shard] = 0
665 -
666 - if (
667 - getattr(layer, "w2_bias", None) is not None
668 - and layer.w2_bias.shape[1] > hidden_size
669 - ):
670 - layer.w2_bias[:, hidden_size:] = 0
671 -
642 def process_weights_after_loading(self, layer: Module) -> None:
643 if getattr(layer, "_already_called_process_weights_after_loading", False):
644 return
vllm/model_executor/layers/quantization/online/moe_base.py
+30
@@ -95,6 +95,36 @@ class OnlineMoEMethodBase(FusedMoEMethodBase):
95
96 initialize_online_processing(layer)
97
98 + def _zero_padding(self, layer: torch.nn.Module) -> None:
99 + hidden_size = layer.moe_config.hidden_dim_unpadded
100 + intermediate_size = layer.moe_config.intermediate_size_per_partition_unpadded
101 +
102 + w13_shard = layer.w13_weight.shape[1] // self.moe.w13_num_shards
103 + if w13_shard > intermediate_size:
104 + for shard in range(self.moe.w13_num_shards):
105 + start = shard * w13_shard + intermediate_size
106 + layer.w13_weight[:, start : (shard + 1) * w13_shard, :] = 0
107 + if layer.w13_weight.shape[2] > hidden_size:
108 + layer.w13_weight[:, :, hidden_size:] = 0
109 +
110 + if layer.w2_weight.shape[1] > hidden_size:
111 + layer.w2_weight[:, hidden_size:, :] = 0
112 + if layer.w2_weight.shape[2] > intermediate_size:
113 + layer.w2_weight[:, :, intermediate_size:] = 0
114 +
115 + if getattr(layer, "w13_bias", None) is not None:
116 + w13_bias_shard = layer.w13_bias.shape[1] // self.moe.w13_num_shards
117 + if w13_bias_shard > intermediate_size:
118 + for shard in range(self.moe.w13_num_shards):
119 + start = shard * w13_bias_shard + intermediate_size
120 + layer.w13_bias[:, start : (shard + 1) * w13_bias_shard] = 0
121 +
122 + if (
123 + getattr(layer, "w2_bias", None) is not None
124 + and layer.w2_bias.shape[1] > hidden_size
125 + ):
126 + layer.w2_bias[:, hidden_size:] = 0
127 +
128 @abstractmethod
129 def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
130 pass
vllm/model_executor/layers/quantization/online/mxfp4.py new
+317
@@ -0,0 +1,317 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +"""Online MXFP4 (microscaling FP4, block-32) quantization methods."""
5 +
6 +from typing import TYPE_CHECKING
7 +
8 +import torch
9 +from torch.nn import Module
10 +
11 +if TYPE_CHECKING:
12 + import vllm.model_executor.layers.fused_moe.modular_kernel as mk
13 + from vllm.model_executor.layers.fused_moe import (
14 + FusedMoEParallelConfig,
15 + FusedMoEQuantConfig,
16 + RoutedExperts,
17 + )
18 +
19 +from vllm.model_executor.kernels.linear import init_mxfp4_linear_kernel
20 +from vllm.model_executor.layers.fused_moe.oracle.mxfp4 import (
21 + TRITON_BACKENDS,
22 + Mxfp4MoeBackend,
23 + convert_weight_to_mxfp4_moe_kernel_format,
24 + make_mxfp4_moe_kernel,
25 + make_mxfp4_moe_quant_config,
26 + mxfp4_round_up_hidden_size_and_intermediate_size,
27 + select_mxfp4_moe_backend,
28 +)
29 +from vllm.model_executor.layers.quantization.online.fp8 import (
30 + _Fp8OnlineLinearBase,
31 +)
32 +from vllm.model_executor.layers.quantization.online.moe_base import (
33 + OnlineMoEMethodBase,
34 +)
35 +from vllm.model_executor.layers.quantization.utils.mxfp4_utils import (
36 + mxfp4_quantize,
37 +)
38 +from vllm.model_executor.layers.quantization.utils.quant_utils import kMxfp4Dynamic
39 +from vllm.model_executor.utils import replace_parameter
40 +
41 +# MXFP4 block size (elements per shared e8m0 scale), same as the checkpoint
42 +# and MoE oracle paths.
43 +MXFP4_BLOCK_SIZE = 32
44 +
45 +
46 +def _quantize_mxfp4_moe_weight(
47 + weight: torch.Tensor,
48 +) -> tuple[torch.Tensor, torch.Tensor]:
49 + """Batch quantization: bf16/fp16 stacked expert weights -> MXFP4.
50 +
51 + Returns packed FP4 weights (uint8, two values per byte) and per-block
52 + (group-32) e8m0 scales (uint8), one pair per expert.
53 + """
54 + num_experts = weight.size(0)
55 + first_quant, first_scale = mxfp4_quantize(weight[0])
56 + # Pre-allocate the output tensors rather than stacking.
57 + # This is important for consistent memory layout.
58 + w_quant = torch.empty(
59 + (num_experts, *first_quant.shape),
60 + dtype=first_quant.dtype,
61 + device=weight.device,
62 + )
63 + w_scales = torch.empty(
64 + (num_experts, *first_scale.shape),
65 + dtype=first_scale.dtype,
66 + device=weight.device,
67 + )
68 + w_quant[0] = first_quant
69 + w_scales[0] = first_scale
70 + for i in range(1, num_experts):
71 + w_quant[i], w_scales[i] = mxfp4_quantize(weight[i])
72 +
73 + return w_quant, w_scales
74 +
75 +
76 +class Mxfp4OnlineLinearMethod(_Fp8OnlineLinearBase):
77 + """Online MXFP4 linear method.
78 + Loads bf16/fp16 checkpoints and quantizes weights to MXFP4 (microscaling
79 + FP4 with block-32 scales) during weight loading.
80 + """
81 +
82 + activation_quant_key = kMxfp4Dynamic
83 +
84 + def __init__(self):
85 + super().__init__()
86 + self.kernel = init_mxfp4_linear_kernel(
87 + activation_quant_key=self.activation_quant_key
88 + )
89 +
90 + def create_weights(
91 + self,
92 + layer: torch.nn.Module,
93 + input_size_per_partition: int,
94 + output_partition_sizes: list[int],
95 + input_size: int,
96 + output_size: int,
97 + params_dtype: torch.dtype,
98 + **extra_weight_attrs,
99 + ):
100 + if input_size_per_partition % MXFP4_BLOCK_SIZE != 0:
101 + raise ValueError(
102 + f"MXFP4 requires input_size_per_partition "
103 + f"({input_size_per_partition}) to be divisible by "
104 + f"{MXFP4_BLOCK_SIZE}."
105 + )
106 +
107 + super().create_weights(
108 + layer,
109 + input_size_per_partition,
110 + output_partition_sizes,
111 + input_size,
112 + output_size,
113 + params_dtype,
114 + **extra_weight_attrs,
115 + )
116 +
117 + def process_weights_after_loading(self, layer: Module) -> None:
118 + if getattr(layer, "_already_called_process_weights_after_loading", False):
119 + return
120 +
121 + weight_fp4, weight_scale = mxfp4_quantize(layer.weight.contiguous())
122 +
123 + layer.input_scale = None
124 + replace_parameter(layer, "weight", weight_fp4.data)
125 + replace_parameter(layer, "weight_scale", weight_scale.data)
126 +
127 + self.kernel.process_weights_after_loading(layer)
128 +
129 + layer._already_called_process_weights_after_loading = True
130 +
131 + def apply(
132 + self,
133 + layer: torch.nn.Module,
134 + x: torch.Tensor,
135 + bias: torch.Tensor | None = None,
136 + ) -> torch.Tensor:
137 + return self.kernel.apply_weights(layer, x, bias)
138 +
139 +
140 +class Mxfp4OnlineMoEMethod(OnlineMoEMethodBase):
141 + """MoE method for online MXFP4 (block) quantization."""
142 +
143 + mxfp4_backend: Mxfp4MoeBackend
144 + experts_cls: "type[mk.FusedMoEExperts] | None"
145 + activation_quant_key = kMxfp4Dynamic
146 +
147 + def __init__(self, *, layer: torch.nn.Module):
148 + super().__init__(layer.moe_config)
149 + self.weight_block_size: list[int] = [1, MXFP4_BLOCK_SIZE]
150 + self.weight_scale_name = "weight_scale"
151 +
152 + self.mxfp4_backend, self.experts_cls = select_mxfp4_moe_backend(
153 + config=self.moe, activation_key=self.activation_quant_key
154 + )
155 +
156 + def maybe_roundup_sizes(
157 + self,
158 + hidden_size: int,
159 + intermediate_size_per_partition: int,
160 + act_dtype: torch.dtype,
161 + moe_parallel_config: "FusedMoEParallelConfig",
162 + ) -> tuple[int, int]:
163 + hidden_size, intermediate_size_per_partition = super().maybe_roundup_sizes(
164 + hidden_size=hidden_size,
165 + intermediate_size_per_partition=intermediate_size_per_partition,
166 + act_dtype=act_dtype,
167 + moe_parallel_config=moe_parallel_config,
168 + )
169 + return mxfp4_round_up_hidden_size_and_intermediate_size(
170 + self.mxfp4_backend, hidden_size, intermediate_size_per_partition
171 + )
172 +
173 + def create_weights(
174 + self,
175 + layer: Module,
176 + num_experts: int,
177 + hidden_size: int,
178 + intermediate_size_per_partition: int,
179 + params_dtype: torch.dtype,
180 + **extra_weight_attrs,
181 + ):
182 + if (
183 + hidden_size % MXFP4_BLOCK_SIZE != 0
184 + or intermediate_size_per_partition % MXFP4_BLOCK_SIZE != 0
185 + ):
186 + raise ValueError(
187 + "Online MXFP4 MoE requires hidden/intermediate sizes divisible "
188 + f"by {MXFP4_BLOCK_SIZE}."
189 + )
190 +
191 + super().create_weights(
192 + layer=layer,
193 + num_experts=num_experts,
194 + hidden_size=hidden_size,
195 + intermediate_size_per_partition=intermediate_size_per_partition,
196 + params_dtype=params_dtype,
197 + **extra_weight_attrs,
198 + )
199 +
200 + layer.weight_block_size = [1, MXFP4_BLOCK_SIZE]
201 +
202 + def _setup_kernel(
203 + self,
204 + layer: "RoutedExperts",
205 + w13: torch.Tensor,
206 + w2: torch.Tensor,
207 + w13_scale: torch.Tensor,
208 + w2_scale: torch.Tensor,
209 + w13_bias: torch.Tensor | None,
210 + w2_bias: torch.Tensor | None,
211 + ) -> None:
212 + w13, w2, w13_scale, w2_scale, w13_bias, w2_bias = (
213 + convert_weight_to_mxfp4_moe_kernel_format(
214 + mxfp4_backend=self.mxfp4_backend,
215 + layer=layer,
216 + w13_weight=w13,
217 + w2_weight=w2,
218 + w13_weight_scale=w13_scale,
219 + w2_weight_scale=w2_scale,
220 + w13_bias=w13_bias,
221 + w2_bias=w2_bias,
222 + )
223 + )
224 +
225 + # Handle weight/scale assignment based on backend type
226 + # Copied from `quark_moe.py`.
227 + # TODO: This should not be here, replace_parameter should handle
228 + # triton_kernels.tensor.Tensor.
229 + if self.mxfp4_backend in TRITON_BACKENDS or self.mxfp4_backend in (
230 + Mxfp4MoeBackend.AITER_MXFP4_FP8,
231 + ):
232 + # Triton-based backends: w13/w2 are triton_kernels.tensor.Tensor
233 + # Store on layer for apply(), scales are PrecisionConfig
234 + layer.w13_weight = w13
235 + layer.w2_weight = w2
236 + self.w13_precision_config = w13_scale
237 + self.w2_precision_config = w2_scale
238 + else:
239 + # Standard backends: replace parameters
240 + replace_parameter(layer, "w13_weight", w13)
241 + replace_parameter(layer, "w2_weight", w2)
242 + replace_parameter(layer, "w13_weight_scale", w13_scale)
243 + replace_parameter(layer, "w2_weight_scale", w2_scale)
244 +
245 + if w13_bias is not None and w2_bias is not None:
246 + replace_parameter(layer, "w13_bias", w13_bias)
247 + replace_parameter(layer, "w2_bias", w2_bias)
248 +
249 + self.moe_quant_config = self.get_fused_moe_quant_config(layer)
250 + if self.moe_quant_config is not None and self.experts_cls is not None:
251 + self.moe_kernel = make_mxfp4_moe_kernel(
252 + moe_quant_config=self.moe_quant_config,
253 + moe_config=self.moe,
254 + mxfp4_backend=self.mxfp4_backend,
255 + experts_cls=self.experts_cls,
256 + routing_tables=layer._expert_routing_tables(),
257 + layer=layer,
258 + )
259 +
260 + def get_fused_moe_quant_config(
261 + self, layer: torch.nn.Module
262 + ) -> "FusedMoEQuantConfig | None":
263 + # NOTE: unlike `QuarkOCP_MX_MoEMethod.get_fused_moe_quant_config`, this
264 + # does not branch on `self.mxfp4_backend in TRITON_BACKENDS or
265 + # AITER_MXFP4_FP8` to read `self.w13_precision_config`/
266 + # `self.w2_precision_config` instead of `layer.w13_weight_scale`/
267 + # `layer.w2_weight_scale`. For those backends,
268 + # `convert_weight_to_mxfp4_moe_kernel_format` deletes the latter, so
269 + # `getattr` below would raise `AttributeError`. This is currently
270 + # unreachable because `activation_quant_key` is hardcoded to
271 + # `kMxfp4Dynamic`
272 + # TODO: When supporting online activation quant key override,
273 + # fix this convert_weight_to_mxfp4_moe_kernel_format issue.
274 + w1_scale = getattr(layer, f"w13_{self.weight_scale_name}")
275 + w2_scale = getattr(layer, f"w2_{self.weight_scale_name}")
276 +
277 + return make_mxfp4_moe_quant_config(
278 + mxfp4_backend=self.mxfp4_backend,
279 + w1_scale=w1_scale,
280 + w2_scale=w2_scale,
281 + w1_bias=getattr(layer, "w13_bias", None),
282 + w2_bias=getattr(layer, "w2_bias", None),
283 + a1_scale=layer.w13_input_scale,
284 + a2_scale=layer.w2_input_scale,
285 + swiglu_limit=getattr(layer, "swiglu_limit", None),
286 + gemm1_alpha=getattr(layer, "swiglu_alpha", None),
287 + gemm1_beta=getattr(layer, "swiglu_beta", None),
288 + layer=layer,
289 + )
290 +
291 + def process_weights_after_loading(self, layer: Module) -> None:
292 + if getattr(layer, "_already_called_process_weights_after_loading", False):
293 + return
294 +
295 + self._zero_padding(layer)
296 +
297 + if self.mxfp4_backend == Mxfp4MoeBackend.NONE:
298 + layer._already_called_process_weights_after_loading = True
299 + return
300 +
301 + layer.w13_input_scale = None
302 + layer.w2_input_scale = None
303 +
304 + w13, w13_scale = _quantize_mxfp4_moe_weight(layer.w13_weight)
305 + w2, w2_scale = _quantize_mxfp4_moe_weight(layer.w2_weight)
306 +
307 + self._setup_kernel(
308 + layer,
309 + w13,
310 + w2,
311 + w13_scale,
312 + w2_scale,
313 + getattr(layer, "w13_bias", None),
314 + getattr(layer, "w2_bias", None),
315 + )
316 +
317 + layer._already_called_process_weights_after_loading = True
vllm/model_executor/layers/quantization/utils/mxfp4_utils.py
+317
@@ -6,6 +6,7 @@ import torch
6
7 from vllm.logger import init_logger
8 from vllm.platforms import current_platform
9 +from vllm.triton_utils import tl, triton
10 from vllm.utils.import_utils import has_triton_kernels
11 from vllm.utils.torch_utils import direct_register_custom_op, is_torch_equal_or_newer
12
@@ -184,3 +185,319 @@ except AttributeError as error:
185
186 def xpu_mxfp4_quantize(x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
187 return torch.ops.vllm.xpu_mxfp4_quantize(x)
188 +
189 +
190 +def permute_shape(shape: tuple[int, ...], permute_order: list[int]) -> tuple[int, ...]:
191 + return tuple(shape[i] for i in permute_order)
192 +
193 +
194 +@triton.jit
195 +def _compute_quant_and_scale(src_tensor, valid_src_mask):
196 + BLOCK_SIZE_OUT_DIM: tl.constexpr = src_tensor.shape[0]
197 + BLOCK_SIZE_QUANT_DIM: tl.constexpr = src_tensor.shape[1]
198 + BLOCK_SIZE_QUANT_MX_SCALE: tl.constexpr = src_tensor.shape[1] // 32
199 +
200 + # Explicit cast to fp32 since most ops are not supported on bfloat16. We
201 + # avoid needless conversions to and from bf16
202 + f32_tensor = src_tensor.to(tl.float32)
203 + abs_tensor = tl.abs(f32_tensor)
204 + abs_tensor = tl.where(
205 + valid_src_mask, abs_tensor, -1.0
206 + ) # Don't consider padding tensors in scale computation
207 + abs_tensor = tl.reshape(
208 + abs_tensor, [BLOCK_SIZE_OUT_DIM, BLOCK_SIZE_QUANT_MX_SCALE, 32]
209 + )
210 + max_val = tl.max(abs_tensor, axis=2, keep_dims=True)
211 + # Round-to-nearest-even block scale (OCP MX spec), matching hardware/aiter
212 + # MXFP4 quantizers: compute 2 ** (floor(log2(rounding(max_abs(v)))) - max_exp).
213 + max_val = max_val.to(tl.int32, bitcast=True)
214 + max_val = (max_val + 0x200000).to(tl.uint32, bitcast=True) & 0x7F800000
215 + max_val = max_val.to(tl.float32, bitcast=True)
216 + # Add epsilon to prevent log2(0) = -inf when max_val is zero (all-zero input block)
217 + eps = tl.where(max_val == 0.0, 2 ** (-126), 0.0)
218 + scale_e8m0_unbiased = tl.log2(max_val + eps).floor() - 2
219 + scale_e8m0_unbiased = tl.clamp(scale_e8m0_unbiased, min=-127, max=127)
220 + dequant_scale_rounded = tl.exp2(scale_e8m0_unbiased)
221 + dequant_scale_exponent = dequant_scale_rounded.to(tl.uint32, bitcast=True)
222 +
223 + dequant_scale_rounded = dequant_scale_exponent.to(tl.float32, bitcast=True)
224 + quant_scale = tl.where(dequant_scale_rounded == 0, 0, 1.0 / dequant_scale_rounded)
225 +
226 + f32_tensor = tl.reshape(
227 + f32_tensor, [BLOCK_SIZE_OUT_DIM, BLOCK_SIZE_QUANT_MX_SCALE, 32]
228 + )
229 + quant_tensor = f32_tensor * quant_scale
230 +
231 + # Reshape the tensors after scaling
232 + quant_tensor = quant_tensor.reshape([BLOCK_SIZE_OUT_DIM, BLOCK_SIZE_QUANT_DIM])
233 + # Set the invalid portions of the tensor to 0. This will ensure that any
234 + # padding tensors are 0 in the mx format.
235 + quant_tensor = tl.where(valid_src_mask, quant_tensor, 0)
236 + dequant_scale_exponent = dequant_scale_exponent.reshape(
237 + [BLOCK_SIZE_OUT_DIM, BLOCK_SIZE_QUANT_MX_SCALE]
238 + )
239 +
240 + # First, we simply extract the exponent part of the scales and store the result
241 + dequant_scale_exponent = (dequant_scale_exponent >> 23).to(tl.uint8)
242 + # Now we must convert the tensor to packed e2m1 (mxfp4).
243 + quant_tensor = quant_tensor.to(tl.uint32, bitcast=True)
244 + signs = quant_tensor & 0x80000000
245 + exponents = (quant_tensor >> 23) & 0xFF
246 + mantissas = quant_tensor & 0x7FFFFF
247 +
248 + # 0.25 <= x < 0.75 maps to 0.5, a denormal number
249 + E8_BIAS = 127
250 + E2_BIAS = 1
251 + # Move implicit bit 1 at the beginning to mantissa for denormals. Bits
252 + # shifted out are OR-reduced into the LSB as a sticky bit, so the
253 + # round-to-even logic below can tell an exact tie from a value that is
254 + # merely close to one.
255 + adjusted_exponents = tl.core.sub(E8_BIAS, exponents + 1, sanitize_overflow=False)
256 + folded_mantissas = 0x400000 | (mantissas >> 1)
257 + denormal_mantissas = folded_mantissas >> adjusted_exponents
258 + denormal_mantissas |= (
259 + (denormal_mantissas << adjusted_exponents) != folded_mantissas
260 + ).to(tl.uint32)
261 + mantissas = tl.where(exponents < E8_BIAS, denormal_mantissas, mantissas)
262 +
263 + # For normal numbers, we change the bias from 127 to 1, and for subnormals,
264 + # we keep exponent as 0.
265 + exponents = tl.maximum(exponents, E8_BIAS - E2_BIAS) - (E8_BIAS - E2_BIAS)
266 +
267 + # Combine sign, exponent, and mantissa, rounding to nearest with ties
268 + # broken to even (OCP MX spec, matching hardware/aiter MXFP4
269 + # quantizers) via the standard "add a rounding bias, then truncate"
270 + # trick: the bias's LSB is the kept mantissa bit, so an exact tie
271 + # rounds to whichever candidate is even, while any lower (sticky) bit
272 + # being set always rounds up.
273 + rounding_bias = ((mantissas >> 22) & 1) + 0x1FFFFF
274 + rounded_mantissa = (mantissas + rounding_bias) >> 22 # in {0, 1, 2}
275 + e2m1_tmp = tl.minimum((exponents << 1) + rounded_mantissa, 0x7)
276 + e2m1_value = ((signs >> 28) | e2m1_tmp).to(tl.uint8)
277 +
278 + e2m1_value = tl.reshape(
279 + e2m1_value, [BLOCK_SIZE_OUT_DIM, BLOCK_SIZE_QUANT_DIM // 2, 2]
280 + )
281 + evens, odds = tl.split(e2m1_value)
282 + out_tensor = evens | (odds << 4)
283 +
284 + return out_tensor, dequant_scale_exponent
285 +
286 +
287 +@triton.jit
288 +def _downcast_to_mxfp(
289 + mx_tensor_ptr,
290 + stride_mxt_outer,
291 + stride_mxt_quant: tl.constexpr,
292 + mx_scale_ptr,
293 + stride_mx_scale_outer,
294 + stride_mx_scale_quant,
295 + src_ptr,
296 + stride_src_outer,
297 + stride_src_quant,
298 + outer_dim,
299 + quant_dim,
300 + BLOCK_SIZE_OUT_DIM: tl.constexpr,
301 + BLOCK_SIZE_QUANT_DIM: tl.constexpr,
302 +):
303 + tl.static_assert(
304 + stride_mxt_quant == 1, f"Output stride, {stride_mxt_quant=} must be 1."
305 + )
306 + tl.static_assert(
307 + BLOCK_SIZE_QUANT_DIM % 32 == 0,
308 + f"{BLOCK_SIZE_QUANT_DIM=} must be a multiple of 32",
309 + )
310 +
311 + # uint8 signifies two fp4 e2m1 values packed into a single byte
312 + tl.static_assert(
313 + mx_tensor_ptr.dtype.element_ty == tl.uint8,
314 + f"Invalid {mx_tensor_ptr.dtype.element_ty=}. Must be uint8.",
315 + )
316 +
317 + src_dtype: tl.constexpr = src_ptr.dtype.element_ty
318 + tl.static_assert(
319 + mx_scale_ptr.dtype.element_ty == tl.uint8,
320 + f"{mx_scale_ptr.dtype.element_ty=} must be uint8",
321 + )
322 + tl.static_assert(
323 + (src_dtype == tl.bfloat16) or (src_dtype == tl.float16),
324 + f"{src_dtype=} must be bfloat16 or float16",
325 + )
326 +
327 + outer_block = tl.program_id(0).to(tl.int64)
328 + quant_block = tl.program_id(1).to(tl.int64)
329 +
330 + BLOCK_SIZE_QUANT_MX_SCALE: tl.constexpr = BLOCK_SIZE_QUANT_DIM // 32
331 + BLOCK_SIZE_QUANT_MX_TENSOR: tl.constexpr = BLOCK_SIZE_QUANT_DIM // 2
332 +
333 + start_src_quant = quant_block * BLOCK_SIZE_QUANT_DIM
334 + start_mx_scale_quant = quant_block * BLOCK_SIZE_QUANT_MX_SCALE
335 + start_mx_quant = quant_block * BLOCK_SIZE_QUANT_MX_TENSOR
336 + start_out = outer_block * BLOCK_SIZE_OUT_DIM
337 +
338 + src_ptr += start_src_quant * stride_src_quant + start_out * stride_src_outer
339 + mx_scale_ptr += (
340 + start_mx_scale_quant * stride_mx_scale_quant + start_out * stride_mx_scale_outer
341 + )
342 + mx_tensor_ptr += start_mx_quant * stride_mxt_quant + start_out * stride_mxt_outer
343 +
344 + offs_src_quant = tl.arange(0, BLOCK_SIZE_QUANT_DIM)[None, :].to(tl.int64)
345 + offs_mxt_quant = tl.arange(0, BLOCK_SIZE_QUANT_MX_TENSOR)[None, :].to(tl.int64)
346 + offs_scale_quant = tl.arange(0, BLOCK_SIZE_QUANT_MX_SCALE)[None, :].to(tl.int64)
347 + offs_outer = tl.arange(0, BLOCK_SIZE_OUT_DIM)[:, None].to(tl.int64)
348 +
349 + mask_src_quant = start_src_quant + offs_src_quant < quant_dim
350 + mask_n = start_out + offs_outer < outer_dim
351 + full_mask_src = mask_src_quant and mask_n
352 +
353 + mask_mxt_quant = start_mx_quant + offs_mxt_quant < tl.cdiv(quant_dim, 2)
354 + full_mask_mxt = mask_mxt_quant and mask_n
355 +
356 + scale_mask_k = start_mx_scale_quant + offs_scale_quant < tl.cdiv(quant_dim, 32)
357 + full_scale_mask = scale_mask_k and mask_n
358 +
359 + src_tensor_offsets = (
360 + offs_src_quant * stride_src_quant + offs_outer * stride_src_outer
361 + )
362 + mx_scale_offsets = (
363 + offs_scale_quant * stride_mx_scale_quant + offs_outer * stride_mx_scale_outer
364 + )
365 + mx_tensor_offsets = (
366 + offs_mxt_quant * stride_mxt_quant + offs_outer * stride_mxt_outer
367 + )
368 + src_tensor = tl.load(src_ptr + src_tensor_offsets, mask=full_mask_src)
369 +
370 + out_tensor, scale_tensor = _compute_quant_and_scale(src_tensor, full_mask_src)
371 +
372 + tl.store(mx_scale_ptr + mx_scale_offsets, scale_tensor, mask=full_scale_mask)
373 + tl.store(mx_tensor_ptr + mx_tensor_offsets, out_tensor, mask=full_mask_mxt)
374 +
375 +
376 +def downcast_to_mxfp(
377 + src_tensor: torch.Tensor,
378 + axis: int,
379 + out_quant_tensor: torch.Tensor | None = None,
380 + out_scale: torch.Tensor | None = None,
381 + BLOCK_OUT_DIM: int = 128,
382 + BLOCK_QUANT_DIM: int = 32,
383 +):
384 + """
385 + Convert the src weights to MXFP4. The src weight is quantized along the
386 + axis dimension into packed e2m1 values (torch.uint8, two values per
387 + byte), so the size of that dimension in the output is half of the
388 + logical (unpacked) size.
389 + """
390 + out_quant_type = torch.uint8
391 + ndim = src_tensor.ndim
392 + assert -ndim <= axis < ndim, f"Invalid axis {axis=}"
393 + axis = axis if axis >= 0 else axis + ndim
394 +
395 + L = src_tensor.shape[axis]
396 + # We make this assertion since we can't track if the "real" shape was odd,
397 + # and we padded it to be even.
398 + # We want to maintain the property dequant(quant(x)).shape == x.shape
399 + assert L % 2 == 0, f"axis dim must be divisible by 2 for e2m1. Got {L}"
400 +
401 + device = src_tensor.device
402 +
403 + packed_quant_dim = triton.cdiv(L, 2)
404 + out_scale_dim = triton.cdiv(L, 32)
405 +
406 + # Move the quantization axis to the end for the kernel, then permute back.
407 + permute_order = list(range(ndim))
408 + permute_order[axis], permute_order[-1] = permute_order[-1], permute_order[axis]
409 +
410 + prmted_quant_tensor_shape = permute_shape(src_tensor.shape, permute_order)[:-1] + (
411 + packed_quant_dim,
412 + )
413 + prmted_scale_shape = permute_shape(src_tensor.shape, permute_order)[:-1] + (
414 + out_scale_dim,
415 + )
416 + prmted_src_tensor = src_tensor.permute(permute_order)
417 +
418 + if out_quant_tensor is None:
419 + out_quant_tensor = torch.empty(
420 + prmted_quant_tensor_shape, dtype=out_quant_type, device=device
421 + )
422 + else:
423 + expected_shape = (
424 + src_tensor.shape[:axis] + (packed_quant_dim,) + src_tensor.shape[axis + 1 :]
425 + )
426 + assert out_quant_tensor.shape == expected_shape, (
427 + f"{out_quant_tensor.shape=} != {expected_shape=}"
428 + )
429 + assert out_quant_tensor.dtype == out_quant_type, (
430 + f"{out_quant_tensor.dtype=} != {out_quant_type=}"
431 + )
432 + assert out_quant_tensor.stride(axis) == 1, (
433 + f"{out_quant_tensor.stride(axis)=} != 1"
434 + )
435 + # We expect the axis dimension to be last, so permute the tensor
436 + out_quant_tensor = out_quant_tensor.permute(permute_order)
437 +
438 + if out_scale is None:
439 + out_scale = torch.empty(prmted_scale_shape, dtype=torch.uint8, device=device)
440 + else:
441 + expected_scale_shape = permute_shape(prmted_scale_shape, permute_order)
442 + assert out_scale.shape == expected_scale_shape, (
443 + f"{out_scale.shape=} {expected_scale_shape=}"
444 + )
445 + assert out_scale.dtype == torch.uint8, f"{out_scale.dtype=} != torch.uint8"
446 + out_scale = out_scale.permute(permute_order)
447 +
448 + # Flatten input tensor for kernel. This will typically make a copy
449 + reshaped_src_tensor = prmted_src_tensor.reshape(-1, L)
450 + blocks_quant_dim = triton.cdiv(reshaped_src_tensor.shape[-1], BLOCK_QUANT_DIM)
451 + blocks_out_dim = triton.cdiv(reshaped_src_tensor.shape[0], BLOCK_OUT_DIM)
452 +
453 + # Flatten the output tensors for the kernel, this should be a view always
454 + kernel_quant_tensor = out_quant_tensor.reshape(-1, packed_quant_dim)
455 + kernel_scale = out_scale.reshape(-1, out_scale_dim)
456 + assert kernel_quant_tensor.data_ptr() == out_quant_tensor.data_ptr()
457 + assert kernel_scale.data_ptr() == out_scale.data_ptr()
458 +
459 + _downcast_to_mxfp[(blocks_out_dim, blocks_quant_dim)](
460 + kernel_quant_tensor,
461 + *kernel_quant_tensor.stride(),
462 + kernel_scale,
463 + *kernel_scale.stride(),
464 + reshaped_src_tensor,
465 + *reshaped_src_tensor.stride(),
466 + *reshaped_src_tensor.shape,
467 + BLOCK_OUT_DIM,
468 + BLOCK_QUANT_DIM,
469 + num_warps=8,
470 + )
471 +
472 + out_quant_tensor = out_quant_tensor.permute(permute_order)
473 + out_scale = out_scale.permute(permute_order).contiguous()
474 + return out_quant_tensor, out_scale, permute_shape(prmted_scale_shape, permute_order)
475 +
476 +
477 +def mxfp4_quantize(x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
478 + """Quantize a bf16/fp16 tensor to MXFP4 along its last dimension.
479 +
480 + Dispatches to the fastest backend available on the current platform:
481 +
482 + - the native XPU custom op on XPU,
483 + - aiter on ROCm when `aiter` is installed,
484 + - and the portable Triton kernel (``downcast_to_mxfp``) otherwise.
485 +
486 + Returns packed FP4 values (uint8, two values per byte) and per-block
487 + (group-32) e8m0 scales (uint8).
488 + """
489 + if current_platform.is_xpu():
490 + return xpu_mxfp4_quantize(x)
491 +
492 + from vllm._aiter_ops import is_aiter_found_and_supported
493 +
494 + if is_aiter_found_and_supported() and x.dtype == torch.bfloat16:
495 + from vllm.model_executor.layers.quantization.quark.utils import (
496 + quark_quantize_weight_to_mxfp4,
497 + )
498 +
499 + return quark_quantize_weight_to_mxfp4(x)
500 +
501 + quant_tensor, scale, _ = downcast_to_mxfp(x, axis=-1)
502 +
503 + return quant_tensor, scale
vllm/utils/import_utils.py
+5
@@ -565,6 +565,11 @@ def has_humming() -> bool:
565 return _has_module("humming")
566
567
568 +def has_quark():
569 + """Whether the optional `quark` package is available."""
570 + return _has_module("quark")
571 +
572 +
573 def check_torchcodec_available():
574 """Whether the optional `torchcodec` package is available."""
575 try: