Spaces:
Running on Zero
fix: Don't route CUDA/ZeroGPU calls through a manually-created thread
Browse filesThe MLX thread-affinity fix from earlier today applied unconditionally to
every backend, including the CUDA/ZeroGPU path used on HF Spaces. That
broke the deployed Space on startup:
RuntimeError: Low-level CUDA init (torch._C._cuda_init) reached. This
means ZeroGPU's PyTorch CUDA emulation mode did not intercept a CUDA
operation in your code.
at orchestrator.py:526 self.model = self.model.to("cuda")
called from app.py lifespan() via inference_executor
.to("cuda") during lifespan-triggered model loading was already the
deliberately-chosen, working pattern from an earlier fix (see "Explicitly
.to('cuda') instead of device_map='auto' on ZeroGPU"). It worked because
it ran on the main thread, which is the context ZeroGPU's CUDA
interception actually manages. Moving it into a manually-created
ThreadPoolExecutor thread moved it out of that context entirely, letting
a real low-level CUDA init leak through where ZeroGPU forbids it outside
an @spaces.GPU call.
Fix: gate the dedicated executor on HAS_MLX, which is only ever True on
a machine that actually has mlx_lm installed (Apple Silicon) -- never on
a HF Spaces Linux/CUDA container. Non-MLX inference (CUDA/GGUF) now runs
exactly as it did before the MLX fix: direct calls guarded by a plain
lock for the non-streaming endpoint, and a plain sync generator handed
straight to StreamingResponse (Starlette's own threadpool, not a custom
one) for streaming -- restoring the exact configuration that was
deployed and working. MLX keeps its dedicated single-thread fix,
verified end-to-end locally after the refactor (streaming + regression
suite both pass).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@@ -8,6 +8,7 @@ import os
|
|
| 8 |
import queue
|
| 9 |
import shutil
|
| 10 |
import sys
|
|
|
|
| 11 |
import time
|
| 12 |
from concurrent.futures import ThreadPoolExecutor
|
| 13 |
from contextlib import asynccontextmanager
|
|
@@ -26,27 +27,50 @@ PROJECT_ROOT = Path(__file__).resolve().parent
|
|
| 26 |
if str(PROJECT_ROOT) not in sys.path:
|
| 27 |
sys.path.insert(0, str(PROJECT_ROOT))
|
| 28 |
|
| 29 |
-
from controlai_agent.orchestrator import ControlAIAgent
|
| 30 |
from controlai_rag.chunker import chunk_document
|
| 31 |
from controlai_rag.document_loader import load_single_file
|
| 32 |
from controlai_rag.index import get_shared_index
|
| 33 |
|
| 34 |
# MLX keeps its compute stream in thread-local state: the model must be
|
| 35 |
# loaded on the exact same OS thread that later runs generation, or MLX
|
| 36 |
-
# raises "There is no Stream(cpu, 0) in current thread."
|
| 37 |
-
#
|
| 38 |
-
#
|
| 39 |
-
#
|
| 40 |
-
#
|
| 41 |
-
#
|
| 42 |
-
#
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
@asynccontextmanager
|
| 47 |
async def lifespan(app: FastAPI):
|
| 48 |
print("Pre-loading ControlAI Core Engine on startup...")
|
| 49 |
-
await
|
| 50 |
print("ControlAI Core Engine is online and ready for traffic.")
|
| 51 |
yield
|
| 52 |
|
|
@@ -217,31 +241,48 @@ async def chat_stream_endpoint(req: ChatRequest):
|
|
| 217 |
|
| 218 |
message = req.message.strip()
|
| 219 |
history = req.history
|
| 220 |
-
event_queue: queue.Queue = queue.Queue()
|
| 221 |
-
_DONE = object()
|
| 222 |
|
| 223 |
-
|
| 224 |
-
#
|
| 225 |
-
#
|
| 226 |
-
#
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
return StreamingResponse(
|
| 247 |
event_generator(),
|
|
@@ -261,8 +302,7 @@ async def chat_endpoint(req: ChatRequest) -> ChatResponse:
|
|
| 261 |
|
| 262 |
t0 = time.time()
|
| 263 |
try:
|
| 264 |
-
|
| 265 |
-
result = await loop.run_in_executor(inference_executor, _run_on_gpu, req.message.strip(), req.history)
|
| 266 |
elapsed = time.time() - t0
|
| 267 |
|
| 268 |
# Collect tool traces
|
|
|
|
| 8 |
import queue
|
| 9 |
import shutil
|
| 10 |
import sys
|
| 11 |
+
import threading
|
| 12 |
import time
|
| 13 |
from concurrent.futures import ThreadPoolExecutor
|
| 14 |
from contextlib import asynccontextmanager
|
|
|
|
| 27 |
if str(PROJECT_ROOT) not in sys.path:
|
| 28 |
sys.path.insert(0, str(PROJECT_ROOT))
|
| 29 |
|
| 30 |
+
from controlai_agent.orchestrator import HAS_MLX, ControlAIAgent
|
| 31 |
from controlai_rag.chunker import chunk_document
|
| 32 |
from controlai_rag.document_loader import load_single_file
|
| 33 |
from controlai_rag.index import get_shared_index
|
| 34 |
|
| 35 |
# MLX keeps its compute stream in thread-local state: the model must be
|
| 36 |
# loaded on the exact same OS thread that later runs generation, or MLX
|
| 37 |
+
# raises "There is no Stream(cpu, 0) in current thread." Routing every agent
|
| 38 |
+
# call through this single dedicated worker thread keeps MLX on one
|
| 39 |
+
# consistent thread for the whole process lifetime.
|
| 40 |
+
#
|
| 41 |
+
# This must NEVER be used for the CUDA/ZeroGPU path: ZeroGPU's `spaces`
|
| 42 |
+
# library only intercepts CUDA calls within the exact context it manages
|
| 43 |
+
# (the main thread during startup; the request-handling context for
|
| 44 |
+
# @spaces.GPU calls). Moving a CUDA-touching call into this manually-created
|
| 45 |
+
# thread let a real `torch._C._cuda_init()` leak through outside any
|
| 46 |
+
# @spaces.GPU context and crashed the Space on startup:
|
| 47 |
+
# "Low-level CUDA init reached. ZeroGPU's PyTorch CUDA emulation mode
|
| 48 |
+
# did not intercept a CUDA operation in your code."
|
| 49 |
+
# HAS_MLX is only ever True on the machine that actually has mlx_lm
|
| 50 |
+
# installed (Apple Silicon) -- never on a HF Spaces Linux/CUDA container --
|
| 51 |
+
# so gating on it keeps MLX's fix local while restoring the CUDA/GGUF path
|
| 52 |
+
# to calling directly on whatever thread FastAPI/spaces already controls,
|
| 53 |
+
# exactly as it worked before.
|
| 54 |
+
inference_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="controlai-inference") if HAS_MLX else None
|
| 55 |
+
# llama.cpp's Llama object is not safe for concurrent generation calls from
|
| 56 |
+
# multiple threads; on the non-MLX path (no dedicated executor serializing
|
| 57 |
+
# things for us) a plain lock does that job instead.
|
| 58 |
+
inference_lock = threading.Lock()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
async def _run_inference(fn, *args):
|
| 62 |
+
"""Run an inference call on the MLX-safe dedicated thread if MLX is in
|
| 63 |
+
play, otherwise directly (correct for CUDA/ZeroGPU and GGUF)."""
|
| 64 |
+
if inference_executor is not None:
|
| 65 |
+
return await asyncio.get_event_loop().run_in_executor(inference_executor, fn, *args)
|
| 66 |
+
with inference_lock:
|
| 67 |
+
return fn(*args)
|
| 68 |
|
| 69 |
|
| 70 |
@asynccontextmanager
|
| 71 |
async def lifespan(app: FastAPI):
|
| 72 |
print("Pre-loading ControlAI Core Engine on startup...")
|
| 73 |
+
await _run_inference(get_agent)
|
| 74 |
print("ControlAI Core Engine is online and ready for traffic.")
|
| 75 |
yield
|
| 76 |
|
|
|
|
| 241 |
|
| 242 |
message = req.message.strip()
|
| 243 |
history = req.history
|
|
|
|
|
|
|
| 244 |
|
| 245 |
+
if inference_executor is not None:
|
| 246 |
+
# MLX path: a persistent dedicated thread is required (see the
|
| 247 |
+
# inference_executor comment above), so bridge it into the async
|
| 248 |
+
# response via a queue -- Starlette's own rotating threadpool would
|
| 249 |
+
# violate MLX's single-thread requirement.
|
| 250 |
+
event_queue: queue.Queue = queue.Queue()
|
| 251 |
+
_DONE = object()
|
| 252 |
+
|
| 253 |
+
def _produce() -> None:
|
| 254 |
+
try:
|
| 255 |
+
with inference_lock:
|
| 256 |
+
for event in _run_stream_on_gpu(message, history):
|
| 257 |
+
event_queue.put(event)
|
| 258 |
+
except Exception as exc:
|
| 259 |
+
event_queue.put({"type": "error", "error": str(exc)})
|
| 260 |
+
finally:
|
| 261 |
+
event_queue.put(_DONE)
|
| 262 |
+
|
| 263 |
+
async def event_generator():
|
| 264 |
+
loop = asyncio.get_event_loop()
|
| 265 |
+
loop.run_in_executor(inference_executor, _produce)
|
| 266 |
+
while True:
|
| 267 |
+
# Draining the queue never touches MLX, so this can safely
|
| 268 |
+
# run on the default threadpool.
|
| 269 |
+
event = await loop.run_in_executor(None, event_queue.get)
|
| 270 |
+
if event is _DONE:
|
| 271 |
+
break
|
| 272 |
+
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
| 273 |
+
else:
|
| 274 |
+
# CUDA/ZeroGPU/GGUF path: a plain sync generator handed directly to
|
| 275 |
+
# StreamingResponse, exactly as this ran before the MLX fix existed.
|
| 276 |
+
# Starlette wraps this in its own threadpool (iterate_in_threadpool),
|
| 277 |
+
# which -- unlike a manually created ThreadPoolExecutor -- is a
|
| 278 |
+
# context ZeroGPU's CUDA interception correctly recognizes.
|
| 279 |
+
def event_generator():
|
| 280 |
+
try:
|
| 281 |
+
with inference_lock:
|
| 282 |
+
for event in _run_stream_on_gpu(message, history):
|
| 283 |
+
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
| 284 |
+
except Exception as exc:
|
| 285 |
+
yield f"data: {json.dumps({'type': 'error', 'error': str(exc)}, ensure_ascii=False)}\n\n"
|
| 286 |
|
| 287 |
return StreamingResponse(
|
| 288 |
event_generator(),
|
|
|
|
| 302 |
|
| 303 |
t0 = time.time()
|
| 304 |
try:
|
| 305 |
+
result = await _run_inference(_run_on_gpu, req.message.strip(), req.history)
|
|
|
|
| 306 |
elapsed = time.time() - t0
|
| 307 |
|
| 308 |
# Collect tool traces
|