Compare commits

..
7 changed files with 194 additions and 153 deletions

No files matched your search

+2 -9
View File
@@ -313,15 +313,8 @@ One process per exact identity owns a build; other workers wait and then acquire
leases for the published artifact. Publication is invisible until all payloads leases for the published artifact. Publication is invisible until all payloads
and metadata are validated, hashed, fsynced, and atomically renamed. and metadata are validated, hashed, fsynced, and atomically renamed.
`coordination_timeout_seconds` bounds domain-initialization and lookup/build `coordination_timeout_seconds` bounds filesystem lock acquisition. It does not
lock acquisition. Store construction and each later resolution or publication cancel synchronous validation, a producer that has already started, or atomic
operation have separate budgets from the same wait policy, rather than one
end-to-end startup deadline. A domain-init timeout follows the retryable domain
failure policy below. After contention ends, a fresh runtime construction can
retry initialization; the timed-out runtime retains its original failure.
The coordination budget does not cancel filesystem I/O, synchronous validation,
a producer that has already started, or atomic
publication. A hung in-process producer therefore blocks its owning process and publication. A hung in-process producer therefore blocks its owning process and
must be handled by external process supervision. Enforceable producer must be handled by external process supervision. Enforceable producer
cancellation requires a future process-isolated producer contract. cancellation requires a future process-isolated producer contract.
+2 -10
View File
@@ -136,16 +136,8 @@ so only when it is marked retryable; unsupported capabilities, identity
collisions, producer failures, and publication failures remain visible as collisions, producer failures, and publication failures remain visible as
startup failures. startup failures.
`coordination_timeout_seconds` bounds acquisition of the domain-initialization `coordination_timeout_seconds` bounds acquisition of lookup and build locks; it
lock and lookup/build locks. Store construction and each later resolution or is not a hard wall-clock deadline for in-process validation, production, or
publication operation receive separate coordination budgets from the same
`WaitPolicy`; they do not share an end-to-end startup deadline. A domain-init
lock timeout is a retryable domain failure: preferred mode may fall back and
required mode fails. Retrying initialization requires constructing a fresh
runtime after contention ends; a waiter never cancels or unlocks the owner.
The coordination budget is not a hard wall-clock deadline for filesystem I/O,
in-process validation, production, or
atomic publication. Once this V1 implementation becomes the producer, the atomic publication. Once this V1 implementation becomes the producer, the
synchronous producer and publication run to completion. A hung producer blocks synchronous producer and publication run to completion. A hung producer blocks
its owning process and requires external process supervision; enforceable its owning process and requires external process supervision; enforceable
@@ -4,8 +4,10 @@
import asyncio import asyncio
import multiprocessing as mp import multiprocessing as mp
import queue import queue
import signal
import threading import threading
import time import time
import weakref
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock, Mock from unittest.mock import MagicMock, Mock
@@ -1330,6 +1332,7 @@ class TestExecutorShutdownCleaner:
class FakeProcess: class FakeProcess:
def __init__(self, name): def __init__(self, name):
self.name = name self.name = name
self.pid = 123
self.alive = True self.alive = True
self.terminated = False self.terminated = False
self.join_timeouts = [] self.join_timeouts = []
@@ -1358,6 +1361,132 @@ class TestExecutorShutdownCleaner:
assert first.terminated and second.terminated assert first.terminated and second.terminated
assert not first.is_alive() and not second.is_alive() assert not first.is_alive() and not second.is_alive()
@pytest.mark.parametrize("cooperative", [False, True])
def test_real_workers_exit_and_are_reaped(self, monkeypatch, cooperative):
from vllm_omni.diffusion.executor import multiproc_executor as executor_module
ctx = mp.get_context("fork")
ready, stop = ctx.Event(), ctx.Event()
def run_worker():
signal.signal(signal.SIGTERM, signal.SIG_IGN)
ready.set()
stop.wait()
dead = ctx.Process(target=lambda: None)
worker = ctx.Process(target=run_worker)
monkeypatch.setattr(executor_module, "_WORKER_SHUTDOWN_GRACE_S", 0.2)
monkeypatch.setattr(executor_module, "_WORKER_TERMINATE_GRACE_S", 0.1)
monkeypatch.setattr(executor_module, "_WORKER_KILL_GRACE_S", 5.0, raising=False)
dead.start()
worker.start()
try:
dead.join(5)
assert not dead.is_alive()
assert ready.wait(5), "worker did not install its signal handler"
mq = Mock()
if cooperative:
mq.enqueue.side_effect = lambda *args, **kwargs: stop.set()
else:
mq.enqueue.side_effect = OSError("shutdown queue unavailable")
cleaner = executor_module._ExecutorShutdownCleaner(mq, 2, [dead, worker])
cleaner()
assert not worker.is_alive()
assert worker.exitcode == (0 if cooperative else -signal.SIGKILL)
assert cleaner.processes == []
assert cleaner.broadcast_mq is None
cleaner()
finally:
for proc in (dead, worker):
if proc.is_alive():
proc.kill()
proc.join(5)
proc.close()
@pytest.mark.parametrize("failed_action", ["terminate", "kill"])
def test_kill_phase_shares_deadline_and_continues_after_os_errors(self, monkeypatch, failed_action):
from vllm_omni.diffusion.executor import multiproc_executor as executor_module
first, second = Mock(pid=101), Mock(pid=102)
first.name, second.name = "first", "second"
for proc in (first, second):
proc.is_alive.side_effect = [True, True, True, False]
getattr(first, failed_action).side_effect = OSError("signal failed")
first.join.side_effect = [OSError("join failed"), None, None]
monotonic = Mock(side_effect=[100, 100, 110, 120, 120, 124, 130, 130, 134])
monkeypatch.setattr(executor_module, "time", SimpleNamespace(monotonic=monotonic))
cleaner = executor_module._ExecutorShutdownCleaner(processes=[first, second])
cleaner()
first.kill.assert_called_once()
second.kill.assert_called_once()
assert [call.args[0] for call in first.join.call_args_list] == [15, 5, 5]
assert [call.args[0] for call in second.join.call_args_list] == [5, 1, 1]
assert cleaner.processes == []
def test_executor_retains_survivor_for_explicit_shutdown_retry(self, monkeypatch):
from vllm_omni.diffusion.executor import multiproc_executor as executor_module
survivor = Mock(pid=123)
survivor.name = "surviving-worker"
survivor.is_alive.return_value = True
cleaner = executor_module._ExecutorShutdownCleaner(processes=[survivor])
executor, _, _ = _make_executor()
executor._shutdown_cleaner = cleaner
executor._processes = [survivor]
executor._finalizer = weakref.finalize(executor, cleaner)
executor._pump_stop = threading.Event()
executor._futures_lock = threading.RLock()
executor._rpc_futures = {}
executor._output_futures = {}
executor._batch_split_map = {}
log_error = Mock()
monkeypatch.setattr(executor_module.logger, "error", log_error)
executor.shutdown()
assert not executor._finalizer.alive
assert executor._shutdown_cleaner is cleaner
assert executor._processes == [survivor]
log_error.assert_called_once()
assert log_error.call_args.args[1] == [("surviving-worker", 123)]
survivor.is_alive.return_value = False
executor.shutdown()
executor.shutdown()
assert executor._shutdown_cleaner is None
assert executor._processes == []
def test_concurrent_cleanup_does_not_repeat_process_operations(self):
from vllm_omni.diffusion.executor import multiproc_executor as executor_module
joining, release = threading.Event(), threading.Event()
proc = Mock(pid=123)
proc.is_alive.return_value = True
def join(timeout):
joining.set()
assert release.wait(5)
proc.is_alive.return_value = False
proc.join.side_effect = join
cleaner = executor_module._ExecutorShutdownCleaner(processes=[proc])
thread = threading.Thread(target=cleaner)
thread.start()
try:
assert joining.wait(5)
cleaner()
proc.join.assert_called_once()
finally:
release.set()
thread.join(5)
assert not thread.is_alive()
assert cleaner.processes == []
# ───────── monitor thread & death sentinel integration tests ───────── # ───────── monitor thread & death sentinel integration tests ─────────
@@ -1406,7 +1535,7 @@ class TestMultiprocExecutorWorkerMonitor:
executor._result_mq = None executor._result_mq = None
executor._shutdown_cleaner = None executor._shutdown_cleaner = None
# Use a no-op so shutdown() doesn't crash on None resources. # Use a no-op so shutdown() doesn't crash on None resources.
executor._finalizer = lambda: None executor._finalizer = weakref.finalize(executor, lambda: None)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Attributes added by remove_bubble_v2 (async D2H); shutdown() iterates # Attributes added by remove_bubble_v2 (async D2H); shutdown() iterates
# over them, so they need to exist even when constructed via __new__. # over them, so they need to exist even when constructed via __new__.
@@ -1441,7 +1570,7 @@ class TestMultiprocExecutorWorkerMonitor:
executor._broadcast_mq = None executor._broadcast_mq = None
executor._result_mq = None executor._result_mq = None
executor._shutdown_cleaner = None executor._shutdown_cleaner = None
executor._finalizer = lambda: None executor._finalizer = weakref.finalize(executor, lambda: None)
proc = _make_short_lived_process() proc = _make_short_lived_process()
executor._processes = [proc] executor._processes = [proc]
@@ -4,10 +4,6 @@
from __future__ import annotations from __future__ import annotations
import multiprocessing as mp
import threading
import time
from multiprocessing.connection import Connection
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
@@ -19,7 +15,6 @@ from vllm_omni.host_weight_runtime import (
CanonicalJson, CanonicalJson,
ComponentIdentity, ComponentIdentity,
FailureCode, FailureCode,
HostWeightError,
HostWeightFailure, HostWeightFailure,
HostWeightLease, HostWeightLease,
HostWeightLeaseCarrier, HostWeightLeaseCarrier,
@@ -45,14 +40,12 @@ from vllm_omni.host_weight_runtime import (
StoreStatus, StoreStatus,
TensorWriteSpec, TensorWriteSpec,
ValidationLevel, ValidationLevel,
WaitPolicy,
WeightArtifactIdentity, WeightArtifactIdentity,
WeightProductionSpec, WeightProductionSpec,
WeightRepresentation, WeightRepresentation,
WeightSourceIdentity, WeightSourceIdentity,
) )
from vllm_omni.host_weight_runtime.filesystem import FilesystemHostWeightStore, detect_storage_class from vllm_omni.host_weight_runtime.filesystem import FilesystemHostWeightStore, detect_storage_class
from vllm_omni.host_weight_runtime.filesystem.locks import FileLock, lock_is_active
pytestmark = [pytest.mark.core_model, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -525,107 +518,6 @@ def test_retryable_store_initialization_failure_obeys_runtime_mode(
assert resolution.report.attempts[0].failure.retryable assert resolution.report.attempts[0].failure.retryable
def _initialize_contended_runtime(config: HostWeightRuntimeConfig, output: Connection) -> None:
try:
output.send("starting")
started = time.monotonic()
runtime = HostWeightRuntime.from_config(config)
resolution = runtime.resolve(_identity())
try:
output.send((time.monotonic() - started, resolution.report))
finally:
if resolution.lease is not None:
resolution.lease.close()
finally:
output.close()
@pytest.mark.parametrize(
("mode", "expected"),
[
(RuntimeMode.PREFERRED, ResolutionOutcome.CANONICAL_FALLBACK),
(RuntimeMode.REQUIRED, ResolutionOutcome.FAILED),
],
)
def test_domain_lock_timeout_and_fresh_runtime_recovery(
tmp_path: Path, mode: RuntimeMode, expected: ResolutionOutcome
) -> None:
config = HostWeightRuntimeConfig(mode=mode, domain=_domain(tmp_path), wait=WaitPolicy(0.1))
initial = HostWeightRuntime.from_config(config)
built = initial.resolve(_identity(), producer=CountingProducer(_identity()))
assert built.lease is not None
built.lease.close()
metadata = {name: (tmp_path / name).read_bytes() for name in ("domain.json", "domain-policy.json")}
lock_path = tmp_path / "locks" / "domain-init.lock"
ctx = mp.get_context("spawn")
reader, writer = ctx.Pipe(duplex=False)
process = ctx.Process(target=_initialize_contended_runtime, args=(config, writer))
with FileLock(lock_path, exclusive=True, deadline=None):
process.start()
writer.close()
try:
# Import/bootstrap is outside the short coordination budget.
assert reader.poll(60), "waiter did not start"
assert reader.recv() == "starting"
assert reader.poll(5), "domain initialization did not respect the coordination timeout"
elapsed, report = reader.recv()
assert 0.1 <= elapsed < 5
assert report.outcome is expected
failure = report.attempts[0].failure
assert failure is not None
assert failure.stage is ResolutionStage.DOMAIN
assert failure.code is FailureCode.DOMAIN_UNAVAILABLE
assert failure.retryable
assert "timed out waiting for" in failure.message
assert "domain-init.lock" in failure.message
assert lock_is_active(lock_path), "waiter must not unlock the owner"
process.join(5)
assert process.exitcode == 0
finally:
if process.is_alive():
process.kill()
process.join(5)
process.close()
reader.close()
assert {name: (tmp_path / name).read_bytes() for name in metadata} == metadata
recovered = HostWeightRuntime.from_config(config).resolve(_identity())
assert recovered.report.outcome is ResolutionOutcome.LOCAL_HIT
assert recovered.lease is not None
recovered.lease.close()
def test_direct_store_waits_for_domain_lock_released_before_deadline(tmp_path: Path) -> None:
config = HostWeightRuntimeConfig(mode=RuntimeMode.PREFERRED, domain=_domain(tmp_path))
assert config.domain is not None
initial = HostWeightRuntime.from_config(config)
assert initial.store is not None
lock_path = tmp_path / "locks" / "domain-init.lock"
with FileLock(lock_path, exclusive=True, deadline=None) as owner:
release = threading.Timer(0.1, owner.close)
release.start()
try:
store = FilesystemHostWeightStore(config.domain, config.capacity, config.integrity, wait=WaitPolicy(5))
finally:
release.join(5)
assert store.root == tmp_path
def test_direct_store_domain_timeout_is_typed(tmp_path: Path) -> None:
config = HostWeightRuntimeConfig(mode=RuntimeMode.PREFERRED, domain=_domain(tmp_path))
assert config.domain is not None
HostWeightRuntime.from_config(config)
lock_path = tmp_path / "locks" / "domain-init.lock"
with FileLock(lock_path, exclusive=True, deadline=None):
with pytest.raises(HostWeightError) as caught:
FilesystemHostWeightStore(config.domain, config.capacity, config.integrity, wait=WaitPolicy(0.01))
assert caught.value.failure.code is FailureCode.DOMAIN_UNAVAILABLE
assert caught.value.failure.retryable
assert lock_is_active(lock_path)
assert not lock_is_active(lock_path)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("stage", "code"), ("stage", "code"),
[ [
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations from __future__ import annotations
import concurrent.futures import concurrent.futures
@@ -10,7 +13,7 @@ import threading
import time import time
import weakref import weakref
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass, field
from multiprocessing.synchronize import Event from multiprocessing.synchronize import Event
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
@@ -42,6 +45,7 @@ _DEQUEUE_TIMEOUT_S = 5.0
_DLO_DP_WAVE_TIMEOUT_S = float(os.environ.get("VLLM_OMNI_DLO_DP_WAVE_TIMEOUT", 600.0)) _DLO_DP_WAVE_TIMEOUT_S = float(os.environ.get("VLLM_OMNI_DLO_DP_WAVE_TIMEOUT", 600.0))
_WORKER_SHUTDOWN_GRACE_S = 15.0 _WORKER_SHUTDOWN_GRACE_S = 15.0
_WORKER_TERMINATE_GRACE_S = 5.0 _WORKER_TERMINATE_GRACE_S = 5.0
_WORKER_KILL_GRACE_S = 5.0
_RESULT_PUMP_JOIN_TIMEOUT_S = 2.0 _RESULT_PUMP_JOIN_TIMEOUT_S = 2.0
@@ -80,33 +84,61 @@ class _ExecutorShutdownCleaner:
broadcast_mq: MessageQueue | None = None broadcast_mq: MessageQueue | None = None
num_workers: int = 0 num_workers: int = 0
processes: list[mp.Process] | None = None processes: list[mp.Process] | None = None
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
def __call__(self) -> None: def __call__(self) -> None:
"""Clean up background resources.""" """Clean up background resources."""
# The worker monitor and an explicit shutdown may race. A retry must
# not signal/join the same Process objects while cleanup is in flight.
if not self._lock.acquire(blocking=False):
return
try:
self._cleanup()
finally:
self._lock.release()
def _cleanup(self) -> None:
if self.broadcast_mq is not None: if self.broadcast_mq is not None:
try: try:
for _ in range(self.num_workers): for _ in range(self.num_workers):
self.broadcast_mq.enqueue(SHUTDOWN_MESSAGE, timeout=1.0) self.broadcast_mq.enqueue(SHUTDOWN_MESSAGE, timeout=1.0)
self.broadcast_mq = None
except Exception as exc: except Exception as exc:
logger.warning("Failed to send shutdown signal: %s", exc) logger.warning("Failed to send shutdown signal: %s", exc)
finally:
self.broadcast_mq = None
if self.processes: if self.processes:
join_deadline = time.monotonic() + _WORKER_SHUTDOWN_GRACE_S
for proc in self.processes:
if not proc.is_alive():
continue
proc.join(max(0.0, join_deadline - time.monotonic()))
alive = [proc for proc in self.processes if proc.is_alive()] alive = [proc for proc in self.processes if proc.is_alive()]
for proc in alive: for action, grace in (
logger.warning("Terminating diffusion worker %s after timeout", proc.name) (None, _WORKER_SHUTDOWN_GRACE_S),
proc.terminate() ("terminate", _WORKER_TERMINATE_GRACE_S),
("kill", _WORKER_KILL_GRACE_S),
):
if not alive:
break
if action is not None:
for proc in alive:
try:
logger.warning("Calling %s on diffusion worker %s (pid=%s)", action, proc.name, proc.pid)
getattr(proc, action)()
except OSError:
logger.exception("Failed to %s diffusion worker %s (pid=%s)", action, proc.name, proc.pid)
terminate_deadline = time.monotonic() + _WORKER_TERMINATE_GRACE_S deadline = time.monotonic() + grace
for proc in alive: for proc in alive:
proc.join(max(0.0, terminate_deadline - time.monotonic())) try:
proc.join(max(0.0, deadline - time.monotonic()))
except OSError:
logger.exception("Failed to join diffusion worker %s (pid=%s)", proc.name, proc.pid)
alive = [proc for proc in alive if proc.is_alive()]
self.processes = alive
if alive:
logger.error(
"Diffusion worker cleanup incomplete after kill: %s; retaining processes for shutdown retry",
[(proc.name, proc.pid) for proc in alive],
)
class MultiprocDiffusionExecutor(DiffusionExecutor): class MultiprocDiffusionExecutor(DiffusionExecutor):
@@ -1006,8 +1038,12 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
def shutdown(self) -> None: def shutdown(self) -> None:
self._closed = True self._closed = True
self._pump_stop.set() self._pump_stop.set()
cleaner = self._shutdown_cleaner
try: try:
self._finalizer() if self._finalizer.alive:
self._finalizer()
elif cleaner is not None:
cleaner()
finally: finally:
pump_threads = getattr(self, "_result_pump_threads", []) pump_threads = getattr(self, "_result_pump_threads", [])
for thread in pump_threads: for thread in pump_threads:
@@ -1031,5 +1067,6 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
self._rpc_futures.clear() self._rpc_futures.clear()
self._output_futures.clear() self._output_futures.clear()
self._batch_split_map.clear() self._batch_split_map.clear()
self._shutdown_cleaner = None self._processes = (cleaner.processes or []) if cleaner is not None else []
self._processes = [] if not self._processes:
self._shutdown_cleaner = None
@@ -23,7 +23,7 @@ import regex as re
import torch import torch
from safetensors import SafetensorError, safe_open from safetensors import SafetensorError, safe_open
from ..config import CapacityPolicy, IntegrityPolicy, StorageClass, StorageDomainPolicy, ValidationLevel, WaitPolicy from ..config import CapacityPolicy, IntegrityPolicy, StorageClass, StorageDomainPolicy, ValidationLevel
from ..errors import FailureCode, HostWeightError, HostWeightFailure, ResolutionStage from ..errors import FailureCode, HostWeightError, HostWeightFailure, ResolutionStage
from ..identity import CanonicalJson, WeightArtifactIdentity, canonical_json from ..identity import CanonicalJson, WeightArtifactIdentity, canonical_json
from ..inspection import ( from ..inspection import (
@@ -429,8 +429,6 @@ class FilesystemHostWeightStore:
domain: StorageDomainPolicy, domain: StorageDomainPolicy,
capacity: CapacityPolicy, capacity: CapacityPolicy,
integrity: IntegrityPolicy, integrity: IntegrityPolicy,
*,
wait: WaitPolicy = WaitPolicy(),
) -> None: ) -> None:
self.domain_policy = domain self.domain_policy = domain
self.capacity_policy = capacity self.capacity_policy = capacity
@@ -466,7 +464,7 @@ class FilesystemHostWeightStore:
) )
) )
try: try:
self._initialize_domain(deadline=time.monotonic() + wait.coordination_timeout_seconds) self._initialize_domain()
except HostWeightError: except HostWeightError:
raise raise
except OSError as exc: except OSError as exc:
@@ -487,7 +485,7 @@ class FilesystemHostWeightStore:
) )
) from exc ) from exc
def _initialize_domain(self, *, deadline: float) -> None: def _initialize_domain(self) -> None:
try: try:
self.filesystem_type = detect_filesystem_type(self.root) self.filesystem_type = detect_filesystem_type(self.root)
detected_storage_class = _storage_class_for_filesystem_type(self.filesystem_type) detected_storage_class = _storage_class_for_filesystem_type(self.filesystem_type)
@@ -547,7 +545,7 @@ class FilesystemHostWeightStore:
) )
) )
with FileLock(self.locks_dir / "domain-init.lock", exclusive=True, deadline=deadline): with FileLock(self.locks_dir / "domain-init.lock", exclusive=True, deadline=None):
domain_path = self.root / _DOMAIN_FILE domain_path = self.root / _DOMAIN_FILE
if domain_path.exists(): if domain_path.exists():
domain_metadata = _read_json_file(domain_path) domain_metadata = _read_json_file(domain_path)
+1 -1
View File
@@ -70,7 +70,7 @@ class HostWeightRuntime:
from .filesystem import FilesystemHostWeightStore # noqa: PLC0415 from .filesystem import FilesystemHostWeightStore # noqa: PLC0415
try: try:
store = FilesystemHostWeightStore(config.domain, config.capacity, config.integrity, wait=config.wait) store = FilesystemHostWeightStore(config.domain, config.capacity, config.integrity)
except HostWeightError as exc: except HostWeightError as exc:
return cls( return cls(
config, config,