Skip to content

fix: extend MLX thread-affinity fix (#886) to the Qwen3 LLM backend - #909

Open
shsunmoonlee wants to merge 4 commits into
jamiepine:mainfrom
shsunmoonlee:claude/mlx-llm-thread-affinity
Open

fix: extend MLX thread-affinity fix (#886) to the Qwen3 LLM backend#909
shsunmoonlee wants to merge 4 commits into
jamiepine:mainfrom
shsunmoonlee:claude/mlx-llm-thread-affinity

Conversation

@shsunmoonlee

@shsunmoonlee shsunmoonlee commented Jul 18, 2026

Copy link
Copy Markdown

Summary

Builds on #886 (its three commits are included here) — extends the dedicated-MLX-thread fix to the one backend it missed: MLXQwenLLMBackend.

#886 pins TTS and STT MLX calls to a single worker thread, but the Qwen3 LLM backend (backend/backends/qwen_llm_backend.py) still dispatches load_model/generate through asyncio.to_thread(). Result: with #886 applied, TTS and STT work, but POST /llm/generate (personality Compose/Rewrite, dictation refinement, voicebox.speak with personality: true) still fails 100% with the same error, raised from mlx_lm.generate's wired_limit context manager on exit:

RuntimeError: There is no Stream(gpu, 0) in current thread.

Fix

Route the MLX LLM backend's unload/load/generate through the same _run_on_mlx_thread helper #886 introduces, so every MLX call in the process (TTS, STT, and LLM) shares the one dedicated worker thread. Sharing a single serialized worker also matches the app's "one LLM, one model cache, one GPU-memory footprint" design.

Verification

Apple M3 Pro, macOS 26.5 (Darwin 25.5.0), Python 3.12.8, mlx 0.32.0 / mlx-lm 0.31.1 / mlx-audio 0.4.1, Qwen3 0.6B (4-bit MLX):

  • Before: POST /llm/generate → 500, traceback above, 100% repro from a fresh backend start.
  • After: POST /llm/generate returns generated text; repeated calls stable.
  • Regression check: Qwen3-TTS 1.7B /generate and Whisper Turbo /transcribe still work after the change (round-trip verified: generated audio transcribes back to the input text).

Happy to rebase/fold into #886 if @AlexStephenLytton prefers to pick the commit into that branch instead.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added Apple Silicon MPS support for PyTorch and Qwen voice backends.
    • MLX health checks now report the MPS backend variant.
  • Bug Fixes

    • Improved MLX model loading, speech generation, and transcription reliability.
    • Added MPS memory cleanup when models are unloaded.
    • Improved Apple Silicon setup and audio-device detection.
  • Chores

    • Updated Apple Silicon installation steps and runtime requirements for MLX features.

AlexStephenLytton and others added 4 commits July 12, 2026 20:30
Two related bugs on Apple Silicon, both stemming from the same root
cause (mlx-lm/mlx-audio never getting installed correctly by `just
setup-python`), plus one cosmetic health-endpoint bug found along the
way:

1. `backend/backends/mlx_backend.py`: pin every MLX model load/generate/
   transcribe call to a single dedicated worker thread instead of
   asyncio.to_thread()'s default pool. MLX's Metal command
   encoder/stream is thread-local — if model load and generation land
   on different pool threads (which asyncio.to_thread() does not
   guarantee against), MLX raises "There is no Stream(gpu, N) in
   current thread." Fixes jamiepine#699.

2. `justfile`: `just setup-python` installed requirements-mlx.txt (mlx
   + miniaudio) on Apple Silicon and stopped — it never ran the
   `pip install --no-deps mlx-lm==0.31.1` / `mlx-audio==0.4.1` steps
   that .github/workflows/release.yml already does for packaged
   builds. Without mlx-audio installed, get_backend_type() falls back
   to "pytorch" silently: no error, no log line calling this out, just
   a backend that's much slower than intended and never actually uses
   MLX. Fixes jamiepine#823.

   Installing mlx-lm without --no-deps (the obvious thing to try once
   you notice jamiepine#823) reintroduces a second problem: mlx-lm>=0.30.0
   requires transformers>=5.0.0, which conflicts with the
   transformers<=4.57.6 cap in requirements.txt. transformers>=5.0
   changed `check_model_inputs` from a decorator factory
   (`@check_model_inputs()`) to a plain decorator
   (`@check_model_inputs`, called as `check_model_inputs(func)`) —
   qwen_tts's CustomVoice tokenizer still uses the old calling
   convention, so loading Qwen CustomVoice raises `TypeError:
   check_model_inputs() missing 1 required positional argument:
   'func'` once transformers gets silently upgraded. --no-deps avoids
   this entirely since mlx-lm's own transformers requirement is never
   consulted; documented this in requirements-mlx.txt so the next
   person doesn't hit the same trap.

3. `backend/routes/health.py`: `/health`'s `backend_variant` field
   never had an `mlx` branch, so it always reported "cpu" regardless
   of whether MLX was active or working — cosmetic, but it's what a
   couple of the linked issues (jamiepine#706, jamiepine#723) used to conclude MLX
   wasn't running on the GPU when `backend_type` said otherwise. Added
   the missing branch so `backend_variant` reports "mps" when the mlx
   backend is selected.

All three verified on real Apple Silicon hardware (M4): repeated
generations with no thread crash, `just setup-python` now installs a
working MLX backend from a clean venv, Qwen CustomVoice downloads and
generates successfully post-fix, and `/health` reports
`backend_variant: "mps"` when MLX is active.

Fixes jamiepine#699, fixes jamiepine#823
…e Silicon

get_torch_device() already supports allow_mps, and other engines
(luxtts_backend.py, qwen_llm_backend.py) already pass allow_mps=True —
but PyTorchTTSBackend, PyTorchSTTBackend, and QwenCustomVoiceBackend
never did, so any Apple Silicon install that ends up on the PyTorch
backend (e.g. MLX unavailable, or before this PR's justfile fix)
silently ran these three on plain CPU instead of falling back to
PyTorch+MPS.

This turned out to matter more than expected: benchmarking on M4
hardware, PyTorch+MPS generation (RTF ~1.7x) was faster than this
same PR's MLX thread-fix (RTF ~3.4-4.3x) for the same qwen engine and
model size, and faster than a CUDA RTX 2080 Ti (RTF ~2.3-3.1x) too.
I'm not proposing changing the default backend selection here — one
engine, one model size, one machine is not enough data for that, and
MLX-Whisper is reportedly faster than PyTorch-CPU for STT — but MPS
should clearly be an option rather than silently absent.

Verified on M4: PyTorchTTSBackend()._get_device() and
QwenCustomVoiceBackend()._get_device() both now return "mps" (were
"cpu").
…ad thread-safety

Two follow-ups from the automated review on this PR:

1. backend/backends/base.py: empty_device_cache() only handled "cuda"
   and "xpu", never "mps" — so once the previous commit enabled MPS for
   the PyTorch Qwen3-TTS/CustomVoice engines, switching model sizes on
   Apple Silicon would leak MPS-cached memory (torch.mps.empty_cache()
   never got called). Added the missing branch.

2. backend/backends/qwen_custom_voice_backend.py: unload_model() had
   its own inline `if torch.cuda.is_available(): torch.cuda.empty_cache()`
   instead of using the shared empty_device_cache() helper — meant it
   never freed MPS memory either, same bug as jamiepine#1 but duplicated instead
   of shared. Switched to the shared helper (matches pytorch_backend.py's
   existing pattern).

3. backend/backends/mlx_backend.py: load_model_async()'s unload-on-
   model-size-change call went straight to self.unload_model() instead
   of routing through _run_on_mlx_thread like every other MLX call in
   this file (the whole point of the earlier commit in this PR). Fixed
   for consistency with the thread-pinning fix.

Verified on M4: 0.6B -> generate still works post-changes (regression
check), torch.mps.empty_cache() confirmed callable without error in
this environment.
The dedicated-MLX-thread fix (jamiepine#886) covers the TTS and STT backends, but
MLXQwenLLMBackend still dispatches load/generate through
asyncio.to_thread(), so /llm/generate (personality compose/rewrite,
dictation refinement) crashes with the same
'There is no Stream(gpu, 0) in current thread.' — raised from
mlx_lm.generate's wired_limit on exit — whenever load and generate land
on different pool threads.

Route the MLX LLM backend's unload/load/generate through the same
_run_on_mlx_thread helper so every MLX call in the process shares one
worker thread.

Verified on Apple M3 Pro (macOS 26.5, mlx 0.32.0, mlx-lm 0.31.1,
mlx-audio 0.4.1): /llm/generate failed 100% before, succeeds after;
TTS + STT unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8b7a5114-ca5c-4cdc-ae38-40b7b4b77ca7

📥 Commits

Reviewing files that changed from the base of the PR and between f2cf2a7 and 7b7a3b0.

📒 Files selected for processing (8)
  • backend/backends/base.py
  • backend/backends/mlx_backend.py
  • backend/backends/pytorch_backend.py
  • backend/backends/qwen_custom_voice_backend.py
  • backend/backends/qwen_llm_backend.py
  • backend/requirements-mlx.txt
  • backend/routes/health.py
  • justfile

📝 Walkthrough

Walkthrough

Apple Silicon support now includes MPS cache clearing and device selection, dedicated-thread execution for MLX operations, corrected MLX health reporting, and explicit MLX runtime installation steps.

Changes

Apple Silicon backend support

Layer / File(s) Summary
MPS device selection and cache handling
backend/backends/base.py, backend/backends/pytorch_backend.py, backend/backends/qwen_custom_voice_backend.py, backend/routes/health.py
MPS is accepted by relevant device selectors, MPS cache memory is cleared during unload, and MLX health responses report the mps variant.
Dedicated MLX execution thread
backend/backends/mlx_backend.py, backend/backends/qwen_llm_backend.py
MLX model loading, TTS, STT, and LLM generation are routed through a shared single-worker executor.
MLX runtime installation and dependency setup
backend/requirements-mlx.txt, justfile
MLX audio dependencies and pinned --no-deps installation commands are documented and added to Apple Silicon setup.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: jamiepine

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main change: extending the MLX thread-affinity fix to the Qwen3 LLM backend.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@leighton-tidwell

Copy link
Copy Markdown

Confirming we hit exactly this: on Voicebox 0.5.0 / Apple Silicon (macOS 26.5.1, arm64), dictation refinement through the Qwen3 LLM backend fails 100% with There is no Stream(gpu, 1) in current thread.

backend/backends/qwen_llm_backend.py _generate_sync  (asyncio.to_thread)
  → mlx_lm stream_generate → wired_limit → mx.synchronize(s)

TTS/STT aside, this is the path that breaks refinement for us (repro'd with mlx-community/Qwen3-4B-4bit). Would love to see this land — happy to test a build.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants