[Elastic EP] Reduce eager-mode reconfiguration downtime (#51885)
Signed-off-by: Itay Alroy <ialroy@nvidia.com>
Itay Alroy committed
Aug 19, 2026 at 05:13 UTC
a2257f95b79287933defef5be40b74ce93053da3
12 files changed
+194
-303
tests/distributed/test_eplb_execute.py
-122
@@ -782,125 +782,3 @@ 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
- )
tests/v1/worker/test_gpu_model_runner_v2_eplb.py
+1
-2
@@ -159,13 +159,12 @@ def test_v2_setup_eplb_from_mapping_rebuilds_state(monkeypatch):
159
160
runner = _make_runner(model=SimpleNamespace(is_moe=True))
161
mapping = torch.tensor([[0, 1, 2, 3]], dtype=torch.int64)
162
- mrv2.GPUModelRunner.setup_eplb_from_mapping(runner, mapping, 2)
162
+ mrv2.GPUModelRunner.setup_eplb_from_mapping(runner, mapping)
163
164
assert runner.eplb_state is not None
165
assert runner.eplb_state.built_from_mapping is True
166
assert FakeEplbState.from_mapping_kwargs is not None
167
assert FakeEplbState.from_mapping_kwargs["expanded_physical_to_logical"] is mapping
168
- assert FakeEplbState.from_mapping_kwargs["num_valid_physical_experts"] == 2
168
169
170
def test_v2_sample_tokens_runs_eplb_on_non_last_pp_rank(monkeypatch):
vllm/config/parallel.py
+1
-1
@@ -973,7 +973,7 @@ class ParallelConfig:
973
974
if self.enable_eplb and self.eplb_config.communicator is None:
975
# Prefer NIXL when available: zero-copy RDMA reads, compatible
976
- # with both async EPLB and elastic EP (deferred remote setup).
976
+ # with both async EPLB and elastic EP.
977
# Fallbacks: pynccl for elastic EP (stateless groups need it),
978
# torch_gloo for static EP. torch_nccl is avoided because NCCL
979
# is incompatible with async EPLB (multi-stream conflicts) and
vllm/distributed/elastic_ep/elastic_execute.py
+108
-54
@@ -29,12 +29,15 @@ from vllm.distributed.elastic_ep.standby_state import (
29
create_standby_groups,
30
get_standby_dp_group,
31
get_standby_ep_group,
32
+ get_standby_eplb_group,
33
pop_standby_groups,
34
)
34
-from vllm.distributed.eplb.eplb_communicator import create_eplb_communicator
35
+from vllm.distributed.eplb.eplb_communicator import (
36
+ EplbCommunicator,
37
+)
38
from vllm.distributed.parallel_state import (
39
+ GroupCoordinator,
40
_replace_active_groups,
37
- get_eplb_group,
41
)
42
from vllm.distributed.stateless_coordinator import StatelessGroupCoordinator
43
from vllm.logger import init_logger
@@ -149,10 +152,12 @@ class ElasticEPScalingExecutor:
152
self.worker_ref = weakref.ref(worker)
153
self.reconfig_request = None
154
self._staged_moe_quant_methods: dict[nn.Module, FusedMoEMethodBase] = {}
155
+ self._prepared_eplb_communicator: EplbCommunicator | None = None
156
self._async_executor = ThreadPoolExecutor(
157
max_workers=1, thread_name_prefix="ElasticEPAsync"
158
)
159
self._async_future: Future[None] | None = None
160
+ self._group_cleanup_future: Future[None] | None = None
161
162
@property
163
def worker(self):
@@ -207,12 +212,40 @@ class ElasticEPScalingExecutor:
212
self._async_future = None
213
future.result()
214
215
+ def _destroy_retired_groups(
216
+ self, groups: tuple[GroupCoordinator | None, ...]
217
+ ) -> None:
218
+ from vllm.platforms import current_platform
219
+
220
+ current_platform.set_device(self.worker.device)
221
+ for group in groups:
222
+ if group is not None:
223
+ group.destroy()
224
+
225
+ def _start_group_cleanup(self, groups: tuple[GroupCoordinator | None, ...]) -> None:
226
+ assert self._group_cleanup_future is None
227
+ self._group_cleanup_future = self._async_executor.submit(
228
+ self._destroy_retired_groups, groups
229
+ )
230
+
231
+ def _wait_for_group_cleanup(self) -> None:
232
+ if (future := self._group_cleanup_future) is not None:
233
+ self._group_cleanup_future = None
234
+ future.result()
235
+
236
+ def shutdown(self) -> None:
237
+ try:
238
+ self._wait_for_group_cleanup()
239
+ finally:
240
+ self._async_executor.shutdown()
241
+
242
def load_model(self) -> None:
243
self.worker.load_model(load_dummy_weights=True)
244
213
- def create_standby_groups(
245
+ def prepare_reconfiguration(
246
self, reconfig_request: ReconfigureDistributedRequest, use_all2all: bool
247
) -> None:
248
+ self._wait_for_group_cleanup()
249
self.reconfig_request = reconfig_request
250
new_dp_size = reconfig_request.new_data_parallel_size
251
old_dp_size = get_dp_group().world_size
@@ -227,8 +260,20 @@ class ElasticEPScalingExecutor:
260
use_all2all=use_all2all,
261
enable_eplb=parallel_config.enable_eplb,
262
)
230
- if new_dp_size < old_dp_size:
231
- self.stage_standby_moe_quant_methods()
263
+ self.stage_standby_moe_quant_methods()
264
+ self._prepare_eplb_communicator(get_standby_eplb_group())
265
+ if new_dp_size > old_dp_size:
266
+ self.transfer_weights(old_dp_size, new_dp_size)
267
+ self._warm_target_groups(get_standby_dp_group(), get_standby_ep_group())
268
+
269
+ def _prepare_eplb_communicator(self, eplb_group) -> None:
270
+ assert eplb_group is not None
271
+ model_runner = self.worker.model_runner
272
+ eplb_state = model_runner.eplb_state
273
+ assert eplb_state is not None
274
+ self._prepared_eplb_communicator = eplb_state.create_communicator(
275
+ model_runner.model_config, eplb_group
276
+ )
277
278
def transfer_weights(self, old_dp_size: int, new_dp_size: int) -> None:
279
standby_dp_group = get_standby_dp_group()
@@ -276,6 +321,15 @@ class ElasticEPScalingExecutor:
321
)
322
torch.accelerator.synchronize()
323
324
+ def _warm_target_groups(self, dp_group, ep_group) -> None:
325
+ assert dp_group is not None and ep_group is not None
326
+ stream = torch.Stream(device=dp_group.device)
327
+ with stream:
328
+ tensor = torch.zeros(1, dtype=torch.int32, device=dp_group.device)
329
+ for group in (dp_group, ep_group):
330
+ torch.distributed.all_reduce(tensor, group=group.device_group)
331
+ stream.synchronize()
332
+
333
def broadcast_expert_mapping(self) -> None:
334
standby_dp_group = get_standby_dp_group()
335
assert standby_dp_group is not None
@@ -362,15 +416,22 @@ class ElasticEPScalingExecutor:
416
torch.accelerator.empty_cache()
417
418
def switch_and_remove(self) -> None:
419
+ # Removing ranks skipped preparation, so wait for prior cleanup here.
420
+ self._wait_for_group_cleanup()
421
self._release_cuda_graphs()
366
- _replace_active_groups(world=None, dp=None, ep=None, eplb=None, node_count=None)
422
+ retired_groups = _replace_active_groups(
423
+ world=None, dp=None, ep=None, eplb=None, node_count=None
424
+ )
425
+ self._start_group_cleanup(retired_groups)
426
+ # Finish collective cleanup before this worker is shut down.
427
+ self._wait_for_group_cleanup()
428
368
- def switch_and_prepare(self) -> None:
429
+ def switch_and_prepare(self) -> tuple[GroupCoordinator | None, ...]:
430
old_dp_size = get_dp_group().world_size
431
old_ep_size = get_ep_group().world_size
432
433
self._release_cuda_graphs()
373
- _replace_active_groups(**pop_standby_groups())
434
+ retired_groups = _replace_active_groups(**pop_standby_groups())
435
436
parallel_config = self.worker.vllm_config.parallel_config
437
reconfig_request = self.reconfig_request
@@ -453,7 +514,6 @@ class ElasticEPScalingExecutor:
514
)
515
eplb_model_state.expert_load_pass = expanded_expert_load_pass
516
eplb_model_state.expert_load_window = expanded_expert_load_window
456
- eplb_state.num_valid_physical_experts = old_num_physical_experts
517
else:
518
assert pad_size < 0
519
eplb_model_state.expert_load_pass = eplb_model_state.expert_load_pass[
@@ -462,7 +522,6 @@ class ElasticEPScalingExecutor:
522
eplb_model_state.expert_load_window = eplb_model_state.expert_load_window[
523
:, :, :num_physical_experts
524
]
465
- eplb_state.num_valid_physical_experts = num_physical_experts
525
526
model = self.worker.model_runner.get_model()
527
model.expert_weights = []
@@ -485,18 +544,9 @@ class ElasticEPScalingExecutor:
544
if getattr(module._quant_method, "wraps_legacy_quant_method", False):
545
module._replace_quant_method(module._quant_method.old_quant_method)
546
488
- eplb_model_state.expert_buffer = [
489
- torch.empty_like(w) for w in model.expert_weights[0]
490
- ]
491
- assert parallel_config.eplb_config.communicator is not None, (
492
- "EPLB communicator backend must be set by ParallelConfig"
493
- )
494
- eplb_model_state.communicator = create_eplb_communicator(
495
- group_coordinator=get_eplb_group(),
496
- backend=parallel_config.eplb_config.communicator,
497
- expert_weights=model.expert_weights,
498
- expert_buffer=eplb_model_state.expert_buffer,
499
- )
547
+ assert self._prepared_eplb_communicator is not None
548
+ eplb_state.update_communicator(model_config, self._prepared_eplb_communicator)
549
+ self._prepared_eplb_communicator = None
550
551
if (
552
self.worker.vllm_config.compilation_config.mode
@@ -513,8 +563,12 @@ class ElasticEPScalingExecutor:
563
compilation_counter.stock_torch_compile_count += 1
564
self.worker.model_runner.model.compile(fullgraph=True, backend=backend)
565
566
+ return retired_groups
567
+
568
def _perform_eplb_reshuffle(
517
- self, rank_mapping: dict[int, int] | None = None
569
+ self,
570
+ rank_mapping: dict[int, int] | None = None,
571
+ async_op: bool = False,
572
) -> None:
573
if get_ep_group().rank == 0:
574
logger.info("[Elastic EP] Starting expert resharding...")
@@ -522,21 +576,19 @@ class ElasticEPScalingExecutor:
576
eplb_state = self.worker.model_runner.eplb_state
577
assert eplb_state is not None
578
525
- model_config = self.worker.model_runner.model_config
526
- eplb_model_state = eplb_state.model_states[model_config.compute_hash()]
579
is_async_enabled = eplb_state.is_async
528
- eplb_state.is_async = False
580
+ run_async = async_op and is_async_enabled
581
+ eplb_state.is_async = run_async
582
if rank_mapping is None:
583
eplb_state.rearrange()
584
else:
585
eplb_state.rearrange(rank_mapping=rank_mapping)
533
- # NOTE(yongji): check whether we need to synchronize here
534
- torch.accelerator.synchronize()
586
+ if not run_async:
587
+ # Wait for non-blocking expert resharding copies before continuing
588
+ # the Elastic EP reconfiguration.
589
+ torch.accelerator.synchronize()
590
# reset expert_rearrangement_step to ensure all ranks are synchronized
591
eplb_state.expert_rearrangement_step = 0
537
- eplb_state.num_valid_physical_experts = (
538
- eplb_model_state.physical_to_logical_map.shape[1]
539
- )
592
eplb_state.is_async = is_async_enabled
593
# Start the async worker thread if it doesn't exist yet (idempotent).
594
# This is needed for new workers after scale-up: they create EplbState
@@ -544,25 +596,31 @@ class ElasticEPScalingExecutor:
596
# groups aren't ready yet.
597
eplb_state.start_async_loop()
598
if get_ep_group().rank == 0:
547
- logger.info("[Elastic EP] Expert resharding completed")
599
+ logger.info(
600
+ "[Elastic EP] Expert resharding %s",
601
+ "scheduled" if run_async else "completed",
602
+ )
603
604
def commit_scale_up(self, is_existing_worker: bool) -> None:
605
if is_existing_worker:
606
self.broadcast_expert_mapping()
552
- self.switch_and_prepare()
607
+ retired_groups = self.switch_and_prepare()
608
else:
554
- mapping, _, num_valid_experts = self.receive_expert_mapping()
555
- self.worker.model_runner.setup_eplb_from_mapping(mapping, num_valid_experts)
556
- self._perform_eplb_reshuffle()
609
+ mapping = self.receive_expert_mapping()
610
+ self.worker.model_runner.setup_eplb_from_mapping(mapping)
611
self.warm_and_capture()
612
+ self._perform_eplb_reshuffle(async_op=True)
613
+ if is_existing_worker:
614
+ self._start_group_cleanup(retired_groups)
615
616
def commit_scale_down(self, new_dp_size: int, removing: bool) -> None:
617
self.perform_scale_down_eplb_reshuffle(new_dp_size)
618
if removing:
619
self.switch_and_remove()
620
else:
564
- self.switch_and_prepare()
621
+ retired_groups = self.switch_and_prepare()
622
self.warm_and_capture()
623
+ self._start_group_cleanup(retired_groups)
624
625
def perform_scale_down_eplb_reshuffle(self, new_dp_size: int) -> None:
626
eplb_state = self.worker.model_runner.eplb_state
@@ -578,7 +636,7 @@ class ElasticEPScalingExecutor:
636
}
637
self._perform_eplb_reshuffle(rank_mapping=rank_mapping)
638
581
- def receive_weights(self) -> None:
639
+ def prepare_new_worker(self) -> None:
640
dp_group = get_dp_group()
641
assert isinstance(dp_group, StatelessGroupCoordinator)
642
new_dp_size = dp_group.world_size
@@ -618,19 +676,18 @@ class ElasticEPScalingExecutor:
676
expert_weights=expert_weights,
677
)
678
torch.accelerator.synchronize()
679
+ self._warm_target_groups(get_dp_group(), get_ep_group())
680
622
- def receive_expert_mapping(self) -> tuple[torch.Tensor, int, int]:
681
+ def receive_expert_mapping(self) -> torch.Tensor:
682
dp_group = get_dp_group()
683
assert isinstance(dp_group, StatelessGroupCoordinator)
625
- physical_to_logical, num_local_physical_experts, num_logical_experts = (
626
- broadcast_expert_mapping(
627
- physical_to_logical=None,
628
- num_local_physical_experts=None,
629
- num_logical_experts=None,
630
- dp_group=dp_group,
631
- src_rank=0,
632
- device=self.worker.device,
633
- )
684
+ physical_to_logical, num_local_physical_experts, _ = broadcast_expert_mapping(
685
+ physical_to_logical=None,
686
+ num_local_physical_experts=None,
687
+ num_logical_experts=None,
688
+ dp_group=dp_group,
689
+ src_rank=0,
690
+ device=self.worker.device,
691
)
692
num_moe_layers = physical_to_logical.shape[0]
693
new_dp_size = get_dp_group().world_size
@@ -642,13 +699,10 @@ class ElasticEPScalingExecutor:
699
dtype=physical_to_logical.dtype,
700
device=physical_to_logical.device,
701
)
645
- old_num_physical_experts = physical_to_logical.shape[1]
646
- expanded_physical_to_logical[:, :old_num_physical_experts] = physical_to_logical
647
- return (
648
- expanded_physical_to_logical,
649
- num_logical_experts,
650
- old_num_physical_experts,
702
+ expanded_physical_to_logical[:, : physical_to_logical.shape[1]] = (
703
+ physical_to_logical
704
)
705
+ return expanded_physical_to_logical
706
707
def warmup_local_kernels(self) -> None:
708
with set_current_vllm_config(self.worker.vllm_config):
vllm/distributed/elastic_ep/elastic_state.py
+12
-37
@@ -30,12 +30,10 @@ WorkerType = Literal["existing", "new", "removing"]
30
31
32
class ScaleUpExistingEngineState(enum.IntEnum):
33
- CREATE_STANDBY_GROUPS = 0
34
- STAGE_QUANT_METHODS = 1
35
- TRANSFER_WEIGHTS = 2
36
- SYNC_KV_CACHE_MEMORY_SIZE = 3
37
- COMMIT_SCALE_UP = 4 # Blocks forward passes.
38
- COMPLETE = 5
33
+ PREPARE = 0
34
+ SYNC_KV_CACHE_MEMORY_SIZE = 1
35
+ COMMIT_SCALE_UP = 2 # Blocks forward passes.
36
+ COMPLETE = 3
37
38
39
class ScaleUpNewEngineState(enum.IntEnum):
@@ -95,7 +93,7 @@ class ElasticEPScalingState:
93
self.state = (
94
ScaleUpNewEngineState.PRE_KV_INIT
95
if worker_type == "new"
98
- else ScaleUpExistingEngineState.CREATE_STANDBY_GROUPS
96
+ else ScaleUpExistingEngineState.PREPARE
97
)
98
else:
99
self.state = (
@@ -166,20 +164,8 @@ class ElasticEPScalingState:
164
state = self.state
165
assert self.old_dp_group is not None
166
169
- if state == ScaleUpExistingEngineState.CREATE_STANDBY_GROUPS:
170
- if not self._create_standby_groups():
171
- return False
172
- self.state = ScaleUpExistingEngineState.STAGE_QUANT_METHODS
173
- return True
174
-
175
- elif state == ScaleUpExistingEngineState.STAGE_QUANT_METHODS:
176
- if not self._execute_async("stage_standby_moe_quant_methods"):
177
- return False
178
- self.state = ScaleUpExistingEngineState.TRANSFER_WEIGHTS
179
- return True
180
-
181
- elif state == ScaleUpExistingEngineState.TRANSFER_WEIGHTS:
182
- if not self._transfer_weights():
167
+ if state == ScaleUpExistingEngineState.PREPARE:
168
+ if not self._prepare_workers():
169
return False
170
self.state = ScaleUpExistingEngineState.SYNC_KV_CACHE_MEMORY_SIZE
171
return True
@@ -210,7 +196,7 @@ class ElasticEPScalingState:
196
assert self.new_dp_group is not None and self.new_dp_store is not None
197
198
if state == ScaleUpNewEngineState.PRE_KV_INIT:
213
- self._collective_rpc("elastic_ep_execute", args=("receive_weights",))
199
+ self._collective_rpc("elastic_ep_execute", args=("prepare_new_worker",))
200
self.engine_core.available_gpu_memory_for_kv_cache = (
201
ParallelConfig.sync_kv_cache_memory_size(self.new_dp_group, -1)
202
)
@@ -243,7 +229,7 @@ class ElasticEPScalingState:
229
assert self.old_dp_group is not None
230
231
if state == ScaleDownRemainingEngineState.PREPARE:
246
- if self._create_standby_groups():
232
+ if self._prepare_workers():
233
self.state = ScaleDownRemainingEngineState.COMMIT_SCALE_DOWN
234
self._mark_ready_for_switch()
235
return True
@@ -328,29 +314,18 @@ class ElasticEPScalingState:
314
self._prepare_future = None
315
return True
316
331
- def _create_standby_groups(self) -> bool:
317
+ def _prepare_workers(self) -> bool:
318
assert self.old_dp_group is not None
319
if not self._ensure_new_dp_group():
320
return False
321
if not self._execute_async(
336
- "create_standby_groups",
322
+ "prepare_reconfiguration",
323
self.reconfig_request,
324
self.new_parallel_config.use_all2all,
325
):
326
return False
327
if self.old_dp_group.rank() == 0:
342
- logger.info("[Elastic EP] Created standby communication groups")
343
- return True
344
-
345
- def _transfer_weights(self) -> bool:
346
- assert self.reconfig_request is not None and self.old_dp_group is not None
347
- old_dp_size = self.old_dp_group.size()
348
- new_dp_size = self.reconfig_request.new_data_parallel_size
349
-
350
- if not self._execute_async("transfer_weights", old_dp_size, new_dp_size):
351
- return False
352
- if self.old_dp_group.rank() == 0:
353
- logger.info("[Elastic EP] Transferred weights to new workers")
328
+ logger.info("[Elastic EP] Prepared reconfiguration")
329
return True
330
331
def _sync_kv_cache_memory_size(self) -> bool:
vllm/distributed/eplb/eplb_communicator.py
+12
-36
@@ -248,7 +248,6 @@ class NixlEplbCommunicator(EplbCommunicator):
248
cpu_group: ProcessGroup,
249
all_expert_weights: Sequence[Sequence[torch.Tensor]],
250
expert_buffer: Sequence[torch.Tensor],
251
- defer_remote_setup: bool = False,
251
) -> None:
252
"""Create a NIXL-backed EPLB communicator.
253
@@ -256,11 +255,6 @@ class NixlEplbCommunicator(EplbCommunicator):
255
cpu_group: CPU process group for metadata exchange.
256
all_expert_weights: Expert weight tensors for all MoE layers.
257
expert_buffer: Pre-allocated receive buffer tensors.
259
- defer_remote_setup: If True, postpone the collective
260
- all-gather of NIXL agent metadata until the first
261
- ``set_transfer_context`` call. Required for elastic EP
262
- where ranks join asynchronously and cannot participate
263
- in collectives at construction time.
258
"""
259
assert all_expert_weights, (
260
"NixlEplbCommunicator requires non-empty all_expert_weights."
@@ -317,29 +311,17 @@ class NixlEplbCommunicator(EplbCommunicator):
311
] = {}
312
313
self._cuda_device_id = int(self._device.index or 0)
320
- self._remote_state_initialized = False
314
self._init_step("buffers", self._init_registered_buffers)
322
- if defer_remote_setup:
323
- logger.info_once("NIXL EPLB: deferring remote agent setup (elastic EP).")
324
- else:
325
- self._init_remote_state()
315
+ self._init_remote_state()
316
self._log_initialized()
317
318
def _init_remote_state(self) -> None:
319
"""Exchange NIXL agent metadata and RDMA pointer info with all peers.
320
321
This is a collective operation (uses ``all_gather_object`` twice).
332
- Under elastic EP the call is deferred to the first
333
- ``set_transfer_context`` invocation, where all ranks are
334
- guaranteed to be synchronized.
322
"""
323
self._init_step("agents", self._init_remote_agents)
324
self._init_step("send meta", self._exchange_remote_send_meta)
338
- self._remote_state_initialized = True
339
-
340
- def _ensure_remote_state(self) -> None:
341
- if not self._remote_state_initialized:
342
- self._init_remote_state()
325
326
@property
327
def needs_profile_buffer_reservation(self) -> bool:
@@ -373,7 +355,6 @@ class NixlEplbCommunicator(EplbCommunicator):
355
pass
356
357
def set_transfer_context(self, old_indices: np.ndarray, layer_idx: int) -> None:
376
- self._ensure_remote_state()
358
assert not self._xfer_entries, (
359
f"set_transfer_context() called with {len(self._xfer_entries)} "
360
f"pending transfers from layer {self._layer_idx}; "
@@ -673,9 +654,8 @@ def create_eplb_communicator(
654
Falls back to ``"torch_nccl"`` when *None*.
655
Stateless (elastic EP) groups support ``"torch_nccl"``,
656
``"pynccl"``, and ``"nixl"``; ``"torch_nccl"`` is silently
676
- promoted to ``"pynccl"``. ``"nixl"`` uses deferred remote
677
- agent setup to avoid collective deadlocks during elastic
678
- scaling. When tensors reside on CPU, ``"torch_gloo"`` or
657
+ promoted to ``"pynccl"``. When tensors reside on CPU,
658
+ ``"torch_gloo"`` or
659
``"torch_nccl"`` are used via the CPU process group.
660
expert_weights: Expert weight tensors for *all* MoE layers.
661
Shape ``(num_layers)(num_tensors_per_layer)``.
@@ -729,22 +709,19 @@ def create_eplb_communicator(
709
) from exc
710
711
is_stateless = isinstance(group_coordinator, StatelessGroupCoordinator)
732
- if is_stateless:
733
- if backend == "nixl":
734
- pass # handled below with defer_remote_setup=True
735
- elif backend not in ("torch_nccl", "pynccl"):
712
+ if is_stateless and backend != "nixl":
713
+ if backend not in ("torch_nccl", "pynccl"):
714
raise ValueError(
715
f"Elastic EP requires 'torch_nccl', 'pynccl', or 'nixl' "
716
f"EPLB communicator (got '{backend}')."
717
)
740
- else:
741
- if backend == "torch_nccl":
742
- logger.warning(
743
- "Stateless elastic EP requires PyNCCL backend. "
744
- "Forcing EPLB communicator to 'pynccl'."
745
- )
746
- backend = "pynccl"
747
- return _create_pynccl()
718
+ if backend == "torch_nccl":
719
+ logger.warning(
720
+ "Stateless elastic EP requires PyNCCL backend. "
721
+ "Forcing EPLB communicator to 'pynccl'."
722
+ )
723
+ backend = "pynccl"
724
+ return _create_pynccl()
725
726
if backend == "nixl":
727
if not has_nixl():
@@ -761,7 +738,6 @@ def create_eplb_communicator(
738
cpu_group=group_coordinator.cpu_group,
739
all_expert_weights=expert_weights,
740
expert_buffer=expert_buffer,
764
- defer_remote_setup=is_stateless,
741
)
742
except Exception as exc:
743
raise RuntimeError(
vllm/distributed/eplb/eplb_state.py
+46
-25
@@ -37,6 +37,7 @@ from torch.distributed import ProcessGroup, all_reduce
37
from vllm.config import ModelConfig, ParallelConfig
38
from vllm.config.utils import compute_hash_cached
39
from vllm.distributed.parallel_state import (
40
+ GroupCoordinator,
41
get_ep_group,
42
get_eplb_group,
43
get_node_count,
@@ -289,14 +290,6 @@ class EplbState:
290
"""
291
CUDA device index for the async EPLB worker thread.
292
"""
292
- self.num_valid_physical_experts: int = 0
293
- """
294
- Number of valid physical experts.
295
- This is the number of physical experts that are
296
- actually mapped to logical experts. In elastic EP,
297
- newly started EP ranks may not have physical experts
298
- mapped yet.
299
- """
293
if self.device.type == "cuda":
294
self.cuda_device_index = self.device.index
295
if self.cuda_device_index is None and torch.cuda.is_available():
@@ -499,7 +492,6 @@ class EplbState:
492
num_unpadded_tokens_tensors=num_unpadded_tokens_tensors,
493
)
494
self.model_states[model_config.compute_hash()] = model_state
502
- self.num_valid_physical_experts = model.num_physical_experts
495
496
def prepare_forward(
497
self,
@@ -766,28 +758,28 @@ class EplbState:
758
# Map the physical expert load to global logical experts
759
global_expert_load_windows = []
760
for eplb_model_state in self.model_states.values():
769
- expert_load_window = eplb_model_state.expert_load_window[
770
- :, :, : self.num_valid_physical_experts
771
- ]
761
+ expert_load_window = eplb_model_state.expert_load_window
762
+ physical_to_logical = eplb_model_state.physical_to_logical_map
763
+ invalid_idx = eplb_model_state.model.num_logical_experts
764
logical_expert_load_window = torch.zeros(
765
self.expert_load_window_size,
766
eplb_model_state.model.num_moe_layers,
775
- eplb_model_state.model.num_logical_experts,
767
+ invalid_idx + 1,
768
dtype=eplb_model_state.expert_load_window.dtype,
769
device=eplb_model_state.expert_load_window.device,
770
)
771
logical_expert_load_window.scatter_add_(
772
dim=-1,
781
- index=eplb_model_state.physical_to_logical_map[
782
- :, : self.num_valid_physical_experts
783
- ]
773
+ index=physical_to_logical.masked_fill(
774
+ physical_to_logical < 0, invalid_idx
775
+ )
776
.unsqueeze(0)
777
.expand_as(expert_load_window)
778
.long(),
779
src=expert_load_window,
780
)
781
790
- global_expert_load_window = logical_expert_load_window.sum(dim=0)
782
+ global_expert_load_window = logical_expert_load_window[..., :-1].sum(dim=0)
783
global_expert_load_windows.append(global_expert_load_window)
784
# Perform all-reduce to get the expert load across all ranks for each model
785
global_expert_load_windows = self._allreduce_list(global_expert_load_windows)
@@ -960,7 +952,7 @@ class EplbState:
952
953
Each pending result is acknowledged (consumed_event recorded) so the
954
async worker can proceed, but the transferred weights are intentionally
963
- NOT applied — a full synchronous rearrange is expected to follow.
955
+ NOT applied — a full rearrange is expected to follow.
956
957
Ranks are kept in lockstep via _all_ranks_result_ready (all_reduce
958
on the EP CPU group). The async worker's coordinated-stop collectives
@@ -1060,7 +1052,6 @@ class EplbState:
1052
device: torch.device,
1053
parallel_config: ParallelConfig,
1054
expanded_physical_to_logical: torch.Tensor,
1063
- num_valid_physical_experts: int,
1055
) -> "EplbState":
1056
eplb_state = cls(
1057
parallel_config=parallel_config,
@@ -1070,12 +1061,24 @@ class EplbState:
1061
model=model,
1062
model_config=model_config,
1063
)
1073
- eplb_state.num_valid_physical_experts = num_valid_physical_experts
1074
- eplb_model_state = eplb_state.model_states[model_config.compute_hash()]
1064
+ eplb_state.update_mapping(
1065
+ model_config,
1066
+ expanded_physical_to_logical,
1067
+ )
1068
+
1069
+ return eplb_state
1070
+
1071
+ def update_mapping(
1072
+ self,
1073
+ model_config: ModelConfig,
1074
+ expanded_physical_to_logical: torch.Tensor,
1075
+ ) -> None:
1076
+ eplb_model_state = self.model_states[model_config.compute_hash()]
1077
eplb_model_state.physical_to_logical_map.copy_(expanded_physical_to_logical)
1078
1079
(logical_to_physical_map_cpu, logical_replica_count_cpu) = compute_logical_maps(
1078
- expanded_physical_to_logical.cpu(), model.num_logical_experts
1080
+ expanded_physical_to_logical.cpu(),
1081
+ eplb_model_state.model.num_logical_experts,
1082
)
1083
1084
max_num_replicas = eplb_model_state.logical_to_physical_map.shape[-1]
@@ -1087,13 +1090,31 @@ class EplbState:
1090
max_num_replicas - num_replicas,
1091
),
1092
value=-1,
1090
- ).to(device)
1091
- logical_replica_count = logical_replica_count_cpu.to(device)
1093
+ ).to(self.device)
1094
+ logical_replica_count = logical_replica_count_cpu.to(self.device)
1095
1096
eplb_model_state.logical_to_physical_map.copy_(logical_to_physical_map)
1097
eplb_model_state.logical_replica_count.copy_(logical_replica_count)
1098
1096
- return eplb_state
1099
+ def create_communicator(
1100
+ self, model_config: ModelConfig, group_coordinator: GroupCoordinator
1101
+ ) -> EplbCommunicator:
1102
+ model_state = self.model_states[model_config.compute_hash()]
1103
+ backend = self.parallel_config.eplb_config.communicator
1104
+ assert backend is not None
1105
+ return create_eplb_communicator(
1106
+ group_coordinator,
1107
+ backend,
1108
+ model_state.model.expert_weights,
1109
+ model_state.expert_buffer,
1110
+ )
1111
+
1112
+ def update_communicator(
1113
+ self,
1114
+ model_config: ModelConfig,
1115
+ communicator: EplbCommunicator,
1116
+ ) -> None:
1117
+ self.model_states[model_config.compute_hash()].communicator = communicator
1118
1119
1120
@dataclass
vllm/distributed/parallel_state.py
+7
-7
@@ -1366,21 +1366,21 @@ def _replace_active_groups(
1366
ep: GroupCoordinator | None,
1367
eplb: GroupCoordinator | None,
1368
node_count: int | None,
1369
-) -> None:
1370
- """Destroy the current DP/EP/WORLD/EPLB groups and replace them.
1369
+) -> tuple[GroupCoordinator | None, ...]:
1370
+ """Replace the active groups and return the groups they replaced.
1371
1372
- Destruction is collective — all ranks in the old groups must call this
1373
- function together. Pass all-``None`` to tear down without replacement.
1372
+ The caller must destroy the returned DP, EP, WORLD, and EPLB groups
1373
+ collectively and in that order. Pass all-``None`` to remove the active
1374
+ groups without replacement.
1375
"""
1376
global _WORLD, _DP, _EP, _EPLB, _NODE_COUNT
1376
- for group in (_DP, _EP, _WORLD, _EPLB):
1377
- if group is not None:
1378
- group.destroy()
1377
+ old_groups = _DP, _EP, _WORLD, _EPLB
1378
_WORLD = world
1379
_DP = dp
1380
_EP = ep
1381
_EPLB = eplb
1382
_NODE_COUNT = node_count
1383
+ return old_groups
1384
1385
1386
_TP: GroupCoordinator | None = None
vllm/v1/worker/gpu/eplb_utils.py
-2
@@ -144,7 +144,6 @@ class EPLBController:
144
model: nn.Module,
145
model_config: Any,
146
expanded_physical_to_logical: torch.Tensor,
147
- old_num_physical_experts: int,
147
) -> None:
148
moe_model = get_mixture_of_experts_model(model)
149
assert moe_model is not None
@@ -155,6 +154,5 @@ class EPLBController:
154
device=self.device,
155
parallel_config=self.parallel_config,
156
expanded_physical_to_logical=expanded_physical_to_logical,
158
- num_valid_physical_experts=old_num_physical_experts,
157
)
158
self._has_registered_models = True
vllm/v1/worker/gpu/model_runner.py
-2
@@ -1962,13 +1962,11 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1962
def setup_eplb_from_mapping(
1963
self,
1964
expanded_physical_to_logical: torch.Tensor,
1965
- old_num_physical_experts: int,
1965
) -> None:
1966
self.eplb.setup_from_mapping(
1967
self.model,
1968
self.model_config,
1969
expanded_physical_to_logical,
1971
- old_num_physical_experts,
1970
)
1971
1972
########### EPLB methods end ###########
vllm/v1/worker/gpu_model_runner.py
+5
-15
@@ -3517,17 +3517,11 @@ class GPUModelRunner(
3517
def setup_eplb_from_mapping(
3518
self,
3519
expanded_physical_to_logical: torch.Tensor,
3520
- old_num_physical_experts: int,
3520
) -> None:
3522
- assert self._moe_model is not None
3523
-
3524
- self.eplb_state = EplbState.from_mapping(
3525
- model=self._moe_model,
3526
- model_config=self.model_config,
3527
- device=self.device,
3528
- parallel_config=self.parallel_config,
3529
- expanded_physical_to_logical=expanded_physical_to_logical,
3530
- num_valid_physical_experts=old_num_physical_experts,
3521
+ assert self.eplb_state is not None
3522
+ self.eplb_state.update_mapping(
3523
+ self.model_config,
3524
+ expanded_physical_to_logical,
3525
)
3526
3527
def _pool(
@@ -5488,11 +5482,7 @@ class GPUModelRunner(
5482
# MixtureOfExperts themselves.
5483
self._moe_model = get_mixture_of_experts_model(self.model)
5484
5491
- if (
5492
- self.parallel_config.enable_eplb
5493
- and not load_dummy_weights
5494
- and self._moe_model is not None
5495
- ):
5485
+ if self._moe_model is not None and self.parallel_config.enable_eplb:
5486
logger.info_once(
5487
"EPLB is enabled for MoE part of model %s.",
5488
self.model_config.model,
vllm/v1/worker/gpu_worker.py
+2
@@ -1376,6 +1376,8 @@ class Worker(WorkerBase):
1376
if weight_transfer_engine := getattr(self, "weight_transfer_engine", None):
1377
weight_transfer_engine.shutdown()
1378
1379
+ self.elastic_ep_executor.shutdown()
1380
+
1381
# Release GPU resources held by the model runner so that memory
1382
# can be reclaimed when running in-process
1383
if model_runner := getattr(self, "model_runner", None):