[EPLB] Enable nixl eplb communicator for elastic ep (#45013)
Signed-off-by: Markov Ilya <markovilya197@gmail.com> Signed-off-by: Markov Ilya <markovilya19@gmail.com> Co-authored-by: Markov Ilya <markovilya19@gmail.com>
Ilya Markov committed
Jun 22, 2026 at 19:54 UTC
ac614587f514e9032bff75eb04c86ddeb3c9dbef
7 files changed
+329
-97
tests/distributed/test_elastic_ep.py
+34
-35
@@ -59,9 +59,8 @@ def _run_gsm8k_eval(server: RemoteOpenAIServer, stage: str) -> float:
59
return accuracy
60
61
62
-@multi_gpu_test(num_gpus=4)
63
-def test_elastic_ep_scaling():
64
- vllm_serve_args = [
62
+def _base_serve_args(use_async_eplb: bool = False) -> list[str]:
63
+ args = [
64
"--trust-remote-code",
65
"--tensor-parallel-size",
66
"1",
@@ -79,7 +78,11 @@ def test_elastic_ep_scaling():
78
"--eplb-config.num_redundant_experts",
79
"0",
80
"--eplb-config.use_async",
82
- "false",
81
+ "true" if use_async_eplb else "false",
82
+ "--eplb-config.step_interval",
83
+ "10",
84
+ "--eplb-config.window_size",
85
+ "5",
86
"--data-parallel-backend",
87
"ray",
88
"--data-parallel-size",
@@ -90,7 +93,23 @@ def test_elastic_ep_scaling():
93
94
leader_address = os.environ.get("LEADER_ADDRESS")
95
if leader_address:
93
- vllm_serve_args.extend(["--data-parallel-address", leader_address])
96
+ args.extend(["--data-parallel-address", leader_address])
97
+
98
+ return args
99
+
100
+
101
+@pytest.mark.parametrize(
102
+ "use_async_eplb", [False, True], ids=["sync_eplb", "async_eplb"]
103
+)
104
+@multi_gpu_test(num_gpus=4)
105
+def test_elastic_ep_scaling(use_async_eplb: bool):
106
+ if use_async_eplb:
107
+ from vllm.distributed.eplb.eplb_communicator import has_nixl
108
+
109
+ if not has_nixl():
110
+ pytest.skip("Async EPLB with elastic EP requires NIXL (not installed)")
111
+
112
+ vllm_serve_args = _base_serve_args(use_async_eplb)
113
114
with RemoteOpenAIServer(
115
MODEL_NAME, vllm_serve_args, env_dict={}, max_wait_seconds=1200
@@ -128,44 +147,24 @@ def test_elastic_ep_scaling():
147
print(f" Tolerance: {ACCURACY_TOL:.3f}")
148
149
150
+@pytest.mark.parametrize(
151
+ "use_async_eplb", [False, True], ids=["sync_eplb", "async_eplb"]
152
+)
153
@multi_gpu_test(num_gpus=4)
132
-def test_elastic_ep_scaling_uneven():
154
+def test_elastic_ep_scaling_uneven(use_async_eplb: bool):
155
"""Test scale up with uneven worker distribution.
156
157
This tests the case where num_new_workers % old_dp_size != 0,
158
specifically 2 -> 3 where remainder = 1 % 2 = 1.
159
This exercises the remainder handling in sender-receiver pairing.
160
"""
139
- vllm_serve_args = [
140
- "--trust-remote-code",
141
- "--tensor-parallel-size",
142
- "1",
143
- "--gpu-memory-utilization",
144
- "0.8",
145
- "--max-model-len",
146
- "4096",
147
- "--max-num-seqs",
148
- str(MAX_NUM_SEQS),
149
- "--enable-expert-parallel",
150
- "--all2all-backend",
151
- "allgather_reducescatter",
152
- "--enable-elastic-ep",
153
- "--enable-eplb",
154
- "--eplb-config.num_redundant_experts",
155
- "0",
156
- "--eplb-config.use_async",
157
- "false",
158
- "--data-parallel-backend",
159
- "ray",
160
- "--data-parallel-size",
161
- "2",
162
- "--api-server-count",
163
- "1",
164
- ]
161
+ if use_async_eplb:
162
+ from vllm.distributed.eplb.eplb_communicator import has_nixl
163
166
- leader_address = os.environ.get("LEADER_ADDRESS")
167
- if leader_address:
168
- vllm_serve_args.extend(["--data-parallel-address", leader_address])
164
+ if not has_nixl():
165
+ pytest.skip("Async EPLB with elastic EP requires NIXL (not installed)")
166
+
167
+ vllm_serve_args = _base_serve_args(use_async_eplb)
168
169
with RemoteOpenAIServer(
170
MODEL_NAME, vllm_serve_args, env_dict={}, max_wait_seconds=1200
tests/distributed/test_eplb_execute.py
+122
@@ -782,3 +782,125 @@ def test_rearrange_expert_weights_profile_mode(world_size):
782
_test_rearrange_expert_weights_profile_mode,
783
world_size,
784
)
785
+
786
+
787
+def _test_nixl_deferred_init_worker(
788
+ env,
789
+ world_size: int,
790
+ num_layers: int,
791
+ num_local_experts: int,
792
+ num_logical_experts: int,
793
+) -> None:
794
+ """Exercise NixlEplbCommunicator with defer_remote_setup=True (elastic EP path)."""
795
+ from vllm.distributed.eplb.eplb_communicator import NixlEplbCommunicator
796
+
797
+ set_env_vars_and_device(env)
798
+
799
+ vllm_config = VllmConfig()
800
+ vllm_config.parallel_config.tensor_parallel_size = world_size
801
+
802
+ with set_current_vllm_config(vllm_config):
803
+ ensure_model_parallel_initialized(
804
+ tensor_model_parallel_size=world_size, pipeline_model_parallel_size=1
805
+ )
806
+
807
+ ep_group_coordinator = get_tp_group()
808
+ ep_group = ep_group_coordinator.cpu_group
809
+ ep_rank = torch.distributed.get_rank()
810
+ device = torch.device(f"cuda:{ep_rank}")
811
+
812
+ total_physical_experts = world_size * num_local_experts
813
+ hidden_sizes = [32, 64]
814
+
815
+ redundancy_config = create_redundancy_config(
816
+ num_logical_experts, total_physical_experts
817
+ )
818
+ old_indices = create_expert_indices_with_redundancy(
819
+ num_layers,
820
+ num_logical_experts,
821
+ total_physical_experts,
822
+ redundancy_config,
823
+ )
824
+
825
+ new_redundancy_config = create_redundancy_config(
826
+ num_logical_experts, total_physical_experts
827
+ )
828
+ new_indices = create_expert_indices_with_redundancy(
829
+ num_layers,
830
+ num_logical_experts,
831
+ total_physical_experts,
832
+ new_redundancy_config,
833
+ )
834
+
835
+ expert_weights = create_expert_weights(
836
+ num_layers, num_local_experts, hidden_sizes, ep_rank, device, old_indices
837
+ )
838
+
839
+ expert_buffer = [torch.empty_like(w) for w in expert_weights[0]]
840
+
841
+ communicator = NixlEplbCommunicator(
842
+ cpu_group=ep_group_coordinator.cpu_group,
843
+ all_expert_weights=expert_weights,
844
+ expert_buffer=expert_buffer,
845
+ defer_remote_setup=True,
846
+ )
847
+ assert not communicator._remote_state_initialized
848
+
849
+ rearrange_expert_weights_inplace(
850
+ old_indices,
851
+ new_indices,
852
+ expert_weights,
853
+ expert_buffer,
854
+ ep_group,
855
+ communicator,
856
+ )
857
+
858
+ assert communicator._remote_state_initialized
859
+
860
+ local_ok = verify_expert_weights_after_shuffle(
861
+ expert_weights,
862
+ new_indices,
863
+ hidden_sizes,
864
+ ep_rank,
865
+ num_local_experts,
866
+ )
867
+
868
+ local_ok = (
869
+ verify_redundant_experts_have_same_weights(
870
+ expert_weights,
871
+ new_indices,
872
+ hidden_sizes,
873
+ ep_rank,
874
+ world_size,
875
+ num_local_experts,
876
+ )
877
+ and local_ok
878
+ )
879
+ assert_verification_synced(
880
+ local_ok,
881
+ "Deferred NIXL init verification failed on at least one rank.",
882
+ )
883
+
884
+
885
+@pytest.mark.skipif(not has_nixl(), reason="NIXL is not available")
886
+@pytest.mark.parametrize(
887
+ "world_size,num_layers,num_local_experts,num_logical_experts",
888
+ [(2, 2, 3, 4)],
889
+)
890
+def test_nixl_deferred_init(
891
+ world_size,
892
+ num_layers,
893
+ num_local_experts,
894
+ num_logical_experts,
895
+):
896
+ """Test NixlEplbCommunicator with defer_remote_setup=True (elastic EP path)."""
897
+
898
+ if torch.accelerator.device_count() < world_size:
899
+ pytest.skip(f"Need at least {world_size} GPUs to run the test")
900
+ distributed_run(
901
+ _test_nixl_deferred_init_worker,
902
+ world_size,
903
+ num_layers,
904
+ num_local_experts,
905
+ num_logical_experts,
906
+ )
vllm/config/parallel.py
+22
-22
@@ -803,13 +803,6 @@ class ParallelConfig:
803
if self.enable_elastic_ep:
804
if not self.enable_eplb:
805
raise ValueError("Elastic EP is only supported with enable_eplb=True.")
806
- if self.eplb_config.use_async:
807
- raise ValueError(
808
- "Elastic EP requires the pynccl communicator, which is "
809
- "incompatible with async EPLB due to NCCL multi-stream "
810
- "conflicts. Disable async EPLB (eplb_config.use_async=False) "
811
- "to use elastic EP."
812
- )
806
if self.pipeline_parallel_size > 1:
807
raise ValueError(
808
"Elastic EP is not supported with pipeline parallelism "
@@ -821,6 +814,15 @@ class ParallelConfig:
814
"or data_parallel_hybrid_lb. Elastic EP relies on a single API "
815
"server and core client to coordinate scale up/down."
816
)
817
+ if self.eplb_config.use_async:
818
+ from vllm.distributed.nixl_utils import is_nixl_available
819
+
820
+ if not is_nixl_available():
821
+ raise ValueError(
822
+ "Elastic EP with async EPLB requires the NIXL "
823
+ "package. Either install NIXL or set "
824
+ "--eplb-config.use_async=false."
825
+ )
826
827
if self.data_parallel_size > 1 or self.data_parallel_size_local == 0:
828
# Data parallel was specified in the engine args.
@@ -929,23 +931,21 @@ class ParallelConfig:
931
)
932
933
if self.enable_eplb and self.eplb_config.communicator is None:
932
- if self.enable_elastic_ep:
933
- # Elastic EP requires stateless mode
934
- # (torch.distributed.batch_isend_irecv doesn't
935
- # support stateless mode), so we use PyNCCL backend
934
+ # Prefer NIXL when available: zero-copy RDMA reads, compatible
935
+ # with both async EPLB and elastic EP (deferred remote setup).
936
+ # Fallbacks: pynccl for elastic EP (stateless groups need it),
937
+ # torch_gloo for static EP. torch_nccl is avoided because NCCL
938
+ # is incompatible with async EPLB (multi-stream conflicts) and
939
+ # batched isend/irecv hangs under high load.
940
+ # See https://github.com/pytorch/pytorch/issues/174288
941
+ from vllm.distributed.nixl_utils import is_nixl_available
942
+
943
+ if is_nixl_available():
944
+ self.eplb_config.communicator = "nixl"
945
+ elif self.enable_elastic_ep:
946
self.eplb_config.communicator = "pynccl"
947
else:
938
- # Avoid torch_nccl: NCCL is fundamentally incompatible
939
- # with async EPLB due to multi-stream conflicts, and
940
- # batched isend/irecv hangs under high load.
941
- # See https://github.com/pytorch/pytorch/issues/174288
942
- # Prefer nixl when available; fall back to torch_gloo.
943
- from vllm.distributed.nixl_utils import is_nixl_available
944
-
945
- if is_nixl_available():
946
- self.eplb_config.communicator = "nixl"
947
- else:
948
- self.eplb_config.communicator = "torch_gloo"
948
+ self.eplb_config.communicator = "torch_gloo"
949
950
@property
951
def use_ray(self) -> bool:
vllm/distributed/elastic_ep/elastic_execute.py
+11
@@ -207,6 +207,9 @@ class ElasticEPScalingExecutor:
207
)
208
if new_dp_size > old_dp_size:
209
self._set_eplb_suppressed(True)
210
+ eplb_state = self.worker.model_runner.eplb_state
211
+ if eplb_state is not None:
212
+ eplb_state.drain_async()
213
elif new_dp_size < old_dp_size:
214
self._stage_standby_moe_quant_methods()
215
@@ -540,6 +543,11 @@ class ElasticEPScalingExecutor:
543
eplb_model_state.physical_to_logical_map.shape[1]
544
)
545
eplb_state.is_async = is_async_enabled
546
+ # Start the async worker thread if it doesn't exist yet (idempotent).
547
+ # This is needed for new workers after scale-up: they create EplbState
548
+ # in setup_eplb_from_mapping() but don't start the thread there because
549
+ # groups aren't ready yet.
550
+ eplb_state.start_async_loop()
551
if get_ep_group().rank == 0:
552
logger.info("[Elastic EP] Expert resharding completed")
553
@@ -549,6 +557,9 @@ class ElasticEPScalingExecutor:
557
558
def perform_scale_down_eplb_reshuffle(self, new_dp_size: int) -> None:
559
self._set_eplb_suppressed(True)
560
+ eplb_state = self.worker.model_runner.eplb_state
561
+ if eplb_state is not None:
562
+ eplb_state.drain_async()
563
parallel_config = self.worker.vllm_config.parallel_config
564
tp_size = parallel_config.tensor_parallel_size
565
old_ep_size = parallel_config.data_parallel_size * tp_size
vllm/distributed/eplb/async_worker.py
+27
-13
@@ -8,7 +8,6 @@ import threading
8
from typing import TYPE_CHECKING
9
10
import torch
11
-from torch.distributed import ProcessGroup
11
12
from vllm.distributed.parallel_state import get_eplb_group
13
from vllm.logger import init_logger
@@ -26,8 +25,7 @@ def start_async_worker(
25
state: "EplbState",
26
is_profile: bool = False,
27
) -> threading.Thread:
29
- eplb_group = get_eplb_group().device_group
30
- rank = eplb_group.rank()
28
+ rank = get_eplb_group().device_group.rank()
29
device_index = state.cuda_device_index
30
assert state.is_async
31
@@ -38,7 +36,6 @@ def start_async_worker(
36
try:
37
transfer_run_periodically(
38
state=state,
41
- eplb_group=eplb_group,
39
cuda_stream=cuda_stream,
40
is_profile=is_profile,
41
)
@@ -78,13 +75,15 @@ def run_rebalance_experts(
75
76
def transfer_run_periodically(
77
state: "EplbState",
81
- eplb_group: ProcessGroup,
78
cuda_stream: torch.cuda.Stream,
79
is_profile: bool = False,
80
) -> None:
81
while True:
82
state.rearrange_event.wait(stream=cuda_stream)
87
- logger.info("async worker woke up for EPLB transfer")
83
+
84
+ eplb_group = get_eplb_group().device_group
85
+ eplb_cpu_group = get_eplb_group().cpu_group
86
+ ep_rank = eplb_group.rank()
87
88
assert state.is_async
89
for model_state in state.model_states.values():
@@ -101,16 +100,32 @@ def transfer_run_periodically(
100
new_physical_to_logical_map = run_rebalance_experts(
101
model_state, state, physical_to_logical_map_cpu, cuda_stream
102
)
104
- logger.info(
105
- "Async worker computed new indices for model %s",
106
- model_state.model_name,
107
- )
103
104
# Execute one EPLB layer transfer per model forward pass. Each iteration
105
# of this loop will copy the new set of expert weights into
106
# model_state.expert_buffer, which will be consumed by the main thread in
112
- # move_to_workspace
113
- while model_state.rebalanced and layer_idx < num_layers:
107
+ # move_to_workspace.
108
+ # We sync the rebalanced flag across ranks before each iteration so
109
+ # all ranks make a coordinated decision to continue or stop.
110
+ while layer_idx < num_layers:
111
+ flag = torch.tensor(
112
+ [int(model_state.rebalanced)],
113
+ dtype=torch.int32,
114
+ device="cpu",
115
+ )
116
+ torch.distributed.all_reduce(flag, group=eplb_cpu_group)
117
+ if int(flag.item()) != eplb_cpu_group.size():
118
+ logger.warning(
119
+ "async worker (rank=%d): layer %d coordinated stop "
120
+ "(flag_sum=%d, group_size=%d)",
121
+ ep_rank,
122
+ layer_idx,
123
+ int(flag.item()),
124
+ eplb_cpu_group.size(),
125
+ )
126
+ model_state.rebalanced = False
127
+ break
128
+
129
transfer_metadata = transfer_layer(
130
old_layer_indices=physical_to_logical_map_cpu[layer_idx],
131
new_layer_indices=new_physical_to_logical_map[layer_idx],
@@ -143,6 +158,5 @@ def transfer_run_periodically(
158
# finish copying model_state.expert_buffer into
159
# model_state.model.expert_weights[layer_idx]
160
consumed_event.wait(stream=cuda_stream)
146
- logger.debug("Layer %d transfer complete", layer_idx)
161
assert model_state.pending_result is None
162
layer_idx += 1
vllm/distributed/eplb/eplb_communicator.py
+71
-25
@@ -246,7 +246,20 @@ class NixlEplbCommunicator(EplbCommunicator):
246
cpu_group: ProcessGroup,
247
all_expert_weights: Sequence[Sequence[torch.Tensor]],
248
expert_buffer: Sequence[torch.Tensor],
249
+ defer_remote_setup: bool = False,
250
) -> None:
251
+ """Create a NIXL-backed EPLB communicator.
252
+
253
+ Args:
254
+ cpu_group: CPU process group for metadata exchange.
255
+ all_expert_weights: Expert weight tensors for all MoE layers.
256
+ expert_buffer: Pre-allocated receive buffer tensors.
257
+ defer_remote_setup: If True, postpone the collective
258
+ all-gather of NIXL agent metadata until the first
259
+ ``set_transfer_context`` call. Required for elastic EP
260
+ where ranks join asynchronously and cannot participate
261
+ in collectives at construction time.
262
+ """
263
assert all_expert_weights, (
264
"NixlEplbCommunicator requires non-empty all_expert_weights."
265
)
@@ -302,10 +315,29 @@ class NixlEplbCommunicator(EplbCommunicator):
315
] = {}
316
317
self._cuda_device_id = int(self._device.index or 0)
318
+ self._remote_state_initialized = False
319
self._init_step("buffers", self._init_registered_buffers)
320
+ if defer_remote_setup:
321
+ logger.info_once("NIXL EPLB: deferring remote agent setup (elastic EP).")
322
+ else:
323
+ self._init_remote_state()
324
+ self._log_initialized()
325
+
326
+ def _init_remote_state(self) -> None:
327
+ """Exchange NIXL agent metadata and RDMA pointer info with all peers.
328
+
329
+ This is a collective operation (uses ``all_gather_object`` twice).
330
+ Under elastic EP the call is deferred to the first
331
+ ``set_transfer_context`` invocation, where all ranks are
332
+ guaranteed to be synchronized.
333
+ """
334
self._init_step("agents", self._init_remote_agents)
335
self._init_step("send meta", self._exchange_remote_send_meta)
308
- self._log_initialized()
336
+ self._remote_state_initialized = True
337
+
338
+ def _ensure_remote_state(self) -> None:
339
+ if not self._remote_state_initialized:
340
+ self._init_remote_state()
341
342
@property
343
def needs_profile_buffer_reservation(self) -> bool:
@@ -339,8 +371,7 @@ class NixlEplbCommunicator(EplbCommunicator):
371
pass
372
373
def set_transfer_context(self, old_indices: np.ndarray, layer_idx: int) -> None:
342
- # Pre-compute expert_id -> src_row mapping for every rank so that
343
- # add_recv can immediately issue NIXL READs.
374
+ self._ensure_remote_state()
375
assert not self._xfer_entries, (
376
f"set_transfer_context() called with {len(self._xfer_entries)} "
377
f"pending transfers from layer {self._layer_idx}; "
@@ -523,6 +554,21 @@ class NixlEplbCommunicator(EplbCommunicator):
554
)
555
return (local_handle, remote_handle, xfer_handle)
556
557
+ def _post_read_barrier(self) -> None:
558
+ """Correctness fence: prevents overwrite-while-remote-read race.
559
+
560
+ We avoid ``torch.distributed.monitored_barrier`` because it
561
+ calls ``get_backend(group)`` which fails for stateless groups
562
+ (elastic EP). An async ``all_reduce`` + ``wait(timeout)``
563
+ works with both regular and stateless groups and provides
564
+ equivalent timeout detection.
565
+ """
566
+ _dummy = torch.zeros(1, dtype=torch.int32)
567
+ work = torch.distributed.all_reduce(
568
+ _dummy, group=self._cpu_group, async_op=True
569
+ )
570
+ work.wait(timeout=timedelta(minutes=5))
571
+
572
def execute(self) -> None:
573
assert self._layer_idx is not None or not self._xfer_entries, (
574
"set_transfer_context() must be called before execute() "
@@ -531,13 +577,7 @@ class NixlEplbCommunicator(EplbCommunicator):
577
try:
578
self._wait_for_all_transfers([x[2] for x in self._xfer_entries])
579
534
- # Post-READ barrier.
535
- # Correctness fence for zero-copy: prevents overwrite-while-
536
- # remote-read race.
537
- torch.distributed.monitored_barrier(
538
- group=self._cpu_group,
539
- timeout=timedelta(minutes=5),
540
- )
580
+ self._post_read_barrier()
581
finally:
582
for local_h, remote_h, xfer_h in self._xfer_entries:
583
with contextlib.suppress(Exception):
@@ -628,11 +668,13 @@ def create_eplb_communicator(
668
device and CPU communication groups.
669
backend: Communicator backend name (``"torch_nccl"``,
670
``"torch_gloo"``, ``"pynccl"``, or ``"nixl"``).
631
- Stateless (elastic EP) groups only support ``"torch_nccl"``
632
- and ``"pynccl"``; ``"torch_nccl"`` is silently promoted to
633
- ``"pynccl"`` in that case. When tensors reside on CPU,
634
- ``"torch_gloo"`` or ``"torch_nccl"`` are used via the CPU
635
- process group.
671
+ Falls back to ``"torch_nccl"`` when *None*.
672
+ Stateless (elastic EP) groups support ``"torch_nccl"``,
673
+ ``"pynccl"``, and ``"nixl"``; ``"torch_nccl"`` is silently
674
+ promoted to ``"pynccl"``. ``"nixl"`` uses deferred remote
675
+ agent setup to avoid collective deadlocks during elastic
676
+ scaling. When tensors reside on CPU, ``"torch_gloo"`` or
677
+ ``"torch_nccl"`` are used via the CPU process group.
678
expert_weights: Expert weight tensors for *all* MoE layers.
679
Shape ``(num_layers)(num_tensors_per_layer)``.
680
NixlEplbCommunicator registers all layers with NIXL for
@@ -686,18 +728,21 @@ def create_eplb_communicator(
728
729
is_stateless = isinstance(group_coordinator, StatelessGroupCoordinator)
730
if is_stateless:
689
- if backend not in ("torch_nccl", "pynccl"):
731
+ if backend == "nixl":
732
+ pass # handled below with defer_remote_setup=True
733
+ elif backend not in ("torch_nccl", "pynccl"):
734
raise ValueError(
691
- f"Elastic EP requires 'torch_nccl' or 'pynccl' EPLB communicator "
692
- f"(got '{backend}')."
735
+ f"Elastic EP requires 'torch_nccl', 'pynccl', or 'nixl' "
736
+ f"EPLB communicator (got '{backend}')."
737
)
694
- if backend == "torch_nccl":
695
- logger.warning(
696
- "Stateless elastic EP requires PyNCCL backend. "
697
- "Forcing EPLB communicator to 'pynccl'."
698
- )
699
- backend = "pynccl"
700
- return _create_pynccl()
738
+ else:
739
+ if backend == "torch_nccl":
740
+ logger.warning(
741
+ "Stateless elastic EP requires PyNCCL backend. "
742
+ "Forcing EPLB communicator to 'pynccl'."
743
+ )
744
+ backend = "pynccl"
745
+ return _create_pynccl()
746
747
if backend == "nixl":
748
if not has_nixl():
@@ -714,6 +759,7 @@ def create_eplb_communicator(
759
cpu_group=group_coordinator.cpu_group,
760
all_expert_weights=expert_weights,
761
expert_buffer=expert_buffer,
762
+ defer_remote_setup=is_stateless,
763
)
764
except Exception as exc:
765
raise RuntimeError(
vllm/distributed/eplb/eplb_state.py
+42
-2
@@ -27,6 +27,7 @@ physical experts.
27
"""
28
29
import threading
30
+import time
31
from collections.abc import Sequence
32
from dataclasses import dataclass
33
@@ -825,6 +826,45 @@ class EplbState:
826
is_profile=is_profile,
827
)
828
829
+ def drain_async(self) -> None:
830
+ """Drain in-flight async EPLB by consuming all remaining layer results.
831
+
832
+ Each pending result is acknowledged (consumed_event recorded) so the
833
+ async worker can proceed, but the transferred weights are intentionally
834
+ NOT applied — a full synchronous rearrange is expected to follow.
835
+
836
+ Ranks are kept in lockstep via _all_ranks_result_ready (all_reduce
837
+ on the EP CPU group). The async worker's coordinated-stop collectives
838
+ use the separate EPLB group, so the two sets of collectives do not
839
+ interfere.
840
+
841
+ No-op when no async cycle is in progress (rebalanced=False).
842
+ """
843
+ if not self.is_async:
844
+ return
845
+ for model_key, ms in self.model_states.items():
846
+ needs_drain = ms.rebalanced
847
+ if needs_drain:
848
+ logger.info(
849
+ "Draining async EPLB worker for model %s",
850
+ model_key,
851
+ )
852
+ while ms.rebalanced:
853
+ if self._all_ranks_result_ready(ms):
854
+ result = ms.pending_result
855
+ assert result is not None
856
+ if result.layer_idx == ms.model.num_moe_layers - 1:
857
+ ms.rebalanced = False
858
+ ms.pending_result = None
859
+ result.consumed_event.record()
860
+ else:
861
+ time.sleep(0.001)
862
+ if needs_drain:
863
+ logger.info(
864
+ "Async EPLB worker drained for model %s",
865
+ model_key,
866
+ )
867
+
868
def _all_ranks_result_ready(self, model_state: EplbModelState) -> bool:
869
parallel_state = get_ep_group()
870
has_result = int(model_state.pending_result is not None)
@@ -850,8 +890,9 @@ class EplbState:
890
"""
891
All-reduce a list of tensors.
892
"""
893
+ ep_group = get_ep_group().device_group
894
if len(tensor_list) == 1:
854
- all_reduce(tensor_list[0], group=get_ep_group().device_group)
895
+ all_reduce(tensor_list[0], group=ep_group)
896
return tensor_list
897
assert all(t.dim() == 2 for t in tensor_list), "All tensors must be 2D."
898
assert all(t.shape[1] == tensor_list[0].shape[1] for t in tensor_list), (
@@ -863,7 +904,6 @@ class EplbState:
904
shapes = [t.shape for t in tensor_list]
905
concat_tensor = torch.cat(tensor_list, dim=0)
906
866
- ep_group = get_ep_group().device_group
907
all_reduce(concat_tensor, group=ep_group)
908
909
all_reduce_list = []