[Quantization] Add per-token NVFP4 CuTe-DSL MoE backend (#50030)

Signed-off-by: aoshen02 <aoshen@inferact.ai> Signed-off-by: S1ro1 <matej.sirovatka@gmail.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>

Matej Sirovatka committed Sep 30, 2026 at 03:42 UTC 0103a9b96fde27d26a464bf2c30b4d977b2a134f
9 files changed +186 -66
tests/kernels/moe/test_flashinfer_cutedsl_nvfp4_moe.py
+52 -19
@@ -29,14 +29,20 @@ from vllm.model_executor.layers.fused_moe.config import (
29 FusedMoEConfig,
30 FusedMoEParallelConfig,
31 RoutingMethodType,
32 - nvfp4_moe_quant_config,
32 )
33 from vllm.model_executor.layers.fused_moe.experts.flashinfer_cutedsl_moe import (
34 FlashInferCuteDSLExperts,
35 )
36 +from vllm.model_executor.layers.fused_moe.oracle.nvfp4 import (
37 + NvFp4MoeBackend,
38 + make_nvfp4_moe_quant_config,
39 +)
40 from vllm.model_executor.layers.quantization.utils.flashinfer_fp4_moe import (
41 prepare_nvfp4_moe_layer_for_flashinfer_cutedsl,
42 )
43 +from vllm.model_executor.layers.quantization.utils.flashinfer_utils import (
44 + quantize_nvfp4_per_token_input,
45 +)
46 from vllm.platforms import current_platform
47 from vllm.utils.flashinfer import has_flashinfer_cutedsl_moe_nvfp4
48 from vllm.utils.math_utils import next_power_of_2
@@ -159,8 +165,12 @@ def _dequantize_nvfp4_linear(
165 return (tensor_f32 * tensor_sf.unsqueeze(-1)).reshape(m, k).to(dtype)
166
167
162 -@pytest.mark.parametrize("m,n,k,e,topk", [(16, 128, 512, 4, 2)])
168 +@pytest.mark.parametrize(
169 + "m,n,k,e,topk",
170 + [(16, 128, 512, 4, 2), (16, 192, 512, 4, 2), (16, 192, 2688, 4, 2)],
171 +)
172 @pytest.mark.parametrize("activation,alpha,beta,limit", _ACT_CASES)
173 +@pytest.mark.parametrize("per_token_activation", [False, True])
174 @pytest.mark.parametrize("dtype", [torch.bfloat16])
175 @torch.inference_mode()
176 def test_flashinfer_cutedsl_fp4_moe(
@@ -173,6 +183,7 @@ def test_flashinfer_cutedsl_fp4_moe(
183 alpha: float | None,
184 beta: float | None,
185 limit: float | None,
186 + per_token_activation: bool,
187 dtype: torch.dtype,
188 workspace_init,
189 ):
@@ -181,6 +192,10 @@ def test_flashinfer_cutedsl_fp4_moe(
192 VllmConfig(parallel_config=ParallelConfig(pipeline_parallel_size=1))
193 ):
194 hidden_states = torch.randn((m, k), device="cuda", dtype=dtype) / 10
195 + # Keep GEMM1 activation variance stable as K changes, so the fixed
196 + # tolerance against the BF16 GEMM2 reference measures comparable FP4
197 + # quantization error at each hidden width.
198 + hidden_states *= (512 / k) ** 0.5
199
200 w1_rows = 2 * n if activation.is_gated else n
201 w1 = torch.randn((e, w1_rows, k), device="cuda", dtype=dtype) / 15
@@ -193,7 +208,6 @@ def test_flashinfer_cutedsl_fp4_moe(
208 hidden_states, score, topk, renormalize=False
209 )
210
196 - activation = MoEActivation.RELU2_NO_MUL
211 moe_config = FusedMoEConfig(
212 num_experts=e,
213 experts_per_token=topk,
@@ -231,19 +245,30 @@ def test_flashinfer_cutedsl_fp4_moe(
245 w2_scale_2=(1.0 / w2_global_scale),
246 a2_scale=a2_scale,
247 )
234 - quant_config = nvfp4_moe_quant_config(
235 - g1_alphas=w1_alpha,
236 - g2_alphas=w2_alpha,
237 - a1_gscale=(1.0 / a1_scale),
238 - a2_gscale=(1.0 / a2_scale),
239 - w1_scale=w1_scale_cutedsl,
248 + # I=192 must remain unpadded for gated experts, but become 256 for
249 + # non-gated experts. The reference below uses the checkpoint shapes.
250 + expected_n = n if activation.is_gated else (n + 127) // 128 * 128
251 + assert moe_config.intermediate_size_per_partition == expected_n
252 + assert w1_cutedsl.shape[1] == expected_n * (2 if activation.is_gated else 1)
253 + assert w2_cutedsl.shape[2] * 2 == expected_n
254 + expected_k = (k + 255) // 256 * 256
255 + assert moe_config.hidden_dim == expected_k
256 + assert moe_config.hidden_dim_unpadded == k
257 + assert w1_cutedsl.shape[2] * 2 == expected_k
258 + assert w2_cutedsl.shape[1] == expected_k
259 + quant_config = make_nvfp4_moe_quant_config(
260 + backend=NvFp4MoeBackend.FLASHINFER_CUTEDSL,
261 + w13_scale_2=w1_alpha,
262 + w2_scale_2=w2_alpha,
263 + a13_scale=a1_scale,
264 + a2_scale=a2_scale,
265 + w13_scale=w1_scale_cutedsl,
266 w2_scale=w2_scale_cutedsl,
241 - is_scale_swizzled=False,
267 # Unset params must be omitted rather than forwarded as None into
268 # the kernel's float-typed SwiGLU arguments.
244 - gemm1_alpha=alpha,
245 - gemm1_beta=beta,
246 - gemm1_clamp_limit=limit,
269 + swiglu_alpha=alpha,
270 + swiglu_beta=beta,
271 + swiglu_limit=limit,
272 )
273
274 cutedsl_experts = mk.FusedMoEKernel(
@@ -256,11 +281,13 @@ def test_flashinfer_cutedsl_fp4_moe(
281 FlashInferCuteDSLExperts(
282 moe_config=moe_config,
283 quant_config=quant_config,
284 + per_token_activation=per_token_activation,
285 ),
286 )
287
288 cutedsl_output = cutedsl_experts.apply(
263 - hidden_states=hidden_states,
289 + # The model runner pads inputs and trims outputs around this kernel.
290 + hidden_states=torch.nn.functional.pad(hidden_states, (0, expected_k - k)),
291 w1=w1_cutedsl,
292 w2=w2_cutedsl,
293 topk_weights=topk_weights,
@@ -270,19 +297,25 @@ def test_flashinfer_cutedsl_fp4_moe(
297 expert_map=None,
298 apply_router_weight_on_input=False,
299 )
300 + cutedsl_output = cutedsl_output[:, :k]
301
302 a_global_scale = torch.ones(1, device="cuda", dtype=torch.float32)
275 - a_q, a_scale = ops.scaled_fp4_quant(
276 - hidden_states,
277 - a_global_scale,
278 - is_sf_swizzled_layout=False,
279 - )
303 + if per_token_activation:
304 + a_q, a_scale, a_row_scale = quantize_nvfp4_per_token_input(hidden_states)
305 + else:
306 + a_q, a_scale = ops.scaled_fp4_quant(
307 + hidden_states,
308 + a_global_scale,
309 + is_sf_swizzled_layout=False,
310 + )
311 a_in_dtype = _dequantize_nvfp4_linear(
312 a_q,
313 a_scale,
314 a_global_scale,
315 dtype=dtype,
316 )
317 + if per_token_activation:
318 + a_in_dtype = (a_in_dtype.float() * a_row_scale.view(-1, 1)).to(dtype)
319
320 w1_d = torch.empty((e, w1_rows, k), device="cuda", dtype=dtype)
321 w2_d = torch.empty((e, k, n), device="cuda", dtype=dtype)
tests/quantization/test_online.py
+24
@@ -605,6 +605,30 @@ def test_checkpoint_quantization_rejects_online_shorthand(tmp_path) -> None:
605 ModelConfig(model=str(tmp_path), quantization="fp8_per_channel")
606
607
608 +def test_nvfp4_per_token_backend_contract() -> None:
609 + from vllm.model_executor.layers.fused_moe.experts.flashinfer_cutedsl_moe import (
610 + FlashInferCuteDSLExperts,
611 + )
612 + from vllm.model_executor.layers.fused_moe.experts.flashinfer_cutlass_moe import (
613 + FlashInferExperts,
614 + )
615 + from vllm.model_executor.layers.fused_moe.experts.trtllm_nvfp4_moe import (
616 + TrtLlmNvFp4ExpertsModular,
617 + TrtLlmNvFp4ExpertsMonolithic,
618 + )
619 + from vllm.model_executor.layers.quantization.utils.quant_utils import (
620 + kNvfp4DynamicToken,
621 + kNvfp4Static,
622 + )
623 +
624 + scheme = (kNvfp4Static, kNvfp4DynamicToken)
625 + assert TrtLlmNvFp4ExpertsMonolithic._supports_quant_scheme(*scheme)
626 + assert TrtLlmNvFp4ExpertsModular._supports_quant_scheme(*scheme)
627 + assert FlashInferCuteDSLExperts._supports_quant_scheme(*scheme)
628 + assert FlashInferCuteDSLExperts._supports_no_act_and_mul()
629 + assert not FlashInferExperts._supports_quant_scheme(*scheme)
630 +
631 +
632 @pytest.mark.parametrize("per_token_activation", [False, True])
633 @pytest.mark.parametrize("input_dtype", [torch.bfloat16, torch.float16])
634 def test_nvfp4_one_sided_sizes_dispatched_activations(
tests/quantization/test_trtllm_nvfp4_hidden_dim_padding.py
+3 -3
@@ -11,8 +11,8 @@ from vllm.model_executor.layers.quantization.utils.flashinfer_fp4_moe import (
11 prepare_nvfp4_moe_layer_for_fi_or_cutlass,
12 )
13 from vllm.model_executor.layers.quantization.utils.flashinfer_utils import (
14 + align_fp4_moe_hidden_dim_for_fi,
15 align_fp4_moe_weights_for_fi,
15 - align_trtllm_fp4_moe_hidden_dim_for_fi,
16 )
17
18
@@ -63,7 +63,7 @@ def test_align_trtllm_fp4_moe_hidden_dim_noop():
63 w2_scale = torch.arange(2 * 512 * 1, dtype=torch.uint8).reshape(2, 512, 1)
64
65 out_w13, out_w13_scale, out_w2, out_w2_scale, padded_hidden = (
66 - align_trtllm_fp4_moe_hidden_dim_for_fi(w13, w13_scale, w2, w2_scale)
66 + align_fp4_moe_hidden_dim_for_fi(w13, w13_scale, w2, w2_scale)
67 )
68
69 assert padded_hidden == 512
@@ -90,7 +90,7 @@ def test_align_trtllm_fp4_moe_hidden_dim_pads_to_256_multiple():
90 )
91
92 out_w13, out_w13_scale, out_w2, out_w2_scale, out_hidden_dim = (
93 - align_trtllm_fp4_moe_hidden_dim_for_fi(w13, w13_scale, w2, w2_scale)
93 + align_fp4_moe_hidden_dim_for_fi(w13, w13_scale, w2, w2_scale)
94 )
95
96 assert out_hidden_dim == padded_hidden_dim
vllm/model_executor/layers/fused_moe/experts/flashinfer_cutedsl_moe.py
+39 -5
@@ -14,11 +14,14 @@ from vllm.model_executor.layers.fused_moe.topk_weight_and_reduce import (
14 TopKWeightAndReduceNoOP,
15 )
16 from vllm.model_executor.layers.quantization.utils.flashinfer_utils import (
17 + NVFP4_PER_TOKEN_BASE_GLOBAL_SCALE,
18 activation_to_flashinfer_int,
19 + quantize_nvfp4_per_token_input,
20 )
21 from vllm.model_executor.layers.quantization.utils.quant_utils import (
22 QuantKey,
23 kNvfp4Dynamic,
24 + kNvfp4DynamicToken,
25 kNvfp4Static,
26 )
27 from vllm.platforms import current_platform
@@ -40,6 +43,7 @@ class FlashInferCuteDSLExperts(mk.FusedMoEExpertsModular):
43 self,
44 moe_config: FusedMoEConfig,
45 quant_config: FusedMoEQuantConfig,
46 + per_token_activation: bool = False,
47 ):
48 super().__init__(
49 moe_config=moe_config,
@@ -63,6 +67,14 @@ class FlashInferCuteDSLExperts(mk.FusedMoEExpertsModular):
67 self.gemm1_clamp_limit = quant_config.gemm1_clamp_limit
68 self.situ_beta = moe_config.activation_situ_beta
69 self.situ_linear_beta = moe_config.activation_situ_linear_beta
70 + self.per_token_activation = per_token_activation
71 + self.per_token_global_scale = None
72 + if per_token_activation:
73 + assert quant_config.a2_gscale is not None
74 + self.per_token_global_scale = quant_config.a2_gscale.new_full(
75 + (1,),
76 + NVFP4_PER_TOKEN_BASE_GLOBAL_SCALE,
77 + )
78
79 def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
80 layer.w13_weight_scale_2.data.mul_(layer.w13_input_scale)
@@ -92,6 +104,7 @@ class FlashInferCuteDSLExperts(mk.FusedMoEExpertsModular):
104 ) -> bool:
105 SUPPORTED_W_A = [
106 (kNvfp4Static, kNvfp4Dynamic),
107 + (kNvfp4Static, kNvfp4DynamicToken),
108 ]
109 return (weight_key, activation_key) in SUPPORTED_W_A
110
@@ -114,6 +127,10 @@ class FlashInferCuteDSLExperts(mk.FusedMoEExpertsModular):
127 def finalize_weight_and_reduce_impl(self) -> mk.TopKWeightAndReduce:
128 return TopKWeightAndReduceNoOP()
129
130 + @property
131 + def expects_unquantized_inputs(self) -> bool:
132 + return self.per_token_activation
133 +
134 def workspace_shapes(
135 self,
136 M: int,
@@ -127,8 +144,8 @@ class FlashInferCuteDSLExperts(mk.FusedMoEExpertsModular):
144 ) -> tuple[tuple[int, ...], tuple[int, ...], tuple[int, ...]]:
145 workspace1 = (0,)
146 workspace2 = (0,)
130 - # K is packed (K//2 for uint8), so output uses hidden_dim.
131 - assert self.hidden_dim == K * 2
147 + expected_hidden_dim = K if self.expects_unquantized_inputs else K * 2
148 + assert self.hidden_dim == expected_hidden_dim
149 output = (M, self.hidden_dim)
150 return (workspace1, workspace2, output)
151
@@ -151,13 +168,26 @@ class FlashInferCuteDSLExperts(mk.FusedMoEExpertsModular):
168 apply_router_weight_on_input: bool | None,
169 ):
170 assert self.quant_dtype == "nvfp4"
154 - assert a1q_scale is not None
171 assert self.w1_scale is not None
172 assert self.w2_scale is not None
173
174 + if self.expects_unquantized_inputs:
175 + hidden_states, block_scale, per_token_scale = (
176 + quantize_nvfp4_per_token_input(hidden_states)
177 + )
178 + fc2_input_scale = self.per_token_global_scale
179 + else:
180 + assert a1q_scale is not None
181 + block_scale = a1q_scale
182 + per_token_scale = None
183 + fc2_input_scale = self.a2_gscale
184 +
185 + assert block_scale is not None
186 + assert fc2_input_scale is not None
187 +
188 # a1q_scale is (M, K//16) float8_e4m3fn from fp4_quantize.
189 # The functional API expects x_sf with trailing dim: (M, K//16, 1).
160 - x_sf = a1q_scale.unsqueeze(-1)
190 + x_sf = block_scale.unsqueeze(-1)
191
192 # The kernel defaults swiglu_{alpha,beta,limit} to the plain-SwiGLU
193 # values, so only forward the ones the model actually sets.
@@ -188,6 +218,9 @@ class FlashInferCuteDSLExperts(mk.FusedMoEExpertsModular):
218 }
219 swiglu_kwargs = {k: v for k, v in swiglu_params.items() if v is not None}
220
221 + per_token_kwargs = (
222 + {"per_token_scale": per_token_scale} if self.per_token_activation else {}
223 + )
224 flashinfer_cute_dsl_fused_moe_nvfp4(
225 x=hidden_states,
226 x_sf=x_sf,
@@ -196,7 +229,7 @@ class FlashInferCuteDSLExperts(mk.FusedMoEExpertsModular):
229 w1_weight=w1,
230 w1_weight_sf=self.w1_scale,
231 w1_alpha=self.g1_alphas,
199 - fc2_input_scale=self.a2_gscale,
232 + fc2_input_scale=fc2_input_scale,
233 w2_weight=w2,
234 w2_weight_sf=self.w2_scale,
235 w2_alpha=self.g2_alphas,
@@ -209,4 +242,5 @@ class FlashInferCuteDSLExperts(mk.FusedMoEExpertsModular):
242 MoEActivation.SILU if activation == MoEActivation.SITU else activation
243 ),
244 **swiglu_kwargs,
245 + **per_token_kwargs,
246 )
vllm/model_executor/layers/fused_moe/experts/trtllm_nvfp4_moe.py
+7 -27
@@ -24,6 +24,7 @@ from vllm.model_executor.layers.fused_moe.utils import fi_moe_largest_bucket
24 from vllm.model_executor.layers.quantization.utils.flashinfer_utils import (
25 activation_to_flashinfer_int,
26 has_flashinfer_situ_activation,
27 + quantize_nvfp4_per_token_input,
28 )
29 from vllm.model_executor.layers.quantization.utils.quant_utils import (
30 QuantKey,
@@ -37,10 +38,6 @@ from vllm.utils.flashinfer import has_flashinfer_trtllm_fused_moe
38
39 logger = init_logger(__name__)
40
40 -# Base scale for per-token NVFP4 activation quant; the kernel folds the
41 -# per-token global scale (from the activation amax) on top of it.
42 -_PER_TOKEN_BASE_GLOBAL_SCALE = 1.0 / (448.0 * 6.0)
43 -
41
42 class TrtLlmNvFp4ExpertsBase:
43 """NvFp4 TRTLLM-Gen MoE kernels. Supports modular and monolithic interface."""
@@ -259,23 +256,6 @@ class TrtLlmNvFp4ExpertsBase:
256 def expects_unquantized_inputs(self) -> bool:
257 return self.per_token_activation
258
262 - def _quantize_per_token_input(
263 - self, hidden_states: torch.Tensor
264 - ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
265 - """NVFP4-quantize activations with a per-token global scale.
266 -
267 - Returns ``(packed_fp4, block_scale, per_token_scale)``.
268 - """
269 - from flashinfer import SfLayout, nvfp4_quantize
270 -
271 - hs_fp4, hs_block_scale, per_token_scale = nvfp4_quantize(
272 - hidden_states,
273 - _PER_TOKEN_BASE_GLOBAL_SCALE,
274 - sfLayout=SfLayout.layout_linear,
275 - per_token_activation=True,
276 - )
277 - return hs_fp4, hs_block_scale, per_token_scale
278 -
259 def _get_chunk_size(self) -> int:
260 MAX_GRID_Y = 65535
261 MAX_TILE_TOKENS_DIM = 128
@@ -361,9 +341,9 @@ class TrtLlmNvFp4ExpertsModular(TrtLlmNvFp4ExpertsBase, mk.FusedMoEExpertsModula
341
342 # Per-token: input is unquantized, quantize it here. Otherwise it was
343 # already quantized in prepare() with the static global scale.
364 - if self.per_token_activation:
344 + if self.expects_unquantized_inputs:
345 hidden_states, block_scale, per_token_scale = (
366 - self._quantize_per_token_input(hidden_states)
346 + quantize_nvfp4_per_token_input(hidden_states)
347 )
348 else:
349 block_scale, per_token_scale = a1q_scale, None
@@ -437,7 +417,7 @@ class TrtLlmNvFp4ExpertsModular(TrtLlmNvFp4ExpertsBase, mk.FusedMoEExpertsModula
417 ) -> UnfinalizedMoEOutput | None:
418 assert self._supports_activation(activation)
419 # Per-token defers input quant to _invoke_kernel, so a1q_scale is None.
440 - assert a1q_scale is not None or self.per_token_activation
420 + assert a1q_scale is not None or self.expects_unquantized_inputs
421
422 # DeepEP produces int64 indexes.
423 topk_ids = topk_ids.to(dtype=torch.int32)
@@ -539,7 +519,7 @@ class TrtLlmNvFp4ExpertsMonolithic(
519 import flashinfer
520
521 assert self._supports_activation(activation)
542 - assert a1q_scale is not None or self.per_token_activation
522 + assert a1q_scale is not None or self.expects_unquantized_inputs
523 assert self.quant_config.w1_scale is not None
524 assert self.quant_config.w2_scale is not None
525 assert (
@@ -551,9 +531,9 @@ class TrtLlmNvFp4ExpertsMonolithic(
531 )
532
533 # Per-token: input is unquantized, quantize it here (see modular apply).
554 - if self.per_token_activation:
534 + if self.expects_unquantized_inputs:
535 hidden_states, block_scale, per_token_scale = (
556 - self._quantize_per_token_input(hidden_states)
536 + quantize_nvfp4_per_token_input(hidden_states)
537 )
538 else:
539 block_scale, per_token_scale = a1q_scale, None
vllm/model_executor/layers/fused_moe/oracle/nvfp4.py
+8 -1
@@ -612,7 +612,14 @@ def make_nvfp4_moe_kernel(
612 logger.info_once("Using %s", prepare_finalize.__class__.__name__)
613
614 extra_kwargs = {}
615 - if backend == NvFp4MoeBackend.FLASHINFER_TRTLLM and per_token_activation:
615 + if (
616 + backend
617 + in (
618 + NvFp4MoeBackend.FLASHINFER_TRTLLM,
619 + NvFp4MoeBackend.FLASHINFER_CUTEDSL,
620 + )
621 + and per_token_activation
622 + ):
623 extra_kwargs["per_token_activation"] = True
624
625 # Create Experts.
vllm/model_executor/layers/fused_moe/runner/moe_runner.py
+1 -1
@@ -720,7 +720,7 @@ class MoERunner(MoERunnerInterface):
720 )
721
722 # Record before `_maybe_pad_hidden_states` pads activations to match
723 - # `moe_config.hidden_dim`, e.g. after `align_trtllm_fp4_moe_hidden_dim_for_fi`
723 + # `moe_config.hidden_dim`, e.g. after `align_fp4_moe_hidden_dim_for_fi`
724 # so routed output can be trimmed before
725 # shared+routed add / latent up proj if needed.
726
vllm/model_executor/layers/quantization/utils/flashinfer_fp4_moe.py
+34 -8
@@ -9,8 +9,8 @@ import torch
9 from vllm.logger import init_logger
10 from vllm.model_executor.layers.fused_moe.activation import MoEActivation
11 from vllm.model_executor.layers.quantization.utils.flashinfer_utils import (
12 + align_fp4_moe_hidden_dim_for_fi,
13 align_fp4_moe_weights_for_fi,
13 - align_trtllm_fp4_moe_hidden_dim_for_fi,
14 )
15 from vllm.model_executor.layers.quantization.utils.nvfp4_utils import (
16 swizzle_blockscale,
@@ -128,9 +128,9 @@ def prepare_nvfp4_moe_layer_for_flashinfer_cutedsl(
128 ]:
129 """Prepare weights for the CuteDSL wrapper-based NvFP4 MoE backend.
130
131 - Converts weight scale factors to MMA layout expected by CuteDslMoEWrapper,
132 - and interleaves w13 gate/linear rows for gated activations. Non-gated
133 - activations use a single w13 projection and keep its row order unchanged.
131 + Pads the runtime expert tensors to the kernel's GEMM alignment, converts
132 + weight scale factors to the MMA layout expected by CuteDslMoEWrapper, and
133 + interleaves w13 gate/linear rows for gated activations.
134 """
135 # Global scaling factors (same as other FlashInfer backends).
136 num_experts = w13.shape[0]
@@ -140,12 +140,38 @@ def prepare_nvfp4_moe_layer_for_flashinfer_cutedsl(
140 )
141 a2_scale = amax_for_moe_activation_quant(a2_scale, enable_eplb).repeat(num_experts)
142
143 - if layer.activation.is_gated:
143 + gated = layer.activation.is_gated
144 + if gated:
145 w13, w13_scale = reorder_w13_to_w31_for_flashinfer_cutedsl(
146 layer.activation, w13, w13_scale
147 )
148
148 - # Interleave up/gate rows for w13 weights and scales.
149 + # GEMM1's output dimension must be a multiple of 128: 2I for gated
150 + # activations (also required by interleaving), but only I for non-gated.
151 + # Keep the checkpoint tensors unchanged and pad only the kernel's runtime
152 + # representation. Zero rows also make the padded GEMM2 contraction a no-op.
153 + w13, w13_scale, w2, w2_scale, padded_intermediate = align_fp4_moe_weights_for_fi(
154 + w13,
155 + w13_scale,
156 + w2,
157 + w2_scale,
158 + is_act_and_mul=gated,
159 + min_alignment=64 if gated else 128,
160 + )
161 + layer.moe_config.intermediate_size_per_partition = padded_intermediate
162 +
163 + # GEMM1 gathers full 256-element K tiles of activations and their scales
164 + # without a K-tail predicate. Pad runtime weights to match; the MoE runner
165 + # pads activations and trims the output using the original hidden size.
166 + w13, w13_scale, w2, w2_scale, padded_hidden = align_fp4_moe_hidden_dim_for_fi(
167 + w13, w13_scale, w2, w2_scale
168 + )
169 + if layer.moe_config.hidden_dim_unpadded is None:
170 + layer.moe_config.hidden_dim_unpadded = layer.moe_config.hidden_dim
171 + layer.moe_config.hidden_dim = padded_hidden
172 +
173 + if gated:
174 + # Interleave up/gate rows for the fused gated activation.
175 w13 = interleave_linear_and_gate(w13, group_size=64, dim=1)
176 w13_scale = interleave_linear_and_gate(w13_scale, group_size=64, dim=1)
177
@@ -371,8 +397,8 @@ def prepare_nvfp4_moe_layer_for_fi_or_cutlass(
397
398 # Shuffle weights and scales for FI TRTLLM NVFP4 MoE kernels.
399 if backend == NvFp4MoeBackend.FLASHINFER_TRTLLM:
374 - w13, w13_scale, w2, w2_scale, padded_hidden = (
375 - align_trtllm_fp4_moe_hidden_dim_for_fi(w13, w13_scale, w2, w2_scale)
400 + w13, w13_scale, w2, w2_scale, padded_hidden = align_fp4_moe_hidden_dim_for_fi(
401 + w13, w13_scale, w2, w2_scale
402 )
403 if layer.moe_config.hidden_dim_unpadded is None:
404 layer.moe_config.hidden_dim_unpadded = layer.moe_config.hidden_dim
vllm/model_executor/layers/quantization/utils/flashinfer_utils.py
+18 -2
@@ -13,6 +13,8 @@ if TYPE_CHECKING:
13
14 logger = init_logger(__name__)
15
16 +NVFP4_PER_TOKEN_BASE_GLOBAL_SCALE = 1.0 / (448.0 * 6.0)
17 +
18
19 def activation_to_flashinfer_int(activation: MoEActivation) -> int:
20 return activation_to_flashinfer_type(activation).value
@@ -50,6 +52,20 @@ def activation_to_flashinfer_type(activation: MoEActivation) -> "ActivationType"
52 return ACTIVATION_TO_FI_ACTIVATION[activation]
53
54
55 +def quantize_nvfp4_per_token_input(
56 + hidden_states: torch.Tensor,
57 +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
58 + """Quantize NVFP4 activations with one FP32 decode scale per token."""
59 + from flashinfer import SfLayout, nvfp4_quantize
60 +
61 + return nvfp4_quantize(
62 + hidden_states,
63 + NVFP4_PER_TOKEN_BASE_GLOBAL_SCALE,
64 + sfLayout=SfLayout.layout_linear,
65 + per_token_activation=True,
66 + )
67 +
68 +
69 def swap_w13_to_w31(x: torch.Tensor) -> torch.Tensor:
70 return (
71 x.reshape(-1, 2, x.shape[-2] // 2, x.shape[-1]).flip(dims=[1]).reshape(x.shape)
@@ -264,7 +280,7 @@ def align_fp4_moe_weights_for_fi(
280 return padded_w13, padded_w13_scale, padded_w2, padded_w2_scale, padded_intermediate
281
282
267 -def align_trtllm_fp4_moe_hidden_dim_for_fi(
283 +def align_fp4_moe_hidden_dim_for_fi(
284 w13: torch.Tensor,
285 w13_scale: torch.Tensor,
286 w2: torch.Tensor,
@@ -279,7 +295,7 @@ def align_trtllm_fp4_moe_hidden_dim_for_fi(
295 return w13, w13_scale, w2, w2_scale, hidden_size
296
297 logger.warning_once(
282 - "Padding hidden size from %d to %d for TRTLLM NVFP4 MoE weights. "
298 + "Padding hidden size from %d to %d for FlashInfer NVFP4 MoE weights. "
299 "This requires activation slicing at runtime and may cause "
300 "performance degradation.",
301 hidden_size,