fix(mlx): run all MLX operations on a single dedicated thread — fixes "There is no Stream(gpu, N) in current thread"#896
Conversation
mlx_lm creates a thread-local GPU stream at import time (mlx_lm/generate.py:226, mx.new_thread_local_stream, mlx-lm 0.31.x). MLX arrays and ops that capture that stream fail with "There is no Stream(gpu, N) in current thread" when they are later evaluated from a different thread — which is exactly what asyncio.to_thread's rotating worker pool does. Observed on the Qwen3-TTS voice-clone (ICL) path: /generate/stream returned HTTP 500 on every request. Fix: a shared single-worker ThreadPoolExecutor so MLX module imports, model loading, and generation/transcription all run on the same dedicated thread. Zero practical cost: MLX is single-GPU and generations were already serialized. Verified: cloned-voice synthesis with Qwen 0.6B succeeds after the fix (warm RTF ~0.9 on an M3 Max); kokoro and whisper paths unaffected. Fixes jamiepine#675 Fixes jamiepine#699 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughMLX backend model loading, TTS generation, and STT transcription now run through a shared single-worker executor instead of ChangesMLX thread dispatch
Estimated code review effort: 2 (Simple) | ~10 minutes Sequence Diagram(s)sequenceDiagram
participant Backend
participant MLXDispatcher
participant DedicatedThread
Backend->>MLXDispatcher: Dispatch model loading or inference
MLXDispatcher->>DedicatedThread: Execute synchronous MLX helper
DedicatedThread-->>Backend: Return result
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
backend/backends/mlx_backend.py (1)
25-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winPreserve
contextvarsto maintainasyncio.to_thread's behavior.
asyncio.to_threadnatively copiescontextvars, which are frequently used by web frameworks and logging libraries to track request IDs and tracing contexts across async tasks. When manually switching toloop.run_in_executor(), these context variables are dropped.You can explicitly copy the context to perfectly mirror the original
to_threadbehavior and prevent potential tracing regressions.♻️ Proposed refactor to preserve context
+import contextvars +import functools + async def _run_on_mlx_thread(fn, *args): """Run a blocking MLX operation on the dedicated MLX thread.""" - return await asyncio.get_running_loop().run_in_executor(_MLX_EXECUTOR, fn, *args) + loop = asyncio.get_running_loop() + ctx = contextvars.copy_context() + func_call = functools.partial(ctx.run, fn, *args) + return await loop.run_in_executor(_MLX_EXECUTOR, func_call)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/backends/mlx_backend.py` around lines 25 - 28, Update _run_on_mlx_thread to capture the current contextvars context before submitting the blocking operation, then execute fn with its arguments inside that copied context on _MLX_EXECUTOR, preserving asyncio.to_thread behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@backend/backends/mlx_backend.py`:
- Around line 25-28: Update _run_on_mlx_thread to capture the current
contextvars context before submitting the blocking operation, then execute fn
with its arguments inside that copied context on _MLX_EXECUTOR, preserving
asyncio.to_thread behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 37c93f4e-9826-4a07-ad9c-34f4eb06e4db
📒 Files selected for processing (1)
backend/backends/mlx_backend.py
Symptom
On macOS (Apple Silicon, MLX backend), Qwen3-TTS voice-clone (ICL) generation fails with HTTP 500 on every request:
The same error hits the MLX STT path (
voicebox.transcribevia HTTP/MCP), with varying stream numbers (Stream(gpu, 4), …).Fixes #675
Fixes #699
Root cause
mlx_lmcreates a thread-local GPU stream at module import time —mlx_lm/generate.py:226in mlx-lm 0.31.x:Any MLX array or op that captures that stream can only be evaluated on the thread that created it.
backend/backends/mlx_backend.pydispatched model loading and generation throughasyncio.to_thread, which uses a rotating worker pool — so the import, the load, and each generation could land on different threads. As soon as a generation ran on a thread other than the importing one, MLX raisedThere is no Stream(gpu, N) in current threadand the request failed. (mlx_audiohas module-level streams on its paths too, so the STT backend is affected the same way.)This is why behavior looked inconsistent across entry points (#675): whichever call path happened to reuse the importing thread worked; everything else failed.
Fix
Route all MLX work — module imports, model loading, generation, transcription — through one shared single-worker
ThreadPoolExecutor(_MLX_EXECUTOR+_run_on_mlx_threadhelper), replacing the fourasyncio.to_threadcall sites inmlx_backend.py. Everything MLX now lives on the same dedicated thread for the lifetime of the process.The serialization is free in practice: MLX is single-GPU and generations were already serialized by the backend.
Verification
On an M3 Max (macOS 26, mlx-lm 0.31.3):
/generate/stream→ HTTP 500 (There is no Stream(gpu, 1) in current thread), 100% reproducible.🤖 Generated with Claude Code
Summary by CodeRabbit