Skip to content

Commit ceb8e18

Browse files
authored
[BugFix][EPLB] Split unquant fused MC2 weights (vllm-project#11282)
### What this PR does / why we need it? Fixes vllm-project#11284. This PR fixes the unquantized fused MC2 + dynamic EPLB path for MoE weights. When `enable_fused_mc2 == 1` and dynamic EPLB is enabled, unquantized MoE weights were kept as whole NZ tensors while the EPLB adaptor expected per-expert movable weights. That could make EPLB communication send an NZ tensor that was not split into a tensor list, which may hang in `batch_isend_irecv`. The fix keeps the existing NZ format cast for unquantized fused MC2 weights, but for the dynamic EPLB fused MC2 case it additionally splits the cast weights into per-expert tensor lists, releases the original whole tensors, and teaches the unquantized MLP path to consume either whole tensors or tensor lists. The EPLB adaptor now uses `w13_weight_list` and `w2_weight_list` for the unquantized fused MC2 case. Regression coverage was added for: - splitting unquantized fused MC2 dynamic EPLB weights after loading - using split weight lists in the fused MC2 unquantized apply path - accepting split weight lists in `unquant_apply_mlp` - routing EPLB unquantized fused MC2 weights through `w13_weight_list` and `w2_weight_list` ### Does this PR introduce _any_ user-facing change? No. This is a bug fix for an internal NPU MoE/EPLB communication path. ### How was this patch tested? ```bash bash format.sh ci ``` Passed. ```bash python -m pytest -q tests/ut/eplb/adaptor/test_vllm_adaptor.py tests/ut/ops/test_fused_moe.py tests/ut/ops/test_moe_mlp.py ``` Passed on A3: ```text 43 passed, 6 skipped, 16 warnings in 2.81s ``` - vLLM version: v0.23.0 - vLLM main: vllm-project/vllm@1f486d9 Signed-off-by: freyfwt <freytian1996@gmail.com>
1 parent bff0a54 commit ceb8e18

5 files changed

Lines changed: 182 additions & 7 deletions

File tree

tests/ut/eplb/adaptor/test_vllm_adaptor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ def test_init_fp16(self, mock_get_config, mock_func):
5050
self.model.quant_config = None
5151
adaptor = VllmEplbAdaptor(self.model)
5252
self.assertEqual(adaptor.expert_weight_key_per_layer[0], (QuantType.NONE, True))
53+
self.assertIs(adaptor.expert_param_per_layer[0][0][0], self.mock_layer.w13_weight_list[0])
54+
self.assertIs(adaptor.expert_param_per_layer[0][0][1], self.mock_layer.w2_weight_list[0])
5355

5456
@patch("torch.empty_like", return_value=torch.zeros(16, 32))
5557
@patch("vllm_ascend.eplb.adaptor.vllm_adaptor.get_ascend_config")

tests/ut/ops/test_fused_moe.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ def _build_layer(self, *, has_bias=True, zero_expert_num=0):
384384
@pytest.mark.parametrize("enable_fused_mc2", [True, False])
385385
def test_process_weights_after_loading_transposes_and_formats(self, monkeypatch, enable_fused_mc2):
386386
method = AscendUnquantizedFusedMoEMethod.__new__(AscendUnquantizedFusedMoEMethod)
387+
method.dynamic_eplb = False
387388
method._maybe_pad_weight = MagicMock(side_effect=lambda weight: weight)
388389
layer = self._build_layer()
389390
original_w13 = layer.w13_weight.detach().clone()
@@ -408,6 +409,36 @@ def test_process_weights_after_loading_transposes_and_formats(self, monkeypatch,
408409
assert maybe_trans_nz.call_count == 2
409410
format_cast.assert_not_called()
410411

412+
def test_process_weights_after_loading_splits_dynamic_eplb_fused_mc2_weights(self, monkeypatch):
413+
method = AscendUnquantizedFusedMoEMethod.__new__(AscendUnquantizedFusedMoEMethod)
414+
method.dynamic_eplb = True
415+
method._maybe_pad_weight = MagicMock(side_effect=lambda weight: weight)
416+
layer = nn.Module()
417+
layer.w13_weight = nn.Parameter(torch.randn(2, 3, 4))
418+
layer.w2_weight = nn.Parameter(torch.randn(2, 4, 3))
419+
expected_w13 = layer.w13_weight.detach().clone().transpose(1, 2).contiguous()
420+
expected_w2 = layer.w2_weight.detach().clone().transpose(1, 2).contiguous()
421+
format_cast = MagicMock(side_effect=lambda weight, _: weight)
422+
empty_cache = MagicMock()
423+
424+
mock_ascend_config = MagicMock()
425+
mock_ascend_config.enable_fused_mc2 = True
426+
monkeypatch.setattr(fused_moe_module, "get_ascend_config", lambda: mock_ascend_config)
427+
monkeypatch.setattr(fused_moe_module.torch_npu, "npu_format_cast", format_cast)
428+
monkeypatch.setattr(fused_moe_module.torch, "npu", SimpleNamespace(empty_cache=empty_cache), raising=False)
429+
430+
method.process_weights_after_loading(layer)
431+
432+
assert "w13_weight" not in layer._parameters
433+
assert "w2_weight" not in layer._parameters
434+
assert len(layer.w13_weight_list) == 2
435+
assert len(layer.w2_weight_list) == 2
436+
torch.testing.assert_close(layer.w13_weight_list[0], expected_w13[0])
437+
torch.testing.assert_close(layer.w2_weight_list[1], expected_w2[1])
438+
assert layer.w13_weight_list[0].untyped_storage().data_ptr() != expected_w13[0].untyped_storage().data_ptr()
439+
assert format_cast.call_count == 2
440+
empty_cache.assert_called_once()
441+
411442
@pytest.mark.parametrize("moe_comm_type", [MoECommType.MC2, MoECommType.FUSED_MC2])
412443
def test_apply_builds_fused_experts_input(self, monkeypatch, moe_comm_type):
413444
method = AscendUnquantizedFusedMoEMethod.__new__(AscendUnquantizedFusedMoEMethod)
@@ -459,12 +490,105 @@ def test_apply_builds_fused_experts_input(self, monkeypatch, moe_comm_type):
459490
assert fused_input.weights.w2[0] is layer.w2_weight
460491
assert isinstance(fused_input.weights.w1_scale, list)
461492
assert isinstance(fused_input.weights.w2_scale, list)
493+
assert fused_input.weights.w1_scale[0].dtype == torch.int64
494+
assert fused_input.weights.w2_scale[0].dtype == torch.int64
495+
assert fused_input.weights.w1_scale_bias[0].dtype == torch.float32
496+
assert fused_input.weights.w2_scale_bias[0].dtype == torch.float32
462497
else:
463498
assert fused_input.weights.w1 is layer.w13_weight
464499
assert fused_input.weights.w2 is layer.w2_weight
465500
assert fused_input.weights.w1_scale is None
466501
assert fused_input.weights.w2_scale is None
467502

503+
@pytest.mark.parametrize("moe_comm_type", [MoECommType.MC2, MoECommType.FUSED_MC2])
504+
def test_apply_uses_weight_lists_when_dynamic_eplb_splits_weights(self, monkeypatch, moe_comm_type):
505+
method = AscendUnquantizedFusedMoEMethod.__new__(AscendUnquantizedFusedMoEMethod)
506+
method.moe = SimpleNamespace(has_bias=False)
507+
method.dynamic_eplb = True
508+
method.tid2eid = None
509+
layer = self._build_layer(has_bias=False)
510+
layer.w13_weight_list = [torch.randn(4, 6), torch.randn(4, 6)]
511+
layer.w2_weight_list = [torch.randn(3, 4), torch.randn(3, 4)]
512+
hidden_states = torch.randn(2, 4, dtype=torch.float16)
513+
topk_weights = torch.ones(2, 2, dtype=torch.float32)
514+
topk_ids = torch.tensor([[0, 1], [1, 0]], dtype=torch.int64)
515+
moe_comm_method = MagicMock()
516+
moe_comm_method.fused_experts.return_value = torch.ones_like(hidden_states)
517+
monkeypatch.setattr(
518+
fused_moe_module,
519+
"_EXTRA_CTX",
520+
SimpleNamespace(moe_comm_type=moe_comm_type, moe_comm_method=moe_comm_method),
521+
)
522+
monkeypatch.setattr(fused_moe_module, "select_experts", MagicMock(return_value=(topk_weights, topk_ids)))
523+
monkeypatch.setattr(fused_moe_module, "get_forward_context", MagicMock(return_value=MagicMock(input_ids=None)))
524+
525+
method.apply(
526+
layer=layer,
527+
x=hidden_states,
528+
use_grouped_topk=False,
529+
top_k=2,
530+
router_logits=torch.randn(2, 4),
531+
renormalize=True,
532+
num_experts=4,
533+
)
534+
535+
fused_input = moe_comm_method.fused_experts.call_args.kwargs["fused_experts_input"]
536+
assert fused_input.weights.w1 is layer.w13_weight_list
537+
assert fused_input.weights.w2 is layer.w2_weight_list
538+
if moe_comm_type == MoECommType.FUSED_MC2:
539+
assert len(fused_input.weights.w1_scale) == 1
540+
assert len(fused_input.weights.w2_scale) == 1
541+
assert fused_input.weights.w1_scale[0].dtype == torch.int64
542+
assert fused_input.weights.w2_scale[0].dtype == torch.int64
543+
assert fused_input.weights.w1_scale[0].numel() == 0
544+
assert fused_input.weights.w2_scale[0].numel() == 0
545+
assert fused_input.weights.w1_scale_bias[0].dtype == torch.float32
546+
assert fused_input.weights.w2_scale_bias[0].dtype == torch.float32
547+
assert fused_input.weights.w1_scale_bias[0].numel() == 0
548+
assert fused_input.weights.w2_scale_bias[0].numel() == 0
549+
else:
550+
assert fused_input.weights.w1_scale is None
551+
assert fused_input.weights.w2_scale is None
552+
553+
def test_apply_warns_when_dynamic_eplb_fused_mc2_weights_are_not_split(self, monkeypatch):
554+
method = AscendUnquantizedFusedMoEMethod.__new__(AscendUnquantizedFusedMoEMethod)
555+
method.moe = SimpleNamespace(has_bias=False)
556+
method.dynamic_eplb = True
557+
method.tid2eid = None
558+
layer = self._build_layer(has_bias=False)
559+
hidden_states = torch.randn(2, 4, dtype=torch.float16)
560+
topk_weights = torch.ones(2, 2, dtype=torch.float32)
561+
topk_ids = torch.tensor([[0, 1], [1, 0]], dtype=torch.int64)
562+
moe_comm_method = MagicMock()
563+
moe_comm_method.fused_experts.return_value = torch.ones_like(hidden_states)
564+
warning_once = MagicMock()
565+
monkeypatch.setattr(
566+
fused_moe_module,
567+
"_EXTRA_CTX",
568+
SimpleNamespace(moe_comm_type=MoECommType.FUSED_MC2, moe_comm_method=moe_comm_method),
569+
)
570+
monkeypatch.setattr(fused_moe_module, "select_experts", MagicMock(return_value=(topk_weights, topk_ids)))
571+
monkeypatch.setattr(fused_moe_module, "get_forward_context", MagicMock(return_value=MagicMock(input_ids=None)))
572+
monkeypatch.setattr(fused_moe_module.logger, "warning_once", warning_once)
573+
574+
method.apply(
575+
layer=layer,
576+
x=hidden_states,
577+
use_grouped_topk=False,
578+
top_k=2,
579+
router_logits=torch.randn(2, 4),
580+
renormalize=True,
581+
num_experts=4,
582+
)
583+
584+
warning_once.assert_called_once()
585+
warning_msg = warning_once.call_args.args[0]
586+
assert "dynamic EPLB" in warning_msg
587+
assert "not split into tensor lists" in warning_msg
588+
fused_input = moe_comm_method.fused_experts.call_args.kwargs["fused_experts_input"]
589+
assert fused_input.weights.w1[0] is layer.w13_weight
590+
assert fused_input.weights.w2[0] is layer.w2_weight
591+
468592
def test_apply_adds_zero_expert_result_and_force_balances(self, monkeypatch):
469593
method = AscendUnquantizedFusedMoEMethod.__new__(AscendUnquantizedFusedMoEMethod)
470594
method.moe = SimpleNamespace(has_bias=False)

tests/ut/ops/test_moe_mlp.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import torch
66
from vllm.model_executor.layers.fused_moe.activation import MoEActivation
77

8-
from vllm_ascend.ops.fused_moe.moe_mlp import cumsum_group_list, unified_apply_mlp
8+
from vllm_ascend.ops.fused_moe.moe_mlp import cumsum_group_list, unified_apply_mlp, unquant_apply_mlp
99
from vllm_ascend.ops.fused_moe.moe_runtime_args import (
1010
MoEMlpComputeInput,
1111
MoEQuantParams,
@@ -64,6 +64,40 @@ def test_w4a8_per_channel_gmm_swiglu_flag(self):
6464

6565

6666
class TestUnifiedApplyMlpRequest(unittest.TestCase):
67+
def test_unquant_apply_mlp_wraps_tensor_weights_for_grouped_matmul(self):
68+
hidden_states = torch.randn(2, 8)
69+
gate_up_out = torch.randn(2, 16)
70+
expected = torch.randn(2, 8)
71+
w1 = torch.randn(2, 8, 16)
72+
w2 = torch.randn(2, 8, 8)
73+
74+
with (
75+
patch(
76+
"vllm_ascend.ops.fused_moe.moe_mlp.torch_npu.npu_grouped_matmul",
77+
side_effect=[[gate_up_out], [expected]],
78+
create=True,
79+
) as mock_grouped_matmul,
80+
patch(
81+
"vllm_ascend.ops.fused_moe.moe_mlp.torch_npu.npu_swiglu",
82+
return_value=gate_up_out,
83+
create=True,
84+
),
85+
):
86+
output, _ = unquant_apply_mlp(
87+
hidden_states=hidden_states,
88+
w1=w1,
89+
w2=w2,
90+
group_list=torch.tensor([1, 1]),
91+
need_trans=True,
92+
)
93+
94+
self.assertTrue(output is expected)
95+
first_call, second_call = mock_grouped_matmul.call_args_list
96+
self.assertEqual(len(first_call.kwargs["weight"]), 1)
97+
self.assertEqual(len(second_call.kwargs["weight"]), 1)
98+
self.assertEqual(first_call.kwargs["weight"][0].shape, torch.Size([2, 16, 8]))
99+
self.assertEqual(second_call.kwargs["weight"][0].shape, torch.Size([2, 8, 8]))
100+
67101
def test_request_unquant_path(self):
68102
hidden_states = torch.randn(2, 8)
69103
expected = torch.randn(2, 8)

vllm_ascend/eplb/adaptor/vllm_adaptor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
EPLB_EXPERT_WEIGHT_NAMES = {
2929
(QuantType.NONE, False): ("w13_weight", "w2_weight"),
30-
(QuantType.NONE, True): ("w13_weight", "w2_weight"),
30+
(QuantType.NONE, True): ("w13_weight_list", "w2_weight_list"),
3131
(QuantType.W8A8, False): (
3232
"w13_weight_list",
3333
"w2_weight_list",

vllm_ascend/ops/fused_moe/fused_moe.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,16 @@ def process_weights_after_loading(self, layer):
131131
# ND format (or other formats), remove this specific 'if' check and the forced
132132
# npu_format_cast. At that point, the operator should be able to handle weights
133133
# in their native format without explicit casting here.
134-
if get_ascend_config().enable_fused_mc2:
134+
enable_fused_mc2 = get_ascend_config().enable_fused_mc2
135+
if enable_fused_mc2:
135136
layer.w13_weight.data = torch_npu.npu_format_cast(layer.w13_weight.data, ACL_FORMAT_FRACTAL_NZ)
136137
layer.w2_weight.data = torch_npu.npu_format_cast(layer.w2_weight.data, ACL_FORMAT_FRACTAL_NZ)
138+
if enable_fused_mc2 == 1 and self.dynamic_eplb:
139+
layer.w13_weight_list = [weight.clone() for weight in layer.w13_weight.data.unbind(dim=0)]
140+
layer.w2_weight_list = [weight.clone() for weight in layer.w2_weight.data.unbind(dim=0)]
141+
del layer.w13_weight
142+
del layer.w2_weight
143+
torch.npu.empty_cache()
137144
else:
138145
layer.w13_weight.data = maybe_trans_nz(layer.w13_weight.data)
139146
layer.w2_weight.data = maybe_trans_nz(layer.w2_weight.data)
@@ -231,17 +238,25 @@ def apply(
231238
# (due to signature constraints), we are forced to use a placeholder empty tensor.
232239
# This TODO tracks the requirement to update the C++ operator to accept Optional[Tensor]
233240
# or None for scales in non-quantized scenarios.
241+
w13_weight_list = getattr(layer, "w13_weight_list", None)
242+
w2_weight_list = getattr(layer, "w2_weight_list", None)
243+
has_split_weight_lists = isinstance(w13_weight_list, list) and isinstance(w2_weight_list, list)
234244
if _EXTRA_CTX.moe_comm_type == MoECommType.FUSED_MC2:
235-
w1 = [layer.w13_weight]
245+
if self.dynamic_eplb and not has_split_weight_lists:
246+
logger.warning_once(
247+
"FUSED_MC2 is enabled with dynamic EPLB, but unquantized MoE weights are not split into "
248+
"tensor lists. This may cause accuracy issues or communication hangs."
249+
)
250+
w1 = w13_weight_list if isinstance(w13_weight_list, list) else [layer.w13_weight]
251+
w2 = w2_weight_list if isinstance(w2_weight_list, list) else [layer.w2_weight]
236252
w1_scale = [torch.tensor([], dtype=torch.int64)]
237-
w2 = [layer.w2_weight]
238253
w2_scale = [torch.tensor([], dtype=torch.int64)]
239254
w1_scale_bias = [torch.tensor([], dtype=torch.float32)]
240255
w2_scale_bias = [torch.tensor([], dtype=torch.float32)]
241256
else:
242-
w1 = layer.w13_weight
257+
w1 = w13_weight_list if isinstance(w13_weight_list, list) else layer.w13_weight
243258
w1_scale = None
244-
w2 = layer.w2_weight
259+
w2 = w2_weight_list if isinstance(w2_weight_list, list) else layer.w2_weight
245260
w2_scale = None
246261
w1_scale_bias = None
247262
w2_scale_bias = None

0 commit comments

Comments
 (0)