1966 lines
78 KiB
Diff
1966 lines
78 KiB
Diff
From fc7b101d3fa07d5bd097b1a880de15c208664d48 Mon Sep 17 00:00:00 2001
|
|
From: halt95 <halt95@users.noreply.github.com>
|
|
Date: Thu, 24 Sep 2026 01:36:08 -0400
|
|
Subject: [PATCH 057/103] engine, executor: drain-order shutdown
|
|
|
|
- EngineCore survives a late SIGTERM during teardown (bounded deferral) so
|
|
every worker drains its guard telemetry.
|
|
- A worker exits right after its orderly shutdown.
|
|
- A worker failure racing shutdown stays a failure (exit 1).
|
|
- Rank 0 waits for the PLE child's whole thread group, bounded, instead of its
|
|
reap; drain-aware failure ladder with a bounded grace for survivors whose
|
|
shutdown already started.
|
|
- tests: the sorted-emit graph arm takes max_len on the host before capture.
|
|
---
|
|
tests/kernels/merlin_sorted_emit_arm.py | 16 +-
|
|
.../test_engine_core_teardown_signal.py | 194 ++++++++
|
|
.../test_multiproc_executor_parent_death.py | 2 +-
|
|
.../test_multiproc_failed_worker_ladder.py | 212 ++++++++
|
|
.../test_multiproc_ple_exit_without_reap.py | 344 +++++++++++++
|
|
.../test_multiproc_worker_exit_after_drain.py | 456 ++++++++++++++++++
|
|
vllm/v1/engine/core.py | 98 +++-
|
|
vllm/v1/executor/multiproc_executor.py | 226 ++++++++-
|
|
vllm/v1/ple_offload/worker.py | 118 +++++
|
|
9 files changed, 1640 insertions(+), 26 deletions(-)
|
|
create mode 100644 tests/v1/engine/test_engine_core_teardown_signal.py
|
|
create mode 100644 tests/v1/executor/test_multiproc_failed_worker_ladder.py
|
|
create mode 100644 tests/v1/executor/test_multiproc_ple_exit_without_reap.py
|
|
create mode 100644 tests/v1/executor/test_multiproc_worker_exit_after_drain.py
|
|
|
|
diff --git a/tests/kernels/merlin_sorted_emit_arm.py b/tests/kernels/merlin_sorted_emit_arm.py
|
|
index 700dab1..5141f28 100644
|
|
--- a/tests/kernels/merlin_sorted_emit_arm.py
|
|
+++ b/tests/kernels/merlin_sorted_emit_arm.py
|
|
@@ -97,8 +97,11 @@ def _inputs(name, k, lengths, kind):
|
|
return logits, torch.tensor(lengths, dtype=torch.int32), prefill
|
|
|
|
|
|
-def _launch(logits, lengths, out, ws, k):
|
|
- torch.ops._C.persistent_topk(logits, lengths, out, ws, k, int(lengths.max()))
|
|
+def _launch(logits, lengths, out, ws, k, max_len):
|
|
+ # max_len is a host int computed BEFORE any graph capture: int(t.max()) on
|
|
+ # a CUDA tensor is a D2H sync, which is illegal inside torch.cuda.graph
|
|
+ # (cudaErrorStreamCaptureUnsupported).
|
|
+ torch.ops._C.persistent_topk(logits, lengths, out, ws, k, max_len)
|
|
|
|
|
|
def main() -> int:
|
|
@@ -109,9 +112,10 @@ def main() -> int:
|
|
cells, replays_ok = {}, True
|
|
for name, k, lengths, kind in CELLS:
|
|
logits, lens, prefill = _inputs(name, k, lengths, kind)
|
|
+ max_len = int(lens.max()) # CPU tensor: no device sync
|
|
logits, lens = logits.to(dev), lens.to(dev)
|
|
out = prefill.to(dev).clone()
|
|
- _launch(logits, lens, out, ws, k)
|
|
+ _launch(logits, lens, out, ws, k, max_len)
|
|
torch.cuda.synchronize()
|
|
cells[name] = out.cpu().clone()
|
|
if graph:
|
|
@@ -119,11 +123,11 @@ def main() -> int:
|
|
g = torch.cuda.CUDAGraph()
|
|
s = torch.cuda.Stream()
|
|
s.wait_stream(torch.cuda.current_stream())
|
|
- with torch.cuda.stream(s):
|
|
- _launch(logits, lens, static_out, ws, k) # warm on a side stream
|
|
+ with torch.cuda.stream(s): # warm on a side stream
|
|
+ _launch(logits, lens, static_out, ws, k, max_len)
|
|
torch.cuda.current_stream().wait_stream(s)
|
|
with torch.cuda.graph(g):
|
|
- _launch(logits, lens, static_out, ws, k)
|
|
+ _launch(logits, lens, static_out, ws, k, max_len)
|
|
static_out.copy_(prefill.to(dev))
|
|
g.replay()
|
|
torch.cuda.synchronize()
|
|
diff --git a/tests/v1/engine/test_engine_core_teardown_signal.py b/tests/v1/engine/test_engine_core_teardown_signal.py
|
|
new file mode 100644
|
|
index 0000000..94a5255
|
|
--- /dev/null
|
|
+++ b/tests/v1/engine/test_engine_core_teardown_signal.py
|
|
@@ -0,0 +1,194 @@
|
|
+# SPDX-License-Identifier: Apache-2.0
|
|
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
+"""EngineCore must survive a late SIGTERM while it tears down its executor.
|
|
+
|
|
+Workers carry PR_SET_PDEATHSIG=SIGKILL, so an EngineCore that dies before
|
|
+model_executor.shutdown() has reaped them SIGKILLs every worker before its
|
|
+bounds-guard FINAL DRAIN runs. Observed on an earlier-build shutdown: the
|
|
+process-group SIGTERM started the shutdown, the API server's process manager
|
|
+sent a second SIGTERM in the same second, the finally block had already reset
|
|
+SIGTERM to SIG_DFL, EngineCore died before "[shutdown] Executor: waiting for
|
|
+worker exit", and FINAL DRAIN printed on 2 of 4 ranks.
|
|
+
|
|
+The test drives the real EngineCoreProc.run_engine_core with a fake engine whose
|
|
+shutdown() receives that second SIGTERM mid-teardown. Unfixed code: the child
|
|
+dies with -SIGTERM before teardown completes (the failing control).
|
|
+"""
|
|
+
|
|
+import contextlib
|
|
+import multiprocessing
|
|
+import os
|
|
+import signal
|
|
+import sys
|
|
+import time
|
|
+from types import SimpleNamespace
|
|
+
|
|
+import pytest
|
|
+
|
|
+pytestmark = pytest.mark.skipif(
|
|
+ not sys.platform.startswith("linux"), reason="fork + POSIX signals"
|
|
+)
|
|
+
|
|
+
|
|
+def _child_run(conn, mode: str = "once") -> None:
|
|
+ import logging
|
|
+
|
|
+ from vllm.v1.engine import core
|
|
+
|
|
+ if mode == "once-hang":
|
|
+ core._TEARDOWN_SIGNAL_BOUND_S = 1.0
|
|
+
|
|
+ run_engine_core = core.EngineCoreProc.run_engine_core
|
|
+
|
|
+ records: list[str] = []
|
|
+
|
|
+ class _Capture(logging.Handler):
|
|
+ def emit(self, record):
|
|
+ records.append(record.getMessage())
|
|
+
|
|
+ core.logger.addHandler(_Capture(level=logging.WARNING))
|
|
+
|
|
+ class _FakeEngineCore:
|
|
+ def __init__(self, *args, **kwargs):
|
|
+ import queue
|
|
+
|
|
+ self.input_queue = queue.Queue()
|
|
+ self.shutdown_state = None
|
|
+ self.vllm_config = kwargs["vllm_config"]
|
|
+
|
|
+ def run_busy_loop(self):
|
|
+ # As after the first (process-group) SIGTERM: the busy loop exits.
|
|
+ raise SystemExit
|
|
+
|
|
+ def has_work(self):
|
|
+ return False
|
|
+
|
|
+ def shutdown(self):
|
|
+ # The process manager's SIGTERM, delivered mid-teardown.
|
|
+ conn.send(("teardown-start", None))
|
|
+ os.kill(os.getpid(), signal.SIGTERM)
|
|
+ if mode == "twice-hang":
|
|
+ time.sleep(0.2)
|
|
+ conn.send(("second-signal", None))
|
|
+ os.kill(os.getpid(), signal.SIGTERM)
|
|
+ if mode in ("twice-hang", "once-hang"):
|
|
+ time.sleep(3600) # teardown hangs (e.g. distributed cleanup)
|
|
+ time.sleep(0.3) # the executor waiting for its workers
|
|
+ conn.send(("teardown-done", None))
|
|
+
|
|
+ core.EngineCoreProc = _FakeEngineCore
|
|
+ core.maybe_register_config_serialize_by_value = lambda: None
|
|
+ core.set_process_title = lambda *a, **k: None
|
|
+ core.maybe_init_worker_tracer = lambda *a, **k: None
|
|
+ core.decorate_logs = lambda *a, **k: None
|
|
+
|
|
+ vllm_config = SimpleNamespace(
|
|
+ parallel_config=SimpleNamespace(
|
|
+ data_parallel_size=1,
|
|
+ numa_bind=False,
|
|
+ data_parallel_index=0,
|
|
+ reconfigure_for_independent_dp_rank=lambda: None,
|
|
+ ),
|
|
+ kv_transfer_config=None,
|
|
+ model_config=SimpleNamespace(is_moe=False),
|
|
+ shutdown_timeout=60,
|
|
+ )
|
|
+ with contextlib.suppress(SystemExit):
|
|
+ run_engine_core(vllm_config=vllm_config)
|
|
+ conn.send(
|
|
+ (
|
|
+ "after",
|
|
+ {
|
|
+ "sigterm_is_default": signal.getsignal(signal.SIGTERM)
|
|
+ is signal.SIG_DFL,
|
|
+ "sigint_is_default": signal.getsignal(signal.SIGINT) is signal.SIG_DFL,
|
|
+ "warnings": records,
|
|
+ },
|
|
+ )
|
|
+ )
|
|
+ conn.close()
|
|
+
|
|
+
|
|
+def test_late_sigterm_does_not_kill_engine_core_teardown():
|
|
+ ctx = multiprocessing.get_context("fork")
|
|
+ parent, child = ctx.Pipe(duplex=False)
|
|
+ proc = ctx.Process(target=_child_run, args=(child,))
|
|
+ proc.start()
|
|
+ child.close()
|
|
+ events = {}
|
|
+ deadline = time.monotonic() + 60
|
|
+ while time.monotonic() < deadline:
|
|
+ if not parent.poll(0.1):
|
|
+ # after exit, drain whatever was sent before it
|
|
+ if not proc.is_alive() and not parent.poll(0):
|
|
+ break
|
|
+ continue
|
|
+ try:
|
|
+ name, payload = parent.recv()
|
|
+ except EOFError:
|
|
+ break
|
|
+ events[name] = payload
|
|
+ if name == "after":
|
|
+ break
|
|
+ proc.join(30)
|
|
+ assert "teardown-start" in events, f"fake teardown never ran: {events}"
|
|
+ assert proc.exitcode != -signal.SIGTERM, (
|
|
+ "EngineCore died of the SIGTERM delivered during teardown "
|
|
+ "(workers would be SIGKILLed before their FINAL DRAIN)"
|
|
+ )
|
|
+ assert "teardown-done" in events, f"teardown did not complete: {events}"
|
|
+ after = events.get("after")
|
|
+ assert after is not None, f"run_engine_core did not return: {events}"
|
|
+ assert proc.exitcode == 0, proc.exitcode
|
|
+ # default dispositions are restored once teardown is over
|
|
+ assert after["sigterm_is_default"] and after["sigint_is_default"], after
|
|
+ # fail loud: the deferred signal is logged
|
|
+ assert any(
|
|
+ "SIGTERM during resource teardown deferred" in m for m in after["warnings"]
|
|
+ ), after["warnings"]
|
|
+
|
|
+
|
|
+def _run_hanging_teardown(mode: str, bound_s: float):
|
|
+ ctx = multiprocessing.get_context("fork")
|
|
+ parent, child = ctx.Pipe(duplex=False)
|
|
+ proc = ctx.Process(target=_child_run, args=(child, mode))
|
|
+ start = time.monotonic()
|
|
+ proc.start()
|
|
+ child.close()
|
|
+ proc.join(bound_s)
|
|
+ elapsed = time.monotonic() - start
|
|
+ alive = proc.is_alive()
|
|
+ if alive:
|
|
+ proc.kill()
|
|
+ proc.join(10)
|
|
+ events = []
|
|
+ while True:
|
|
+ try:
|
|
+ if not parent.poll(0.2):
|
|
+ break
|
|
+ events.append(parent.recv()[0])
|
|
+ except (EOFError, OSError):
|
|
+ break
|
|
+ return alive, proc.exitcode, elapsed, events
|
|
+
|
|
+
|
|
+def test_second_late_signal_ends_a_hung_teardown():
|
|
+ # The deferral must be bounded. The first late SIGTERM is
|
|
+ # deferred (the drain-race fix); a second one while teardown hangs ends
|
|
+ # the process by that signal. Unfixed code (every late signal deferred)
|
|
+ # stays alive until killed (the failing control).
|
|
+ alive, exitcode, elapsed, events = _run_hanging_teardown("twice-hang", 15)
|
|
+ assert "teardown-start" in events and "second-signal" in events, events
|
|
+ assert not alive, f"hung teardown survived a second SIGTERM ({elapsed:.1f}s)"
|
|
+ assert exitcode == -signal.SIGTERM, exitcode
|
|
+ assert "teardown-done" not in events, events
|
|
+
|
|
+
|
|
+def test_teardown_watchdog_bounds_a_single_deferred_signal():
|
|
+ # One deferred SIGTERM, then teardown hangs: the watchdog ends the process
|
|
+ # (exit code 1) once _TEARDOWN_SIGNAL_BOUND_S (1 s here) has passed.
|
|
+ alive, exitcode, elapsed, events = _run_hanging_teardown("once-hang", 15)
|
|
+ assert "teardown-start" in events, events
|
|
+ assert not alive, f"hung teardown outlived the watchdog ({elapsed:.1f}s)"
|
|
+ assert exitcode == 1, exitcode
|
|
+ assert "teardown-done" not in events, events
|
|
diff --git a/tests/v1/executor/test_multiproc_executor_parent_death.py b/tests/v1/executor/test_multiproc_executor_parent_death.py
|
|
index 111fca6..2ea2755 100644
|
|
--- a/tests/v1/executor/test_multiproc_executor_parent_death.py
|
|
+++ b/tests/v1/executor/test_multiproc_executor_parent_death.py
|
|
@@ -87,7 +87,7 @@ def _run_fake_worker_main(
|
|
time.sleep(3600)
|
|
raise AssertionError(f"unknown block mode: {block_mode}")
|
|
|
|
- def shutdown(self):
|
|
+ def shutdown(self, *, destroy_distributed: bool = True):
|
|
pass
|
|
|
|
executor_module.WorkerProc = FakeWorkerProc
|
|
diff --git a/tests/v1/executor/test_multiproc_failed_worker_ladder.py b/tests/v1/executor/test_multiproc_failed_worker_ladder.py
|
|
new file mode 100644
|
|
index 0000000..9c59113
|
|
--- /dev/null
|
|
+++ b/tests/v1/executor/test_multiproc_failed_worker_ladder.py
|
|
@@ -0,0 +1,212 @@
|
|
+# SPDX-License-Identifier: Apache-2.0
|
|
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
+"""After a worker died, parked survivors are killed fast; draining ones are not.
|
|
+
|
|
+A drain fault-injection run (earlier build): the PLE fault killed rank 0. One
|
|
+survivor drained and exited at once. Two ranks sat in a collective with the
|
|
+dead rank, inside C code where neither the grace nor the SIGTERM reaches them,
|
|
+and the executor ran its full 5 s grace + 4 s SIGTERM wait before SIGKILL.
|
|
+Workers were gone 17.6 s after the fault, vs 8.7 s on an earlier build, where the
|
|
+engine died on the late SIGTERM and PDEATHSIG killed the survivors at once.
|
|
+the drain-order change keeps the engine alive for the drain, so the failure ladder has
|
|
+to be short on its own.
|
|
+
|
|
+A known hazard: a blanket 1 s cap can cut a survivor that is still
|
|
+draining. Each worker now publishes a shared byte: 0 = shutdown not started,
|
|
+1 = started (FINAL DRAIN in progress), 2 = done. Only workers with no drain
|
|
+signal get the 1 s ladder.
|
|
+
|
|
+A later drain fault-injection run: a survivor whose shutdown had STARTED blocked
|
|
+inside it on the dead rank (no FINAL DRAIN line) and got the full orderly
|
|
+ladder; workers gone 17.6 s after the fault. Started shutdowns now get a
|
|
+bounded 2 s grace on the failure path (every measured FINAL DRAIN < 0.1 s),
|
|
+then SIGTERM, then SIGKILL after 1 s.
|
|
+"""
|
|
+
|
|
+import contextlib
|
|
+import signal
|
|
+import subprocess
|
|
+import sys
|
|
+import time
|
|
+from types import SimpleNamespace
|
|
+
|
|
+import pytest
|
|
+
|
|
+pytestmark = [
|
|
+ pytest.mark.skip_global_cleanup,
|
|
+ pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals"),
|
|
+]
|
|
+
|
|
+# Parked in a collective: ignores SIGTERM, never exits on its own.
|
|
+_PARKED = (
|
|
+ "import signal, time\n"
|
|
+ "signal.signal(signal.SIGTERM, signal.SIG_IGN)\n"
|
|
+ "print('ready', flush=True)\n"
|
|
+ "time.sleep(600)\n"
|
|
+)
|
|
+# Draining: default SIGTERM (would die -15), exits 0 by itself after 1.2 s --
|
|
+# 12x the longest FINAL DRAIN measured on the reference host, inside the 2 s failure grace.
|
|
+_DRAINING_S = 1.2
|
|
+_DRAINING = (
|
|
+ "import os, time\n"
|
|
+ "print('ready', flush=True)\n"
|
|
+ f"time.sleep({_DRAINING_S})\n"
|
|
+ "os._exit(0)\n"
|
|
+)
|
|
+
|
|
+
|
|
+class _Proc:
|
|
+ """The BaseProcess surface _ensure_worker_termination uses."""
|
|
+
|
|
+ def __init__(self, script):
|
|
+ self.popen = subprocess.Popen(
|
|
+ [sys.executable, "-c", script], stdout=subprocess.PIPE, text=True
|
|
+ )
|
|
+ assert self.popen.stdout.readline().strip() == "ready"
|
|
+
|
|
+ def is_alive(self):
|
|
+ return self.popen.poll() is None
|
|
+
|
|
+ def terminate(self):
|
|
+ self.popen.send_signal(signal.SIGTERM)
|
|
+
|
|
+ def kill(self):
|
|
+ self.popen.kill()
|
|
+
|
|
+
|
|
+class _Handle:
|
|
+ def __init__(self, script, state):
|
|
+ self.proc = _Proc(script)
|
|
+ self.death_writer = None
|
|
+ self.worker_response_mq = None
|
|
+ # None = no state published (older handle): treated as parked.
|
|
+ self.shutdown_state = None if state is None else SimpleNamespace(value=state)
|
|
+
|
|
+
|
|
+def _executor(workers, *, failed):
|
|
+ from vllm.v1.executor.multiproc_executor import MultiprocExecutor
|
|
+
|
|
+ executor = object.__new__(MultiprocExecutor)
|
|
+ executor.workers = workers
|
|
+ executor.is_failed = failed
|
|
+ executor.rpc_broadcast_mq = None
|
|
+ executor.response_mqs = []
|
|
+ return executor
|
|
+
|
|
+
|
|
+def _shutdown(specs, *, failed):
|
|
+ workers = [_Handle(script, state) for script, state in specs]
|
|
+ try:
|
|
+ start = time.monotonic()
|
|
+ _executor(workers, failed=failed).shutdown()
|
|
+ elapsed = time.monotonic() - start
|
|
+ # The ladder's last step sends SIGKILL without waiting for the reap.
|
|
+ for w in workers:
|
|
+ with contextlib.suppress(subprocess.TimeoutExpired):
|
|
+ w.proc.popen.wait(2)
|
|
+ codes = [w.proc.popen.returncode for w in workers]
|
|
+ finally:
|
|
+ for w in workers:
|
|
+ if w.proc.is_alive():
|
|
+ w.proc.kill()
|
|
+ w.proc.popen.wait(10)
|
|
+ return elapsed, codes
|
|
+
|
|
+
|
|
+def test_failure_shutdown_kills_parked_survivors_fast():
|
|
+ elapsed, codes = _shutdown([(_PARKED, 0), (_PARKED, None)], failed=True)
|
|
+ assert all(c is not None for c in codes), codes
|
|
+ assert elapsed < 3.0, (
|
|
+ f"a failure-triggered shutdown took {elapsed:.1f} s to kill ranks parked "
|
|
+ "with the dead rank (the orderly 5 s grace + 4 s SIGTERM ladder)"
|
|
+ )
|
|
+
|
|
+
|
|
+def test_failure_shutdown_lets_a_draining_survivor_finish():
|
|
+ elapsed, codes = _shutdown([(_DRAINING, 1), (_PARKED, 0)], failed=True)
|
|
+ assert codes[0] == 0, (
|
|
+ f"the draining survivor was signalled (exit {codes[0]}) instead of "
|
|
+ "finishing its FINAL DRAIN"
|
|
+ )
|
|
+ assert codes[1] is not None
|
|
+ # The parked survivor is SIGKILLed at ~2 s; the drainer was never signalled.
|
|
+ assert _DRAINING_S - 0.3 <= elapsed < 3.0, elapsed
|
|
+
|
|
+
|
|
+def test_failure_shutdown_ends_a_survivor_stuck_in_its_shutdown():
|
|
+ # Started its shutdown (state 1), then blocked on the dead rank: ignores
|
|
+ # SIGTERM like a rank inside a device synchronize, never exits by itself.
|
|
+ elapsed, codes = _shutdown([(_PARKED, 1), (_PARKED, 0)], failed=True)
|
|
+ assert all(c is not None for c in codes), codes
|
|
+ assert codes[0] == -signal.SIGKILL, codes
|
|
+ assert elapsed < 2.0 + 1.0 + 0.6, (
|
|
+ f"a failure-triggered shutdown took {elapsed:.1f} s to end a survivor "
|
|
+ "stuck inside its started shutdown (bound: 2 s grace + 1 s SIGTERM wait)"
|
|
+ )
|
|
+
|
|
+
|
|
+def test_orderly_shutdown_keeps_its_grace(monkeypatch):
|
|
+ monkeypatch.setenv("VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS", "2")
|
|
+ elapsed, codes = _shutdown([(_PARKED, 0), (_PARKED, 0)], failed=False)
|
|
+ assert all(c is not None for c in codes), codes
|
|
+ # Unchanged: the configured grace, then the 4 s SIGTERM wait, then SIGKILL.
|
|
+ assert elapsed >= 2.0 + 4.0 - 0.5, elapsed
|
|
+
|
|
+
|
|
+def test_worker_main_publishes_shutdown_started_then_done(monkeypatch):
|
|
+ """worker_main marks 1 before WorkerProc.shutdown() (drain) and 2 after."""
|
|
+ import multiprocessing
|
|
+ import os
|
|
+
|
|
+ import vllm.v1.executor.multiproc_executor as executor_module
|
|
+
|
|
+ seen = []
|
|
+ state = multiprocessing.RawValue("b", 0)
|
|
+
|
|
+ class _Inner:
|
|
+ def shutdown(self):
|
|
+ seen.append(("drain", state.value))
|
|
+
|
|
+ def fake_init(self, *args, **kwargs):
|
|
+ self.rank = 0
|
|
+ self.worker = _Inner()
|
|
+ self.rpc_broadcast_mq = None
|
|
+ self.worker_response_mq = SimpleNamespace(
|
|
+ export_handle=lambda: None,
|
|
+ wait_until_ready=lambda: None,
|
|
+ shutdown=lambda: None,
|
|
+ )
|
|
+ self.peer_response_handles = []
|
|
+
|
|
+ def busy_loop(self):
|
|
+ seen.append(("busy", state.value))
|
|
+ raise ValueError("boom") # an unexpected failure: full teardown path
|
|
+
|
|
+ monkeypatch.setattr(executor_module.WorkerProc, "__init__", fake_init)
|
|
+ monkeypatch.setattr(executor_module.WorkerProc, "worker_busy_loop", busy_loop)
|
|
+ monkeypatch.setattr(executor_module, "destroy_model_parallel", lambda: None)
|
|
+ monkeypatch.setattr(
|
|
+ executor_module, "destroy_distributed_environment", lambda: None
|
|
+ )
|
|
+ monkeypatch.setattr(executor_module, "maybe_init_worker_tracer", lambda **k: None)
|
|
+ monkeypatch.setattr(executor_module, "set_worker_net_device", lambda *a, **k: None)
|
|
+ monkeypatch.setattr(
|
|
+ executor_module, "_install_worker_parent_death_signal", lambda pid: None
|
|
+ )
|
|
+ monkeypatch.setattr(executor_module.signal, "signal", lambda *a, **k: None)
|
|
+ ready_reader, ready_writer = multiprocessing.Pipe(duplex=False)
|
|
+ executor_module.WorkerProc.worker_main(
|
|
+ vllm_config=SimpleNamespace(
|
|
+ parallel_config=SimpleNamespace(
|
|
+ assigned_physical_gpu_ids=None, numa_bind=False
|
|
+ )
|
|
+ ),
|
|
+ ready_pipe=ready_writer,
|
|
+ death_pipe=None,
|
|
+ shutdown_state=state,
|
|
+ inherited_fds=[],
|
|
+ expected_parent_pid=os.getpid(),
|
|
+ )
|
|
+ ready_reader.close()
|
|
+ assert seen == [("busy", 0), ("drain", 1)], seen
|
|
+ assert state.value == 2
|
|
diff --git a/tests/v1/executor/test_multiproc_ple_exit_without_reap.py b/tests/v1/executor/test_multiproc_ple_exit_without_reap.py
|
|
new file mode 100644
|
|
index 0000000..4bab227
|
|
--- /dev/null
|
|
+++ b/tests/v1/executor/test_multiproc_ple_exit_without_reap.py
|
|
@@ -0,0 +1,344 @@
|
|
+# SPDX-License-Identifier: Apache-2.0
|
|
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
+"""Rank 0 waits for its PLE offload child to leave userspace, not for its reap.
|
|
+
|
|
+A drain run on an earlier build: on every F cycle three ranks exited
|
|
+right after their FINAL DRAIN, but Worker_PP0_TP0, the rank that owns the PLE
|
|
+offload process, sat in Worker.shutdown() -> PleOffloadWorkerHandle.close()
|
|
+until the executor's grace ran out (SIGTERM at +5 s, SIGKILL at +9 s). A
|
|
+read-only /proc sample showed the cause. The offload child (RSS ~51 GB)
|
|
+finished its userspace, and its leader went Z at ~+2.7 s. One thread stayed R
|
|
+with PF_EXITING set, in-kernel, freeing the address space, so the child was
|
|
+not reapable until ~+8.6 s.
|
|
+
|
|
+A known hazard: a Z leader alone does not prove the child left
|
|
+userspace, because another thread can still run. close() therefore waits for
|
|
+every task to be Z/X or PF_EXITING.
|
|
+
|
|
+Covered here:
|
|
+- the predicate itself, on a fake procfs;
|
|
+- close() does not wait for the reap once the thread group is in exit (fake
|
|
+ procfs over a live, unreaped child); the earlier build waits for the reap and fails this;
|
|
+- close() does wait while a non-leader thread still runs userspace (real
|
|
+ child whose leader exits alone); the earlier commit fails this;
|
|
+- the bound: a child that ignores SIGTERM is SIGKILLed within the bound, and
|
|
+ the bound fits inside the executor's grace;
|
|
+- the default close() still reaps.
|
|
+"""
|
|
+
|
|
+import contextlib
|
|
+import platform
|
|
+import signal
|
|
+import socket
|
|
+import subprocess
|
|
+import sys
|
|
+import tempfile
|
|
+import threading
|
|
+import time
|
|
+from multiprocessing.connection import Connection
|
|
+from pathlib import Path
|
|
+
|
|
+import pytest
|
|
+
|
|
+pytestmark = pytest.mark.skip_global_cleanup
|
|
+
|
|
+linux_only = pytest.mark.skipif(
|
|
+ sys.platform != "linux" or platform.machine() not in ("x86_64", "aarch64"),
|
|
+ reason="needs Linux /proc and a raw SYS_exit number",
|
|
+)
|
|
+
|
|
+_SYS_EXIT = {"x86_64": 60, "aarch64": 93}.get(platform.machine(), -1)
|
|
+
|
|
+# On SIGTERM: run the cleanup (unlink + marker, like proc_main's finally), then
|
|
+# the leader thread exits alone (raw SYS_exit) while a second thread keeps
|
|
+# running userspace for LINGER seconds. mode=ignore: SIGTERM is ignored.
|
|
+_STUB_CHILD = r"""
|
|
+import ctypes
|
|
+import os
|
|
+import pathlib
|
|
+import signal
|
|
+import sys
|
|
+import threading
|
|
+import time
|
|
+
|
|
+endpoint, marker, linger_s, sys_exit, mode = sys.argv[1:]
|
|
+stop = threading.Event()
|
|
+if mode == "ignore":
|
|
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
+else:
|
|
+ signal.signal(signal.SIGTERM, lambda _s, _f: stop.set())
|
|
+pathlib.Path(endpoint).touch()
|
|
+print("ready", flush=True)
|
|
+if mode == "ignore":
|
|
+ time.sleep(600)
|
|
+stop.wait()
|
|
+os.unlink(endpoint)
|
|
+pathlib.Path(marker).touch()
|
|
+
|
|
+
|
|
+def linger():
|
|
+ time.sleep(float(linger_s)) # userspace: NOT in the exit path yet
|
|
+ os._exit(0)
|
|
+
|
|
+
|
|
+threading.Thread(target=linger).start()
|
|
+ctypes.CDLL(None).syscall(int(sys_exit), 0)
|
|
+"""
|
|
+
|
|
+
|
|
+def _stat_line(pid, comm, state, flags):
|
|
+ # Field 9 (flags) is the 7th field after the parenthesised comm.
|
|
+ return f"{pid} ({comm}) {state} 1 1 1 0 -1 {flags} 0 0 0 0\n"
|
|
+
|
|
+
|
|
+def _fake_task_tree(root: Path, pid: int, tasks):
|
|
+ for tid, comm, state, flags in tasks:
|
|
+ d = root / str(pid) / "task" / str(tid)
|
|
+ d.mkdir(parents=True)
|
|
+ (d / "stat").write_text(_stat_line(tid, comm, state, flags))
|
|
+
|
|
+
|
|
+def _start_handle(tmp: Path, linger_s: float, mode: str = "graceful"):
|
|
+ from vllm.v1.ple_offload.worker import PleOffloadWorkerHandle
|
|
+
|
|
+ endpoint = tmp / "ple.sock"
|
|
+ marker = tmp / "cleanup.done"
|
|
+ proc = subprocess.Popen(
|
|
+ [
|
|
+ sys.executable,
|
|
+ "-c",
|
|
+ _STUB_CHILD,
|
|
+ str(endpoint),
|
|
+ str(marker),
|
|
+ str(linger_s),
|
|
+ str(_SYS_EXIT),
|
|
+ mode,
|
|
+ ],
|
|
+ stdout=subprocess.PIPE,
|
|
+ text=True,
|
|
+ )
|
|
+ assert proc.stdout.readline().strip() == "ready"
|
|
+ # gpu_worker._start_ple_offload_watchdog: a thread blocked in proc.wait()
|
|
+ # holds Popen._waitpid_lock for as long as the child is unreapable.
|
|
+ watchdog = threading.Thread(target=proc.wait, daemon=True)
|
|
+ watchdog.start()
|
|
+ parent_death, child_death = socket.socketpair()
|
|
+ handle = PleOffloadWorkerHandle(
|
|
+ proc=proc,
|
|
+ death_writer=Connection(parent_death.detach()),
|
|
+ ready_pipe_reader=None,
|
|
+ launch_deadline=time.monotonic() + 60,
|
|
+ ipc_addr=f"ipc://{endpoint}",
|
|
+ )
|
|
+ return handle, proc, watchdog, endpoint, marker, child_death
|
|
+
|
|
+
|
|
+def _finish(proc, watchdog, child_end, join_s=15):
|
|
+ with contextlib.suppress(ProcessLookupError):
|
|
+ if proc.returncode is None:
|
|
+ proc.send_signal(signal.SIGKILL)
|
|
+ watchdog.join(join_s)
|
|
+ child_end.close()
|
|
+
|
|
+
|
|
+@pytest.mark.parametrize(
|
|
+ "tasks, expected",
|
|
+ [
|
|
+ # Observed shape (a shutdown sample): leader Z, one task R with PF_EXITING.
|
|
+ ([(1, "python3.13", "Z", 0x40810C), (2, "python3.13", "R", 0x40044C)], True),
|
|
+ # Leader Z, a thread still in userspace (no PF_EXITING).
|
|
+ ([(1, "python3.13", "Z", 0x40810C), (2, "python3.13", "S", 0x400040)], False),
|
|
+ ([(1, "python3.13", "S", 0x400100)], False),
|
|
+ ([(1, "odd) comm (x", "X", 0)], True),
|
|
+ ([], True),
|
|
+ ],
|
|
+)
|
|
+def test_thread_group_predicate(tmp_path, tasks, expected):
|
|
+ from vllm.v1.ple_offload.worker import _child_thread_group_exiting
|
|
+
|
|
+ pid = 4242
|
|
+ if tasks:
|
|
+ _fake_task_tree(tmp_path, pid, tasks)
|
|
+ else:
|
|
+ (tmp_path / str(pid) / "task").mkdir(parents=True)
|
|
+ proc = type("P", (), {"pid": pid, "returncode": None})()
|
|
+ assert _child_thread_group_exiting(proc, str(tmp_path)) is expected
|
|
+
|
|
+
|
|
+@linux_only
|
|
+def test_exit_imminent_close_does_not_wait_for_the_reap(monkeypatch, tmp_path):
|
|
+ import vllm.v1.ple_offload.worker as ple_worker
|
|
+
|
|
+ monkeypatch.setattr(ple_worker, "_PROCESS_EXIT_IMMINENT", True, raising=False)
|
|
+ with tempfile.TemporaryDirectory() as d:
|
|
+ # A live child that will not exit on SIGTERM stands in for the 51 GB
|
|
+ # child: unreapable for as long as the test wants.
|
|
+ handle, proc, watchdog, endpoint, marker, child_end = _start_handle(
|
|
+ Path(d), 0, mode="ignore"
|
|
+ )
|
|
+ try:
|
|
+ # procfs says: leader Z, the other task R with PF_EXITING.
|
|
+ fake = tmp_path / "proc"
|
|
+ _fake_task_tree(
|
|
+ fake,
|
|
+ proc.pid,
|
|
+ [(proc.pid, "python3.13", "Z", 0x40810C), (1, "x", "R", 0x40044C)],
|
|
+ )
|
|
+ monkeypatch.setattr(ple_worker, "_PROC_ROOT", str(fake), raising=False)
|
|
+ start = time.monotonic()
|
|
+ handle.close()
|
|
+ elapsed = time.monotonic() - start
|
|
+ assert elapsed < 1.0, (
|
|
+ f"close() waited {elapsed:.1f} s for the offload child's reap "
|
|
+ "although its thread group was in the kernel exit path; the "
|
|
+ "executor's 5 s grace expires first on the reference host"
|
|
+ )
|
|
+ assert proc.poll() is None, "close() reaped or killed the child"
|
|
+ assert handle.death_writer is None
|
|
+ finally:
|
|
+ _finish(proc, watchdog, child_end)
|
|
+
|
|
+
|
|
+@linux_only
|
|
+def test_exit_imminent_close_waits_for_a_userspace_thread(monkeypatch):
|
|
+ import vllm.v1.ple_offload.worker as ple_worker
|
|
+
|
|
+ monkeypatch.setattr(ple_worker, "_PROCESS_EXIT_IMMINENT", True, raising=False)
|
|
+ linger_s = 1.5
|
|
+ with tempfile.TemporaryDirectory() as d:
|
|
+ handle, proc, watchdog, endpoint, marker, child_end = _start_handle(
|
|
+ Path(d), linger_s
|
|
+ )
|
|
+ try:
|
|
+ start = time.monotonic()
|
|
+ handle.close()
|
|
+ elapsed = time.monotonic() - start
|
|
+ assert elapsed >= linger_s - 0.2, (
|
|
+ f"close() returned after {elapsed:.2f} s, while a child thread "
|
|
+ f"was still running userspace ({linger_s} s): a Z leader is "
|
|
+ "not the whole thread group"
|
|
+ )
|
|
+ assert elapsed < linger_s + 1.5, elapsed
|
|
+ assert marker.exists(), "child cleanup (proc_main finally) never ran"
|
|
+ assert not endpoint.exists()
|
|
+ finally:
|
|
+ _finish(proc, watchdog, child_end)
|
|
+ assert proc.returncode == 0
|
|
+
|
|
+
|
|
+@linux_only
|
|
+def test_exit_imminent_wait_is_bounded_inside_the_grace(monkeypatch):
|
|
+ import vllm.envs as envs
|
|
+ import vllm.v1.ple_offload.worker as ple_worker
|
|
+
|
|
+ monkeypatch.setattr(ple_worker, "_PROCESS_EXIT_IMMINENT", True, raising=False)
|
|
+ bound = ple_worker._EXIT_IMMINENT_CHILD_WAIT_S
|
|
+ # Rank 0 must be gone before the executor's grace; leave >= 1 s for the
|
|
+ # rest of its shutdown and its own exit.
|
|
+ assert bound <= envs.VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS - 1.0
|
|
+ with tempfile.TemporaryDirectory() as d:
|
|
+ handle, proc, watchdog, endpoint, marker, child_end = _start_handle(
|
|
+ Path(d), 0, mode="ignore"
|
|
+ )
|
|
+ try:
|
|
+ start = time.monotonic()
|
|
+ handle.close()
|
|
+ elapsed = time.monotonic() - start
|
|
+ assert bound - 0.1 <= elapsed < bound + 1.0, elapsed
|
|
+ watchdog.join(5)
|
|
+ assert proc.returncode == -signal.SIGKILL
|
|
+ finally:
|
|
+ _finish(proc, watchdog, child_end)
|
|
+
|
|
+
|
|
+@linux_only
|
|
+def test_default_close_still_reaps(monkeypatch):
|
|
+ import vllm.v1.ple_offload.worker as ple_worker
|
|
+
|
|
+ monkeypatch.setattr(ple_worker, "_PROCESS_EXIT_IMMINENT", False, raising=False)
|
|
+ with tempfile.TemporaryDirectory() as d:
|
|
+ handle, proc, watchdog, endpoint, marker, child_end = _start_handle(
|
|
+ Path(d), 1.0
|
|
+ )
|
|
+ try:
|
|
+ handle.close()
|
|
+ # Unchanged full-teardown path: the child is reaped before return.
|
|
+ assert proc.returncode is not None
|
|
+ assert marker.exists() and not endpoint.exists()
|
|
+ finally:
|
|
+ _finish(proc, watchdog, child_end)
|
|
+
|
|
+
|
|
+class _FakeInner:
|
|
+ def __init__(self):
|
|
+ self.calls = 0
|
|
+
|
|
+ def shutdown(self):
|
|
+ self.calls += 1
|
|
+
|
|
+
|
|
+@pytest.mark.parametrize("destroy_distributed", [False, True])
|
|
+def test_worker_shutdown_marks_exit_imminent_only_on_the_exit_path(
|
|
+ monkeypatch, destroy_distributed
|
|
+):
|
|
+ import vllm.v1.executor.multiproc_executor as executor_module
|
|
+ import vllm.v1.ple_offload.worker as ple_worker
|
|
+
|
|
+ monkeypatch.setattr(ple_worker, "_PROCESS_EXIT_IMMINENT", False, raising=False)
|
|
+ monkeypatch.setattr(executor_module, "destroy_model_parallel", lambda: None)
|
|
+ monkeypatch.setattr(
|
|
+ executor_module, "destroy_distributed_environment", lambda: None
|
|
+ )
|
|
+ proc = object.__new__(executor_module.WorkerProc)
|
|
+ proc.rpc_broadcast_mq = None
|
|
+ proc.worker_response_mq = None
|
|
+ proc.worker = _FakeInner()
|
|
+ proc.shutdown(destroy_distributed=destroy_distributed)
|
|
+ assert proc.worker.calls == 1
|
|
+ assert ple_worker._PROCESS_EXIT_IMMINENT is (not destroy_distributed)
|
|
+
|
|
+
|
|
+_FATAL_CHILD = r"""
|
|
+import os
|
|
+import sys
|
|
+import time
|
|
+
|
|
+import vllm.v1.ple_offload.worker as ple_worker
|
|
+from vllm.v1.ple_offload.fatal import ple_fail_closed
|
|
+
|
|
+
|
|
+def forbidden(*args, **kwargs):
|
|
+ os.write(1, b"CLOSE-CALLED\n")
|
|
+ time.sleep(10) # what a reap wait would cost
|
|
+
|
|
+
|
|
+ple_worker.PleOffloadWorkerHandle.close = forbidden
|
|
+ple_worker.PleOffloadWorkerHandle._close_without_reap = forbidden
|
|
+t0 = time.monotonic()
|
|
+os.write(1, b"FIRE %.6f\n" % t0)
|
|
+try:
|
|
+ raise RuntimeError("PLE test fault: exc_after_prepare")
|
|
+except RuntimeError as error:
|
|
+ ple_fail_closed(where="execute_model", rank=0, layer="l", sequence=9, error=error)
|
|
+os.write(1, b"RETURNED\n")
|
|
+"""
|
|
+
|
|
+
|
|
+def test_ple_fatal_path_exits_without_closing_or_reaping_the_offload_child():
|
|
+ """Drain fault-injection test: rank 0 died ~8.3 s after its PLE fatal line. The fatal path
|
|
+ must be a straight os._exit(1): no Worker.shutdown(), no
|
|
+ PleOffloadWorkerHandle.close() (terminate + reap wait). The offload child
|
|
+ is ended by its own death-pipe monitor when this process exits."""
|
|
+ start = time.monotonic()
|
|
+ proc = subprocess.run(
|
|
+ [sys.executable, "-c", _FATAL_CHILD],
|
|
+ capture_output=True,
|
|
+ text=True,
|
|
+ timeout=120,
|
|
+ )
|
|
+ assert proc.returncode == 1, (proc.returncode, proc.stdout, proc.stderr[-500:])
|
|
+ assert "CLOSE-CALLED" not in proc.stdout
|
|
+ assert "RETURNED" not in proc.stdout
|
|
+ assert "FIRE " in proc.stdout
|
|
+ assert "PLE fatal: where=execute_model" in proc.stderr
|
|
+ assert time.monotonic() - start < 60
|
|
diff --git a/tests/v1/executor/test_multiproc_worker_exit_after_drain.py b/tests/v1/executor/test_multiproc_worker_exit_after_drain.py
|
|
new file mode 100644
|
|
index 0000000..7694a62
|
|
--- /dev/null
|
|
+++ b/tests/v1/executor/test_multiproc_worker_exit_after_drain.py
|
|
@@ -0,0 +1,456 @@
|
|
+# SPDX-License-Identifier: Apache-2.0
|
|
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
+"""A worker whose orderly shutdown has finished must exit, not linger.
|
|
+
|
|
+A fault-injection run with the drain-order deferred-signal fix: every rank printed
|
|
+its bounds-guard FINAL DRAIN within ~1 s of the executor closing the death pipes,
|
|
+but then stayed alive past VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS (5 s); the
|
|
+executor logged two WARNINGs and SIGKILLed the workers at ~9 s. After
|
|
+Worker.shutdown() (drain, instrumentation flushes, PLE cleanup) WorkerProc
|
|
+destroys the model-parallel groups, and PyNcclCommunicator.destroy() joins its
|
|
+ncclCommAbort thread for up to 5 s per communicator while the CUDA graphs that
|
|
+captured NCCL ops are still alive. The executor's SIGTERM is then a no-op (the
|
|
+worker's handler raises SystemExit only once and the death-pipe monitor already
|
|
+set shutdown_requested), so the ladder runs to SIGKILL.
|
|
+
|
|
+The fake below reproduces that: destroy_model_parallel blocks _SLOW_DESTROY_S.
|
|
+Unfixed code pays it before the process can exit (the failing controls); fixed
|
|
+code runs WorkerProc.shutdown(destroy_distributed=False) and exits right after.
|
|
+"""
|
|
+
|
|
+import logging
|
|
+import multiprocessing
|
|
+import os
|
|
+import sys
|
|
+import threading
|
|
+import time
|
|
+from types import SimpleNamespace
|
|
+
|
|
+import pytest
|
|
+
|
|
+# CPU-only fakes: no process groups or device memory to clean up afterwards.
|
|
+pytestmark = pytest.mark.skip_global_cleanup
|
|
+
|
|
+_SLOW_DESTROY_S = 6.0 # > the executor's 5 s grace, like the NCCL destroy join
|
|
+_EXIT_BOUND_S = 3.0 # reference-host bound: all workers gone <= 3 s after the drain
|
|
+
|
|
+
|
|
+class _FakeMQ:
|
|
+ def __init__(self, entered: threading.Event | None = None):
|
|
+ self.entered = entered
|
|
+ self.stop = threading.Event()
|
|
+ self.shutting_down = False
|
|
+
|
|
+ def export_handle(self):
|
|
+ return None
|
|
+
|
|
+ def wait_until_ready(self):
|
|
+ pass
|
|
+
|
|
+ def shutdown(self):
|
|
+ self.shutting_down = True
|
|
+ self.stop.set()
|
|
+
|
|
+ def dequeue(self, indefinite=True):
|
|
+ if self.entered is not None:
|
|
+ self.entered.set()
|
|
+ # shm_broadcast.acquire_read raises RuntimeError("cancelled") once the
|
|
+ # death-pipe monitor has called shutdown().
|
|
+ self.stop.wait()
|
|
+ raise RuntimeError("cancelled")
|
|
+
|
|
+
|
|
+class _FakeInnerWorker:
|
|
+ """Stands in for gpu_worker.Worker; shutdown() is where FINAL DRAIN runs."""
|
|
+
|
|
+ def __init__(self, record):
|
|
+ self.record = record
|
|
+
|
|
+ def shutdown(self):
|
|
+ self.record("drain")
|
|
+
|
|
+
|
|
+def _patch_executor(executor_module, record, entered, slow_destroy_s, set_=setattr):
|
|
+ real_worker_proc = executor_module.WorkerProc
|
|
+
|
|
+ def fake_init(self, *args, **kwargs):
|
|
+ self.rank = 0
|
|
+ self.worker = _FakeInnerWorker(record)
|
|
+ self.rpc_broadcast_mq = _FakeMQ(entered)
|
|
+ self.worker_response_mq = _FakeMQ()
|
|
+ self.peer_response_handles = []
|
|
+ self.use_async_scheduling = False
|
|
+
|
|
+ def slow_destroy_model_parallel():
|
|
+ record("destroy_model_parallel")
|
|
+ time.sleep(slow_destroy_s)
|
|
+
|
|
+ set_(real_worker_proc, "__init__", fake_init)
|
|
+ set_(executor_module, "destroy_model_parallel", slow_destroy_model_parallel)
|
|
+ set_(
|
|
+ executor_module,
|
|
+ "destroy_distributed_environment",
|
|
+ lambda: record("destroy_distributed_environment"),
|
|
+ )
|
|
+ set_(executor_module, "maybe_init_worker_tracer", lambda **kwargs: None)
|
|
+ set_(executor_module, "set_worker_net_device", lambda *args, **kwargs: None)
|
|
+
|
|
+
|
|
+def _fake_config():
|
|
+ return SimpleNamespace(
|
|
+ parallel_config=SimpleNamespace(
|
|
+ assigned_physical_gpu_ids=None,
|
|
+ numa_bind=False,
|
|
+ )
|
|
+ )
|
|
+
|
|
+
|
|
+# ---------------------------------------------------------------------------
|
|
+# In-process: the real worker_main / WorkerProc.shutdown, os._exit intercepted.
|
|
+# ---------------------------------------------------------------------------
|
|
+
|
|
+
|
|
+class _Exited(BaseException):
|
|
+ def __init__(self, code):
|
|
+ super().__init__(code)
|
|
+ self.code = code
|
|
+
|
|
+
|
|
+def _run_worker_main_in_process(
|
|
+ monkeypatch,
|
|
+ *,
|
|
+ busy_loop_error=None,
|
|
+ fail_after_shutdown_request=False,
|
|
+ slow_destroy_s=_SLOW_DESTROY_S,
|
|
+):
|
|
+ import vllm.v1.executor.multiproc_executor as executor_module
|
|
+
|
|
+ events: list[tuple[str, float]] = []
|
|
+
|
|
+ def record(name):
|
|
+ events.append((name, time.monotonic()))
|
|
+
|
|
+ entered = threading.Event()
|
|
+ _patch_executor(
|
|
+ executor_module, record, entered, slow_destroy_s, monkeypatch.setattr
|
|
+ )
|
|
+ if busy_loop_error is not None:
|
|
+
|
|
+ def failing_busy_loop(self):
|
|
+ if fail_after_shutdown_request:
|
|
+ # Idle until the death-pipe monitor has set shutdown_requested
|
|
+ # and shut the queues down, then fail for a reason of its own.
|
|
+ entered.set()
|
|
+ assert self.rpc_broadcast_mq.stop.wait(30)
|
|
+ raise busy_loop_error
|
|
+
|
|
+ monkeypatch.setattr(
|
|
+ executor_module.WorkerProc, "worker_busy_loop", failing_busy_loop
|
|
+ )
|
|
+ # worker_main arms PDEATHSIG and installs SIGTERM/SIGINT handlers; keep
|
|
+ # both off the pytest process.
|
|
+ monkeypatch.setattr(
|
|
+ executor_module, "_install_worker_parent_death_signal", lambda pid: None
|
|
+ )
|
|
+ monkeypatch.setattr(executor_module.signal, "signal", lambda *a, **k: None)
|
|
+ monkeypatch.setattr(logging, "shutdown", lambda *a, **k: None)
|
|
+ # The orderly path marks this (pytest) process exit-imminent for the PLE
|
|
+ # handle; restore the flag so later PLE close() tests see a live process.
|
|
+ ple_worker = sys.modules.get("vllm.v1.ple_offload.worker")
|
|
+ if ple_worker is not None:
|
|
+ monkeypatch.setattr(
|
|
+ ple_worker,
|
|
+ "_PROCESS_EXIT_IMMINENT",
|
|
+ getattr(ple_worker, "_PROCESS_EXIT_IMMINENT", False),
|
|
+ raising=False,
|
|
+ )
|
|
+
|
|
+ def fake_exit(code):
|
|
+ record("os._exit")
|
|
+ raise _Exited(code)
|
|
+
|
|
+ monkeypatch.setattr(os, "_exit", fake_exit)
|
|
+
|
|
+ ctx = multiprocessing.get_context()
|
|
+ ready_reader, ready_writer = ctx.Pipe(duplex=False)
|
|
+ death_reader, death_writer = ctx.Pipe(duplex=False)
|
|
+
|
|
+ def close_death_pipe_when_idle():
|
|
+ # The executor's shutdown(): close the death pipe once the worker idles.
|
|
+ if entered.wait(30):
|
|
+ record("death_pipe_closed")
|
|
+ death_writer.close()
|
|
+
|
|
+ closer = threading.Thread(target=close_death_pipe_when_idle, daemon=True)
|
|
+ closer.start()
|
|
+
|
|
+ exit_code = None
|
|
+ start = time.monotonic()
|
|
+ try:
|
|
+ executor_module.WorkerProc.worker_main(
|
|
+ vllm_config=_fake_config(),
|
|
+ ready_pipe=ready_writer,
|
|
+ death_pipe=death_reader,
|
|
+ inherited_fds=[],
|
|
+ expected_parent_pid=os.getpid(),
|
|
+ )
|
|
+ except _Exited as exited:
|
|
+ exit_code = exited.code
|
|
+ except SystemExit as exited:
|
|
+ record("SystemExit")
|
|
+ exit_code = exited.code
|
|
+ finally:
|
|
+ entered.set() # a failing busy loop never dequeues
|
|
+ closer.join(5)
|
|
+ assert ready_reader.recv()["status"] == "READY"
|
|
+ ready_reader.close()
|
|
+ return events, exit_code, time.monotonic() - start
|
|
+
|
|
+
|
|
+def test_orderly_shutdown_exits_right_after_drain(monkeypatch):
|
|
+ events, exit_code, _ = _run_worker_main_in_process(monkeypatch)
|
|
+ names = [name for name, _ in events]
|
|
+ assert "drain" in names, f"FINAL DRAIN never ran: {names}"
|
|
+ assert exit_code is not None, (
|
|
+ "worker_main returned instead of exiting the process after its "
|
|
+ f"orderly shutdown (the process would linger in teardown): {names}"
|
|
+ )
|
|
+ assert exit_code == 0
|
|
+ assert "destroy_model_parallel" not in names, (
|
|
+ f"the exit-only NCCL destroy ran before the process exit: {names}"
|
|
+ )
|
|
+ t = dict(events)
|
|
+ assert names.index("drain") < names.index("os._exit"), names
|
|
+ assert t["os._exit"] - t["drain"] <= _EXIT_BOUND_S, events
|
|
+
|
|
+
|
|
+def test_unexpected_busy_loop_failure_keeps_full_teardown(monkeypatch):
|
|
+ # Not an orderly shutdown (no shutdown request): unchanged behaviour --
|
|
+ # full WorkerProc.shutdown incl. distributed destroy, then a normal return.
|
|
+ events, exit_code, _ = _run_worker_main_in_process(
|
|
+ monkeypatch, busy_loop_error=ValueError("boom"), slow_destroy_s=0.0
|
|
+ )
|
|
+ names = [name for name, _ in events]
|
|
+ assert exit_code is None, names
|
|
+ assert names.count("drain") == 1, names
|
|
+ assert "destroy_model_parallel" in names, names
|
|
+ assert "destroy_distributed_environment" in names, names
|
|
+
|
|
+
|
|
+def test_failure_racing_shutdown_is_not_an_orderly_exit(monkeypatch):
|
|
+ # A real busy-loop failure that happens while a shutdown
|
|
+ # request is pending must not take the orderly exit-0 path. Unfixed code
|
|
+ # treats ANY exception as orderly once shutdown_requested is set (the
|
|
+ # failing control: exit code 0, no distributed teardown).
|
|
+ events, exit_code, _ = _run_worker_main_in_process(
|
|
+ monkeypatch,
|
|
+ busy_loop_error=ValueError("boom"),
|
|
+ fail_after_shutdown_request=True,
|
|
+ slow_destroy_s=0.0,
|
|
+ )
|
|
+ names = [name for name, _ in events]
|
|
+ assert "death_pipe_closed" in names, names
|
|
+ assert exit_code not in (None, 0), (
|
|
+ f"a worker failure that raced the shutdown request exited {exit_code!r} "
|
|
+ f"(hidden as a clean exit): {names}"
|
|
+ )
|
|
+ assert "os._exit" not in names, f"took the orderly exit path: {names}"
|
|
+ assert names.count("drain") == 1, names
|
|
+ assert "destroy_model_parallel" in names, names
|
|
+ assert "destroy_distributed_environment" in names, names
|
|
+
|
|
+
|
|
+def test_busy_loop_raises_shutdown_type_only_for_its_own_cancellation():
|
|
+ import vllm.v1.executor.multiproc_executor as executor_module
|
|
+
|
|
+ proc = executor_module.WorkerProc.__new__(executor_module.WorkerProc)
|
|
+ proc.rpc_broadcast_mq = _FakeMQ()
|
|
+ proc.rpc_broadcast_mq.shutdown()
|
|
+ with pytest.raises(executor_module.WorkerShutdownRequested):
|
|
+ proc.worker_busy_loop()
|
|
+
|
|
+ class _NotShutDownMQ(_FakeMQ):
|
|
+ def dequeue(self, indefinite=True):
|
|
+ raise RuntimeError("cancelled") # but nobody shut this queue down
|
|
+
|
|
+ proc.rpc_broadcast_mq = _NotShutDownMQ()
|
|
+ with pytest.raises(RuntimeError) as info:
|
|
+ proc.worker_busy_loop()
|
|
+ assert not isinstance(info.value, executor_module.WorkerShutdownRequested)
|
|
+
|
|
+
|
|
+# ---------------------------------------------------------------------------
|
|
+# Real processes: the executor's own grace/SIGTERM/SIGKILL ladder.
|
|
+# ---------------------------------------------------------------------------
|
|
+
|
|
+
|
|
+def _worker_child(ready_pipe, death_pipe, events_pipe, parent_pid):
|
|
+ import vllm.v1.executor.multiproc_executor as executor_module
|
|
+
|
|
+ def record(name):
|
|
+ events_pipe.send((name, time.monotonic()))
|
|
+
|
|
+ _patch_executor(executor_module, record, None, _SLOW_DESTROY_S)
|
|
+ executor_module.WorkerProc.worker_main(
|
|
+ vllm_config=_fake_config(),
|
|
+ ready_pipe=ready_pipe,
|
|
+ death_pipe=death_pipe,
|
|
+ inherited_fds=[],
|
|
+ expected_parent_pid=parent_pid,
|
|
+ )
|
|
+
|
|
+
|
|
+def test_executor_reaps_drained_workers_within_grace():
|
|
+ import vllm.v1.executor.multiproc_executor as executor_module
|
|
+
|
|
+ ctx = multiprocessing.get_context("spawn")
|
|
+ procs, deaths, readers = [], [], []
|
|
+ for _ in range(2): # two ranks, as the executor sees them
|
|
+ ready_reader, ready_writer = ctx.Pipe(duplex=False)
|
|
+ death_reader, death_writer = ctx.Pipe(duplex=False)
|
|
+ events_reader, events_writer = ctx.Pipe(duplex=False)
|
|
+ proc = ctx.Process(
|
|
+ target=_worker_child,
|
|
+ args=(ready_writer, death_reader, events_writer, os.getpid()),
|
|
+ daemon=True,
|
|
+ )
|
|
+ proc.start()
|
|
+ ready_writer.close()
|
|
+ death_reader.close()
|
|
+ events_writer.close()
|
|
+ assert ready_reader.poll(180), "worker child never became READY"
|
|
+ assert ready_reader.recv()["status"] == "READY"
|
|
+ ready_reader.close()
|
|
+ procs.append(proc)
|
|
+ deaths.append(death_writer)
|
|
+ readers.append(events_reader)
|
|
+
|
|
+ warnings: list[str] = []
|
|
+
|
|
+ class _Capture(logging.Handler):
|
|
+ def emit(self, record):
|
|
+ warnings.append(record.getMessage())
|
|
+
|
|
+ handler = _Capture(level=logging.WARNING)
|
|
+ executor_module.logger.addHandler(handler)
|
|
+ try:
|
|
+ start = time.monotonic()
|
|
+ for death_writer in deaths: # MultiprocExecutor.shutdown()
|
|
+ death_writer.close()
|
|
+ executor_module.MultiprocExecutor._ensure_worker_termination(procs)
|
|
+ elapsed = time.monotonic() - start
|
|
+ finally:
|
|
+ executor_module.logger.removeHandler(handler)
|
|
+ for proc in procs:
|
|
+ if proc.is_alive():
|
|
+ proc.kill()
|
|
+ proc.join(10)
|
|
+
|
|
+ for rank, reader in enumerate(readers):
|
|
+ names = []
|
|
+ # The child has exited: read what it sent until EOF (Windows reports a
|
|
+ # closed pipe as BrokenPipeError).
|
|
+ while True:
|
|
+ try:
|
|
+ if not reader.poll(0.5):
|
|
+ break
|
|
+ names.append(reader.recv()[0])
|
|
+ except (EOFError, OSError):
|
|
+ break
|
|
+ assert "drain" in names, f"rank {rank} never drained: {names}"
|
|
+ assert not warnings, f"executor escalated past the grace period: {warnings}"
|
|
+ assert elapsed <= _EXIT_BOUND_S, (
|
|
+ f"workers took {elapsed:.1f}s to exit after the death pipes closed"
|
|
+ )
|
|
+ assert [proc.exitcode for proc in procs] == [0, 0]
|
|
+
|
|
+
|
|
+def _stuck_worker_child(ready_pipe, death_pipe, events_pipe, parent_pid):
|
|
+ import vllm.v1.executor.multiproc_executor as executor_module
|
|
+
|
|
+ def record(name):
|
|
+ events_pipe.send((name, time.monotonic()))
|
|
+
|
|
+ _patch_executor(executor_module, record, None, 0.0)
|
|
+
|
|
+ def stuck_busy_loop(self):
|
|
+ # Never leaves the loop and ignores the queue shutdown (a hung RPC).
|
|
+ record("busy")
|
|
+ while True:
|
|
+ time.sleep(0.05)
|
|
+
|
|
+ executor_module.WorkerProc.worker_busy_loop = stuck_busy_loop
|
|
+ executor_module.WorkerProc.worker_main(
|
|
+ vllm_config=_fake_config(),
|
|
+ ready_pipe=ready_pipe,
|
|
+ death_pipe=death_pipe,
|
|
+ inherited_fds=[],
|
|
+ expected_parent_pid=parent_pid,
|
|
+ )
|
|
+
|
|
+
|
|
+def test_ladder_bounds_a_worker_stuck_in_its_busy_loop(monkeypatch):
|
|
+ """Documents (does not change) the bounded ladder.
|
|
+
|
|
+ A READY worker that never leaves its busy loop after the death pipe
|
|
+ closes: the death-pipe monitor has already set shutdown_requested, so the
|
|
+ executor's SIGTERM is a no-op in the worker's handler and the ladder ends
|
|
+ in SIGKILL -- that rank's FINAL DRAIN does not run. The bound is the
|
|
+ property: grace + 4 s, never longer.
|
|
+ """
|
|
+ import vllm.envs as envs
|
|
+ import vllm.v1.executor.multiproc_executor as executor_module
|
|
+
|
|
+ grace_s = 1
|
|
+ monkeypatch.setattr(
|
|
+ envs, "VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS", grace_s, raising=False
|
|
+ )
|
|
+ ctx = multiprocessing.get_context("spawn")
|
|
+ ready_reader, ready_writer = ctx.Pipe(duplex=False)
|
|
+ death_reader, death_writer = ctx.Pipe(duplex=False)
|
|
+ events_reader, events_writer = ctx.Pipe(duplex=False)
|
|
+ proc = ctx.Process(
|
|
+ target=_stuck_worker_child,
|
|
+ args=(ready_writer, death_reader, events_writer, os.getpid()),
|
|
+ daemon=True,
|
|
+ )
|
|
+ proc.start()
|
|
+ ready_writer.close()
|
|
+ death_reader.close()
|
|
+ events_writer.close()
|
|
+ assert ready_reader.poll(180), "worker child never became READY"
|
|
+ assert ready_reader.recv()["status"] == "READY"
|
|
+ ready_reader.close()
|
|
+
|
|
+ warnings: list[str] = []
|
|
+
|
|
+ class _Capture(logging.Handler):
|
|
+ def emit(self, record):
|
|
+ warnings.append(record.getMessage())
|
|
+
|
|
+ handler = _Capture(level=logging.WARNING)
|
|
+ executor_module.logger.addHandler(handler)
|
|
+ try:
|
|
+ start = time.monotonic()
|
|
+ death_writer.close()
|
|
+ executor_module.MultiprocExecutor._ensure_worker_termination([proc])
|
|
+ elapsed = time.monotonic() - start
|
|
+ finally:
|
|
+ executor_module.logger.removeHandler(handler)
|
|
+ if proc.is_alive():
|
|
+ proc.kill()
|
|
+ proc.join(10)
|
|
+ names = []
|
|
+ while True:
|
|
+ try:
|
|
+ if not events_reader.poll(0.5):
|
|
+ break
|
|
+ names.append(events_reader.recv()[0])
|
|
+ except (EOFError, OSError):
|
|
+ break
|
|
+ assert "busy" in names, names
|
|
+ assert "drain" not in names, names # the documented gap: SIGKILL, no drain
|
|
+ assert any("after grace period" in w for w in warnings), warnings
|
|
+ assert any("sending SIGKILL" in w for w in warnings), warnings
|
|
+ assert proc.exitcode == -9, proc.exitcode
|
|
+ assert elapsed <= grace_s + 4 + 2, f"ladder took {elapsed:.1f}s"
|
|
diff --git a/vllm/v1/engine/core.py b/vllm/v1/engine/core.py
|
|
index 110f56d..72da32e 100644
|
|
--- a/vllm/v1/engine/core.py
|
|
+++ b/vllm/v1/engine/core.py
|
|
@@ -4,12 +4,13 @@ import gc
|
|
import os
|
|
import queue
|
|
import signal
|
|
+import sys
|
|
import threading
|
|
import time
|
|
from collections import defaultdict, deque
|
|
from collections.abc import Callable, Generator, Sequence
|
|
from concurrent.futures import Future
|
|
-from contextlib import ExitStack, contextmanager
|
|
+from contextlib import ExitStack, contextmanager, suppress
|
|
from enum import IntEnum
|
|
from functools import partial
|
|
from inspect import isclass, signature
|
|
@@ -102,6 +103,76 @@ HANDSHAKE_TIMEOUT_MINS = 5
|
|
_R = TypeVar("_R") # Return type for collective_rpc
|
|
|
|
|
|
+# How long EngineCore teardown may run after it deferred a late SIGTERM/SIGINT
|
|
+# before the process exits anyway. Covers the whole of engine_core.shutdown(),
|
|
+# not only the executor's worker ladder (grace + 4 s): a hang in later teardown
|
|
+# (scheduler, distributed cleanup) must not make the process unkillable.
|
|
+_TEARDOWN_SIGNAL_BOUND_S = 30.0
|
|
+
|
|
+
|
|
+class _TeardownSignalGuard:
|
|
+ """EngineCore teardown: defer ONE late SIGTERM/SIGINT, bounded.
|
|
+
|
|
+ Dying on the first late signal would SIGKILL every worker
|
|
+ (PR_SET_PDEATHSIG) before its shutdown drain; see the finally block of
|
|
+ EngineCoreProc.run_engine_core. The deferral is bounded two ways:
|
|
+ - a SECOND late signal restores the default disposition and re-delivers
|
|
+ itself, so the process dies of it (the maintainer's escalation);
|
|
+ - after the first deferred signal a watchdog ends the process with
|
|
+ exit code 1 if teardown is still running _TEARDOWN_SIGNAL_BOUND_S later.
|
|
+ """
|
|
+
|
|
+ def __init__(self, bound_s: float | None = None) -> None:
|
|
+ self.bound_s = _TEARDOWN_SIGNAL_BOUND_S if bound_s is None else bound_s
|
|
+ self.deferred = 0
|
|
+ self._done = threading.Event()
|
|
+ self._watchdog: threading.Thread | None = None
|
|
+
|
|
+ def __call__(self, signum, frame) -> None:
|
|
+ name = signal.Signals(signum).name
|
|
+ if self._done.is_set():
|
|
+ return
|
|
+ self.deferred += 1
|
|
+ if self.deferred == 1:
|
|
+ logger.warning(
|
|
+ "[shutdown] EngineCore: %s during resource teardown deferred; "
|
|
+ "workers finish their shutdown (FINAL DRAIN) first (a second "
|
|
+ "signal, or %.0f s of teardown, ends the process)",
|
|
+ name,
|
|
+ self.bound_s,
|
|
+ )
|
|
+ self._watchdog = threading.Thread(
|
|
+ target=self._expire,
|
|
+ daemon=True,
|
|
+ name="EngineCoreTeardownWatchdog",
|
|
+ )
|
|
+ self._watchdog.start()
|
|
+ return
|
|
+ logger.warning(
|
|
+ "[shutdown] EngineCore: second late signal (%s) during resource "
|
|
+ "teardown; not deferring again",
|
|
+ name,
|
|
+ )
|
|
+ signal.signal(signum, signal.SIG_DFL)
|
|
+ os.kill(os.getpid(), signum)
|
|
+
|
|
+ def _expire(self) -> None:
|
|
+ if self._done.wait(self.bound_s):
|
|
+ return
|
|
+ logger.error(
|
|
+ "[shutdown] EngineCore: resource teardown still running %.0f s "
|
|
+ "after a deferred signal; exiting (code 1)",
|
|
+ self.bound_s,
|
|
+ )
|
|
+ for stream in (sys.stdout, sys.stderr):
|
|
+ with suppress(Exception):
|
|
+ stream.flush()
|
|
+ os._exit(1)
|
|
+
|
|
+ def disarm(self) -> None:
|
|
+ self._done.set()
|
|
+
|
|
+
|
|
class EngineCore:
|
|
"""Inner loop of vLLM's Engine."""
|
|
|
|
@@ -1371,12 +1442,29 @@ class EngineCoreProc(EngineCore):
|
|
engine_core._send_engine_dead()
|
|
raise e
|
|
finally:
|
|
- signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
|
- signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
+ # Workers carry PR_SET_PDEATHSIG=SIGKILL (fail-closed PLE teardown),
|
|
+ # so if this process dies before model_executor.shutdown() has reaped
|
|
+ # them, every worker is SIGKILLed mid-shutdown and its bounds-guard
|
|
+ # FINAL DRAIN never runs. The API server's process manager sends a
|
|
+ # second SIGTERM right after the signal that started this shutdown;
|
|
+ # with SIG_DFL here it killed teardown before the executor waited for
|
|
+ # the workers. Defer ONE late signal until teardown ends; a second
|
|
+ # signal or _TEARDOWN_SIGNAL_BOUND_S of teardown after the deferral
|
|
+ # still ends the process (_TeardownSignalGuard), as do the
|
|
+ # executor's terminate/kill ladder and the process manager's timeout
|
|
+ # (kill_process_tree -> SIGKILL).
|
|
+ teardown_guard = _TeardownSignalGuard()
|
|
+ signal.signal(signal.SIGTERM, teardown_guard)
|
|
+ signal.signal(signal.SIGINT, teardown_guard)
|
|
if signal_callback is not None:
|
|
signal_callback.stop()
|
|
- if engine_core is not None:
|
|
- engine_core.shutdown()
|
|
+ try:
|
|
+ if engine_core is not None:
|
|
+ engine_core.shutdown()
|
|
+ finally:
|
|
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
|
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
+ teardown_guard.disarm()
|
|
if clean_shutdown:
|
|
from vllm.platforms import current_platform
|
|
|
|
diff --git a/vllm/v1/executor/multiproc_executor.py b/vllm/v1/executor/multiproc_executor.py
|
|
index fc744cb..0c5af34 100644
|
|
--- a/vllm/v1/executor/multiproc_executor.py
|
|
+++ b/vllm/v1/executor/multiproc_executor.py
|
|
@@ -114,6 +114,65 @@ def _install_worker_parent_death_signal(expected_parent_pid: int) -> None:
|
|
os._exit(1)
|
|
|
|
|
|
+# Grace and post-SIGTERM wait, after a worker's death, for survivors that never
|
|
+# started their shutdown (see MultiprocExecutor._ensure_worker_termination).
|
|
+_FAILED_WORKER_WAIT_S = 1.0
|
|
+# After a worker's death, the bounded grace for survivors whose shutdown has
|
|
+# STARTED (FINAL DRAIN in progress). Every FINAL DRAIN measured on the reference host took
|
|
+# < 0.1 s (drain-path tests: the drain line lands within ~0.05 s of the
|
|
+# shutdown request); 2 s is 20x that, so a draining rank is not cut
|
|
+# (a shorter cap did), while one blocked inside its shutdown on the dead rank is ended.
|
|
+_FAILED_DRAIN_GRACE_S = 2.0
|
|
+# Per-worker shared byte (WorkerProcHandle.shutdown_state): written by the
|
|
+# worker in worker_main's finally, read by the executor's termination ladder.
|
|
+_SHUTDOWN_NOT_STARTED = 0
|
|
+_SHUTDOWN_STARTED = 1
|
|
+_SHUTDOWN_DONE = 2
|
|
+
|
|
+
|
|
+class WorkerShutdownRequested(RuntimeError):
|
|
+ """The busy loop's dequeue was cancelled by a shutdown request.
|
|
+
|
|
+ Raised only by WorkerProc.worker_busy_loop when the RPC broadcast queue
|
|
+ reports its own cancellation (shm_broadcast: RuntimeError("cancelled")
|
|
+ after MessageQueue.shutdown(), which the death-pipe monitor calls once the
|
|
+ parent closes the pipe). worker_main takes the orderly exit path on this
|
|
+ type only; any other exception is a failure, even one that races a
|
|
+ shutdown request. Subclasses RuntimeError so existing catchers still match.
|
|
+ """
|
|
+
|
|
+
|
|
+def _exit_worker_process(exit_code: int) -> None:
|
|
+ """End a worker process whose orderly shutdown has already run.
|
|
+
|
|
+ Called after WorkerProc.shutdown(destroy_distributed=False): the FINAL
|
|
+ DRAIN, the instrumentation flushes and the PLE offload / shared-memory
|
|
+ cleanup are done. What would remain is process-exit-only teardown --
|
|
+ destroying the NCCL communicators (PyNcclCommunicator.destroy joins its
|
|
+ ncclCommAbort thread for up to 5 s per communicator while CUDA graphs that
|
|
+ captured NCCL ops are still alive) and interpreter finalization (CUDA
|
|
+ context, allocator pools). The driver and kernel reclaim all of it at
|
|
+ process exit, and without this the executor's 5 s grace expired and the
|
|
+ worker was SIGKILLed at ~9 s anyway. Flush the logs,
|
|
+ then leave.
|
|
+ """
|
|
+ logger.info(
|
|
+ "[shutdown] WorkerProc: shutdown complete; exiting process (code %d) "
|
|
+ "without distributed teardown",
|
|
+ exit_code,
|
|
+ )
|
|
+ try:
|
|
+ import logging
|
|
+
|
|
+ logging.shutdown()
|
|
+ except Exception: # noqa: BLE001 -- the exit must happen regardless
|
|
+ pass
|
|
+ for stream in (sys.stdout, sys.stderr):
|
|
+ with suppress(Exception):
|
|
+ stream.flush()
|
|
+ os._exit(exit_code)
|
|
+
|
|
+
|
|
class FutureWrapper(Future):
|
|
def __init__(
|
|
self,
|
|
@@ -491,10 +550,36 @@ class MultiprocExecutor(Executor):
|
|
return future if non_block else future.result()
|
|
|
|
@staticmethod
|
|
- def _ensure_worker_termination(worker_procs: list[BaseProcess]):
|
|
+ def _ensure_worker_termination(
|
|
+ worker_procs: list[BaseProcess],
|
|
+ *,
|
|
+ after_failure: bool = False,
|
|
+ shutdown_states: list[Any] | None = None,
|
|
+ ):
|
|
"""Ensure that all worker processes are terminated. Assumes workers have
|
|
received termination requests. Waits for processing, then sends
|
|
- termination and kill signals if needed."""
|
|
+ termination and kill signals if needed.
|
|
+
|
|
+ after_failure: a worker already died. Survivors split in two. Those
|
|
+ that never started WorkerProc.shutdown() are parked in a collective
|
|
+ with the dead rank (NCCL / gloo, in C, where neither the grace nor a
|
|
+ SIGTERM reaches them -- in a fault-injection test two ranks ran the full
|
|
+ 5 s + 4 s ladder to SIGKILL): they get _FAILED_WORKER_WAIT_S to start
|
|
+ their shutdown, then SIGTERM, then SIGKILL after as long again. Those
|
|
+ whose shutdown_states entry shows the shutdown started (FINAL DRAIN,
|
|
+ flushes) get _FAILED_DRAIN_GRACE_S from the start of the ladder, then
|
|
+ SIGTERM, then SIGKILL after _FAILED_WORKER_WAIT_S: a started shutdown
|
|
+ can itself block on the dead rank (in a drain fault-injection test one
|
|
+ survivor had started its shutdown and never logged its FINAL DRAIN --
|
|
+ the drain's device synchronize waits on work paired with the dead
|
|
+ rank -- and ran the full orderly ladder, workers gone 17.6 s after the
|
|
+ fault). Before the drain-order fix the engine died on the late SIGTERM and
|
|
+ PDEATHSIG killed all survivors at once. A worker with no state (None,
|
|
+ or no shutdown_states) counts as parked. The orderly ladder (no
|
|
+ failure) is unchanged.
|
|
+ """
|
|
+ grace_s = float(envs.VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS)
|
|
+ term_wait_s = 4.0
|
|
|
|
def wait_for_termination(procs, timeout):
|
|
if not time:
|
|
@@ -516,9 +601,42 @@ class MultiprocExecutor(Executor):
|
|
"[shutdown] Executor: waiting for worker exit count=%d",
|
|
initial_count,
|
|
)
|
|
- if wait_for_termination(
|
|
- active_procs(), timeout=envs.VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS
|
|
- ):
|
|
+ if after_failure and time:
|
|
+ start = time.time()
|
|
+ states = list(shutdown_states or [None] * len(worker_procs))
|
|
+
|
|
+ def parked() -> list[BaseProcess]:
|
|
+ return [
|
|
+ proc
|
|
+ for proc, state in zip(worker_procs, states)
|
|
+ if proc.is_alive()
|
|
+ and (state is None or state.value == _SHUTDOWN_NOT_STARTED)
|
|
+ ]
|
|
+
|
|
+ if not wait_for_termination(active_procs(), _FAILED_WORKER_WAIT_S):
|
|
+ stuck = parked()
|
|
+ if stuck:
|
|
+ logger.warning(
|
|
+ "[shutdown] Executor: workers parked after a worker "
|
|
+ "failure (shutdown not started); sending SIGTERM count=%d",
|
|
+ len(stuck),
|
|
+ )
|
|
+ for p in stuck:
|
|
+ p.terminate()
|
|
+ if not wait_for_termination(stuck, _FAILED_WORKER_WAIT_S):
|
|
+ stuck = [p for p in stuck if p.is_alive()]
|
|
+ logger.warning(
|
|
+ "[shutdown] Executor: parked workers still running "
|
|
+ "after SIGTERM; sending SIGKILL count=%d",
|
|
+ len(stuck),
|
|
+ )
|
|
+ for p in stuck:
|
|
+ p.kill()
|
|
+ # Workers that started their shutdown get a BOUNDED grace, counted
|
|
+ # from the start of this ladder, then SIGTERM, then SIGKILL.
|
|
+ grace_s = max(0.0, _FAILED_DRAIN_GRACE_S - (time.time() - start))
|
|
+ term_wait_s = _FAILED_WORKER_WAIT_S
|
|
+ if wait_for_termination(active_procs(), timeout=grace_s):
|
|
logger.info_once("[shutdown] Executor: all workers exited gracefully")
|
|
return
|
|
|
|
@@ -531,7 +649,7 @@ class MultiprocExecutor(Executor):
|
|
)
|
|
for p in remaining:
|
|
p.terminate()
|
|
- if not wait_for_termination(active_procs(), 4):
|
|
+ if not wait_for_termination(active_procs(), term_wait_s):
|
|
# Send SIGKILL if still running
|
|
remaining = active_procs()
|
|
logger.warning(
|
|
@@ -559,7 +677,13 @@ class MultiprocExecutor(Executor):
|
|
if w.death_writer is not None:
|
|
w.death_writer.close()
|
|
w.death_writer = None
|
|
- self._ensure_worker_termination([w.proc for w in workers])
|
|
+ self._ensure_worker_termination(
|
|
+ [w.proc for w in workers],
|
|
+ after_failure=getattr(self, "is_failed", False),
|
|
+ shutdown_states=[
|
|
+ getattr(w, "shutdown_state", None) for w in workers
|
|
+ ],
|
|
+ )
|
|
|
|
for w in workers:
|
|
# Shutdown response queues
|
|
@@ -610,6 +734,7 @@ class UnreadyWorkerProcHandle:
|
|
rank: int
|
|
ready_pipe: Connection
|
|
death_writer: Connection | None = None
|
|
+ shutdown_state: Any = None
|
|
|
|
|
|
@dataclass
|
|
@@ -623,6 +748,8 @@ class WorkerProcHandle:
|
|
# `peer_worker_response_mqs[i]`
|
|
peer_worker_response_mqs: list[MessageQueue | None]
|
|
death_writer: Connection | None = None
|
|
+ # Shared byte: _SHUTDOWN_NOT_STARTED / _STARTED / _DONE (worker writes).
|
|
+ shutdown_state: Any = None
|
|
|
|
@classmethod
|
|
def from_unready_handle(
|
|
@@ -637,6 +764,7 @@ class WorkerProcHandle:
|
|
worker_response_mq=worker_response_mq,
|
|
peer_worker_response_mqs=peer_worker_response_mqs,
|
|
death_writer=unready_handle.death_writer,
|
|
+ shutdown_state=unready_handle.shutdown_state,
|
|
)
|
|
|
|
|
|
@@ -775,6 +903,8 @@ class WorkerProc:
|
|
ready_reader, ready_writer = context.Pipe(duplex=False)
|
|
# Death pipe to let child detect parent process exit
|
|
death_reader, death_writer = context.Pipe(duplex=False)
|
|
+ # One shared byte: has this worker started / finished its shutdown?
|
|
+ shutdown_state = context.RawValue("b", _SHUTDOWN_NOT_STARTED)
|
|
if inherited_fds is not None:
|
|
inherited_fds = inherited_fds.copy()
|
|
inherited_fds.extend((ready_reader.fileno(), death_writer.fileno()))
|
|
@@ -787,6 +917,7 @@ class WorkerProc:
|
|
"input_shm_handle": input_shm_handle,
|
|
"ready_pipe": ready_writer,
|
|
"death_pipe": death_reader,
|
|
+ "shutdown_state": shutdown_state,
|
|
"shared_worker_lock": shared_worker_lock,
|
|
"is_driver_worker": is_driver_worker,
|
|
# Have the worker close parent end of this worker's pipes too
|
|
@@ -811,7 +942,9 @@ class WorkerProc:
|
|
death_reader.close()
|
|
# Keep death_writer open in parent - when parent exits,
|
|
# death_reader in child will get EOFError
|
|
- return UnreadyWorkerProcHandle(proc, rank, ready_reader, death_writer)
|
|
+ return UnreadyWorkerProcHandle(
|
|
+ proc, rank, ready_reader, death_writer, shutdown_state
|
|
+ )
|
|
|
|
@staticmethod
|
|
def wait_for_response_handle_ready(
|
|
@@ -872,7 +1005,14 @@ class WorkerProc:
|
|
|
|
return cast(list[WorkerProcHandle], ready_proc_handles)
|
|
|
|
- def shutdown(self):
|
|
+ def shutdown(self, *, destroy_distributed: bool = True):
|
|
+ if not destroy_distributed:
|
|
+ # worker_main os._exits right after this: the PLE offload handle
|
|
+ # (rank 0 owns the child) must not wait for the child's reap,
|
|
+ # which trails its exit by seconds for a large host table.
|
|
+ ple_worker = sys.modules.get("vllm.v1.ple_offload.worker")
|
|
+ if ple_worker is not None:
|
|
+ ple_worker.mark_process_exit_imminent()
|
|
if self.rpc_broadcast_mq is not None:
|
|
self.rpc_broadcast_mq.shutdown()
|
|
if self.worker_response_mq is not None:
|
|
@@ -880,8 +1020,12 @@ class WorkerProc:
|
|
self.worker.shutdown()
|
|
self.rpc_broadcast_mq = None
|
|
self.worker_response_mq = None
|
|
- destroy_model_parallel()
|
|
- destroy_distributed_environment()
|
|
+ # worker_main passes False when the process exits right after this
|
|
+ # (see _exit_worker_process): the NCCL destroy is exit-only work that
|
|
+ # can block for 5 s per communicator.
|
|
+ if destroy_distributed:
|
|
+ destroy_model_parallel()
|
|
+ destroy_distributed_environment()
|
|
|
|
def monitor_death_pipe(self, death_pipe, shutdown_requested: threading.Event):
|
|
if death_pipe is None:
|
|
@@ -948,8 +1092,20 @@ class WorkerProc:
|
|
set_worker_net_device(kwargs.get("local_rank", 0), kwargs["vllm_config"])
|
|
|
|
worker = None
|
|
+ # Set once a READY worker leaves its busy loop because shutdown was
|
|
+ # requested (death pipe EOF or SIGTERM/SIGINT): the process then exits
|
|
+ # straight after WorkerProc.shutdown (see _exit_worker_process).
|
|
+ orderly_shutdown = False
|
|
+ # Set when a READY worker's busy loop FAILED while a shutdown request
|
|
+ # was pending: full teardown, then exit code 1 (never the exit-0 path).
|
|
+ failed_during_shutdown = False
|
|
ready_writer = kwargs.pop("ready_pipe")
|
|
death_pipe = kwargs.pop("death_pipe", None)
|
|
+ shutdown_state = kwargs.pop("shutdown_state", None)
|
|
+
|
|
+ def mark_shutdown(value: int) -> None:
|
|
+ if shutdown_state is not None:
|
|
+ shutdown_state.value = value
|
|
|
|
# Close inherited pipes from parent (incl. other worker pipes)
|
|
# Explicitly passing in existing pipes and closing them makes the pipe
|
|
@@ -996,7 +1152,7 @@ class WorkerProc:
|
|
|
|
worker.worker_busy_loop()
|
|
|
|
- except Exception:
|
|
+ except Exception as e:
|
|
# NOTE: if an Exception arises in busy_loop, we send
|
|
# a FAILURE message over the MQ RPC to notify the Executor,
|
|
# which triggers system shutdown.
|
|
@@ -1004,10 +1160,20 @@ class WorkerProc:
|
|
|
|
if ready_writer is not None:
|
|
logger.exception("WorkerProc failed to start.")
|
|
- elif shutdown_requested.is_set():
|
|
+ elif isinstance(e, WorkerShutdownRequested) and (
|
|
+ shutdown_requested.is_set()
|
|
+ ):
|
|
+ # The busy loop left BECAUSE of the shutdown request (its
|
|
+ # dequeue was cancelled), not because something failed.
|
|
logger.debug_once(
|
|
"[shutdown] WorkerProc: exiting after shutdown request"
|
|
)
|
|
+ orderly_shutdown = True
|
|
+ elif shutdown_requested.is_set():
|
|
+ # A real failure that raced a shutdown request: keep it a
|
|
+ # failure -- full teardown, then a non-zero exit.
|
|
+ logger.exception("WorkerProc failed during shutdown.")
|
|
+ failed_during_shutdown = True
|
|
else:
|
|
logger.exception("WorkerProc failed.")
|
|
|
|
@@ -1023,6 +1189,7 @@ class WorkerProc:
|
|
logger.debug_once(
|
|
"[shutdown] WorkerProc: terminated by shutdown signal"
|
|
)
|
|
+ orderly_shutdown = ready_writer is None
|
|
else:
|
|
logger.warning("WorkerProc was terminated")
|
|
# SystemExit must never be ignored
|
|
@@ -1035,7 +1202,26 @@ class WorkerProc:
|
|
death_pipe.close()
|
|
# Clean up once worker exits busy loop
|
|
if worker is not None:
|
|
+ # Tells the executor's ladder this rank is draining, not parked
|
|
+ # in a collective (see _ensure_worker_termination).
|
|
+ mark_shutdown(_SHUTDOWN_STARTED)
|
|
+ if orderly_shutdown:
|
|
+ # Everything that must happen before exit (FINAL DRAIN,
|
|
+ # instrumentation flushes, PLE cleanup) is in
|
|
+ # worker.shutdown(); then exit instead of paying the NCCL
|
|
+ # destroy + finalization past the executor's grace.
|
|
+ exit_code = 0
|
|
+ try:
|
|
+ worker.shutdown(destroy_distributed=False)
|
|
+ except BaseException:
|
|
+ logger.exception("WorkerProc shutdown failed")
|
|
+ exit_code = 1
|
|
+ mark_shutdown(_SHUTDOWN_DONE)
|
|
+ _exit_worker_process(exit_code)
|
|
worker.shutdown()
|
|
+ mark_shutdown(_SHUTDOWN_DONE)
|
|
+ if failed_during_shutdown:
|
|
+ sys.exit(1)
|
|
|
|
class ResponseStatus(Enum):
|
|
SUCCESS = auto()
|
|
@@ -1116,8 +1302,20 @@ class WorkerProc:
|
|
def worker_busy_loop(self):
|
|
"""Main busy loop for Multiprocessing Workers"""
|
|
assert self.rpc_broadcast_mq is not None
|
|
+ mq = self.rpc_broadcast_mq
|
|
while True:
|
|
- self._execute_worker_rpc(self.rpc_broadcast_mq.dequeue(indefinite=True))
|
|
+ # No local holds the request across the next dequeue: its payload
|
|
+ # must be released first (test_multiproc_executor).
|
|
+ self._execute_worker_rpc(self._dequeue_worker_rpc(mq))
|
|
+
|
|
+ @staticmethod
|
|
+ def _dequeue_worker_rpc(mq: MessageQueue):
|
|
+ try:
|
|
+ return mq.dequeue(indefinite=True)
|
|
+ except RuntimeError as e:
|
|
+ if getattr(mq, "shutting_down", False) and str(e) == "cancelled":
|
|
+ raise WorkerShutdownRequested("RPC broadcast queue shut down") from e
|
|
+ raise
|
|
|
|
def _execute_worker_rpc(
|
|
self,
|
|
diff --git a/vllm/v1/ple_offload/worker.py b/vllm/v1/ple_offload/worker.py
|
|
index 3d29407..608f364 100644
|
|
--- a/vllm/v1/ple_offload/worker.py
|
|
+++ b/vllm/v1/ple_offload/worker.py
|
|
@@ -102,6 +102,86 @@ def _unlink_ipc_socket(ipc_addr: str) -> None:
|
|
logger.warning("Failed to unlink PLE IPC socket %s: %s", path, error)
|
|
|
|
|
|
+# Set once by a GPU worker process whose orderly shutdown is about to end in
|
|
+# os._exit (multiproc_executor._exit_worker_process). PleOffloadWorkerHandle
|
|
+# then waits for the offload child's whole thread group to be in the kernel's
|
|
+# exit path, not for its REAP. In a drain fault-injection test the ~51 GB
|
|
+# child's leader went Z at ~+2.7 s, but one thread stayed R -- PF_EXITING set,
|
|
+# in-kernel, freeing the address space -- until the reap at ~+8.6 s. Rank 0
|
|
+# waiting for that reap ran the executor's grace -> SIGTERM -> SIGKILL ladder on
|
|
+# every cycle. A read-only sample of one such shutdown showed
|
|
+# the same thing: from Z to reap (6.0 s), the only live task was R with
|
|
+# PF_EXITING, the leader's fd table was already closed (0 /dev/nvidia fds,
|
|
+# down from 13), and the child was never an nvidia-smi compute app, before or
|
|
+# after. It holds no GPU memory (host-mapped pull: no CUDA context). What is
|
|
+# left is kernel-side host-memory teardown, and nothing in this process
|
|
+# needs to wait for it.
|
|
+_PROCESS_EXIT_IMMINENT = False
|
|
+# Bound for the child's userspace cleanup (its proc_main finally) on that path;
|
|
+# it took ~2.7 s on the reference host. It has to fit inside the executor's 5 s
|
|
+# VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS with room for rank 0's own exit. After it,
|
|
+# the child is SIGKILLed (userspace ends at once) and not waited for.
|
|
+_EXIT_IMMINENT_CHILD_WAIT_S = 3.5
|
|
+# procfs mount the predicate reads (tests point it at a fake tree).
|
|
+_PROC_ROOT = "/proc"
|
|
+# task_struct flag: set by do_exit() -> exit_signals() before exit_mm(); a task
|
|
+# carrying it never returns to userspace.
|
|
+_PF_EXITING = 0x4
|
|
+
|
|
+
|
|
+def mark_process_exit_imminent() -> None:
|
|
+ """This process os._exits right after its shutdown; see close()."""
|
|
+ global _PROCESS_EXIT_IMMINENT
|
|
+ _PROCESS_EXIT_IMMINENT = True
|
|
+
|
|
+
|
|
+def _task_in_exit(stat: bytes) -> bool:
|
|
+ """One /proc/<pid>/task/<tid>/stat: Z/X, or PF_EXITING (kernel exit path)."""
|
|
+ # Fields after the parenthesised comm (which may contain spaces/parens):
|
|
+ # [0]=state ... [6]=flags (stat field 9).
|
|
+ fields = stat[stat.rfind(b")") + 2 :].split()
|
|
+ if not fields:
|
|
+ return True
|
|
+ if fields[0] in (b"Z", b"X"):
|
|
+ return True
|
|
+ try:
|
|
+ return bool(int(fields[6]) & _PF_EXITING)
|
|
+ except (IndexError, ValueError):
|
|
+ return False
|
|
+
|
|
+
|
|
+def _child_thread_group_exiting(
|
|
+ proc: subprocess.Popen, proc_root: str | None = None
|
|
+) -> bool:
|
|
+ """True once EVERY task of ``proc`` is exited or in the kernel exit path.
|
|
+
|
|
+ A zombie leader alone is not enough (a thread can still run userspace
|
|
+ after the leader exits). Never reaps: the child is unreaped, so its PID
|
|
+ cannot be reused and <proc_root>/<pid>/task is its own. Off Linux, only a
|
|
+ reaped child counts.
|
|
+ """
|
|
+ if proc.returncode is not None:
|
|
+ return True
|
|
+ task_dir = os.path.join(proc_root or _PROC_ROOT, str(proc.pid), "task")
|
|
+ try:
|
|
+ tids = os.listdir(task_dir)
|
|
+ except FileNotFoundError:
|
|
+ return True
|
|
+ except OSError:
|
|
+ return proc.poll() is not None
|
|
+ for tid in tids:
|
|
+ try:
|
|
+ with open(os.path.join(task_dir, tid, "stat"), "rb") as fh:
|
|
+ stat = fh.read()
|
|
+ except FileNotFoundError:
|
|
+ continue # this task finished exiting between listdir and open
|
|
+ except OSError:
|
|
+ return False
|
|
+ if not _task_in_exit(stat):
|
|
+ return False
|
|
+ return True
|
|
+
|
|
+
|
|
def _publish_fence() -> None:
|
|
with _PUBLISH_FENCE:
|
|
pass
|
|
@@ -448,6 +528,9 @@ class PleOffloadWorkerHandle:
|
|
if self.ready_pipe_reader is not None:
|
|
self.ready_pipe_reader.close()
|
|
self.ready_pipe_reader = None
|
|
+ if graceful and _PROCESS_EXIT_IMMINENT:
|
|
+ self._close_without_reap()
|
|
+ return
|
|
if graceful and self.proc.poll() is None:
|
|
# Keep parent-death supervision connected while SIGTERM lets
|
|
# proc_main run its shutdown event and finally cleanup.
|
|
@@ -472,6 +555,41 @@ class PleOffloadWorkerHandle:
|
|
if self.proc.poll() is not None and self.ipc_addr is not None:
|
|
_unlink_ipc_socket(self.ipc_addr)
|
|
|
|
+ def _close_without_reap(self) -> None:
|
|
+ """close() for a parent that os._exits next: wait (bounded) until every
|
|
+ child task has left userspace, never for the reap.
|
|
+
|
|
+ SIGTERM lets proc_main's finally unlink the IPC socket, as in the
|
|
+ graceful path; parent-death supervision stays connected meanwhile.
|
|
+ Once the child has run exit, the address-space teardown that keeps it
|
|
+ unreapable needs nothing from this process: the orphan is reaped by
|
|
+ the subreaper/init after this process exits.
|
|
+ """
|
|
+ if not _child_thread_group_exiting(self.proc):
|
|
+ with contextlib.suppress(ProcessLookupError):
|
|
+ os.kill(self.proc.pid, signal.SIGTERM)
|
|
+ deadline = time.monotonic() + _EXIT_IMMINENT_CHILD_WAIT_S
|
|
+ while not _child_thread_group_exiting(self.proc):
|
|
+ if time.monotonic() >= deadline:
|
|
+ logger.warning(
|
|
+ "PLE offload process %d still running %.1f s after "
|
|
+ "SIGTERM; killing it without waiting (parent exits)",
|
|
+ self.proc.pid,
|
|
+ _EXIT_IMMINENT_CHILD_WAIT_S,
|
|
+ )
|
|
+ with contextlib.suppress(ProcessLookupError):
|
|
+ os.kill(self.proc.pid, signal.SIGKILL)
|
|
+ break
|
|
+ time.sleep(0.01)
|
|
+ exited = _child_thread_group_exiting(self.proc)
|
|
+ if self.death_writer is not None:
|
|
+ self.death_writer.close()
|
|
+ self.death_writer = None
|
|
+ # Same fallback as close(): the child has run exit, so this exact
|
|
+ # PLE-owned address is no longer in use.
|
|
+ if exited and self.ipc_addr is not None:
|
|
+ _unlink_ipc_socket(self.ipc_addr)
|
|
+
|
|
|
|
def _init_offload_distributed() -> None:
|
|
"""Initialize the single-rank Gloo world required by TP-aware layers."""
|
|
--
|
|
2.43.0
|
|
|