Support kimi k3 nvfp4 checkpoint (#53132)

Signed-off-by: wzhao18 <wzhao18.sz@gmail.com> Co-authored-by: OpenAI Codex <codex@openai.com>

Wei Zhao committed Aug 21, 2026 at 01:59 UTC f8e060271381e352a4eabec97f1839a32e41ec41
6 files changed +151 -35
tests/kernels/moe/test_trtllm_nvfp4_moe.py
+21 -2
@@ -23,7 +23,7 @@ from tests.kernels.utils import torch_moe
23 from vllm import _custom_ops as ops
24 from vllm.config import ParallelConfig, VllmConfig, set_current_vllm_config
25 from vllm.model_executor.custom_op import CustomOp, op_registry
26 -from vllm.model_executor.layers.activation import SiluAndMulWithClamp
26 +from vllm.model_executor.layers.activation import SiluAndMulWithClamp, SituAndMul
27 from vllm.model_executor.layers.fused_moe import fused_topk
28 from vllm.model_executor.layers.fused_moe.activation import MoEActivation
29 from vllm.model_executor.layers.fused_moe.all2all_utils import (
@@ -63,6 +63,7 @@ MNK_FACTORS = [
63 _SWIGLU_LIMIT = 0.1
64 _LARGE_OUTPUT1_SCALE = 32768.0
65 _CLAMP_OP_NAME = "test_silu_and_mul_with_clamp"
66 +_SITU_OP_NAME = "test_situ_and_mul"
67
68 # Test-only fixed-limit clamp. ``custom_op_name`` makes the class itself
69 # valid as an ``activation=`` argument to ``torch_moe`` (which only looks
@@ -78,12 +79,24 @@ if _CLAMP_OP_NAME not in op_registry:
79 super().__init__(_SWIGLU_LIMIT, compile_native=compile_native)
80
81
82 +if _SITU_OP_NAME not in op_registry:
83 +
84 + @CustomOp.register(_SITU_OP_NAME)
85 + class _SituAndMulTest(SituAndMul):
86 + custom_op_name = _SITU_OP_NAME
87 +
88 + def __init__(self, *, compile_native: bool = True) -> None:
89 + super().__init__(4.0, 25.0, compile_native=compile_native)
90 +
91 +
92 SILU_WITH_CLAMP = op_registry[_CLAMP_OP_NAME]
93 +SITU = op_registry[_SITU_OP_NAME]
94
95
96 ACTIVATION_CASES = [
97 pytest.param(MoEActivation.SILU, MoEActivation.SILU, None, id="silu"),
98 pytest.param(MoEActivation.SILU, SILU_WITH_CLAMP, _SWIGLU_LIMIT, id="silu_clamp"),
99 + pytest.param(MoEActivation.SITU, SITU, None, id="situ"),
100 pytest.param(
101 MoEActivation.RELU2_NO_MUL,
102 MoEActivation.RELU2_NO_MUL,
@@ -108,7 +121,7 @@ def test_trtllm_fp4_moe_no_graph(
121 topk: int,
122 dtype: torch.dtype,
123 activation: MoEActivation,
111 - torch_activation: MoEActivation | type[SiluAndMulWithClamp],
124 + torch_activation: MoEActivation | type[SiluAndMulWithClamp] | type[SituAndMul],
125 swiglu_limit: float | None,
126 workspace_init,
127 ):
@@ -173,6 +186,10 @@ def test_trtllm_fp4_moe_no_graph(
186 in_dtype=dtype,
187 routing_method=RoutingMethodType.TopK,
188 max_num_tokens=next_power_of_2(m),
189 + activation_situ_beta=4.0 if activation == MoEActivation.SITU else None,
190 + activation_situ_linear_beta=(
191 + 25.0 if activation == MoEActivation.SITU else None
192 + ),
193 )
194
195 trtllm_inner = TrtLlmNvFp4ExpertsModular(
@@ -191,6 +208,8 @@ def test_trtllm_fp4_moe_no_graph(
208 fake_layer.w13_input_scale = torch.ones_like(quant_config.g1_alphas)
209 fake_layer.w2_input_scale = torch.ones_like(quant_config.g2_alphas)
210 trtllm_inner.process_weights_after_loading(fake_layer)
211 + if activation == MoEActivation.SITU:
212 + torch.testing.assert_close(trtllm_inner.g1_scale_c, quant_config.a2_gscale)
213
214 trtllm_experts = mk.FusedMoEKernel(
215 maybe_make_prepare_finalize(
tests/quantization/test_modelopt.py
+59
@@ -23,6 +23,7 @@ from vllm.model_executor.layers.linear import UnquantizedLinearMethod
23 from vllm.model_executor.layers.quantization.modelopt import (
24 ModelOptFp8Config,
25 ModelOptFp8LinearMethod,
26 + ModelOptFp8PbWoLinearMethod,
27 ModelOptMixedPrecisionConfig,
28 ModelOptMxFp8Config,
29 ModelOptNvFp4Config,
@@ -137,6 +138,64 @@ def test_modelopt_fp8_updates_weight_dims_after_transpose():
138 method.fp8_linear.process_weights_after_loading.assert_called_once_with(layer)
139
140
141 +def test_modelopt_fp8_pb_wo_hides_output_padding(default_vllm_config):
142 + config = ModelOptFp8Config(
143 + quant_method="FP8_PB_WO",
144 + is_checkpoint_fp8_serialized=True,
145 + kv_cache_quant_method=None,
146 + exclude_modules=[],
147 + )
148 + default_vllm_config.model_config = Mock(dtype=torch.bfloat16)
149 + kernel = Mock()
150 +
151 + with (
152 + set_current_vllm_config(default_vllm_config),
153 + patch(
154 + "vllm.model_executor.layers.quantization.modelopt.init_fp8_linear_kernel",
155 + return_value=kernel,
156 + ),
157 + patch(
158 + "vllm.model_executor.parameter.get_tensor_model_parallel_rank",
159 + return_value=0,
160 + ),
161 + patch(
162 + "vllm.model_executor.parameter.get_tensor_model_parallel_world_size",
163 + return_value=1,
164 + ),
165 + ):
166 + method = ModelOptFp8PbWoLinearMethod(config)
167 + layer = torch.nn.Module()
168 + method.create_weights(
169 + layer,
170 + input_size_per_partition=128,
171 + output_partition_sizes=[128, 65],
172 + input_size=128,
173 + output_size=193,
174 + params_dtype=torch.bfloat16,
175 + )
176 +
177 + assert layer.weight.shape == (193, 128)
178 + assert layer.weight_scale.shape == (2, 1, 1, 1)
179 +
180 + layer.weight.data.fill_(1)
181 + method.process_weights_after_loading(layer)
182 +
183 + assert layer.weight.shape == (256, 128)
184 + assert torch.count_nonzero(layer.weight[193:].float()) == 0
185 + kernel.process_weights_after_loading.assert_called_once_with(layer)
186 +
187 + physical_output = torch.randn(2, 256, dtype=torch.bfloat16)
188 + kernel.apply_weights.return_value = physical_output
189 + bias = torch.randn(193, dtype=torch.bfloat16)
190 + output = method.apply(layer, torch.randn(2, 128), bias)
191 +
192 + torch.testing.assert_close(output, physical_output[:, :193] + bias)
193 + assert output.shape == (2, 193)
194 + assert output.is_contiguous()
195 + kernel.apply_weights.assert_called_once()
196 + assert kernel.apply_weights.call_args.args[2] is None
197 +
198 +
199 def test_modelopt_nvfp4_leaves_excluded_parallel_lm_head_unquantized():
200 config = ModelOptNvFp4Config(
201 is_checkpoint_nvfp4_serialized=True,
vllm/model_executor/layers/attention/mla_attention.py
+11 -2
@@ -371,8 +371,17 @@ def _get_kv_b_proj_input_dtype(
371 return kv_b_proj.params_dtype
372 if weight_dtype == torch.uint8:
373 return None
374 - if weight_dtype == current_platform.fp8_dtype() and not use_fp8_prefill:
375 - return None
374 + if weight_dtype == current_platform.fp8_dtype():
375 + from vllm.model_executor.layers.quantization.modelopt import (
376 + ModelOptFp8PbWoLinearMethod,
377 + )
378 +
379 + quant_method = getattr(kv_b_proj, "quant_method", None)
380 + # FP8_PB_WO dynamically quantizes BF16/FP16 inputs in the linear method.
381 + if isinstance(quant_method, ModelOptFp8PbWoLinearMethod):
382 + return quant_method.input_dtype
383 + if not use_fp8_prefill:
384 + return None
385 return weight_dtype
386
387
vllm/model_executor/layers/fused_moe/experts/trtllm_nvfp4_moe.py
+18 -16
@@ -70,16 +70,9 @@ class TrtLlmNvFp4ExpertsBase:
70 )
71 self.local_num_experts = moe_config.num_local_experts
72 self.ep_rank = moe_config.moe_parallel_config.ep_rank
73 + self.is_situ = moe_config.activation == MoEActivation.SITU
74
74 - assert self.quant_config.g1_alphas is not None
75 - assert self.quant_config.a2_gscale is not None
76 - if moe_config.is_act_and_mul:
77 - # g1_alpha_s = a13_scale * w13_scale_2
78 - # a2_gscale = (1 / a2_scale)
79 - # g1_scale_c = a13_scale * w13_scale_2 / a2_scale
80 - self.g1_scale_c = self.quant_config.g1_alphas * self.quant_config.a2_gscale
81 - else:
82 - self.g1_scale_c = self.quant_config.a2_gscale.clone()
75 + self.g1_scale_c = self._compute_g1_scale_c()
76
77 # Fall back to moe_config.swiglu_* when quant_config doesn't carry them
78 # (ModelOpt NVFP4 checkpoints store these on moe_config, not quant_config).
@@ -120,7 +113,6 @@ class TrtLlmNvFp4ExpertsBase:
113 # (gemm1_alpha) and situ linear_beta -> gatedActBeta (gemm1_beta).
114 # These operate on the dequantized gate/up, so they are NOT folded by
115 # g1_alphas in process_weights_after_loading.
123 - self.is_situ = moe_config.activation == MoEActivation.SITU
116 if self.is_situ:
117 situ_beta = moe_config.activation_situ_beta
118 situ_linear_beta = moe_config.activation_situ_linear_beta
@@ -143,18 +135,28 @@ class TrtLlmNvFp4ExpertsBase:
135 clamp,
136 )
137
138 + def _compute_g1_scale_c(self) -> torch.Tensor:
139 + assert self.quant_config.g1_alphas is not None
140 + assert self.quant_config.a2_gscale is not None
141 + if not self.moe_config.is_act_and_mul:
142 + return self.quant_config.a2_gscale.clone()
143 + if self.is_situ:
144 + # SITU applies its nonlinear activation after g1_alphas, so only
145 + # the output quantization factor belongs in g1_scale_c.
146 + return self.quant_config.a2_gscale.clone()
147 +
148 + # g1_alphas = a13_scale * w13_scale_2
149 + # a2_gscale = 1 / a2_scale
150 + # g1_scale_c = a13_scale * w13_scale_2 / a2_scale
151 + return self.quant_config.g1_alphas * self.quant_config.a2_gscale
152 +
153 def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
154 layer.w13_weight_scale_2.data.mul_(layer.w13_input_scale)
155 layer.w2_weight_scale_2.data.mul_(layer.w2_input_scale)
156 # Recompute g1_scale_c since g1_alphas was just fused in-place.
157 # Register as a layer parameter so EPLB rearranges it alongside
158 # other expert weights.
152 - assert self.quant_config.g1_alphas is not None
153 - assert self.quant_config.a2_gscale is not None
154 - if self.moe_config.is_act_and_mul:
155 - g1_scale_c = self.quant_config.g1_alphas * self.quant_config.a2_gscale
156 - else:
157 - g1_scale_c = self.quant_config.a2_gscale.clone()
159 + g1_scale_c = self._compute_g1_scale_c()
160 layer.register_parameter(
161 "g1_scale_c",
162 torch.nn.Parameter(g1_scale_c, requires_grad=False),
vllm/model_executor/layers/quantization/modelopt.py
+31 -12
@@ -624,6 +624,8 @@ class ModelOptFp8PbWoLinearMethod(LinearMethodBase):
624 where block size is typically 128 for both dims.
625
626 vLLM executes it as FP8 GEMM with *dynamic per-token* activation quant.
627 + Output widths that are not block-aligned are padded and restored
628 + to their logical width before returning to the model.
629 """
630
631 _WEIGHT_BLOCK_SIZE: tuple[int, int] = (128, 128)
@@ -670,6 +672,12 @@ class ModelOptFp8PbWoLinearMethod(LinearMethodBase):
672 # element-space -> block-space for BlockQuantScaleParameter.
673 layer.weight_block_size = self.weight_block_size
674
675 + block_n, block_k = self._WEIGHT_BLOCK_SIZE
676 + remainder = output_size_per_partition % block_n
677 + self.output_padding = 0 if remainder == 0 else block_n - remainder
678 + self.logical_output_size = output_size_per_partition
679 + physical_output_size = output_size_per_partition + self.output_padding
680 +
681 weight = ModelWeightParameter(
682 data=torch.empty(
683 output_size_per_partition,
@@ -682,19 +690,13 @@ class ModelOptFp8PbWoLinearMethod(LinearMethodBase):
690 )
691 layer.register_parameter("weight", weight)
692
685 - block_n, block_k = self._WEIGHT_BLOCK_SIZE
686 - if output_size_per_partition % block_n != 0:
687 - raise ValueError(
688 - "ModelOpt FP8_PB_WO requires out_features divisible by "
689 - f"{block_n}, got {output_size_per_partition}."
690 - )
693 if input_size_per_partition % block_k != 0:
694 raise ValueError(
695 "ModelOpt FP8_PB_WO requires in_features divisible by "
696 f"{block_k}, got {input_size_per_partition}."
697 )
698
697 - out_blks = output_size_per_partition // block_n
699 + out_blks = physical_output_size // block_n
700 in_blks = input_size_per_partition // block_k
701
702 # Match ModelOpt's exported shape so weight loading works without a
@@ -711,7 +713,7 @@ class ModelOptFp8PbWoLinearMethod(LinearMethodBase):
713 self.w8a8_block_fp8_linear = init_fp8_linear_kernel(
714 activation_quant_key=self.activation_quant_key,
715 weight_quant_key=self.weight_quant_key,
714 - weight_shape=layer.weight.shape,
716 + weight_shape=(physical_output_size, input_size_per_partition),
717 input_dtype=self.input_dtype,
718 out_dtype=self.out_dtype,
719 module_name=self.__class__.__name__,
@@ -719,7 +721,15 @@ class ModelOptFp8PbWoLinearMethod(LinearMethodBase):
721
722 def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
723 # Keep weight in [out, in] layout for Fp8BlockScaledMMLinearKernel.
722 - layer.weight = Parameter(layer.weight.data, requires_grad=False)
724 + weight = layer.weight.data
725 + if self.output_padding:
726 + padded_weight = weight.new_zeros(
727 + self.logical_output_size + self.output_padding,
728 + weight.shape[1],
729 + )
730 + padded_weight[: self.logical_output_size].copy_(weight)
731 + weight = padded_weight
732 + layer.weight = Parameter(weight, requires_grad=False)
733
734 scale = layer.weight_scale
735 if scale.dim() == 4:
@@ -733,8 +743,7 @@ class ModelOptFp8PbWoLinearMethod(LinearMethodBase):
743
744 layer.weight_scale = Parameter(scale.contiguous(), requires_grad=False)
745
736 - if hasattr(self, "fp8_linear"):
737 - self.fp8_linear.process_weights_after_loading(layer)
746 + self.w8a8_block_fp8_linear.process_weights_after_loading(layer)
747
748 def apply(
749 self,
@@ -742,7 +751,15 @@ class ModelOptFp8PbWoLinearMethod(LinearMethodBase):
751 x: torch.Tensor,
752 bias: torch.Tensor | None = None,
753 ) -> torch.Tensor:
745 - return self.w8a8_block_fp8_linear.apply_weights(layer, x, bias)
754 + kernel_bias = None if self.output_padding else bias
755 + output = self.w8a8_block_fp8_linear.apply_weights(layer, x, kernel_bias)
756 + if not self.output_padding:
757 + return output
758 +
759 + output = output[..., : self.logical_output_size].contiguous()
760 + if bias is not None:
761 + output.add_(bias)
762 + return output
763
764
765 class ModelOptFp8MoEMethod(FusedMoEMethodBase):
@@ -2390,6 +2407,8 @@ class ModelOptMixedPrecisionConfig(ModelOptQuantConfigBase):
2407 if isinstance(layer, (LinearBase, ParallelLMHead)):
2408 if quant_algo == "FP8":
2409 return ModelOptFp8LinearMethod(self.fp8_config)
2410 + if quant_algo == "FP8_PB_WO":
2411 + return ModelOptFp8PbWoLinearMethod(self.fp8_config)
2412 if quant_algo == "NVFP4":
2413 return ModelOptNvFp4LinearMethod(self.nvfp4_config)
2414 if quant_algo == "W4A16_NVFP4":
vllm/models/kimi_k3/nvidia/kda.py
+11 -3
@@ -36,7 +36,7 @@ from vllm.model_executor.model_loader.weight_utils import (
36 default_weight_loader,
37 sharded_weight_loader,
38 )
39 -from vllm.model_executor.parameter import BasevLLMParameter
39 +from vllm.model_executor.parameter import BasevLLMParameter, BlockQuantScaleParameter
40 from vllm.model_executor.utils import set_weight_attrs
41 from vllm.models.kimi_k3.nvidia.kda_metadata import (
42 KimiK3KDAAttentionBackend,
@@ -102,7 +102,11 @@ class _KimiGDNMergedColumnParallelLinear(MergedColumnParallelLinear):
102 ) -> None:
103 tp_rank = self.tp_rank
104 param_tp_rank = getattr(param, "tp_rank", None)
105 - if loaded_shard_id == self.replicated_shard_id:
105 + replicate_block_scale = (
106 + isinstance(param, BlockQuantScaleParameter)
107 + and loaded_weight.shape[param.output_dim] < self.tp_size
108 + )
109 + if loaded_shard_id == self.replicated_shard_id or replicate_block_scale:
110 self.tp_rank = 0
111 if param_tp_rank is not None:
112 param.tp_rank = 0
@@ -121,7 +125,11 @@ class _KimiGDNMergedColumnParallelLinear(MergedColumnParallelLinear):
125 ) -> None:
126 tp_rank = self.tp_rank
127 param_tp_rank = getattr(param, "tp_rank", None)
124 - if loaded_shard_id == self.replicated_shard_id:
128 + replicate_block_scale = (
129 + isinstance(param, BlockQuantScaleParameter)
130 + and loaded_weight.shape[param.output_dim] < self.tp_size
131 + )
132 + if loaded_shard_id == self.replicated_shard_id or replicate_block_scale:
133 self.tp_rank = 0
134 if param_tp_rank is not None:
135 param.tp_rank = 0