[Quantization] Select per-token NVFP4 MoE backends explicitly (#57176)
Signed-off-by: aoshen02 <aoshen@inferact.ai> Signed-off-by: S1ro1 <matej.sirovatka@gmail.com> Signed-off-by: aoshen02 <aoshen02@users.noreply.github.com> Co-authored-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: OpenAI Codex <noreply@openai.com> Co-authored-by: aoshen02 <aoshen02@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Matej Sirovatka committed
Sep 23, 2026 at 00:30 UTC
9127170b880dc86e032c4acaf649c225fdeff3b1
6 files changed
+97
-21
tests/quantization/test_online.py
+71
@@ -36,6 +36,7 @@ from vllm.model_executor.kernels.linear.mxfp8.marlin import (
36
)
37
from vllm.model_executor.layers.attention import Attention
38
from vllm.model_executor.layers.fused_moe import FusedMoEFactory
39
+from vllm.model_executor.layers.fused_moe.oracle import nvfp4 as nvfp4_oracle
40
from vllm.model_executor.layers.linear import (
41
ColumnParallelLinear,
42
LinearBase,
@@ -503,6 +504,76 @@ def test_checkpoint_quantization_rejects_online_shorthand(tmp_path) -> None:
504
ModelConfig(model=str(tmp_path), quantization="fp8_per_channel")
505
506
507
+@pytest.mark.parametrize("per_token_activation", [False, True])
508
+@pytest.mark.parametrize("input_dtype", [torch.bfloat16, torch.float16])
509
+def test_nvfp4_one_sided_sizes_dispatched_activations(
510
+ monkeypatch, per_token_activation: bool, input_dtype: torch.dtype
511
+) -> None:
512
+ from tests.kernels.moe.utils import make_dummy_moe_config
513
+ from vllm.model_executor.layers.fused_moe.all2all_utils import (
514
+ flashinfer_one_sided_dispatch_layout,
515
+ )
516
+ from vllm.model_executor.layers.fused_moe.config import FusedMoEQuantConfig
517
+ from vllm.model_executor.layers.fused_moe.experts.trtllm_nvfp4_moe import (
518
+ TrtLlmNvFp4ExpertsModular,
519
+ )
520
+
521
+ config = make_dummy_moe_config(hidden_dim=6144, in_dtype=input_dtype)
522
+ config.moe_parallel_config.dp_size = 2
523
+ config.moe_parallel_config.ep_size = 2
524
+ config.moe_parallel_config.use_ep = True
525
+ config.moe_parallel_config.all2all_backend = "flashinfer_nvlink_one_sided"
526
+ expert_quant_config = FusedMoEQuantConfig.make("nvfp4")
527
+
528
+ def check_dispatch_layout(*, quant_config, input_dtype, **kwargs):
529
+ assert quant_config is expert_quant_config
530
+ layout = flashinfer_one_sided_dispatch_layout(
531
+ config.hidden_dim, quant_config, input_dtype=input_dtype
532
+ )
533
+ if per_token_activation:
534
+ assert input_dtype == config.in_dtype
535
+ assert (
536
+ layout.x_bytes_per_token == config.hidden_dim * config.in_dtype.itemsize
537
+ )
538
+ assert layout.x_sf_bytes_per_token == 0
539
+ else:
540
+ assert input_dtype is None
541
+ assert layout.x_bytes_per_token == config.hidden_dim // 2
542
+ assert layout.x_sf_bytes_per_token == config.hidden_dim // 16
543
+ assert expert_quant_config.quant_dtype == "nvfp4"
544
+ raise RuntimeError("dispatch layout verified before allocation")
545
+
546
+ monkeypatch.setattr(
547
+ nvfp4_oracle, "maybe_make_prepare_finalize", check_dispatch_layout
548
+ )
549
+ with pytest.raises(
550
+ RuntimeError, match="dispatch layout verified before allocation"
551
+ ):
552
+ nvfp4_oracle.make_nvfp4_moe_kernel(
553
+ moe_quant_config=expert_quant_config,
554
+ moe_config=config,
555
+ experts_cls=TrtLlmNvFp4ExpertsModular,
556
+ backend=nvfp4_oracle.NvFp4MoeBackend.FLASHINFER_TRTLLM,
557
+ per_token_activation=per_token_activation,
558
+ )
559
+
560
+
561
+@pytest.mark.parametrize(
562
+ "input_dtype",
563
+ [torch.float32, torch.float8_e4m3fn, torch.float8_e5m2, torch.int8],
564
+)
565
+def test_nvfp4_one_sided_rejects_unsupported_input_dtype(input_dtype: torch.dtype):
566
+ from vllm.model_executor.layers.fused_moe.all2all_utils import (
567
+ flashinfer_one_sided_dispatch_layout,
568
+ )
569
+ from vllm.model_executor.layers.fused_moe.config import FusedMoEQuantConfig
570
+
571
+ with pytest.raises(ValueError, match="unpacked inputs must be float16 or bfloat16"):
572
+ flashinfer_one_sided_dispatch_layout(
573
+ 6144, FusedMoEQuantConfig.make("nvfp4"), input_dtype=input_dtype
574
+ )
575
+
576
+
577
@pytest.mark.skipif(
578
not is_quant_method_supported("fp8"),
579
reason="FP8 is not supported on this GPU type.",
vllm/model_executor/layers/fused_moe/all2all_utils.py
+12
-2
@@ -48,9 +48,18 @@ class FlashInferOneSidedDispatchLayout:
48
49
50
def flashinfer_one_sided_dispatch_layout(
51
- hidden_dim: int, quant_config: FusedMoEQuantConfig
51
+ hidden_dim: int,
52
+ quant_config: FusedMoEQuantConfig,
53
+ input_dtype: torch.dtype | None = None,
54
) -> FlashInferOneSidedDispatchLayout:
55
"""Return the one-sided activation payload layout."""
56
+ if input_dtype is not None:
57
+ if input_dtype not in (torch.float16, torch.bfloat16):
58
+ raise ValueError(
59
+ "flashinfer_nvlink_one_sided unpacked inputs must be float16 "
60
+ f"or bfloat16, got {input_dtype}"
61
+ )
62
+ return FlashInferOneSidedDispatchLayout(hidden_dim * input_dtype.itemsize, 0)
63
if quant_config.quant_dtype is None:
64
return FlashInferOneSidedDispatchLayout(hidden_dim * 2, 0)
65
if quant_config.quant_dtype == "nvfp4":
@@ -174,6 +183,7 @@ def maybe_make_prepare_finalize(
183
allow_new_interface: bool = False,
184
use_monolithic: bool = False,
185
all2all_manager: Any | None = None,
186
+ input_dtype: torch.dtype | None = None,
187
) -> FusedMoEPrepareAndFinalize | None:
188
if not moe.moe_parallel_config.use_all2all_kernels:
189
if not allow_new_interface:
@@ -367,7 +377,7 @@ def maybe_make_prepare_finalize(
377
get_current_vllm_config().scheduler_config.max_num_batched_tokens
378
)
379
dispatch_layout = flashinfer_one_sided_dispatch_layout(
370
- moe.hidden_dim, quant_config
380
+ moe.hidden_dim, quant_config, input_dtype=input_dtype
381
)
382
prepare_finalize = FlashInferNVLinkOneSidedPrepareAndFinalize(
383
max_num_tokens=max_num_tokens,
vllm/model_executor/layers/fused_moe/experts/trtllm_nvfp4_moe.py
+8
-17
@@ -28,6 +28,7 @@ from vllm.model_executor.layers.quantization.utils.flashinfer_utils import (
28
from vllm.model_executor.layers.quantization.utils.quant_utils import (
29
QuantKey,
30
kNvfp4Dynamic,
31
+ kNvfp4DynamicToken,
32
kNvfp4Static,
33
)
34
from vllm.model_executor.utils import is_weights_pre_processed
@@ -222,10 +223,10 @@ class TrtLlmNvFp4ExpertsBase:
223
activation_key: QuantKey | None,
224
) -> bool:
225
"""Supports Nvfp4 quantization."""
225
- SUPPORTED_W_A = [
226
- (kNvfp4Static, kNvfp4Dynamic),
227
- ]
228
- return (weight_key, activation_key) in SUPPORTED_W_A
226
+ return weight_key == kNvfp4Static and activation_key in (
227
+ kNvfp4Dynamic,
228
+ kNvfp4DynamicToken,
229
+ )
230
231
@staticmethod
232
def _supports_activation(activation: MoEActivation) -> bool:
@@ -316,22 +317,12 @@ class TrtLlmNvFp4ExpertsModular(TrtLlmNvFp4ExpertsBase, mk.FusedMoEExpertsModula
317
expert_tokens_meta: mk.ExpertTokensMetadata | None,
318
activation: MoEActivation,
319
) -> tuple[tuple[int, ...], tuple[int, ...], tuple[int, ...]]:
319
- if self.per_token_activation:
320
- # Deferred input quant leaves K unpacked here, breaking the
321
- # workspace assumptions below. Per-token NVFP4 is only supported on
322
- # the monolithic (non-EP) path for now.
323
- raise NotImplementedError(
324
- "NVFP4 per-token activation is only supported on the monolithic "
325
- "(non-EP) FlashInfer TRTLLM MoE path."
326
- )
327
-
320
# The workspaces for this implementation are managed by flashinfer.
321
workspace1 = (0,)
322
workspace2 = (0,)
323
332
- # Hidden states are Nvfp4, packed into int8 dtype, so we
333
- # need to multiply K by 2 to get the output shape right.
334
- assert self.hidden_dim == K * 2
324
+ # Per-token inputs are unpacked; otherwise each byte holds two FP4 values.
325
+ assert self.hidden_dim == (K if self.expects_unquantized_inputs else K * 2)
326
output = (M, self.hidden_dim)
327
328
return (workspace1, workspace2, output)
@@ -349,7 +340,7 @@ class TrtLlmNvFp4ExpertsModular(TrtLlmNvFp4ExpertsBase, mk.FusedMoEExpertsModula
340
topk_ids: torch.Tensor,
341
activation: MoEActivation,
342
global_num_experts: int,
352
- a1q_scale: torch.Tensor,
343
+ a1q_scale: torch.Tensor | None,
344
):
345
import flashinfer
346
vllm/model_executor/layers/fused_moe/oracle/nvfp4.py
+1
@@ -605,6 +605,7 @@ def make_nvfp4_moe_kernel(
605
routing_tables=routing_tables,
606
allow_new_interface=True,
607
use_monolithic=issubclass(experts_cls, mk.FusedMoEExpertsMonolithic),
608
+ input_dtype=moe_config.in_dtype if per_token_activation else None,
609
)
610
assert prepare_finalize is not None
611
vllm/model_executor/layers/quantization/online/nvfp4.py
+2
-2
@@ -24,7 +24,7 @@ from vllm.model_executor.layers.quantization.utils.nvfp4_emulation_utils import
24
)
25
from vllm.model_executor.layers.quantization.utils.quant_utils import (
26
amax_for_moe_weight_quant,
27
- kNvfp4Dynamic,
27
+ kNvfp4DynamicToken,
28
kNvfp4Static,
29
weight_amax,
30
)
@@ -100,7 +100,7 @@ class Nvfp4OnlineMoEMethod(OnlineMoEMethodBase):
100
self.nvfp4_backend, self.experts_cls = select_nvfp4_moe_backend(
101
config=self.moe,
102
weight_key=kNvfp4Static,
103
- activation_key=kNvfp4Dynamic,
103
+ activation_key=kNvfp4DynamicToken,
104
)
105
106
def process_weights_after_loading(self, layer: Module) -> None:
vllm/model_executor/layers/quantization/utils/quant_utils.py
+3
@@ -208,6 +208,9 @@ kNvfp4DynamicGroupScale = ScaleDesc(FP8_DTYPE, False, GroupShape(1, 16))
208
kNvfp4Dynamic = QuantKey(
209
FP4_DTYPE, scale=kNvfp4DynamicGroupScale, scale2=kStaticTensorScale
210
)
211
+kNvfp4DynamicToken = QuantKey(
212
+ FP4_DTYPE, scale=kNvfp4DynamicGroupScale, scale2=kDynamicTokenScale
213
+)
214
215
kNvfp4StaticGroupScale = ScaleDesc(FP8_DTYPE, True, GroupShape(1, 16))
216
kNvfp4Static = QuantKey(