Spaces:
Sleeping
Sleeping
File size: 6,284 Bytes
046db3f 5e96f99 046db3f 5e96f99 14b0898 5e96f99 3c0d20e 046db3f 3c0d20e 046db3f 3c0d20e 14b0898 3e47cef 046db3f 14b0898 046db3f 5e96f99 046db3f 3e47cef 5e96f99 3c0d20e 14b0898 046db3f 3c0d20e 5e96f99 3c0d20e 3e47cef 3c0d20e 3e47cef 046db3f 57e38d0 046db3f 5e96f99 046db3f dfdc75f 14b0898 5e96f99 046db3f dfdc75f 5e96f99 046db3f 3c0d20e 5e96f99 046db3f 3c0d20e 5e96f99 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 | 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)
|