The v2 top level had grown to ~27 dirs + 13 loose files — half of them
single-concept abstraction shells (memory/ 86 LOC, transport/ 222, parity/ 178,
extend/ 226) and half vendored fastvideo code sitting as peers to the actual v2
design. Regroup to mirror README section 3 "Planes & dependency order":
core/ enums+types, card, loop, program, parity, request, parallel
(the model-native contracts; no kernels)
runtime/ + folded-in substrate: cache, memory, transport, extend
(the import graph shows only runtime consumes them; compile/cudagraph
already lived here)
serving/ + deploy/ (products / fleet)
_vendor/ all copied fastvideo: models, layers, attention, configs, distributed,
platforms, api, hooks, logging_utils, third_party + fastvideo_args/
utils/logger/envs/forward_context — internal layout unchanged, still
mirrors upstream for diffing
28 dirs + 13 root files -> 8 dirs + 6 root files. Pure mechanical move: 947
absolute-import paths rewritten (v2.X -> v2.{core,runtime,serving,_vendor}.X),
boundary-anchored so platform/ (native dispatch) and platforms/ (vendored CUDA
detect) no longer collide and neither does hooks/ vs extend/. Deleted the empty
v2/loader/. README section 3 + 16 updated; stale test count corrected
(34 files/216 tests -> 22 files/143 tests).
Validated: `import v2` stays torch-free; `v2/run_tests.py` and `pytest v2/tests/`
-> 143 passed, 2 skipped on the numpy toy backend. (On a GPU box force the toy
backend with CUDA_VISIBLE_DEVICES="" or detect() picks cuda.) No external importer
changed — examples use the re-exported `from v2 import VideoGenerator`.
230 lines
10 KiB
Python
230 lines
10 KiB
Python
"""AsyncEngine — the serving runtime.
|
|
|
|
A request queue, lifecycle state machine, and live streaming over a concurrency pool: each request
|
|
waits for a free pool slot, then runs its program to completion in its own asyncio task (cooperatively
|
|
yielding so streaming events flow and a stalled request backs off instead of busy-spinning). Pooled
|
|
run-to-completion — no step-interleaved scheduler, no batching. Drives BOTH inline ``ProgramRunner``
|
|
and ``DisaggregatedRunner`` (capacity-aware pool dispatch) through one driver.
|
|
|
|
Cancellation is common-path: ``cancel(request_id)`` trips the runner's CancelScope, which raises at
|
|
the next step boundary and delivers partial artifacts + a structured ``cancelled``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
from typing import Any
|
|
from collections.abc import AsyncIterator
|
|
|
|
from v2.core.request.artifacts import Output
|
|
from v2.core.request.cancel import Cancelled
|
|
from v2.core.request.streams import OmniEvent
|
|
from v2.runtime.disaggregated import DisaggregatedRunner
|
|
from v2.runtime.engine import Engine
|
|
|
|
_SENTINEL = object()
|
|
|
|
|
|
def _safe_put(q: asyncio.Queue, item: Any) -> None:
|
|
"""Non-blocking put on an unbounded queue — safe to call from finally/cancellation paths."""
|
|
with contextlib.suppress(Exception):
|
|
q.put_nowait(item)
|
|
|
|
|
|
class RequestState:
|
|
WAITING = "waiting"
|
|
RUNNING = "running"
|
|
COMPLETED = "completed"
|
|
CANCELLED = "cancelled"
|
|
FAILED = "failed"
|
|
|
|
|
|
class AsyncEngine:
|
|
|
|
def __init__(self,
|
|
engine: Engine | None = None,
|
|
*,
|
|
max_concurrent: int = 8,
|
|
max_stall_ticks: int = 200_000,
|
|
max_history: int = 512):
|
|
self.engine = engine if engine is not None else Engine()
|
|
self._disagg: dict[str, tuple] = {} # model_id -> (PoolSet, Program)
|
|
self._events: dict[str, asyncio.Queue] = {}
|
|
self._states: dict[str, str] = {}
|
|
self._results: dict[str, Output] = {}
|
|
self._runners: dict[str, Any] = {}
|
|
self._order: list[str] = [] # submission order, for bounded eviction
|
|
# the serving pool: bounds how many requests run concurrently (each runs its program to
|
|
# completion in a slot — no step-interleaving / batching). A request waits for a free slot.
|
|
self.max_concurrent = max_concurrent
|
|
self._pool = asyncio.Semaphore(max_concurrent)
|
|
self.max_stall_ticks = max_stall_ticks
|
|
self.max_history = max_history
|
|
|
|
def _evict(self) -> None:
|
|
"""Bound retained per-request state to the most recent ``max_history`` terminal requests."""
|
|
terminal = (RequestState.COMPLETED, RequestState.CANCELLED, RequestState.FAILED)
|
|
i = 0
|
|
while len(self._order) > self.max_history and i < len(self._order):
|
|
rid = self._order[i]
|
|
if self._states.get(rid) in terminal:
|
|
self._order.pop(i)
|
|
self._events.pop(rid, None)
|
|
self._states.pop(rid, None)
|
|
self._results.pop(rid, None)
|
|
self._runners.pop(rid, None)
|
|
else:
|
|
i += 1
|
|
|
|
# --- registration ------------------------------------------------------- #
|
|
def register(self, model_id: str, instance: Any, program: Any) -> None:
|
|
self.engine.register(model_id, instance, program)
|
|
|
|
def register_disaggregated(self, model_id: str, pools: Any, program: Any) -> None:
|
|
self._disagg[model_id] = (pools, program)
|
|
|
|
def register_workflow(self, workflow: Any) -> Any:
|
|
return self.engine.register_workflow(workflow)
|
|
|
|
def is_disaggregated(self, model_id: str) -> bool:
|
|
return model_id in self._disagg
|
|
|
|
def _make_runner(self, request: Any) -> Any:
|
|
if request.model_id in self._disagg:
|
|
pools, program = self._disagg[request.model_id]
|
|
return DisaggregatedRunner(pools,
|
|
program,
|
|
request,
|
|
observers=self.engine.observers,
|
|
interceptors=self.engine.interceptors)
|
|
return self.engine._make_runner(request)
|
|
|
|
# --- lifecycle / control ------------------------------------------------ #
|
|
def state(self, request_id: str) -> str | None:
|
|
return self._states.get(request_id)
|
|
|
|
def cancel(self, request_id: str) -> bool:
|
|
runner = self._runners.get(request_id)
|
|
if runner is not None and getattr(runner, "cancel_scope", None) is not None:
|
|
runner.cancel_scope.cancel()
|
|
return True
|
|
return False
|
|
|
|
def result(self, request_id: str) -> Output | None:
|
|
return self._results.get(request_id)
|
|
|
|
@property
|
|
def in_flight(self) -> int:
|
|
return sum(1 for s in self._states.values() if s == RequestState.RUNNING)
|
|
|
|
@property
|
|
def queue_depth(self) -> int:
|
|
return sum(1 for s in self._states.values() if s == RequestState.WAITING)
|
|
|
|
def serves(self, model_id: str) -> bool:
|
|
return (model_id in self.engine._registry or model_id in self._disagg or model_id in self.engine._workflows)
|
|
|
|
# --- the driver: one asyncio task per request --------------------------- #
|
|
async def _run(self, request: Any) -> None:
|
|
rid = request.request_id
|
|
evq = self._events[rid]
|
|
runner = None
|
|
stream_cursor = 0
|
|
stall = 0
|
|
slot = False
|
|
try:
|
|
await self._pool.acquire() # wait for a free serving slot (pooled run-to-completion)
|
|
slot = True
|
|
runner = self._make_runner(request)
|
|
self._runners[rid] = runner
|
|
self._states[rid] = RequestState.RUNNING
|
|
await evq.put(OmniEvent("request.start", rid, payload={"task": request.task.value}))
|
|
while not runner.done:
|
|
runner.cancel_scope.check() # common-path cancellation (step boundary)
|
|
p0 = runner._progress
|
|
runner.tick()
|
|
# surface any newly emitted media chunks as live events
|
|
events = runner.stream.events
|
|
while stream_cursor < len(events):
|
|
await evq.put(events[stream_cursor])
|
|
stream_cursor += 1
|
|
if not runner.done and runner._progress == p0:
|
|
stall += 1
|
|
if stall > self.max_stall_ticks:
|
|
raise RuntimeError(f"request {rid} stalled (state={getattr(runner,'state','?')})")
|
|
await asyncio.sleep(0.0005) # backpressure backoff (capacity wait)
|
|
else:
|
|
stall = 0
|
|
await asyncio.sleep(0) # cooperative yield so streaming + other slots progress
|
|
out = runner.output()
|
|
self._results[rid] = out
|
|
for name in out.artifacts:
|
|
await evq.put(OmniEvent("artifact.ready", rid, payload={"name": name}))
|
|
self._states[rid] = RequestState.COMPLETED
|
|
await evq.put(OmniEvent("request.complete", rid, payload={"metrics": dict(out.metrics)}))
|
|
except Cancelled:
|
|
self._states[rid] = RequestState.CANCELLED
|
|
if runner is not None and hasattr(runner, "output"):
|
|
try:
|
|
partial = runner.output()
|
|
partial.error = "cancelled"
|
|
self._results[rid] = partial
|
|
except Exception:
|
|
pass
|
|
_safe_put(evq, OmniEvent("request.cancelled", rid))
|
|
except asyncio.CancelledError: # driver task cancelled (e.g. client gone)
|
|
self._states[rid] = RequestState.CANCELLED
|
|
raise
|
|
except Exception as e: # noqa: BLE001 — request-fatal isolation: one request's error
|
|
self._states[rid] = RequestState.FAILED
|
|
self._results[rid] = Output(request_id=rid, error=str(e))
|
|
_safe_put(evq, OmniEvent("request.failed", rid, payload={"error": str(e)}))
|
|
finally:
|
|
# release any pool this request occupied — a cancel/error mid-loop must NOT leak capacity
|
|
if runner is not None and hasattr(runner, "close"):
|
|
with contextlib.suppress(Exception):
|
|
runner.close()
|
|
if slot:
|
|
self._pool.release() # free the serving slot for a waiting request
|
|
_safe_put(evq, _SENTINEL)
|
|
self._evict()
|
|
|
|
async def submit(self, request: Any) -> AsyncIterator[OmniEvent]:
|
|
"""Submit a request and stream its events live (request.start → media.chunk* → artifact.ready*
|
|
→ request.complete | request.cancelled | request.failed). Cancels the driver if the consumer
|
|
abandons the stream (e.g. client disconnect), so no orphaned compute keeps running."""
|
|
rid = request.request_id
|
|
if rid in self._events and self._states.get(rid) in (RequestState.WAITING, RequestState.RUNNING):
|
|
raise ValueError(f"request_id {rid!r} is already in flight")
|
|
self._events[rid] = asyncio.Queue()
|
|
self._states[rid] = RequestState.WAITING
|
|
self._order.append(rid)
|
|
task = asyncio.create_task(self._run(request))
|
|
evq = self._events[rid]
|
|
try:
|
|
while True:
|
|
ev = await evq.get()
|
|
if ev is _SENTINEL:
|
|
break
|
|
yield ev
|
|
finally:
|
|
if not task.done():
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
await task
|
|
|
|
async def generate(self, request: Any) -> Output:
|
|
"""Non-streaming: drive to completion, return the Output (the offline path over the queue)."""
|
|
async for _ in self.submit(request):
|
|
pass
|
|
return self._results[request.request_id]
|
|
|
|
async def generate_many(self, requests: list[Any]) -> dict[str, Output]:
|
|
"""Run requests concurrently (they interleave at step granularity through the event loop)."""
|
|
await asyncio.gather(*(self._drain(r) for r in requests))
|
|
return {r.request_id: self._results[r.request_id] for r in requests}
|
|
|
|
async def _drain(self, request: Any) -> None:
|
|
async for _ in self.submit(request):
|
|
pass
|