main
py 2,050 lines 82.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import asyncio
4 import contextlib
5 import queue
6 import sys
7 import uuid
8 import weakref
9 from abc import ABC, abstractmethod
10 from collections import Counter, defaultdict
11 from collections.abc import Awaitable, Callable, Sequence
12 from concurrent.futures import Future
13 from dataclasses import dataclass
14 from multiprocessing.connection import Connection
15 from multiprocessing.queues import Queue
16 from threading import Thread
17 from typing import Any, TypeAlias, TypeVar
18
19 import msgspec
20 import msgspec.msgpack
21 import zmq
22 import zmq.asyncio
23
24 from vllm import envs
25 from vllm.config import VllmConfig
26 from vllm.config.kv_events import KVEventsConfig
27 from vllm.envs import VLLM_ENGINE_READY_TIMEOUT_S
28 from vllm.logger import init_logger
29 from vllm.lora.request import LoRARequest
30 from vllm.renderers import BaseRenderer
31 from vllm.tasks import SupportedTask
32 from vllm.tracing import instrument
33 from vllm.utils.async_utils import in_loop
34 from vllm.utils.network_utils import (
35 close_sockets,
36 get_open_zmq_inproc_path,
37 make_zmq_socket,
38 )
39 from vllm.v1.engine import (
40 EEP_NOTIFICATION_CALL_ID,
41 FT_STATUS_CALL_ID,
42 EEPNotificationType,
43 EngineCoreOutputs,
44 EngineCoreReadyResponse,
45 EngineCoreRequest,
46 EngineCoreRequestType,
47 PauseMode,
48 ReconfigureDistributedRequest,
49 ReconfigureRankType,
50 UtilityOutput,
51 )
52 from vllm.v1.engine.coordinator import DPCoordinator
53 from vllm.v1.engine.core import EngineCore, EngineCoreProc
54 from vllm.v1.engine.exceptions import EngineDeadError
55 from vllm.v1.engine.tensor_ipc import TensorIpcSender
56 from vllm.v1.engine.utils import (
57 CoreEngineActorManager,
58 CoreEngineProcManager,
59 get_engine_zmq_addresses,
60 launch_core_engines,
61 )
62 from vllm.v1.executor import Executor
63 from vllm.v1.fault_tolerance.engine_core_sentinel import FT_UTILITY_METHOD
64 from vllm.v1.fault_tolerance.utils import (
65 FaultToleranceRequest,
66 FaultToleranceResult,
67 )
68 from vllm.v1.pool.late_interaction import get_late_interaction_engine_index
69 from vllm.v1.serial_utils import MsgpackDecoder, MsgpackEncoder, bytestr
70
71 logger = init_logger(__name__)
72
73 AnyFuture: TypeAlias = asyncio.Future[Any] | Future[Any]
74
75 _R = TypeVar("_R") # Return type for collective_rpc
76
77 EngineIdentity = bytes
78
79
80 class EngineCoreClient(ABC):
81 """EngineCoreClient: subclasses handle different methods for pushing
82 and pulling from the EngineCore for asyncio / multiprocessing.
83
84 Subclasses:
85 * InprocClient: In process EngineCore (for V0-style LLMEngine use)
86 * SyncMPClient: ZMQ + background proc EngineCore (for LLM)
87 * AsyncMPClient: ZMQ + background proc EngineCore w/ asyncio (for AsyncLLM)
88 """
89
90 def get_kv_event_sources(self) -> dict[int, KVEventsConfig]:
91 """KV-event publisher config of each ready engine, keyed by DP rank."""
92 return {}
93
94 @staticmethod
95 def make_client(
96 multiprocess_mode: bool,
97 asyncio_mode: bool,
98 vllm_config: VllmConfig,
99 executor_class: type[Executor],
100 log_stats: bool,
101 renderer: BaseRenderer | None = None,
102 ) -> "EngineCoreClient":
103 # renderer is passed through to the multiprocess clients, which start
104 # the frontend MM warmup (renderer.start_mm_warmup_in_background) once
105 # the engine-core processes have been forked, so warmup overlaps
106 # engine-core load without a live thread at fork() time. In-process
107 # clients do not take a renderer: the engine core is built in this
108 # process, so there is no other process to overlap with and the MM
109 # warmup runs synchronously inside renderer.warmup() instead.
110 # TODO: support this for debugging purposes.
111 if asyncio_mode and not multiprocess_mode:
112 raise NotImplementedError(
113 "Running EngineCore in asyncio without multiprocessing "
114 "is not currently supported."
115 )
116
117 if multiprocess_mode and asyncio_mode:
118 return EngineCoreClient.make_async_mp_client(
119 vllm_config,
120 executor_class,
121 log_stats,
122 renderer=renderer,
123 )
124
125 if multiprocess_mode and not asyncio_mode:
126 return SyncMPClient(
127 vllm_config,
128 executor_class,
129 log_stats,
130 renderer=renderer,
131 )
132
133 return InprocClient(vllm_config, executor_class, log_stats)
134
135 @staticmethod
136 @instrument(span_name="Overall Loading")
137 def make_async_mp_client(
138 vllm_config: VllmConfig,
139 executor_class: type[Executor],
140 log_stats: bool,
141 client_addresses: dict[str, Any] | None = None,
142 client_count: int = 1,
143 client_index: int = 0,
144 renderer: BaseRenderer | None = None,
145 ) -> "AsyncMPClient":
146 parallel_config = vllm_config.parallel_config
147 client_args = (
148 vllm_config,
149 executor_class,
150 log_stats,
151 client_addresses,
152 client_count,
153 client_index,
154 )
155 if parallel_config.data_parallel_size > 1:
156 if parallel_config.data_parallel_external_lb:
157 # External load balancer - client per DP rank.
158 return DPAsyncMPClient(
159 *client_args,
160 renderer=renderer,
161 )
162 # Internal load balancer - client balances to all DP ranks.
163 return DPLBAsyncMPClient(
164 *client_args,
165 renderer=renderer,
166 )
167 return AsyncMPClient(
168 *client_args,
169 renderer=renderer,
170 )
171
172 @abstractmethod
173 def shutdown(self, timeout: float | None = None) -> None: ...
174
175 def get_output(self) -> EngineCoreOutputs:
176 raise NotImplementedError
177
178 def get_supported_tasks(self) -> tuple[SupportedTask, ...]:
179 raise NotImplementedError
180
181 def add_request(self, request: EngineCoreRequest) -> None:
182 raise NotImplementedError
183
184 def profile(self, is_start: bool = True, profile_prefix: str | None = None) -> None:
185 raise NotImplementedError
186
187 def reset_mm_cache(self) -> None:
188 raise NotImplementedError
189
190 def reset_prefix_cache(
191 self, reset_running_requests: bool = False, reset_connector: bool = False
192 ) -> bool:
193 raise NotImplementedError
194
195 def reset_encoder_cache(self) -> None:
196 raise NotImplementedError
197
198 def sleep(self, level: int = 1, mode: PauseMode = "abort") -> None:
199 raise NotImplementedError
200
201 def release_kv_cache_memory(self) -> None:
202 raise NotImplementedError
203
204 def wake_up(self, tags: list[str] | None = None) -> bool:
205 raise NotImplementedError
206
207 def is_sleeping(self) -> bool:
208 raise NotImplementedError
209
210 def execute_dummy_batch(self) -> None:
211 raise NotImplementedError
212
213 def set_weight_version(self, weight_version: str) -> None:
214 raise NotImplementedError
215
216 def get_weight_version(self) -> str:
217 raise NotImplementedError
218
219 async def execute_dummy_batch_async(self) -> None:
220 raise NotImplementedError
221
222 async def set_weight_version_async(self, weight_version: str) -> None:
223 raise NotImplementedError
224
225 async def get_weight_version_async(self) -> str:
226 raise NotImplementedError
227
228 def abort_requests(self, request_ids: list[str]) -> None:
229 raise NotImplementedError
230
231 def add_lora(self, lora_request: LoRARequest) -> bool:
232 raise NotImplementedError
233
234 def remove_lora(self, lora_id: int) -> bool:
235 raise NotImplementedError
236
237 def list_loras(self) -> set[int]:
238 raise NotImplementedError
239
240 def pin_lora(self, lora_id: int) -> bool:
241 raise NotImplementedError
242
243 def save_sharded_state(
244 self, path: str, pattern: str | None = None, max_size: int | None = None
245 ) -> None:
246 raise NotImplementedError
247
248 def collective_rpc(
249 self,
250 method: str | Callable[..., _R],
251 timeout: float | None = None,
252 args: tuple = (),
253 kwargs: dict[str, Any] | None = None,
254 ) -> list[_R]:
255 raise NotImplementedError
256
257 def dp_engines_running(self) -> bool:
258 """Returns True if data parallel engines are collectively in a
259 running state."""
260 raise NotImplementedError
261
262 async def commit_elastic_ep(self) -> None:
263 raise NotImplementedError
264
265 async def prepare_elastic_ep(self, new_data_parallel_size: int) -> None:
266 raise NotImplementedError
267
268 async def get_output_async(self) -> EngineCoreOutputs:
269 raise NotImplementedError
270
271 async def get_supported_tasks_async(self) -> tuple[SupportedTask, ...]:
272 raise NotImplementedError
273
274 async def add_request_async(self, request: EngineCoreRequest) -> None:
275 raise NotImplementedError
276
277 async def profile_async(
278 self, is_start: bool = True, profile_prefix: str | None = None
279 ) -> None:
280 raise NotImplementedError
281
282 async def reset_mm_cache_async(self) -> None:
283 raise NotImplementedError
284
285 async def reset_prefix_cache_async(
286 self, reset_running_requests: bool = False, reset_connector: bool = False
287 ) -> bool:
288 raise NotImplementedError
289
290 async def reset_encoder_cache_async(self) -> None:
291 raise NotImplementedError
292
293 async def sleep_async(self, level: int = 1, mode: PauseMode = "abort") -> None:
294 raise NotImplementedError
295
296 async def release_kv_cache_memory_async(self) -> None:
297 raise NotImplementedError
298
299 async def wake_up_async(self, tags: list[str] | None = None) -> bool:
300 raise NotImplementedError
301
302 async def is_sleeping_async(self) -> bool:
303 raise NotImplementedError
304
305 async def abort_requests_async(self, request_ids: list[str]) -> None:
306 raise NotImplementedError
307
308 async def add_lora_async(self, lora_request: LoRARequest) -> bool:
309 raise NotImplementedError
310
311 async def remove_lora_async(self, lora_id: int) -> bool:
312 raise NotImplementedError
313
314 async def list_loras_async(self) -> set[int]:
315 raise NotImplementedError
316
317 async def pin_lora_async(self, lora_id: int) -> bool:
318 raise NotImplementedError
319
320 async def save_sharded_state_async(
321 self, path: str, pattern: str | None = None, max_size: int | None = None
322 ) -> None:
323 raise NotImplementedError
324
325 async def collective_rpc_async(
326 self,
327 method: str | Callable[..., _R],
328 timeout: float | None = None,
329 args: tuple = (),
330 kwargs: dict[str, Any] | None = None,
331 ) -> list[_R]:
332 raise NotImplementedError
333
334 async def handle_fault(
335 self, fault_tolerance_request: FaultToleranceRequest
336 ) -> FaultToleranceResult:
337 raise NotImplementedError
338
339 async def get_status(self):
340 raise NotImplementedError
341
342
343 class InprocClient(EngineCoreClient):
344 """InprocClient: client for in-process EngineCore. Intended
345 for use in LLMEngine for V0-style add_request() and step()
346 EngineCore setup in this process (no busy loop).
347
348 * pushes EngineCoreRequest directly into the EngineCore
349 * pulls EngineCoreOutputs by stepping the EngineCore
350 """
351
352 # Takes no renderer: this class has no _start_mm_warmup (only MPClient
353 # does), so a renderer would never be used here. EngineCore is built in
354 # this process and MM warmup runs synchronously inside renderer.warmup().
355 def __init__(
356 self,
357 vllm_config: VllmConfig,
358 executor_class: type[Executor],
359 log_stats: bool,
360 *,
361 executor_fail_callback: Callable | None = None,
362 ):
363 self.engine_core = EngineCore(
364 vllm_config,
365 executor_class,
366 log_stats,
367 executor_fail_callback=executor_fail_callback,
368 )
369 # Release the executor's workers when the client is collected, as
370 # MPClient does; they hold GPU memory for the life of the process
371 # otherwise. The callback must not reference self, or self would stay
372 # reachable and the finalizer would never run.
373 self._finalizer = weakref.finalize(self, self.engine_core.shutdown)
374
375 def get_output(self) -> EngineCoreOutputs:
376 outputs, model_executed = self.engine_core.step_fn()
377 self.engine_core.post_step(model_executed=model_executed)
378 return outputs and outputs.get(0) or EngineCoreOutputs()
379
380 def get_supported_tasks(self) -> tuple[SupportedTask, ...]:
381 return self.engine_core.get_supported_tasks()
382
383 def add_request(self, request: EngineCoreRequest) -> None:
384 req, request_wave = self.engine_core.preprocess_add_request(request)
385 self.engine_core.add_request(req, request_wave)
386
387 def abort_requests(self, request_ids: list[str]) -> None:
388 if len(request_ids) > 0:
389 self.engine_core.abort_requests(request_ids)
390
391 def shutdown(self, timeout: float | None = None) -> None:
392 if self._finalizer.detach() is not None:
393 self.engine_core.shutdown()
394
395 def profile(self, is_start: bool = True, profile_prefix: str | None = None) -> None:
396 self.engine_core.profile(is_start, profile_prefix)
397
398 def reset_mm_cache(self) -> None:
399 self.engine_core.reset_mm_cache()
400
401 def reset_prefix_cache(
402 self, reset_running_requests: bool = False, reset_connector: bool = False
403 ) -> bool:
404 return self.engine_core.reset_prefix_cache(
405 reset_running_requests, reset_connector
406 )
407
408 def reset_encoder_cache(self) -> None:
409 self.engine_core.reset_encoder_cache()
410
411 def sleep(self, level: int = 1, mode: PauseMode = "abort") -> None:
412 if mode == "wait":
413 raise ValueError("'wait' pause mode is not supported in inproc-engine mode")
414 result = self.engine_core.sleep(level, mode)
415 assert result is None
416
417 def release_kv_cache_memory(self) -> None:
418 self.engine_core.release_kv_cache_memory()
419
420 def wake_up(self, tags: list[str] | None = None) -> bool:
421 return self.engine_core.wake_up(tags)
422
423 def is_sleeping(self) -> bool:
424 return self.engine_core.is_sleeping()
425
426 def execute_dummy_batch(self) -> None:
427 self.engine_core.execute_dummy_batch()
428
429 def set_weight_version(self, weight_version: str) -> None:
430 self.engine_core.set_weight_version(weight_version)
431
432 def get_weight_version(self) -> str:
433 return self.engine_core.get_weight_version()
434
435 def add_lora(self, lora_request: LoRARequest) -> bool:
436 return self.engine_core.add_lora(lora_request)
437
438 def remove_lora(self, lora_id: int) -> bool:
439 return self.engine_core.remove_lora(lora_id)
440
441 def list_loras(self) -> set[int]:
442 return self.engine_core.list_loras()
443
444 def pin_lora(self, lora_id: int) -> bool:
445 return self.engine_core.pin_lora(lora_id)
446
447 def save_sharded_state(
448 self, path: str, pattern: str | None = None, max_size: int | None = None
449 ) -> None:
450 self.engine_core.save_sharded_state(path, pattern, max_size)
451
452 def collective_rpc(
453 self,
454 method: str | Callable[..., _R],
455 timeout: float | None = None,
456 args: tuple = (),
457 kwargs: dict[str, Any] | None = None,
458 ) -> list[_R]:
459 return self.engine_core.collective_rpc(method, timeout, args, kwargs)
460
461 def dp_engines_running(self) -> bool:
462 return False
463
464
465 @dataclass
466 class BackgroundResources:
467 """Used as a finalizer for clean shutdown, avoiding
468 circular reference back to the client object."""
469
470 ctx: zmq.Context
471 # If CoreEngineProcManager, it manages local engines;
472 # if CoreEngineActorManager, it manages all engines.
473 engine_manager: CoreEngineProcManager | CoreEngineActorManager | None = None
474 coordinator: DPCoordinator | None = None
475 output_socket: zmq.Socket | zmq.asyncio.Socket | None = None
476 input_socket: zmq.Socket | zmq.asyncio.Socket | None = None
477 first_req_send_socket: zmq.asyncio.Socket | None = None
478 first_req_rcv_socket: zmq.asyncio.Socket | None = None
479 stats_update_socket: zmq.asyncio.Socket | None = None
480 output_queue_task: asyncio.Task | None = None
481 stats_update_task: asyncio.Task | None = None
482 shutdown_path: str | None = None
483
484 # Set if any of the engines are dead. Here so that the output
485 # processing threads can access it without holding a ref to the client.
486 engine_dead: bool = False
487
488 def __call__(self):
489 """Clean up background resources."""
490 logger.debug_once("[shutdown] MPClient: background resource cleanup start")
491 self.engine_dead = True
492 if self.engine_manager is not None:
493 self.engine_manager.shutdown(
494 timeout=envs.VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS
495 )
496 if self.coordinator is not None:
497 self.coordinator.shutdown()
498
499 if isinstance(self.output_socket, zmq.asyncio.Socket):
500 # Async case.
501 loop = self.output_queue_task._loop if self.output_queue_task else None
502
503 sockets = (
504 self.output_socket,
505 self.input_socket,
506 self.first_req_send_socket,
507 self.first_req_rcv_socket,
508 self.stats_update_socket,
509 )
510
511 tasks = (self.output_queue_task, self.stats_update_task)
512
513 def close_sockets_and_tasks():
514 close_sockets(sockets)
515 for task in tasks:
516 if task is not None and not task.done():
517 with contextlib.suppress(Exception):
518 task.cancel()
519
520 if loop is not None:
521 if in_loop(loop):
522 close_sockets_and_tasks()
523 elif not loop.is_closed():
524 loop.call_soon_threadsafe(close_sockets_and_tasks)
525 else:
526 # Loop has been closed, try to clean up directly.
527 del tasks
528 del close_sockets_and_tasks
529 close_sockets(sockets)
530 del self.output_queue_task
531 del self.stats_update_task
532 else:
533 # Sync case.
534
535 # ZMQ context termination can hang if the sockets
536 # aren't explicitly closed first.
537 close_sockets((self.output_socket, self.input_socket))
538
539 if self.shutdown_path is not None:
540 # We must ensure that the sync output socket is
541 # closed cleanly in its own thread.
542 with self.ctx.socket(zmq.PAIR) as shutdown_sender:
543 shutdown_sender.connect(self.shutdown_path)
544 # Send shutdown signal.
545 shutdown_sender.send(b"")
546
547 logger.debug_once("[shutdown] MPClient: background resource cleanup complete")
548
549 def validate_alive(self, frames: Sequence[zmq.Frame]):
550 if len(frames) == 1 and (frames[0].buffer == EngineCoreProc.ENGINE_CORE_DEAD):
551 self.engine_dead = True
552 raise EngineDeadError()
553
554
555 @dataclass
556 class ElasticScalingCache:
557 existing_core_engines: list[EngineIdentity]
558 num_new_core_engines: int
559 pending_notifications: dict[EEPNotificationType, set[int]]
560
561
562 class MPClient(EngineCoreClient):
563 """MPClient: base client for multi-proc EngineCore.
564 EngineCore runs in a background process busy loop, getting
565 new EngineCoreRequests and returning EngineCoreOutputs
566
567 * pushes EngineCoreRequests via input_socket
568 * pulls EngineCoreOutputs via output_socket
569
570 * AsyncMPClient subclass for AsyncLLM usage
571 * SyncMPClient subclass for LLM usage
572 """
573
574 def __init__(
575 self,
576 asyncio_mode: bool,
577 vllm_config: VllmConfig,
578 executor_class: type[Executor],
579 log_stats: bool,
580 client_addresses: dict[str, Any] | None = None,
581 renderer: BaseRenderer | None = None,
582 ):
583 self.vllm_config = vllm_config
584 self._renderer: BaseRenderer | None = renderer
585 self._effective_attention_block_sizes: set[int | None] = set()
586 self._kv_event_sources: dict[int, KVEventsConfig] = {}
587
588 # ZMQ setup.
589 sync_ctx = zmq.Context(io_threads=2)
590 self.ctx = zmq.asyncio.Context(sync_ctx) if asyncio_mode else sync_ctx
591
592 # This will ensure resources created so far are closed
593 # when the client is garbage collected, even if an
594 # exception is raised mid-construction.
595 self.resources = BackgroundResources(ctx=sync_ctx)
596 self._finalizer = weakref.finalize(self, self.resources)
597 success = False
598 try:
599 # State used for data parallel.
600 self.engines_running = False
601 parallel_config = vllm_config.parallel_config
602 # Elastic EP can remove a rank and later add it back with the same
603 # identity. The client input ROUTER needs handover to allow the new
604 # engine to replace the dead connection.
605 enable_input_socket_handover = parallel_config.enable_elastic_ep
606
607 self.stats_update_address: str | None = None
608 tensor_queue: Queue | None = None
609 if client_addresses:
610 # Engines are managed externally to this client.
611 input_address = client_addresses["input_address"]
612 output_address = client_addresses["output_address"]
613 self.stats_update_address = client_addresses.get("stats_update_address")
614 # Tensor queues passed via client_addresses for multi-API-server case
615 tensor_queue = client_addresses.get("tensor_queue")
616 self.input_socket = self.resources.input_socket = make_zmq_socket(
617 self.ctx,
618 input_address,
619 zmq.ROUTER,
620 bind=True,
621 router_handover=enable_input_socket_handover,
622 )
623 self.resources.output_socket = make_zmq_socket(
624 self.ctx, output_address, zmq.PULL
625 )
626
627 # Report bound endpoints back so the parent can forward
628 # them to engines (mirrors the DPCoordinator pattern).
629 actual_address_pipe: Connection | None = client_addresses.get(
630 "actual_address_pipe"
631 )
632 if actual_address_pipe is not None:
633 try:
634 actual_input = self.input_socket.getsockopt(
635 zmq.LAST_ENDPOINT
636 ).decode()
637 actual_output = self.resources.output_socket.getsockopt(
638 zmq.LAST_ENDPOINT
639 ).decode()
640 actual_address_pipe.send(
641 {
642 "input_address": actual_input,
643 "output_address": actual_output,
644 }
645 )
646 finally:
647 actual_address_pipe.close()
648 # Engines are managed externally: this process does not fork
649 # them, so there is no fork race; start the MM warmup now.
650 self._start_mm_warmup()
651 else:
652 # Engines are managed by this client.
653 addresses = get_engine_zmq_addresses(vllm_config)
654 self.input_socket = self.resources.input_socket = make_zmq_socket(
655 self.ctx,
656 addresses.inputs[0],
657 zmq.ROUTER,
658 bind=True,
659 router_handover=enable_input_socket_handover,
660 )
661 self.resources.output_socket = make_zmq_socket(
662 self.ctx, addresses.outputs[0], zmq.PULL
663 )
664
665 # Resolve ``tcp://host:0`` placeholders to bound endpoints
666 # before engines DEALER-connect. No-op for IPC.
667 addresses.inputs[0] = self.input_socket.getsockopt(
668 zmq.LAST_ENDPOINT
669 ).decode()
670 addresses.outputs[0] = self.resources.output_socket.getsockopt(
671 zmq.LAST_ENDPOINT
672 ).decode()
673
674 with launch_core_engines(
675 vllm_config, executor_class, log_stats, addresses
676 ) as engine_launch:
677 self.resources.coordinator = engine_launch.coordinator
678 self.resources.engine_manager = engine_launch.engine_manager
679 coordinator = engine_launch.coordinator
680 addresses = engine_launch.addresses
681 tensor_queue = engine_launch.tensor_queue
682 # Engine-core processes have now all been forked/started
683 # (CoreEngineProcManager.proc.start()). It is now safe to
684 # launch the frontend background MM warmup: it must not
685 # run while fork() is in flight (a live thread holding a
686 # lock would deadlock the forked child), but the
687 # engine-core model load (minutes) that follows is exactly
688 # what the warmup should overlap with.
689 self._start_mm_warmup()
690
691 self.stats_update_address = addresses.frontend_stats_publish_address
692 if coordinator is not None:
693 assert self.stats_update_address == (
694 coordinator.get_stats_publish_address()
695 )
696
697 # Serialization setup with tensor queues for multimodal tensor IPC.
698 tensor_ipc_sender: TensorIpcSender | None = None
699 model_config = getattr(vllm_config, "model_config", None)
700 if model_config is not None and model_config.multimodal_config is not None:
701 mm_tensor_ipc = model_config.multimodal_config.mm_tensor_ipc
702 if mm_tensor_ipc == "torch_shm" and tensor_queue is not None:
703 tensor_ipc_sender = TensorIpcSender(tensor_queue)
704
705 self.encoder = MsgpackEncoder(oob_tensor_consumer=tensor_ipc_sender)
706 self.decoder = MsgpackDecoder(EngineCoreOutputs)
707
708 dp_size = parallel_config.data_parallel_size
709 dp_rank = parallel_config.data_parallel_index
710 dp_local_size = parallel_config.data_parallel_size_local
711 offline_mode = parallel_config.data_parallel_rank_local is not None
712 # Client manages local+remote EngineCores in pure internal LB case.
713 # Client manages local EngineCores in hybrid and external LB case.
714 num_ranks = dp_local_size if parallel_config.local_engines_only else dp_size
715 self.engine_ranks_managed = (
716 [dp_rank] if offline_mode else list(range(dp_rank, dp_rank + num_ranks))
717 )
718 assert parallel_config.data_parallel_size_local <= len(
719 self.engine_ranks_managed
720 )
721
722 # ZMQ identity of each engine that this client will talk to.
723 self.core_engines: list[EngineIdentity] = [
724 rank.to_bytes(2, "little") for rank in self.engine_ranks_managed
725 ]
726
727 # Wait for ready messages from each engine on the input socket.
728 identities = set(self.core_engines)
729 sync_input_socket = zmq.Socket.shadow(self.input_socket)
730 while identities:
731 if not sync_input_socket.poll(
732 timeout=VLLM_ENGINE_READY_TIMEOUT_S * 1000 # convert to ms
733 ):
734 raise TimeoutError(
735 f"Timed out waiting for engine core processes to "
736 f"start. This is often caused by slow weight loading "
737 f"for large models. Waited "
738 f"{VLLM_ENGINE_READY_TIMEOUT_S}s (configured by "
739 f"VLLM_ENGINE_READY_TIMEOUT_S). To increase the "
740 f"timeout, set the environment variable: "
741 f"VLLM_ENGINE_READY_TIMEOUT_S=<seconds>"
742 )
743 identity, payload = sync_input_socket.recv_multipart()
744 identities.remove(identity)
745 self._apply_ready_response(payload)
746
747 self.core_engine: EngineIdentity = self.core_engines[0]
748 self.utility_results: dict[int, AnyFuture] = {}
749
750 # Start monitoring engine core processes for unexpected failures
751 self.start_engine_core_monitor()
752
753 success = True
754 finally:
755 if not success:
756 self._finalizer()
757
758 def shutdown(self, timeout: float | None = None) -> None:
759 """Shutdown engine manager under timeout and clean up resources."""
760 if self._finalizer.detach() is not None:
761 timeout_str = "default" if timeout is None else f"{timeout}s"
762 logger.info("[shutdown] MPClient: start timeout=%s", timeout_str)
763 if self.resources.engine_manager is not None:
764 logger.info_once("[shutdown] MPClient: stopping engine manager")
765 self.resources.engine_manager.shutdown(timeout=timeout)
766 logger.info_once("[shutdown] MPClient: engine manager stopped")
767 logger.info_once("[shutdown] MPClient: cleaning up background resources")
768 self.resources()
769 logger.info_once("[shutdown] MPClient: complete")
770
771 def _format_exception(self, e: Exception) -> Exception:
772 """If errored, use EngineDeadError so root cause is clear."""
773 return (
774 EngineDeadError(suppress_context=True) if self.resources.engine_dead else e
775 )
776
777 def ensure_alive(self):
778 if self.resources.engine_dead:
779 raise EngineDeadError()
780
781 def dp_engines_running(self) -> bool:
782 return self.engines_running
783
784 def _start_mm_warmup(self) -> None:
785 # Called once the engine-core process(es) have been created (forked or
786 # externally managed). Overlap the frontend MM warmup with the
787 # engine-core model load. This is a no-op when no renderer was passed
788 # (e.g. text-only serving or tests).
789 if self._renderer is not None:
790 self._renderer.start_mm_warmup_in_background()
791
792 def start_engine_core_monitor(self):
793 """Start a monitor thread for engine core processes."""
794 engine_manager = self.resources.engine_manager
795 if engine_manager is None:
796 # No engine processes to monitor
797 return
798
799 self_ref = weakref.ref(self)
800
801 # Monitor engine core process liveness. If any die unexpectedly,
802 # marks the engine as dead, and shuts down the client.
803 def monitor_engine_cores():
804 engine_manager.monitor_engine_liveness()
805 _self = self_ref()
806 if not _self or not _self._finalizer.alive or _self.resources.engine_dead:
807 return
808 _self.resources.engine_dead = True
809 logger.warning_once(
810 "[shutdown] MPClient: engine core exited unexpectedly; starting cleanup"
811 )
812 _self.shutdown()
813 # Note: For MPClient, we don't have a failure callback mechanism
814 # like MultiprocExecutor, but we set engine_dead flag which will
815 # cause subsequent operations to raise EngineDeadError
816
817 Thread(
818 target=monitor_engine_cores, daemon=True, name="MPClientEngineMonitor"
819 ).start()
820
821 def _apply_ready_response(self, payload: bytes) -> None:
822 """Decode an EngineCoreReadyResponse and sync any post-initialization
823 config changes (e.g. auto-fitted max_model_len) back to the frontend."""
824 vllm_config = self.vllm_config
825 response = (
826 msgspec.msgpack.decode(payload, type=EngineCoreReadyResponse)
827 if payload
828 else None
829 )
830 self._effective_attention_block_sizes.add(
831 response.effective_attention_block_size if response is not None else None
832 )
833 sizes = self._effective_attention_block_sizes
834 vllm_config.cache_config.effective_attention_block_size = (
835 next(iter(sizes)) if len(sizes) == 1 else None
836 )
837 if response is None:
838 return
839 vllm_config.model_config.max_model_len = min(
840 vllm_config.model_config.max_model_len, response.max_model_len
841 )
842
843 # Setup KV cache config with initialization state from
844 # engine core process. Sum num_gpu_blocks from all engines in DP case.
845 num_gpu_blocks = vllm_config.cache_config.num_gpu_blocks or 0
846 num_gpu_blocks += response.num_gpu_blocks
847 vllm_config.cache_config.num_gpu_blocks = num_gpu_blocks
848
849 # Sync block_size: may be enlarged by _align_hybrid_block_size in the
850 # worker for hybrid Mamba models.
851 cache_config = vllm_config.cache_config
852 cache_config.block_size = response.block_size
853 cache_config.mamba_block_size = response.mamba_block_size
854 # Keep these as per-engine cache_config_info values; do not sum across DP.
855 cache_config.kv_cache_size_tokens = (
856 getattr(cache_config, "kv_cache_size_tokens", None)
857 if getattr(cache_config, "kv_cache_size_tokens", None) is not None
858 else response.kv_cache_size_tokens
859 )
860 cache_config.kv_cache_max_concurrency = (
861 getattr(cache_config, "kv_cache_max_concurrency", None)
862 if getattr(cache_config, "kv_cache_max_concurrency", None) is not None
863 else response.kv_cache_max_concurrency
864 )
865
866 # In external DP LB mode, the coordinator address that the
867 # front-end procs connect to is obtained by each engine via it's
868 # initial handshake with the rank 0 front-end.
869 if response.dp_stats_address is not None:
870 if self.stats_update_address is None:
871 self.stats_update_address = response.dp_stats_address
872 else:
873 assert response.dp_stats_address == self.stats_update_address
874
875 if response.kv_events_config is not None:
876 self._kv_event_sources[response.data_parallel_rank] = (
877 response.kv_events_config
878 )
879
880 def get_kv_event_sources(self) -> dict[int, KVEventsConfig]:
881 return dict(self._kv_event_sources)
882
883
884 def _process_utility_output(
885 output: UtilityOutput, utility_results: dict[int, AnyFuture]
886 ):
887 """Set the result from a utility method in the waiting future."""
888 future = utility_results.pop(output.call_id)
889 failure_message = output.failure_message
890 try:
891 if failure_message is not None:
892 future.set_exception(Exception(failure_message))
893 else:
894 assert output.result is not None
895 future.set_result(output.result.result)
896 except asyncio.InvalidStateError:
897 # This can happen if the future is cancelled due to the
898 # original calling task being cancelled.
899 if failure_message is not None:
900 logger.error(
901 "Cancelled call to utility method failed with error: %s",
902 failure_message,
903 )
904
905
906 class SyncMPClient(MPClient):
907 """Synchronous client for multi-proc EngineCore."""
908
909 @instrument(span_name="SyncMPClient init")
910 def __init__(
911 self,
912 vllm_config: VllmConfig,
913 executor_class: type[Executor],
914 log_stats: bool,
915 renderer: BaseRenderer | None = None,
916 ):
917 super().__init__(
918 asyncio_mode=False,
919 vllm_config=vllm_config,
920 executor_class=executor_class,
921 log_stats=log_stats,
922 renderer=renderer,
923 )
924
925 self.is_dp = self.vllm_config.parallel_config.data_parallel_size > 1
926 self.outputs_queue = queue.Queue[EngineCoreOutputs | Exception]()
927
928 # Ensure that the outputs socket processing thread does not have
929 # a ref to the client which prevents gc.
930 ctx = self.ctx
931 out_socket = self.resources.output_socket
932 decoder = self.decoder
933 utility_results = self.utility_results
934 outputs_queue = self.outputs_queue
935
936 shutdown_path = get_open_zmq_inproc_path()
937 resources = self.resources
938 resources.shutdown_path = shutdown_path
939
940 def process_outputs_socket():
941 assert isinstance(out_socket, zmq.Socket)
942 shutdown_socket = ctx.socket(zmq.PAIR)
943 try:
944 shutdown_socket.bind(shutdown_path)
945 poller = zmq.Poller()
946 poller.register(shutdown_socket, zmq.POLLIN)
947 poller.register(out_socket, zmq.POLLIN)
948 while True:
949 socks = poller.poll()
950 if not socks:
951 continue
952 if len(socks) == 2 or socks[0][0] == shutdown_socket:
953 # shutdown signal, exit thread.
954 break
955
956 frames = out_socket.recv_multipart(copy=False)
957 resources.validate_alive(frames)
958 outputs: EngineCoreOutputs = decoder.decode(frames)
959 if outputs.utility_output:
960 _process_utility_output(outputs.utility_output, utility_results)
961 else:
962 outputs_queue.put_nowait(outputs)
963 except Exception as e:
964 outputs_queue.put_nowait(e)
965 finally:
966 # Close sockets.
967 shutdown_socket.close(linger=0)
968 out_socket.close(linger=0)
969
970 # Process outputs from engine in separate thread.
971 self.output_queue_thread = Thread(
972 target=process_outputs_socket,
973 name="EngineCoreOutputQueueThread",
974 daemon=True,
975 )
976 self.output_queue_thread.start()
977
978 # The thread takes on responsibility for closing the socket.
979 self.resources.output_socket = None
980
981 def get_output(self) -> EngineCoreOutputs:
982 # If an exception arises in process_outputs_socket task,
983 # it is forwarded to the outputs_queue so we can raise it
984 # from this (run_output_handler) task to shut down the server.
985 outputs = self.outputs_queue.get()
986
987 if isinstance(outputs, Exception):
988 raise self._format_exception(outputs) from None
989 if outputs.wave_complete is not None:
990 self.engines_running = False
991 return outputs
992
993 def _send_input(self, request_type: EngineCoreRequestType, request: Any):
994 self.ensure_alive()
995 # (Identity, RequestType, SerializedRequest)
996 msg = (self.core_engine, request_type.value, *self.encoder.encode(request))
997 # Any zero-copy tensor/ndarray frames are kept alive by zmq itself
998 # until it's finished sending them (there is a ref chain from the underlying
999 # memoryview back to the original owning tensor/ndarray).
1000 self.input_socket.send_multipart(msg, copy=False)
1001
1002 def call_utility(self, method: str, *args) -> Any:
1003 call_id = uuid.uuid1().int >> 64
1004 future: Future[Any] = Future()
1005 self.utility_results[call_id] = future
1006 self._send_input(EngineCoreRequestType.UTILITY, (0, call_id, method, args))
1007
1008 return future.result()
1009
1010 def get_supported_tasks(self) -> tuple[SupportedTask, ...]:
1011 return self.call_utility("get_supported_tasks")
1012
1013 def add_request(self, request: EngineCoreRequest) -> None:
1014 if self.is_dp:
1015 self.engines_running = True
1016 self._send_input(EngineCoreRequestType.ADD, request)
1017
1018 def abort_requests(self, request_ids: list[str]) -> None:
1019 if request_ids and not self.resources.engine_dead:
1020 self._send_input(EngineCoreRequestType.ABORT, request_ids)
1021
1022 def profile(self, is_start: bool = True, profile_prefix: str | None = None) -> None:
1023 self.call_utility("profile", is_start, profile_prefix)
1024
1025 def reset_mm_cache(self) -> None:
1026 self.call_utility("reset_mm_cache")
1027
1028 def reset_prefix_cache(
1029 self, reset_running_requests: bool = False, reset_connector: bool = False
1030 ) -> bool:
1031 return self.call_utility(
1032 "reset_prefix_cache", reset_running_requests, reset_connector
1033 )
1034
1035 def reset_encoder_cache(self) -> None:
1036 self.call_utility("reset_encoder_cache")
1037
1038 def add_lora(self, lora_request: LoRARequest) -> bool:
1039 return self.call_utility("add_lora", lora_request)
1040
1041 def remove_lora(self, lora_id: int) -> bool:
1042 return self.call_utility("remove_lora", lora_id)
1043
1044 def list_loras(self) -> set[int]:
1045 return self.call_utility("list_loras")
1046
1047 def pin_lora(self, lora_id: int) -> bool:
1048 return self.call_utility("pin_lora", lora_id)
1049
1050 def sleep(self, level: int = 1, mode: PauseMode = "abort") -> None:
1051 self.call_utility("sleep", level, mode)
1052
1053 def release_kv_cache_memory(self) -> None:
1054 self.call_utility("release_kv_cache_memory")
1055
1056 def wake_up(self, tags: list[str] | None = None) -> bool:
1057 return self.call_utility("wake_up", tags)
1058
1059 def is_sleeping(self) -> bool:
1060 return self.call_utility("is_sleeping")
1061
1062 def execute_dummy_batch(self) -> None:
1063 self.call_utility("execute_dummy_batch")
1064
1065 def set_weight_version(self, weight_version: str) -> None:
1066 self.call_utility("set_weight_version", weight_version)
1067
1068 def get_weight_version(self) -> str:
1069 return self.call_utility("get_weight_version")
1070
1071 def collective_rpc(
1072 self,
1073 method: str | Callable[..., _R],
1074 timeout: float | None = None,
1075 args: tuple = (),
1076 kwargs: dict[str, Any] | None = None,
1077 ) -> list[_R]:
1078 return self.call_utility("collective_rpc", method, timeout, args, kwargs)
1079
1080 def save_sharded_state(
1081 self, path: str, pattern: str | None = None, max_size: int | None = None
1082 ) -> None:
1083 self.call_utility("save_sharded_state", path, pattern, max_size)
1084
1085
1086 class AsyncMPClient(MPClient):
1087 """Asyncio-compatible client for multi-proc EngineCore."""
1088
1089 @instrument(span_name="AsyncMPClient init")
1090 def __init__(
1091 self,
1092 vllm_config: VllmConfig,
1093 executor_class: type[Executor],
1094 log_stats: bool,
1095 client_addresses: dict[str, Any] | None = None,
1096 client_count: int = 1,
1097 client_index: int = 0,
1098 renderer: BaseRenderer | None = None,
1099 ):
1100 super().__init__(
1101 asyncio_mode=True,
1102 vllm_config=vllm_config,
1103 executor_class=executor_class,
1104 log_stats=log_stats,
1105 client_addresses=client_addresses,
1106 renderer=renderer,
1107 )
1108
1109 self.client_count = client_count
1110 self.client_index = client_index
1111 self.outputs_queue = asyncio.Queue[EngineCoreOutputs | Exception]()
1112
1113 # locally-cached engine status
1114 self._engine_status: dict[int, dict] = {}
1115 if self.vllm_config.parallel_config.enable_fault_tolerance:
1116 self._engine_status = {
1117 rank: {"id": rank, "status": "healthy"}
1118 for rank in self.engine_ranks_managed
1119 }
1120 try:
1121 # If we are running in an asyncio event loop, start the queue task.
1122 # Otherwise, it will be started lazily. If it is not started here,
1123 # we could miss EXECUTOR_FAILED messages from engine core if they
1124 # occur prior to any requests being sent.
1125 asyncio.get_running_loop()
1126 self._ensure_output_queue_task()
1127 except RuntimeError:
1128 pass
1129
1130 def _ensure_output_queue_task(self):
1131 resources = self.resources
1132 if resources.output_queue_task is not None:
1133 return
1134
1135 # Perform IO in separate task to parallelize as much as possible.
1136 # Avoid task having direct reference back to the client.
1137 decoder = self.decoder
1138 utility_results = self.utility_results
1139 outputs_queue = self.outputs_queue
1140 output_handler: (
1141 Callable[[AsyncMPClient, EngineCoreOutputs], Awaitable[None]] | None
1142 ) = getattr(self.__class__, "process_engine_outputs", None)
1143 _self_ref = weakref.ref(self)
1144 output_socket = resources.output_socket
1145 assert output_socket is not None
1146
1147 notification_callback_handler: (
1148 Callable[[AsyncMPClient, Sequence[Any]], Any] | None
1149 ) = getattr(self.__class__, "eep_process_engine_core_notification", None)
1150
1151 async def process_outputs_socket():
1152 try:
1153 while True:
1154 frames = await output_socket.recv_multipart(copy=False)
1155 resources.validate_alive(frames)
1156 outputs: EngineCoreOutputs = decoder.decode(frames)
1157 if outputs.utility_output:
1158 if (
1159 outputs.utility_output.call_id == EEP_NOTIFICATION_CALL_ID
1160 and notification_callback_handler is not None
1161 ):
1162 assert _self_ref is not None
1163 _self = _self_ref()
1164 if not _self:
1165 return
1166 if outputs.utility_output.result is None:
1167 continue
1168 notification_data = outputs.utility_output.result.result
1169 assert isinstance(notification_data, Sequence)
1170 assert len(notification_data) == 2
1171 asyncio.create_task(
1172 notification_callback_handler(_self, notification_data)
1173 )
1174 elif outputs.utility_output.call_id == FT_STATUS_CALL_ID:
1175 _self = _self_ref()
1176 if not _self:
1177 return
1178 if outputs.utility_output.result is not None:
1179 _self._engine_status[outputs.engine_index] = (
1180 outputs.utility_output.result.result
1181 )
1182 else:
1183 _process_utility_output(
1184 outputs.utility_output, utility_results
1185 )
1186 continue
1187
1188 if output_handler is not None:
1189 assert _self_ref is not None
1190 _self = _self_ref()
1191 if not _self:
1192 # Client has been garbage collected, abort.
1193 return
1194 await output_handler(_self, outputs)
1195
1196 if outputs.outputs or outputs.scheduler_stats:
1197 outputs_queue.put_nowait(outputs)
1198 except Exception as e:
1199 outputs_queue.put_nowait(e)
1200 except asyncio.CancelledError:
1201 outputs_queue.put_nowait(EngineDeadError())
1202
1203 resources.output_queue_task = asyncio.create_task(
1204 process_outputs_socket(), name="EngineCoreOutputQueueTask"
1205 )
1206
1207 async def get_output_async(self) -> EngineCoreOutputs:
1208 self._ensure_output_queue_task()
1209 # If an exception arises in process_outputs_socket task,
1210 # it is forwarded to the outputs_queue so we can raise it
1211 # from this (run_output_handler) task to shut down the server.
1212 assert self.outputs_queue is not None
1213 outputs = await self.outputs_queue.get()
1214 if isinstance(outputs, Exception):
1215 raise self._format_exception(outputs) from None
1216 return outputs
1217
1218 def _send_input(
1219 self,
1220 request_type: EngineCoreRequestType,
1221 request: Any,
1222 engine: EngineIdentity | None = None,
1223 ) -> Awaitable[Any]:
1224 if engine is None:
1225 engine = self.core_engine
1226
1227 message = (request_type.value, *self.encoder.encode(request))
1228 return self._send_input_message(message, engine)
1229
1230 def _send_input_message(
1231 self, message: tuple[bytestr, ...], engine: EngineIdentity
1232 ) -> Awaitable[Any]:
1233 self.ensure_alive()
1234 # Any zero-copy tensor/ndarray frames are kept alive by zmq itself
1235 # until it's finished sending them (there is a ref chain from the underlying
1236 # memoryview back to the original owning tensor/ndarray).
1237 return self.input_socket.send_multipart((engine,) + message, copy=False)
1238
1239 async def call_utility_async(self, method: str, *args) -> Any:
1240 return await self._call_utility_async(method, *args, engine=self.core_engine)
1241
1242 async def call_utility_all_async(self, method: str, *args) -> list[Any]:
1243 # Like the Rust client's call_utility: one result per managed engine.
1244 return await asyncio.gather(
1245 *[
1246 self._call_utility_async(method, *args, engine=engine)
1247 for engine in self.core_engines
1248 ]
1249 )
1250
1251 async def call_utility_consensus_async(self, method: str, *args) -> Any:
1252 results = await self.call_utility_all_async(method, *args)
1253 if any(result != results[0] for result in results):
1254 raise RuntimeError(
1255 f"Engines returned different {method} results: {results}"
1256 )
1257 return results[0]
1258
1259 async def _call_utility_async(
1260 self, method: str, *args, engine: EngineIdentity
1261 ) -> Any:
1262 call_id = uuid.uuid1().int >> 64
1263 future = asyncio.get_running_loop().create_future()
1264 self.utility_results[call_id] = future
1265 message = (
1266 EngineCoreRequestType.UTILITY.value,
1267 *self.encoder.encode((self.client_index, call_id, method, args)),
1268 )
1269 await self._send_input_message(message, engine)
1270 self._ensure_output_queue_task()
1271 return await future
1272
1273 async def get_supported_tasks_async(self) -> tuple[SupportedTask, ...]:
1274 return await self.call_utility_async("get_supported_tasks")
1275
1276 async def add_request_async(self, request: EngineCoreRequest) -> None:
1277 request.client_index = self.client_index
1278 await self._send_input(EngineCoreRequestType.ADD, request)
1279 self._ensure_output_queue_task()
1280
1281 async def abort_requests_async(self, request_ids: list[str]) -> None:
1282 if request_ids and not self.resources.engine_dead:
1283 await self._send_input(EngineCoreRequestType.ABORT, request_ids)
1284
1285 async def pause_scheduler_async(
1286 self, mode: PauseMode = "abort", clear_cache: bool = True
1287 ) -> None:
1288 await self.call_utility_async("pause_scheduler", mode, clear_cache)
1289
1290 async def resume_scheduler_async(self) -> None:
1291 await self.call_utility_async("resume_scheduler")
1292
1293 async def is_scheduler_paused_async(self) -> bool:
1294 return await self.call_utility_consensus_async("is_scheduler_paused")
1295
1296 async def profile_async(
1297 self, is_start: bool = True, profile_prefix: str | None = None
1298 ) -> None:
1299 await self.call_utility_async("profile", is_start, profile_prefix)
1300
1301 async def reset_mm_cache_async(self) -> None:
1302 await self.call_utility_async("reset_mm_cache")
1303
1304 async def reset_prefix_cache_async(
1305 self, reset_running_requests: bool = False, reset_connector: bool = False
1306 ) -> bool:
1307 return all(
1308 await self.call_utility_all_async(
1309 "reset_prefix_cache", reset_running_requests, reset_connector
1310 )
1311 )
1312
1313 async def reset_encoder_cache_async(self) -> None:
1314 await self.call_utility_async("reset_encoder_cache")
1315
1316 async def sleep_async(self, level: int = 1, mode: PauseMode = "abort") -> None:
1317 await self.call_utility_async("sleep", level, mode)
1318
1319 async def release_kv_cache_memory_async(self) -> None:
1320 await self.call_utility_async("release_kv_cache_memory")
1321
1322 async def wake_up_async(self, tags: list[str] | None = None) -> bool:
1323 return all(await self.call_utility_all_async("wake_up", tags))
1324
1325 async def is_sleeping_async(self) -> bool:
1326 return await self.call_utility_consensus_async("is_sleeping")
1327
1328 async def execute_dummy_batch_async(self) -> None:
1329 await self.call_utility_async("execute_dummy_batch")
1330
1331 async def set_weight_version_async(self, weight_version: str) -> None:
1332 await self.call_utility_async("set_weight_version", weight_version)
1333
1334 async def get_weight_version_async(self) -> str:
1335 return await self.call_utility_consensus_async("get_weight_version")
1336
1337 async def add_lora_async(self, lora_request: LoRARequest) -> bool:
1338 return await self.call_utility_async("add_lora", lora_request)
1339
1340 async def remove_lora_async(self, lora_id: int) -> bool:
1341 return await self.call_utility_async("remove_lora", lora_id)
1342
1343 async def list_loras_async(self) -> set[int]:
1344 return await self.call_utility_async("list_loras")
1345
1346 async def pin_lora_async(self, lora_id: int) -> bool:
1347 return await self.call_utility_async("pin_lora", lora_id)
1348
1349 async def save_sharded_state_async(
1350 self, path: str, pattern: str | None = None, max_size: int | None = None
1351 ) -> None:
1352 await self.call_utility_async("save_sharded_state", path, pattern, max_size)
1353
1354 async def collective_rpc_async(
1355 self,
1356 method: str | Callable[..., _R],
1357 timeout: float | None = None,
1358 args: tuple = (),
1359 kwargs: dict[str, Any] | None = None,
1360 ) -> list[_R]:
1361 return await self.call_utility_async(
1362 "collective_rpc", method, timeout, args, kwargs
1363 )
1364
1365 async def compute_weight_checksums_async(self) -> list[dict[str, str]]:
1366 per_engine = await self.call_utility_all_async("compute_weight_checksums")
1367 return [worker for workers in per_engine for worker in workers]
1368
1369 async def handle_fault(
1370 self, ft_request: FaultToleranceRequest
1371 ) -> FaultToleranceResult:
1372 res = await self.call_utility_async(FT_UTILITY_METHOD, ft_request)
1373 result = msgspec.convert(res, FaultToleranceResult)
1374 if not result.success:
1375 status = self._engine_status.get(self.engine_ranks_managed[0])
1376 if status is not None:
1377 status["last_ft_request_id"] = result.request_id
1378 status["ft_error"] = result.reason
1379 return result
1380
1381 async def get_status(self):
1382 return {
1383 "schema_version": 1,
1384 "total_engines": len(self.engine_ranks_managed),
1385 "engines": list(self._engine_status.values()),
1386 }
1387
1388
1389 class DPAsyncMPClient(AsyncMPClient):
1390 """Asyncio-compatible client for multi-proc, multi-engine (data parallel)
1391 EngineCore. Assumes external load-balancing by default."""
1392
1393 def __init__(
1394 self,
1395 vllm_config: VllmConfig,
1396 executor_class: type[Executor],
1397 log_stats: bool,
1398 client_addresses: dict[str, Any] | None = None,
1399 client_count: int = 1,
1400 client_index: int = 0,
1401 renderer: BaseRenderer | None = None,
1402 ):
1403 self.current_wave = 0
1404
1405 super().__init__(
1406 vllm_config,
1407 executor_class,
1408 log_stats,
1409 client_addresses,
1410 client_count,
1411 client_index,
1412 renderer,
1413 )
1414
1415 # List of [waiting, running, kv_cache_usage] per engine.
1416 # Used only by DPLBAsyncMPClient subclass.
1417 self.lb_engines: list[list[int | float]] = [
1418 [0, 0, 0.0] for _ in self.core_engines
1419 ]
1420
1421 self.eep_scaling_cache: ElasticScalingCache | None = None
1422
1423 self.first_req_sock_addr = get_open_zmq_inproc_path()
1424 self.first_req_send_socket = self.resources.first_req_send_socket = (
1425 make_zmq_socket(self.ctx, self.first_req_sock_addr, zmq.PAIR, bind=True)
1426 )
1427 try:
1428 # If we are running in an asyncio event loop, start the stats task.
1429 # Otherwise, it will be started lazily.
1430 asyncio.get_running_loop()
1431 self._ensure_stats_update_task()
1432 except RuntimeError:
1433 pass
1434
1435 def _ensure_stats_update_task(self):
1436 resources = self.resources
1437 if resources.stats_update_task is not None:
1438 return
1439
1440 assert self.stats_update_address is not None
1441 stats_addr: str = self.stats_update_address
1442 assert len(self.engine_ranks_managed) > 0
1443
1444 async def run_engine_stats_update_task():
1445 with (
1446 make_zmq_socket(self.ctx, stats_addr, zmq.XSUB, linger=0) as socket,
1447 make_zmq_socket(
1448 self.ctx, self.first_req_sock_addr, zmq.PAIR, bind=False, linger=0
1449 ) as first_req_rcv_socket,
1450 ):
1451 assert isinstance(socket, zmq.asyncio.Socket)
1452 assert isinstance(first_req_rcv_socket, zmq.asyncio.Socket)
1453 self.resources.stats_update_socket = socket
1454 self.resources.first_req_rcv_socket = first_req_rcv_socket
1455 # Send subscription message.
1456 await socket.send(b"\x01")
1457
1458 poller = zmq.asyncio.Poller()
1459 poller.register(socket, zmq.POLLIN)
1460 poller.register(first_req_rcv_socket, zmq.POLLIN)
1461
1462 while True:
1463 events = await poller.poll()
1464 if (
1465 not self.engines_running
1466 and len(events) == 2
1467 or (events[0][0] == first_req_rcv_socket)
1468 ):
1469 # Check if this is a regular request notification or
1470 # scale up notification
1471 buf = first_req_rcv_socket.recv(flags=zmq.NOBLOCK).result()
1472
1473 decoded = msgspec.msgpack.decode(buf)
1474 if (
1475 isinstance(decoded, (list, tuple))
1476 and len(decoded) == 2
1477 and decoded[0] == "SCALE_ELASTIC_EP"
1478 ):
1479 # Extract new engine count from the decoded message
1480 new_engine_count = decoded[1]
1481 # Update engine_ranks_managed and count_slice
1482 parallel_config = self.vllm_config.parallel_config
1483 dp_size = parallel_config.data_parallel_size
1484 dp_rank = parallel_config.data_parallel_rank
1485 assert dp_rank == 0
1486 assert dp_size == new_engine_count
1487 assert not (
1488 parallel_config.data_parallel_hybrid_lb
1489 or parallel_config.data_parallel_external_lb
1490 )
1491 num_ranks = dp_size
1492 self.engine_ranks_managed = list(
1493 range(dp_rank, dp_rank + num_ranks)
1494 )
1495 if len(self.lb_engines) < new_engine_count:
1496 self.lb_engines = self.lb_engines + [
1497 [0, 0, 0.0]
1498 for _ in range(
1499 new_engine_count - len(self.lb_engines)
1500 )
1501 ]
1502 else:
1503 self.lb_engines = self.lb_engines[:new_engine_count]
1504 # Send scale up notification to coordinator
1505 scale_msg = msgspec.msgpack.encode(
1506 ("SCALE_ELASTIC_EP", new_engine_count)
1507 )
1508 await socket.send(scale_msg)
1509 continue
1510
1511 # we're sending a request while the engines are
1512 # paused, so that it can wake the others up
1513 # (to run dummy EP loop).
1514 assert decoded[0] == "FIRST_REQ"
1515 target_eng_index = decoded[1]
1516 self.engines_running = True
1517 msg = msgspec.msgpack.encode(
1518 (target_eng_index, self.current_wave)
1519 )
1520 await socket.send(msg)
1521
1522 buf = None
1523 while True:
1524 # Drain all stats events (we only care about latest).
1525 future: asyncio.Future[bytes] = socket.recv(flags=zmq.NOBLOCK)
1526 if isinstance(future.exception(), zmq.Again):
1527 break
1528 buf = future.result()
1529 if buf is None:
1530 continue
1531
1532 # Update local load-balancing state.
1533 counts, wave, running = msgspec.msgpack.decode(buf)
1534 self.current_wave = wave
1535 self.engines_running = running
1536 if counts is not None:
1537 # Running and waiting counts are global from the
1538 # Coordinator including all EngineCores. Slice to get
1539 # just the cores managed by this client.
1540 ranks = self.engine_ranks_managed
1541 count_slice = slice(ranks[0], ranks[-1] + 1)
1542 sliced_counts = counts[count_slice]
1543 self.lb_engines = sliced_counts
1544 logger.debug(
1545 "Received counts: %s (%s)", sliced_counts, count_slice
1546 )
1547
1548 resources.stats_update_task = asyncio.create_task(
1549 run_engine_stats_update_task()
1550 )
1551
1552 async def add_request_async(self, request: EngineCoreRequest) -> None:
1553 self._ensure_stats_update_task()
1554
1555 request.current_wave = self.current_wave
1556 request.client_index = self.client_index
1557
1558 chosen_engine = self.get_core_engine_for_request(request)
1559 to_await = self._send_input(EngineCoreRequestType.ADD, request, chosen_engine)
1560 if not self.engines_running:
1561 # Notify coordinator that we're sending a request
1562 req_msg = msgspec.msgpack.encode(("FIRST_REQ", chosen_engine))
1563 await self.first_req_send_socket.send(req_msg)
1564
1565 await to_await
1566
1567 self._ensure_output_queue_task()
1568
1569 def get_core_engine_for_request(self, request: EngineCoreRequest):
1570 return self.core_engine
1571
1572
1573 class DPLBAsyncMPClient(DPAsyncMPClient):
1574 """Asyncio-compatible client for multi-proc, multi-engine (data parallel)
1575 EngineCore. Load-balances between multiple engine processes."""
1576
1577 def __init__(
1578 self,
1579 vllm_config: VllmConfig,
1580 executor_class: type[Executor],
1581 log_stats: bool,
1582 client_addresses: dict[str, Any] | None = None,
1583 client_count: int = 1,
1584 client_index: int = 0,
1585 renderer: BaseRenderer | None = None,
1586 ):
1587 self.client_count = client_count
1588
1589 # To route aborts to the correct engine.
1590 self.reqs_in_flight: dict[str, EngineIdentity] = {}
1591
1592 # Exact per-engine count of this client's unfinished requests.
1593 self.engine_inflight: Counter[EngineIdentity] = Counter()
1594
1595 super().__init__(
1596 vllm_config,
1597 executor_class,
1598 log_stats,
1599 client_addresses,
1600 client_count,
1601 client_index,
1602 renderer,
1603 )
1604
1605 assert len(self.core_engines) > 1
1606 self._prepared_elastic_ep: tuple[int, int] | None = None
1607
1608 self.eng_start_index = (
1609 len(self.core_engines) * self.client_index
1610 ) // client_count
1611
1612 def get_core_engine_for_request(self, request: EngineCoreRequest) -> EngineIdentity:
1613 if (engine := self.reqs_in_flight.get(request.request_id)) is not None:
1614 # This is for streaming-input session affinity.
1615 return engine
1616
1617 # Engines are in rank order.
1618 if (eng_index := request.data_parallel_rank) is None and (
1619 eng_index := get_late_interaction_engine_index(
1620 request.pooling_params, len(self.core_engines)
1621 )
1622 ) is None:
1623 current_counts = self.lb_engines
1624 # TODO use P2C alg for larger DP sizes
1625 num_engines = len(current_counts)
1626 min_score: float = sys.maxsize
1627 eng_index = 0
1628 for i in range(num_engines):
1629 # Start from client_index to help with balancing when engines
1630 # are empty.
1631 idx = (self.eng_start_index + i) % num_engines
1632 waiting, running, kv_cache_usage = current_counts[idx]
1633 # Estimate engine load as the greater of the coordinator's
1634 # latest (waiting + running) snapshot and this client's own
1635 # in-flight count (scaled by the number of clients). The
1636 # in-flight floor is exact and can't be erased by a snapshot
1637 # rebind, so a burst spreads round-robin even when snapshots
1638 # race with routing decisions; the snapshot raises the score
1639 # when other clients or stale requests load the engine.
1640 inflight = self.engine_inflight[self.core_engines[idx]]
1641 score: float = max(self.client_count * inflight, waiting + running)
1642 if waiting:
1643 # Waiting requests are penalized in proportion to KV cache
1644 # pressure: a queue on a KV-bound engine drains slowly, so
1645 # new requests should strongly prefer other engines. With
1646 # low KV usage the queue is transient (e.g. mid-burst) and
1647 # the penalty stays off, preserving exact round-robin.
1648 # Ramps from 0 at <=50% usage to 3x waiting at 100%.
1649 score += waiting * 6.0 * max(0.0, kv_cache_usage - 0.5)
1650 if score < min_score:
1651 min_score = score
1652 eng_index = idx
1653 # Increment local waiting count for better balancing between stats
1654 # updates from the coordinator (which happen every 100ms).
1655 current_counts[eng_index][0] += self.client_count
1656 # Rotate the scan start so that ties (equal scores, e.g. right
1657 # after a coordinator stats reset when engines look equally loaded)
1658 # don't systematically favor the same engine. This removes the
1659 # fixed tie-break bias without affecting load-aware decisions when
1660 # scores actually differ.
1661 self.eng_start_index = (self.eng_start_index + 1) % num_engines
1662
1663 chosen_engine = self.core_engines[eng_index]
1664 # Record which engine is chosen for this request, to handle aborts.
1665 self.reqs_in_flight[request.request_id] = chosen_engine
1666 self.engine_inflight[chosen_engine] += 1
1667 return chosen_engine
1668
1669 async def call_utility_async(self, method: str, *args) -> Any:
1670 # Only the result from the first engine is returned.
1671 return (await self.call_utility_all_async(method, *args))[0]
1672
1673 @staticmethod
1674 async def process_engine_outputs(
1675 self: "DPLBAsyncMPClient", outputs: EngineCoreOutputs
1676 ):
1677 if outputs.finished_requests and self.reqs_in_flight:
1678 for req_id in outputs.finished_requests:
1679 if (engine := self.reqs_in_flight.pop(req_id, None)) is not None:
1680 self.engine_inflight[engine] -= 1
1681
1682 @staticmethod
1683 async def eep_process_engine_core_notification(
1684 self: "DPLBAsyncMPClient", notification_data: tuple[str, int]
1685 ):
1686 cache = self.eep_scaling_cache
1687 notification_type_str, dp_rank = notification_data
1688 try:
1689 notification_type = EEPNotificationType(notification_type_str)
1690 except ValueError as e:
1691 raise ValueError(
1692 f"Unknown EEP notification type: {notification_type_str}"
1693 ) from e
1694
1695 if notification_type == EEPNotificationType.RECONFIGURE_FINISHED:
1696 from vllm.v1.engine import UtilityResult
1697
1698 # NOTE(yongji): process a dummy UtilityOutput to resolve the future
1699 # awaited in _eep_wait_for_setup_switch_complete(), signaling that
1700 # all engine cores have completed reconfiguration.
1701 dummy_output = UtilityOutput(
1702 call_id=EEP_NOTIFICATION_CALL_ID, result=UtilityResult(None)
1703 )
1704 _process_utility_output(dummy_output, self.utility_results)
1705 return
1706 assert cache is not None
1707 if notification_type not in cache.pending_notifications:
1708 cache.pending_notifications[notification_type] = set()
1709 if dp_rank in cache.pending_notifications[notification_type]:
1710 raise ValueError(
1711 f"Duplicate notification {notification_type} from dp_rank {dp_rank}"
1712 )
1713 cache.pending_notifications[notification_type].add(dp_rank)
1714 if len(cache.pending_notifications[notification_type]) >= abs(
1715 cache.num_new_core_engines
1716 ):
1717 engine_manager = self.resources.engine_manager
1718 assert isinstance(engine_manager, CoreEngineActorManager)
1719 assert cache.num_new_core_engines < 0
1720 old_dp_size = len(cache.existing_core_engines)
1721 new_dp_size = old_dp_size + cache.num_new_core_engines
1722 engine_manager.scale_down_elastic_ep(old_dp_size, new_dp_size)
1723 self.vllm_config.parallel_config.data_parallel_size_local = len(
1724 engine_manager.local_engine_actors
1725 )
1726 self.eep_scaling_cache = None
1727
1728 async def abort_requests_async(self, request_ids: list[str]) -> None:
1729 if not request_ids or self.resources.engine_dead:
1730 return
1731
1732 if len(request_ids) == 1:
1733 # Fast-path common case.
1734 if engine := self.reqs_in_flight.get(request_ids[0]):
1735 await self._abort_requests(request_ids, engine)
1736 return
1737
1738 by_engine = defaultdict[EngineIdentity, list[str]](list)
1739 for req_id in request_ids:
1740 if engine := self.reqs_in_flight.get(req_id):
1741 by_engine[engine].append(req_id)
1742 for engine, req_ids in by_engine.items():
1743 await self._abort_requests(req_ids, engine)
1744
1745 async def _abort_requests(
1746 self, request_ids: list[str], engine: EngineIdentity
1747 ) -> None:
1748 await self._send_input(EngineCoreRequestType.ABORT, request_ids, engine)
1749
1750 async def commit_elastic_ep(self) -> None:
1751 """Commit prepared elastic EP scaling."""
1752 prepared = self._prepared_elastic_ep
1753 if prepared is None:
1754 raise RuntimeError("Elastic EP scaling has not been prepared")
1755 new_data_parallel_size, num_redundant_experts = prepared
1756 cur_data_parallel_size = len(self.core_engines)
1757 if new_data_parallel_size > cur_data_parallel_size:
1758 await self._commit_scale_up_elastic_ep(new_data_parallel_size)
1759 else:
1760 await self._commit_scale_down_elastic_ep(new_data_parallel_size)
1761 self.vllm_config.parallel_config.eplb_config.num_redundant_experts = (
1762 num_redundant_experts
1763 )
1764 self._prepared_elastic_ep = None
1765
1766 async def prepare_elastic_ep(self, new_data_parallel_size: int) -> None:
1767 """Prepare elastic EP scaling without routing requests to new engines."""
1768 if (prepared := self._prepared_elastic_ep) is not None:
1769 if prepared[0] == new_data_parallel_size:
1770 return
1771 raise RuntimeError("Elastic EP scaling is already prepared")
1772 cur_data_parallel_size = len(self.core_engines)
1773 assert self.vllm_config.parallel_config.data_parallel_backend == "ray", (
1774 "Only ray DP backend supports scaling elastic EP"
1775 )
1776 parallel_config = self.vllm_config.parallel_config
1777 if new_data_parallel_size > parallel_config.elastic_ep_max_dp_size:
1778 raise ValueError(
1779 f"Cannot scale to data_parallel_size {new_data_parallel_size}; "
1780 "--elastic-ep-max-dp-size is "
1781 f"{parallel_config.elastic_ep_max_dp_size}."
1782 )
1783 num_experts = self.vllm_config.model_config.get_num_experts()
1784 num_physical_experts = (
1785 num_experts + parallel_config.eplb_config.num_redundant_experts
1786 )
1787 num_redundant_experts = (
1788 num_physical_experts * new_data_parallel_size // cur_data_parallel_size
1789 - num_experts
1790 )
1791 if num_redundant_experts < 0:
1792 # Scaling keeps physical experts per engine fixed, so below this
1793 # size the logical experts no longer fit.
1794 raise ValueError(
1795 f"Cannot scale to data_parallel_size {new_data_parallel_size}, "
1796 f"minimum is "
1797 f"{-(-num_experts * cur_data_parallel_size // num_physical_experts)}"
1798 )
1799 if new_data_parallel_size < cur_data_parallel_size:
1800 await self._prepare_scale_down_elastic_ep(new_data_parallel_size)
1801 else:
1802 await self._prepare_scale_up_elastic_ep(
1803 new_data_parallel_size, num_redundant_experts
1804 )
1805 self._prepared_elastic_ep = new_data_parallel_size, num_redundant_experts
1806
1807 def _eep_wait_for_setup_switch_complete(self) -> asyncio.Future:
1808 """Wait for core engines to switch to the new setup.
1809
1810 In eep_process_engine_core_notification(), a dummy UtilityOutput with
1811 EEP_NOTIFICATION_CALL_ID will be set when RECONFIGURE_FINISHED
1812 notification is received from engine 0. We create a future with
1813 that call_id and wait for it to be resolved.
1814 """
1815 future = asyncio.get_running_loop().create_future()
1816 self.utility_results[EEP_NOTIFICATION_CALL_ID] = future
1817 self._ensure_output_queue_task()
1818 return future
1819
1820 def _wait_for_new_engine_ready(self, new_core_engines: list[bytes]) -> None:
1821 new_engine_identities = set(new_core_engines)
1822 sync_input_socket = zmq.Socket.shadow(self.input_socket)
1823 while new_engine_identities:
1824 if not sync_input_socket.poll(timeout=VLLM_ENGINE_READY_TIMEOUT_S * 1000):
1825 raise TimeoutError(
1826 f"Timed out waiting for new engine core processes to "
1827 f"start. Waited "
1828 f"{VLLM_ENGINE_READY_TIMEOUT_S}s (configured by "
1829 f"VLLM_ENGINE_READY_TIMEOUT_S). To increase the "
1830 f"timeout, set the environment variable: "
1831 f"VLLM_ENGINE_READY_TIMEOUT_S=<seconds>"
1832 )
1833 identity, payload = sync_input_socket.recv_multipart()
1834 new_engine_identities.discard(identity)
1835 self._apply_ready_response(payload)
1836
1837 def _setup_elastic_ep_reconfig_bootstrap(self) -> None:
1838 from vllm.distributed.utils import create_tcp_store
1839 from vllm.utils.network_utils import get_open_ports_list
1840
1841 parallel_config = self.vllm_config.parallel_config
1842 parallel_config._data_parallel_master_port_list = get_open_ports_list(5)
1843 parallel_config.data_parallel_master_port = (
1844 parallel_config._data_parallel_master_port_list.pop()
1845 )
1846
1847 ip = parallel_config.data_parallel_master_ip
1848 store = create_tcp_store(
1849 ip,
1850 0,
1851 is_master=True,
1852 world_size=-1,
1853 wait_for_workers=False,
1854 )
1855 parallel_config._coord_store_port = store.port
1856 self._coord_store = store
1857
1858 def _make_reconfig_request(
1859 self,
1860 new_data_parallel_size: int,
1861 rank_type: ReconfigureRankType = ReconfigureRankType.KEEP_CURRENT_RANK,
1862 ) -> ReconfigureDistributedRequest:
1863 parallel_config = self.vllm_config.parallel_config
1864 return ReconfigureDistributedRequest(
1865 new_data_parallel_size=new_data_parallel_size,
1866 new_data_parallel_rank=rank_type,
1867 new_data_parallel_rank_local=ReconfigureRankType.KEEP_CURRENT_RANK,
1868 new_data_parallel_master_ip=parallel_config.data_parallel_master_ip,
1869 new_data_parallel_master_port=parallel_config.data_parallel_master_port,
1870 new_data_parallel_master_port_list=parallel_config._data_parallel_master_port_list,
1871 coord_store_port=parallel_config._coord_store_port,
1872 )
1873
1874 async def _prepare_scale_up_elastic_ep(
1875 self,
1876 new_data_parallel_size: int,
1877 num_redundant_experts: int,
1878 ) -> None:
1879 """Prepare scale up by creating new engine cores and reconfiguring
1880 existing ones."""
1881 self._setup_elastic_ep_reconfig_bootstrap()
1882
1883 # Phase 1: Send reconfig messages to existing engines
1884 reconfig_futures = []
1885 for engine in self.core_engines:
1886 reconfig_request = self._make_reconfig_request(new_data_parallel_size)
1887 coro = self._call_utility_async(
1888 "reinitialize_distributed", reconfig_request, engine=engine
1889 )
1890 reconfig_futures.append(asyncio.create_task(coro))
1891
1892 # Phase 2: Create new engines
1893 assert isinstance(self.resources.engine_manager, CoreEngineActorManager)
1894 start_new_worker_future = asyncio.to_thread(
1895 self.resources.engine_manager.scale_up_elastic_ep,
1896 self.vllm_config,
1897 new_data_parallel_size,
1898 num_redundant_experts,
1899 )
1900
1901 # Phase 3: Wait for new engines to be created
1902 # and reconfig messages to be received
1903 await asyncio.gather(start_new_worker_future, *reconfig_futures)
1904 ready_keys = [future.result() for future in reconfig_futures]
1905 ready_keys.extend(
1906 f"eep_ready/{rank}"
1907 for rank in range(len(self.core_engines), new_data_parallel_size)
1908 )
1909 await asyncio.to_thread(self._coord_store.wait, ready_keys)
1910 logger.info("[Elastic EP] Successfully started new engines")
1911
1912 def _eep_commit_pause_mode(self) -> PauseMode:
1913 from vllm.distributed.elastic_ep.elastic_execute import (
1914 can_reuse_fused_moe_kernel,
1915 )
1916
1917 # MRV2 re-warms through the request pool, so running requests must
1918 # finish first while new ones stay queued in the scheduler.
1919 parallel_config = self.vllm_config.parallel_config
1920 if self.vllm_config.use_v2_model_runner and not can_reuse_fused_moe_kernel(
1921 parallel_config
1922 ):
1923 return "wait"
1924 return "keep"
1925
1926 async def _commit_scale_up_elastic_ep(self, new_data_parallel_size: int) -> None:
1927 new_core_engines = [
1928 rank.to_bytes(2, "little")
1929 for rank in range(len(self.core_engines), new_data_parallel_size)
1930 ]
1931
1932 await self.pause_scheduler_async(
1933 mode=self._eep_commit_pause_mode(), clear_cache=False
1934 )
1935 wait_future = self._eep_wait_for_setup_switch_complete()
1936 finish_futures = [
1937 asyncio.create_task(
1938 self._call_utility_async("commit_prepared_elastic_ep", engine=engine)
1939 )
1940 for engine in self.core_engines
1941 ]
1942 try:
1943 await asyncio.gather(*finish_futures)
1944 await wait_future
1945 self._wait_for_new_engine_ready(new_core_engines)
1946 except Exception:
1947 wait_future.cancel()
1948 raise
1949
1950 self.core_engines.extend(new_core_engines)
1951 # Update the parallel config
1952 parallel_config = self.vllm_config.parallel_config
1953 parallel_config.data_parallel_size = new_data_parallel_size
1954 if isinstance(self.resources.engine_manager, CoreEngineActorManager):
1955 parallel_config.data_parallel_size_local = len(
1956 self.resources.engine_manager.local_engine_actors
1957 )
1958 # Notify coordinator about scale up through existing
1959 # stats_update_task connection
1960 self._ensure_stats_update_task()
1961 scale_up_marker = msgspec.msgpack.encode(
1962 ("SCALE_ELASTIC_EP", new_data_parallel_size)
1963 )
1964 await self.first_req_send_socket.send(scale_up_marker)
1965
1966 logger.info(
1967 "[Elastic EP] Scale up completed, new data parallel size: %s",
1968 new_data_parallel_size,
1969 )
1970 await self.resume_scheduler_async()
1971
1972 async def _prepare_scale_down_elastic_ep(self, new_data_parallel_size: int) -> None:
1973 self._setup_elastic_ep_reconfig_bootstrap()
1974
1975 reconfig_futures = []
1976 for engine in self.core_engines[:new_data_parallel_size]:
1977 reconfig_request = self._make_reconfig_request(new_data_parallel_size)
1978 coro = self._call_utility_async(
1979 "reinitialize_distributed", reconfig_request, engine=engine
1980 )
1981 reconfig_futures.append(asyncio.create_task(coro))
1982
1983 ready_keys = await asyncio.gather(*reconfig_futures)
1984 await asyncio.to_thread(self._coord_store.wait, ready_keys)
1985
1986 async def _commit_scale_down_elastic_ep(self, new_data_parallel_size: int) -> None:
1987 """Scale down the data parallel size by shutting down and
1988 reconfiguring existing engine cores."""
1989 cur_data_parallel_size = len(self.core_engines)
1990
1991 self.eep_scaling_cache = ElasticScalingCache(
1992 existing_core_engines=self.core_engines.copy(),
1993 num_new_core_engines=new_data_parallel_size - cur_data_parallel_size,
1994 pending_notifications=dict(),
1995 )
1996
1997 old_core_engines = self.core_engines
1998 # NOTE(yongji): Immediately stop sending requests to the removing engines.
1999 self.core_engines = old_core_engines[:new_data_parallel_size]
2000 self.lb_engines = self.lb_engines[:new_data_parallel_size]
2001 # Pending coordinator snapshots must use the surviving ranks too.
2002 self.engine_ranks_managed = self.engine_ranks_managed[:new_data_parallel_size]
2003 for rank in range(new_data_parallel_size, cur_data_parallel_size):
2004 self._kv_event_sources.pop(rank, None)
2005 removed_dp_size = cur_data_parallel_size - new_data_parallel_size
2006 eep_mode = self._eep_commit_pause_mode()
2007 pause_modes = [eep_mode] * new_data_parallel_size + ["abort"] * removed_dp_size
2008 pause_futures = [
2009 self._call_utility_async("pause_scheduler", mode, False, engine=engine)
2010 for mode, engine in zip(pause_modes, old_core_engines)
2011 ]
2012 await asyncio.gather(*pause_futures)
2013 assert isinstance(self.resources.engine_manager, CoreEngineActorManager)
2014 self.resources.engine_manager.remove_run_refs_for_scale_down(removed_dp_size)
2015 wait_future = self._eep_wait_for_setup_switch_complete()
2016 reconfig_futures = []
2017 for cur_dp_rank, engine in enumerate(old_core_engines):
2018 if cur_dp_rank < new_data_parallel_size:
2019 coro = self._call_utility_async(
2020 "commit_prepared_elastic_ep", engine=engine
2021 )
2022 else:
2023 reconfig_request = self._make_reconfig_request(
2024 new_data_parallel_size,
2025 ReconfigureRankType.SHUTDOWN_CURRENT_RANK,
2026 )
2027 coro = self._call_utility_async(
2028 "reinitialize_distributed", reconfig_request, engine=engine
2029 )
2030 reconfig_futures.append(asyncio.create_task(coro))
2031
2032 try:
2033 await asyncio.gather(*reconfig_futures)
2034
2035 self.vllm_config.parallel_config.data_parallel_size = new_data_parallel_size
2036 self._ensure_stats_update_task()
2037 scale_down_marker = msgspec.msgpack.encode(
2038 ("SCALE_ELASTIC_EP", new_data_parallel_size)
2039 )
2040 await self.first_req_send_socket.send(scale_down_marker)
2041 await wait_future
2042 await self.resume_scheduler_async()
2043 except Exception:
2044 wait_future.cancel()
2045 raise
2046
2047 logger.info(
2048 "[Elastic EP] Scale down completed, new data parallel size: %s",
2049 new_data_parallel_size,
2050 )