| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Tests for FlashInfer CuTeDSL NVFP4 MoE.""" |
| 4 | |
| 5 | from types import SimpleNamespace |
| 6 | |
| 7 | import pytest |
| 8 | import torch |
| 9 | |
| 10 | import vllm.model_executor.layers.fused_moe.modular_kernel as mk |
| 11 | from tests.kernels.quantization.nvfp4_utils import ( |
| 12 | FLOAT4_E2M1_MAX, |
| 13 | FLOAT8_E4M3_MAX, |
| 14 | break_fp4_bytes, |
| 15 | ) |
| 16 | from vllm import _custom_ops as ops |
| 17 | from vllm.config import ParallelConfig, VllmConfig, set_current_vllm_config |
| 18 | from vllm.model_executor.layers.activation import ( |
| 19 | SiluAndMul, |
| 20 | SiluAndMulWithClamp, |
| 21 | SwigluOAIAndMul, |
| 22 | ) |
| 23 | from vllm.model_executor.layers.fused_moe import fused_topk |
| 24 | from vllm.model_executor.layers.fused_moe.activation import MoEActivation |
| 25 | from vllm.model_executor.layers.fused_moe.all2all_utils import ( |
| 26 | maybe_make_prepare_finalize, |
| 27 | ) |
| 28 | from vllm.model_executor.layers.fused_moe.config import ( |
| 29 | FusedMoEConfig, |
| 30 | FusedMoEParallelConfig, |
| 31 | RoutingMethodType, |
| 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 |
| 49 | from vllm.utils.torch_utils import set_random_seed |
| 50 | |
| 51 | if not has_flashinfer_cutedsl_moe_nvfp4() or not ( |
| 52 | current_platform.is_device_capability_family(100) |
| 53 | ): |
| 54 | pytest.skip( |
| 55 | "Requires FlashInfer CuTeDSL NVFP4 MoE on SM100", |
| 56 | allow_module_level=True, |
| 57 | ) |
| 58 | |
| 59 | _SWIGLU_ALPHA = 1.702 |
| 60 | _SWIGLU_BETA = 1.0 |
| 61 | # The GEMM1 output is O(0.5) at these tensor scales, so the OAI default of 7.0 |
| 62 | # would never clamp anything. Pick a limit that actually bites. |
| 63 | _SWIGLU_LIMIT = 0.3 |
| 64 | |
| 65 | _ACT_CASES = [ |
| 66 | pytest.param(MoEActivation.SILU, None, None, None, id="silu"), |
| 67 | pytest.param(MoEActivation.SILU, None, None, _SWIGLU_LIMIT, id="silu-clamp"), |
| 68 | pytest.param(MoEActivation.RELU2_NO_MUL, None, None, None, id="relu2_no_mul"), |
| 69 | pytest.param( |
| 70 | MoEActivation.SWIGLUOAI, |
| 71 | _SWIGLU_ALPHA, |
| 72 | _SWIGLU_BETA, |
| 73 | _SWIGLU_LIMIT, |
| 74 | id="swigluoai", |
| 75 | ), |
| 76 | pytest.param( |
| 77 | MoEActivation.SWIGLUOAI_UNINTERLEAVE, |
| 78 | _SWIGLU_ALPHA, |
| 79 | _SWIGLU_BETA, |
| 80 | _SWIGLU_LIMIT, |
| 81 | id="swigluoai_uninterleave", |
| 82 | ), |
| 83 | ] |
| 84 | |
| 85 | |
| 86 | def _reference_activation( |
| 87 | activation: MoEActivation, |
| 88 | alpha: float | None, |
| 89 | beta: float | None, |
| 90 | limit: float | None, |
| 91 | ): |
| 92 | """VLLM's own op for this activation, so the reference is not re-derived.""" |
| 93 | if activation == MoEActivation.RELU2_NO_MUL: |
| 94 | return lambda x: torch.square(torch.relu(x)) |
| 95 | if activation == MoEActivation.SWIGLUOAI: |
| 96 | # SwigluOAIAndMul hardcodes beta=1 and reads gate/up interleaved. |
| 97 | assert beta == 1.0 |
| 98 | return SwigluOAIAndMul(alpha=alpha, limit=limit) |
| 99 | if activation == MoEActivation.SWIGLUOAI_UNINTERLEAVE: |
| 100 | return SiluAndMulWithClamp(limit, alpha, beta, compile_native=False) |
| 101 | if limit is not None: |
| 102 | return SiluAndMulWithClamp(limit, compile_native=False) |
| 103 | return SiluAndMul() |
| 104 | |
| 105 | |
| 106 | def _torch_moe_reference( |
| 107 | a: torch.Tensor, |
| 108 | w1: torch.Tensor, |
| 109 | w2: torch.Tensor, |
| 110 | score: torch.Tensor, |
| 111 | topk: int, |
| 112 | act_fn, |
| 113 | ) -> torch.Tensor: |
| 114 | m = a.shape[0] |
| 115 | weights, ids = torch.topk(torch.softmax(score, dim=-1, dtype=torch.float32), topk) |
| 116 | x = a.view(m, 1, -1).repeat(1, topk, 1).reshape(m * topk, -1) |
| 117 | flat_ids = ids.reshape(-1) |
| 118 | out = torch.zeros(m * topk, w2.shape[1], dtype=a.dtype, device=a.device) |
| 119 | for expert in range(w1.shape[0]): |
| 120 | mask = flat_ids == expert |
| 121 | if mask.any(): |
| 122 | acc = act_fn(x[mask] @ w1[expert].transpose(0, 1)).to(a.dtype) |
| 123 | out[mask] = acc @ w2[expert].transpose(0, 1) |
| 124 | return ( |
| 125 | (out.view(m, topk, -1).float() * weights.view(m, topk, 1)) |
| 126 | .sum(dim=1) |
| 127 | .to(a.dtype) |
| 128 | ) |
| 129 | |
| 130 | |
| 131 | def _quantize_nvfp4_linear( |
| 132 | weight: torch.Tensor, |
| 133 | ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: |
| 134 | weights_q = [] |
| 135 | scales = [] |
| 136 | global_scales = [] |
| 137 | for expert_weight in weight: |
| 138 | global_scale = ( |
| 139 | FLOAT8_E4M3_MAX * FLOAT4_E2M1_MAX / expert_weight.abs().max() |
| 140 | ).to(torch.float32) |
| 141 | weight_q, scale = ops.scaled_fp4_quant( |
| 142 | expert_weight, |
| 143 | global_scale, |
| 144 | is_sf_swizzled_layout=False, |
| 145 | ) |
| 146 | weights_q.append(weight_q) |
| 147 | scales.append(scale) |
| 148 | global_scales.append(global_scale) |
| 149 | return torch.stack(weights_q), torch.stack(scales), torch.stack(global_scales) |
| 150 | |
| 151 | |
| 152 | def _dequantize_nvfp4_linear( |
| 153 | tensor_fp4: torch.Tensor, |
| 154 | tensor_sf: torch.Tensor, |
| 155 | global_scale: torch.Tensor, |
| 156 | dtype: torch.dtype, |
| 157 | ) -> torch.Tensor: |
| 158 | assert tensor_fp4.dtype == torch.uint8 |
| 159 | m, packed_k = tensor_fp4.shape |
| 160 | k = packed_k * 2 |
| 161 | tensor_f32 = break_fp4_bytes(tensor_fp4, torch.float32) |
| 162 | tensor_f32 = tensor_f32.reshape(m, k // 16, 16) |
| 163 | tensor_sf = tensor_sf.view(torch.float8_e4m3fn).to(torch.float32) |
| 164 | tensor_sf = tensor_sf[:, : k // 16] / global_scale |
| 165 | return (tensor_f32 * tensor_sf.unsqueeze(-1)).reshape(m, k).to(dtype) |
| 166 | |
| 167 | |
| 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( |
| 177 | m: int, |
| 178 | n: int, |
| 179 | k: int, |
| 180 | e: int, |
| 181 | topk: int, |
| 182 | activation: MoEActivation, |
| 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 | ): |
| 190 | set_random_seed(7) |
| 191 | with set_current_vllm_config( |
| 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 |
| 202 | w2 = torch.randn((e, k, n), device="cuda", dtype=dtype) / 15 |
| 203 | w1_q, w1_scale, w1_global_scale = _quantize_nvfp4_linear(w1) |
| 204 | w2_q, w2_scale, w2_global_scale = _quantize_nvfp4_linear(w2) |
| 205 | |
| 206 | score = torch.randn((m, e), device="cuda", dtype=dtype) |
| 207 | topk_weights, topk_ids, _ = fused_topk( |
| 208 | hidden_states, score, topk, renormalize=False |
| 209 | ) |
| 210 | |
| 211 | moe_config = FusedMoEConfig( |
| 212 | num_experts=e, |
| 213 | experts_per_token=topk, |
| 214 | hidden_dim=k, |
| 215 | intermediate_size=n, |
| 216 | num_local_experts=e, |
| 217 | num_logical_experts=e, |
| 218 | activation=activation, |
| 219 | device="cuda", |
| 220 | moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(), |
| 221 | in_dtype=dtype, |
| 222 | routing_method=RoutingMethodType.TopK, |
| 223 | max_num_tokens=next_power_of_2(m), |
| 224 | ) |
| 225 | fake_layer = SimpleNamespace(activation=activation, moe_config=moe_config) |
| 226 | a1_scale = torch.ones(1, device="cuda", dtype=torch.float32) |
| 227 | a2_scale = torch.ones(1, device="cuda", dtype=torch.float32) |
| 228 | ( |
| 229 | w1_cutedsl, |
| 230 | w1_scale_cutedsl, |
| 231 | w1_alpha, |
| 232 | a1_scale, |
| 233 | w2_cutedsl, |
| 234 | w2_scale_cutedsl, |
| 235 | w2_alpha, |
| 236 | a2_scale, |
| 237 | ) = prepare_nvfp4_moe_layer_for_flashinfer_cutedsl( |
| 238 | layer=fake_layer, |
| 239 | w13=w1_q, |
| 240 | w13_scale=w1_scale, |
| 241 | w13_scale_2=(1.0 / w1_global_scale), |
| 242 | a13_scale=a1_scale, |
| 243 | w2=w2_q, |
| 244 | w2_scale=w2_scale, |
| 245 | w2_scale_2=(1.0 / w2_global_scale), |
| 246 | a2_scale=a2_scale, |
| 247 | ) |
| 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, |
| 267 | # Unset params must be omitted rather than forwarded as None into |
| 268 | # the kernel's float-typed SwiGLU arguments. |
| 269 | swiglu_alpha=alpha, |
| 270 | swiglu_beta=beta, |
| 271 | swiglu_limit=limit, |
| 272 | ) |
| 273 | |
| 274 | cutedsl_experts = mk.FusedMoEKernel( |
| 275 | maybe_make_prepare_finalize( |
| 276 | moe=moe_config, |
| 277 | quant_config=quant_config, |
| 278 | allow_new_interface=True, |
| 279 | use_monolithic=False, |
| 280 | ), |
| 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( |
| 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, |
| 294 | topk_ids=topk_ids, |
| 295 | activation=activation, |
| 296 | global_num_experts=e, |
| 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) |
| 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) |
| 322 | for idx in range(e): |
| 323 | w1_d[idx] = _dequantize_nvfp4_linear( |
| 324 | w1_q[idx], |
| 325 | w1_scale[idx], |
| 326 | w1_global_scale[idx], |
| 327 | dtype=dtype, |
| 328 | ) |
| 329 | w2_d[idx] = _dequantize_nvfp4_linear( |
| 330 | w2_q[idx], |
| 331 | w2_scale[idx], |
| 332 | w2_global_scale[idx], |
| 333 | dtype=dtype, |
| 334 | ) |
| 335 | |
| 336 | torch_output = _torch_moe_reference( |
| 337 | a_in_dtype, |
| 338 | w1_d, |
| 339 | w2_d, |
| 340 | score, |
| 341 | topk, |
| 342 | _reference_activation(activation, alpha, beta, limit), |
| 343 | ) |
| 344 | torch.testing.assert_close( |
| 345 | torch_output, |
| 346 | cutedsl_output, |
| 347 | atol=3e-2, |
| 348 | rtol=2e-1, |
| 349 | ) |
| 350 | # Outputs here are O(1e-2) while NVFP4 noise is O(1e-3), so an absolute |
| 351 | # tolerance loose enough for the quantization error also accepts a zero |
| 352 | # tensor. Compare direction too, which dropped SwiGLU params or a wrong |
| 353 | # w13 layout would break. |
| 354 | cosine = torch.nn.functional.cosine_similarity( |
| 355 | cutedsl_output.flatten().float(), torch_output.flatten().float(), dim=0 |
| 356 | ) |
| 357 | assert cosine > 0.99, f"cosine similarity {cosine:.4f} below 0.99" |