[ROCm][Quantization] Support AMD Quark per-block FP8 for fused MoE layers (#52263)

Signed-off-by: huji <huidong.ji@amd.com> Signed-off-by: jimmy-adams <41593649+jimmy-adams@users.noreply.github.com>

jimmy-adams committed Sep 8, 2026 at 18:30 UTC e41a17e606c9a8ffa639ebde5bce885667ff27ea
5 files changed +283 -56
tests/quantization/test_quark.py
+158 -1
@@ -11,7 +11,7 @@ import importlib.metadata
11 from dataclasses import dataclass
12 from importlib.util import find_spec
13 from types import SimpleNamespace
14 -from unittest.mock import MagicMock, patch
14 +from unittest.mock import MagicMock, Mock, patch
15
16 import huggingface_hub
17 import lm_eval
@@ -26,6 +26,7 @@ from vllm.config.cache import CacheConfig
26 from vllm.forward_context import set_forward_context
27 from vllm.model_executor.layers.attention import Attention
28 from vllm.model_executor.layers.fused_moe import (
29 + FusedMoeWeightScaleSupported,
30 RoutedExperts,
31 UnquantizedFusedMoEMethod,
32 )
@@ -48,6 +49,7 @@ from vllm.model_executor.layers.quantization.quark.quark import ( # noqa: E501
49 from vllm.model_executor.layers.quantization.quark.quark_moe import ( # noqa: E501
50 QuarkMoEMethod,
51 QuarkW4A8Fp8MoEMethod,
52 + QuarkW8A8Fp8MoEMethod,
53 QuarkW8A8Int8MoEMethod,
54 )
55 from vllm.model_executor.layers.quantization.quark.schemes import QuarkScheme
@@ -247,6 +249,26 @@ QTENSOR_CONFIGS = [
249 act_quant_key=kFp8Dynamic128Sym,
250 dispatch_cls=QuarkW8A8Fp8PerBlock,
251 ),
252 + QTensorConfig(
253 + name="fp8_w8a8_dynamic_block_fp32_moe",
254 + weight={
255 + "dtype": "fp8_e4m3",
256 + "qscheme": "per_block",
257 + "is_dynamic": False,
258 + "block_size": [128, 128],
259 + "symmetric": True,
260 + },
261 + input_tensors={
262 + "dtype": "fp8_e4m3",
263 + "qscheme": "per_group",
264 + "is_dynamic": True,
265 + "group_size": 128,
266 + "symmetric": True,
267 + },
268 + weight_quant_key=kFp8Static128BlockSym,
269 + act_quant_key=kFp8Dynamic128Sym,
270 + dispatch_cls=QuarkW8A8Fp8MoEMethod,
271 + ),
272 QTensorConfig(
273 name="fp8_w8a8_block_static_input",
274 weight={
@@ -968,12 +990,111 @@ def test_quant_method_dispatch_instantiation(case, monkeypatch, default_vllm_con
990 lambda: True,
991 )
992
993 + # default_vllm_config carries no model, but the FP8 and OCP MX methods
994 + # read the model type off the HF config.
995 + monkeypatch.setattr(
996 + "vllm.model_executor.layers.quantization.quark.quark_moe."
997 + "get_current_vllm_config",
998 + lambda: SimpleNamespace(
999 + model_config=SimpleNamespace(hf_config=SimpleNamespace())
1000 + ),
1001 + )
1002 +
1003 layer = TestRoutedExperts()
1004 method = config.get_quant_method(layer, "experts")
1005
1006 assert isinstance(method, case.dispatch_cls)
1007
1008
1009 +QUARK_MOE_MODULE = "vllm.model_executor.layers.quantization.quark.quark_moe"
1010 +# validate_fp8_block_shape_moe imports this from vllm.distributed when called,
1011 +# so the patch has to target the source module rather than a local binding.
1012 +TP_WORLD_SIZE = "vllm.distributed.get_tensor_model_parallel_world_size"
1013 +
1014 +
1015 +def _make_per_block_fp8_moe_method(
1016 + activation_quant_key: QuantKey = kFp8Dynamic128Sym,
1017 +) -> QuarkW8A8Fp8MoEMethod:
1018 + return QuarkW8A8Fp8MoEMethod(
1019 + _make_test_moe_config(),
1020 + kFp8Static128BlockSym,
1021 + activation_quant_key,
1022 + )
1023 +
1024 +
1025 +def test_quark_w8a8_fp8_moe_per_block_requires_dynamic_group_input():
1026 + with (
1027 + patch(
1028 + f"{QUARK_MOE_MODULE}.select_fp8_moe_backend",
1029 + return_value=(Mock(), Mock()),
1030 + ),
1031 + patch(f"{QUARK_MOE_MODULE}.get_current_vllm_config"),
1032 + pytest.raises(ValueError, match="per-block scales"),
1033 + ):
1034 + _make_per_block_fp8_moe_method(kFp8StaticTensorSym)
1035 +
1036 +
1037 +def test_quark_w8a8_fp8_moe_per_block_weight_shapes():
1038 + with (
1039 + patch(
1040 + f"{QUARK_MOE_MODULE}.select_fp8_moe_backend",
1041 + return_value=(Mock(), Mock()),
1042 + ),
1043 + patch(f"{QUARK_MOE_MODULE}.get_current_vllm_config"),
1044 + patch(TP_WORLD_SIZE, return_value=1),
1045 + ):
1046 + method = _make_per_block_fp8_moe_method()
1047 + assert method.block_quant
1048 + assert method.weight_block_size == [128, 128]
1049 +
1050 + layer = torch.nn.Module()
1051 + method.create_weights(
1052 + layer,
1053 + num_experts=4,
1054 + hidden_size=512,
1055 + intermediate_size_per_partition=256,
1056 + params_dtype=torch.bfloat16,
1057 + )
1058 +
1059 + w13_num_shards = method.moe.w13_num_shards
1060 + assert layer.weight_block_size == [128, 128]
1061 + assert layer.w13_weight.shape == (4, w13_num_shards * 256, 512)
1062 + assert layer.w2_weight.shape == (4, 512, 256)
1063 + # Quark exports block scales as `weight_scale`, like the per-tensor and
1064 + # per-channel schemes, so all schemes register the same parameter name.
1065 + assert not hasattr(layer, "w13_weight_scale_inv")
1066 + assert not hasattr(layer, "w2_weight_scale_inv")
1067 + # One scale per 128x128 tile of each expert's weight.
1068 + assert layer.w13_weight_scale.shape == (
1069 + 4,
1070 + w13_num_shards * (256 // 128),
1071 + 512 // 128,
1072 + )
1073 + assert layer.w2_weight_scale.shape == (4, 512 // 128, 256 // 128)
1074 + # The loader shards block scales on the block grid, not per row.
1075 + for scale in (layer.w13_weight_scale, layer.w2_weight_scale):
1076 + assert scale.quant_method == FusedMoeWeightScaleSupported.BLOCK.value
1077 +
1078 +
1079 +def test_quark_w8a8_fp8_moe_per_block_rejects_misaligned_partition():
1080 + with (
1081 + patch(
1082 + f"{QUARK_MOE_MODULE}.select_fp8_moe_backend",
1083 + return_value=(Mock(), Mock()),
1084 + ),
1085 + patch(f"{QUARK_MOE_MODULE}.get_current_vllm_config"),
1086 + patch(TP_WORLD_SIZE, return_value=1),
1087 + pytest.raises(ValueError, match="not divisible by"),
1088 + ):
1089 + _make_per_block_fp8_moe_method().create_weights(
1090 + torch.nn.Module(),
1091 + num_experts=4,
1092 + hidden_size=512,
1093 + intermediate_size_per_partition=192,
1094 + params_dtype=torch.bfloat16,
1095 + )
1096 +
1097 +
1098 @pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8"])
1099 def test_quark_fp8_w_per_tensor_a_per_tensor(
1100 kv_cache_dtype: str, monkeypatch, dist_init, workspace_init
@@ -1111,6 +1232,42 @@ def test_quark_int8_w8a8_moe(vllm_runner, tp):
1232 assert output
1233
1234
1235 +@pytest.mark.parametrize("tp", [1])
1236 +def test_quark_fp8_w8a8_per_block_moe(vllm_runner, tp):
1237 + """Test per-block (128x128) FP8 MoE quantization with a tiny Qwen3 MoE model."""
1238 + model_path = "Adamji/tiny-qwen3-moe-fp8-per-block"
1239 + with vllm_runner(
1240 + model_path,
1241 + enforce_eager=True,
1242 + tensor_parallel_size=tp,
1243 + gpu_memory_utilization=0.1,
1244 + ) as llm:
1245 +
1246 + def check_model(model):
1247 + experts = model.model.layers[0].mlp.experts
1248 + method = experts._quant_method
1249 + assert isinstance(method, QuarkW8A8Fp8MoEMethod), (
1250 + f"Expected QuarkW8A8Fp8MoEMethod, got {type(method)}"
1251 + )
1252 + assert method.weight_qscheme == "per_block"
1253 + assert method.weight_block_size == [128, 128]
1254 +
1255 + # hidden_size=128, moe_intermediate_size=256 and 4 experts, so one
1256 + # scale per 128x128 tile, with w13 stacking gate on top of up.
1257 + routed_experts = experts.routed_experts
1258 + assert routed_experts.w13_weight_scale.shape == (4, 4, 1)
1259 + assert routed_experts.w2_weight_scale.shape == (4, 1, 2)
1260 + # Quark exports the block scales under the same name as the
1261 + # per-tensor and per-channel schemes.
1262 + assert not hasattr(routed_experts, "w13_weight_scale_inv")
1263 + assert not hasattr(routed_experts, "w2_weight_scale_inv")
1264 +
1265 + llm.apply_model(check_model)
1266 +
1267 + output = llm.generate_greedy("Hello", max_tokens=4)
1268 + assert output
1269 +
1270 +
1271 @pytest.mark.skipif(
1272 not (on_gfx950() or on_gfx942()),
1273 reason="Quark W4A8 (INT4-FP8) MoE requires the AITER kernel on gfx942/gfx950",
vllm/model_executor/layers/quantization/compressed_tensors/compressed_tensors_moe/compressed_tensors_moe_w8a8_fp8.py
+5 -19
@@ -8,7 +8,6 @@ from compressed_tensors.quantization import (
8 QuantizationStrategy,
9 )
10
11 -from vllm.distributed import get_tensor_model_parallel_world_size
11 from vllm.logger import init_logger
12 from vllm.model_executor.layers.fused_moe import (
13 FusedMoeWeightScaleSupported,
@@ -31,6 +30,7 @@ from vllm.model_executor.layers.quantization.compressed_tensors.compressed_tenso
30 from vllm.model_executor.layers.quantization.utils.fp8_utils import (
31 process_fp8_input_tensor_strategy_moe,
32 process_fp8_weight_tensor_strategy_moe,
33 + validate_fp8_block_shape_moe,
34 )
35 from vllm.model_executor.layers.quantization.utils.quant_utils import (
36 kFp8Dynamic128Sym,
@@ -129,28 +129,14 @@ class CompressedTensorsW8A8Fp8MoEMethod(CompressedTensorsMoEMethod):
129 if self.block_quant:
130 assert self.weight_block_size is not None
131 layer.weight_block_size = self.weight_block_size
132 - tp_size = get_tensor_model_parallel_world_size()
132 block_n, block_k = (
133 self.weight_block_size[0],
134 self.weight_block_size[1],
135 )
137 - # NOTE: To ensure proper alignment of the block-wise quantization
138 - # scales, the output_size of the weights for both the gate and up
139 - # layers must be divisible by block_n.
140 - # Required by column parallel or enabling merged weights
141 - if intermediate_size_per_partition % block_n != 0:
142 - raise ValueError(
143 - f"The output_size of gate's and up's weight = "
144 - f"{intermediate_size_per_partition} is not divisible by "
145 - f"weight quantization block_n = {block_n}."
146 - )
147 - if tp_size > 1 and intermediate_size_per_partition % block_k != 0:
148 - # Required by row parallel
149 - raise ValueError(
150 - f"The input_size of down's weight = "
151 - f"{intermediate_size_per_partition} is not divisible by "
152 - f"weight quantization block_k = {block_k}."
153 - )
136 + validate_fp8_block_shape_moe(
137 + intermediate_size_per_partition,
138 + self.weight_block_size,
139 + )
140
141 # WEIGHTS
142 w13_weight = torch.nn.Parameter(
vllm/model_executor/layers/quantization/fp8.py
+12 -22
@@ -53,6 +53,7 @@ from vllm.model_executor.layers.quantization.utils.fp8_utils import (
53 process_fp8_weight_tensor_strategy,
54 process_fp8_weight_tensor_strategy_moe,
55 validate_fp8_block_shape,
56 + validate_fp8_block_shape_moe,
57 )
58 from vllm.model_executor.layers.quantization.utils.marlin_utils import (
59 get_marlin_input_dtype,
@@ -575,34 +576,23 @@ class Fp8MoEMethod(FusedMoEMethodBase):
576 assert self.moe_block_shape is not None
577 moe_block_shape = self.moe_block_shape
578 layer.weight_block_size = self.weight_block_size
578 - tp_size = get_tensor_model_parallel_world_size()
579 block_n, block_k = (
580 self.weight_block_size[0],
581 self.weight_block_size[1],
582 )
583 - # NOTE: To ensure proper alignment of the block-wise quantization
584 - # scales, the output_size of the weights for both the gate and up
585 - # layers must be divisible by block_n.
586 - # Required by column parallel or enabling merged weights
587 - if intermediate_size_per_partition % block_n != 0:
588 - if self.weight_scale_refine is None:
589 - raise ValueError(
590 - f"The output_size of gate's and up's weight = "
591 - f"{intermediate_size_per_partition} is not divisible by "
592 - f"weight quantization block_n = {block_n}."
593 - )
583 + if self.weight_scale_refine is None:
584 + validate_fp8_block_shape_moe(
585 + intermediate_size_per_partition,
586 + self.weight_block_size,
587 + )
588 + else:
589 # Use the refined block grid for the scale parameters; the
590 # loader upsamples the checkpoint scales accordingly.
596 - block_n, block_k = moe_block_shape
597 - if tp_size > 1 and intermediate_size_per_partition % block_k != 0:
598 - # Required by row parallel
599 - if self.weight_scale_refine is None:
600 - raise ValueError(
601 - f"The input_size of down's weight = "
602 - f"{intermediate_size_per_partition} is not divisible by "
603 - f"weight quantization block_k = {block_k}."
604 - )
605 - block_n, block_k = moe_block_shape
591 + tp_size = get_tensor_model_parallel_world_size()
592 + if intermediate_size_per_partition % block_n != 0:
593 + block_n, block_k = moe_block_shape
594 + if tp_size > 1 and intermediate_size_per_partition % block_k != 0:
595 + block_n, block_k = moe_block_shape
596
597 # WEIGHTS
598 w13_weight = torch.nn.Parameter(
vllm/model_executor/layers/quantization/quark/quark_moe.py
+79 -14
@@ -57,6 +57,9 @@ from vllm.model_executor.layers.fused_moe.oracle.nvfp4 import (
57 select_nvfp4_moe_backend,
58 )
59 from vllm.model_executor.layers.quantization.quark.utils import QuarkQTensorHint
60 +from vllm.model_executor.layers.quantization.utils.fp8_utils import (
61 + validate_fp8_block_shape_moe,
62 +)
63 from vllm.model_executor.layers.quantization.utils.ocp_mx_utils import (
64 _ACTIVATION_QUANT_KEY_MAP,
65 _WEIGHT_QUANT_KEY_MAP,
@@ -66,8 +69,10 @@ from vllm.model_executor.layers.quantization.utils.ocp_mx_utils import (
69 from vllm.model_executor.layers.quantization.utils.quant_utils import (
70 GroupShape,
71 QuantKey,
72 + kFp8Dynamic128Sym,
73 kFp8DynamicTensorSym,
74 kFp8DynamicTokenSym,
75 + kFp8Static128BlockSym,
76 kFp8StaticChannelSym,
77 kFp8StaticTensorSym,
78 kInt4W4A8StaticChannelSym,
@@ -230,10 +235,12 @@ class QuarkW8A8Fp8MoEMethod(QuarkMoEMethod):
235 kFp8StaticTensorSym,
236 kFp8DynamicTensorSym,
237 kFp8DynamicTokenSym,
238 + kFp8Dynamic128Sym,
239 ]
240 supported_weight_quant_keys = [
241 kFp8StaticChannelSym,
242 kFp8StaticTensorSym,
243 + kFp8Static128BlockSym,
244 ]
245
246 def __init__(
@@ -244,14 +251,18 @@ class QuarkW8A8Fp8MoEMethod(QuarkMoEMethod):
251 ):
252 super().__init__(moe, weight_quant_key, activation_quant_key)
253 self.weight_dtype = "fp8"
247 - self.weight_qscheme = (
248 - "per_channel" if weight_quant_key == kFp8StaticChannelSym else "per_tensor"
249 - )
250 - self.input_qscheme = (
251 - "per_channel"
252 - if activation_quant_key == kFp8DynamicTokenSym
253 - else "per_tensor"
254 - )
254 + if weight_quant_key == kFp8StaticChannelSym:
255 + self.weight_qscheme = "per_channel"
256 + elif weight_quant_key == kFp8Static128BlockSym:
257 + self.weight_qscheme = "per_block"
258 + else:
259 + self.weight_qscheme = "per_tensor"
260 + if activation_quant_key == kFp8DynamicTokenSym:
261 + self.input_qscheme = "per_channel"
262 + elif activation_quant_key == kFp8Dynamic128Sym:
263 + self.input_qscheme = "per_group"
264 + else:
265 + self.input_qscheme = "per_tensor"
266 self.static_input_scales = activation_quant_key == kFp8StaticTensorSym
267 per_tensor = (
268 self.weight_qscheme == "per_tensor" and self.input_qscheme == "per_tensor"
@@ -259,16 +270,31 @@ class QuarkW8A8Fp8MoEMethod(QuarkMoEMethod):
270 per_channel = (
271 self.weight_qscheme == "per_channel" and self.input_qscheme == "per_channel"
272 )
262 - self.act_quant_group_shape = (
263 - GroupShape.PER_TOKEN if per_channel else GroupShape.PER_TENSOR
273 + per_block = (
274 + self.weight_qscheme == "per_block" and self.input_qscheme == "per_group"
275 )
265 - if not (per_tensor or per_channel):
276 + if not (per_tensor or per_channel or per_block):
277 raise ValueError(
267 - "For FP8 Fused MoE layers, only per-tensor and per-channel "
268 - "scales for weights and activations are supported. Found "
269 - f"{self.weight_qscheme}, {self.input_qscheme}"
278 + "For FP8 Fused MoE layers, only per-tensor, per-channel and "
279 + "per-block scales for weights and activations are supported. "
280 + f"Found {self.weight_qscheme}, {self.input_qscheme}"
281 ) # noqa E501
282
283 + # One scale per (block_n, block_k) tile of the weight, so the block
284 + # shape is carried by the weight key's group shape.
285 + self.weight_block_size: list[int] | None = (
286 + list(weight_quant_key.scale.group_shape) if per_block else None
287 + )
288 + self.block_quant = self.weight_block_size is not None
289 +
290 + if per_channel:
291 + self.act_quant_group_shape = GroupShape.PER_TOKEN
292 + elif per_block:
293 + assert self.weight_block_size is not None
294 + self.act_quant_group_shape = GroupShape(1, self.weight_block_size[1])
295 + else:
296 + self.act_quant_group_shape = GroupShape.PER_TENSOR
297 +
298 self.fp8_backend, self.experts_cls = select_fp8_moe_backend(
299 config=moe,
300 weight_key=self.weight_quant_key,
@@ -293,6 +319,15 @@ class QuarkW8A8Fp8MoEMethod(QuarkMoEMethod):
319 layer.weight_block_size = None
320 params_dtype = torch.float8_e4m3fn
321
322 + if self.block_quant:
323 + assert self.weight_block_size is not None
324 + layer.weight_block_size = self.weight_block_size
325 + block_n, block_k = self.weight_block_size[0], self.weight_block_size[1]
326 + validate_fp8_block_shape_moe(
327 + intermediate_size_per_partition,
328 + self.weight_block_size,
329 + )
330 +
331 # WEIGHTS
332 w13_weight = torch.nn.Parameter(
333 torch.zeros(
@@ -368,6 +403,35 @@ class QuarkW8A8Fp8MoEMethod(QuarkMoEMethod):
403 )
404 set_weight_attrs(w13_weight_scale, extra_weight_attrs)
405 set_weight_attrs(w2_weight_scale, extra_weight_attrs)
406 + elif self.weight_qscheme == "per_block":
407 + # One scale per (block_n, block_k) tile of each expert's weight.
408 + w13_weight_scale = torch.nn.Parameter(
409 + torch.ones(
410 + num_experts,
411 + self.moe.w13_num_shards
412 + * ((intermediate_size_per_partition + block_n - 1) // block_n),
413 + (hidden_size + block_k - 1) // block_k,
414 + dtype=torch.float32,
415 + ),
416 + requires_grad=False,
417 + )
418 + layer.register_parameter("w13_weight_scale", w13_weight_scale)
419 + w2_weight_scale = torch.nn.Parameter(
420 + torch.ones(
421 + num_experts,
422 + (hidden_size + block_n - 1) // block_n,
423 + (intermediate_size_per_partition + block_k - 1) // block_k,
424 + dtype=torch.float32,
425 + ),
426 + requires_grad=False,
427 + )
428 + layer.register_parameter("w2_weight_scale", w2_weight_scale)
429 + # Add PER-BLOCK quantization for RoutedExperts.weight_loader.
430 + extra_weight_attrs.update(
431 + {"quant_method": FusedMoeWeightScaleSupported.BLOCK.value}
432 + )
433 + set_weight_attrs(w13_weight_scale, extra_weight_attrs)
434 + set_weight_attrs(w2_weight_scale, extra_weight_attrs)
435
436 # INPUT_SCALES
437 if self.static_input_scales:
@@ -548,6 +612,7 @@ class QuarkW8A8Fp8MoEMethod(QuarkMoEMethod):
612 a2_scale=layer.w2_input_scale,
613 w1_bias=getattr(layer, "w13_bias", None),
614 w2_bias=getattr(layer, "w2_bias", None),
615 + block_shape=self.weight_block_size,
616 per_act_token_quant=self.input_qscheme == "per_channel",
617 per_out_ch_quant=self.weight_qscheme == "per_channel",
618 swiglu_limit=getattr(layer, "swiglu_limit", None),
vllm/model_executor/layers/quantization/utils/fp8_utils.py
+29
@@ -1265,6 +1265,35 @@ def validate_fp8_block_shape(
1265 )
1266
1267
1268 +def validate_fp8_block_shape_moe(
1269 + intermediate_size_per_partition: int,
1270 + block_size: list[int],
1271 +) -> None:
1272 + """Validate fused MoE block quantization shapes for tensor parallelism."""
1273 + from vllm.distributed import get_tensor_model_parallel_world_size
1274 +
1275 + tp_size = get_tensor_model_parallel_world_size()
1276 + block_n, block_k = block_size[0], block_size[1]
1277 +
1278 + # NOTE: To ensure proper alignment of the block-wise quantization
1279 + # scales, the output_size of the weights for both the gate and up
1280 + # layers must be divisible by block_n.
1281 + # Required by column parallel or enabling merged weights
1282 + if intermediate_size_per_partition % block_n != 0:
1283 + raise ValueError(
1284 + f"The output_size of gate's and up's weight = "
1285 + f"{intermediate_size_per_partition} is not divisible by "
1286 + f"weight quantization block_n = {block_n}."
1287 + )
1288 + if tp_size > 1 and intermediate_size_per_partition % block_k != 0:
1289 + # Required by row parallel
1290 + raise ValueError(
1291 + f"The input_size of down's weight = "
1292 + f"{intermediate_size_per_partition} is not divisible by "
1293 + f"weight quantization block_k = {block_k}."
1294 + )
1295 +
1296 +
1297 def create_fp8_weight_parameter(
1298 output_size_per_partition: int,
1299 input_size_per_partition: int,