[Bugfix] Ensure stage and diffusion subprocesses exit when parent dies unexpectedly (#3751)

Signed-off-by: Lancer <maruixiang6688@gmail.com>
Co-authored-by: Alicia <115451386+congw729@users.noreply.github.com>
This commit is contained in:
LancerandAlicia authored and GitHub committed 2026-05-28 08:22:04 +08:00
1 parent 1f3a3e3848
commit 5c3c81920b
12 files changed
+270 -36

No files matched your search

@@ -28,7 +28,7 @@ class FakeAdapter:
req = running.pop()
self.waiting_for_chunk_running_requests.append(req)
def restore_queues(self, waiting, running):
def restore_queues(self, waiting, running, scheduler_requests=None):
"""Put requests back."""
self.restore_called = True
running.extend(self.waiting_for_chunk_running_requests)
@@ -533,6 +533,52 @@ def test_finish_requests_restores_status(build_adapter):
assert req_id not in adapter.requests_origin_status
def test_finish_requests_removes_zombies_from_chunk_waiting_deques(build_adapter):
adapter, _ = build_adapter(stage_id=1)
zombie = _req("req-zombie", RequestStatus.WAITING_FOR_CHUNK)
other = _req("req-live", RequestStatus.WAITING_FOR_CHUNK)
adapter.waiting_for_chunk_waiting_requests = deque([zombie, other])
adapter.waiting_for_chunk_running_requests = deque([other, zombie])
adapter.requests_with_ready_chunks.add("req-zombie")
adapter.finished_requests.add("req-zombie")
requests_map = {
"req-zombie": zombie,
"req-live": other,
}
adapter.finish_requests(
["req-zombie"],
RequestStatus.FINISHED_ABORTED,
requests_map,
)
assert [req.request_id for req in adapter.waiting_for_chunk_waiting_requests] == ["req-live"]
assert [req.request_id for req in adapter.waiting_for_chunk_running_requests] == ["req-live"]
assert "req-zombie" not in adapter.requests_with_ready_chunks
assert "req-zombie" not in adapter.finished_requests
def test_restore_queues_skips_requests_missing_from_scheduler_requests(build_adapter):
adapter, _ = build_adapter(stage_id=1)
zombie = _req("req-zombie", RequestStatus.WAITING_FOR_CHUNK)
live = _req("req-live", RequestStatus.WAITING_FOR_CHUNK)
waiting_queue = DummyWaitingQueue()
running_queue = []
adapter.waiting_for_chunk_waiting_requests = deque([zombie, live])
adapter.waiting_for_chunk_running_requests = deque([zombie, live])
adapter.restore_queues(
waiting_queue,
running_queue,
scheduler_requests={"req-live": live},
)
assert [req.request_id for req in waiting_queue] == ["req-live"]
assert [req.request_id for req in running_queue] == ["req-live"]
assert not adapter.waiting_for_chunk_waiting_requests
assert not adapter.waiting_for_chunk_running_requests
# ---------------------------------------------------------------
# Scheduler trigger tests
# ---------------------------------------------------------------
@@ -1,3 +1,4 @@
import concurrent.futures
import importlib
import os
import threading
@@ -379,6 +380,44 @@ def test_build_logical_stage_init_plans_applies_replica_device_splits(monkeypatc
assert all(replica.num_replicas == 3 for replica in stage_plans[1].replicas)
def test_initialize_stage_replicas_reuses_long_lived_executor(monkeypatch):
engine = object.__new__(AsyncOmniEngine)
engine._stage_init_executor = None
cfg = types.SimpleNamespace(model_config=types.SimpleNamespace(max_model_len=64))
stage_plans = [_make_llm_plan(0, configured_stage_id=0, vllm_config=cfg, num_replicas=1)]
created_executors = []
class FakeExecutor:
def __init__(self, *args, **kwargs):
self.shutdown_calls = []
created_executors.append(self)
def submit(self, fn, *args, **kwargs):
future = concurrent.futures.Future()
future.set_result(fn(*args, **kwargs))
return future
def shutdown(self, wait=False, cancel_futures=False):
self.shutdown_calls.append((wait, cancel_futures))
monkeypatch.setattr(concurrent.futures, "ThreadPoolExecutor", FakeExecutor)
monkeypatch.setattr(
engine,
"_initialize_replica",
lambda plan, _stage_init_timeout, _stage_launch_lock: f"client-{plan.replica_id}",
)
first = engine._initialize_stage_replicas(stage_plans, stage_init_timeout=1)
second = engine._initialize_stage_replicas(stage_plans, stage_init_timeout=1)
assert first == {0: ["client-0"]}
assert second == {0: ["client-0"]}
assert len(created_executors) == 1
assert engine._stage_init_executor is created_executors[0]
def test_initialize_stage_replicas_collects_results_by_stage_and_replica_id(monkeypatch):
engine = object.__new__(AsyncOmniEngine)
@@ -410,6 +449,42 @@ def test_initialize_stage_replicas_collects_results_by_stage_and_replica_id(monk
}
def test_shutdown_closes_long_lived_stage_init_executor():
engine = object.__new__(AsyncOmniEngine)
engine._shutdown_called = False
engine._weak_finalizer = None
engine._omni_master_server = None
engine._coordinator_runtime = None
engine.orchestrator_thread = types.SimpleNamespace(join=lambda timeout: None, is_alive=lambda: False)
class FakeQueue:
def __init__(self):
self.closed = False
self.sync_q = types.SimpleNamespace(put_nowait=lambda _msg: None)
def close(self):
self.closed = True
class FakeExecutor:
def __init__(self):
self.shutdown_calls = []
def shutdown(self, wait=False, cancel_futures=False):
self.shutdown_calls.append((wait, cancel_futures))
engine.request_queue = FakeQueue()
engine.output_queue = FakeQueue()
engine.rpc_output_queue = FakeQueue()
engine._stage_init_executor = FakeExecutor()
engine.shutdown()
assert engine._stage_init_executor is None
assert engine.request_queue.closed is True
assert engine.output_queue.closed is True
assert engine.rpc_output_queue.closed is True
def test_initialize_stages_cleans_up_successful_replicas_after_partial_multi_replica_failure(monkeypatch):
import vllm_omni.engine.async_omni_engine as engine_mod
+5 -1
View File
@@ -232,7 +232,11 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
finally:
if self.chunk_transfer_adapter:
# Add request waiting for chunk to the waiting and running queue
self.chunk_transfer_adapter.restore_queues(self.waiting, self.running)
self.chunk_transfer_adapter.restore_queues(
self.waiting,
self.running,
scheduler_requests=self.requests,
)
if self.input_coordinator:
self.input_coordinator.restore_queues(self.waiting, self.running)
try:
@@ -213,7 +213,11 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
if self.chunk_transfer_adapter:
# Don't fall back: base scheduler doesn't handle async_chunk
# requests with empty prompt_token_ids.
self.chunk_transfer_adapter.restore_queues(self.waiting, self.running)
self.chunk_transfer_adapter.restore_queues(
self.waiting,
self.running,
scheduler_requests=self.requests,
)
else:
res = super().schedule()
if self.input_coordinator:
@@ -345,7 +349,11 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
# otherwise they are permanently orphaned in the adapter's
# internal deques and never scheduled again.
if self.chunk_transfer_adapter:
self.chunk_transfer_adapter.restore_queues(self.waiting, self.running)
self.chunk_transfer_adapter.restore_queues(
self.waiting,
self.running,
scheduler_requests=self.requests,
)
if self.input_coordinator:
self.input_coordinator.restore_queues(self.waiting, self.running)
+12 -1
View File
@@ -32,6 +32,7 @@ from vllm_omni.distributed.omni_connectors.utils.serialization import (
OmniMsgpackEncoder,
)
from vllm_omni.distributed.omni_coordinator import OmniCoordClientForStage
from vllm_omni.engine.stage_init_utils import set_death_signal
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -41,6 +42,14 @@ if TYPE_CHECKING:
logger = init_logger(__name__)
_SIGNAL_EXIT_BASE = 128
def _signal_exit_code(signum: int) -> int:
"""Return the conventional process exit code for signal-driven exits."""
return _SIGNAL_EXIT_BASE + signum
class StageDiffusionProc:
"""Subprocess entry point for diffusion inference.
@@ -624,11 +633,13 @@ class StageDiffusionProc:
"""
shutdown_requested = False
set_death_signal(signal.SIGTERM)
def signal_handler(signum: int, frame: Any) -> None:
nonlocal shutdown_requested
if not shutdown_requested:
shutdown_requested = True
raise SystemExit(128 + signum)
raise SystemExit(_signal_exit_code(signum))
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
@@ -11,6 +11,7 @@ to DiffusionModelRunner.
import gc
import multiprocessing as mp
import os
import signal
from collections.abc import Iterable, Iterator
from contextlib import AbstractContextManager, contextmanager, nullcontext
from dataclasses import dataclass
@@ -46,6 +47,7 @@ from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.sched.interface import DiffusionSchedulerOutput
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
from vllm_omni.diffusion.worker.utils import BaseRunnerOutput
from vllm_omni.engine.stage_init_utils import set_death_signal
from vllm_omni.lora.request import LoRARequest
from vllm_omni.platforms import current_omni_platform
from vllm_omni.profiler import OmniTorchProfilerWrapper, create_omni_profiler
@@ -831,6 +833,8 @@ class WorkerProc:
"""Worker initialization and execution loops."""
from vllm_omni.plugins import load_omni_general_plugins
set_death_signal(signal.SIGTERM)
# Set process title for visibility in nvidia-smi / htop (optional, non-fatal)
try:
import setproctitle
@@ -389,17 +389,28 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
request.status = RequestStatus.PREEMPTED
waiting_queue.prepend_requests([request])
def restore_queues(self, waiting_queue: Any, running_queue: list[Request]) -> None:
def restore_queues(
self,
waiting_queue: Any,
running_queue: list[Request],
scheduler_requests: dict[str, Request] | None = None,
) -> None:
"""
Restore requests waiting for chunk to the waiting and running queues.
"""
# Add request waiting for chunk to the waiting and running queue
for request in self.waiting_for_chunk_waiting_requests:
waiting_queue.add_request(request)
if scheduler_requests is None or request.request_id in scheduler_requests:
waiting_queue.add_request(request)
self.waiting_for_chunk_waiting_requests = deque()
if self.waiting_for_chunk_running_requests:
running_queue.extend(self.waiting_for_chunk_running_requests)
live_running_requests = [
request
for request in self.waiting_for_chunk_running_requests
if scheduler_requests is None or request.request_id in scheduler_requests
]
running_queue.extend(live_running_requests)
self.waiting_for_chunk_running_requests = deque()
def postprocess_scheduler_output(
@@ -493,3 +504,20 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
continue
if req_id in self.requests_origin_status:
request.status = self.requests_origin_status.pop(req_id)
request_ids = set(request_ids)
self.waiting_for_chunk_waiting_requests = deque(
request for request in self.waiting_for_chunk_waiting_requests if request.request_id not in request_ids
)
self.waiting_for_chunk_running_requests = deque(
request for request in self.waiting_for_chunk_running_requests if request.request_id not in request_ids
)
for req_id in request_ids:
self.requests_with_ready_chunks.discard(req_id)
self.finished_requests.discard(req_id)
self._finished_load_reqs.discard(req_id)
self._cancelled_load_reqs.add(req_id)
return []
+34 -26
View File
@@ -354,6 +354,7 @@ class AsyncOmniEngine:
self.output_queue: janus.Queue[EngineQueueMessage] = janus.Queue()
self.rpc_output_queue: janus.Queue[EngineQueueMessage] = janus.Queue()
self._shutdown_called = False
self._stage_init_executor: concurrent.futures.ThreadPoolExecutor | None = None
self._weak_finalizer: weakref.finalize | None = None
self._rpc_lock = threading.Lock()
@@ -1167,35 +1168,37 @@ class AsyncOmniEngine:
primary_exc = exc
break
# --- 2) LLM replicas: parallel init via a scoped ThreadPoolExecutor. ---
# --- 2) LLM replicas: parallel init via a long-lived ThreadPoolExecutor. ---
if primary_exc is None and llm_replicas:
future_to_replica: dict[concurrent.futures.Future[StagePoolClient], tuple[int, int]] = {}
with concurrent.futures.ThreadPoolExecutor(
max_workers=max(1, len(llm_replicas)),
thread_name_prefix="stage-init",
) as init_executor:
for stage_idx, replica in llm_replicas:
future = init_executor.submit(
self._initialize_replica,
replica,
stage_init_timeout,
stage_launch_lock,
)
future_to_replica[future] = (stage_idx, replica.replica_id)
if getattr(self, "_stage_init_executor", None) is None:
self._stage_init_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=max(1, len(llm_replicas)),
thread_name_prefix="stage-init",
)
init_executor = self._stage_init_executor
for stage_idx, replica in llm_replicas:
future = init_executor.submit(
self._initialize_replica,
replica,
stage_init_timeout,
stage_launch_lock,
)
future_to_replica[future] = (stage_idx, replica.replica_id)
for future in concurrent.futures.as_completed(future_to_replica):
stage_idx, replica_id = future_to_replica[future]
try:
initialized_clients_by_stage[stage_idx][replica_id] = future.result()
except concurrent.futures.CancelledError:
continue
except Exception as exc:
if primary_exc is None:
primary_exc = exc
for other_future in future_to_replica:
if other_future is future:
continue
other_future.cancel()
for future in concurrent.futures.as_completed(future_to_replica):
stage_idx, replica_id = future_to_replica[future]
try:
initialized_clients_by_stage[stage_idx][replica_id] = future.result()
except concurrent.futures.CancelledError:
continue
except Exception as exc:
if primary_exc is None:
primary_exc = exc
for other_future in future_to_replica:
if other_future is future:
continue
other_future.cancel()
if primary_exc is not None:
setattr(primary_exc, "_initialized_clients_by_stage", initialized_clients_by_stage)
@@ -2424,6 +2427,11 @@ class AsyncOmniEngine:
if self.orchestrator_thread.is_alive():
logger.warning("[AsyncOmniEngine] Orchestrator thread did not exit in time")
stage_init_executor = getattr(self, "_stage_init_executor", None)
if stage_init_executor is not None:
stage_init_executor.shutdown(wait=False, cancel_futures=True)
self._stage_init_executor = None
for q in (self.request_queue, self.output_queue, self.rpc_output_queue):
try:
q.close()
+28 -2
View File
@@ -6,11 +6,13 @@ Directly inherits from vLLM's AsyncMPClient to reuse EngineCore architecture.
from __future__ import annotations
import contextlib
import inspect
import multiprocessing.connection
import os
import socket
import threading
import time
import weakref
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
@@ -158,6 +160,7 @@ class StageEngineCoreClientBase(StageClientBase):
self.engine_outputs: Any = None
self._proc = proc
self._shutting_down = False
self.client_addresses = dict(client_addresses or {})
self._omni_kv_config = getattr(getattr(vllm_config, "model_config", None), "omni_kv_config", None)
self._kv_sender_host = self._resolve_contact_host()
@@ -233,15 +236,37 @@ class StageEngineCoreClientBase(StageClientBase):
multiprocessing.connection.wait([proc.sentinel])
except Exception:
return
# Give multiprocessing a brief chance to publish a stable exitcode
# before emitting the monitor log. Without this, the sentinel can
# fire first and the log may misleadingly print ``None``.
with contextlib.suppress(Exception):
proc.join(timeout=0)
exitcode = proc.exitcode
if exitcode is None:
deadline = time.monotonic() + 0.2
while exitcode is None and time.monotonic() < deadline:
time.sleep(0.01)
with contextlib.suppress(Exception):
proc.join(timeout=0)
exitcode = proc.exitcode
resources = resources_ref()
if resources is None or resources.engine_dead:
if resources is None or resources.engine_dead or self._shutting_down:
return
if exitcode == 0:
logger.info(
"[StageEngineCoreClient] stage-%s [rep-%s] subprocess exited cleanly.",
stage_id,
replica_id,
)
return
resources.engine_dead = True
logger.error(
"[StageEngineCoreClient] stage-%s [rep-%s] subprocess died unexpectedly (exit code %s).",
stage_id,
replica_id,
proc.exitcode,
exitcode,
)
t = threading.Thread(
@@ -462,6 +487,7 @@ class StageEngineCoreClientBase(StageClientBase):
def shutdown(self, timeout: float | None = None) -> None:
"""Shutdown managed resources and any externally spawned subprocess."""
self._shutting_down = True
child_procs: list[psutil.Process] = []
if self._proc is not None and self._proc.pid is not None:
try:
@@ -36,6 +36,7 @@ from vllm.v1.engine.utils import (
from vllm.v1.utils import shutdown
from vllm_omni.distributed.omni_coordinator import OmniCoordClientForStage
from vllm_omni.engine.stage_init_utils import set_death_signal
if TYPE_CHECKING:
from vllm.config import VllmConfig
@@ -44,6 +45,14 @@ if TYPE_CHECKING:
logger = init_logger(__name__)
_SIGNAL_EXIT_BASE = 128
def _signal_exit_code(signum: int) -> int:
"""Return the conventional process exit code for signal-driven exits."""
return _SIGNAL_EXIT_BASE + signum
class StageEngineCoreProc(EngineCoreProc):
"""Stage-specific engine core process for vLLM-Omni.
@@ -89,6 +98,7 @@ class StageEngineCoreProc(EngineCoreProc):
# like upstream vLLM.
stage_label = f"stage{omni_stage_id}" if omni_stage_id is not None else "noid"
set_death_signal(signal.SIGTERM)
set_process_title(f"StageEngineCoreProc_{stage_label}_replica{omni_replica_id}_DP{dp_rank}")
decorate_logs()
os.environ["VLLM_OMNI_REPLICA_ID"] = str(max(int(omni_replica_id), 0))
@@ -142,6 +152,7 @@ class StageEngineCoreProc(EngineCoreProc):
def signal_handler(signum: int, frame: Any) -> None:
engine_core.shutdown_state = EngineShutdownState.REQUESTED
signal_callback.trigger()
raise SystemExit(_signal_exit_code(signum))
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
+13
View File
@@ -182,6 +182,19 @@ def terminate_alive_proc(proc, timeout=5):
proc.kill()
def set_death_signal(sig: int) -> None:
"""Best-effort parent-death signal for Linux subprocesses."""
try:
import ctypes
import platform
if platform.system() != "Linux":
return
ctypes.CDLL("libc.so.6").prctl(1, sig)
except Exception:
pass
def patch_generation_config_if_needed(model_config: Any) -> None:
"""Guard InputProcessor init for models whose config lacks model_type."""
try: