#!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from __future__ import annotations import argparse import asyncio import contextlib import copy import multiprocessing import os import signal import time from functools import partial from http import HTTPStatus from multiprocessing.process import BaseProcess import aiohttp import psutil import uvicorn import uvloop from fastapi import FastAPI, Response import vllm.envs as envs from vllm.logger import init_logger from vllm.utils.system_utils import ( decorate_logs, kill_process_tree, set_process_title, ) logger = init_logger(__name__) CHILD_EXIT_GRACE_S = 5.0 def infer_multi_port_external_lb_start_rank(args: argparse.Namespace) -> int: start_rank = getattr(args, "data_parallel_start_rank", None) if start_rank is not None: return start_rank node_rank = getattr(args, "node_rank", 0) or 0 local_size = getattr(args, "data_parallel_size_local", 0) or 0 return node_rank * local_size def validate_multi_port_external_lb_args(args: argparse.Namespace) -> None: if getattr(args, "grpc", False): raise ValueError( "Error: --data-parallel-multi-port-external-lb does not support --grpc" ) if args.uds is not None: raise ValueError( "Error: --data-parallel-multi-port-external-lb does not support --uds" ) if bool(args.ssl_keyfile) != bool(args.ssl_certfile): raise ValueError( "Error: --ssl-keyfile and --ssl-certfile must be provided together" ) if args.api_server_count not in (None, 1): raise ValueError( "Error: --data-parallel-multi-port-external-lb currently requires " "--api-server-count=1" ) if args.data_parallel_rank is not None: raise ValueError( "Error: --data-parallel-multi-port-external-lb manages child " "--data-parallel-rank values internally" ) if args.data_parallel_external_lb or args.data_parallel_hybrid_lb: raise ValueError( "Error: --data-parallel-multi-port-external-lb cannot be combined with " "--data-parallel-external-lb or --data-parallel-hybrid-lb" ) if args.data_parallel_size < 2: raise ValueError( "Error: --data-parallel-multi-port-external-lb requires " "--data-parallel-size > 1" ) local_size = args.data_parallel_size_local if local_size is None or local_size < 2: raise ValueError( "Error: --data-parallel-multi-port-external-lb requires " "--data-parallel-size-local >= 2" ) if local_size > args.data_parallel_size: raise ValueError( "Error: --data-parallel-size-local cannot exceed --data-parallel-size" ) if args.data_parallel_size % local_size != 0: raise ValueError( "Error: --data-parallel-size must be divisible by " "--data-parallel-size-local" ) start_rank = infer_multi_port_external_lb_start_rank(args) if start_rank + local_size > args.data_parallel_size: raise ValueError( "Error: multi-port supervised ranks would exceed --data-parallel-size" ) supervisor_port = args.data_parallel_supervisor_port child_port_min = args.port child_port_max = args.port + local_size - 1 if child_port_min <= supervisor_port <= child_port_max: raise ValueError( f"Error: --data-parallel-supervisor-port {supervisor_port} " f"overlaps with child rank ports {child_port_min}-{child_port_max}" ) def _build_vllm_dp_server_args( args: argparse.Namespace, local_rank: int ) -> argparse.Namespace: child_args = copy.copy(args) child_args.port = args.port + local_rank child_args.data_parallel_rank = ( infer_multi_port_external_lb_start_rank(args) + local_rank ) child_args.data_parallel_start_rank = None child_args.data_parallel_size_local = 1 child_args.data_parallel_external_lb = True child_args.data_parallel_hybrid_lb = False child_args.data_parallel_multi_port_external_lb = False child_args.data_parallel_supervisor_port = None child_args.api_server_count = 1 child_args.device_ids = _build_device_ids(args, local_rank) return child_args def _build_device_ids(args: argparse.Namespace, local_rank: int) -> list[int | str]: """Build the --device-ids value for a DP child process. The child resolves these against its own inherited device-control env var (e.g. CUDA_VISIBLE_DEVICES), so integer IDs must stay env-relative here rather than being translated to physical IDs. """ devices_per_rank = args.tensor_parallel_size * args.pipeline_parallel_size start = local_rank * devices_per_rank stop = start + devices_per_rank device_ids = getattr(args, "device_ids", None) if device_ids is not None: if stop > len(device_ids): raise ValueError( f"--device-ids has {len(device_ids)} entries, but DP rank " f"{local_rank} needs devices [{start}, {stop})" ) return device_ids[start:stop] return list(range(start, stop)) def _child_base_url(args: argparse.Namespace, port: int) -> str: host = args.host or "127.0.0.1" if host == "0.0.0.0": host = "127.0.0.1" elif host == "::": host = "::1" scheme = "https" if args.ssl_keyfile and args.ssl_certfile else "http" return f"{scheme}://{host}:{port}" def _join_processes_with_timeout(processes: list[BaseProcess], timeout: float) -> None: deadline = time.monotonic() + timeout for process in processes: remaining = deadline - time.monotonic() if remaining <= 0: break process.join(timeout=remaining) async def _probe_endpoint( session: aiohttp.ClientSession, args: argparse.Namespace, port: int, path: str, conn_err_failure_threshold: int = 3, conn_err_retry_delay: float = 5.0, ) -> bool: """ Probe /health endpoint for 200 status. If there is a connection error, retry every N seconds. """ for iteration in range(conn_err_failure_threshold): try: probe_ssl = None if args.ssl_keyfile and args.ssl_certfile: # Probes target node-local child servers over loopback, so skip # certificate verification to avoid SAN/hostname mismatches for # localhost/127.0.0.1 deployments. probe_ssl = False async with session.get( _child_base_url(args, port) + path, ssl=probe_ssl ) as response: # vLLM returns 503 on EngineDeadError, so we should return # immediately if vLLM responds with a non-200 status code. return response.status == HTTPStatus.OK except (aiohttp.ClientError, asyncio.TimeoutError) as e: # Allow retry of connection errors. logger.debug( "Probe attempt %d/%d failed on port %d: %r", iteration + 1, conn_err_failure_threshold, port, e, ) if iteration < conn_err_failure_threshold - 1: await asyncio.sleep(conn_err_retry_delay) return False def _build_dp_supervisor_app(supervisor: DPSupervisor) -> FastAPI: app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None) app.state.supervisor = supervisor def _status_response(ok: bool) -> Response: return Response( status_code=(HTTPStatus.OK if ok else HTTPStatus.SERVICE_UNAVAILABLE) ) @app.get("/health", include_in_schema=False) async def health() -> Response: return _status_response(app.state.supervisor.is_ready) @app.get("/ready", include_in_schema=False) @app.get("/readyz", include_in_schema=False) async def ready() -> Response: return _status_response(app.state.supervisor.is_ready) return app def _run_python_vllm_dp_server(child_args: argparse.Namespace) -> None: from vllm.entrypoints.openai.api_server import run_server uvloop.run(run_server(child_args)) def _run_rust_vllm_dp_server(child_args: argparse.Namespace) -> None: from vllm.entrypoints.cli.serve import run_multi_api_server run_multi_api_server(child_args) def _run_vllm_dp_server(child_args: argparse.Namespace) -> None: """ Entrypoint function for the vLLM DP Server. """ # Create a fresh process group for the vLLM DP Server, # so that CTRL-C is propagated cleanly. os.setpgrp() name = f"APIServer_DP{child_args.data_parallel_rank}" set_process_title(name) decorate_logs(name) if envs.VLLM_RUST_FRONTEND_PATH: _run_rust_vllm_dp_server(child_args) else: _run_python_vllm_dp_server(child_args) class DPSupervisor: def __init__(self, args: argparse.Namespace): validate_multi_port_external_lb_args(args) self.args = args self.supervisor_port = args.data_parallel_supervisor_port self.child_ports = [ args.port + local_rank for local_rank in range(args.data_parallel_size_local) ] self._is_ready = False self._processes: list[BaseProcess] = [] self._shutdown_event = asyncio.Event() self._shutdown_signal = signal.SIGTERM @property def is_ready(self) -> bool: return self._is_ready and not self._shutdown_event.is_set() async def run(self) -> None: loop = asyncio.get_running_loop() decorate_logs("DPSupervisor") # K8s sends SIGTERM for shutdown - begin graceful termination. for sig in (signal.SIGTERM, signal.SIGINT): loop.add_signal_handler(sig, partial(self._handle_signal, sig)) supervisor_server: uvicorn.Server | None = None supervisor_server_task: asyncio.Task[None] | None = None try: # Launch vLLM DP Servers and begin monitoring them. self._start_children() monitor_task = asyncio.create_task( self._monitor_children(), name="dp-monitor" ) # Only start the DPSupervisor server once the vLLM DP Servers are # ready. This avoids the supervisor /health endpoint returning 503 # to external load balancer probes while the engines initialize. await self._wait_until_ready(monitor_task) if self.is_ready and not monitor_task.done(): supervisor_server, supervisor_server_task = await self._start_server() await monitor_task finally: self._is_ready = False await self._shutdown_children() # Shutdown the DP Supervisor server. if supervisor_server is not None and supervisor_server_task is not None: supervisor_server.should_exit = True await supervisor_server_task async def _start_server(self) -> tuple[uvicorn.Server, asyncio.Task[None]]: """ Launch the DPSupervisor HTTP server. Called only after the vLLM DP Servers are ready so that /health does not return 503 to external probes while the engines are initializing. """ app = _build_dp_supervisor_app(self) host = self.args.host or "0.0.0.0" config = uvicorn.Config( app, host=host, port=self.supervisor_port, log_level=self.args.uvicorn_log_level, ssl_keyfile=self.args.ssl_keyfile, ssl_certfile=self.args.ssl_certfile, ssl_ca_certs=self.args.ssl_ca_certs, ssl_cert_reqs=self.args.ssl_cert_reqs, ssl_ciphers=self.args.ssl_ciphers, ) supervisor_server = uvicorn.Server(config) supervisor_server_task = asyncio.create_task( supervisor_server.serve(), name="dp-supervisor", ) supervisor_server_task.add_done_callback( lambda _task: self._shutdown_event.set() ) # Ensure DPSupervisor task starts on the event loop. while not supervisor_server.started: if supervisor_server_task.done(): supervisor_server_task.result() raise RuntimeError("DPSupervisor exited before startup.") await asyncio.sleep(0.05) logger.info("Started DPSupervisor on %s:%d", host, self.supervisor_port) return supervisor_server, supervisor_server_task async def _wait_until_ready(self, monitor_task: asyncio.Task[None]) -> None: """ Block until the vLLM DP Servers are ready or shutdown is triggered. Returns early if monitoring stops (e.g. a DP Server dies during startup), in which case the supervisor server is never started. """ logger.info("Waiting for vLLM DP Servers to become ready.") while not self._is_ready and not self._shutdown_event.is_set(): if monitor_task.done(): return await asyncio.sleep(0.05) def _handle_signal(self, signum: int) -> None: """ Signal handler that is added to the event loop. This catches the SIGTERM from K8s and begins graceful shutdown, by setting the _shutdown_event(), which is watched by the main coroutine monitoring the vLLM DP Servers. """ if self._shutdown_event.is_set(): return self._shutdown_signal = signal.Signals(signum) logger.info( "DPSupervisor received %s, shutting down.", self._shutdown_signal.name, ) self._shutdown_event.set() self._is_ready = False def _start_children(self) -> None: """ Launch vLLM DP Servers on separate GPUs. """ logger.info("Launching vLLM DP Servers") context = multiprocessing.get_context("spawn") for local_rank in range(self.args.data_parallel_size_local): child_args = _build_vllm_dp_server_args(self.args, local_rank) process = context.Process( target=_run_vllm_dp_server, name=f"APIServer_DPRank_{child_args.data_parallel_rank}", args=(child_args,), ) process.start() self._processes.append(process) async def _probe_all_children(self) -> None: """ Background coroutine: probes all child endpoints on each interval. Exits when any server becomes unhealthy after being ready, signalling _monitor_children to initiate shutdown. """ timeout = aiohttp.ClientTimeout(total=self.args.dp_supervisor_probe_timeout_s) async with aiohttp.ClientSession(timeout=timeout) as session: while not self._shutdown_event.is_set(): threshold = ( self.args.dp_supervisor_probe_failure_threshold if self._is_ready else 1 ) results = await asyncio.gather( *( _probe_endpoint( session, self.args, port, "/health", conn_err_failure_threshold=threshold, conn_err_retry_delay=self.args.dp_supervisor_probe_interval_s, ) for port in self.child_ports ), return_exceptions=True, ) all_healthy = all(r is True for r in results) if all_healthy: # If all healthy, we are ready to receive requests. # This conditional avoids a potential race condition # where shutdown is set, THEN the probe returns true. if not self._shutdown_event.is_set(): self._is_ready = True elif self._is_ready: # Once ready, any failure in the probe means vLLM is dead. num_unhealthy = sum(1 for r in results if r is not True) logger.info( "DPSupervisor probe found %s unhealthy DP Servers.", num_unhealthy, ) self._is_ready = False self._shutdown_event.set() return with contextlib.suppress(asyncio.TimeoutError): logger.debug( "Waiting for %s seconds before next probe", self.args.dp_supervisor_probe_interval_s, ) await asyncio.wait_for( self._shutdown_event.wait(), timeout=self.args.dp_supervisor_probe_interval_s, ) async def _monitor_children(self) -> None: """ Main coroutine task that monitors the children vLLM servers. Before the vLLM servers are /ready: - if the pid is dead, we will shut down - if the probe fails, we try again after dp_supervisor_probe_interval_s After the vLLM servers are /ready: - if the pid is dead, we will shut down - if the probe fails, we will shut down """ probe_task = asyncio.create_task( self._probe_all_children(), name="dp-health-probe" ) try: while not self._shutdown_event.is_set(): # 1. Check for dead processes n_failed = len([p for p in self._processes if not p.is_alive()]) if n_failed > 0: logger.info("DPSupervisor found %s exited DP Servers.", n_failed) break # 2. Check if the probe background task crashed or failed. if probe_task.done(): # Extract exception if it crashed, or log failure exc = probe_task.exception() if not probe_task.cancelled() else None logger.info("DPSupervisor probe task stopped. Exception: %s", exc) break # Sleep for probe_interval seconds or until a shutdown. with contextlib.suppress(asyncio.TimeoutError): logger.debug( "Waiting for %s seconds before next monitor", self.args.dp_supervisor_probe_interval_s, ) await asyncio.wait_for( self._shutdown_event.wait(), timeout=self.args.dp_supervisor_probe_interval_s, ) finally: # Cleanup probe task if needed. if not probe_task.done(): probe_task.cancel() with contextlib.suppress(asyncio.CancelledError): await probe_task async def _shutdown_children(self) -> None: """Terminate the vLLM DP servers.""" timeout = self.args.shutdown_timeout + CHILD_EXIT_GRACE_S try: logger.info( "DPSupervisor forwarding %s to DP Servers.", self._shutdown_signal.name, ) for process in self._processes: pid = process.pid if not process.is_alive() or pid is None: continue with contextlib.suppress(ProcessLookupError, OSError): os.kill(pid, self._shutdown_signal) try: await asyncio.to_thread( _join_processes_with_timeout, self._processes, timeout ) except asyncio.CancelledError: logger.warning("Shutdown await cancelled") raise finally: for process in self._processes: pid = process.pid if not process.is_alive() or pid is None: continue logger.warning( "DP server %s did not exit within %.1fs; force killing.", process.name, timeout, ) with contextlib.suppress( ProcessLookupError, OSError, psutil.NoSuchProcess, psutil.AccessDenied, ): kill_process_tree(pid) def run_dp_supervisor(args: argparse.Namespace) -> None: uvloop.run(DPSupervisor(args).run())