[Elastic EP] Support Model Runner V2 (#53934)

Signed-off-by: almogtavor <almogtavor@gmail.com> Signed-off-by: Almog Tavor <70065337+almogtavor@users.noreply.github.com>

Almog Tavor committed Sep 29, 2026 at 02:20 UTC 171d1b9ef6fdfb86a45d2c540b52535e4d5a660f
12 files changed +171 -80
tests/v1/cudagraph/test_cudagraph_manager.py
+3 -1
@@ -224,7 +224,9 @@ def test_speculator_capture_preserves_decode_query_bounds(
224 )
225 manager = SpeculatorCudaGraphManager.__new__(SpeculatorCudaGraphManager)
226 manager.max_num_reqs = num_reqs
227 - manager.dp_size = 1
227 + manager.vllm_config = SimpleNamespace(
228 + parallel_config=SimpleNamespace(data_parallel_size=1)
229 + )
230 buffers = InputBuffers(num_reqs, num_tokens, torch.device("cpu"))
231 block_tables = MagicMock()
232 block_tables.cp_size = 1
tests/v1/engine/test_engine_core_client.py
+4
@@ -215,6 +215,10 @@ def _make_dplb_client(num_engines: int = 3, client_count: int = 1) -> DPLBAsyncM
215 client.lb_engines = [[0, 0, 0.0] for _ in range(num_engines)]
216 client.eng_start_index = 0
217 client._kv_event_sources = {}
218 + client.vllm_config = SimpleNamespace(
219 + use_v2_model_runner=True,
220 + parallel_config=SimpleNamespace(all2all_backend="allgather_reducescatter"),
221 + )
222 return client
223
224
tests/v1/worker/test_gpu_model_runner_v2_eplb.py
+12 -21
@@ -24,7 +24,6 @@ class FakeMemoryProfiler:
24
25 class FakeEplbState:
26 instances: list["FakeEplbState"] = []
27 - from_mapping_kwargs: dict[str, Any] | None = None
27
28 def __init__(self, parallel_config: Any, device: torch.device):
29 self.parallel_config = parallel_config
@@ -33,7 +32,7 @@ class FakeEplbState:
32 self.step_calls: list[tuple[bool, bool, bool]] = []
33 self.async_started = False
34 self.is_async = True
36 - self.built_from_mapping = False
35 + self.update_mapping_calls: list[tuple[Any, torch.Tensor]] = []
36 FakeEplbState.instances.append(self)
37
38 def add_model(self, model: Any, model_config: Any) -> None:
@@ -45,12 +44,8 @@ class FakeEplbState:
44 def start_async_loop(self) -> None:
45 self.async_started = True
46
48 - @classmethod
49 - def from_mapping(cls, **kwargs: Any) -> "FakeEplbState":
50 - cls.from_mapping_kwargs = kwargs
51 - state = cls(kwargs["parallel_config"], kwargs["device"])
52 - state.built_from_mapping = True
53 - return state
47 + def update_mapping(self, model_config: Any, mapping: torch.Tensor) -> None:
48 + self.update_mapping_calls.append((model_config, mapping))
49
50
51 def _make_runner(**overrides: Any) -> Any:
@@ -136,7 +131,7 @@ def test_v2_load_model_registers_moe_with_eplb(monkeypatch):
131 assert runner.eplb_state.async_started is True
132
133
139 -def test_v2_load_model_with_dummy_weights_skips_eplb_registration(monkeypatch):
134 +def test_v2_load_model_with_dummy_weights_defers_eplb_async_loop(monkeypatch):
135 FakeEplbState.instances.clear()
136 model = SimpleNamespace(is_moe=True)
137
@@ -159,24 +154,20 @@ def test_v2_load_model_with_dummy_weights_skips_eplb_registration(monkeypatch):
154
155 assert runner.load_config.load_format == "dummy"
156 assert runner.eplb_state is not None
162 - assert runner.eplb_state.add_model_calls == []
157 + # A scaling-up worker registers so it can join the EPLB communicator.
158 + assert runner.eplb_state.add_model_calls == [(model, runner.model_config)]
159 assert runner.eplb_state.async_started is False
160
161
166 -def test_v2_setup_eplb_from_mapping_rebuilds_state(monkeypatch):
167 - FakeEplbState.instances.clear()
168 - FakeEplbState.from_mapping_kwargs = None
169 - monkeypatch.setattr(eplb, "EplbState", FakeEplbState)
170 - monkeypatch.setattr(eplb, "get_mixture_of_experts_model", lambda model: model)
171 -
172 - runner = _make_runner(model=SimpleNamespace(is_moe=True))
162 +def test_v2_setup_eplb_from_mapping_updates_state_in_place():
163 + runner = _make_runner()
164 + state = FakeEplbState(runner.parallel_config, runner.device)
165 + runner.eplb_state = state
166 mapping = torch.tensor([[0, 1, 2, 3]], dtype=torch.int64)
167 mrv2.GPUModelRunner.setup_eplb_from_mapping(runner, mapping)
168
176 - assert runner.eplb_state is not None
177 - assert runner.eplb_state.built_from_mapping is True
178 - assert FakeEplbState.from_mapping_kwargs is not None
179 - assert FakeEplbState.from_mapping_kwargs["expanded_physical_to_logical"] is mapping
169 + assert runner.eplb_state is state
170 + assert state.update_mapping_calls == [(runner.model_config, mapping)]
171
172
173 def test_v2_sample_tokens_runs_eplb_on_non_last_pp_rank(monkeypatch):
vllm/config/vllm.py
-3
@@ -3086,9 +3086,6 @@ class VllmConfig:
3086 if self.parallel_config.use_ubatching:
3087 unsupported.extend(self._get_dbo_unsupported_features())
3088
3089 - if self.parallel_config.enable_elastic_ep:
3090 - unsupported.append("elastic expert parallelism")
3091 -
3089 if self.cache_config.mamba_cache_mode == "all":
3090 unsupported.append("mamba cache mode 'all'")
3091
vllm/distributed/elastic_ep/elastic_execute.py
+33 -22
@@ -54,6 +54,8 @@ from vllm.platforms import current_platform
54 from vllm.utils import is_moe_layer
55 from vllm.v1.engine import ReconfigureDistributedRequest, ReconfigureRankType
56 from vllm.v1.worker.dp_utils import skip_dp_coordination
57 +from vllm.v1.worker.gpu.cudagraph_utils import CudaGraphManager
58 +from vllm.v1.worker.gpu.eplb_utils import preserve_serving_state
59 from vllm.v1.worker.gpu_ubatch_wrapper import UBatchWrapper
60 from vllm.v1.worker.workspace import lock_workspace, unlock_workspace
61
@@ -171,6 +173,17 @@ class ElasticEPScalingExecutor:
173 self.worker.vllm_config.parallel_config,
174 )
175
176 + @contextmanager
177 + def _suppress_eplb(self) -> Iterator[None]:
178 + # A rank warming up alone must not advance the EPLB step counter.
179 + runner = self.worker.model_runner
180 + was_suppressed = runner.eep_eplb_suppressed
181 + runner.eep_eplb_suppressed = True
182 + try:
183 + yield
184 + finally:
185 + runner.eep_eplb_suppressed = was_suppressed
186 +
187 @contextmanager
188 def _disable_flashinfer_autotune(self) -> Iterator[None]:
189 kernel_config = self.worker.vllm_config.kernel_config
@@ -421,7 +434,17 @@ class ElasticEPScalingExecutor:
434 self._staged_moe_quant_methods.clear()
435
436 def _release_cuda_graphs(self) -> None:
424 - if isinstance(self.worker.model_runner.model, CUDAGraphWrapper):
437 + manager = getattr(self.worker.model_runner, "cudagraph_manager", None)
438 + speculator = getattr(self.worker.model_runner, "speculator", None)
439 + if manager is not None:
440 + # MRV2 captures through CudaGraphManager instead of wrapping the
441 + # model, so neither wrapper branch below ever fires.
442 + manager.release_graphs()
443 + for attr in vars(speculator).values() if speculator else ():
444 + if isinstance(attr, CudaGraphManager):
445 + attr.release_graphs()
446 +
447 + elif isinstance(self.worker.model_runner.model, CUDAGraphWrapper):
448 wrapper = self.worker.model_runner.model
449 wrapper.concrete_cudagraph_entries = {}
450
@@ -431,6 +454,8 @@ class ElasticEPScalingExecutor:
454 torch.compiler.reset()
455 with set_current_vllm_config(self.worker.vllm_config):
456 reset_compile_wrapper(self.worker.model_runner.get_model())
457 + if speculator is not None:
458 + reset_compile_wrapper(speculator.model)
459
460 gc.collect()
461 torch.accelerator.synchronize()
@@ -675,42 +700,28 @@ class ElasticEPScalingExecutor:
700 self.warm_and_capture()
701
702 def warm_and_capture(self) -> None:
678 - # Save and clear block tables so the dummy MoE forward doesn't
679 - # write dummy slot mappings into real KV-cache blocks.
680 - multi_block_table = self.worker.model_runner.input_batch.block_table
681 - saved_block_tables: list[tuple[torch.Tensor, torch.Tensor]] = []
682 - for bt in multi_block_table.block_tables:
683 - saved_block_tables.append(
684 - (bt.block_table.gpu.clone(), bt.block_table.cpu.clone())
685 - )
686 - multi_block_table.clear()
687 -
703 # _ensure_workspace_size allocates a fresh tensor on grow, leaving
704 # any captured CUDA graph with a stale data pointer; drop graphs
705 # before re-warm so captures realign with the resized buffer.
706 self._release_cuda_graphs()
707 unlock_workspace()
708
694 - # Grow the MoE workspace at max_num_tokens. compile_or_warm_up_model
695 - # alone only exercises cudagraph-capture sizes and can leave the
696 - # workspace too small for post-reshuffle routing. Use _dummy_run
697 - # directly with skip_eplb=True so dummy routing doesn't pollute the
698 - # just-rebalanced EPLB stats.
709 runner = self.worker.model_runner
710 all2all_manager = get_ep_all2all_manager()
711 reuse_kernel = self._can_reuse_fused_moe_kernel()
712 + serving_state = (
713 + preserve_serving_state(runner)
714 + if self.worker.use_v2_model_runner
715 + else runner.preserve_serving_state()
716 + )
717 with (
718 skip_dp_coordination() if reuse_kernel else nullcontext(),
719 all2all_manager.mask_remote_ranks() if reuse_kernel else nullcontext(),
720 self._disable_flashinfer_autotune() if reuse_kernel else nullcontext(),
721 + self._suppress_eplb(),
722 + serving_state,
723 ):
724 runner._dummy_run(runner.max_num_tokens, is_profile=True, skip_eplb=True)
725 self.worker.compile_or_warm_up_model()
726
727 lock_workspace()
711 -
712 - for bt, (saved_gpu, saved_cpu) in zip(
713 - multi_block_table.block_tables, saved_block_tables
714 - ):
715 - bt.block_table.gpu.copy_(saved_gpu)
716 - bt.block_table.cpu.copy_(saved_cpu)
vllm/v1/engine/core_client.py
+19 -2
@@ -1893,13 +1893,29 @@ class DPLBAsyncMPClient(DPAsyncMPClient):
1893 await asyncio.to_thread(self._coord_store.wait, ready_keys)
1894 logger.info("[Elastic EP] Successfully started new engines")
1895
1896 + def _eep_commit_pause_mode(self) -> PauseMode:
1897 + from vllm.distributed.elastic_ep.elastic_execute import (
1898 + can_reuse_fused_moe_kernel,
1899 + )
1900 +
1901 + # MRV2 re-warms through the request pool, so running requests must
1902 + # finish first while new ones stay queued in the scheduler.
1903 + parallel_config = self.vllm_config.parallel_config
1904 + if self.vllm_config.use_v2_model_runner and not can_reuse_fused_moe_kernel(
1905 + parallel_config
1906 + ):
1907 + return "wait"
1908 + return "keep"
1909 +
1910 async def _commit_scale_up_elastic_ep(self, new_data_parallel_size: int) -> None:
1911 new_core_engines = [
1912 rank.to_bytes(2, "little")
1913 for rank in range(len(self.core_engines), new_data_parallel_size)
1914 ]
1915
1902 - await self.pause_scheduler_async(mode="keep", clear_cache=False)
1916 + await self.pause_scheduler_async(
1917 + mode=self._eep_commit_pause_mode(), clear_cache=False
1918 + )
1919 wait_future = self._eep_wait_for_setup_switch_complete()
1920 finish_futures = [
1921 asyncio.create_task(
@@ -1971,7 +1987,8 @@ class DPLBAsyncMPClient(DPAsyncMPClient):
1987 for rank in range(new_data_parallel_size, cur_data_parallel_size):
1988 self._kv_event_sources.pop(rank, None)
1989 removed_dp_size = cur_data_parallel_size - new_data_parallel_size
1974 - pause_modes = ["keep"] * new_data_parallel_size + ["abort"] * removed_dp_size
1990 + eep_mode = self._eep_commit_pause_mode()
1991 + pause_modes = [eep_mode] * new_data_parallel_size + ["abort"] * removed_dp_size
1992 pause_futures = [
1993 self._call_utility_async("pause_scheduler", mode, False, engine=engine)
1994 for mode, engine in zip(pause_modes, old_core_engines)
vllm/v1/worker/gpu/block_table.py
+5
@@ -15,6 +15,9 @@ from vllm.v1.worker.gpu.buffer_utils import (
15
16
17 class BlockTables:
18 + # Set by the elastic EP warmup so KV writes land in the null block.
19 + redirect_writes_to_null_block = False
20 +
21 def __init__(
22 self,
23 block_sizes: list[int],
@@ -126,6 +129,8 @@ class BlockTables:
129 bpk = self.blocks_per_kv_block[i]
130 if bpk > 1:
131 block_ids = [b * bpk + k for b in block_ids for k in range(bpk)]
132 + if self.redirect_writes_to_null_block:
133 + block_ids = [0] * len(block_ids)
134 end = start + len(block_ids)
135 row_capacity = self.block_tables[i].gpu.shape[1]
136 if end > row_capacity:
vllm/v1/worker/gpu/cudagraph_utils.py
+17 -1
@@ -161,7 +161,6 @@ class CudaGraphManager:
161 # DBO supports FULL CUDA graphs only.
162 self.ubatch_runner = ubatch_runner
163
164 - self.dp_size = vllm_config.parallel_config.data_parallel_size
164 self.tp_size = vllm_config.parallel_config.tensor_parallel_size
165 self.is_first_pp_rank = get_pp_group().is_first_rank
166 self.is_last_pp_rank = get_pp_group().is_last_rank
@@ -385,6 +384,23 @@ class CudaGraphManager:
384 self._candidates.setdefault(key, []).extend(matching)
385 current_range_start = num_tokens + 1
386
387 + @property
388 + def dp_size(self) -> int:
389 + # Not cached: elastic EP rewrites parallel_config in place on scale.
390 + return self.vllm_config.parallel_config.data_parallel_size
391 +
392 + def release_graphs(self) -> None:
393 + """Drop the captured graphs so a later capture() can refill them.
394 +
395 + Elastic EP reallocates the MoE workspace when it grows, which leaves
396 + every captured graph holding a stale data pointer. `_capture_descs` is
397 + kept, so `needs_capture()` still reports the work to redo.
398 + """
399 + self.graphs.clear()
400 + self._graphs_captured = False
401 + if self.breakable_cg_runner is not None:
402 + BreakableCUDAGraphWrapper.clear_all_graphs()
403 +
404 def needs_capture(self) -> bool:
405 return len(self._capture_descs) > 0
406
vllm/v1/worker/gpu/eplb_utils.py
+41 -20
@@ -1,9 +1,10 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -from collections.abc import Callable
4 +from collections.abc import Callable, Iterator
5 +from contextlib import contextmanager
6 from functools import wraps
6 -from typing import Any
7 +from typing import TYPE_CHECKING, Any
8
9 import torch
10 import torch.nn as nn
@@ -15,6 +16,9 @@ from vllm.model_executor.models.interfaces import (
16 get_mixture_of_experts_model,
17 )
18
19 +if TYPE_CHECKING:
20 + from vllm.v1.worker.gpu.model_runner import GPUModelRunner
21 +
22 logger = init_logger(__name__)
23
24
@@ -37,6 +41,27 @@ def step_eplb_after(*, is_dummy: bool = False) -> Callable:
41 return decorator
42
43
44 +@contextmanager
45 +def preserve_serving_state(model_runner: "GPUModelRunner") -> Iterator[None]:
46 + """Keep the elastic EP warmup out of the request pool and the KV cache."""
47 + # After the drain only parked streaming sessions still hold a slot, and
48 + # those are re-added from NewRequestData on their next chunk.
49 + _remove_all_requests(model_runner)
50 + model_runner.block_tables.redirect_writes_to_null_block = True
51 + try:
52 + yield
53 + finally:
54 + _remove_all_requests(model_runner)
55 + model_runner.block_tables.redirect_writes_to_null_block = False
56 + if model_runner.kv_block_zeroer is not None:
57 + model_runner.kv_block_zeroer.zero_block_ids([0])
58 +
59 +
60 +def _remove_all_requests(model_runner: "GPUModelRunner") -> None:
61 + for req_id in list(model_runner.req_states.req_id_to_index):
62 + model_runner._remove_request(req_id)
63 +
64 +
65 class EPLBController:
66 def __init__(self, parallel_config: Any, device: torch.device):
67 self.parallel_config = parallel_config
@@ -82,10 +107,8 @@ class EPLBController:
107 self._has_registered_models = True
108 return True
109
85 - def maybe_register_model(
86 - self, model: nn.Module, model_config: Any, load_dummy_weights: bool
87 - ) -> bool:
88 - if not self.parallel_config.enable_eplb or load_dummy_weights:
110 + def maybe_register_model(self, model: nn.Module, model_config: ModelConfig) -> bool:
111 + if not self.parallel_config.enable_eplb:
112 return False
113
114 moe_model = get_mixture_of_experts_model(model)
@@ -100,7 +123,13 @@ class EPLBController:
123 self._has_registered_models = True
124 return True
125
103 - def maybe_start_async_loop(self, eplb_models_added: bool) -> None:
126 + def maybe_start_async_loop(
127 + self, eplb_models_added: bool, load_dummy_weights: bool = False
128 + ) -> None:
129 + # A scaling-up worker registers to join the communicator handshake, but
130 + # its groups are not live yet, so _perform_eplb_reshuffle() starts this.
131 + if load_dummy_weights:
132 + return
133 if eplb_models_added and self.state is not None and self.state.is_async:
134 self.state.start_async_loop()
135
@@ -128,18 +157,10 @@ class EPLBController:
157
158 def setup_from_mapping(
159 self,
131 - model: nn.Module,
132 - model_config: Any,
160 + model_config: ModelConfig,
161 expanded_physical_to_logical: torch.Tensor,
162 ) -> None:
135 - moe_model = get_mixture_of_experts_model(model)
136 - assert moe_model is not None
137 -
138 - self.state = EplbState.from_mapping(
139 - model=moe_model,
140 - model_config=model_config,
141 - device=self.device,
142 - parallel_config=self.parallel_config,
143 - expanded_physical_to_logical=expanded_physical_to_logical,
144 - )
145 - self._has_registered_models = True
163 + # Update in place: a fresh EplbState would build a second communicator
164 + # over a group the other ranks have already left.
165 + assert self.state is not None
166 + self.state.update_mapping(model_config, expanded_physical_to_logical)
vllm/v1/worker/gpu/model_runner.py
+13 -9
@@ -234,10 +234,6 @@ class GPUModelRunner(LoRAModelRunnerMixin):
234 # Persistent buffer for intermediate tensors (non-first PP ranks).
235 self.intermediate_tensors: IntermediateTensors | None = None
236
237 - # Data parallelism.
238 - self.dp_size = self.parallel_config.data_parallel_size
239 - self.dp_rank = self.parallel_config.data_parallel_rank
240 -
237 # Dual batch overlap. Created in initialize_kv_cache(), once everything
238 # it runs the microbatched forward with exists.
239 self.ubatch_runner: UBatchRunner | None = None
@@ -509,9 +505,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
505 if self.is_pooling_model and self.is_last_pp_rank:
506 self.pooling_runner = PoolingRunner(self.model, self.vllm_config)
507 eplb_models_added |= self.eplb.maybe_register_model(
512 - self.model, self.model_config, load_dummy_weights
508 + self.model, self.model_config
509 )
514 - self.eplb.maybe_start_async_loop(eplb_models_added)
510 + self.eplb.maybe_start_async_loop(eplb_models_added, load_dummy_weights)
511
512 if not self.is_first_pp_rank:
513 # For non-first PP ranks, create intermediate tensors sized
@@ -526,6 +522,16 @@ class GPUModelRunner(LoRAModelRunnerMixin):
522
523 get_offloader().post_init()
524
525 + @property
526 + def dp_size(self) -> int:
527 + # Not cached: elastic EP rewrites parallel_config in place on reconfigure.
528 + return self.parallel_config.data_parallel_size
529 +
530 + @property
531 + def dp_rank(self) -> int:
532 + # Not cached: elastic EP rewrites parallel_config in place on reconfigure.
533 + return self.parallel_config.data_parallel_rank
534 +
535 def get_model(self) -> nn.Module:
536 return self.model
537
@@ -2337,9 +2343,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
2343 def setup_eplb_from_mapping(
2344 self, expanded_physical_to_logical: torch.Tensor
2345 ) -> None:
2340 - self.eplb.setup_from_mapping(
2341 - self.model, self.model_config, expanded_physical_to_logical
2342 - )
2346 + self.eplb.setup_from_mapping(self.model_config, expanded_physical_to_logical)
2347
2348 ########### EPLB methods end ###########
2349
vllm/v1/worker/gpu/warmup.py
+7 -1
@@ -294,7 +294,11 @@ def _warmup_kernels(
294 model_runner.scheduler_config.max_num_batched_tokens
295 // max(prompt_len, decode_query_len),
296 )
297 - if max_blocks_per_req > 0:
297 + block_tables = getattr(model_runner, "block_tables", None)
298 + null_blocks = block_tables is not None and (
299 + block_tables.redirect_writes_to_null_block
300 + )
301 + if max_blocks_per_req > 0 and not null_blocks:
302 # Reserve block 0 (null block) and ensure we have enough blocks.
303 # Encoder-only models allocate no KV blocks, so this cap doesn't apply.
304 num_reqs = min(
@@ -450,4 +454,6 @@ def _warmup_kernels(
454 cleanup_output.finished_req_ids = set(req_ids)
455 worker_execute_model(cleanup_output)
456 model_runner.kv_connector.set_disabled(False)
457 + if model_runner.kv_block_zeroer is not None:
458 + model_runner.kv_block_zeroer.zero_block_ids([0])
459 torch.accelerator.synchronize()
vllm/v1/worker/gpu_model_runner.py
+17
@@ -4833,6 +4833,23 @@ class GPUModelRunner(
4833 self.input_batch.num_tokens_no_spec[i] = pos + 1
4834 self.input_batch.prev_req_id_to_index = prev_req_id_to_index
4835
4836 + @contextmanager
4837 + def preserve_serving_state(self):
4838 + multi_block_table = self.input_batch.block_table
4839 + saved = [
4840 + (bt.block_table.gpu.clone(), bt.block_table.cpu.clone())
4841 + for bt in multi_block_table.block_tables
4842 + ]
4843 + multi_block_table.clear()
4844 + try:
4845 + yield
4846 + finally:
4847 + for bt, (saved_gpu, saved_cpu) in zip(
4848 + multi_block_table.block_tables, saved
4849 + ):
4850 + bt.block_table.gpu.copy_(saved_gpu)
4851 + bt.block_table.cpu.copy_(saved_cpu)
4852 +
4853 def take_draft_token_ids(self) -> DraftTokenIds | None:
4854 if not self.num_spec_tokens or not self._draft_token_req_ids:
4855 return None