Spaces:
Running
Running
File size: 6,869 Bytes
7b90b65 954e0aa 7b90b65 ac347e7 7b90b65 ac347e7 7b90b65 ac347e7 7b90b65 6ee4b4a 7b90b65 6ee4b4a 7b90b65 00c3bab 7b90b65 ac347e7 7b90b65 6ee4b4a 7b90b65 6ee4b4a 7b90b65 f412b66 | 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 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | """
Embedding Batch Workers β Async batching infrastructure for embedding inference.
Architecture:
- Single asyncio.Queue for all embedding jobs.
- Dedicated async worker coroutines drain the queue in micro-batches.
- Workers offload heavy inference to a thread via run_in_executor.
- A shared in-memory job_store dict tracks job status + results.
- Warmup loop periodically does a dummy forward pass to keep OpenMP threads alive.
"""
import asyncio
import time
import uuid
from loguru import logger
from typing import Any
from .constants import BATCH_MAX_SIZE, BATCH_WINDOW_S, WARMUP_INTERVAL_S
from .schemas import EmbeddingJob
# βββββββββββββββββββββββ Job Store ββββββββββββββββββββββββ
job_store: dict[str, dict[str, Any]] = {}
"""
{
"<job_id>": {
"status": "pending" | "processing" | "done" | "error",
"result": <list[list[float]] for EmbeddingJob> | None,
"error": <str> | None,
}
}
"""
def create_job() -> str:
"""Create a new pending job and return its ID."""
job_id = str(uuid.uuid4())
job_store[job_id] = {"status": "pending", "result": None, "error": None}
return job_id
# βββββββββββββββββββββββ Request-in-Flight Gate ββββββββββββββββββββββββ
_request_in_flight_count = 0
def set_request_in_flight(active: bool):
"""Increment/decrement in-flight counter. Thread-safe enough for a gate."""
global _request_in_flight_count
if active:
_request_in_flight_count += 1
else:
_request_in_flight_count = max(0, _request_in_flight_count - 1)
def is_request_in_flight() -> bool:
return _request_in_flight_count > 0
# βββββββββββββββββββββββ Queue ββββββββββββββββββββββββ
embedding_queue: asyncio.Queue[EmbeddingJob] = asyncio.Queue()
# βββββββββββββββββββββββ Workers ββββββββββββββββββββββββ
async def embedding_worker():
"""
Drains up to {BATCH_MAX_SIZE} embedding jobs every {BATCH_WINDOW_S * 1000:.0f}ms.
One SentenceTransformer forward pass per batch:
1. Collect texts from all jobs in the batch
2. get_embedder().embed_documents(all_texts) β raw [B, D] embeddings
3. Distribute results back to individual jobs
Results are written into job_store and each job's done Event is set.
"""
from src.rag.rag import get_embedder
loop = asyncio.get_event_loop()
while True:
# Wait for at least one job
first_job: EmbeddingJob = await embedding_queue.get()
batch: list[EmbeddingJob] = [first_job]
# Collect up to 7 more within the time window
deadline = loop.time() + BATCH_WINDOW_S
while len(batch) < BATCH_MAX_SIZE:
remaining = deadline - loop.time()
if remaining <= 0:
break
try:
job = await asyncio.wait_for(embedding_queue.get(), timeout=remaining)
batch.append(job)
except asyncio.TimeoutError:
break
try:
set_request_in_flight(True)
# Gather all texts from all jobs in the batch
all_texts: list[str] = []
text_counts: list[int] = []
for job in batch:
all_texts.extend(job.texts)
text_counts.append(len(job.texts))
# Single forward pass for the entire batch
embedder = get_embedder()
all_embeddings = await loop.run_in_executor(
None, embedder.embed_documents, all_texts
)
# Distribute results back to individual jobs
idx = 0
for i, job in enumerate(batch):
n = text_counts[i]
job_result = all_embeddings[idx: idx + n]
idx += n
# Use .get() or setdefault to avoid KeyError if initialization was missed
if job.job_id not in job_store:
job_store[job.job_id] = {"status": "pending", "result": None, "error": None}
job_store[job.job_id].update({
"status": "done",
"result": job_result
})
job.done.set()
except Exception as e:
logger.error(f"Embedding batch failed: {e}", exc_info=True)
for job in batch:
if job.job_id not in job_store:
job_store[job.job_id] = {"status": "error", "result": None, "error": str(e)}
else:
job_store[job.job_id].update({
"status": "error",
"error": str(e)
})
# Critical: always set the event so the request doesn't hang
if not job.done.is_set():
job.done.set()
finally:
set_request_in_flight(False)
# βββββββββββββββββββββββ Warmup Loop ββββββββββββββββββββββββ
async def _warmup_loop():
"""
Periodically does a dummy forward pass to prevent OpenMP/MKL thread pool
spin-down during idle periods.
Skipped entirely if a real request is in flight.
"""
from src.rag.rag import warmup_embedder
loop = asyncio.get_event_loop()
while True:
await asyncio.sleep(WARMUP_INTERVAL_S)
if is_request_in_flight():
continue
t0 = time.monotonic()
try:
await loop.run_in_executor(None, warmup_embedder)
except Exception as e:
logger.warning(f"Warmup cycle error (non-fatal): {e}")
continue
elapsed_ms = (time.monotonic() - t0) * 1000
logger.info(f"Warmup cycle done ({elapsed_ms:.0f}ms)")
# βββββββββββββββββββββββ Startup ββββββββββββββββββββββββ
_workers_started = False
def start_workers():
"""
Launch all async worker coroutines. Call once during app startup.
- 1 embedding worker (batched SentenceTransformer inference)
- 1 warmup loop (keeps OpenMP threads alive)
"""
global _workers_started
if _workers_started:
return
_workers_started = True
# Use only 1 worker to save RAM on this environment
asyncio.create_task(embedding_worker(), name="embedding_worker_0")
asyncio.create_task(_warmup_loop(), name="warmup_loop")
logger.info("Batch workers started (embedding worker + warmup loop)")
|