Files
vllm-project--vllm/vllm/entrypoints/openai/dp_supervisor.py
T
wehub-resource-sync 7ce4c8e27e
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:55:37 +08:00

557 lines
20 KiB
Python

#!/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())