172 lines
5.5 KiB
Python
172 lines
5.5 KiB
Python
"""
|
|
Global MLX lock to prevent concurrent Metal/MLX model access.
|
|
|
|
MLX models (STT, LLM, TTS) cannot be used concurrently from multiple threads
|
|
on Apple Silicon due to Metal command buffer limitations. This module provides
|
|
a global lock that all MLX handlers should acquire before using their models.
|
|
"""
|
|
|
|
import logging
|
|
import types
|
|
from threading import Lock, RLock, current_thread, get_ident
|
|
from time import perf_counter
|
|
from typing import Literal
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Global reentrant lock for MLX model access
|
|
# Using RLock (reentrant lock) so the same thread can acquire it multiple times
|
|
_mlx_lock = RLock()
|
|
_mlx_lock_state = Lock()
|
|
_lock_owner_ident: int | None = None
|
|
_lock_owner_thread: str | None = None
|
|
_lock_owner_handler: str | None = None
|
|
_lock_acquired_at: float | None = None
|
|
_lock_depth = 0
|
|
|
|
|
|
def _owner_snapshot(now: float | None = None) -> str:
|
|
if _lock_owner_ident is None:
|
|
return "none"
|
|
elapsed = ""
|
|
if _lock_acquired_at is not None:
|
|
elapsed = f" for {(now or perf_counter()) - _lock_acquired_at:.3f}s"
|
|
return f"{_lock_owner_handler} on {_lock_owner_thread}{elapsed}"
|
|
|
|
|
|
def _record_lock_acquired(handler_name: str) -> int:
|
|
global _lock_acquired_at, _lock_depth, _lock_owner_handler, _lock_owner_ident, _lock_owner_thread
|
|
ident = get_ident()
|
|
thread_name = current_thread().name
|
|
now = perf_counter()
|
|
with _mlx_lock_state:
|
|
if _lock_owner_ident == ident:
|
|
_lock_depth += 1
|
|
else:
|
|
_lock_owner_ident = ident
|
|
_lock_owner_thread = thread_name
|
|
_lock_owner_handler = handler_name
|
|
_lock_acquired_at = now
|
|
_lock_depth = 1
|
|
return _lock_depth
|
|
|
|
|
|
def _record_lock_released(handler_name: str) -> tuple[int, float | None]:
|
|
global _lock_acquired_at, _lock_depth, _lock_owner_handler, _lock_owner_ident, _lock_owner_thread
|
|
ident = get_ident()
|
|
now = perf_counter()
|
|
with _mlx_lock_state:
|
|
if _lock_owner_ident != ident:
|
|
logger.warning(
|
|
"%s: MLX lock release requested by non-owner thread; owner=%s",
|
|
handler_name,
|
|
_owner_snapshot(now),
|
|
)
|
|
return _lock_depth, None
|
|
|
|
hold_s = None if _lock_acquired_at is None else now - _lock_acquired_at
|
|
_lock_depth -= 1
|
|
depth = _lock_depth
|
|
if _lock_depth <= 0:
|
|
_lock_owner_ident = None
|
|
_lock_owner_thread = None
|
|
_lock_owner_handler = None
|
|
_lock_acquired_at = None
|
|
_lock_depth = 0
|
|
return depth, hold_s
|
|
|
|
|
|
def acquire_mlx_lock(timeout: float | None = None, handler_name: str = "Unknown") -> bool:
|
|
"""
|
|
Acquire the global MLX lock.
|
|
|
|
Args:
|
|
timeout: Optional timeout in seconds
|
|
handler_name: Name of the handler acquiring the lock (for logging)
|
|
|
|
Returns:
|
|
True if lock was acquired, False if timeout occurred
|
|
"""
|
|
with _mlx_lock_state:
|
|
owner_before = _owner_snapshot()
|
|
logger.debug("%s: Attempting to acquire MLX lock (owner=%s)", handler_name, owner_before)
|
|
start = perf_counter()
|
|
acquired = _mlx_lock.acquire(timeout=timeout) if timeout else _mlx_lock.acquire(blocking=True)
|
|
wait_s = perf_counter() - start
|
|
|
|
if acquired:
|
|
depth = _record_lock_acquired(handler_name)
|
|
if wait_s >= 0.25:
|
|
logger.info(
|
|
"%s: MLX lock acquired after %.2fs (previous_owner=%s, depth=%d)",
|
|
handler_name,
|
|
wait_s,
|
|
owner_before,
|
|
depth,
|
|
)
|
|
else:
|
|
logger.debug(
|
|
"%s: MLX lock acquired after %.3fs (previous_owner=%s, depth=%d)",
|
|
handler_name,
|
|
wait_s,
|
|
owner_before,
|
|
depth,
|
|
)
|
|
else:
|
|
with _mlx_lock_state:
|
|
owner_after = _owner_snapshot()
|
|
logger.warning(
|
|
"%s: Failed to acquire MLX lock after %.3fs (timeout=%s, owner=%s)",
|
|
handler_name,
|
|
wait_s,
|
|
timeout,
|
|
owner_after,
|
|
)
|
|
|
|
return acquired
|
|
|
|
|
|
def release_mlx_lock(handler_name: str = "Unknown") -> None:
|
|
"""
|
|
Release the global MLX lock.
|
|
|
|
Args:
|
|
handler_name: Name of the handler releasing the lock (for logging)
|
|
"""
|
|
try:
|
|
depth, hold_s = _record_lock_released(handler_name)
|
|
_mlx_lock.release()
|
|
if hold_s is not None and depth == 0 and hold_s >= 0.25:
|
|
logger.info("%s: MLX lock released after holding %.2fs", handler_name, hold_s)
|
|
else:
|
|
logger.debug(
|
|
"%s: MLX lock released%s",
|
|
handler_name,
|
|
"" if hold_s is None else f" after holding {hold_s:.3f}s (depth={depth})",
|
|
)
|
|
except RuntimeError as e:
|
|
logger.error(f"{handler_name}: Failed to release MLX lock: {e}")
|
|
|
|
|
|
class MLXLockContext:
|
|
"""Context manager for MLX lock."""
|
|
|
|
def __init__(self, handler_name: str = "Unknown", timeout: float | None = None) -> None:
|
|
self.handler_name = handler_name
|
|
self.timeout = timeout
|
|
self.acquired = False
|
|
|
|
def __enter__(self) -> bool:
|
|
self.acquired = acquire_mlx_lock(timeout=self.timeout, handler_name=self.handler_name)
|
|
return self.acquired
|
|
|
|
def __exit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc_val: BaseException | None,
|
|
exc_tb: types.TracebackType | None,
|
|
) -> Literal[False]:
|
|
if self.acquired:
|
|
release_mlx_lock(handler_name=self.handler_name)
|
|
return False # Do not suppress exceptions
|