Signed-off-by: summer <128961079+zhang-keliang@users.noreply.github.com> Co-authored-by: multica-agent <github@multica.ai>
941 lines
36 KiB
Python
941 lines
36 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""Unit tests for MultiprocDiffusionExecutor async result pump and wait_output_ready."""
|
|
|
|
import concurrent.futures
|
|
import queue
|
|
import threading
|
|
import time
|
|
from collections import OrderedDict
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from vllm_omni.diffusion.data import AsyncDiffusionOutput, AsyncOutputKind, DiffusionOutput
|
|
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
|
|
|
|
|
def _make_executor(step_execution=False):
|
|
"""Create a minimal MultiprocDiffusionExecutor-like object with pump state."""
|
|
from vllm_omni.diffusion.executor.multiproc_executor import MultiprocDiffusionExecutor
|
|
|
|
od_config = MagicMock()
|
|
od_config.step_execution = step_execution
|
|
|
|
executor = object.__new__(MultiprocDiffusionExecutor)
|
|
executor.od_config = od_config
|
|
executor._rpc_id_counter = 0
|
|
executor._rpc_id_lock = threading.Lock()
|
|
executor._rpc_futures = {}
|
|
executor._output_futures = {}
|
|
executor._completed_outputs = {}
|
|
executor._dropped_output_ids = OrderedDict()
|
|
executor._batch_split_map = {}
|
|
executor._futures_lock = threading.RLock()
|
|
executor._pump_running = False
|
|
executor._pump_stop = threading.Event()
|
|
executor._sync_result_buffer = queue.Queue()
|
|
executor._result_mq = MagicMock()
|
|
executor._result_mqs = []
|
|
executor._broadcast_mq = MagicMock()
|
|
executor._closed = False
|
|
executor._is_failed = False
|
|
executor._finalizer = MagicMock() # no-op in tests
|
|
executor._shutdown_cleaner = None
|
|
executor._processes = []
|
|
return executor
|
|
|
|
|
|
def _feed_one_msg_to_pump(executor, msg):
|
|
"""Run _result_pump in a daemon thread, feed one *msg*, then stop."""
|
|
call_count = [0]
|
|
|
|
def mock_dequeue(timeout=None):
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
return msg
|
|
executor._pump_stop.set()
|
|
time.sleep(0.05)
|
|
raise TimeoutError
|
|
|
|
executor._result_mq.dequeue = mock_dequeue
|
|
t = threading.Thread(target=executor._result_pump, daemon=True)
|
|
t.start()
|
|
t.join(timeout=2.0)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _mock_unpack(mocker):
|
|
"""Real _result_pump calls unpack_diffusion_output_shm; mock it away."""
|
|
mocker.patch(
|
|
"vllm_omni.diffusion.executor.multiproc_executor.unpack_diffusion_output_shm",
|
|
)
|
|
|
|
|
|
class TestNextRpcId:
|
|
"""Test _next_rpc_id counter."""
|
|
|
|
def test_counter_increments(self):
|
|
executor = _make_executor()
|
|
id1 = executor._next_rpc_id()
|
|
id2 = executor._next_rpc_id()
|
|
id3 = executor._next_rpc_id()
|
|
assert id1 == "1"
|
|
assert id2 == "2"
|
|
assert id3 == "3"
|
|
|
|
def test_counter_is_threadsafe(self):
|
|
executor = _make_executor()
|
|
ids = []
|
|
|
|
def get_id():
|
|
ids.append(executor._next_rpc_id())
|
|
|
|
threads = [threading.Thread(target=get_id) for _ in range(10)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
# All IDs should be unique
|
|
assert len(set(ids)) == 10
|
|
|
|
|
|
class TestWaitOutputReady:
|
|
"""Test wait_output_ready future creation and caching."""
|
|
|
|
def test_returns_new_future_when_not_cached(self):
|
|
executor = _make_executor()
|
|
fut = executor.wait_output_ready("abc123")
|
|
assert isinstance(fut, concurrent.futures.Future)
|
|
assert not fut.done()
|
|
|
|
def test_future_resolves_when_output_arrives(self):
|
|
executor = _make_executor()
|
|
fut = executor.wait_output_ready("abc123")
|
|
output = DiffusionOutput(output="data")
|
|
|
|
# Simulate pump resolving the future
|
|
with executor._futures_lock:
|
|
executor._output_futures.pop("abc123")
|
|
if not fut.done():
|
|
fut.set_result(output)
|
|
|
|
assert fut.result(timeout=1.0) is output
|
|
|
|
def test_returns_cached_future_when_already_completed(self):
|
|
executor = _make_executor()
|
|
output = DiffusionOutput(output="cached_data")
|
|
fut = concurrent.futures.Future()
|
|
fut.set_result(output)
|
|
with executor._futures_lock:
|
|
executor._completed_outputs["abc123"] = fut
|
|
|
|
fut = executor.wait_output_ready("abc123")
|
|
assert fut.done()
|
|
assert fut.result(timeout=1.0) is output
|
|
|
|
def test_removes_from_cache_after_retrieval(self):
|
|
executor = _make_executor()
|
|
output = DiffusionOutput(output="cached_data")
|
|
with executor._futures_lock:
|
|
executor._completed_outputs["abc123"] = output
|
|
|
|
executor.wait_output_ready("abc123")
|
|
# Second call should not find cached result
|
|
with executor._futures_lock:
|
|
assert "abc123" not in executor._completed_outputs
|
|
|
|
|
|
class TestResultPumpDispatch:
|
|
"""Test _result_pump message routing (running the real pump in a thread)."""
|
|
|
|
def test_non_async_message_placed_in_sync_buffer(self):
|
|
executor = _make_executor()
|
|
msg = DiffusionOutput(output="sync_result")
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
assert not executor._sync_result_buffer.empty()
|
|
retrieved = executor._sync_result_buffer.get_nowait()
|
|
assert isinstance(retrieved, DiffusionOutput)
|
|
|
|
def test_start_result_pump_reads_every_worker_queue(self):
|
|
executor = _make_executor()
|
|
messages = [DiffusionOutput(output="rank0"), DiffusionOutput(output="rank1")]
|
|
result_mqs = [MagicMock(), MagicMock()]
|
|
|
|
def make_dequeue(message):
|
|
emitted = False
|
|
|
|
def dequeue(timeout=None):
|
|
nonlocal emitted
|
|
if not emitted:
|
|
emitted = True
|
|
return message
|
|
executor._pump_stop.wait(0.01)
|
|
raise TimeoutError
|
|
|
|
return dequeue
|
|
|
|
for result_mq, message in zip(result_mqs, messages, strict=True):
|
|
result_mq.dequeue = make_dequeue(message)
|
|
|
|
executor._result_mqs = result_mqs
|
|
executor._result_mq = result_mqs[0]
|
|
executor._start_result_pump()
|
|
deadline = time.monotonic() + 2.0
|
|
while executor._sync_result_buffer.qsize() < 2 and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
executor._pump_stop.set()
|
|
for thread in executor._result_pump_threads:
|
|
thread.join(timeout=2.0)
|
|
|
|
assert len(executor._result_pump_threads) == 2
|
|
received = [executor._sync_result_buffer.get_nowait().output for _ in range(2)]
|
|
assert sorted(received) == ["rank0", "rank1"]
|
|
|
|
def test_compute_done_routes_to_rpc_future(self):
|
|
executor = _make_executor()
|
|
rpc_id = "42"
|
|
fut = concurrent.futures.Future()
|
|
with executor._futures_lock:
|
|
executor._rpc_futures[rpc_id] = fut
|
|
|
|
msg = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.COMPUTE_DONE,
|
|
rpc_id=rpc_id,
|
|
async_output_id="abc",
|
|
)
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
assert fut.done()
|
|
result = fut.result(timeout=1.0)
|
|
assert result.kind == AsyncOutputKind.COMPUTE_DONE
|
|
|
|
def test_output_ready_routes_to_output_future(self):
|
|
executor = _make_executor()
|
|
async_output_id = "abc123"
|
|
output = DiffusionOutput(output="final")
|
|
fut = concurrent.futures.Future()
|
|
with executor._futures_lock:
|
|
executor._output_futures[async_output_id] = fut
|
|
|
|
msg = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id=async_output_id,
|
|
output=output,
|
|
)
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
assert fut.done()
|
|
assert fut.result(timeout=1.0) is output
|
|
|
|
def test_output_ready_with_error_routes_to_future_as_exception(self):
|
|
executor = _make_executor()
|
|
async_output_id = "abc123"
|
|
fut = concurrent.futures.Future()
|
|
with executor._futures_lock:
|
|
executor._output_futures[async_output_id] = fut
|
|
|
|
msg = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id=async_output_id,
|
|
error="Background D2H/SHM packing failed",
|
|
)
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
assert fut.done()
|
|
with pytest.raises(RuntimeError, match="Background D2H/SHM packing failed"):
|
|
fut.result(timeout=1.0)
|
|
|
|
def test_output_ready_caches_when_no_future_waiting(self):
|
|
"""When OUTPUT_READY arrives but no future is waiting, result is cached."""
|
|
executor = _make_executor()
|
|
async_output_id = "abc123"
|
|
output = DiffusionOutput(output="orphan")
|
|
|
|
msg = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id=async_output_id,
|
|
output=output,
|
|
)
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
# Later call to wait_output_ready should find it cached
|
|
fut = executor.wait_output_ready(async_output_id)
|
|
assert fut.done()
|
|
assert fut.result(timeout=1.0) is output
|
|
assert async_output_id not in executor._output_futures
|
|
|
|
def test_output_ready_atomic_resolution_when_future_already_waiting(self):
|
|
"""When OUTPUT_READY arrives and a future is already waiting, resolve it directly."""
|
|
executor = _make_executor()
|
|
async_output_id = "abc123"
|
|
output = DiffusionOutput(output="waiting")
|
|
fut = executor.wait_output_ready(async_output_id)
|
|
assert not fut.done()
|
|
|
|
msg = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id=async_output_id,
|
|
output=output,
|
|
)
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
assert fut.done()
|
|
assert fut.result(timeout=1.0) is output
|
|
assert async_output_id not in executor._output_futures
|
|
assert async_output_id not in executor._completed_outputs
|
|
|
|
|
|
class _FakeBatchOutput:
|
|
"""Batch-level output exposing per-request results."""
|
|
|
|
def __init__(self, results):
|
|
self._results = results
|
|
|
|
def get_request_output(self, req_id):
|
|
result = self._results.get(req_id)
|
|
if result is None:
|
|
return None
|
|
return SimpleNamespace(result=result)
|
|
|
|
|
|
def _make_scheduler_output(req_ids):
|
|
return SimpleNamespace(
|
|
scheduled_new_reqs=[SimpleNamespace(request_id=rid, req=SimpleNamespace()) for rid in req_ids]
|
|
)
|
|
|
|
|
|
class TestBatchSplitDelivery:
|
|
"""execute_batch must resolve per-request futures in either arrival order."""
|
|
|
|
@staticmethod
|
|
def _run(executor, req_ids, batch_id, deliver_early):
|
|
# Keep execute_batch on the fused request-batch path (not DLO DP).
|
|
executor.od_config.parallel_config.data_parallel_size = 1
|
|
executor.od_config.enable_distributed_layerwise_offload = False
|
|
executor._ensure_open = lambda: None
|
|
|
|
outputs = {rid: DiffusionOutput(output=f"img-{rid}") for rid in req_ids}
|
|
ready = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id=batch_id,
|
|
output=_FakeBatchOutput(outputs),
|
|
)
|
|
|
|
def fake_collective_rpc(*args, **kwargs):
|
|
if deliver_early:
|
|
# Worker's background D2H/SHM thread wins the race: OUTPUT_READY
|
|
# is pumped before execute_batch registers the split map.
|
|
_feed_one_msg_to_pump(executor, ready)
|
|
return AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.COMPUTE_DONE,
|
|
rpc_id="1",
|
|
async_output_id=batch_id,
|
|
)
|
|
|
|
executor.collective_rpc = fake_collective_rpc
|
|
batch = executor.execute_batch(_make_scheduler_output(req_ids))
|
|
if not deliver_early:
|
|
_feed_one_msg_to_pump(executor, ready)
|
|
return batch, outputs
|
|
|
|
def test_output_ready_after_split_map(self):
|
|
executor = _make_executor()
|
|
req_ids = ["r0", "r1", "r2"]
|
|
_, outputs = self._run(executor, req_ids, "batch-1", deliver_early=False)
|
|
|
|
for rid in req_ids:
|
|
fut = executor.wait_output_ready(f"batch-1/{rid}")
|
|
assert fut.done()
|
|
assert fut.result(timeout=1.0) is outputs[rid]
|
|
|
|
def test_output_ready_before_split_map(self):
|
|
"""Regression: the whole batch used to be lost, hanging every request."""
|
|
executor = _make_executor()
|
|
req_ids = ["r0", "r1", "r2"]
|
|
_, outputs = self._run(executor, req_ids, "batch-1", deliver_early=True)
|
|
|
|
for rid in req_ids:
|
|
fut = executor.wait_output_ready(f"batch-1/{rid}")
|
|
assert fut.done(), f"request {rid} never resolved"
|
|
assert fut.result(timeout=1.0) is outputs[rid]
|
|
|
|
# No stale batch-level state left behind.
|
|
assert executor._batch_split_map == {}
|
|
assert executor._completed_outputs == {}
|
|
|
|
def test_engine_waiter_before_output_is_resolved(self):
|
|
"""Consumers already blocked in wait_output_ready must be woken."""
|
|
executor = _make_executor()
|
|
req_ids = ["r0", "r1"]
|
|
batch_id = "batch-2"
|
|
futures = {rid: executor.wait_output_ready(f"{batch_id}/{rid}") for rid in req_ids}
|
|
|
|
_, outputs = self._run(executor, req_ids, batch_id, deliver_early=True)
|
|
|
|
for rid in req_ids:
|
|
assert futures[rid].done(), f"request {rid} never resolved"
|
|
assert futures[rid].result(timeout=1.0) is outputs[rid]
|
|
|
|
|
|
class TestShutdownCleansUpFutures:
|
|
"""Test that shutdown cancels pending futures."""
|
|
|
|
def test_shutdown_joins_result_pump_threads(self):
|
|
executor = _make_executor()
|
|
pump = threading.Thread(
|
|
target=executor._pump_stop.wait,
|
|
name="test-result-pump",
|
|
)
|
|
executor._result_pump_threads = [pump]
|
|
pump.start()
|
|
|
|
executor.shutdown()
|
|
|
|
assert not pump.is_alive()
|
|
assert executor._result_pump_threads == []
|
|
|
|
def test_shutdown_sets_exception_on_pending_futures(self):
|
|
executor = _make_executor()
|
|
|
|
rpc_fut = concurrent.futures.Future()
|
|
output_fut = concurrent.futures.Future()
|
|
with executor._futures_lock:
|
|
executor._rpc_futures["1"] = rpc_fut
|
|
executor._output_futures["abc"] = output_fut
|
|
|
|
executor.shutdown()
|
|
|
|
assert rpc_fut.done()
|
|
with pytest.raises(RuntimeError, match="Executor shut down"):
|
|
rpc_fut.result(timeout=1.0)
|
|
|
|
assert output_fut.done()
|
|
with pytest.raises(RuntimeError, match="Executor shut down"):
|
|
output_fut.result(timeout=1.0)
|
|
|
|
assert len(executor._rpc_futures) == 0
|
|
assert len(executor._output_futures) == 0
|
|
|
|
|
|
class _RacyFuture(concurrent.futures.Future):
|
|
# done() always lies and reports False, to deterministically force the
|
|
# pump's check-then-act race window without needing real thread timing.
|
|
def done(self) -> bool:
|
|
return False
|
|
|
|
|
|
def _racy_cancelled_future() -> concurrent.futures.Future:
|
|
fut = _RacyFuture()
|
|
fut.cancel()
|
|
assert fut.cancelled()
|
|
return fut
|
|
|
|
|
|
class TestResultPumpCancelledFutureRace:
|
|
"""Regression for #5793: a future cancelled concurrently with the pump's
|
|
resolve call (e.g. an asyncio.wait_for timeout, or a request abort) must
|
|
be dropped, not raise InvalidStateError and kill the pump thread.
|
|
"""
|
|
|
|
def test_compute_done_racing_cancel_does_not_crash_pump(self):
|
|
executor = _make_executor()
|
|
fut = _racy_cancelled_future()
|
|
with executor._futures_lock:
|
|
executor._rpc_futures["1"] = fut
|
|
|
|
msg = AsyncDiffusionOutput(kind=AsyncOutputKind.COMPUTE_DONE, rpc_id="1", async_output_id="abc")
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
assert fut.cancelled()
|
|
|
|
def test_output_ready_racing_cancel_does_not_crash_pump(self):
|
|
executor = _make_executor()
|
|
fut = _racy_cancelled_future()
|
|
with executor._futures_lock:
|
|
executor._output_futures["abc123"] = fut
|
|
|
|
msg = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id="abc123",
|
|
output=DiffusionOutput(output="late"),
|
|
)
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
assert fut.cancelled()
|
|
assert executor._completed_outputs == {}
|
|
|
|
def test_batch_split_racing_cancel_does_not_crash_pump(self):
|
|
executor = _make_executor()
|
|
cancelled_fut = _racy_cancelled_future()
|
|
healthy_fut = concurrent.futures.Future()
|
|
with executor._futures_lock:
|
|
executor._output_futures["batch-1/r-aborted"] = cancelled_fut
|
|
executor._output_futures["batch-1/r-healthy"] = healthy_fut
|
|
executor._batch_split_map["batch-1"] = {
|
|
"batch-1/r-aborted": "r-aborted",
|
|
"batch-1/r-healthy": "r-healthy",
|
|
}
|
|
|
|
outputs = {
|
|
"r-aborted": DiffusionOutput(output="late"),
|
|
"r-healthy": DiffusionOutput(output="ok"),
|
|
}
|
|
msg = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id="batch-1",
|
|
output=_FakeBatchOutput(outputs),
|
|
)
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
assert cancelled_fut.cancelled()
|
|
assert healthy_fut.done()
|
|
assert healthy_fut.result(timeout=1.0) is outputs["r-healthy"]
|
|
assert executor._completed_outputs == {}
|
|
|
|
|
|
class TestResultPumpDiscardsDoneFuture:
|
|
"""A registered future that is already cancelled/done must have its late
|
|
OUTPUT_READY result discarded, not re-cached into _completed_outputs.
|
|
|
|
rahul-steiger-nv (#6439 review): the old `else` branch cached the result
|
|
whenever `pending.done()` was true, so a genuinely cancelled waiter (the
|
|
common abort case) leaked the late result back into the cache. The pump
|
|
must distinguish "no waiter registered" (cache) from "waiter registered
|
|
but done/cancelled" (discard).
|
|
"""
|
|
|
|
def test_single_output_discards_for_cancelled_future(self):
|
|
executor = _make_executor()
|
|
fut: concurrent.futures.Future = concurrent.futures.Future()
|
|
fut.cancel()
|
|
assert fut.cancelled() and fut.done()
|
|
with executor._futures_lock:
|
|
executor._output_futures["aid-x"] = fut
|
|
|
|
msg = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id="aid-x",
|
|
output=DiffusionOutput(output="late"),
|
|
)
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
assert executor._completed_outputs == {}
|
|
assert "aid-x" not in executor._output_futures
|
|
|
|
def test_batch_split_discards_for_cancelled_member(self):
|
|
executor = _make_executor()
|
|
cancelled_fut: concurrent.futures.Future = concurrent.futures.Future()
|
|
cancelled_fut.cancel()
|
|
assert cancelled_fut.cancelled() and cancelled_fut.done()
|
|
healthy_fut: concurrent.futures.Future = concurrent.futures.Future()
|
|
with executor._futures_lock:
|
|
executor._output_futures["batch-9/r-aborted"] = cancelled_fut
|
|
executor._output_futures["batch-9/r-healthy"] = healthy_fut
|
|
executor._batch_split_map["batch-9"] = {
|
|
"batch-9/r-aborted": "r-aborted",
|
|
"batch-9/r-healthy": "r-healthy",
|
|
}
|
|
|
|
outputs = {
|
|
"r-aborted": DiffusionOutput(output="late"),
|
|
"r-healthy": DiffusionOutput(output="ok"),
|
|
}
|
|
msg = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id="batch-9",
|
|
output=_FakeBatchOutput(outputs),
|
|
)
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
# Aborted member's result is discarded, healthy member still delivered.
|
|
assert healthy_fut.done()
|
|
assert healthy_fut.result(timeout=1.0) is outputs["r-healthy"]
|
|
assert executor._completed_outputs == {}
|
|
|
|
|
|
class TestDropOutput:
|
|
"""drop_output drains an aborted request's async output so it cannot leak.
|
|
|
|
An aborted request never calls wait_output_ready, so a late OUTPUT_READY is
|
|
unpacked and cached in _completed_outputs forever (issue #6413). drop_output
|
|
evicts it, handling both arrival orderings.
|
|
"""
|
|
|
|
def test_drops_already_cached_output(self):
|
|
executor = _make_executor()
|
|
fut = concurrent.futures.Future()
|
|
fut.set_result(DiffusionOutput(output="leaked"))
|
|
with executor._futures_lock:
|
|
executor._completed_outputs["aid-1"] = fut
|
|
|
|
executor.drop_output("aid-1")
|
|
|
|
with executor._futures_lock:
|
|
assert "aid-1" not in executor._completed_outputs
|
|
|
|
def test_prevents_caching_when_output_not_yet_arrived(self):
|
|
executor = _make_executor()
|
|
|
|
# Abort finalized before OUTPUT_READY: register a placeholder waiter.
|
|
executor.drop_output("aid-2")
|
|
|
|
# The late result now resolves into the placeholder instead of caching.
|
|
output = DiffusionOutput(output="late")
|
|
msg = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id="aid-2",
|
|
output=output,
|
|
)
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
with executor._futures_lock:
|
|
assert "aid-2" not in executor._completed_outputs
|
|
assert "aid-2" not in executor._output_futures
|
|
|
|
def test_leaves_real_waiter_untouched(self):
|
|
executor = _make_executor()
|
|
real = executor.wait_output_ready("aid-3")
|
|
|
|
executor.drop_output("aid-3")
|
|
|
|
with executor._futures_lock:
|
|
# A genuine waiter still drains via the normal path.
|
|
assert executor._output_futures.get("aid-3") is real
|
|
|
|
def test_drop_then_pump_then_late_wait_fails_fast_without_caching(self):
|
|
"""drop_output → OUTPUT_READY → late wait_output_ready.
|
|
|
|
The aborted output's tensors must NOT be retained anywhere, and the
|
|
late waiter must get a completed (failed) Future rather than a fresh
|
|
one that never resolves — the verl-omni fully-async abort ordering.
|
|
"""
|
|
executor = _make_executor()
|
|
|
|
executor.drop_output("aid-dpw")
|
|
msg = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id="aid-dpw",
|
|
output=DiffusionOutput(output="discarded"),
|
|
)
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
with executor._futures_lock:
|
|
assert "aid-dpw" not in executor._completed_outputs
|
|
assert "aid-dpw" not in executor._output_futures
|
|
|
|
fut = executor.wait_output_ready("aid-dpw")
|
|
assert fut.done(), "late wait after drop returned a never-completing future"
|
|
with pytest.raises(RuntimeError, match="was dropped"):
|
|
fut.result(timeout=0)
|
|
# Terminal: the dropped-id LRU keeps the id so any later wait_output_ready
|
|
# on the same id also fails fast instead of hanging on a fresh Future.
|
|
with executor._futures_lock:
|
|
assert "aid-dpw" in executor._dropped_output_ids
|
|
second = executor.wait_output_ready("aid-dpw")
|
|
assert second.done()
|
|
with pytest.raises(RuntimeError, match="was dropped"):
|
|
second.result(timeout=0)
|
|
|
|
def test_drop_then_wait_share_placeholder_and_fail_on_delivery(self):
|
|
"""drop_output → wait_output_ready (before pump) share one Future, and
|
|
delivery terminates it with the dropped error instead of the tensors."""
|
|
executor = _make_executor()
|
|
|
|
executor.drop_output("aid-dw")
|
|
fut = executor.wait_output_ready("aid-dw")
|
|
with executor._futures_lock:
|
|
assert executor._output_futures.get("aid-dw") is fut
|
|
assert not fut.done()
|
|
|
|
msg = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id="aid-dw",
|
|
output=DiffusionOutput(output="discarded"),
|
|
)
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
assert fut.done()
|
|
with pytest.raises(RuntimeError, match="was dropped"):
|
|
fut.result(timeout=0)
|
|
with executor._futures_lock:
|
|
assert "aid-dw" not in executor._completed_outputs
|
|
assert "aid-dw" not in executor._output_futures
|
|
|
|
def test_drop_after_cached_then_late_wait_fails_fast(self):
|
|
"""Result already cached → drop_output → late wait must not hang."""
|
|
executor = _make_executor()
|
|
fut = concurrent.futures.Future()
|
|
fut.set_result(DiffusionOutput(output="cached"))
|
|
with executor._futures_lock:
|
|
executor._completed_outputs["aid-cd"] = fut
|
|
|
|
executor.drop_output("aid-cd")
|
|
late = executor.wait_output_ready("aid-cd")
|
|
assert late.done()
|
|
with pytest.raises(RuntimeError, match="was dropped"):
|
|
late.result(timeout=0)
|
|
|
|
def test_dropped_ids_memory_is_bounded(self, monkeypatch):
|
|
from vllm_omni.diffusion.executor import multiproc_executor as mpe
|
|
|
|
cap = 8
|
|
monkeypatch.setattr(mpe, "_DROPPED_OUTPUT_IDS_MAX", cap)
|
|
executor = _make_executor()
|
|
n = cap + 5
|
|
for i in range(n):
|
|
executor.drop_output(f"aid-b{i}")
|
|
executor._pump_stop.clear()
|
|
_feed_one_msg_to_pump(
|
|
executor,
|
|
AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id=f"aid-b{i}",
|
|
output=DiffusionOutput(output=i),
|
|
),
|
|
)
|
|
with executor._futures_lock:
|
|
assert len(executor._dropped_output_ids) == cap
|
|
# Oldest evicted, newest kept; no tensors retained anywhere.
|
|
assert "aid-b0" not in executor._dropped_output_ids
|
|
assert f"aid-b{n - 1}" in executor._dropped_output_ids
|
|
assert executor._completed_outputs == {}
|
|
assert executor._output_futures == {}
|
|
|
|
def test_cancelled_waiter_then_pump_then_late_wait_fails_fast_twice(self):
|
|
"""Fully-async abort overlap: step_streaming registers a real waiter,
|
|
the async abort cancels it, then the pump delivers OUTPUT_READY.
|
|
|
|
Reproduces SamitHuang's 09-14 P1 chain:
|
|
* _finish_output line 1029 discarded a cancelled waiter without
|
|
_remember_dropped → later wait_output_ready allocated a fresh
|
|
Future and hung.
|
|
* wait_output_ready line 1097 returned the cancelled existing future
|
|
as if it were live → abort-after-cancel never installed dropped
|
|
state.
|
|
* wait_output_ready line 1101 deleted the LRU entry on the first
|
|
failed wait → a second late wait on the same id missed the record
|
|
and hung.
|
|
"""
|
|
executor = _make_executor()
|
|
|
|
# step_streaming registers a real waiter.
|
|
real = executor.wait_output_ready("aid-cx")
|
|
assert not real.done()
|
|
|
|
# asyncio.wrap_future(...).cancel() marks the underlying
|
|
# concurrent.futures.Future cancelled.
|
|
assert real.cancel()
|
|
assert real.cancelled()
|
|
assert real.done()
|
|
|
|
# Pump delivers OUTPUT_READY for the same id after the cancel.
|
|
msg = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id="aid-cx",
|
|
output=DiffusionOutput(output="discarded-after-cancel"),
|
|
)
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
with executor._futures_lock:
|
|
# Tensors are not re-cached; the cancelled waiter is gone.
|
|
assert "aid-cx" not in executor._completed_outputs
|
|
assert "aid-cx" not in executor._output_futures
|
|
# The id was recorded as dropped so late waits fail fast.
|
|
assert "aid-cx" in executor._dropped_output_ids
|
|
|
|
# Late wait returns an already-failed Future (not a fresh never-completing one).
|
|
late = executor.wait_output_ready("aid-cx")
|
|
assert late.done()
|
|
with pytest.raises(RuntimeError, match="was dropped"):
|
|
late.result(timeout=0)
|
|
|
|
# A second late wait must ALSO fail fast: the LRU entry must persist.
|
|
late2 = executor.wait_output_ready("aid-cx")
|
|
assert late2.done()
|
|
with pytest.raises(RuntimeError, match="was dropped"):
|
|
late2.result(timeout=0)
|
|
with executor._futures_lock:
|
|
assert "aid-cx" in executor._dropped_output_ids
|
|
assert "aid-cx" not in executor._output_futures
|
|
assert "aid-cx" not in executor._completed_outputs
|
|
|
|
def test_wait_output_ready_replaces_stale_cancelled_existing_future(self):
|
|
"""A cancelled non-placeholder Future in _output_futures is not a live
|
|
waiter: the next wait_output_ready must evict it and install dropped
|
|
state rather than returning the cancelled future.
|
|
|
|
Reproduces SamitHuang's wait_output_ready:1135 finding: without this,
|
|
abort-after-cancel skips _remember_dropped entirely and downstream
|
|
pump delivery falls into the discard branch that used to lose the id.
|
|
"""
|
|
executor = _make_executor()
|
|
|
|
stale = executor.wait_output_ready("aid-stale")
|
|
assert stale.cancel()
|
|
assert stale.done() and stale.cancelled()
|
|
|
|
replacement = executor.wait_output_ready("aid-stale")
|
|
assert replacement is not stale
|
|
assert replacement.done()
|
|
with pytest.raises(RuntimeError, match="was dropped"):
|
|
replacement.result(timeout=0)
|
|
with executor._futures_lock:
|
|
assert "aid-stale" not in executor._output_futures
|
|
assert "aid-stale" in executor._dropped_output_ids
|
|
|
|
def test_pump_after_cancelled_waiter_then_wait_stays_dropped(self):
|
|
"""Timeline-B: the cancelled-waiter has already been observed by a
|
|
second wait_output_ready and evicted into the dropped-id LRU, THEN the
|
|
pump delivers OUTPUT_READY.
|
|
|
|
Without a dropped-id guard in _finish_output, the pump's ``pending``
|
|
is ``None`` (the second wait already popped the stale entry), so the
|
|
no-waiter branch caches the tensors in ``_completed_outputs``. A
|
|
subsequent wait_output_ready then pops that cache and returns a
|
|
successful Future — violating the "every late wait on a dropped id
|
|
fails the same way" contract from wait_output_ready.
|
|
"""
|
|
executor = _make_executor()
|
|
|
|
# (1) step_streaming registers a real waiter.
|
|
real = executor.wait_output_ready("aid-tb")
|
|
assert not real.done()
|
|
|
|
# (2) asyncio cancels it.
|
|
assert real.cancel()
|
|
|
|
# (3) Second wait_output_ready observes the stale entry, evicts it into
|
|
# the dropped-id LRU and returns an already-failed Future.
|
|
first_late = executor.wait_output_ready("aid-tb")
|
|
assert first_late.done()
|
|
with pytest.raises(RuntimeError, match="was dropped"):
|
|
first_late.result(timeout=0)
|
|
with executor._futures_lock:
|
|
assert "aid-tb" not in executor._output_futures
|
|
assert "aid-tb" in executor._dropped_output_ids
|
|
|
|
# (4) Pump delivers OUTPUT_READY for the dropped id AFTER the LRU
|
|
# record is already in place.
|
|
msg = AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id="aid-tb",
|
|
output=DiffusionOutput(output="late-after-evict"),
|
|
)
|
|
_feed_one_msg_to_pump(executor, msg)
|
|
|
|
# (5) The tensors must NOT be cached, and the id must remain in the LRU.
|
|
with executor._futures_lock:
|
|
assert "aid-tb" not in executor._completed_outputs
|
|
assert "aid-tb" not in executor._output_futures
|
|
assert "aid-tb" in executor._dropped_output_ids
|
|
|
|
# (6) A subsequent wait_output_ready must still fail fast, NOT pop a
|
|
# successful cached Future.
|
|
second_late = executor.wait_output_ready("aid-tb")
|
|
assert second_late.done()
|
|
with pytest.raises(RuntimeError, match="was dropped"):
|
|
second_late.result(timeout=0)
|
|
|
|
def test_cancel_between_done_check_and_set_result_records_dropped(self):
|
|
"""Race: consumer cancels the Future between the pump's ``.done()``
|
|
check and the actual ``set_result``.
|
|
|
|
Reproduces Gaohan123's 09-19 P2: ``try_set_result`` catches the
|
|
``InvalidStateError`` silently, but the waiter has already been
|
|
popped from ``_output_futures`` by ``_finish_output``. Without
|
|
recording the id as dropped, a subsequent ``wait_output_ready``
|
|
allocates a fresh Future that can never complete.
|
|
"""
|
|
|
|
class _RaceCancellingFuture(concurrent.futures.Future):
|
|
"""Future that mimics a consumer cancelling in the tiny window
|
|
between ``pending.done()`` and ``pending.set_result(...)``."""
|
|
|
|
def set_result(self, result):
|
|
self.cancel() # race lands here — mid-branch cancel
|
|
super().set_result(result) # raises InvalidStateError
|
|
|
|
def set_exception(self, exc):
|
|
self.cancel()
|
|
super().set_exception(exc)
|
|
|
|
executor = _make_executor()
|
|
racy = _RaceCancellingFuture()
|
|
with executor._futures_lock:
|
|
executor._output_futures["aid-race"] = racy
|
|
|
|
# Simulate the pump delivery: at entry .done() is False, then the
|
|
# concurrent cancel lands, then set_result raises InvalidStateError.
|
|
with executor._futures_lock:
|
|
executor._finish_output("aid-race", DiffusionOutput(output="lost"), None)
|
|
|
|
assert racy.cancelled()
|
|
with executor._futures_lock:
|
|
# The waiter has been popped and the id must be recorded as dropped.
|
|
assert "aid-race" not in executor._output_futures
|
|
assert "aid-race" not in executor._completed_outputs
|
|
assert "aid-race" in executor._dropped_output_ids
|
|
|
|
# Late wait_output_ready must fail fast, not allocate a fresh Future.
|
|
late = executor.wait_output_ready("aid-race")
|
|
assert late.done()
|
|
with pytest.raises(RuntimeError, match="was dropped"):
|
|
late.result(timeout=0)
|
|
# And a second late wait must also fail fast (LRU stays populated).
|
|
late2 = executor.wait_output_ready("aid-race")
|
|
assert late2.done()
|
|
with pytest.raises(RuntimeError, match="was dropped"):
|
|
late2.result(timeout=0)
|
|
|
|
def test_batch_split_drop_placeholder_discards_member(self):
|
|
"""_deliver_batch_split applies the same contract: a dropped member is
|
|
discarded (never cached), a live member is resolved directly."""
|
|
executor = _make_executor()
|
|
live = executor.wait_output_ready("batch-d/r-live")
|
|
executor.drop_output("batch-d/r-dropped")
|
|
with executor._futures_lock:
|
|
executor._batch_split_map["batch-d"] = {
|
|
"batch-d/r-dropped": "r-dropped",
|
|
"batch-d/r-live": "r-live",
|
|
}
|
|
outputs = {
|
|
"r-dropped": DiffusionOutput(output="discarded"),
|
|
"r-live": DiffusionOutput(output="ok"),
|
|
}
|
|
_feed_one_msg_to_pump(
|
|
executor,
|
|
AsyncDiffusionOutput(
|
|
kind=AsyncOutputKind.OUTPUT_READY,
|
|
async_output_id="batch-d",
|
|
output=_FakeBatchOutput(outputs),
|
|
),
|
|
)
|
|
assert live.result(timeout=1.0) is outputs["r-live"]
|
|
with executor._futures_lock:
|
|
assert executor._completed_outputs == {}
|
|
assert executor._output_futures == {}
|
|
late = executor.wait_output_ready("batch-d/r-dropped")
|
|
assert late.done()
|
|
with pytest.raises(RuntimeError, match="was dropped"):
|
|
late.result(timeout=0)
|
|
|
|
|
|
class TestShutdownClearsCompletedOutputs:
|
|
"""shutdown() must release cached async outputs (issue #6413)."""
|
|
|
|
def test_shutdown_clears_completed_outputs(self):
|
|
executor = _make_executor()
|
|
fut = concurrent.futures.Future()
|
|
fut.set_result(DiffusionOutput(output="cached"))
|
|
with executor._futures_lock:
|
|
executor._completed_outputs["aid-4"] = fut
|
|
|
|
executor.shutdown()
|
|
|
|
assert executor._completed_outputs == {}
|