atakan Claude Fable 5 commited on
Commit
4de16e3
·
1 Parent(s): 7cdd3d6

fix: Don't route CUDA/ZeroGPU calls through a manually-created thread

Browse files

The 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>

Files changed (1) hide show
  1. app.py +76 -36
app.py CHANGED
@@ -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." Starlette's default
37
- # StreamingResponse threadpool picks a different worker thread per request,
38
- # which breaks that invariant. Routing every agent call through this single
39
- # dedicated worker thread keeps MLX on one consistent thread for the whole
40
- # process lifetime, and -- as a bonus -- a max_workers=1 executor naturally
41
- # serializes every request through the one model instance (llama.cpp's Llama
42
- # object also isn't safe for concurrent calls from multiple threads).
43
- inference_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="controlai-inference")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
 
46
  @asynccontextmanager
47
  async def lifespan(app: FastAPI):
48
  print("Pre-loading ControlAI Core Engine on startup...")
49
- await asyncio.get_event_loop().run_in_executor(inference_executor, get_agent)
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
- def _produce() -> None:
224
- # Runs entirely on inference_executor's single dedicated thread --
225
- # the same thread the model was loaded on -- so MLX's thread-local
226
- # stream stays valid.
227
- try:
228
- for event in _run_stream_on_gpu(message, history):
229
- event_queue.put(event)
230
- except Exception as exc:
231
- event_queue.put({"type": "error", "error": str(exc)})
232
- finally:
233
- event_queue.put(_DONE)
234
-
235
- async def event_generator():
236
- loop = asyncio.get_event_loop()
237
- loop.run_in_executor(inference_executor, _produce)
238
- while True:
239
- # Draining the queue never touches MLX, so this can safely run on
240
- # the default threadpool.
241
- event = await loop.run_in_executor(None, event_queue.get)
242
- if event is _DONE:
243
- break
244
- yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- loop = asyncio.get_event_loop()
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