Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions backend/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,17 +171,19 @@ def check_cuda_compatibility() -> tuple[bool, str | None]:

def empty_device_cache(device: str) -> None:
"""
Free cached memory on the given device (CUDA or XPU).
Free cached memory on the given device (CUDA, XPU, or MPS).

Backends should call this after unloading models so VRAM is returned
to the OS.
Backends should call this after unloading models so VRAM/unified
memory is returned to the OS.
"""
import torch

if device == "cuda" and torch.cuda.is_available():
torch.cuda.empty_cache()
elif device == "xpu" and hasattr(torch, "xpu"):
torch.xpu.empty_cache()
elif device == "mps" and hasattr(torch, "mps"):
torch.mps.empty_cache()
Comment on lines +185 to +186

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the changed area and related helpers in backend/backends/base.py
FILE="backend/backends/base.py"
wc -l "$FILE"
sed -n '140,220p' "$FILE"

# Find related uses of empty_device_cache and device detection helpers
rg -n "empty_device_cache|get_torch_device|mps|torch\.mps|torch\.backends\.mps" backend -S

Repository: jamiepine/voicebox

Length of output: 10703


🌐 Web query:

PyTorch mps empty_cache availability check torch.backends.mps.is_available docs

💡 Result:

In PyTorch, torch.mps.empty_cache is a standard function available as part of the MPS (Metal Performance Shaders) backend [1][2]. It is used to release unoccupied cached memory held by the caching allocator, which can help reclaim memory for other GPU applications [3][2]. Regarding your query about checking availability: 1. MPS Availability Check: To check if MPS is available in your current environment, you should use torch.backends.mps.is_available [4][5]. This function returns a boolean indicating whether the MPS backend is supported and currently available [4][6]. 2. Function Location: Note that while torch.backends.mps.is_available is used for checking availability [4][7], the empty_cache function resides under the torch.mps namespace (i.e., torch.mps.empty_cache) [1][3]. It is not located under torch.backends.mps. Recent updates to PyTorch have also enhanced torch.mps.empty_cache to clear the MPSGraphCache in addition to the standard allocator pool, which is particularly useful for workloads involving variable tensor shapes [8]. Summary of common MPS-related checks: - Check if MPS is available: torch.backends.mps.is_available [4][7] - Check if PyTorch was built with MPS support: torch.backends.mps.is_built [7][6] - Clear MPS cache: torch.mps.empty_cache [1][2]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import importlib.util, sys
spec = importlib.util.find_spec("torch")
print("torch_installed=", spec is not None)
if spec is not None:
    import torch
    print("torch_version=", getattr(torch, "__version__", "unknown"))
    print("hasattr(torch, 'mps')=", hasattr(torch, "mps"))
    print("hasattr(torch.backends, 'mps')=", hasattr(torch.backends, "mps"))
    if hasattr(torch.backends, "mps"):
        try:
            print("torch.backends.mps.is_available()=", torch.backends.mps.is_available())
        except Exception as e:
            print("torch.backends.mps.is_available() raised:", type(e).__name__, e)
    if hasattr(torch, "mps"):
        try:
            print("torch.mps exists; empty_cache attr=", hasattr(torch.mps, "empty_cache"))
        except Exception as e:
            print("torch.mps probe raised:", type(e).__name__, e)
PY

Repository: jamiepine/voicebox

Length of output: 179


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect git context around the file without running repository code
git diff -- backend/backends/base.py | sed -n '1,220p'

Repository: jamiepine/voicebox

Length of output: 156


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Focus on the specific helper implementation and surrounding device logic.
FILE="backend/backends/base.py"
nl -ba "$FILE" | sed -n '160,205p'

Repository: jamiepine/voicebox

Length of output: 196


🌐 Web query:

Does torch.mps.empty_cache raise when torch.backends.mps.is_available is false

💡 Result:

Yes, calling torch.mps.empty_cache when the MPS (Metal Performance Shaders) backend is not available raises a RuntimeError with the message "Cannot execute emptyCache without MPS backend" [1][2]. Because torch.mps.empty_cache interacts directly with the underlying MPS allocator [3][4], it is not safe to call on non-Apple Silicon devices or environments where the MPS backend is not initialized [1][2]. To avoid this error in cross-platform code, you should guard the call by checking the availability of the backend first [1][5][2]: import torch if torch.backends.mps.is_available: torch.mps.empty_cache

Citations:


Guard the MPS cache clear with a backend availability check.

hasattr(torch, "mps") only checks the namespace; torch.mps.empty_cache() can still raise when MPS isn’t available. Mirror the CUDA branch and gate this on torch.backends.mps.is_available().

🤖 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/base.py` around lines 185 - 186, Update the MPS branch in
the cache-clearing logic to require torch.backends.mps.is_available() before
calling torch.mps.empty_cache(), while preserving the existing device == "mps"
condition and CUDA behavior.



def manual_seed(seed: int, device: str) -> None:
Expand Down
38 changes: 28 additions & 10 deletions backend/backends/mlx_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,27 @@
from typing import Optional, List, Tuple
import asyncio
import logging
from concurrent.futures import ThreadPoolExecutor
import numpy as np
from pathlib import Path

logger = logging.getLogger(__name__)

# MLX's Metal command encoder/stream is thread-local: it's bound to
# whichever thread first touches the GPU device. asyncio.to_thread() uses
# the loop's default executor, which can (and does) hand different calls
# to different worker threads — model load on one thread, then generate()
# on another raises "There is no Stream(gpu, N) in current thread." Pin
# every MLX call to the same single dedicated worker instead.
# See https://github.com/jamiepine/voicebox/issues/699
_mlx_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="mlx-worker")


def _run_on_mlx_thread(func, *args):
"""Run ``func(*args)`` on the single dedicated MLX worker thread."""
loop = asyncio.get_running_loop()
return loop.run_in_executor(_mlx_executor, func, *args)

# PATCH: Import and apply offline patch BEFORE any huggingface_hub usage
# This prevents mlx_audio from making network requests when models are cached
from ..utils.hf_offline_patch import patch_huggingface_hub_offline, ensure_original_qwen_config_cached
Expand Down Expand Up @@ -77,12 +93,14 @@ async def load_model_async(self, model_size: Optional[str] = None):
if self.model is not None and self._current_model_size == model_size:
return

# Unload existing model if different size requested
# Unload existing model if different size requested — routed
# through the same dedicated thread as load/generate so it stays
# serialized with any in-flight MLX call (see _run_on_mlx_thread).
if self.model is not None and self._current_model_size != model_size:
self.unload_model()
await _run_on_mlx_thread(self.unload_model)

# Run blocking load in thread pool
await asyncio.to_thread(self._load_model_sync, model_size)
# Run blocking load on the dedicated MLX thread
await _run_on_mlx_thread(self._load_model_sync, model_size)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Alias for compatibility
load_model = load_model_async
Expand Down Expand Up @@ -258,8 +276,8 @@ def _generate_sync():

return audio, sample_rate

# Run blocking inference in thread pool
audio, sample_rate = await asyncio.to_thread(_generate_sync)
# Run blocking inference on the dedicated MLX thread
audio, sample_rate = await _run_on_mlx_thread(_generate_sync)

return audio, sample_rate

Expand Down Expand Up @@ -292,8 +310,8 @@ async def load_model_async(self, model_size: Optional[str] = None):
if self.model is not None and self.model_size == model_size:
return

# Run blocking load in thread pool
await asyncio.to_thread(self._load_model_sync, model_size)
# Run blocking load on the dedicated MLX thread
await _run_on_mlx_thread(self._load_model_sync, model_size)

# Alias for compatibility
load_model = load_model_async
Expand Down Expand Up @@ -363,5 +381,5 @@ def _transcribe_sync():
else:
return str(result).strip()

# Run blocking transcription in thread pool
return await asyncio.to_thread(_transcribe_sync)
# Run blocking transcription on the dedicated MLX thread
return await _run_on_mlx_thread(_transcribe_sync)
4 changes: 2 additions & 2 deletions backend/backends/pytorch_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def __init__(self, model_size: str = "1.7B"):

def _get_device(self) -> str:
"""Get the best available device."""
return get_torch_device(allow_xpu=True, allow_directml=True)
return get_torch_device(allow_xpu=True, allow_directml=True, allow_mps=True)

def is_loaded(self) -> bool:
"""Check if model is loaded."""
Expand Down Expand Up @@ -256,7 +256,7 @@ def __init__(self, model_size: str = "base"):

def _get_device(self) -> str:
"""Get the best available device."""
return get_torch_device(allow_xpu=True, allow_directml=True)
return get_torch_device(allow_xpu=True, allow_directml=True, allow_mps=True)

def is_loaded(self) -> bool:
"""Check if model is loaded."""
Expand Down
6 changes: 3 additions & 3 deletions backend/backends/qwen_custom_voice_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
)
Expand Down Expand Up @@ -65,7 +66,7 @@ def __init__(self, model_size: str = "1.7B"):
self._current_model_size: Optional[str] = None

def _get_device(self) -> str:
return get_torch_device(allow_xpu=True, allow_directml=True)
return get_torch_device(allow_xpu=True, allow_directml=True, allow_mps=True)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def is_loaded(self) -> bool:
return self.model is not None
Expand Down Expand Up @@ -127,8 +128,7 @@ def unload_model(self) -> None:
self.model = None
self._current_model_size = None

if torch.cuda.is_available():
torch.cuda.empty_cache()
empty_device_cache(self.device)

logger.info("Qwen CustomVoice unloaded")

Expand Down
48 changes: 38 additions & 10 deletions backend/requirements-mlx.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,42 @@ mlx>=0.30.0
# M1 installs with `ModuleNotFoundError: miniaudio` (issue #505).
miniaudio>=1.59

# NOTE: mlx-audio is intentionally not listed here. From 0.3.1 onward it
# declares `transformers==5.0.0rc3` / `>=5.0.0`, which conflicts with the
# `transformers<=4.57.6` cap in requirements.txt and breaks CI's clean
# resolver. The mlx-audio API surface we use (mlx_audio.tts.load,
# mlx_audio.stt.load) works fine on transformers 4.57.x in practice.
# sounddevice is a runtime dep of mlx-audio (device enumeration for its
# playback helpers). mlx-audio 0.4.1 pins sounddevice==0.5.3 exactly; not
# listing it here left it unresolved on a from-scratch install, which
# doesn't crash immediately but leaves pip's resolver warning about a
# missing dependency and mlx-audio's playback helper unusable.
sounddevice==0.5.3

# NOTE: mlx-lm and mlx-audio are intentionally not listed here — both are
# installed separately with --no-deps (see `just setup-python` and
# .github/workflows/release.yml), NOT via this requirements file. From
# 0.30.0 onward mlx-lm declares `transformers>=5.0.0` (mlx-audio>=0.3.1
# does the same), which conflicts with the `transformers<=4.57.6` cap in
# requirements.txt. A plain `pip install -r` here would let pip resolve
# mlx-lm freely and silently upgrade transformers past that cap, which
# breaks the qwen_tts (PyTorch) engines at *runtime*, not install time:
# qwen_tts's `@check_model_inputs()` call site
# (qwen_tts/core/tokenizer_12hz/modeling_qwen3_tts_tokenizer_v2.py) uses
# the pre-5.0 signature (a decorator factory called with no arguments).
# transformers>=5.0 changed `check_model_inputs` to a plain decorator
# (`check_model_inputs(func)`, no parens at the call site), so loading
# Qwen CustomVoice through qwen_tts on the newer transformers raises
# `TypeError: check_model_inputs() missing 1 required positional
# argument: 'func'`.
#
# Install it via `pip install --no-deps mlx-audio==0.4.1` after this file
# (see .github/workflows/release.yml). Most other mlx-audio runtime deps
# (huggingface_hub, librosa, mlx-lm, numba, numpy, protobuf, pyloudnorm,
# sounddevice, tqdm) are already in requirements.txt or pulled in by
# other engines.
# Install mlx-lm and mlx-audio via:
# pip install --no-deps mlx-lm==0.31.1
# pip install --no-deps mlx-audio==0.4.1
# --no-deps means both packages' own `transformers>=5.0.0` requirement is
# never consulted, so transformers stays at whatever requirements.txt
# installed. The mlx_audio.tts/mlx_audio.stt API surface we use works
# fine against transformers 4.57.x in practice (verified in dev and CI).
# `just setup-python` previously installed this file's contents (mlx +
# miniaudio + sounddevice) and stopped — mlx_lm and mlx_audio were never
# installed at all, so the backend silently fell back to the PyTorch
# backend on Apple Silicon instead of MLX (issue #823), and once mlx_lm
# was installed by hand without --no-deps (the obvious fix to try), it
# reintroduced the transformers conflict above (issue #699 is the
# MLX-generation-side symptom of the same root cause). See the two
# `pip install --no-deps` lines added to `just setup-python` below.
2 changes: 2 additions & 0 deletions backend/routes/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ async def health():
default_variant = "cuda"
elif has_xpu:
default_variant = "xpu"
elif backend_type == "mlx":
default_variant = "mps"

return models.HealthResponse(
status="healthy",
Expand Down
8 changes: 8 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ setup-python:
if [ "$(uname -m)" = "arm64" ] && [ "$(uname)" = "Darwin" ]; then
echo "Detected Apple Silicon — installing MLX dependencies..."
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
# mlx-lm and mlx-audio are installed --no-deps and separately from
# requirements-mlx.txt on purpose — see the NOTE at the bottom of
# that file. Without this step the backend silently falls back to
# the PyTorch backend on Apple Silicon (issue #823), and installing
# mlx-lm without --no-deps upgrades transformers past the
# requirements.txt cap and breaks qwen_tts at runtime (issue #699).
{{ pip }} install --no-deps mlx-lm==0.31.1
{{ pip }} install --no-deps mlx-audio==0.4.1
fi
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
{{ pip }} install pyinstaller ruff pytest pytest-asyncio -q
Expand Down