[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