[Bugfix] when loading weights skip empty expert bias if model does not support them (#50937)

Signed-off-by: walterbm <walter.beller.morales@gmail.com> Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com> Co-authored-by: Harry Mellor <19981378+hmellor@users.noreply.github.com>

Walter Beller-Morales committed Aug 7, 2026 at 15:45 UTC 70456e5e6fb4ee86b8ffd985f918e44ba632d925
2 files changed +89 -1
tests/kernels/moe/test_moe_weight_loading_padded.py
+81
@@ -14,6 +14,8 @@ import torch
14
15 from vllm.model_executor.layers.fused_moe.routed_experts import RoutedExperts
16
17 +from .utils import make_dummy_moe_config
18 +
19
20 class TestGetHiddenDim:
21 """Unit tests for _get_hidden_dim."""
@@ -376,6 +378,85 @@ class TestWeightLoadingWithPaddedHiddenSize:
378 )
379
380
381 +class TestLoadWeightsExpertBias:
382 + """Some quantized exports (e.g. GPTQ, llm-compressor NVFP4) materialize
383 + all-zero per-expert `.bias` tensors for models whose experts have no bias
384 + params. `RoutedExperts.load_weights` needs to ignore them like
385 + `AutoWeightsLoader` does, instead of raising AttributeError for the
386 + nonexistent `w13_bias`/`w2_bias` params.
387 + """
388 +
389 + NUM_EXPERTS = 2
390 +
391 + def _make_experts(self, has_bias: bool) -> torch.nn.Module:
392 + experts = torch.nn.Module()
393 + experts.layer_name = "model.layers.0.mlp.experts"
394 + experts.moe_config = make_dummy_moe_config(num_experts=self.NUM_EXPERTS)
395 + mapping = RoutedExperts.build_expert_params_mapping(
396 + "gate_proj",
397 + "down_proj",
398 + "up_proj",
399 + num_experts=self.NUM_EXPERTS,
400 + routed_experts_prefix="",
401 + include_fused=True,
402 + )
403 + experts.get_expert_mapping = lambda **_: mapping
404 +
405 + def weight_loader(**_):
406 + return True
407 +
408 + names = ["w13_weight", "w2_weight"]
409 + if has_bias:
410 + names += ["w13_bias", "w2_bias"]
411 + for name in names:
412 + param = torch.nn.Parameter(torch.zeros(1), requires_grad=False)
413 + param.weight_loader = weight_loader
414 + setattr(experts, name, param)
415 + return experts
416 +
417 + def _checkpoint_weights(self) -> list[tuple[str, torch.Tensor]]:
418 + return [
419 + (f"{expert_id}.{proj}.{suffix}", torch.zeros(1, 1))
420 + for expert_id in range(self.NUM_EXPERTS)
421 + for proj in ("gate_proj", "up_proj", "down_proj")
422 + for suffix in ("weight", "bias")
423 + ]
424 +
425 + def test_bias_free_experts_ignore_checkpoint_biases(self):
426 + experts = self._make_experts(has_bias=False)
427 + loaded = list(RoutedExperts.load_weights(experts, self._checkpoint_weights()))
428 + assert set(loaded) == {"w13_weight", "w2_weight"}
429 +
430 + def test_experts_with_bias_params_load_checkpoint_biases(self):
431 + experts = self._make_experts(has_bias=True)
432 + loaded = list(RoutedExperts.load_weights(experts, self._checkpoint_weights()))
433 + assert set(loaded) == {"w13_weight", "w2_weight", "w13_bias", "w2_bias"}
434 +
435 + def test_missing_non_bias_param_names_the_weight(self):
436 + experts = self._make_experts(has_bias=False)
437 + weights = [("0.down_proj.new_scale", torch.zeros(1, 1))]
438 + with pytest.raises(AttributeError, match="w2_new_scale"):
439 + list(RoutedExperts.load_weights(experts, weights))
440 +
441 + @pytest.mark.parametrize(
442 + "expert_name,param_name",
443 + [
444 + # Pre-fused checkpoints name biases `<proj>_bias` (gpt-oss) or
445 + # `<proj>.bias` (quark), neither of which rewrites to a real param.
446 + # Skipping them would silently drop a bias the layer does have, so
447 + # they must raise; models rename them via WeightsMapper instead.
448 + ("down_proj_bias", "w2_weight_bias"),
449 + ("gate_up_proj_bias", "w13_weight_bias"),
450 + ("down_proj.bias", "w2_weight.bias"),
451 + ],
452 + )
453 + def test_fused_bias_names_are_not_skipped(self, expert_name, param_name):
454 + experts = self._make_experts(has_bias=True)
455 + weights = [(expert_name, torch.zeros(self.NUM_EXPERTS, 1))]
456 + with pytest.raises(AttributeError, match=param_name.replace(".", r"\.")):
457 + list(RoutedExperts.load_weights(experts, weights))
458 +
459 +
460 class TestPerTensorScaleCoercion:
461 """Regression test for shape-(1,) per-tensor scales (issue #43297).
462
vllm/model_executor/layers/fused_moe/routed_experts.py
+8 -1
@@ -936,7 +936,14 @@ class RoutedExperts(PluggableLayer):
936 matched = True
937 weight_name = qual_name.replace(weight_name, param_name)
938 param_name = weight_name.removeprefix(f"{self.layer_name}.")
939 - param = getattr(self, param_name)
939 + param = getattr(self, param_name, None)
940 + if param is None:
941 + if param_name.endswith(("w13_bias", "w2_bias")):
942 + continue
943 + raise AttributeError(
944 + f"Layer {self.layer_name} has no parameter {param_name!r} "
945 + f"for checkpoint weight {qual_name!r}"
946 + )
947 if is_fused:
948 # w1 and w3 share one fused tensor; use a local copy so the
949 # transpose below doesn't mutate loaded_weight across