Co-authored-by: Junda (David) Su <90978028+Davids048@users.noreply.github.com> Co-authored-by: Matthew Noto <99706358+RandNMR73@users.noreply.github.com> Co-authored-by: XOR-op <17672363+XOR-op@users.noreply.github.com> Co-authored-by: Zhang Peiyuan <42993249+jzhang38@users.noreply.github.com>
464 lines
15 KiB
Python
464 lines
15 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 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,
|
|
_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."""
|
|
|
|
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())
|