Files

573 lines
20 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""GPU pool tests.
InProcessGpuPool is exercised end-to-end. SubprocessGpuPool is driven
with an injected ``worker_factory`` that stands up a fake worker inside
a thread (not a subprocess) so the test suite stays CPU-only.
"""
from __future__ import annotations
import asyncio
import multiprocessing as mp
import queue
import threading
import time
from concurrent.futures import Future
from dataclasses import dataclass
from typing import Any
import pytest
from fastvideo.api.schema import (
GeneratorConfig,
GenerationRequest,
GpuPoolConfig,
WarmupConfig,
)
from fastvideo.entrypoints.streaming.gpu_pool import (
GpuPool,
InProcessGpuPool,
PoolAcquireTimeout,
SubprocessGpuPool,
_PendingJob,
_WorkerHandle,
)
# ----------------------------------------------------------------------
# In-process pool
# ----------------------------------------------------------------------
@dataclass
class _MockGenerator:
sleep_s: float = 0.0
def generate(self, request: GenerationRequest) -> dict[str, Any]:
if self.sleep_s:
time.sleep(self.sleep_s)
return {
"frames": [],
"prompt_echo": request.prompt,
}
class TestInProcessGpuPool:
def test_is_gpu_pool(self):
assert isinstance(InProcessGpuPool(_MockGenerator()), GpuPool)
def test_acquire_returns_deterministic_assignment(self):
pool = InProcessGpuPool(_MockGenerator(), gpu_id=7)
async def run():
a = await pool.acquire("sess-a")
return a
a = asyncio.run(run())
assert a.gpu_id == 7
assert a.worker_id.startswith("inproc-")
def test_acquire_is_sticky_across_calls(self):
pool = InProcessGpuPool(_MockGenerator())
async def run():
a = await pool.acquire("sess-a")
b = await pool.acquire("sess-a")
return a, b
a, b = asyncio.run(run())
assert a == b
def test_run_without_acquire_raises(self):
pool = InProcessGpuPool(_MockGenerator())
async def run():
with pytest.raises(RuntimeError):
await pool.run("sess-a", GenerationRequest(prompt="hi"))
asyncio.run(run())
def test_run_returns_generator_output(self):
pool = InProcessGpuPool(_MockGenerator())
async def run():
await pool.acquire("sess-a")
return await pool.run("sess-a", GenerationRequest(prompt="hi"))
result = asyncio.run(run())
assert result["prompt_echo"] == "hi"
def test_release_frees_binding(self):
pool = InProcessGpuPool(_MockGenerator())
async def run():
await pool.acquire("sess-a")
await pool.release("sess-a")
with pytest.raises(RuntimeError):
await pool.run("sess-a", GenerationRequest(prompt="hi"))
asyncio.run(run())
def test_health_reports_active_sessions(self):
pool = InProcessGpuPool(_MockGenerator())
async def run():
await pool.acquire("sess-a")
health = pool.health()
assert health.total_workers == 1
assert health.active_sessions == 1
assert health.available_workers == 0
asyncio.run(run())
# ----------------------------------------------------------------------
# Subprocess pool (driven by a thread-backed fake worker factory)
# ----------------------------------------------------------------------
class _ThreadWorker:
"""Stand-in for a subprocess worker.
Runs a Python thread that pulls jobs from ``job_queue`` and invokes
a supplied mock generator. The control flow matches
:func:`worker_main` exactly (ready + result dict shapes) so the
parent-side pool under test exercises the same code paths.
"""
def __init__(
self,
generator: _MockGenerator,
*,
job_queue: mp.Queue,
result_queue: mp.Queue,
shutdown_event: threading.Event,
warmup_ms: float = 0.0,
) -> None:
self._generator = generator
self._job_queue = job_queue
self._result_queue = result_queue
self._shutdown_event = shutdown_event
self._warmup_ms = warmup_ms
self._thread = threading.Thread(target=self._run, daemon=True)
def start(self) -> None:
self._thread.start()
def join(self, timeout: float | None = None) -> None:
self._thread.join(timeout)
def _run(self) -> None:
if self._warmup_ms:
time.sleep(self._warmup_ms / 1000.0)
self._result_queue.put({"kind": "ready"})
while not self._shutdown_event.is_set():
try:
item = self._job_queue.get(timeout=0.1)
except queue.Empty:
continue
if item is None:
return
try:
result = self._generator.generate(item["request"])
self._result_queue.put({
"kind": "result",
"job_id": item["job_id"],
"result": result,
})
except Exception as exc: # pragma: no cover - defensive
self._result_queue.put({
"kind": "error",
"job_id": item["job_id"],
"error": repr(exc),
})
def _thread_worker_factory(generator_builder):
"""Return a WorkerFactory that uses thread workers instead of procs."""
def factory(
*,
gpu_id: int,
generator_config: GeneratorConfig,
warmup_config: WarmupConfig,
) -> _WorkerHandle:
ctx = mp.get_context("spawn")
job_queue: mp.Queue = ctx.Queue()
result_queue: mp.Queue = ctx.Queue()
shutdown_event = threading.Event()
ready = threading.Event()
boot_ok = threading.Event()
mp_shutdown = ctx.Event()
generator = generator_builder(gpu_id)
worker = _ThreadWorker(
generator,
job_queue=job_queue,
result_queue=result_queue,
shutdown_event=shutdown_event,
)
worker.start()
# Drain the ready sentinel from the queue in the same way the
# real factory does (thread waiter populates ``ready`` /
# ``boot_ok``).
def _await_ready() -> None:
while True:
try:
msg = result_queue.get(timeout=1.0)
except queue.Empty:
if shutdown_event.is_set():
return
continue
if msg.get("kind") == "ready":
boot_ok.set()
ready.set()
return
if msg.get("kind") == "error":
ready.set()
return
threading.Thread(target=_await_ready, daemon=True).start()
class _FakeProcess:
def __init__(self, stop: threading.Event, worker: _ThreadWorker):
self._stop = stop
self._worker = worker
def is_alive(self) -> bool:
return self._worker._thread.is_alive()
def join(self, timeout: float | None = None) -> None:
self._stop.set()
self._worker.join(timeout)
def kill(self) -> None:
self._stop.set()
fake_process = _FakeProcess(shutdown_event, worker)
return _WorkerHandle(
process=fake_process, # type: ignore[arg-type]
job_queue=job_queue,
result_queue=result_queue,
gpu_id=gpu_id,
worker_id=f"gpu{gpu_id}-fake",
ready=ready,
boot_ok=boot_ok,
shutdown_event=mp_shutdown,
)
return factory
@pytest.fixture
def pool_factory():
"""Provide a SubprocessGpuPool built against thread workers."""
async def _build(num_workers: int = 2):
pool = SubprocessGpuPool(
generator_config=GeneratorConfig(model_path="/models/fake"),
pool_config=GpuPoolConfig(num_workers=num_workers),
warmup_config=WarmupConfig(enabled=False),
worker_factory=_thread_worker_factory(lambda gpu_id: _MockGenerator()),
)
await pool.start()
return pool
return _build
class TestSubprocessGpuPool:
def test_start_spawns_requested_workers(self, pool_factory):
async def run():
pool = await pool_factory(num_workers=3)
try:
h = pool.health()
assert h.total_workers == 3
assert h.available_workers == 3
finally:
await pool.shutdown()
asyncio.run(run())
def test_acquire_decrements_available(self, pool_factory):
async def run():
pool = await pool_factory(num_workers=2)
try:
a = await pool.acquire("sess-a")
assert a.worker_id.endswith("-fake")
assert pool.health().available_workers == 1
finally:
await pool.shutdown()
asyncio.run(run())
def test_acquire_timeout_when_all_busy(self, pool_factory):
async def run():
pool = await pool_factory(num_workers=1)
try:
await pool.acquire("sess-a")
with pytest.raises(PoolAcquireTimeout):
await pool.acquire("sess-b", timeout=0.1)
finally:
await pool.shutdown()
asyncio.run(run())
def test_run_returns_worker_result(self, pool_factory):
async def run():
pool = await pool_factory(num_workers=1)
try:
await pool.acquire("sess-a")
result = await pool.run("sess-a", GenerationRequest(prompt="hello"))
assert result["prompt_echo"] == "hello"
finally:
await pool.shutdown()
asyncio.run(run())
def test_release_returns_worker_to_pool(self, pool_factory):
async def run():
pool = await pool_factory(num_workers=1)
try:
await pool.acquire("sess-a")
await pool.release("sess-a")
# Now a second acquire should succeed without timeout.
a = await pool.acquire("sess-b", timeout=1.0)
assert a.worker_id.endswith("-fake")
finally:
await pool.shutdown()
asyncio.run(run())
def test_sticky_binding_across_multiple_runs(self, pool_factory):
async def run():
pool = await pool_factory(num_workers=2)
try:
a1 = await pool.acquire("sess-a")
a2 = await pool.acquire("sess-a")
assert a1.worker_id == a2.worker_id
# Two runs land on the same worker.
await pool.run("sess-a", GenerationRequest(prompt="1"))
await pool.run("sess-a", GenerationRequest(prompt="2"))
finally:
await pool.shutdown()
asyncio.run(run())
def test_run_without_acquire_raises(self, pool_factory):
async def run():
pool = await pool_factory(num_workers=1)
try:
with pytest.raises(RuntimeError):
await pool.run("sess-x", GenerationRequest(prompt="x"))
finally:
await pool.shutdown()
asyncio.run(run())
def test_shutdown_is_idempotent(self, pool_factory):
async def run():
pool = await pool_factory(num_workers=2)
await pool.shutdown()
await pool.shutdown() # no raise
asyncio.run(run())
class TestSubprocessGpuPoolFailureModes:
"""Coverage for boot/runtime failures the pool has to absorb."""
@pytest.mark.parametrize("worker_raises", [False, True])
@pytest.mark.parametrize("cancel_during_dispatch", [False, True])
def test_cancelled_request_does_not_break_worker(self, monkeypatch, worker_raises, cancel_during_dispatch):
"""An abandoned result must not take down the reader for later jobs."""
async def run():
loop = asyncio.get_running_loop()
started = asyncio.Event()
dispatch_started = asyncio.Event()
finish_generation = threading.Event()
finish_dispatch = threading.Event()
class _BlockingGenerator:
def generate(self, request):
if request.prompt == "cancel-me":
loop.call_soon_threadsafe(started.set)
if not finish_generation.wait(timeout=5.0):
raise RuntimeError("test failed to unblock generation")
if worker_raises:
raise RuntimeError("abandoned worker error")
return {"prompt_echo": request.prompt}
pool = SubprocessGpuPool(
generator_config=GeneratorConfig(model_path="/models/fake"),
pool_config=GpuPoolConfig(num_workers=1),
warmup_config=WarmupConfig(enabled=False),
worker_factory=_thread_worker_factory(lambda gpu_id: _BlockingGenerator()),
)
await pool.start()
request_task = None
try:
await pool.acquire("sess-a")
if cancel_during_dispatch:
job_queue = pool._workers[0].job_queue
original_put = job_queue.put
def blocked_put(item):
if item is not None and item["request"].prompt == "cancel-me":
loop.call_soon_threadsafe(dispatch_started.set)
if not finish_dispatch.wait(timeout=5.0):
raise RuntimeError("test failed to unblock dispatch")
original_put(item)
monkeypatch.setattr(job_queue, "put", blocked_put)
request_task = asyncio.create_task(pool.run("sess-a", GenerationRequest(prompt="cancel-me")))
await asyncio.wait_for(dispatch_started.wait() if cancel_during_dispatch else started.wait(), 2.0)
request_task.cancel()
with pytest.raises(asyncio.CancelledError):
await request_task
# Cancelling the caller cannot stop an executor thread or a
# worker already generating, but must detach its pending job.
assert not pool._pending
finish_dispatch.set()
await asyncio.wait_for(started.wait(), 2.0)
finish_generation.set()
# Reuse the same worker for another session. FIFO delivery
# ensures its reader sees the abandoned result first.
await pool.release("sess-a")
await pool.acquire("sess-b", timeout=2.0)
result = await asyncio.wait_for(pool.run("sess-b", GenerationRequest(prompt="next")), 2.0)
assert result["prompt_echo"] == "next"
assert not pool._pending
assert all(not task.done() for task in pool._result_reader_tasks)
finally:
finish_dispatch.set()
finish_generation.set()
if request_task is not None:
request_task.cancel()
await asyncio.gather(request_task, return_exceptions=True)
await pool.shutdown()
asyncio.run(run())
@pytest.mark.parametrize("worker_raises", [False, True])
def test_reader_ignores_cancelled_future_before_request_cleanup(self, pool_factory, worker_raises):
"""A cancelled future still in ``_pending`` must not kill the reader."""
async def run():
pool = await pool_factory(num_workers=1)
try:
handle = pool._workers[0]
future = Future()
future.cancel()
pool._pending["cancelled"] = _PendingJob("cancelled", future, "sess-a", handle.worker_id)
handle.result_queue.put({
"job_id": "cancelled",
"kind": "error" if worker_raises else "result",
"error": "abandoned error",
"result": {},
})
await pool.acquire("sess-b")
result = await asyncio.wait_for(pool.run("sess-b", GenerationRequest(prompt="next")), 2.0)
assert result["prompt_echo"] == "next"
assert not pool._pending
assert all(not task.done() for task in pool._result_reader_tasks)
finally:
await pool.shutdown()
asyncio.run(run())
def test_failed_boot_excluded_from_available(self):
"""A worker whose factory leaves ``boot_ok`` unset must not be
handed out by ``acquire``. Otherwise a session lands on a dead
worker and ``run`` blocks forever on the missing result."""
def factory_with_one_failure(*, gpu_id, generator_config, warmup_config):
ctx = mp.get_context("spawn")
job_queue: mp.Queue = ctx.Queue()
result_queue: mp.Queue = ctx.Queue()
ready = threading.Event()
boot_ok = threading.Event()
mp_shutdown = ctx.Event()
ready.set()
# gpu_id 0 boots fine; gpu_id 1 fails (boot_ok stays clear).
if gpu_id == 0:
boot_ok.set()
class _AliveProcess:
def is_alive(self) -> bool:
return True
def join(self, timeout: float | None = None) -> None:
return
def kill(self) -> None:
return
return _WorkerHandle(
process=_AliveProcess(), # type: ignore[arg-type]
job_queue=job_queue,
result_queue=result_queue,
gpu_id=gpu_id,
worker_id=f"gpu{gpu_id}",
ready=ready,
boot_ok=boot_ok,
shutdown_event=mp_shutdown,
)
async def run():
pool = SubprocessGpuPool(
generator_config=GeneratorConfig(model_path="/m"),
pool_config=GpuPoolConfig(num_workers=2),
warmup_config=WarmupConfig(enabled=False),
worker_factory=factory_with_one_failure,
)
await pool.start()
try:
# Only worker 0 booted; only one slot should be available.
assert pool.health().available_workers == 1
a = await pool.acquire("sess-a", timeout=0.1)
assert a.worker_id == "gpu0"
with pytest.raises(PoolAcquireTimeout):
await pool.acquire("sess-b", timeout=0.1)
finally:
await pool.shutdown()
asyncio.run(run())
def test_release_skips_dead_worker(self, pool_factory):
"""If a worker died while bound, releasing the session must not
return its slot to the available queue — a later acquire would
hand the dead slot to a new session."""
async def run():
pool = await pool_factory(num_workers=1)
try:
await pool.acquire("sess-a")
# Simulate the worker dying mid-session.
pool._workers[0].process._stop.set()
pool._workers[0].process._worker.join(timeout=1.0)
await pool.release("sess-a")
# Available queue must remain empty.
assert pool.health().available_workers == 0
finally:
await pool.shutdown()
asyncio.run(run())