inference-server / app /batching.py
Aditya Kulkarni
chore: add pyright CI and use get_running_loop
14b0898
Raw History Blame Contribute Delete
6.28 kB
import asyncio
import time
from dataclasses import dataclass
from sentence_transformers import SentenceTransformer
from .model import predict
@dataclass(frozen=True)
class EmbedBatchResult:
"""Embeddings plus per-request batcher timings.
ttft_ms: enqueue -> encode start (queue wait + batch collection).
ttfr_ms: enqueue -> embeddings ready (ttft + encode).
"""
embeddings: list[list[float]]
ttft_ms: float
ttfr_ms: float
@dataclass
class _Queued:
texts: list[str]
future: asyncio.Future[EmbedBatchResult]
enqueued_at: float
class QueueFullError(Exception):
"""Raised when the request queue is at capacity (maps to HTTP 503)."""
class RequestTimeoutError(Exception):
"""Raised when a request is not served within request_timeout_s (maps to HTTP 504)."""
class DynamicBatcher:
"""Collects concurrent requests into batches for efficient inference.
Flow:
1. Endpoint calls submit(texts) → creates a Future, puts (texts, future) on queue
2. Background _worker loop collects items from queue
3. When max_batch_size reached OR max_wait_ms elapsed → flush:
- Flatten all texts into one list
- Run predict(model, all_texts) via run_in_executor (non-blocking)
- Split results back by request, set each future's result
4. submit() awaits its future and returns the result to the endpoint
"""
def __init__(
self,
model: SentenceTransformer,
max_batch_size: int,
max_wait_ms: int,
max_queue_size: int = 0,
request_timeout_s: float | None = None,
):
"""Initialize the batcher.
max_queue_size=0 leaves the queue unbounded; request_timeout_s=None disables the
per-request deadline.
"""
self._model = model
self._max_batch_size = max_batch_size
self._max_wait_ms = max_wait_ms
self._request_timeout_s = request_timeout_s
self._queue: asyncio.Queue[_Queued] = asyncio.Queue(maxsize=max_queue_size)
self._worker_task: asyncio.Task[None] | None = None
self._inflight = 0
def start(self) -> None:
"""Launch the background worker as an asyncio task."""
self._worker_task = asyncio.create_task(self._worker())
async def stop(self) -> None:
"""Stop the background worker and drain remaining requests."""
if self._worker_task is None:
return
try:
self._worker_task.cancel()
await self._worker_task
except asyncio.CancelledError:
pass
try:
while not self._queue.empty():
item = self._queue.get_nowait()
if not item.future.done():
item.future.set_exception(asyncio.CancelledError)
except asyncio.QueueEmpty:
pass
def is_running(self) -> bool:
"""True when the background worker task is alive (used by the readiness check)."""
return self._worker_task is not None and not self._worker_task.done()
def queue_depth(self) -> int:
"""Number of requests currently waiting in the queue."""
return self._queue.qsize()
def inflight(self) -> int:
"""Number of accepted requests still awaiting their result."""
return self._inflight
async def submit(self, texts: list[str]) -> EmbedBatchResult:
"""Submit a request for batched inference. Called by the /embed endpoint.
Rejects immediately with QueueFullError when the queue is at capacity (backpressure),
and raises RequestTimeoutError if the result isn't ready within request_timeout_s.
"""
loop = asyncio.get_running_loop()
future = loop.create_future()
try:
self._queue.put_nowait(_Queued(texts, future, time.perf_counter()))
except asyncio.QueueFull:
raise QueueFullError("request queue is full")
self._inflight += 1
try:
return await asyncio.wait_for(future, timeout=self._request_timeout_s)
except asyncio.TimeoutError:
raise RequestTimeoutError("request timed out before inference completed")
finally:
self._inflight -= 1
async def _worker(self) -> None:
"""Background loop that collects and processes batches."""
while True:
batch = []
first_item = await self._queue.get()
batch.append(first_item)
while len(batch) < self._max_batch_size:
try:
item = await asyncio.wait_for(self._queue.get(), timeout=self._max_wait_ms / 1000)
batch.append(item)
except asyncio.TimeoutError:
break
# flatten texts
all_texts, sizes = [], [0]
for item in batch:
all_texts.extend(item.texts)
sizes.append(sizes[-1] + len(item.texts))
# run inference: encode the whole aggregated batch in one pass (batch_size =
# max_batch_size), instead of model.encode's hidden default of 32.
loop = asyncio.get_running_loop()
encode_started = time.perf_counter()
try:
all_embeddings = await loop.run_in_executor(
None, predict, self._model, all_texts, self._max_batch_size
)
encode_finished = time.perf_counter()
# split results back and resolve futures (skip ones already timed out/cancelled)
for idx, item in enumerate(batch):
if not item.future.done():
item.future.set_result(
EmbedBatchResult(
embeddings=all_embeddings[sizes[idx] : sizes[idx + 1]],
ttft_ms=round((encode_started - item.enqueued_at) * 1000, 2),
ttfr_ms=round((encode_finished - item.enqueued_at) * 1000, 2),
)
)
except Exception as e:
# to handle raise in predict()
for item in batch:
if not item.future.done():
item.future.set_exception(e)