fix(mlx): serialize accelerator lifecycle and inference

Route MLX load, inference, unload, reset, cache cleanup, and shutdown through a single worker. Add affinity and concurrent-unload regression coverage.\n\nVerified: 17 related backend tests; frontend CI; cargo check.
This commit is contained in:
Jamie Pine
2026-07-19 17:45:52 -07:00
parent f2cf2a729d
commit 0070c04bcf
10 changed files with 286 additions and 66 deletions
+3 -3
View File
@@ -353,15 +353,15 @@ async def _run_shutdown() -> None:
"""Unload models on lifespan exit.""" """Unload models on lifespan exit."""
logger.info("Voicebox server shutting down...") logger.info("Voicebox server shutting down...")
try: try:
tts.unload_tts_model() await tts.unload_tts_model()
except Exception: except Exception:
logger.exception("Failed to unload TTS model") logger.exception("Failed to unload TTS model")
try: try:
transcribe.unload_whisper_model() await transcribe.unload_whisper_model()
except Exception: except Exception:
logger.exception("Failed to unload Whisper model") logger.exception("Failed to unload Whisper model")
try: try:
llm.unload_llm_model() await llm.unload_llm_model()
except Exception: except Exception:
logger.exception("Failed to unload LLM model") logger.exception("Failed to unload LLM model")
+20 -6
View File
@@ -547,7 +547,21 @@ async def ensure_model_cached_or_raise(engine: str, model_size: str = "default")
) )
def unload_model_by_config(config: ModelConfig) -> bool: async def unload_backend(backend) -> None:
"""Free a backend's model, serialized onto the MLX worker when it has one.
MLX backends expose an async ``unload`` that runs the free on the dedicated
MLX thread so it can't collide with an in-flight load/generate. Other
backends only carry the synchronous ``unload_model``.
"""
unload = getattr(backend, "unload", None)
if unload is not None:
await unload()
else:
backend.unload_model()
async def unload_model_by_config(config: ModelConfig) -> bool:
"""Unload a model given its config. Returns True if it was loaded, False otherwise.""" """Unload a model given its config. Returns True if it was loaded, False otherwise."""
from . import get_tts_backend_for_engine from . import get_tts_backend_for_engine
from ..services import tts, transcribe, llm as llm_service from ..services import tts, transcribe, llm as llm_service
@@ -555,7 +569,7 @@ def unload_model_by_config(config: ModelConfig) -> bool:
if config.engine == "whisper": if config.engine == "whisper":
whisper_model = transcribe.get_whisper_model() whisper_model = transcribe.get_whisper_model()
if whisper_model.is_loaded() and whisper_model.model_size == config.model_size: if whisper_model.is_loaded() and whisper_model.model_size == config.model_size:
transcribe.unload_whisper_model() await unload_backend(whisper_model)
return True return True
return False return False
@@ -563,7 +577,7 @@ def unload_model_by_config(config: ModelConfig) -> bool:
backend = llm_service.get_llm_model() backend = llm_service.get_llm_model()
loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None) loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None)
if backend.is_loaded() and loaded_size == config.model_size: if backend.is_loaded() and loaded_size == config.model_size:
backend.unload_model() await unload_backend(backend)
return True return True
return False return False
@@ -571,7 +585,7 @@ def unload_model_by_config(config: ModelConfig) -> bool:
tts_model = tts.get_tts_model() tts_model = tts.get_tts_model()
loaded_size = getattr(tts_model, "_current_model_size", None) or getattr(tts_model, "model_size", None) loaded_size = getattr(tts_model, "_current_model_size", None) or getattr(tts_model, "model_size", None)
if tts_model.is_loaded() and loaded_size == config.model_size: if tts_model.is_loaded() and loaded_size == config.model_size:
tts.unload_tts_model() await unload_backend(tts_model)
return True return True
return False return False
@@ -579,14 +593,14 @@ def unload_model_by_config(config: ModelConfig) -> bool:
backend = get_tts_backend_for_engine(config.engine) backend = get_tts_backend_for_engine(config.engine)
loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None) loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None)
if backend.is_loaded() and loaded_size == config.model_size: if backend.is_loaded() and loaded_size == config.model_size:
backend.unload_model() await unload_backend(backend)
return True return True
return False return False
# All other TTS engines # All other TTS engines
backend = get_tts_backend_for_engine(config.engine) backend = get_tts_backend_for_engine(config.engine)
if backend.is_loaded(): if backend.is_loaded():
backend.unload_model() await unload_backend(backend)
return True return True
return False return False
+55 -29
View File
@@ -3,7 +3,6 @@ MLX backend implementation for TTS and STT using mlx-audio.
""" """
from typing import Optional, List, Tuple from typing import Optional, List, Tuple
import asyncio
import logging import logging
import numpy as np import numpy as np
from pathlib import Path from pathlib import Path
@@ -19,6 +18,7 @@ ensure_original_qwen_config_cached()
from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
from .base import is_model_cached, combine_voice_prompts as _combine_voice_prompts, model_load_progress from .base import is_model_cached, combine_voice_prompts as _combine_voice_prompts, model_load_progress
from ..services.mlx_thread import run_on_mlx_thread, clear_mlx_cache
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
@@ -63,6 +63,22 @@ class MLXTTSBackend:
weight_extensions=(".safetensors", ".bin", ".npz"), weight_extensions=(".safetensors", ".bin", ".npz"),
) )
def _ensure_loaded_sync(self, model_size: Optional[str]):
"""Load the model if the requested size isn't already resident.
Runs on the MLX worker thread so it stays serialized with generation.
"""
if model_size is None:
model_size = self.model_size
if self.model is not None and self._current_model_size == model_size:
return
if self.model is not None and self._current_model_size != model_size:
self.unload_model()
self._load_model_sync(model_size)
async def load_model_async(self, model_size: Optional[str] = None): async def load_model_async(self, model_size: Optional[str] = None):
""" """
Lazy load the MLX TTS model. Lazy load the MLX TTS model.
@@ -70,23 +86,15 @@ class MLXTTSBackend:
Args: Args:
model_size: Model size to load (1.7B or 0.6B) model_size: Model size to load (1.7B or 0.6B)
""" """
if model_size is None: await run_on_mlx_thread(self._ensure_loaded_sync, model_size)
model_size = self.model_size
# If already loaded with correct size, return
if self.model is not None and self._current_model_size == model_size:
return
# Unload existing model if different size requested
if self.model is not None and self._current_model_size != model_size:
self.unload_model()
# Run blocking load in thread pool
await asyncio.to_thread(self._load_model_sync, model_size)
# Alias for compatibility # Alias for compatibility
load_model = load_model_async load_model = load_model_async
async def unload(self):
"""Free the model, serialized onto the MLX worker thread."""
await run_on_mlx_thread(self.unload_model)
def _load_model_sync(self, model_size: str): def _load_model_sync(self, model_size: str):
"""Synchronous model loading.""" """Synchronous model loading."""
model_path = self._get_model_path(model_size) model_path = self._get_model_path(model_size)
@@ -110,6 +118,7 @@ class MLXTTSBackend:
del self.model del self.model
self.model = None self.model = None
self._current_model_size = None self._current_model_size = None
clear_mlx_cache()
logger.info("MLX TTS model unloaded") logger.info("MLX TTS model unloaded")
async def create_voice_prompt( async def create_voice_prompt(
@@ -187,8 +196,6 @@ class MLXTTSBackend:
Returns: Returns:
Tuple of (audio_array, sample_rate) Tuple of (audio_array, sample_rate)
""" """
await self.load_model_async(None)
logger.info("Generating audio for text: %s", text) logger.info("Generating audio for text: %s", text)
def _generate_sync(): def _generate_sync():
@@ -258,8 +265,13 @@ class MLXTTSBackend:
return audio, sample_rate return audio, sample_rate
# Run blocking inference in thread pool # Load-if-needed and inference run as one job on the MLX worker so a
audio, sample_rate = await asyncio.to_thread(_generate_sync) # concurrent unload or different-size load can't land between them.
def _load_and_generate():
self._ensure_loaded_sync(None)
return _generate_sync()
audio, sample_rate = await run_on_mlx_thread(_load_and_generate)
return audio, sample_rate return audio, sample_rate
@@ -279,12 +291,10 @@ class MLXSTTBackend:
hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}") hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
return is_model_cached(hf_repo, weight_extensions=(".safetensors", ".bin", ".npz")) return is_model_cached(hf_repo, weight_extensions=(".safetensors", ".bin", ".npz"))
async def load_model_async(self, model_size: Optional[str] = None): def _ensure_loaded_sync(self, model_size: Optional[str]):
""" """Load the model if the requested size isn't already resident.
Lazy load the MLX Whisper model.
Args: Runs on the MLX worker thread so it stays serialized with transcription.
model_size: Model size (tiny, base, small, medium, large)
""" """
if model_size is None: if model_size is None:
model_size = self.model_size model_size = self.model_size
@@ -292,12 +302,24 @@ class MLXSTTBackend:
if self.model is not None and self.model_size == model_size: if self.model is not None and self.model_size == model_size:
return return
# Run blocking load in thread pool self._load_model_sync(model_size)
await asyncio.to_thread(self._load_model_sync, model_size)
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the MLX Whisper model.
Args:
model_size: Model size (tiny, base, small, medium, large)
"""
await run_on_mlx_thread(self._ensure_loaded_sync, model_size)
# Alias for compatibility # Alias for compatibility
load_model = load_model_async load_model = load_model_async
async def unload(self):
"""Free the model, serialized onto the MLX worker thread."""
await run_on_mlx_thread(self.unload_model)
def _load_model_sync(self, model_size: str): def _load_model_sync(self, model_size: str):
"""Synchronous model loading.""" """Synchronous model loading."""
progress_model_name = f"whisper-{model_size}" progress_model_name = f"whisper-{model_size}"
@@ -319,6 +341,7 @@ class MLXSTTBackend:
if self.model is not None: if self.model is not None:
del self.model del self.model
self.model = None self.model = None
clear_mlx_cache()
logger.info("MLX Whisper model unloaded") logger.info("MLX Whisper model unloaded")
async def transcribe( async def transcribe(
@@ -338,8 +361,6 @@ class MLXSTTBackend:
Returns: Returns:
Transcribed text Transcribed text
""" """
await self.load_model_async(model_size)
def _transcribe_sync(): def _transcribe_sync():
"""Run synchronous transcription in thread pool.""" """Run synchronous transcription in thread pool."""
# MLX Whisper transcription using generate method # MLX Whisper transcription using generate method
@@ -363,5 +384,10 @@ class MLXSTTBackend:
else: else:
return str(result).strip() return str(result).strip()
# Run blocking transcription in thread pool # Load-if-needed and transcription run as one job on the MLX worker so
return await asyncio.to_thread(_transcribe_sync) # a concurrent unload or load can't land between them.
def _load_and_transcribe():
self._ensure_loaded_sync(model_size)
return _transcribe_sync()
return await run_on_mlx_thread(_load_and_transcribe)
+22 -6
View File
@@ -19,6 +19,7 @@ from .base import (
manual_seed, manual_seed,
model_load_progress, model_load_progress,
) )
from ..services.mlx_thread import run_on_mlx_thread, clear_mlx_cache
from ..utils.hf_offline_patch import force_offline_if_cached from ..utils.hf_offline_patch import force_offline_if_cached
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -202,7 +203,11 @@ class MLXQwenLLMBackend:
weight_extensions=(".safetensors", ".bin", ".npz"), weight_extensions=(".safetensors", ".bin", ".npz"),
) )
async def load_model(self, model_size: Optional[str] = None) -> None: def _ensure_loaded_sync(self, model_size: Optional[str]) -> None:
"""Load the model if the requested size isn't already resident.
Runs on the MLX worker thread so it stays serialized with generation.
"""
if model_size is None: if model_size is None:
model_size = self.model_size model_size = self.model_size
@@ -212,7 +217,14 @@ class MLXQwenLLMBackend:
if self.model is not None and self._current_model_size != model_size: if self.model is not None and self._current_model_size != model_size:
self.unload_model() self.unload_model()
await asyncio.to_thread(self._load_model_sync, model_size) self._load_model_sync(model_size)
async def load_model(self, model_size: Optional[str] = None) -> None:
await run_on_mlx_thread(self._ensure_loaded_sync, model_size)
async def unload(self) -> None:
"""Free the model, serialized onto the MLX worker thread."""
await run_on_mlx_thread(self.unload_model)
def _load_model_sync(self, model_size: str) -> None: def _load_model_sync(self, model_size: str) -> None:
from mlx_lm import load as mlx_load from mlx_lm import load as mlx_load
@@ -243,6 +255,7 @@ class MLXQwenLLMBackend:
self.model = None self.model = None
self.tokenizer = None self.tokenizer = None
self._current_model_size = None self._current_model_size = None
clear_mlx_cache()
logger.info("Qwen3 (MLX) unloaded") logger.info("Qwen3 (MLX) unloaded")
async def generate( async def generate(
@@ -254,10 +267,13 @@ class MLXQwenLLMBackend:
model_size: Optional[str] = None, model_size: Optional[str] = None,
examples: Optional[list[tuple[str, str]]] = None, examples: Optional[list[tuple[str, str]]] = None,
) -> str: ) -> str:
await self.load_model(model_size) # Load-if-needed and inference run as one job on the MLX worker so a
return await asyncio.to_thread( # concurrent unload or different-size load can't land between them.
self._generate_sync, prompt, system, max_tokens, temperature, examples def _load_and_generate() -> str:
) self._ensure_loaded_sync(model_size)
return self._generate_sync(prompt, system, max_tokens, temperature, examples)
return await run_on_mlx_thread(_load_and_generate)
def _generate_sync( def _generate_sync(
self, self,
+3 -3
View File
@@ -66,7 +66,7 @@ async def unload_model():
from ..services import tts from ..services import tts
try: try:
tts.unload_tts_model() await tts.unload_tts_model()
return {"message": "Model unloaded successfully"} return {"message": "Model unloaded successfully"}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -82,7 +82,7 @@ async def unload_model_by_name(model_name: str):
raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}") raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}")
try: try:
was_loaded = unload_model_by_config(config) was_loaded = await unload_model_by_config(config)
if not was_loaded: if not was_loaded:
return {"message": f"Model {model_name} is not loaded"} return {"message": f"Model {model_name} is not loaded"}
return {"message": f"Model {model_name} unloaded successfully"} return {"message": f"Model {model_name} unloaded successfully"}
@@ -454,7 +454,7 @@ async def delete_model(model_name: str):
hf_repo_id = config.hf_repo_id hf_repo_id = config.hf_repo_id
try: try:
unload_model_by_config(config) await unload_model_by_config(config)
cache_dir = hf_constants.HF_HUB_CACHE cache_dir = hf_constants.HF_HUB_CACHE
repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--")) repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--"))
+4 -4
View File
@@ -2,7 +2,7 @@
LLM inference module - delegates to backend abstraction layer. LLM inference module - delegates to backend abstraction layer.
""" """
from ..backends import get_llm_backend, LLMBackend from ..backends import LLMBackend, get_llm_backend, unload_backend
def get_llm_model() -> LLMBackend: def get_llm_model() -> LLMBackend:
@@ -10,6 +10,6 @@ def get_llm_model() -> LLMBackend:
return get_llm_backend() return get_llm_backend()
def unload_llm_model() -> None: async def unload_llm_model() -> None:
"""Unload LLM model to free memory.""" """Unload LLM model to free memory, serialized onto the MLX worker."""
get_llm_backend().unload_model() await unload_backend(get_llm_backend())
+39
View File
@@ -0,0 +1,39 @@
"""Single dedicated worker thread for all MLX GPU work.
MLX's Metal command encoder/stream is thread-local: it binds to whichever
thread first touches the GPU device. ``asyncio.to_thread()`` uses the event
loop's default executor, which hands successive calls to different worker
threads a model loaded on one thread and generated on another raises
"There is no Stream(gpu, N) in current thread" (issue #699).
Routing every MLX load, generate, transcribe and unload through this one
worker keeps them on a single thread. Because the pool has a single worker,
submitted jobs also run to completion one at a time in submission order, so a
load-then-infer pair submitted as one job cannot be interleaved with an unload
or a different-size load from another request.
"""
import asyncio
from concurrent.futures import ThreadPoolExecutor
_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)
def clear_mlx_cache() -> None:
"""Return MLX's cached unified memory to the OS after a model is freed.
Must run on the MLX worker thread (call it from an unload that is already
routed through ``run_on_mlx_thread``). ``clear_cache`` moved out of the
``mlx.core.metal`` namespace in newer MLX, so resolve it from either.
"""
import mlx.core as mx
clear = getattr(mx, "clear_cache", None) or getattr(getattr(mx, "metal", None), "clear_cache", None)
if clear is not None:
clear()
+5 -7
View File
@@ -2,21 +2,19 @@
STT (Speech-to-Text) module - delegates to backend abstraction layer. STT (Speech-to-Text) module - delegates to backend abstraction layer.
""" """
from typing import Optional from ..backends import STTBackend, get_stt_backend, unload_backend
from ..backends import get_stt_backend, STTBackend
def get_whisper_model() -> STTBackend: def get_whisper_model() -> STTBackend:
""" """
Get STT backend instance (MLX or PyTorch based on platform). Get STT backend instance (MLX or PyTorch based on platform).
Returns: Returns:
STT backend instance STT backend instance
""" """
return get_stt_backend() return get_stt_backend()
def unload_whisper_model(): async def unload_whisper_model():
"""Unload Whisper model to free memory.""" """Unload Whisper model to free memory, serialized onto the MLX worker."""
backend = get_stt_backend() await unload_backend(get_stt_backend())
backend.unload_model()
+7 -8
View File
@@ -2,28 +2,27 @@
TTS inference module - delegates to backend abstraction layer. TTS inference module - delegates to backend abstraction layer.
""" """
from typing import Optional
import numpy as np
import io import io
import numpy as np
import soundfile as sf import soundfile as sf
from ..backends import get_tts_backend, TTSBackend from ..backends import TTSBackend, get_tts_backend, unload_backend
def get_tts_model() -> TTSBackend: def get_tts_model() -> TTSBackend:
""" """
Get TTS backend instance (MLX or PyTorch based on platform). Get TTS backend instance (MLX or PyTorch based on platform).
Returns: Returns:
TTS backend instance TTS backend instance
""" """
return get_tts_backend() return get_tts_backend()
def unload_tts_model(): async def unload_tts_model():
"""Unload TTS model to free memory.""" """Unload TTS model to free memory, serialized onto the MLX worker."""
backend = get_tts_backend() await unload_backend(get_tts_backend())
backend.unload_model()
def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes: def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
+128
View File
@@ -0,0 +1,128 @@
"""Regression tests for MLX single-thread serialization.
MLX's Metal stream is thread-local, so every load/generate/unload must run on
one dedicated worker thread (issue #699), and a load+infer pair must run as one
atomic job so a concurrent unload or different-size load can't land between the
load and the inference that reads the model.
These drive the real async orchestration on ``MLXQwenLLMBackend`` with the
heavy mlx-lm calls faked, so they exercise the shipped code paths without
needing MLX installed.
"""
import asyncio
import threading
import time
import pytest
from backend.backends.qwen_llm_backend import MLXQwenLLMBackend
from backend.services import llm as llm_service
from backend.services.mlx_thread import run_on_mlx_thread
@pytest.mark.asyncio
async def test_run_on_mlx_thread_uses_a_single_worker():
idents = set()
def record():
idents.add(threading.get_ident())
await asyncio.gather(*(run_on_mlx_thread(record) for _ in range(12)))
assert len(idents) == 1, "MLX work must stay pinned to one worker thread"
assert idents.pop() != threading.get_ident(), "MLX work must not run on the event loop thread"
def _install_fakes(backend, worker_threads):
"""Replace the heavy sync internals with fakes that record their thread.
``_load_model_sync`` and ``_generate_sync`` sleep briefly so that, if the
load and inference of one request were ever split into separate jobs, a
second request could interleave and be observed.
"""
def fake_load(model_size):
worker_threads.add(threading.get_ident())
time.sleep(0.02)
backend.model = {"size": model_size}
backend._current_model_size = model_size
backend.model_size = model_size
def fake_unload():
worker_threads.add(threading.get_ident())
backend.model = None
backend._current_model_size = None
def fake_generate(prompt, system, max_tokens, temperature, examples=None):
worker_threads.add(threading.get_ident())
# Capture the resident model, do "work", then confirm it wasn't
# swapped or freed underneath us — that is exactly the interleave the
# atomic load+infer job is meant to prevent.
resident = backend.model
assert resident is not None, "model was freed mid-generation"
time.sleep(0.02)
assert backend.model is resident, "model was swapped mid-generation"
return resident["size"]
backend._load_model_sync = fake_load
backend.unload_model = fake_unload
backend._generate_sync = fake_generate
@pytest.mark.asyncio
async def test_concurrent_generate_does_not_cross_models():
backend = MLXQwenLLMBackend()
worker_threads = set()
_install_fakes(backend, worker_threads)
small, large = await asyncio.gather(
backend.generate("a", model_size="0.6B"),
backend.generate("b", model_size="4B"),
)
assert small == "0.6B"
assert large == "4B"
assert len(worker_threads) == 1, "load and generate must share the one MLX thread"
@pytest.mark.asyncio
async def test_unload_cannot_free_model_mid_generation():
backend = MLXQwenLLMBackend()
worker_threads = set()
_install_fakes(backend, worker_threads)
await backend.load_model("0.6B")
# An unload issued while a generation is in flight must serialize behind it
# on the worker rather than free the model out from under it.
size, _ = await asyncio.gather(
backend.generate("a", model_size="0.6B"),
backend.unload(),
)
assert size == "0.6B"
assert backend.model is None, "unload should still take effect once generation completes"
assert len(worker_threads) == 1
@pytest.mark.asyncio
async def test_service_path_unload_serializes_with_generation(monkeypatch):
# The service unload helpers (tts/stt/llm) all route through unload_backend,
# which must serialize on the MLX worker rather than free the model on the
# event-loop thread mid-generation.
backend = MLXQwenLLMBackend()
worker_threads = set()
_install_fakes(backend, worker_threads)
monkeypatch.setattr(llm_service, "get_llm_backend", lambda: backend)
await backend.load_model("0.6B")
size, _ = await asyncio.gather(
backend.generate("a", model_size="0.6B"),
llm_service.unload_llm_model(),
)
assert size == "0.6B"
assert backend.model is None
assert len(worker_threads) == 1