[NVFP4][Emulation] Fuse NVFP4 weight dequantization with compute in triton kernel for w13/w2 MOE MLP linears (#44667)
Signed-off-by: Felix Marty <Felix.Marty@amd.com>
fxmarty-amd committed
Jun 26, 2026 at 04:33 UTC
552a9dbe59bf2e6a35654440c64c5e52bed90586
3 files changed
+906
-68
tests/kernels/quantization/test_nvfp4_emulation.py
+463
@@ -1,10 +1,25 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from typing import cast
4
+
5
import huggingface_hub
6
import pytest
7
import torch
8
from safetensors import safe_open
9
10
+import vllm.model_executor.layers.fused_moe.modular_kernel as mk
11
+from vllm.model_executor.layers.fused_moe.activation import MoEActivation
12
+from vllm.model_executor.layers.fused_moe.config import (
13
+ FusedMoEConfig,
14
+ FusedMoEParallelConfig,
15
+ FusedMoEQuantConfig,
16
+ RoutingMethodType,
17
+ nvfp4_moe_quant_config,
18
+)
19
+from vllm.model_executor.layers.fused_moe.experts.nvfp4_emulation_moe import (
20
+ Nvfp4QuantizationEmulationTritonExperts,
21
+)
22
+from vllm.model_executor.layers.fused_moe.experts.triton_moe import TritonExperts
23
from vllm.model_executor.layers.quantization.utils import (
24
nvfp4_emulation_utils,
25
)
@@ -12,9 +27,167 @@ from vllm.model_executor.layers.quantization.utils.nvfp4_emulation_utils import
27
dequantize_to_dtype,
28
ref_nvfp4_quant_dequant,
29
)
30
+from vllm.model_executor.layers.quantization.utils.quant_utils import (
31
+ QuantKey,
32
+ kNvfp4Dynamic,
33
+ kNvfp4Static,
34
+)
35
from vllm.platforms import current_platform
36
from vllm.triton_utils import triton
37
38
+if current_platform.is_rocm():
39
+ from vllm.platforms.rocm import on_gfx950
40
+else:
41
+
42
+ def on_gfx950() -> bool:
43
+ return False
44
+
45
+
46
+class Nvfp4QuantizationEmulationTritonExpertsReference(TritonExperts):
47
+ """
48
+ Extension of TritonExperts to support emulated NVFP4 MoE experts.
49
+
50
+ It may be used for NVFP4 models when the device does not have
51
+ native support for this dtype.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ moe_config: FusedMoEConfig,
57
+ quant_config: FusedMoEQuantConfig,
58
+ ):
59
+ super().__init__(moe_config, quant_config)
60
+
61
+ # `TritonExperts.apply` expects pre-dequantized weights,
62
+ # which we handle in `apply` below.
63
+ self.w1_scale_val = self.quant_config.w1_scale
64
+ self.w2_scale_val = self.quant_config.w2_scale
65
+
66
+ self.quant_config._w1.scale = None
67
+ self.quant_config._w2.scale = None
68
+
69
+ self.quantization_emulation = True
70
+
71
+ @property
72
+ def quant_dtype(self) -> torch.dtype | str | None:
73
+ return "nvfp4"
74
+
75
+ @property
76
+ def a1_scale(self) -> torch.Tensor | None:
77
+ return self.quant_config.a1_gscale
78
+
79
+ @property
80
+ def expects_unquantized_inputs(self) -> bool:
81
+ return True
82
+
83
+ @staticmethod
84
+ def _supports_quant_scheme(
85
+ weight_key: QuantKey | None,
86
+ activation_key: QuantKey | None,
87
+ ) -> bool:
88
+ return (weight_key, activation_key) == (kNvfp4Static, kNvfp4Dynamic)
89
+
90
+ def apply(
91
+ self,
92
+ output: torch.Tensor,
93
+ hidden_states: torch.Tensor,
94
+ w1: torch.Tensor,
95
+ w2: torch.Tensor,
96
+ topk_weights: torch.Tensor,
97
+ topk_ids: torch.Tensor,
98
+ activation: MoEActivation,
99
+ global_num_experts: int,
100
+ expert_map: torch.Tensor | None,
101
+ a1q_scale: torch.Tensor | None,
102
+ a2_scale: torch.Tensor | None,
103
+ workspace13: torch.Tensor,
104
+ workspace2: torch.Tensor,
105
+ expert_tokens_meta: mk.ExpertTokensMetadata | None,
106
+ apply_router_weight_on_input: bool,
107
+ ):
108
+ assert w1.dtype == torch.uint8
109
+ assert w2.dtype == torch.uint8
110
+
111
+ # Dequantize w1 from packed NVFP4 to fp16/bf16
112
+ w13_global_scale = self.quant_config.g1_alphas
113
+
114
+ w1_dequant = dequantize_to_dtype(
115
+ tensor_fp4=w1,
116
+ tensor_sf=self.w1_scale_val,
117
+ global_scale=w13_global_scale,
118
+ dtype=hidden_states.dtype,
119
+ block_size=16,
120
+ swizzle=False,
121
+ )
122
+
123
+ # Dequantize w2 from packed NVFP4 to fp16/bf16
124
+ w2_global_scale = self.quant_config.g2_alphas
125
+
126
+ w2_dequant = dequantize_to_dtype(
127
+ tensor_fp4=w2,
128
+ tensor_sf=self.w2_scale_val,
129
+ global_scale=w2_global_scale,
130
+ dtype=hidden_states.dtype,
131
+ block_size=16,
132
+ swizzle=False,
133
+ )
134
+
135
+ super().apply(
136
+ output=output,
137
+ hidden_states=hidden_states,
138
+ w1=w1_dequant,
139
+ w2=w2_dequant,
140
+ topk_weights=topk_weights,
141
+ topk_ids=topk_ids,
142
+ activation=activation,
143
+ global_num_experts=global_num_experts,
144
+ expert_map=expert_map,
145
+ a1q_scale=None,
146
+ a2_scale=self.quant_config.a2_gscale,
147
+ workspace13=workspace13,
148
+ workspace2=workspace2,
149
+ expert_tokens_meta=expert_tokens_meta,
150
+ apply_router_weight_on_input=apply_router_weight_on_input,
151
+ )
152
+
153
+
154
+@pytest.mark.parametrize(
155
+ ("config_kwargs", "expected_reason"),
156
+ [
157
+ ({"has_bias": True}, "kernel does not support bias"),
158
+ ({"is_lora_enabled": True}, "kernel does not support LoRA"),
159
+ ],
160
+)
161
+def test_nvfp4_emulation_support_check_rejects_bias_and_lora(
162
+ config_kwargs: dict[str, bool],
163
+ expected_reason: str,
164
+) -> None:
165
+ moe_config = FusedMoEConfig(
166
+ num_experts=2,
167
+ experts_per_token=1,
168
+ hidden_dim=16,
169
+ intermediate_size=16,
170
+ num_local_experts=2,
171
+ num_logical_experts=2,
172
+ moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(),
173
+ activation=MoEActivation.SILU,
174
+ in_dtype=torch.bfloat16,
175
+ device="cuda",
176
+ routing_method=RoutingMethodType.TopK,
177
+ **config_kwargs,
178
+ )
179
+
180
+ supported, reason = Nvfp4QuantizationEmulationTritonExperts.is_supported_config(
181
+ Nvfp4QuantizationEmulationTritonExperts,
182
+ moe_config,
183
+ kNvfp4Static,
184
+ kNvfp4Dynamic,
185
+ mk.FusedMoEActivationFormat.Standard,
186
+ )
187
+
188
+ assert not supported
189
+ assert reason == expected_reason
190
+
191
192
@pytest.mark.skipif(
193
not current_platform.is_cuda_alike(),
@@ -306,3 +479,293 @@ def test_triton_nvfp4_quant_dequant(
479
f"min={ref_min:.3f}ms, max={ref_max:.3f}ms"
480
)
481
print(f" speedup: {speedup:.2f}x")
482
+
483
+
484
+MOE_MODEL_CONFIGS = {
485
+ "nvidia/Qwen3-30B-A3B-NVFP4": {
486
+ "shards": ["model-00001-of-00004.safetensors"],
487
+ "expert_prefix": "model.layers.9.mlp.experts.",
488
+ # Position of the expert index in the dot-split key.
489
+ "expert_idx_pos": 5,
490
+ },
491
+ "nvidia/Kimi-K2.6-NVFP4": {
492
+ "shards": [
493
+ "model-00001-of-00060.safetensors",
494
+ "model-00002-of-00060.safetensors",
495
+ ],
496
+ "expert_prefix": "language_model.model.layers.1.mlp.experts.",
497
+ "expert_idx_pos": 6,
498
+ },
499
+}
500
+
501
+
502
+def _load_nvfp4_moe_weights(
503
+ model_id: str,
504
+ tensor_parallel_size: int,
505
+ max_experts: int | None = None,
506
+):
507
+ """Load and stack NVFP4 MoE weights from checkpoint shards.
508
+
509
+ Returns (w1, w1_scale, w1_gscale, w2, w2_scale, w2_gscale,
510
+ a1_gscale, a2_gscale, num_experts, hidden_dim,
511
+ intermediate_size).
512
+
513
+ When max_experts is set, only the first max_experts experts are loaded.
514
+
515
+ When tensor_parallel_size > 1, the N dimension of w1 and the K
516
+ dimension of w2 are narrowed to the first TP shard (simulating
517
+ column-parallel on w1 / row-parallel on w2).
518
+ """
519
+ cfg = MOE_MODEL_CONFIGS[model_id]
520
+ shards = cast(list[str], cfg["shards"])
521
+ checkpoint_path = huggingface_hub.snapshot_download(
522
+ model_id,
523
+ allow_patterns=shards,
524
+ )
525
+ expert_prefix = cfg["expert_prefix"]
526
+ idx_pos = cast(int, cfg["expert_idx_pos"])
527
+
528
+ # Collect all tensors across shards into a flat dict — an expert's
529
+ # tensors may be split across multiple shard files.
530
+ all_tensors: dict[str, torch.Tensor] = {}
531
+ for shard_name in shards:
532
+ shard_path = f"{checkpoint_path}/{shard_name}"
533
+ with safe_open(shard_path, framework="pt", device="cpu") as f:
534
+ for key in f.keys(): # noqa: SIM118
535
+ if key.startswith(expert_prefix):
536
+ all_tensors[key] = f.get_tensor(key)
537
+
538
+ expert_indices = sorted(
539
+ {
540
+ int(key.split(".")[idx_pos])
541
+ for key in all_tensors
542
+ if key.endswith(".gate_proj.weight")
543
+ }
544
+ )
545
+ if max_experts is not None:
546
+ expert_indices = expert_indices[:max_experts]
547
+ num_experts = len(expert_indices)
548
+
549
+ gate_weights, up_weights, down_weights = [], [], []
550
+ gate_scales, up_scales, down_scales = [], [], []
551
+ gate_gscales, up_gscales, down_gscales = [], [], []
552
+ a1_scales, a2_scales = [], []
553
+
554
+ for idx in expert_indices:
555
+ prefix = f"{expert_prefix}{idx}"
556
+ gate_weights.append(all_tensors[f"{prefix}.gate_proj.weight"])
557
+ gate_scales.append(all_tensors[f"{prefix}.gate_proj.weight_scale"])
558
+ gate_gscales.append(all_tensors[f"{prefix}.gate_proj.weight_scale_2"])
559
+ up_weights.append(all_tensors[f"{prefix}.up_proj.weight"])
560
+ up_scales.append(all_tensors[f"{prefix}.up_proj.weight_scale"])
561
+ up_gscales.append(all_tensors[f"{prefix}.up_proj.weight_scale_2"])
562
+ down_weights.append(all_tensors[f"{prefix}.down_proj.weight"])
563
+ down_scales.append(all_tensors[f"{prefix}.down_proj.weight_scale"])
564
+ down_gscales.append(all_tensors[f"{prefix}.down_proj.weight_scale_2"])
565
+ a1_scales.append(all_tensors[f"{prefix}.gate_proj.input_scale"])
566
+ a2_scales.append(all_tensors[f"{prefix}.down_proj.input_scale"])
567
+
568
+ # Stack into MoE format.
569
+ # w1 = [E, 2*intermediate, hidden//2] (gate + up concatenated)
570
+ w1 = torch.stack(
571
+ [torch.cat([g, u], dim=0) for g, u in zip(gate_weights, up_weights)]
572
+ ).cuda()
573
+ w1_scale = torch.stack(
574
+ [torch.cat([g, u], dim=0) for g, u in zip(gate_scales, up_scales)]
575
+ ).cuda()
576
+ w1_gscale = torch.stack(gate_gscales).cuda()
577
+
578
+ # w2 = [E, hidden, intermediate//2]
579
+ w2 = torch.stack(down_weights).cuda()
580
+ w2_scale = torch.stack(down_scales).cuda()
581
+ w2_gscale = torch.stack(down_gscales).cuda()
582
+
583
+ a13_scale_raw = torch.stack(a1_scales).cuda()
584
+ a2_scale_raw = torch.stack(a2_scales).cuda()
585
+
586
+ # Apply EMULATION transforms (matches oracle/nvfp4.py).
587
+ nvfp4_emulation_utils.kE2M1ToFloat_handle.val = (
588
+ nvfp4_emulation_utils.kE2M1ToFloat_handle.val.cuda()
589
+ )
590
+ a1_gscale = 1.0 / a13_scale_raw.max().to(torch.float32)
591
+ a2_gscale = 1.0 / a2_scale_raw.max().to(torch.float32)
592
+
593
+ # ── Simulate TP sharding ──
594
+ # w1 (gate_up): column-parallel → shard the N dimension (dim 1).
595
+ # w2 (down): row-parallel → shard the K dimension (dim 2,
596
+ # which is the packed K//2 dim).
597
+ # Scales follow the same sharding on the corresponding dimension.
598
+ tp = tensor_parallel_size
599
+ if tp > 1:
600
+ n1 = w1.size(1) // tp
601
+ w1 = w1[:, :n1, :].contiguous()
602
+ w1_scale = w1_scale[:, :n1, :].contiguous()
603
+
604
+ k2_packed = w2.size(2) // tp
605
+ k2_scale = w2_scale.size(2) // tp
606
+ w2 = w2[:, :, :k2_packed].contiguous()
607
+ w2_scale = w2_scale[:, :, :k2_scale].contiguous()
608
+
609
+ hidden_dim = w1.size(2) * 2
610
+ intermediate_size = w1.size(1) // 2
611
+
612
+ return (
613
+ w1,
614
+ w1_scale,
615
+ w1_gscale,
616
+ w2,
617
+ w2_scale,
618
+ w2_gscale,
619
+ a1_gscale,
620
+ a2_gscale,
621
+ num_experts,
622
+ hidden_dim,
623
+ intermediate_size,
624
+ )
625
+
626
+
627
+@pytest.mark.skipif(
628
+ not current_platform.is_cuda_alike(),
629
+ reason="Triton NVFP4 kernel requires CUDA.",
630
+)
631
+@pytest.mark.parametrize("num_tokens", [1, 2, 4, 1024])
632
+@pytest.mark.parametrize("top_k", [4])
633
+@pytest.mark.parametrize("model_id", list(MOE_MODEL_CONFIGS.keys()))
634
+@pytest.mark.parametrize(
635
+ "tensor_parallel_size",
636
+ [pytest.param(val, id=f"tensor_parallel_size:{val}") for val in [1, 2, 4, 8]],
637
+)
638
+def test_nvfp4_moe_correctness(
639
+ num_tokens: int,
640
+ top_k: int,
641
+ model_id: str,
642
+ tensor_parallel_size: int,
643
+) -> None:
644
+ """Compare Nvfp4QuantizationEmulationTritonExperts (fused weight dequant + compute)
645
+ against the unfused reference Nvfp4QuantizationEmulationTritonExpertsReference.
646
+
647
+ Both must produce bit-identical results.
648
+ """
649
+ num_test_experts = max(8, top_k)
650
+ (
651
+ w1,
652
+ w1_scale,
653
+ w1_gscale,
654
+ w2,
655
+ w2_scale,
656
+ w2_gscale,
657
+ a1_gscale,
658
+ a2_gscale,
659
+ num_experts,
660
+ hidden_dim,
661
+ intermediate_size,
662
+ ) = _load_nvfp4_moe_weights(
663
+ model_id,
664
+ tensor_parallel_size,
665
+ max_experts=num_test_experts,
666
+ )
667
+
668
+ moe_config = FusedMoEConfig(
669
+ num_experts=num_experts,
670
+ experts_per_token=top_k,
671
+ hidden_dim=hidden_dim,
672
+ intermediate_size=intermediate_size,
673
+ num_local_experts=num_experts,
674
+ num_logical_experts=num_experts,
675
+ moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(),
676
+ activation=MoEActivation.SILU,
677
+ in_dtype=torch.bfloat16,
678
+ device="cuda",
679
+ routing_method=RoutingMethodType.TopK,
680
+ max_num_tokens=512,
681
+ )
682
+
683
+ def _make_quant_config():
684
+ return nvfp4_moe_quant_config(
685
+ g1_alphas=w1_gscale.clone(),
686
+ g2_alphas=w2_gscale.clone(),
687
+ a1_gscale=a1_gscale.clone(),
688
+ a2_gscale=a2_gscale.clone(),
689
+ w1_scale=w1_scale.clone(),
690
+ w2_scale=w2_scale.clone(),
691
+ )
692
+
693
+ ref_experts = Nvfp4QuantizationEmulationTritonExpertsReference(
694
+ moe_config=moe_config,
695
+ quant_config=_make_quant_config(),
696
+ )
697
+ fused_experts = Nvfp4QuantizationEmulationTritonExperts(
698
+ moe_config=moe_config,
699
+ quant_config=_make_quant_config(),
700
+ )
701
+
702
+ torch.manual_seed(42)
703
+ hidden_states = torch.randn(
704
+ num_tokens, hidden_dim, dtype=torch.bfloat16, device="cuda"
705
+ )
706
+
707
+ topk_weights = torch.randn(
708
+ num_tokens, top_k, dtype=torch.float32, device="cuda"
709
+ ).softmax(dim=-1)
710
+ topk_ids = torch.stack(
711
+ [torch.randperm(num_experts, device="cuda")[:top_k] for _ in range(num_tokens)]
712
+ ).to(torch.int32)
713
+
714
+ N = w1.size(1) # 2 * intermediate
715
+ K = hidden_dim
716
+
717
+ ws13_size = num_tokens * top_k * max(intermediate_size, K)
718
+ ws2_size = num_tokens * top_k * max(N, K)
719
+
720
+ workspace13_ref = torch.zeros(ws13_size, dtype=torch.bfloat16, device="cuda")
721
+ workspace2_ref = torch.zeros(ws2_size, dtype=torch.bfloat16, device="cuda")
722
+ output_ref = torch.zeros(num_tokens, K, dtype=torch.bfloat16, device="cuda")
723
+
724
+ workspace13_fused = torch.zeros_like(workspace13_ref)
725
+ workspace2_fused = torch.zeros_like(workspace2_ref)
726
+ output_fused = torch.zeros_like(output_ref)
727
+
728
+ apply_kwargs = dict(
729
+ hidden_states=hidden_states,
730
+ w1=w1,
731
+ w2=w2,
732
+ topk_weights=topk_weights,
733
+ topk_ids=topk_ids,
734
+ activation=MoEActivation.SILU,
735
+ global_num_experts=num_experts,
736
+ expert_map=None,
737
+ a1q_scale=None,
738
+ a2_scale=None,
739
+ expert_tokens_meta=None,
740
+ apply_router_weight_on_input=False,
741
+ )
742
+
743
+ # Unfused reference.
744
+ ref_experts.apply(
745
+ output=output_ref,
746
+ workspace13=workspace13_ref,
747
+ workspace2=workspace2_ref,
748
+ **apply_kwargs,
749
+ )
750
+
751
+ # Fused implementation.
752
+ fused_experts.apply(
753
+ output=output_fused,
754
+ workspace13=workspace13_fused,
755
+ workspace2=workspace2_fused,
756
+ **apply_kwargs,
757
+ )
758
+
759
+ # Not strict equality on H100, MI325, MI300 (< 0.1% elements).
760
+ # The fused on-the-fly dequant path can lower to a slightly
761
+ # different Triton/MMA tiling than the pre-dequantized
762
+ # reference; experiments with reference-like tiling/masking
763
+ # reduced some diffs were not kept because they regress
764
+ # the fused kernel speed.
765
+ # Strict equality validated on MI355.
766
+ torch.testing.assert_close(
767
+ output_fused,
768
+ output_ref,
769
+ atol=0.0 if on_gfx950() else 0.02,
770
+ rtol=0,
771
+ )
vllm/model_executor/layers/fused_moe/experts/nvfp4_emulation_moe.py
+424
-37
@@ -11,6 +11,8 @@ Weights are dequantized on the fly during each forward, we fall back to calling
11
is applied on `a13`, `a2`.
12
"""
13
14
+from typing import Any
15
+
16
import torch
17
18
import vllm.model_executor.layers.fused_moe.modular_kernel as mk
@@ -21,18 +23,316 @@ from vllm.model_executor.layers.fused_moe.config import (
23
FusedMoEQuantConfig,
24
)
25
from vllm.model_executor.layers.fused_moe.experts.triton_moe import TritonExperts
26
+from vllm.model_executor.layers.fused_moe.fused_moe import (
27
+ try_get_optimal_moe_config,
28
+ write_zeros_to_output,
29
+)
30
+from vllm.model_executor.layers.fused_moe.moe_align_block_size import (
31
+ moe_align_block_size,
32
+)
33
+from vllm.model_executor.layers.fused_moe.utils import (
34
+ _resize_cache,
35
+ moe_kernel_quantize_input,
36
+)
37
from vllm.model_executor.layers.quantization.utils.nvfp4_emulation_utils import (
25
- dequantize_to_dtype,
38
+ _e2m1_inline,
39
)
40
from vllm.model_executor.layers.quantization.utils.quant_utils import (
41
QuantKey,
42
kNvfp4Dynamic,
43
kNvfp4Static,
44
)
45
+from vllm.triton_utils import tl, triton
46
47
logger = init_logger(__name__)
48
49
50
+@triton.jit
51
+def fused_moe_nvfp4_emulation_kernel(
52
+ a_ptr,
53
+ b_ptr,
54
+ c_ptr,
55
+ b_scale_ptr,
56
+ w_global_scale_ptr,
57
+ topk_weights_ptr,
58
+ sorted_token_ids_ptr,
59
+ expert_ids_ptr,
60
+ num_tokens_post_padded_ptr,
61
+ N: tl.constexpr,
62
+ K: tl.constexpr,
63
+ EM,
64
+ num_valid_tokens,
65
+ # Strides — A [M, K]
66
+ stride_am,
67
+ stride_ak,
68
+ # Strides — B [E, N, K//2], passed as (expert, K-packed, N)
69
+ stride_be,
70
+ stride_bk,
71
+ stride_bn,
72
+ # Strides — C [M, topk, N]
73
+ stride_cm,
74
+ stride_cn,
75
+ # Strides — B_scale [E, N, K//BLOCK], passed as (expert, K-scale, N)
76
+ stride_bse,
77
+ stride_bsk,
78
+ stride_bsn,
79
+ block_k_diviable: tl.constexpr,
80
+ BLOCK_SIZE_M: tl.constexpr,
81
+ BLOCK_SIZE_N: tl.constexpr,
82
+ BLOCK_SIZE_K: tl.constexpr,
83
+ GROUP_SIZE_M: tl.constexpr,
84
+ MUL_ROUTED_WEIGHT: tl.constexpr,
85
+ top_k: tl.constexpr,
86
+ compute_type: tl.constexpr,
87
+ group_size: tl.constexpr,
88
+):
89
+ """
90
+ Fused MoE kernel for emulated NVFP4 weight-only dequantization + GEMM.
91
+
92
+ Activations A are BF16 (already QDQ'd externally).
93
+ Weights B are packed uint8 NVFP4 [E, N, K//2] — two FP4 values per byte
94
+ along the K dimension.
95
+ B_scale holds per-block FP8-E4M3 scales [E, N, K // group_size].
96
+ w_global_scale is a per-expert scalar global scale.
97
+
98
+ The dequantization formula per element is:
99
+ w_float = e2m1_decode(nibble) * (block_scale_fp8 * global_scale)
100
+
101
+ Weight loading optimization: each packed byte is loaded exactly once as
102
+ a [BLOCK_SIZE_N, BLOCK_SIZE_K // 2] tile (N-major), both nibbles are
103
+ extracted, decoded and scaled, then tl.interleave produces the
104
+ [BLOCK_SIZE_N, BLOCK_SIZE_K] dequantized tile which is transposed to
105
+ [BLOCK_SIZE_K, BLOCK_SIZE_N] for tl.dot.
106
+ """
107
+ BLOCK_SIZE_K_PACKED: tl.constexpr = BLOCK_SIZE_K // 2
108
+
109
+ # Map program ids to the block of C it should compute.
110
+ pid = tl.program_id(axis=0)
111
+ num_pid_m = tl.cdiv(EM, BLOCK_SIZE_M)
112
+ num_pid_n = tl.cdiv(N, BLOCK_SIZE_N)
113
+ num_pid_in_group = GROUP_SIZE_M * num_pid_n
114
+ group_id = pid // num_pid_in_group
115
+ first_pid_m = group_id * GROUP_SIZE_M
116
+ group_size_m = min(num_pid_m - first_pid_m, GROUP_SIZE_M)
117
+ pid_m = first_pid_m + ((pid % num_pid_in_group) % group_size_m)
118
+ pid_n = (pid % num_pid_in_group) // group_size_m
119
+
120
+ # Token / expert setup
121
+ num_tokens_post_padded = tl.load(num_tokens_post_padded_ptr)
122
+ if pid_m * BLOCK_SIZE_M >= num_tokens_post_padded:
123
+ return
124
+
125
+ offs_token_id = pid_m * BLOCK_SIZE_M + tl.arange(0, BLOCK_SIZE_M).to(tl.int64)
126
+ offs_token = tl.load(sorted_token_ids_ptr + offs_token_id).to(tl.int64)
127
+ token_mask = offs_token < num_valid_tokens
128
+
129
+ off_experts = tl.load(expert_ids_ptr + pid_m).to(tl.int64)
130
+ if off_experts == -1:
131
+ write_zeros_to_output(
132
+ c_ptr,
133
+ stride_cm,
134
+ stride_cn,
135
+ pid_n,
136
+ N,
137
+ offs_token,
138
+ token_mask,
139
+ BLOCK_SIZE_M,
140
+ BLOCK_SIZE_N,
141
+ compute_type,
142
+ )
143
+ return
144
+
145
+ # Pointer setup
146
+ offs_bn = (pid_n * BLOCK_SIZE_N + tl.arange(0, BLOCK_SIZE_N).to(tl.int64)) % N
147
+ offs_k = tl.arange(0, BLOCK_SIZE_K)
148
+ offs_k_packed = tl.arange(0, BLOCK_SIZE_K_PACKED)
149
+
150
+ # A pointers: [BLOCK_SIZE_M, BLOCK_SIZE_K]
151
+ a_ptrs = a_ptr + (
152
+ offs_token[:, None] // top_k * stride_am + offs_k[None, :] * stride_ak
153
+ )
154
+
155
+ # B pointers: [BLOCK_SIZE_N, BLOCK_SIZE_K_PACKED] — N-major so that
156
+ # tl.interleave (which operates on the last dim) produces a
157
+ # [BLOCK_SIZE_N, BLOCK_SIZE_K] tile that we transpose for tl.dot.
158
+ # Each unique byte is loaded exactly once.
159
+ b_ptrs = (
160
+ b_ptr
161
+ + off_experts * stride_be
162
+ + offs_bn[:, None] * stride_bn
163
+ + offs_k_packed[None, :] * stride_bk
164
+ )
165
+
166
+ # B_scale pointers: [BLOCK_SIZE_N, BLOCK_SIZE_K_PACKED] — same
167
+ # N-major layout. Each packed byte index covers 2 K elements that
168
+ # always fall within the same group (group_size=16, so each group
169
+ # spans 8 packed bytes). We can therefore index the scale using
170
+ # offs_k_packed directly.
171
+ # Note: group_size_packed = group_size // 2 maps packed indices to
172
+ # scale indices the same way unpacked indices map via group_size.
173
+ group_size_packed: tl.constexpr = group_size // 2
174
+
175
+ # Load per-expert global scale (scalar).
176
+ w_global_scale = tl.load(w_global_scale_ptr + off_experts).to(tl.float32)
177
+
178
+ # K-loop with FP32 accumulation
179
+ accumulator = tl.zeros((BLOCK_SIZE_M, BLOCK_SIZE_N), dtype=tl.float32)
180
+
181
+ for k in range(0, tl.cdiv(K, BLOCK_SIZE_K)):
182
+ # Load A tile [BLOCK_SIZE_M, BLOCK_SIZE_K].
183
+ if block_k_diviable:
184
+ a = tl.load(
185
+ a_ptrs,
186
+ mask=token_mask[:, None],
187
+ other=0.0,
188
+ )
189
+ else:
190
+ a = tl.load(
191
+ a_ptrs,
192
+ mask=token_mask[:, None] & (offs_k[None, :] < K - k * BLOCK_SIZE_K),
193
+ other=0.0,
194
+ )
195
+
196
+ # Load packed weight tile [BLOCK_SIZE_N, BLOCK_SIZE_K_PACKED].
197
+ if block_k_diviable:
198
+ raw_bytes = tl.load(b_ptrs)
199
+ else:
200
+ kp_mask = offs_k_packed[None, :] < (K // 2) - k * BLOCK_SIZE_K_PACKED
201
+ raw_bytes = tl.load(b_ptrs, mask=kp_mask, other=0)
202
+
203
+ # Extract both nibbles from each byte (each [N, K_packed]).
204
+ low_nibble = raw_bytes & 0x0F
205
+ high_nibble = (raw_bytes >> 4) & 0x0F
206
+
207
+ low_decoded = _e2m1_inline(low_nibble)
208
+ high_decoded = _e2m1_inline(high_nibble)
209
+
210
+ # Load and apply per-block FP8 scales.
211
+ # Scale shape: [BLOCK_SIZE_N, BLOCK_SIZE_K_PACKED], one scale per
212
+ # group_size_packed packed elements.
213
+ b_scale_ptrs = (
214
+ b_scale_ptr
215
+ + off_experts * stride_bse
216
+ + offs_bn[:, None] * stride_bsn
217
+ + ((offs_k_packed[None, :] + BLOCK_SIZE_K_PACKED * k) // group_size_packed)
218
+ * stride_bsk
219
+ )
220
+ if block_k_diviable:
221
+ b_scale_raw = tl.load(b_scale_ptrs)
222
+ else:
223
+ b_scale_raw = tl.load(b_scale_ptrs, mask=kp_mask, other=0.0)
224
+
225
+ b_scale = tl.cast(b_scale_raw, tl.float8e4nv, bitcast=True).to(tl.float32)
226
+ b_scale = b_scale * w_global_scale
227
+
228
+ # Scale both halves with the same per-block scale (the two
229
+ # elements packed in one byte always belong to the same group).
230
+ low_scaled = low_decoded * b_scale
231
+ high_scaled = high_decoded * b_scale
232
+
233
+ # Interleave along last dim: [N, K_packed] x2 -> [N, K],
234
+ # then transpose to [K, N] for tl.dot.
235
+ b = tl.trans(tl.interleave(low_scaled, high_scaled)).to(compute_type)
236
+
237
+ accumulator = tl.dot(a, b, acc=accumulator)
238
+
239
+ # Advance pointers along K.
240
+ a_ptrs += BLOCK_SIZE_K * stride_ak
241
+ b_ptrs += BLOCK_SIZE_K_PACKED * stride_bk
242
+
243
+ # Router weight multiplication (in float32 for stability)
244
+ if MUL_ROUTED_WEIGHT:
245
+ moe_weight = tl.load(topk_weights_ptr + offs_token, mask=token_mask, other=0)
246
+ accumulator = accumulator * moe_weight[:, None]
247
+
248
+ accumulator = accumulator.to(compute_type)
249
+
250
+ # Write output
251
+ offs_cn = pid_n * BLOCK_SIZE_N + tl.arange(0, BLOCK_SIZE_N)
252
+ c_ptrs = c_ptr + stride_cm * offs_token[:, None] + stride_cn * offs_cn[None, :]
253
+ c_mask = token_mask[:, None] & (offs_cn[None, :] < N)
254
+ tl.store(c_ptrs, accumulator, mask=c_mask)
255
+
256
+
257
+def invoke_fused_moe_nvfp4_emulation_kernel(
258
+ A: torch.Tensor,
259
+ B: torch.Tensor,
260
+ C: torch.Tensor,
261
+ B_scale: torch.Tensor,
262
+ act_global_scale: torch.Tensor,
263
+ w_global_scale: torch.Tensor,
264
+ topk_weights: torch.Tensor | None,
265
+ sorted_token_ids: torch.Tensor,
266
+ expert_ids: torch.Tensor,
267
+ num_tokens_post_padded: torch.Tensor,
268
+ mul_routed_weight: bool,
269
+ top_k: int,
270
+ config: dict[str, Any],
271
+ compute_type: tl.dtype,
272
+):
273
+ """Launch the fused NVFP4 emulation MoE kernel.
274
+
275
+ B has shape [E, N, K_packed] where K_packed = K // 2 (two FP4 per byte).
276
+ B_scale has shape [E, N, K // group_size] in FP8-E4M3 (stored as uint8).
277
+ w_global_scale has shape [E] (per-expert scalar).
278
+ """
279
+ assert B_scale is not None and B_scale.ndim == 3
280
+
281
+ N = B.size(1)
282
+ K = A.size(1)
283
+
284
+ M = A.size(0)
285
+ num_tokens = M * top_k
286
+
287
+ EM = sorted_token_ids.size(0)
288
+ if A.size(0) < config["BLOCK_SIZE_M"]:
289
+ EM = min(
290
+ sorted_token_ids.size(0),
291
+ A.size(0) * top_k * config["BLOCK_SIZE_M"],
292
+ )
293
+
294
+ grid = lambda META: (
295
+ triton.cdiv(EM, META["BLOCK_SIZE_M"]) * triton.cdiv(N, META["BLOCK_SIZE_N"]),
296
+ )
297
+
298
+ fused_moe_nvfp4_emulation_kernel[grid](
299
+ A,
300
+ B,
301
+ C,
302
+ B_scale,
303
+ w_global_scale,
304
+ topk_weights,
305
+ sorted_token_ids,
306
+ expert_ids,
307
+ num_tokens_post_padded,
308
+ N,
309
+ K,
310
+ EM,
311
+ num_tokens,
312
+ A.stride(0),
313
+ A.stride(1),
314
+ # B is [E, N, K//2]: swap N and K strides so kernel indexes [K, N].
315
+ B.stride(0),
316
+ B.stride(2),
317
+ B.stride(1),
318
+ C.stride(1),
319
+ C.stride(2),
320
+ # B_scale is [E, N, K//group]: swap N and K strides likewise.
321
+ B_scale.stride(0),
322
+ B_scale.stride(2),
323
+ B_scale.stride(1),
324
+ block_k_diviable=K % config["BLOCK_SIZE_K"] == 0,
325
+ MUL_ROUTED_WEIGHT=mul_routed_weight,
326
+ top_k=top_k,
327
+ compute_type=compute_type,
328
+ group_size=16,
329
+ BLOCK_SIZE_M=config["BLOCK_SIZE_M"],
330
+ BLOCK_SIZE_N=config["BLOCK_SIZE_N"],
331
+ BLOCK_SIZE_K=config["BLOCK_SIZE_K"],
332
+ GROUP_SIZE_M=config["GROUP_SIZE_M"],
333
+ )
334
+
335
+
336
class Nvfp4QuantizationEmulationTritonExperts(TritonExperts):
337
"""
338
Extension of TritonExperts to support emulated NVFP4 MoE experts.
@@ -72,6 +372,31 @@ class Nvfp4QuantizationEmulationTritonExperts(TritonExperts):
372
def expects_unquantized_inputs(self) -> bool:
373
return True
374
375
+ @staticmethod
376
+ def supports_lora() -> bool:
377
+ return False
378
+
379
+ @staticmethod
380
+ def is_supported_config(
381
+ cls: type[mk.FusedMoEExperts],
382
+ moe_config: FusedMoEConfig,
383
+ weight_key: QuantKey | None,
384
+ activation_key: QuantKey | None,
385
+ activation_format: mk.FusedMoEActivationFormat,
386
+ ) -> tuple[bool, str | None]:
387
+ if moe_config.is_lora_enabled:
388
+ return False, "kernel does not support LoRA"
389
+ if moe_config.has_bias:
390
+ return False, "kernel does not support bias"
391
+
392
+ return TritonExperts.is_supported_config(
393
+ cls,
394
+ moe_config,
395
+ weight_key,
396
+ activation_key,
397
+ activation_format,
398
+ )
399
+
400
@staticmethod
401
def _supports_quant_scheme(
402
weight_key: QuantKey | None,
@@ -109,47 +434,109 @@ class Nvfp4QuantizationEmulationTritonExperts(TritonExperts):
434
# w2 shape: [num_experts, hidden_size, intermediate_size//2]
435
assert w1.dtype == torch.uint8
436
assert w2.dtype == torch.uint8
437
+ assert hidden_states.is_contiguous()
438
+ assert hidden_states.dim() == 2
439
113
- # Dequantize w1 from packed NVFP4 to fp16/bf16
114
- w13_global_scale = self.quant_config.g1_alphas
440
+ K = hidden_states.size(-1)
441
+ assert w1.size(2) * 2 == K, f"Hidden size mismatch: {K} != {w1.size(2) * 2}"
442
116
- w1_dequant = dequantize_to_dtype(
117
- tensor_fp4=w1,
118
- tensor_sf=self.w1_scale_val,
119
- global_scale=w13_global_scale,
120
- dtype=hidden_states.dtype,
121
- block_size=16,
122
- swizzle=False,
443
+ E, num_tokens, N, _, top_k_num = self.moe_problem_size(
444
+ hidden_states, w1, w2, topk_ids
445
)
446
125
- # Dequantize w2 from packed NVFP4 to fp16/bf16
126
- w2_global_scale = self.quant_config.g2_alphas
447
+ if global_num_experts == -1:
448
+ global_num_experts = E
449
128
- w2_dequant = dequantize_to_dtype(
129
- tensor_fp4=w2,
130
- tensor_sf=self.w2_scale_val,
131
- global_scale=w2_global_scale,
132
- dtype=hidden_states.dtype,
133
- block_size=16,
134
- swizzle=False,
450
+ # TODO: There is actually no support for tuning of the underlying triton
451
+ # hyperparameters in benchmarks/kernels/benchmark_moe.py, to be added.
452
+ config = try_get_optimal_moe_config(
453
+ w1.size(),
454
+ w2.size(),
455
+ top_k_num,
456
+ self.quant_config.config_name(hidden_states.dtype),
457
+ num_tokens,
458
+ block_shape=None,
459
)
460
137
- # Activation quantization/dequantization is deferred to
138
- # `moe_kernel_quantize_input` in TritonExperts.apply.
139
- super().apply(
140
- output=output,
141
- hidden_states=hidden_states,
142
- w1=w1_dequant,
143
- w2=w2_dequant,
144
- topk_weights=topk_weights,
145
- topk_ids=topk_ids,
146
- activation=activation,
147
- global_num_experts=global_num_experts,
148
- expert_map=expert_map,
149
- a1q_scale=None,
150
- a2_scale=self.quant_config.a2_gscale,
151
- workspace13=workspace13,
152
- workspace2=workspace2,
153
- expert_tokens_meta=expert_tokens_meta,
154
- apply_router_weight_on_input=apply_router_weight_on_input,
461
+ if hidden_states.dtype == torch.bfloat16:
462
+ compute_type = tl.bfloat16
463
+ elif hidden_states.dtype == torch.float16:
464
+ compute_type = tl.float16
465
+ elif hidden_states.dtype == torch.float32:
466
+ compute_type = tl.float32
467
+ else:
468
+ raise ValueError(f"Unsupported compute_type: {hidden_states.dtype}")
469
+
470
+ intermediate_cache1 = _resize_cache(workspace2, (num_tokens, top_k_num, N))
471
+ activation_out_dim = self.adjust_N_for_activation(N, activation)
472
+ intermediate_cache2 = _resize_cache(
473
+ workspace13, (num_tokens * top_k_num, activation_out_dim)
474
)
475
+ intermediate_cache3 = _resize_cache(workspace2, (num_tokens, top_k_num, K))
476
+
477
+ sorted_token_ids, expert_ids, num_tokens_post_padded = moe_align_block_size(
478
+ topk_ids,
479
+ config["BLOCK_SIZE_M"],
480
+ global_num_experts,
481
+ expert_map,
482
+ )
483
+
484
+ # Activation NVFP4 QDQ.
485
+ hidden_states_qdq, _ = moe_kernel_quantize_input(
486
+ A=hidden_states,
487
+ A_scale=self.quant_config.a1_gscale,
488
+ quant_dtype="nvfp4",
489
+ per_act_token_quant=False,
490
+ quantization_emulation=True,
491
+ )
492
+
493
+ # w13: fused weight dequant + GEMM.
494
+ invoke_fused_moe_nvfp4_emulation_kernel(
495
+ hidden_states_qdq,
496
+ w1,
497
+ intermediate_cache1,
498
+ self.w1_scale_val,
499
+ self.quant_config.a1_gscale,
500
+ self.quant_config.g1_alphas,
501
+ None, # topk_weights — applied after w2
502
+ sorted_token_ids,
503
+ expert_ids,
504
+ num_tokens_post_padded,
505
+ False, # mul_routed_weight
506
+ top_k_num,
507
+ config,
508
+ compute_type=compute_type,
509
+ )
510
+
511
+ self.activation(
512
+ activation, intermediate_cache2, intermediate_cache1.view(-1, N)
513
+ )
514
+
515
+ # Activation NVFP4 QDQ.
516
+ intermediate_cache2_qdq, _ = moe_kernel_quantize_input(
517
+ A=intermediate_cache2,
518
+ A_scale=self.quant_config.a2_gscale,
519
+ quant_dtype="nvfp4",
520
+ per_act_token_quant=False,
521
+ quantization_emulation=True,
522
+ )
523
+
524
+ # w2: fused weight dequant + GEMM.
525
+ invoke_fused_moe_nvfp4_emulation_kernel(
526
+ intermediate_cache2_qdq,
527
+ w2,
528
+ intermediate_cache3,
529
+ self.w2_scale_val,
530
+ self.quant_config.a2_gscale,
531
+ self.quant_config.g2_alphas,
532
+ topk_weights,
533
+ sorted_token_ids,
534
+ expert_ids,
535
+ num_tokens_post_padded,
536
+ not apply_router_weight_on_input,
537
+ 1,
538
+ config,
539
+ compute_type=compute_type,
540
+ )
541
+
542
+ self.moe_sum(intermediate_cache3, output)
vllm/model_executor/layers/quantization/utils/nvfp4_emulation_utils.py
+19
-31
@@ -23,28 +23,24 @@ kE2M1ToFloat_handle = SimpleNamespace(
23
24
25
@triton.jit
26
-def _e2m1_inline(magnitude):
27
- """Inline E2M1 lookup using binary tree - 3 levels instead of 7 sequential.
26
+def _e2m1_inline(nibble):
27
+ """Decode an NVFP4 nibble (4 bits: 1 sign + 3 magnitude) to float32.
28
29
- Maps 3-bit magnitude to float: [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0]
30
- Uses bit decomposition for fewer comparisons.
29
+ Uses direct IEEE 754 bit construction.
30
+ For magnitudes 2-7 the FP32 bit pattern is 0x3F000000 + (mag << 22),
31
+ which is a single shift + add + bitcast. Magnitudes 0 (zero) and 1
32
+ (E2M1 subnormal = 0.5) are patched with two tl.where ops.
33
"""
32
- # Bit 2 (MSB): separates 0-3 from 4-7
33
- # Bit 1: separates within groups
34
- # Bit 0 (LSB): separates within pairs
35
- b2 = (magnitude >> 2) & 1 # 0 for mag 0-3, 1 for mag 4-7
36
- b1 = (magnitude >> 1) & 1 # middle bit
37
- b0 = magnitude & 1 # LSB
38
-
39
- # For mag 0-3: [0.0, 0.5, 1.0, 1.5]
40
- low_group = tl.where(
41
- b1 == 1, tl.where(b0 == 1, 1.5, 1.0), tl.where(b0 == 1, 0.5, 0.0)
42
- )
43
- # For mag 4-7: [2.0, 3.0, 4.0, 6.0]
44
- high_group = tl.where(
45
- b1 == 1, tl.where(b0 == 1, 6.0, 4.0), tl.where(b0 == 1, 3.0, 2.0)
46
- )
47
- return tl.where(b2 == 1, high_group, low_group)
34
+ magnitude = nibble & 0x07
35
+ sign = (nibble >> 3) & 1
36
+
37
+ fp32_bits = 0x3F000000 + (magnitude.to(tl.int32) << 22)
38
+ val = fp32_bits.to(tl.float32, bitcast=True)
39
+
40
+ val = tl.where(magnitude == 0, 0.0, val)
41
+ val = tl.where(magnitude == 1, 0.5, val)
42
+
43
+ return tl.where(sign == 1, -val, val)
44
45
46
@triton.jit
@@ -65,7 +61,7 @@ def _dequantize_nvfp4_kernel(
61
"""
62
BLOCK_PACKED: tl.constexpr = BLOCK_SIZE // 2
63
68
- row_idx = tl.program_id(0)
64
+ row_idx = tl.program_id(0).to(tl.int64)
65
tile_idx = tl.program_id(1)
66
67
if has_batch_global_scale:
@@ -105,16 +101,8 @@ def _dequantize_nvfp4_kernel(
101
low_nibble = raw_bytes & 0x0F
102
high_nibble = (raw_bytes >> 4) & 0x0F
103
108
- # Binary tree E2M1 decode
109
- low_mag = low_nibble & 0x07
110
- low_val = _e2m1_inline(low_mag)
111
- low_sign = (low_nibble >> 3) & 1
112
- low_result = tl.where(low_sign == 1, -low_val, low_val) * scale_values
113
-
114
- high_mag = high_nibble & 0x07
115
- high_val = _e2m1_inline(high_mag)
116
- high_sign = (high_nibble >> 3) & 1
117
- high_result = tl.where(high_sign == 1, -high_val, high_val) * scale_values
104
+ low_result = _e2m1_inline(low_nibble) * scale_values
105
+ high_result = _e2m1_inline(high_nibble) * scale_values
106
107
# Interleave for coalesced contiguous store
108
result = tl.interleave(low_result, high_result)