Files
vllm-omni/tests/engine/test_orchestrator.py
T

605 lines
20 KiB
Python

from __future__ import annotations
import asyncio
import concurrent.futures
import queue
import threading
import time
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
import janus
import psutil
import pytest
from vllm.outputs import CompletionOutput, RequestOutput
from vllm.sampling_params import SamplingParams
from vllm.v1.engine.core_client import AsyncMPClient
from vllm_omni.engine.orchestrator import Orchestrator
from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClient
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@dataclass
class OrchestratorFixture:
orchestrator: Orchestrator
request_sync_q: Any
output_sync_q: Any
queues: tuple[janus.Queue, ...]
thread: threading.Thread
result_future: concurrent.futures.Future[None]
class FakeStageClient:
def __init__(
self,
*,
stage_type: str = "llm",
final_output: bool = False,
final_output_type: str = "text",
next_inputs: list[dict] | None = None,
) -> None:
self.stage_type = stage_type
self.final_output = final_output
self.final_output_type = final_output_type
self.next_inputs = list(next_inputs or [])
self.custom_process_input_func = None
self.add_request_calls: list[tuple] = []
self.abort_calls: list[list[str]] = []
self.shutdown_calls = 0
self._engine_core_outputs = queue.Queue()
self._diffusion_outputs = queue.Queue()
# Orchestrator-facing interface.
async def add_request_async(self, *args, **_kwargs) -> None:
self.add_request_calls.append(args)
async def get_output_async(self):
try:
return self._engine_core_outputs.get_nowait()
except queue.Empty:
return SimpleNamespace(outputs=[])
def get_diffusion_output_nowait(self):
try:
return self._diffusion_outputs.get_nowait()
except queue.Empty:
return None
def set_engine_outputs(self, outputs) -> None:
return None
def process_engine_inputs(self, stage_list, prompt=None, streaming_context=None):
return list(self.next_inputs)
async def abort_requests_async(self, request_ids: list[str]) -> None:
self.abort_calls.append(list(request_ids))
def shutdown(self) -> None:
self.shutdown_calls += 1
# Test helpers for seeding fake stage outputs.
def push_engine_core_outputs(self, outputs) -> None:
self._engine_core_outputs.put_nowait(outputs)
def push_diffusion_output(self, output) -> None:
self._diffusion_outputs.put_nowait(output)
class FakeOutputProcessor:
def __init__(self, *, request_outputs: list[object] | None = None) -> None:
self.request_outputs = list(request_outputs or [])
def add_request(self, *_args, **_kwargs) -> None:
return None
def process_outputs(self, *_args, **_kwargs):
return SimpleNamespace(
request_outputs=list(self.request_outputs),
reqs_to_abort=[],
)
def update_scheduler_stats(self, _scheduler_stats) -> None:
return None
class _FakeProc:
pid = 1234
def __init__(self):
self.terminated = False
self.killed = False
self.join_calls = []
def is_alive(self):
return not self.terminated and not self.killed
def terminate(self):
self.terminated = True
def kill(self):
self.killed = True
def join(self, timeout=None):
self.join_calls.append(timeout)
class _FakeChildProc:
def __init__(self):
self.terminated = False
self.killed = False
def is_running(self):
return not self.terminated and not self.killed
def terminate(self):
self.terminated = True
def kill(self):
self.killed = True
def _sampling_params(max_tokens: int = 4) -> SamplingParams:
return SamplingParams(max_tokens=max_tokens)
def _engine_core_outputs(tag: str, timestamp: float) -> SimpleNamespace:
return SimpleNamespace(outputs=[tag], timestamp=timestamp, scheduler_stats=None)
def _build_request_output(
request_id: str,
*,
token_ids: list[int] | None = None,
prompt_token_ids: list[int] | None = None,
finished: bool = True,
text: str = "test",
) -> RequestOutput:
completion = CompletionOutput(
index=0,
text=text,
token_ids=list(token_ids or [1, 2]),
cumulative_logprob=0.0,
logprobs=None,
finish_reason="stop" if finished else None,
stop_reason=None,
)
return RequestOutput(
request_id=request_id,
prompt="prompt",
prompt_token_ids=list(prompt_token_ids or [10, 11]),
prompt_logprobs=None,
outputs=[completion],
finished=finished,
metrics=None,
lora_request=None,
)
def _build_harness(
stage_clients: list[object],
*,
output_processors: list[object] | None = None,
stage_vllm_configs: list[object] | None = None,
async_chunk: bool = False,
) -> OrchestratorFixture:
if output_processors is None:
output_processors = [FakeOutputProcessor() for _ in stage_clients]
if stage_vllm_configs is None:
stage_vllm_configs = [SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)) for _ in stage_clients]
ready_future: concurrent.futures.Future[tuple[Orchestrator, janus.Queue, janus.Queue, janus.Queue]] = (
concurrent.futures.Future()
)
result_future: concurrent.futures.Future[None] = concurrent.futures.Future()
def _runner() -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def _run() -> None:
request_queue = janus.Queue()
output_queue = janus.Queue()
rpc_queue = janus.Queue()
orchestrator = Orchestrator(
request_async_queue=request_queue.async_q,
output_async_queue=output_queue.async_q,
rpc_async_queue=rpc_queue.async_q,
stage_clients=stage_clients,
output_processors=output_processors,
stage_vllm_configs=stage_vllm_configs,
async_chunk=async_chunk,
)
ready_future.set_result((orchestrator, request_queue, output_queue, rpc_queue))
await orchestrator.run()
try:
loop.run_until_complete(_run())
result_future.set_result(None)
except Exception as exc:
result_future.set_exception(exc)
finally:
try:
pending = [task for task in asyncio.all_tasks(loop) if not task.done()]
for task in pending:
task.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.run_until_complete(loop.shutdown_asyncgens())
finally:
asyncio.set_event_loop(None)
loop.close()
thread = threading.Thread(target=_runner, daemon=True, name="test-orchestrator")
thread.start()
orchestrator, request_queue, output_queue, rpc_queue = ready_future.result(timeout=5)
return OrchestratorFixture(
orchestrator=orchestrator,
request_sync_q=request_queue.sync_q,
output_sync_q=output_queue.sync_q,
queues=(request_queue, output_queue, rpc_queue),
thread=thread,
result_future=result_future,
)
async def _shutdown_orchestrator(orchestrator_fixture: OrchestratorFixture) -> None:
orchestrator_fixture.request_sync_q.put_nowait({"type": "shutdown"})
await asyncio.to_thread(orchestrator_fixture.thread.join, 5)
if orchestrator_fixture.thread.is_alive():
raise AssertionError("Timed out waiting for orchestrator thread shutdown")
orchestrator_fixture.result_future.result(timeout=0)
async def _wait_for(predicate, *, timeout: float = 2.0) -> None:
deadline = time.monotonic() + timeout
while not predicate():
if time.monotonic() >= deadline:
raise AssertionError("Timed out waiting for predicate")
await asyncio.sleep(0.01)
async def _get_output_message(orchestrator_fixture: OrchestratorFixture, *, timeout: float = 2.0) -> dict:
deadline = time.monotonic() + timeout
while True:
if time.monotonic() >= deadline:
raise AssertionError("Timed out waiting for orchestrator output")
try:
msg = orchestrator_fixture.output_sync_q.get_nowait()
except queue.Empty:
await asyncio.sleep(0.01)
continue
if msg.get("type") == "output":
return msg
async def _enqueue_add_request(
orchestrator_fixture: OrchestratorFixture,
*,
request_id: str,
prompt,
original_prompt,
sampling_params_list,
final_stage_id: int,
) -> None:
orchestrator_fixture.request_sync_q.put_nowait(
{
"type": "add_request",
"request_id": request_id,
"prompt": prompt,
"original_prompt": original_prompt,
"sampling_params_list": sampling_params_list,
"final_stage_id": final_stage_id,
}
)
async def _enqueue_abort_request(orchestrator_fixture: OrchestratorFixture, request_ids: list[str]) -> None:
orchestrator_fixture.request_sync_q.put_nowait(
{
"type": "abort",
"request_ids": request_ids,
}
)
def test_stage_engine_core_client_shutdown_cleans_children_if_base_shutdown_fails(monkeypatch):
fake_proc = _FakeProc()
fake_child = _FakeChildProc()
class FakePsutilProcess:
def __init__(self, pid):
assert pid == fake_proc.pid
def children(self, recursive=True):
assert recursive
return [fake_child]
def fail_base_shutdown(self):
raise RuntimeError("base shutdown failed")
monkeypatch.setattr(psutil, "Process", FakePsutilProcess)
monkeypatch.setattr(psutil, "wait_procs", lambda procs, timeout: (list(procs), []))
monkeypatch.setattr(AsyncMPClient, "shutdown", fail_base_shutdown)
client = object.__new__(StageEngineCoreClient)
client._proc = fake_proc
with pytest.raises(RuntimeError, match="base shutdown failed"):
client.shutdown()
assert fake_proc.terminated
assert fake_proc.join_calls == [5]
assert fake_child.terminated
def test_stage_engine_core_client_shutdown_kills_stubborn_children(monkeypatch):
fake_proc = _FakeProc()
fake_child = _FakeChildProc()
class FakePsutilProcess:
def __init__(self, pid):
assert pid == fake_proc.pid
def children(self, recursive=True):
assert recursive
return [fake_child]
monkeypatch.setattr(psutil, "Process", FakePsutilProcess)
monkeypatch.setattr(psutil, "wait_procs", lambda procs, timeout: ([], list(procs)))
monkeypatch.setattr(AsyncMPClient, "shutdown", lambda self: None)
client = object.__new__(StageEngineCoreClient)
client._proc = fake_proc
client.shutdown()
assert fake_child.terminated
assert fake_child.killed
@pytest.fixture
def orchestrator_factory():
fixtures: list[OrchestratorFixture] = []
def _factory(*args, **kwargs) -> OrchestratorFixture:
fixture = _build_harness(*args, **kwargs)
fixtures.append(fixture)
return fixture
yield _factory
for fixture in fixtures:
if fixture.thread.is_alive():
fixture.request_sync_q.put_nowait({"type": "shutdown"})
fixture.thread.join(timeout=5)
for q in fixture.queues:
q.close()
@pytest.mark.asyncio
async def test_run_two_stage_llm(orchestrator_factory) -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=False)
stage1 = FakeStageClient(
stage_type="llm",
final_output=True,
next_inputs=[{"prompt_token_ids": [7, 8, 9]}],
)
processors = [
FakeOutputProcessor(request_outputs=[_build_request_output("req-llm", token_ids=[3, 4], finished=True)]),
FakeOutputProcessor(request_outputs=[_build_request_output("req-llm", token_ids=[10, 11], finished=True)]),
]
orchestrator_fixture = orchestrator_factory([stage0, stage1], output_processors=processors)
request = SimpleNamespace(request_id="req-llm", prompt_token_ids=[1, 2, 3])
try:
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-llm",
prompt=request,
original_prompt={"prompt": "hello"},
sampling_params_list=[_sampling_params(), _sampling_params()],
final_stage_id=1,
)
await _wait_for(lambda: len(stage0.add_request_calls) == 1)
stage0.push_engine_core_outputs(_engine_core_outputs("stage0-raw", 1.0))
await _wait_for(lambda: len(stage1.add_request_calls) == 1)
stage1_request = stage1.add_request_calls[0][0]
assert stage1_request.request_id == "req-llm"
assert stage1_request.prompt_token_ids == [7, 8, 9]
stage1.push_engine_core_outputs(_engine_core_outputs("stage1-raw", 2.0))
output_msg = await _get_output_message(orchestrator_fixture)
assert output_msg["request_id"] == "req-llm"
assert output_msg["stage_id"] == 1
assert output_msg["finished"] is True
assert output_msg["engine_outputs"].request_id == "req-llm"
assert "req-llm" not in orchestrator_fixture.orchestrator.request_states
finally:
await _shutdown_orchestrator(orchestrator_fixture)
@pytest.mark.asyncio
async def test_run_single_stage_diffusion(orchestrator_factory) -> None:
stage0 = FakeStageClient(stage_type="diffusion", final_output=True, final_output_type="image")
orchestrator_fixture = orchestrator_factory([stage0])
params = OmniDiffusionSamplingParams()
try:
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-diff",
prompt={"prompt": "draw a cat"},
original_prompt={"prompt": "draw a cat"},
sampling_params_list=[params],
final_stage_id=0,
)
await _wait_for(lambda: len(stage0.add_request_calls) == 1)
stage0.push_diffusion_output(
OmniRequestOutput.from_diffusion(
request_id="req-diff",
images=[],
final_output_type="image",
)
)
output_msg = await _get_output_message(orchestrator_fixture)
assert output_msg["request_id"] == "req-diff"
assert output_msg["stage_id"] == 0
assert output_msg["finished"] is True
assert output_msg["engine_outputs"].request_id == "req-diff"
assert "req-diff" not in orchestrator_fixture.orchestrator.request_states
finally:
await _shutdown_orchestrator(orchestrator_fixture)
@pytest.mark.asyncio
async def test_run_llm_to_diffusion(orchestrator_factory) -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=False)
stage1 = FakeStageClient(stage_type="diffusion", final_output=True, final_output_type="image")
processors = [
FakeOutputProcessor(request_outputs=[_build_request_output("req-img", token_ids=[3, 4], finished=True)]),
FakeOutputProcessor(),
]
orchestrator_fixture = orchestrator_factory([stage0, stage1], output_processors=processors)
request = SimpleNamespace(request_id="req-img", prompt_token_ids=[1, 2, 3])
params = OmniDiffusionSamplingParams()
original_prompt = {"prompt": "draw a fox"}
try:
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-img",
prompt=request,
original_prompt=original_prompt,
sampling_params_list=[_sampling_params(), params],
final_stage_id=1,
)
await _wait_for(lambda: len(stage0.add_request_calls) == 1)
stage0.push_engine_core_outputs(_engine_core_outputs("stage0-raw", 1.0))
await _wait_for(lambda: len(stage1.add_request_calls) == 1)
assert stage1.add_request_calls[0] == ("req-img", original_prompt, params)
stage1.push_diffusion_output(
OmniRequestOutput.from_diffusion(
request_id="req-img",
images=[],
final_output_type="image",
)
)
output_msg = await _get_output_message(orchestrator_fixture)
assert output_msg["request_id"] == "req-img"
assert output_msg["stage_id"] == 1
assert output_msg["finished"] is True
assert output_msg["engine_outputs"].request_id == "req-img"
assert "req-img" not in orchestrator_fixture.orchestrator.request_states
finally:
await _shutdown_orchestrator(orchestrator_fixture)
@pytest.mark.asyncio
async def test_run_async_chunk(orchestrator_factory) -> None:
stage0 = FakeStageClient(stage_type="llm", final_output=False)
stage1 = FakeStageClient(stage_type="llm", final_output=True)
processors = [
FakeOutputProcessor(request_outputs=[_build_request_output("req-async", token_ids=[1], finished=True)]),
FakeOutputProcessor(request_outputs=[_build_request_output("req-async", token_ids=[20, 21], finished=True)]),
]
orchestrator_fixture = orchestrator_factory(
[stage0, stage1],
output_processors=processors,
async_chunk=True,
)
request = SimpleNamespace(request_id="req-async", prompt_token_ids=[1, 2, 3, 4])
try:
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-async",
prompt=request,
original_prompt={"prompt": "hello async"},
sampling_params_list=[_sampling_params(), _sampling_params()],
final_stage_id=1,
)
await _wait_for(lambda: len(stage1.add_request_calls) == 1)
prewarmed_request = stage1.add_request_calls[0][0]
assert prewarmed_request.request_id == "req-async"
assert prewarmed_request.prompt_token_ids
assert all(token_id == 0 for token_id in prewarmed_request.prompt_token_ids)
stage1.push_engine_core_outputs(_engine_core_outputs("stage1-final", 3.0))
output_msg = await _get_output_message(orchestrator_fixture)
assert output_msg["request_id"] == "req-async"
assert output_msg["stage_id"] == 1
assert output_msg["finished"] is True
assert "req-async" not in orchestrator_fixture.orchestrator.request_states
finally:
await _shutdown_orchestrator(orchestrator_fixture)
@pytest.mark.asyncio
async def test_run_shutdown(orchestrator_factory) -> None:
stages = [
FakeStageClient(stage_type="llm", final_output=False),
FakeStageClient(stage_type="diffusion", final_output=True, final_output_type="image"),
]
orchestrator_fixture = orchestrator_factory(stages)
await _shutdown_orchestrator(orchestrator_fixture)
assert not orchestrator_fixture.thread.is_alive()
for stage in stages:
assert stage.shutdown_calls == 1
@pytest.mark.asyncio
async def test_run_abort(orchestrator_factory) -> None:
stages = [
FakeStageClient(stage_type="llm", final_output=False),
FakeStageClient(stage_type="llm", final_output=True),
]
processors = [
FakeOutputProcessor(request_outputs=[_build_request_output("req-abort", token_ids=[1], finished=True)]),
FakeOutputProcessor(request_outputs=[_build_request_output("req-abort", token_ids=[2], finished=True)]),
]
orchestrator_fixture = orchestrator_factory(stages, output_processors=processors)
request = SimpleNamespace(request_id="req-abort", prompt_token_ids=[1, 2, 3])
try:
await _enqueue_add_request(
orchestrator_fixture,
request_id="req-abort",
prompt=request,
original_prompt={"prompt": "cancel me"},
sampling_params_list=[_sampling_params(), _sampling_params()],
final_stage_id=1,
)
await _wait_for(lambda: len(stages[0].add_request_calls) == 1)
await _enqueue_abort_request(orchestrator_fixture, ["req-abort"])
await _wait_for(lambda: all(stage.abort_calls for stage in stages))
for stage in stages:
assert stage.abort_calls == [["req-abort"]]
assert "req-abort" not in orchestrator_fixture.orchestrator.request_states
finally:
await _shutdown_orchestrator(orchestrator_fixture)