[bugfix, rl] Fix race condition bug on async running for diffusion model (#3379)

Signed-off-by: knlnguyen1802 <knlnguyen1802@gmail.com>
Co-authored-by: Samit <285365963@qq.com>
This commit is contained in:
knlnguyen1802andSamit authored and GitHub committed 2026-05-12 14:55:30 +08:00
1 parent d7ea5d5979
commit aa1184d737
4 files changed
+692 -128

No files matched your search

@@ -0,0 +1,510 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for the busy-loop-based RPC routing in DiffusionEngine.
These tests guard the invariants established when ``collective_rpc`` was
moved off ``_rpc_lock`` mutual exclusion and onto a queue drained by the
engine's busy-loop thread:
* Only the busy-loop thread ever calls ``executor.collective_rpc`` after
the loop starts.
* Concurrent ``engine.collective_rpc`` and per-request ``execute_fn``
invocations never overlap on the executor.
* Results are routed back to the correct caller (sync, async, and
per-request paths).
* Pending RPCs are failed cleanly on shutdown.
* The bootstrap path (busy loop not yet started) calls the executor
directly so ``_dummy_run`` keeps working.
"""
from __future__ import annotations
import asyncio
import queue
import threading
import time
from dataclasses import fields as _dc_fields
from types import SimpleNamespace
from typing import Any
import pytest
from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine, _RpcTask
from vllm_omni.diffusion.sched import RequestScheduler
from vllm_omni.diffusion.sched.interface import SamplingParamsKey
from vllm_omni.diffusion.worker.utils import RunnerOutput
# Default values for every batch-key field, so SimpleNamespace-based
# sampling_params satisfy ``get_sampling_params_key``'s attribute lookups.
_SAMPLING_KEY_DEFAULTS = {f.name: f.default for f in _dc_fields(SamplingParamsKey)}
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu]
# ───────────────────────────────────────── helpers ─────────────────────────
class _ConcurrencyTrackingExecutor:
"""Fake executor that records every ``collective_rpc`` invocation and
flags any overlap. Used to assert structural serialization.
"""
def __init__(self, rpc_delay: float = 0.0):
self._active = 0
self._lock = threading.Lock()
self.max_concurrent = 0
self.calls: list[dict[str, Any]] = []
self.thread_ids: set[int] = set()
self.rpc_delay = rpc_delay
self.is_failed = False
self._closed = False
def collective_rpc(
self,
method: str,
timeout: float | None = None,
args: tuple = (),
kwargs: dict | None = None,
unique_reply_rank: int | None = None,
exec_all_ranks: bool = False,
) -> Any:
with self._lock:
self._active += 1
self.max_concurrent = max(self.max_concurrent, self._active)
self.thread_ids.add(threading.get_ident())
self.calls.append(
{
"method": method,
"args": args,
"kwargs": kwargs,
"unique_reply_rank": unique_reply_rank,
"thread": threading.get_ident(),
}
)
try:
if self.rpc_delay:
time.sleep(self.rpc_delay)
# Distinguish per-request execution from raw RPC by method name.
if method in {"execute_model", "execute_stepwise", "generate"}:
# args[0] is the request-like object for execute_model.
req = args[0] if args else None
tag = req.request_ids[0] if req is not None and hasattr(req, "request_ids") else "unknown"
return DiffusionOutput(error=f"result_for_{tag}")
tag = args[0] if args else method
return DiffusionOutput(error=f"rpc_result_for_{tag}")
finally:
with self._lock:
self._active -= 1
def execute_request(self, scheduler_output) -> RunnerOutput:
# Mimic the real MultiprocDiffusionExecutor.execute_request: it
# forwards a single request through collective_rpc.
new_req = scheduler_output.scheduled_new_reqs[0]
result = self.collective_rpc(
"execute_model",
args=(new_req.req,),
unique_reply_rank=0,
exec_all_ranks=True,
)
return RunnerOutput(
req_id=new_req.sched_req_id,
step_index=None,
finished=True,
result=result,
)
def shutdown(self) -> None:
self._closed = True
def _make_request(tag: str):
return SimpleNamespace(
request_ids=[tag],
prompts=[f"prompt_{tag}"],
sampling_params=SimpleNamespace(num_inference_steps=1, **_SAMPLING_KEY_DEFAULTS),
)
def _make_engine_with_loop(
loop: asyncio.AbstractEventLoop,
rpc_delay: float = 0.0,
):
"""Construct a ``DiffusionEngine`` skeleton with a real busy loop.
The engine is wired with a fake executor that asserts no concurrent
calls and a real ``RequestScheduler``.
"""
engine = DiffusionEngine.__new__(DiffusionEngine)
engine.executor = _ConcurrencyTrackingExecutor(rpc_delay=rpc_delay)
sched = RequestScheduler()
sched.initialize(SimpleNamespace(max_num_seqs=1))
engine.scheduler = sched
engine.step_execution = False
engine.execute_fn = engine.executor.execute_request
engine._rpc_lock = threading.RLock()
engine._cv = threading.Condition(engine._rpc_lock)
engine._out_queue = {}
engine.abort_queue = queue.Queue()
engine._rpc_queue = queue.Queue()
engine.main_loop = loop
engine.stop_event = threading.Event()
engine._loop_started = True
engine.worker_thread = threading.Thread(target=engine._busy_loop, daemon=True)
engine.worker_thread.start()
return engine
def _stop_engine(engine: DiffusionEngine) -> None:
with engine._cv:
engine.stop_event.set()
engine._cv.notify_all()
# Race-proof shutdown for the test: drain any RPCs still queued and
# fail them with the documented shutdown error before the busy loop
# has a chance to pick them up after its current in-flight call
# returns. The engine's own ``_fail_pending_rpcs`` then has nothing
# left to do.
while True:
try:
task = engine._rpc_queue.get_nowait()
except queue.Empty:
break
if not task.future.done():
task.future.set_exception(RuntimeError("DiffusionEngine is shutting down."))
engine.worker_thread.join(timeout=5)
assert not engine.worker_thread.is_alive(), "Busy loop thread did not stop"
# ─────────────────────── single-thread invariant ───────────────────────────
@pytest.mark.asyncio
async def test_executor_only_called_from_busy_loop_thread():
"""All executor calls — both per-request and raw RPC — must come from
the busy-loop thread, never from a caller's thread."""
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop)
busy_tid = engine.worker_thread.ident
try:
# Per-request path
await engine.async_add_req_and_wait_for_response(_make_request("req1"))
# Raw RPC path (sync from a worker thread)
result = await asyncio.to_thread(engine.collective_rpc, "ping", args=("a",), unique_reply_rank=0)
assert result.error == "rpc_result_for_a"
# Raw RPC path (async)
result_async = await engine.async_collective_rpc("ping", args=("b",), unique_reply_rank=0)
assert result_async.error == "rpc_result_for_b"
finally:
_stop_engine(engine)
# Every recorded call ran on the busy-loop thread.
assert engine.executor.thread_ids == {busy_tid}, (
f"executor.collective_rpc must run only on the busy-loop thread "
f"(expected {{{busy_tid}}}, got {engine.executor.thread_ids})"
)
@pytest.mark.asyncio
async def test_executor_calls_never_overlap_under_load():
"""Stress: many concurrent ``collective_rpc`` callers + a request must
never produce overlapping executor calls."""
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop, rpc_delay=0.005)
try:
async def _rpc(i: int):
return await engine.async_collective_rpc("ping", args=(f"x{i}",), unique_reply_rank=0)
async def _request(i: int):
return await engine.async_add_req_and_wait_for_response(_make_request(f"r{i}"))
tasks = [_rpc(i) for i in range(20)] + [_request(i) for i in range(5)]
results = await asyncio.gather(*tasks)
finally:
_stop_engine(engine)
assert engine.executor.max_concurrent == 1, (
f"Detected concurrent executor calls (max_concurrent={engine.executor.max_concurrent})"
)
# All 25 calls should have a result.
assert len(results) == 25
assert all(r is not None for r in results)
# ─────────────────────────── result routing ────────────────────────────────
@pytest.mark.asyncio
async def test_collective_rpc_results_routed_to_correct_caller():
"""Under concurrent calls with distinct args, each caller must get its
own result back — not another caller's."""
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop, rpc_delay=0.01)
try:
async def _call(tag: str):
res = await engine.async_collective_rpc("ping", args=(tag,), unique_reply_rank=0)
return tag, res
tags = [f"t{i}" for i in range(15)]
results = await asyncio.gather(*[_call(t) for t in tags])
finally:
_stop_engine(engine)
for tag, res in results:
assert res.error == f"rpc_result_for_{tag}", f"caller for {tag!r} received {res.error!r}"
@pytest.mark.asyncio
async def test_sync_collective_rpc_from_worker_thread():
"""Sync ``collective_rpc`` from non-event-loop threads still receives
the correct result via the busy loop."""
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop)
try:
results: list[Any] = [None] * 8
def _worker(idx: int):
results[idx] = engine.collective_rpc("ping", args=(f"s{idx}",), unique_reply_rank=0)
threads = [threading.Thread(target=_worker, args=(i,)) for i in range(8)]
for t in threads:
t.start()
for t in threads:
t.join(5)
assert not t.is_alive()
finally:
_stop_engine(engine)
for i, r in enumerate(results):
assert r is not None
assert r.error == f"rpc_result_for_s{i}"
# ─────────────────────────── timeout behaviour ─────────────────────────────
@pytest.mark.asyncio
async def test_collective_rpc_times_out_when_busy_loop_busy():
"""If the busy loop is occupied, ``collective_rpc(timeout=...)`` must
raise ``TimeoutError`` rather than block indefinitely."""
loop = asyncio.get_running_loop()
# 2-second per-RPC delay simulates a busy worker.
engine = _make_engine_with_loop(loop, rpc_delay=2.0)
try:
# Kick off one slow RPC to occupy the busy loop.
slow = asyncio.create_task(engine.async_collective_rpc("slow", args=("s",), unique_reply_rank=0))
# Yield so the slow task is enqueued.
await asyncio.sleep(0.05)
with pytest.raises(TimeoutError):
await asyncio.to_thread(
engine.collective_rpc,
"ping",
args=("x",),
unique_reply_rank=0,
timeout=0.2,
)
# The slow call should still complete normally.
result = await slow
assert result.error == "rpc_result_for_s"
finally:
_stop_engine(engine)
@pytest.mark.asyncio
async def test_async_collective_rpc_times_out_when_busy_loop_busy():
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop, rpc_delay=2.0)
try:
slow = asyncio.create_task(engine.async_collective_rpc("slow", args=("s",), unique_reply_rank=0))
await asyncio.sleep(0.05)
with pytest.raises(TimeoutError):
await engine.async_collective_rpc("ping", args=("x",), unique_reply_rank=0, timeout=0.2)
result = await slow
assert result.error == "rpc_result_for_s"
finally:
_stop_engine(engine)
# ─────────────────────────── shutdown handling ─────────────────────────────
@pytest.mark.asyncio
async def test_pending_rpcs_failed_on_shutdown():
"""Shutdown must fail any RPCs still queued so callers don't hang."""
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop, rpc_delay=0.5)
try:
# Start a slow RPC that occupies the busy loop, then queue more.
in_flight = asyncio.create_task(engine.async_collective_rpc("slow", args=("s",), unique_reply_rank=0))
await asyncio.sleep(0.05)
pending = [
asyncio.create_task(engine.async_collective_rpc("ping", args=(f"p{i}",), unique_reply_rank=0))
for i in range(3)
]
# Give them a moment to enqueue.
await asyncio.sleep(0.05)
finally:
_stop_engine(engine)
# The in-flight one finishes normally; pending ones should fail.
assert (await in_flight).error == "rpc_result_for_s"
for t in pending:
with pytest.raises(RuntimeError, match="shutting down"):
await t
# ─────────────────────────── bootstrap path ────────────────────────────────
def test_collective_rpc_before_loop_starts_calls_executor_directly():
"""When ``_loop_started`` is False (e.g. inside ``_dummy_run`` during
``__init__``), ``collective_rpc`` must call the executor synchronously
on the caller's thread without enqueueing.
"""
engine = DiffusionEngine.__new__(DiffusionEngine)
engine._loop_started = False
engine._rpc_lock = threading.RLock()
engine._cv = threading.Condition(engine._rpc_lock)
engine.executor = _ConcurrencyTrackingExecutor()
caller_tid = threading.get_ident()
result = engine.collective_rpc("ping", args=("boot",), unique_reply_rank=0)
assert result.error == "rpc_result_for_boot"
# Critically: ran on the caller's thread, not a busy-loop thread.
assert engine.executor.thread_ids == {caller_tid}
def test_collective_rpc_before_loop_starts_serializes_concurrent_callers():
"""Regression: between ``__init__`` returning and the first async
request starting the busy loop, multiple threads may call
``collective_rpc`` concurrently. The pre-loop fast-path must
serialize them so they cannot race on the shared executor MQ pair.
"""
engine = DiffusionEngine.__new__(DiffusionEngine)
engine._loop_started = False
engine._rpc_lock = threading.RLock()
engine._cv = threading.Condition(engine._rpc_lock)
# Non-trivial delay forces overlap if the lock is missing.
engine.executor = _ConcurrencyTrackingExecutor(rpc_delay=0.02)
n = 8
results: list[Any] = [None] * n
def _call(i: int) -> None:
results[i] = engine.collective_rpc("ping", args=(f"x{i}",), unique_reply_rank=0)
threads = [threading.Thread(target=_call, args=(i,)) for i in range(n)]
for t in threads:
t.start()
for t in threads:
t.join()
assert engine.executor.max_concurrent == 1, "Pre-loop collective_rpc must serialize concurrent callers"
for i, r in enumerate(results):
assert r is not None and r.error == f"rpc_result_for_x{i}"
@pytest.mark.asyncio
async def test_cancelled_future_does_not_kill_busy_loop():
"""Regression: if a queued RPC future is cancelled while the executor
call is in flight, ``_process_rpc_queue`` must not raise
``InvalidStateError`` when trying to set the result/exception. Doing
so would crash the busy-loop thread and stall all later requests.
"""
loop = asyncio.get_running_loop()
# Slow executor so we can cancel mid-flight.
engine = _make_engine_with_loop(loop, rpc_delay=0.3)
try:
# Submit an RPC and immediately cancel its future to simulate
# a sync timeout / asyncio cancellation racing the worker.
with pytest.raises(TimeoutError):
await asyncio.to_thread(
engine.collective_rpc,
"slow",
args=("cancelme",),
unique_reply_rank=0,
timeout=0.05,
)
# Give the busy loop time to finish the in-flight slow call and
# attempt to set state on the cancelled future.
await asyncio.sleep(0.5)
# If the busy loop crashed, this follow-up RPC would hang /
# never complete. Bound it with a timeout to fail fast.
result = await asyncio.wait_for(
engine.async_collective_rpc("ping", args=("after",), unique_reply_rank=0),
timeout=3.0,
)
assert result.error == "rpc_result_for_after"
assert engine.worker_thread.is_alive()
finally:
_stop_engine(engine)
# ─────────────────────────── _RpcTask basics ───────────────────────────────
def test_rpc_task_default_future_is_unique_per_instance():
"""Regression: ``_RpcTask.future`` must default to a *new* Future per
instance, not a shared one (would cross-resolve all callers).
"""
a = _RpcTask(method="m", args=(), kwargs=None, deadline=None, unique_reply_rank=0)
b = _RpcTask(method="m", args=(), kwargs=None, deadline=None, unique_reply_rank=0)
assert a.future is not b.future
a.future.set_result("a")
assert not b.future.done()
# ──────────────── busy-loop drains RPCs without scheduler work ─────────────
@pytest.mark.asyncio
async def test_busy_loop_handles_rpc_without_pending_requests():
"""The wait predicate must wake on RPC submissions even when the
scheduler queue is empty (regression: previously the loop only woke
on ``has_requests()``).
"""
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop)
try:
# No request has been added; only RPC.
result = await asyncio.wait_for(
engine.async_collective_rpc("ping", args=("alone",), unique_reply_rank=0),
timeout=3.0,
)
assert result.error == "rpc_result_for_alone"
finally:
_stop_engine(engine)
# ──────────────── interleaved RPC + request integrity ──────────────────────
@pytest.mark.asyncio
async def test_rpc_and_request_results_do_not_swap():
"""A concurrent RPC and request execution must each receive their own
result (regression for the original race the refactor fixed).
"""
loop = asyncio.get_running_loop()
engine = _make_engine_with_loop(loop, rpc_delay=0.02)
try:
rpc_task = asyncio.create_task(engine.async_collective_rpc("ping", args=("rpc1",), unique_reply_rank=0))
req_task = asyncio.create_task(engine.async_add_req_and_wait_for_response(_make_request("req1")))
rpc_res, req_res = await asyncio.gather(rpc_task, req_task)
finally:
_stop_engine(engine)
assert rpc_res.error == "rpc_result_for_rpc1"
assert req_res.error == "result_for_req1"
# Belt and braces: never overlapped on the executor.
assert engine.executor.max_concurrent == 1
@@ -78,6 +78,9 @@ def _make_engine(num_gpus: int = 1):
engine.scheduler = sched
engine.executor = executor
engine._rpc_lock = threading.RLock()
engine._cv = threading.Condition(engine._rpc_lock)
engine._loop_started = False
engine._rpc_queue = queue.Queue()
engine.abort_queue = queue.Queue()
engine.execute_fn = executor.execute_request
return engine, executor, req_q, res_q
@@ -344,108 +347,6 @@ class TestSerialEngineOperations:
engine.collective_rpc("anything")
# ─────────── timeout regression: RPC must not block on a stalled lock ─────
class TestCollectiveRpcTimeoutWhileLockHeld:
"""``collective_rpc(timeout=...)`` must honour its timeout even when
another thread holds ``engine._rpc_lock`` indefinitely (e.g. request
execution stalled on ``add_req_and_wait_for_response`` → ``execute_fn``
→ ``collective_rpc`` while blocked on an unresponsive worker).
"""
def test_rpc_times_out_when_lock_held_directly(self):
"""Simplest case: lock is manually held by another thread."""
engine, _, _, _ = _make_engine()
stall_started = threading.Event()
def _hold_lock():
engine._rpc_lock.acquire()
stall_started.set()
# Hold the lock far longer than the RPC timeout.
threading.Event().wait(30)
engine._rpc_lock.release()
stall_thread = threading.Thread(target=_hold_lock, daemon=True)
stall_thread.start()
stall_started.wait(5)
# collective_rpc should raise TimeoutError, NOT block forever.
with pytest.raises(TimeoutError):
engine.collective_rpc("health", timeout=0.5)
def test_rpc_times_out_when_request_execution_stalled_on_worker(self):
"""Real-world scenario the bot flagged:
The scheduler/execute path holds ``_rpc_lock`` while blocked on
``executor._result_mq.dequeue()`` because the worker never replies.
A concurrent ``collective_rpc(timeout=...)`` must still time out
instead of hanging forever waiting for the lock.
"""
engine, executor, _, _ = _make_engine()
add_req_blocked = threading.Event()
# Patch dequeue: signal once entered, then block indefinitely
# (simulates a worker that never sends a result).
orig_dequeue = executor._result_mq.dequeue
def _hanging_dequeue(timeout=None):
add_req_blocked.set()
# Block forever — the worker is "hung".
threading.Event().wait(30)
return orig_dequeue(timeout=timeout)
executor._result_mq.dequeue = _hanging_dequeue
# Thread running request execution — acquires the lock, enqueues, then
# blocks on dequeue forever (worker hang).
def _stalled_request_execution():
try:
engine.add_req_and_wait_for_response(_mock_request("stalled"))
except Exception:
pass
t = threading.Thread(target=_stalled_request_execution, daemon=True)
t.start()
# Wait until request execution is truly inside the lock and blocking.
assert add_req_blocked.wait(5)
# collective_rpc should time out at lock acquisition, not hang.
with pytest.raises(TimeoutError):
engine.collective_rpc("health_check", timeout=0.5)
def test_rpc_without_timeout_still_waits_for_lock(self):
"""When no timeout is given, ``collective_rpc`` should still wait
for the lock (blocking) — existing behaviour preserved.
"""
engine, _, _, res_q = _make_engine()
def _hold_and_release():
engine._rpc_lock.acquire()
# Hold for a short time then release.
threading.Event().wait(0.3)
engine._rpc_lock.release()
# Pre-populate a result so collective_rpc succeeds after lock.
res_q.put(_tagged_output("ok"))
t = threading.Thread(target=_hold_and_release, daemon=True)
t.start()
# No timeout -> should block until lock is released, then succeed.
result = engine.collective_rpc(
"ping",
args=("wait",),
unique_reply_rank=0,
)
t.join(5)
assert result.error == "ok"
# ───────── error handling: EngineDeadError propagation through layers ─────
+161 -24
View File
@@ -4,11 +4,13 @@
from __future__ import annotations
import asyncio
import concurrent.futures
import inspect
import queue
import threading
import time
from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import Any
import numpy as np
@@ -38,6 +40,18 @@ from vllm_omni.outputs import OmniRequestOutput
logger = init_logger(__name__)
@dataclass
class _RpcTask:
"""A pending collective_rpc invocation queued for the busy loop."""
method: str
args: tuple
kwargs: dict | None
deadline: float | None
unique_reply_rank: int | None
future: concurrent.futures.Future = field(default_factory=concurrent.futures.Future)
def supports_multimodal_input(od_config: OmniDiffusionConfig) -> tuple[bool, bool]:
if od_config.diffusion_load_format == "diffusers" and (pipe_cls := od_config.diffusers_pipeline_cls) is not None:
signature = inspect.signature(pipe_cls.__call__)
@@ -136,10 +150,15 @@ class DiffusionEngine:
self.stop_event: threading.Event | None = None
self._loop_started = False
self._init_lock = asyncio.Lock()
# _rpc_lock is retained solely as the underlying lock for self._cv,
# which is used to signal the busy loop. Worker-call serialization is
# now handled structurally by routing all executor calls through the
# busy loop rather than via mutual exclusion.
self._rpc_lock = threading.RLock()
self._cv = threading.Condition(self._rpc_lock)
self._out_queue: dict[str, asyncio.Future] = {}
self.abort_queue: queue.Queue[str] = queue.Queue()
self._rpc_queue: queue.Queue[_RpcTask] = queue.Queue()
self.execute_fn = self.executor.execute_step if self.step_execution else self.executor.execute_request
try:
@@ -413,14 +432,24 @@ class DiffusionEngine:
def _busy_loop(self):
while not self.stop_event.is_set():
self._process_aborts_queue()
self._process_rpc_queue()
with self._cv:
while not self.scheduler.has_requests() and not self.stop_event.is_set():
while (
not self.scheduler.has_requests()
and self._rpc_queue.empty()
and self.abort_queue.empty()
and not self.stop_event.is_set()
):
self._cv.wait(timeout=1.0)
if self.stop_event.is_set():
break
if not self.scheduler.has_requests():
# Only RPC / abort work pending; loop back to drain it.
continue
sched_output = self.scheduler.schedule()
if sched_output.is_empty:
@@ -446,9 +475,65 @@ class DiffusionEngine:
)
self._process_aborts_queue()
self._process_rpc_queue()
finished_req_ids = self.scheduler.update_from_output(sched_output, runner_output)
self._handle_finished_requests(finished_req_ids, runner_output)
# Engine is stopping: fail any RPCs still queued so callers don't hang.
self._fail_pending_rpcs(RuntimeError("DiffusionEngine is shutting down."))
def _process_rpc_queue(self) -> None:
"""Execute pending collective_rpc tasks from the busy-loop thread.
Running these here means executor calls are naturally serialized
against execute_fn() without any mutual-exclusion locking.
"""
while True:
try:
task = self._rpc_queue.get_nowait()
except queue.Empty:
return
fut = task.future
if fut.cancelled() or fut.done():
continue
remaining: float | None = None
if task.deadline is not None:
remaining = task.deadline - time.monotonic()
if remaining <= 0:
if not fut.done():
fut.set_exception(TimeoutError(f"RPC call to {task.method} timed out before execution."))
continue
try:
result = self.executor.collective_rpc(
method=task.method,
timeout=remaining,
args=task.args,
kwargs=task.kwargs,
unique_reply_rank=task.unique_reply_rank,
)
except BaseException as exc: # noqa: BLE001 - propagate to caller
# The future may have been cancelled (e.g. by a sync timeout
# or asyncio cancellation) while the executor call was
# running. Setting state on a cancelled/done future raises
# InvalidStateError, which would kill the busy loop.
if not fut.done():
fut.set_exception(exc)
else:
if not fut.done():
fut.set_result(result)
def _fail_pending_rpcs(self, exc: BaseException) -> None:
while True:
try:
task = self._rpc_queue.get_nowait()
except queue.Empty:
return
if not task.future.done():
task.future.set_exception(exc)
def _handle_finished_requests(
self,
finished_ids: set[str],
@@ -502,6 +587,8 @@ class DiffusionEngine:
raise
async def async_add_req_and_wait_for_response(self, request: OmniDiffusionRequest) -> DiffusionOutput:
# No lock needed: add_request is already protected by self._cv, and
# all executor calls are serialized inside the busy loop.
sched_req_id = self.add_request(request)
return await self.get_result(sched_req_id)
@@ -615,6 +702,28 @@ class DiffusionEngine:
if output.error:
raise RuntimeError(f"Dummy run failed: {output.error}")
def _submit_rpc(
self,
method: str,
timeout: float | None,
args: tuple,
kwargs: dict | None,
unique_reply_rank: int | None,
) -> _RpcTask:
assert isinstance(method, str), "Only string method names are supported for now"
deadline = None if timeout is None else time.monotonic() + timeout
task = _RpcTask(
method=method,
args=args,
kwargs=kwargs,
deadline=deadline,
unique_reply_rank=unique_reply_rank,
)
with self._cv:
self._rpc_queue.put(task)
self._cv.notify_all()
return task
def collective_rpc(
self,
method: str,
@@ -625,6 +734,10 @@ class DiffusionEngine:
) -> Any:
"""Call a method on worker processes and get results immediately.
The call is enqueued and executed by the engine's busy loop between
scheduler steps, so it is naturally serialized against per-request
execute_fn() invocations without any explicit mutual-exclusion lock.
Args:
method: The method name (str) to execute on workers
timeout: Optional timeout in seconds
@@ -637,32 +750,56 @@ class DiffusionEngine:
"""
assert isinstance(method, str), "Only string method names are supported for now"
deadline = None if timeout is None else time.monotonic() + timeout
acquired = False
# If the busy loop hasn't started yet (e.g. during _dummy_run in
# __init__, or before the first async request after construction),
# there is no busy-loop thread to drain the RPC queue. Fall back to
# calling the executor directly, but serialize concurrent callers
# via self._cv's underlying lock so multiple threads in this window
# cannot race on the shared broadcast_mq / result_mq transport.
if not self._loop_started:
with self._cv:
# Re-check under the lock: the busy loop may have started
# between the outer check and acquiring the lock, in which
# case we should use the queued path for proper ordering.
if not self._loop_started:
return self.executor.collective_rpc(
method=method,
timeout=timeout,
args=args,
kwargs=kwargs,
unique_reply_rank=unique_reply_rank,
)
task = self._submit_rpc(method, timeout, args, kwargs, unique_reply_rank)
try:
if deadline is None:
self._rpc_lock.acquire()
acquired = True
else:
lock_timeout = max(0, deadline - time.monotonic())
acquired = self._rpc_lock.acquire(timeout=lock_timeout)
if not acquired:
raise TimeoutError(f"RPC call to {method} timed out waiting for engine lock.")
return task.future.result(timeout=timeout)
except concurrent.futures.TimeoutError as exc:
task.future.cancel()
raise TimeoutError(f"RPC call to {method} timed out.") from exc
rpc_timeout = None if deadline is None else max(0, deadline - time.monotonic())
if deadline is not None and rpc_timeout <= 0:
raise TimeoutError(f"RPC call to {method} timed out.")
async def async_collective_rpc(
self,
method: str,
timeout: float | None = None,
args: tuple = (),
kwargs: dict | None = None,
unique_reply_rank: int | None = None,
) -> Any:
"""Async variant of :meth:`collective_rpc` for event-loop callers.
return self.executor.collective_rpc(
method=method,
timeout=rpc_timeout,
args=args,
kwargs=kwargs,
unique_reply_rank=unique_reply_rank,
)
finally:
if acquired:
self._rpc_lock.release()
Mirrors :meth:`async_add_req_and_wait_for_response`: enqueue a task
keyed by a future and ``await`` the result without blocking the loop.
"""
await self._check_and_start_background_loop()
task = self._submit_rpc(method, timeout, args, kwargs, unique_reply_rank)
aio_fut = asyncio.wrap_future(task.future)
try:
if timeout is None:
return await aio_fut
return await asyncio.wait_for(aio_fut, timeout=timeout)
except asyncio.TimeoutError as exc:
task.future.cancel()
raise TimeoutError(f"RPC call to {method} timed out.") from exc
def close(self) -> None:
if hasattr(self, "scheduler"):
@@ -215,11 +215,27 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
if self._closed:
return
dead = [p.name for p in self._processes if p.sentinel in finished]
dead = [p for p in self._processes if p.sentinel in finished]
if dead:
details = []
for p in dead:
code = p.exitcode
# Negative exitcode == killed by signal N (-9 = SIGKILL/OOM,
# -11 = SIGSEGV). Surface this so callers don't only see
# "died unexpectedly" with no root cause.
if code is not None and code < 0:
try:
import signal as _signal
sig = _signal.Signals(-code).name
except (ValueError, ImportError):
sig = f"signal {-code}"
details.append(f"{p.name}(exitcode={code}, {sig})")
else:
details.append(f"{p.name}(exitcode={code})")
logger.error(
"Diffusion worker(s) died unexpectedly: %s",
dead,
details,
)
self.is_failed = True