Files
vllm-omni/vllm_omni/edge/local/session.py
T
tzhouam 723c550209 Integrate portable edge graph backends with Omni runtime
Extract versioned stage contracts, model shared CPU/iGPU/NPU memory constraints, and add explicit graph routes, reservations, output acknowledgements, and worker retirement. Fix native Windows shared-buffer lifetime and host paths. Document qualified hardware paths and remaining rollout gates.
2026-09-22 13:12:33 +08:00

185 lines
7.3 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The three data boundaries the local engine needs, and nothing else.
The proposal's section 4 is explicit that the first version needs exactly three
contracts: **a request/event stream, a tensor-or-buffer reference, and an
opaque state handle**. This module is the first and the third. The second does
not appear in M0 because a single text stage never hands a buffer to anybody --
it is the thing that arrives in M2, when a talker hands codec frames to a
vocoder, and inventing it now would be inventing it wrong.
Two properties here are correctness, not bookkeeping:
**An epoch fences cancellation.** Cancelling a request is not "stop calling
next()". The backend has work in flight, and tokens from that work arrive after
the cancel. Every event carries the epoch it was produced under, and
:class:`BoundedEventStream` drops events from a retired epoch instead of
delivering them. Without that, an interrupted turn's tail lands in the next
turn's output -- the "重复输出和跨请求污染" the proposal names.
**A queue bounds chunks and bytes, both.** A consumer that stops reading must
slow the producer down, not grow the heap. Bounding only the chunk count is not
enough once a chunk can be a second of PCM rather than one token, so the bound
is on both from the start even though M0's chunks are small.
``StateHandle`` is deliberately opaque. The engine holds a *reference* to the
session's state; vLLM holds the actual KV blocks, the position counter and the
RNG. The handle records enough to know when that state is no longer valid --
a different artifact, a different layout version, a retired epoch -- and
nothing that would let a caller reach inside it.
"""
from __future__ import annotations
import asyncio
import uuid
STATE_LAYOUT_VERSION = 1
"""Bumped when the meaning of a handle's state changes. A handle from a
different version is not migrated, it is rejected: the proposal's item 10 says
to treat state as non-migratable until a specific backend pair has been
measured, and a version that silently matches would hide that."""
from omni_stage_contracts.legacy import ChunkEvent, StateHandle # noqa: E402, F401
class StreamClosed(RuntimeError): # noqa: N818 - retained public API
"""Raised on a stream that has been closed while a consumer waited."""
class BoundedEventStream:
"""An async queue with a credit bound on chunks *and* bytes.
Producers ``await put(...)``, which blocks while the consumer is behind:
that is the backpressure. Consumers ``async for`` over it and the credit is
returned as each event leaves the queue.
"""
def __init__(self, *, max_chunks: int = 64, max_bytes: int = 4 << 20) -> None:
if max_chunks < 1 or max_bytes < 1:
raise ValueError("bounds must be positive")
self.max_chunks = max_chunks
self.max_bytes = max_bytes
self._queue: asyncio.Queue[ChunkEvent | None] = asyncio.Queue()
self._bytes = 0
self._chunks = 0
self._credit = asyncio.Condition()
self._closed = False
self._epoch = 0
self.dropped_stale = 0
"""Events discarded because their epoch had been retired. Reported
rather than silently swallowed: a non-zero count on a run with no
cancellation is a bug worth seeing."""
self.high_water_chunks = 0
self.high_water_bytes = 0
# -- producer side ------------------------------------------------------
@staticmethod
def _sizeof(event: ChunkEvent) -> int:
payload = event.payload
if isinstance(payload, (bytes, bytearray, memoryview)):
return len(payload)
if isinstance(payload, str):
return len(payload.encode("utf-8"))
if isinstance(payload, dict):
return sum(
len(str(k))
+ (
len(v.encode("utf-8"))
if isinstance(v, str)
else 8 * (len(v) if isinstance(v, (list, tuple)) else 1)
)
for k, v in payload.items()
)
return 64
async def put(self, event: ChunkEvent) -> None:
"""Enqueue, waiting for credit. Stale-epoch events are dropped here."""
if self._closed:
raise StreamClosed("stream is closed")
if event.epoch < self._epoch:
self.dropped_stale += 1
return
size = self._sizeof(event)
async with self._credit:
await self._credit.wait_for(
lambda: (
self._closed
or (self._chunks < self.max_chunks and self._bytes + size <= self.max_bytes)
# A single event larger than the whole byte bound must still go
# through once the queue is empty, or it deadlocks forever.
or (self._chunks == 0 and size > self.max_bytes)
)
)
if self._closed:
raise StreamClosed("stream closed while producing")
self._chunks += 1
self._bytes += size
self.high_water_chunks = max(self.high_water_chunks, self._chunks)
self.high_water_bytes = max(self.high_water_bytes, self._bytes)
await self._queue.put(event)
async def close(self) -> None:
"""End the stream. Idempotent; wakes every waiter."""
if self._closed:
return
self._closed = True
async with self._credit:
self._credit.notify_all()
await self._queue.put(None)
def retire_epoch(self, epoch: int) -> int:
"""Retire everything at or below ``epoch``; return what was discarded.
Called by cancel. Queued events from the retired epoch are dropped on
the way out rather than scanned here, so this stays O(1) and cannot
race a producer mid-``put``.
"""
self._epoch = max(self._epoch, epoch + 1)
return self.dropped_stale
@property
def epoch(self) -> int:
return self._epoch
# -- consumer side ------------------------------------------------------
async def get(self) -> ChunkEvent | None:
"""Next live event, or ``None`` at end of stream."""
while True:
event = await self._queue.get()
size = 0 if event is None else self._sizeof(event)
if event is not None:
async with self._credit:
self._chunks -= 1
self._bytes -= size
self._credit.notify_all()
if event.epoch < self._epoch:
self.dropped_stale += 1
continue
return event
def __aiter__(self) -> BoundedEventStream:
return self
async def __anext__(self) -> ChunkEvent:
event = await self.get()
if event is None:
raise StopAsyncIteration
return event
def stats(self) -> dict[str, int]:
return {
"max_chunks": self.max_chunks,
"max_bytes": self.max_bytes,
"high_water_chunks": self.high_water_chunks,
"high_water_bytes": self.high_water_bytes,
"dropped_stale": self.dropped_stale,
"epoch": self._epoch,
}
def new_session_id() -> str:
return uuid.uuid4().hex[:16]