Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

1908 lines
79 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Engine pool for oMLX multi-model serving.
This module manages multiple model engines with LRU-based eviction
when memory limits are exceeded. It supports:
- Pre-load memory checking to ensure models fit before loading
- LRU eviction of least recently used models
- Model pinning to keep specific models always loaded
- BatchedEngine for all LLM models (continuous batching)
"""
from __future__ import annotations
import asyncio
import gc
import json
import logging
import time
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from .model_settings import ModelSettingsManager
import mlx.core as mx
from .engine import BaseEngine, BatchedEngine
from .engine.embedding import EmbeddingEngine
from .engine.reranker import RerankerEngine
from .engine.sts import STSEngine
from .engine.stt import STTEngine
from .engine.tts import TTSEngine
from .engine.vlm import VLMBatchedEngine
from .engine_core import get_mlx_executor
from .exceptions import (
InsufficientMemoryError,
ModelBusyError,
ModelLoadingError,
ModelNotFoundError,
ModelTooLargeError,
ModelUnavailableError,
)
from .model_discovery import discover_models, format_size
from .scheduler import SchedulerConfig
from .utils.proc_memory import get_phys_footprint
logger = logging.getLogger(__name__)
@dataclass
class EngineEntry:
"""Per-model state in the engine pool."""
model_id: str # Directory name (e.g., "llama-3b")
model_path: str # Full path to model directory
model_type: Literal[
"llm", "vlm", "embedding", "reranker", "audio_stt", "audio_tts", "audio_sts"
] # Model type
engine_type: Literal[
"batched",
"simple",
"embedding",
"reranker",
"vlm",
"audio_stt",
"audio_tts",
"audio_sts",
] # Engine type to use
estimated_size: int # Pre-calculated from safetensors (bytes)
actual_size: int | None = None # Observed process-memory delta after load settles
config_model_type: str = (
"" # Raw model_type from config.json (e.g., "deepseekocr_2")
)
thinking_default: bool | None = (
None # True if model thinks by default, False if not, None if unknown
)
preserve_thinking_default: bool | None = (
None # True when template supports preserve_thinking (Qwen 3.6+)
)
model_context_length: int | None = (
None # Declared context length from config.json (None if unknown)
)
source_type: str = "local"
source_repo_id: str | None = None
is_helper: bool = False # Speculative-decoding drafter (dFlash/Assistant/MTP)
engine: (
BaseEngine
| EmbeddingEngine
| RerankerEngine
| STTEngine
| STSEngine
| TTSEngine
| None
) = None # Loaded engine instance
last_access: float = 0.0 # Timestamp for LRU (0 if never loaded)
is_loading: bool = False # Prevent concurrent loads
loading_started_at: float | None = None # Timestamp when current load started
is_pinned: bool = False # Never evict if True
abort_loading: bool = False # Set by memory enforcer to abort in-progress load
in_use: int = 0 # in-flight acquire/use lease count; never evict while > 0
abort_requested: bool = False # Set under hard pressure for leased requests
pending_unload_reason: str | None = None # Unload as soon as leases/activity drain
runtime_settings_signature: tuple[tuple[str, str], ...] | None = None
load_failed: bool = False # Sticky until the next discovery refresh
load_failure_message: str | None = None
load_failure_at: float | None = None
class EnginePool:
"""
Manages multiple model engines with LRU-based memory management.
Features:
- Pre-load memory checking (evict before load, not after)
- LRU eviction when memory limit is exceeded
- Model pinning to prevent eviction
- Automatic engine type selection based on model type
"""
def __init__(
self,
scheduler_config: SchedulerConfig | None = None,
):
"""
Initialize the engine pool.
Args:
scheduler_config: Configuration for BatchedEngine schedulers
Note:
Pre-load admission consults `enforcer.get_final_ceiling()` via
the `_get_final_ceiling` callback set by `server.init_server()`.
Until the callback is wired up the pool admits unconditionally.
"""
self._entries: dict[str, EngineEntry] = {}
self._lock = asyncio.Lock()
self._current_model_memory = 0
self._scheduler_config = scheduler_config or SchedulerConfig()
self._process_memory_enforcer: object | None = None # Set by server
self._get_final_ceiling: object | None = None # Set by server
self._settings_manager: object | None = None # Set by server
self._suppress_ttl: bool = False # Suppress TTL during benchmarks
self._load_seconds_per_gb_ema: float | None = None
self._load_time_observations: int = 0
self.configure_hot_cache_budget()
@property
def current_model_memory(self) -> int:
"""Current memory used by loaded models in bytes."""
return self._current_model_memory
def configure_hot_cache_budget(self) -> None:
"""Ensure loaded schedulers share one process-wide hot cache budget."""
hot_max = int(getattr(self._scheduler_config, "hot_cache_max_size", 0) or 0)
if hot_max <= 0:
self._scheduler_config.hot_cache_budget = None
return
current = getattr(self._scheduler_config, "hot_cache_budget", None)
if current is not None and getattr(current, "max_bytes", None) == hot_max:
return
from .cache.paged_ssd_cache import SharedHotCacheBudget
self._scheduler_config.hot_cache_budget = SharedHotCacheBudget(hot_max)
def _current_ceiling(self) -> int:
"""Resolve the current memory ceiling via the enforcer callback.
Returns 0 when no callback is wired up (treated by callers as
"no limit").
"""
cb = self._get_final_ceiling
if cb is None:
return 0
try:
return int(cb())
except Exception: # noqa: BLE001
return 0
def _wake_process_memory_enforcer(self, *, active: bool = False) -> None:
enforcer = self._process_memory_enforcer
wake = getattr(enforcer, "wake", None) if enforcer is not None else None
if callable(wake):
wake(active=active)
@staticmethod
def _canonical_signature_value(value: object) -> str:
if isinstance(value, (dict, list, tuple)):
return json.dumps(value, sort_keys=True, separators=(",", ":"))
return repr(value)
def _engine_runtime_signature(
self,
model_id: str,
runtime_settings: object | None = None,
*,
loaded_engine: object | None = None,
) -> tuple[tuple[str, str], ...] | None:
settings = runtime_settings
if settings is None and self._settings_manager is not None:
get_settings = getattr(self._settings_manager, "get_settings", None)
if callable(get_settings):
settings = get_settings(model_id)
if settings is None:
return None
to_dict = getattr(settings, "to_dict", None)
data = to_dict() if callable(to_dict) else {}
entry = self._entries.get(model_id)
is_diffusion = bool(entry and self._entry_is_diffusion_model(entry))
loaded_engine_name = (
type(loaded_engine).__name__ if loaded_engine is not None else None
)
def has_value(key: str) -> bool:
value = data.get(key)
return value is not None and value != ""
def normalized_index_cache_freq() -> int | None:
value = data.get("index_cache_freq")
try:
freq = int(value) if value is not None else None
except (TypeError, ValueError):
return None
return freq if freq is not None and freq >= 2 else None
signature: list[tuple[str, str]] = []
def add(key: str, value: object) -> None:
signature.append((key, self._canonical_signature_value(value)))
# Security/load gates.
add("trust_remote_code", bool(data.get("trust_remote_code", False)))
add("index_cache_freq", normalized_index_cache_freq())
# Load-time model variants. Dependent fields only matter when their
# feature is active; stale draft paths or tuning defaults must not
# force a reload when the corresponding feature is disabled.
mtp_active = bool(data.get("mtp_enabled", False))
add("mtp_enabled", mtp_active)
turboquant_active = bool(data.get("turboquant_kv_enabled", False))
add("turboquant_kv_enabled", turboquant_active)
if turboquant_active:
add("turboquant_kv_bits", data.get("turboquant_kv_bits", 4))
add("turboquant_skip_last", data.get("turboquant_skip_last", True))
specprefill_active = bool(data.get("specprefill_enabled", False)) and has_value(
"specprefill_draft_model"
)
add("specprefill_enabled", specprefill_active)
if specprefill_active:
add("specprefill_draft_model", data.get("specprefill_draft_model"))
add("specprefill_keep_pct", data.get("specprefill_keep_pct", 0.2))
add("specprefill_threshold", data.get("specprefill_threshold"))
dflash_active = (
bool(data.get("dflash_enabled", False))
and has_value("dflash_draft_model")
and not is_diffusion
)
if loaded_engine_name is not None:
dflash_active = loaded_engine_name == "DFlashEngine"
add("dflash_enabled", dflash_active)
if dflash_active:
add("dflash_draft_model", data.get("dflash_draft_model"))
add(
"dflash_draft_quant_enabled",
bool(data.get("dflash_draft_quant_enabled", False)),
)
if data.get("dflash_draft_quant_enabled", False):
add(
"dflash_draft_quant_weight_bits",
data.get("dflash_draft_quant_weight_bits", 4),
)
add(
"dflash_draft_quant_activation_bits",
data.get("dflash_draft_quant_activation_bits", 16),
)
add(
"dflash_draft_quant_group_size",
data.get("dflash_draft_quant_group_size", 64),
)
add("dflash_max_ctx", data.get("dflash_max_ctx"))
add("dflash_in_memory_cache", data.get("dflash_in_memory_cache", True))
add(
"dflash_in_memory_cache_max_entries",
data.get("dflash_in_memory_cache_max_entries", 4),
)
add(
"dflash_in_memory_cache_max_bytes",
data.get("dflash_in_memory_cache_max_bytes"),
)
add("dflash_ssd_cache", bool(data.get("dflash_ssd_cache", False)))
if data.get("dflash_ssd_cache", False):
add(
"dflash_ssd_cache_max_bytes", data.get("dflash_ssd_cache_max_bytes")
)
add("dflash_draft_window_size", data.get("dflash_draft_window_size"))
add("dflash_draft_sink_size", data.get("dflash_draft_sink_size"))
add("dflash_verify_mode", data.get("dflash_verify_mode"))
vlm_mtp_active = bool(data.get("vlm_mtp_enabled", False)) and has_value(
"vlm_mtp_draft_model"
)
if loaded_engine is not None and vlm_mtp_active:
drafter = getattr(loaded_engine, "vlm_mtp_drafter", None)
if callable(drafter):
drafter = drafter()
vlm_mtp_active = drafter is not None
add("vlm_mtp_enabled", vlm_mtp_active)
if vlm_mtp_active:
add("vlm_mtp_draft_model", data.get("vlm_mtp_draft_model"))
add("vlm_mtp_draft_block_size", data.get("vlm_mtp_draft_block_size"))
return tuple(signature)
@property
def model_count(self) -> int:
"""Total number of discovered models."""
return len(self._entries)
@property
def loaded_model_count(self) -> int:
"""Number of currently loaded models."""
return sum(1 for e in self._entries.values() if e.engine is not None)
async def apply_embedding_batch_size(self, batch_size: int) -> None:
"""Apply embedding batch size to future and currently loaded embedding engines."""
batch_size = int(batch_size)
if batch_size <= 0:
raise ValueError("embedding batch size must be > 0")
async with self._lock:
self._scheduler_config.embedding_batch_size = batch_size
for entry in list(self._entries.values()):
engine = entry.engine if entry is not None else None
if isinstance(engine, EmbeddingEngine):
engine._batch_size = batch_size
def discover_models(
self, model_dirs: str | list[str], pinned_models: list[str] | None = None
) -> None:
"""
Discover models in the specified directory or directories.
Args:
model_dirs: Path or list of paths to directories containing model subdirectories
pinned_models: List of model IDs to pin (never evict)
"""
from pathlib import Path
from .model_discovery import discover_models_from_dirs
if isinstance(model_dirs, str):
dirs = [Path(model_dirs)]
else:
dirs = [Path(d) for d in model_dirs]
if len(dirs) == 1:
discovered = discover_models(dirs[0])
else:
discovered = discover_models_from_dirs(dirs)
pinned_set = set(pinned_models or [])
for model_id, info in discovered.items():
existing = self._entries.get(model_id)
if existing is not None and existing.engine is not None:
# Loaded model: preserve runtime state, only update pinned flag
existing.is_pinned = model_id in pinned_set
else:
# New or unloaded model: create fresh entry
self._entries[model_id] = EngineEntry(
model_id=model_id,
model_path=info.model_path,
model_type=info.model_type,
engine_type=info.engine_type,
estimated_size=info.estimated_size,
config_model_type=getattr(info, "config_model_type", ""),
thinking_default=getattr(info, "thinking_default", None),
preserve_thinking_default=getattr(
info, "preserve_thinking_default", None
),
model_context_length=getattr(info, "model_context_length", None),
source_type=getattr(info, "source_type", "local"),
source_repo_id=getattr(info, "source_repo_id", None),
is_helper=getattr(info, "is_helper", False),
is_pinned=model_id in pinned_set,
)
if model_id in pinned_set:
logger.info(f"Pinned model: {model_id}")
# Remove entries no longer discovered and not loaded
discovered_ids = set(discovered.keys())
stale = [
mid
for mid in self._entries
if mid not in discovered_ids and self._entries[mid].engine is None
]
for mid in stale:
del self._entries[mid]
# Warn about pinned models not found
found_models = set(self._entries.keys())
for model_id in pinned_set:
if model_id not in found_models:
logger.warning(f"Pinned model not found: {model_id}")
logger.info(f"Discovered {len(self._entries)} models")
_MODEL_TYPE_TO_ENGINE: dict[str, str] = {
"llm": "batched",
"vlm": "vlm",
"embedding": "embedding",
"reranker": "reranker",
"audio_stt": "audio_stt",
"audio_tts": "audio_tts",
"audio_sts": "audio_sts",
}
@staticmethod
def _entry_is_diffusion_model(entry: EngineEntry) -> bool:
model_type = (entry.config_model_type or "").lower().replace("-", "_")
return model_type == "diffusion_gemma"
def apply_settings_overrides(
self, settings_manager: "ModelSettingsManager"
) -> None:
"""Apply model_type_override from persisted settings to discovered entries."""
for model_id, entry in self._entries.items():
settings = settings_manager.get_settings(model_id)
if settings.model_type_override:
entry.model_type = settings.model_type_override
entry.engine_type = self._MODEL_TYPE_TO_ENGINE.get(
settings.model_type_override, "batched"
)
logger.info(
f"Applied model_type override for {model_id}: "
f"type={entry.model_type}, engine={entry.engine_type}"
)
def get_model_ids(self) -> list[str]:
"""Get list of all discovered model IDs."""
return list(self._entries.keys())
def get_loaded_model_ids(self) -> list[str]:
"""Get list of currently loaded model IDs."""
return [mid for mid, e in self._entries.items() if e.engine is not None]
def get_entry(self, model_id: str) -> EngineEntry | None:
"""Get entry for a specific model, or None if not found."""
return self._entries.get(model_id)
def _clear_load_failure(self, entry: EngineEntry) -> None:
entry.load_failed = False
entry.load_failure_message = None
entry.load_failure_at = None
def _mark_load_failure(self, entry: EngineEntry, exc: BaseException) -> None:
entry.load_failed = True
entry.load_failure_message = str(exc) or type(exc).__name__
entry.load_failure_at = time.time()
def _raise_if_model_path_missing_locked(
self, model_id: str, entry: EngineEntry
) -> None:
"""Drop stale unloaded entries whose backing model directory vanished."""
model_path = Path(entry.model_path)
if model_path.exists() and (model_path / "config.json").exists():
return
if entry.engine is None:
self._entries.pop(model_id, None)
available = [mid for mid in self._entries if mid != model_id]
raise ModelNotFoundError(model_id, available)
def _raise_if_load_failed(self, model_id: str, entry: EngineEntry) -> None:
if not entry.load_failed:
return
detail = entry.load_failure_message or "previous load attempt failed"
logger.warning(
"Skipping load retry for '%s' after cached failure: %s",
model_id,
detail,
)
raise ModelUnavailableError(
model_id,
f"Model '{model_id}' is unavailable after a previous load failure: {detail}. "
"Reload models after fixing the files to retry.",
)
def set_pinned(self, model_id: str, pinned: bool) -> bool:
"""
Set the pinned status for a model.
Args:
model_id: The model ID to update
pinned: Whether to pin (True) or unpin (False) the model
Returns:
True if successful, False if model not found.
"""
entry = self._entries.get(model_id)
if entry is None:
return False
entry.is_pinned = pinned
return True
def _case_insensitive_entry_match(self, name: str) -> str | None:
"""Find a model entry matching *name* case-insensitively.
Returns the actual model_id if found, None otherwise.
"""
lower = name.lower()
for mid in self._entries:
if mid.lower() == lower:
return mid
return None
def resolve_model_id(self, model_id_or_alias: str, settings_manager) -> str:
"""Resolve a model alias to its actual model_id (directory name).
Tries exact match in _entries first, then case-insensitive match,
then exposed profile model IDs, then scans model settings for alias
match. If those fail and input contains a provider prefix (e.g.
"omlx/my-model"), strips the prefix and retries. Returns the
original string if no match found.
"""
if model_id_or_alias in self._entries:
return model_id_or_alias
# Case-insensitive fallback
ci_match = self._case_insensitive_entry_match(model_id_or_alias)
if ci_match is not None:
return ci_match
all_settings = None
if settings_manager is not None:
# Exposed profiles resolve to the physical model they overlay
# (handles provider prefixes internally).
if hasattr(settings_manager, "get_exposed_profile_source_model_id"):
profile_source = settings_manager.get_exposed_profile_source_model_id(
model_id_or_alias
)
if profile_source is not None:
return profile_source
all_settings = settings_manager.get_all_settings()
for mid, ms in all_settings.items():
if ms.model_alias and ms.model_alias == model_id_or_alias:
return mid
# Strip provider prefix (e.g. "omlx/qwen3.5-35b" -> "qwen3.5-35b")
if "/" in model_id_or_alias:
stripped = model_id_or_alias.split("/", 1)[1]
if stripped in self._entries:
return stripped
ci_match = self._case_insensitive_entry_match(stripped)
if ci_match is not None:
return ci_match
if all_settings is not None:
for mid, ms in all_settings.items():
if ms.model_alias and ms.model_alias == stripped:
return mid
return model_id_or_alias
@staticmethod
def _entry_has_active_requests(entry: EngineEntry) -> bool:
engine = entry.engine
if engine is None:
return False
has_active_requests = getattr(engine, "has_active_requests", None)
if not callable(has_active_requests):
return False
try:
return has_active_requests() is True
except Exception:
return True
def _entry_is_busy(self, entry: EngineEntry) -> bool:
return entry.in_use > 0 or self._entry_has_active_requests(entry)
def _raise_if_reload_busy(self, entry: EngineEntry, operation: str) -> None:
if self._entry_is_busy(entry):
raise ModelBusyError(entry.model_id, operation)
@staticmethod
def _engine_has_usable_tokenizer(engine: object) -> bool:
tokenizer = getattr(engine, "tokenizer", None)
return tokenizer is not None and callable(getattr(tokenizer, "encode", None))
def _validate_llm_engine_ready(self, model_id: str, engine: object | None) -> None:
if engine is None:
raise ModelLoadingError(
model_id,
f"Model '{model_id}' did not return a loaded engine.",
)
llm_engine_types = [BaseEngine]
if isinstance(VLMBatchedEngine, type):
llm_engine_types.append(VLMBatchedEngine)
if isinstance(engine, tuple(llm_engine_types)) and not (
self._engine_has_usable_tokenizer(engine)
):
raise ModelLoadingError(
model_id,
f"Model '{model_id}' loaded without a usable tokenizer.",
)
def _mark_pending_unload_locked(
self,
model_id: str,
reason: str,
*,
abort_requested: bool = False,
) -> bool:
"""Mark a loaded non-pinned model for unload once it is no longer busy.
Caller must hold ``self._lock``. Returns True when a pending marker was
installed. The method deliberately does not unload by itself; call
``_unload_pending_if_idle_locked`` after abort/release state changes.
"""
entry = self._entries.get(model_id)
if entry is None or entry.engine is None or entry.is_loading or entry.is_pinned:
return False
entry.pending_unload_reason = reason
if abort_requested:
entry.abort_requested = True
return True
def _find_pending_unload_ready_locked(self) -> str | None:
candidates: list[tuple[float, str]] = []
for mid, entry in self._entries.items():
if not entry.pending_unload_reason:
continue
if (
entry.engine is None
or entry.is_loading
or entry.is_pinned
or self._entry_is_busy(entry)
):
continue
candidates.append((entry.last_access, mid))
if not candidates:
return None
candidates.sort()
return candidates[0][1]
async def _unload_pending_if_idle_locked(self, model_id: str) -> bool:
"""Unload a pending model if all leases and active requests have drained.
Caller must hold ``self._lock``.
"""
entry = self._entries.get(model_id)
if (
entry is None
or entry.engine is None
or not entry.pending_unload_reason
or entry.is_loading
or entry.is_pinned
or self._entry_is_busy(entry)
):
return False
reason = entry.pending_unload_reason
entry.pending_unload_reason = None
entry.abort_requested = False
logger.warning(
"Unloading pending model '%s' after activity drained (%s)",
model_id,
reason,
)
await self._unload_engine(model_id)
return True
def is_abort_requested(self, model_id: str | None) -> bool:
if model_id is None:
return False
entry = self._entries.get(model_id)
return bool(entry and entry.abort_requested)
async def get_engine(
self,
model_id: str,
force_lm: bool = False,
_lease: bool = False,
runtime_settings: object | None = None,
) -> (
BaseEngine
| EmbeddingEngine
| RerankerEngine
| STTEngine
| STSEngine
| TTSEngine
):
"""
Get or load engine for the specified model.
This method implements pre-load memory checking:
1. Check if model is already loaded -> return immediately
2. Check if model is too large for memory limit -> raise error
3. Evict LRU models until there's enough space
4. Load the model
5. Return the engine
Args:
model_id: The model ID to get engine for
force_lm: Force loading as LM (BatchedEngine) even for VLM models.
Useful for text-only tasks like accuracy benchmarks.
runtime_settings: Optional transient settings used for this engine
load. When its engine-construction signature differs from the
currently loaded engine, the old engine is unloaded and the new
variant is loaded without mutating persisted model settings.
Returns:
The loaded engine (BaseEngine for LLM, EmbeddingEngine for embeddings)
Raises:
ModelNotFoundError: If model is not discovered
ModelTooLargeError: If model exceeds memory limit
InsufficientMemoryError: If can't free enough memory (all pinned)
ModelLoadingError: If model is already being loaded
"""
async with self._lock:
entry = self._entries.get(model_id)
if not entry:
raise ModelNotFoundError(model_id, list(self._entries.keys()))
expected_signature = self._engine_runtime_signature(
model_id,
runtime_settings,
)
unloaded_for_admission = False
# Already loaded - just update access time
if entry.engine is not None:
if (
expected_signature is not None
and entry.runtime_settings_signature is not None
and entry.runtime_settings_signature != expected_signature
) or (
runtime_settings is not None
and entry.runtime_settings_signature is None
):
self._raise_if_reload_busy(
entry,
"reload runtime settings variant",
)
logger.info(
"Runtime settings variant changed for %s; "
"unloading before reload.",
model_id,
)
await self._unload_engine(model_id)
unloaded_for_admission = True
# If force_lm requested but current engine is VLM, unload and reload
if (
entry.engine is not None
and force_lm
and isinstance(entry.engine, VLMBatchedEngine)
):
self._raise_if_reload_busy(entry, "reload as LM")
logger.info(
f"Unloading VLM engine for {model_id} "
f"(force_lm=True, reloading as LM)"
)
await self._unload_engine(model_id)
unloaded_for_admission = True
elif entry.engine is not None:
self._validate_llm_engine_ready(model_id, entry.engine)
if entry.runtime_settings_signature is None:
entry.runtime_settings_signature = expected_signature
entry.last_access = time.time()
if _lease:
entry.in_use += 1
return entry.engine
self._raise_if_model_path_missing_locked(model_id, entry)
self._raise_if_load_failed(model_id, entry)
# Pre-load admission against the memory ceiling from the
# process memory enforcer (min of static and dynamic). Try
# evicting LRU non-pinned models first; if the model still
# cannot fit after evicting everything available, raise.
#
# ceiling == 0 means the enforcer is off (guard disabled or
# not yet wired up), so we admit unconditionally.
ceiling = self._current_ceiling()
if ceiling > 0:
evicted_any = unloaded_for_admission
while True:
# Consult the tracked accumulator alongside live memory:
# after a model settles or idles, mx.get_active_memory() and
# the process footprint can read well below the model's true
# resident size, while _current_model_memory still reflects
# the committed total. Using only live memory lets a second
# large model load without evicting the first, over-
# committing past the ceiling (#1623).
current = max(
mx.get_active_memory(),
get_phys_footprint(),
self._current_model_memory,
)
projected = current + entry.estimated_size
if projected <= ceiling:
break
victim = self._find_lru_victim()
if victim is not None:
logger.info(
f"Evicting '{victim}' to fit '{model_id}' "
f"under memory ceiling "
f"({format_size(projected)} > "
f"{format_size(ceiling)})"
)
await self._unload_engine(victim)
evicted_any = True
continue
failure_current = current
failure_projected = projected
failure_label = "current"
if evicted_any:
# Nothing else to evict after unloading at least one
# model in this get_engine() call. Before failing,
# re-test against the *tracked committed* baseline.
# The phys_footprint term folded into `current` is the
# macOS kernel ledger, which can still count
# reclaimable residue from models we just evicted.
# Pinned/in-use models that could not be evicted remain
# counted in _current_model_memory, preserving the
# #1623 undercount guard. Without a local eviction,
# keep trusting phys_footprint because it may be
# unrelated process pressure rather than model residue.
committed = max(
mx.get_active_memory(), self._current_model_memory
)
committed_projected = committed + entry.estimated_size
if committed_projected <= ceiling:
logger.info(
f"Admitting '{model_id}': committed baseline "
f"{format_size(committed_projected)} fits ceiling "
f"{format_size(ceiling)} "
f"(live footprint {format_size(projected)} included "
"reclaimable residue from evicted models)"
)
break
failure_current = committed
failure_projected = committed_projected
failure_label = "committed"
# Still over budget under the applicable baseline. Use
# ModelTooLargeError when the model alone exceeds the
# ceiling (no chance of fitting), InsufficientMemoryError
# when current usage leaves no room.
if entry.estimated_size > ceiling:
raise ModelTooLargeError(
model_id, entry.estimated_size, ceiling
)
raise InsufficientMemoryError(
required=entry.estimated_size,
current=failure_current,
message=(
f"Cannot load {model_id}: projected memory "
f"{format_size(failure_projected)} would exceed "
f"the memory ceiling {format_size(ceiling)} "
f"({failure_label}: {format_size(failure_current)}, "
f"model: {format_size(entry.estimated_size)}). "
"Free system memory or lower memory_guard_tier."
),
)
# Now load the model
await self._load_engine(
model_id,
force_lm=force_lm,
runtime_settings=runtime_settings,
)
loaded = self._entries[model_id]
self._validate_llm_engine_ready(model_id, loaded.engine)
if _lease:
loaded.in_use += 1
return loaded.engine
async def release_engine(self, model_id: str) -> None:
"""Release one in-use lease previously taken via get_engine(_lease=True)."""
async with self._lock:
e = self._entries.get(model_id)
if e is not None and e.in_use > 0:
e.in_use -= 1
await self._unload_pending_if_idle_locked(model_id)
async def unload_if_idle_unpinned(self, model_id: str) -> bool:
"""Unload a loaded engine only when it is idle and not pinned."""
async with self._lock:
entry = self._entries.get(model_id)
if (
entry is None
or entry.engine is None
or entry.is_loading
or entry.is_pinned
or entry.in_use > 0
):
return False
if self._entry_has_active_requests(entry):
entry.last_access = time.time()
return False
await self._unload_engine(model_id)
return True
@asynccontextmanager
async def acquire(self, model_id: str, force_lm: bool = False):
"""Acquire an engine with an atomic in-use lease.
The lease is taken under the pool lock at acquire time and always
released in finally, so the engine cannot be evicted mid-request even
on exception.
"""
engine = await self.get_engine(model_id, force_lm=force_lm, _lease=True)
try:
yield engine
finally:
await self.release_engine(model_id)
def _find_lru_victim(self) -> str | None:
"""
Find the least recently used non-pinned loaded model.
Skips models with active inference requests to avoid interrupting
in-flight generation.
Returns:
Model ID of the LRU victim, or None if no evictable model found
"""
candidates = []
for mid, e in self._entries.items():
if e.engine is None or e.is_pinned:
continue
if e.in_use > 0:
continue
if self._entry_has_active_requests(e):
logger.debug(f"Skipping victim '{mid}': has active requests")
continue
candidates.append((e.last_access, mid))
if not candidates:
return None
candidates.sort() # Sort by last_access (oldest first)
return candidates[0][1]
async def _unload_other_dflash_engines(self, model_id: str) -> None:
"""Unload other idle DFlash engines before starting a new one.
dflash-mlx installs target hooks on shared Python classes and owns a
process-global runtime cache manager, so multiple loaded DFlash engines
can leak state across model switches.
"""
victims: list[str] = []
blocked: list[str] = []
for mid, e in self._entries.items():
if mid == model_id or e.engine is None:
continue
if type(e.engine).__name__ != "DFlashEngine":
continue
if e.is_loading or e.in_use > 0:
blocked.append(mid)
continue
try:
if e.engine.has_active_requests():
blocked.append(mid)
continue
except AttributeError:
pass
if e.is_pinned:
blocked.append(f"{mid} (pinned)")
continue
victims.append(mid)
if blocked:
raise RuntimeError(
"Cannot load DFlash model "
f"'{model_id}' while another DFlash engine is active: "
f"{', '.join(blocked)}"
)
for victim in victims:
logger.info(
"Unloading DFlash model '%s' before loading '%s' because "
"dflash runtime hooks/cache are process-global",
victim,
model_id,
)
await self._unload_engine(victim)
@staticmethod
def _resolve_scheduler_from_engine(engine: object) -> object | None:
scheduler = getattr(engine, "scheduler", None)
if scheduler is not None:
return scheduler
try:
return engine._engine.engine.scheduler # type: ignore[attr-defined]
except AttributeError:
return None
def _is_idle_for_prefill_eviction(self, entry: EngineEntry) -> bool:
engine = entry.engine
if engine is None or entry.is_pinned or entry.is_loading or entry.in_use > 0:
return False
if self._entry_has_active_requests(entry):
return False
scheduler = self._resolve_scheduler_from_engine(engine)
if scheduler is None:
return True
for attr in ("running", "waiting", "prefilling", "requests"):
value = getattr(scheduler, attr, None)
if value:
return False
return True
def _find_lru_prefill_eviction_victim(self, *, exclude_model_id: str) -> str | None:
candidates = []
for mid, entry in self._entries.items():
if mid == exclude_model_id:
continue
if self._is_idle_for_prefill_eviction(entry):
candidates.append((entry.last_access, mid))
if not candidates:
return None
candidates.sort()
return candidates[0][1]
async def _evict_idle_lru_for_prefill(
self,
exclude_model_id: str,
eviction_request: object,
) -> bool:
"""Evict idle LRU models until the requested prefill step should fit."""
target = int(getattr(eviction_request, "target_cap_bytes", 0) or 0)
predicted = int(getattr(eviction_request, "predicted_transient_bytes", 0) or 0)
request_id = str(getattr(eviction_request, "request_id", ""))
if target <= 0 or predicted <= 0:
return False
evicted_any = False
async with self._lock:
while True:
current = max(
mx.get_active_memory(),
get_phys_footprint(),
self._current_model_memory,
)
if current + predicted <= target:
return evicted_any
victim = self._find_lru_prefill_eviction_victim(
exclude_model_id=exclude_model_id
)
if victim is None:
if evicted_any:
logger.info(
"Prefill eviction for request %s stopped with no "
"more idle victims (current=%s, predicted=%s, "
"target=%s)",
request_id,
format_size(current),
format_size(predicted),
format_size(target),
)
return evicted_any
logger.info(
"Evicting idle model '%s' for prefill headroom on '%s' "
"(request=%s, projected=%s > target=%s)",
victim,
exclude_model_id,
request_id,
format_size(current + predicted),
format_size(target),
)
await self._unload_engine(victim)
evicted_any = True
def _other_entries_serving(self, model_id: str) -> bool:
"""True when any loaded entry other than ``model_id`` is serving.
Used by the settle barrier in ``_unload_engine``: the barrier's
freed-memory check is a delta of the process-global
``mx.get_active_memory()`` gauge, which only measures THIS unload
while no other engine is allocating concurrently.
"""
# Snapshot the items: admin unload routes call _unload_engine without
# the pool lock, so discover_models() can mutate _entries mid-iteration.
for mid, e in list(self._entries.items()):
if mid == model_id or e.engine is None:
continue
if e.in_use > 0:
return True
if self._entry_has_active_requests(e):
return True
return False
async def _unload_engine(self, model_id: str) -> None:
"""
Immediately stop and unload an engine with memory settle barrier.
After stopping the engine, polls mx.get_active_memory() to verify
Metal buffers are actually reclaimed before updating the memory
tracking counter.
Args:
model_id: The model ID to unload
"""
entry = self._entries.get(model_id)
if not entry or entry.engine is None:
return
logger.info(f"Unloading model: {model_id} (immediate abort)")
pre_unload_active = mx.get_active_memory()
try:
await entry.engine.stop()
except Exception as e:
logger.warning(f"Error stopping engine for {model_id}: {e}")
# #1595: the immediate-abort stop() above tears the engine down without the normal
# per-request completion callbacks, so a non-streaming engine's active_requests
# counter can leak a phantom count (a stale engine then looks permanently busy).
# Reset it on teardown so has_active_requests() and the status API stay consistent.
reset = getattr(entry.engine, "_reset_activity_tracking", None)
if callable(reset):
try:
reset()
except Exception as e:
logger.warning(f"Error resetting activity counter for {model_id}: {e}")
# Yield to the event loop before dropping the engine reference.
#
# When abort_all_requests() fires before _unload_engine(), it sets
# asyncio Events for each active request. Server-side streaming
# generators are then scheduled in the asyncio ready queue, but they
# cannot run until the event loop gets control. EngineCore.close()
# (called inside stop()) blocks the event loop with synchronous
# .result() calls on the MLX executor -- scheduler.shutdown() and
# scheduler.deep_reset() -- so those generators are still suspended
# when stop() returns.
#
# If we set entry.engine = None and call gc.collect() immediately,
# the generators are still alive with a local 'engine' variable
# referencing the BatchedEngine, keeping its refcount above zero.
# The model's ~20 GB of MLX weight tensors therefore remain "active"
# in Metal memory, the settle barrier times out, and subsequent load
# attempts fail with 507 because the ceiling is still exceeded.
#
# A few asyncio.sleep(0) calls drain the ready queue -- generator
# tear-down is at most a few frames deep -- so that by the time we
# clear entry.engine and run gc.collect(), no coroutine frame holds
# a stale engine reference.
for _ in range(5):
await asyncio.sleep(0)
# Clear engine reference before settle barrier
entry.engine = None
entry.last_access = 0.0
entry.actual_size = None
entry.abort_requested = False
entry.pending_unload_reason = None
entry.runtime_settings_signature = None
# Force garbage collection to release memory.
# Run mx.clear_cache on the global MLX executor to avoid concurrent
# Metal operations with running engines. See issue #85.
# Synchronize before clearing to prevent releasing Metal buffers
# still referenced by in-flight command buffers. See issue #300.
gc.collect()
loop = asyncio.get_running_loop()
await loop.run_in_executor(
get_mlx_executor(), lambda: (mx.synchronize(), mx.clear_cache())
)
# Memory settle barrier: poll actual freed memory instead of
# trusting the cumulative _current_model_memory estimate.
# Scale tolerance with model size: estimated_size includes a 5%
# overhead factor (model_discovery.py) that may not be reflected in
# actual freed memory. Use 2 GB floor for small models. See #768.
settle_tolerance = max(2 * 1024**3, int(entry.estimated_size * 0.05))
min_expected_freed = max(0, entry.estimated_size - settle_tolerance)
settled = False
settle_indeterminate = False
for _settle_round in range(10):
active_now = mx.get_active_memory()
actual_freed = pre_unload_active - active_now
if actual_freed >= min_expected_freed:
settled = True
logger.debug(
f"Settle round {_settle_round + 1} for '{model_id}': "
f"freed={format_size(actual_freed)} "
f"(need>={format_size(min_expected_freed)}) - settled"
)
break
if self._other_entries_serving(model_id):
# actual_freed is a delta of the process-global MLX gauge,
# so while another engine allocates (prefill/KV growth) the
# amount freed by THIS unload is unmeasurable — the delta can
# even read negative. Burning settle rounds here serializes
# gc/synchronize/clear_cache against live decode for seconds,
# under memory pressure, with the enforcer holding the pool
# lock. Bail out instead: pre-load admission re-reads the
# live gauge, so nothing downstream trusts this sample.
settle_indeterminate = True
logger.info(
f"Settle for '{model_id}' indeterminate under concurrent "
f"activity (freed={format_size(actual_freed)}, "
f"need>={format_size(min_expected_freed)}); skipping "
f"settle wait"
)
break
logger.debug(
f"Settle round {_settle_round + 1} for '{model_id}': "
f"freed={format_size(actual_freed)} "
f"(need>={format_size(min_expected_freed)}) - retry"
)
await asyncio.sleep(0.5)
gc.collect()
await loop.run_in_executor(
get_mlx_executor(), lambda: (mx.synchronize(), mx.clear_cache())
)
# Release memory tracking AFTER barrier
self._current_model_memory -= entry.estimated_size
if settled:
logger.info(
f"Unloaded model: {model_id}, "
f"freed={format_size(actual_freed)} "
f"(expected>={format_size(min_expected_freed)}), "
f"active_memory: {format_size(active_now)} (settled)"
)
elif settle_indeterminate:
# Settle wait skipped (logged above). Emergency reclaim is
# deliberately skipped too: its gc + synchronize + clear_cache
# rounds would stall the live engines that made the measurement
# indeterminate in the first place. Recovery is not lost:
# _wake_process_memory_enforcer() below triggers an immediate
# enforcer re-poll, and pre-load admission re-reads the live gauge
# alongside the tracked accumulator (the #1623 max() in
# get_engine), so any unreleased memory stays visible to both.
pass
else:
# Barrier timed out - try emergency reclaim
logger.warning(
f"Settle barrier timed out for '{model_id}': "
f"freed={format_size(actual_freed)} "
f"(need>={format_size(min_expected_freed)})"
)
for _ in range(3):
gc.collect()
await loop.run_in_executor(
get_mlx_executor(),
lambda: (mx.synchronize(), mx.clear_cache()),
)
await asyncio.sleep(1.0)
active_after = mx.get_active_memory()
if active_after > self._current_model_memory + 5 * 1024**3:
logger.error(
f"Emergency reclaim failed for '{model_id}': "
f"active_memory={format_size(active_after)} "
f"exceeds safe threshold "
f"({format_size(self._current_model_memory + 5 * 1024**3)})"
)
else:
logger.info(
f"Emergency reclaim succeeded: "
f"active_memory={format_size(active_after)}"
)
self._wake_process_memory_enforcer()
def _schedule_failed_load_reclaim(
self, model_id: str, pre_load_memory: int
) -> None:
"""Reclaim memory left behind by a failed model load.
When a load raises partway through (e.g. weights loaded, processor
construction failed), the weights are often reachable only via the
propagating exception's traceback frames. Spawn a background task
that waits briefly for the exception to be handled and dropped, then
runs gc + synchronize + clear_cache rounds until the live memory
reading returns near its pre-load level (or rounds are exhausted).
"""
async def _reclaim() -> None:
loop = asyncio.get_running_loop()
# 2 GB slack over the pre-load level mirrors the unload settle
# barrier's small-model tolerance floor.
target = pre_load_memory + 2 * 1024**3
current = 0
for _round in range(6):
await asyncio.sleep(0.5 if _round == 0 else 1.0)
gc.collect()
await loop.run_in_executor(
get_mlx_executor(),
lambda: (mx.synchronize(), mx.clear_cache()),
)
current = max(mx.get_active_memory(), get_phys_footprint())
if current <= target:
logger.info(
f"Reclaimed memory after failed load of '{model_id}': "
f"current={format_size(current)} "
f"(pre-load={format_size(pre_load_memory)})"
)
self._wake_process_memory_enforcer()
return
logger.warning(
f"Post-failed-load reclaim for '{model_id}' did not settle: "
f"current={format_size(current)} "
f"(pre-load={format_size(pre_load_memory)}). A server restart "
f"may be required to release the leaked memory."
)
self._wake_process_memory_enforcer()
# Keep a reference so the task isn't garbage-collected mid-flight;
# one slot suffices -- a newer failure supersedes the previous task.
self._failed_load_reclaim_task = asyncio.get_running_loop().create_task(
_reclaim()
)
async def _load_engine(
self,
model_id: str,
force_lm: bool = False,
runtime_settings: object | None = None,
) -> None:
"""
Load an engine for the specified model.
Args:
model_id: The model ID to load
force_lm: Force loading as BatchedEngine even for VLM models.
Raises:
ModelLoadingError: If model is already being loaded
"""
entry = self._entries[model_id]
if entry.is_loading:
raise ModelLoadingError(model_id)
entry.is_loading = True
entry.loading_started_at = time.monotonic()
self._wake_process_memory_enforcer(active=True)
load_started_at = entry.loading_started_at
load_completed = False
entry.abort_loading = False
pre_load_memory = max(mx.get_active_memory(), get_phys_footprint())
try:
effective_type = entry.engine_type
if force_lm and effective_type == "vlm":
effective_type = "batched"
logger.info(f"Loading model as LM (force_lm=True): {model_id}")
else:
logger.info(f"Loading model: {model_id}")
# Retrieve per-model settings for post-load transforms
model_settings = runtime_settings
if model_settings is None and self._settings_manager is not None:
model_settings = self._settings_manager.get_settings(model_id)
# Wire the correct model_id / model_path into the shared scheduler
# config so every engine (Batched/VLM/DFlash/Embedding) sees the
# right values when it builds `SchedulerConfig` internally.
self._scheduler_config.model_name = model_id
self._scheduler_config.model_path = entry.model_path
# Native MTP forces LM-only dispatch even for VLM models. Vision
# encoder weights are ignored because the patched mtp_forward only
# exists on the language model path. mtp_enabled was already
# validated as mutually exclusive with dflash in
# metal-knowledge: with the mlx-vlm runtime MTP patch (see
# omlx/patches/mlx_vlm_mtp/qwen35_moe_vlm_runtime.py) VLM models
# can run MTP natively while keeping vision intact. The old
# force-LM-dispatch shortcut here is obsolete for patched
# model families; let VLMBatchedEngine handle MTP-enabled VLMs.
pass
# Check if DFlash is enabled -- takes priority over engine type
# since DFlash has its own model loading pipeline
engine = None
if model_settings is not None:
dflash_enabled = getattr(model_settings, "dflash_enabled", False)
dflash_draft = getattr(model_settings, "dflash_draft_model", None)
if (
dflash_enabled
and dflash_draft
and self._entry_is_diffusion_model(entry)
):
logger.warning(
"DFlash is not supported for diffusion models; "
"loading %s with its native VLM engine",
model_id,
)
elif dflash_enabled and dflash_draft:
try:
from .engine.dflash import DFlashEngine
engine = DFlashEngine(
model_name=entry.model_path,
draft_model_path=dflash_draft,
draft_quant_enabled=getattr(
model_settings, "dflash_draft_quant_enabled", False
),
draft_quant_weight_bits=getattr(
model_settings, "dflash_draft_quant_weight_bits", 4
),
draft_quant_activation_bits=getattr(
model_settings, "dflash_draft_quant_activation_bits", 16
),
draft_quant_group_size=getattr(
model_settings, "dflash_draft_quant_group_size", 64
),
model_settings=model_settings,
fallback_engine_type=effective_type,
scheduler_config=self._scheduler_config,
omlx_ssd_cache_dir=getattr(
self._scheduler_config, "paged_ssd_cache_dir", None
),
)
logger.info(
f"DFlash enabled for {model_id}, draft={dflash_draft}"
)
except ImportError:
logger.warning(
f"DFlash enabled for {model_id} but dflash-mlx is not installed. "
f"Falling back to default engine."
)
except Exception as e:
logger.warning(
f"DFlash init failed for {model_id}: {e}. "
f"Falling back to default engine."
)
# Per-model trust_remote_code (security opt-in, issue #926).
# When unset, defaults to False -- repos with custom modeling_*.py
# will fail to load until the user explicitly toggles this on
# in the admin UI's model settings modal.
trc = (
bool(getattr(model_settings, "trust_remote_code", False))
if model_settings
else False
)
async def prefill_eviction_callback(
eviction_request: object,
*,
_model_id: str = model_id,
) -> bool:
return await self._evict_idle_lru_for_prefill(
exclude_model_id=_model_id,
eviction_request=eviction_request,
)
# Create engine based on engine type (if DFlash not active)
if engine is None:
if effective_type == "embedding":
engine = EmbeddingEngine(
model_name=entry.model_path,
trust_remote_code=trc,
scheduler_config=self._scheduler_config,
)
elif effective_type == "reranker":
engine = RerankerEngine(
model_name=entry.model_path,
trust_remote_code=trc,
)
elif effective_type == "vlm":
engine = VLMBatchedEngine(
model_name=entry.model_path,
trust_remote_code=trc,
scheduler_config=self._scheduler_config,
model_settings=model_settings,
prefill_eviction_callback=prefill_eviction_callback,
)
elif entry.engine_type == "audio_stt":
engine = STTEngine(model_name=entry.model_path)
elif entry.engine_type == "audio_tts":
engine = TTSEngine(model_name=entry.model_path)
elif entry.engine_type == "audio_sts":
engine = STSEngine(
model_name=entry.model_path,
config_model_type=entry.config_model_type,
)
else:
engine = BatchedEngine(
model_name=entry.model_path,
trust_remote_code=trc,
scheduler_config=self._scheduler_config,
model_settings=model_settings,
prefill_eviction_callback=prefill_eviction_callback,
)
_is_dflash_engine = (
engine is not None and type(engine).__name__ == "DFlashEngine"
)
if _is_dflash_engine:
await self._unload_other_dflash_engines(model_id)
try:
await engine.start()
except Exception as start_error:
if _is_dflash_engine:
# DFlash engine failed to start -- fall back to the
# model's natural engine type (VLM or Batched)
logger.warning(
f"DFlash start failed for {model_id}: {start_error}. "
f"Falling back to {effective_type} engine."
)
try:
await engine.stop()
except Exception:
pass
gc.collect()
loop = asyncio.get_running_loop()
await loop.run_in_executor(
get_mlx_executor(),
lambda: (mx.synchronize(), mx.clear_cache()),
)
if effective_type == "vlm":
engine = VLMBatchedEngine(
model_name=entry.model_path,
trust_remote_code=trc,
scheduler_config=self._scheduler_config,
model_settings=model_settings,
prefill_eviction_callback=prefill_eviction_callback,
)
else:
engine = BatchedEngine(
model_name=entry.model_path,
trust_remote_code=trc,
scheduler_config=self._scheduler_config,
model_settings=model_settings,
prefill_eviction_callback=prefill_eviction_callback,
)
try:
await engine.start()
except Exception as fallback_error:
raise RuntimeError(
f"DFlash load failed: {start_error}; "
f"{effective_type} fallback also failed: {fallback_error}"
) from start_error
logger.info(
f"Successfully loaded {model_id} as {effective_type} "
f"(fallback from DFlash)"
)
elif force_lm and entry.engine_type == "vlm":
# force_lm created a BatchedEngine but mlx-lm can't
# load this VLM model -- fall back to VLMBatchedEngine.
logger.warning(
f"LM loading failed for VLM model {model_id} "
f"(force_lm=True), falling back to VLM engine: "
f"{start_error}"
)
try:
await engine.stop()
except Exception:
pass
gc.collect()
loop = asyncio.get_running_loop()
await loop.run_in_executor(
get_mlx_executor(),
lambda: (mx.synchronize(), mx.clear_cache()),
)
engine = VLMBatchedEngine(
model_name=entry.model_path,
trust_remote_code=trc,
scheduler_config=self._scheduler_config,
model_settings=model_settings,
prefill_eviction_callback=prefill_eviction_callback,
)
try:
await engine.start()
except Exception as fallback_error:
raise RuntimeError(
f"LM load failed (force_lm=True): {start_error}; "
f"VLM fallback also failed: {fallback_error}"
) from start_error
logger.info(
f"Successfully loaded {model_id} as VLM "
f"(fallback from force_lm)"
)
elif entry.engine_type == "vlm":
# VLM loading failed -- fall back to LLM (BatchedEngine)
logger.warning(
f"VLM loading failed for {model_id}, "
f"falling back to LLM: {start_error}"
)
try:
await engine.stop()
except Exception:
pass
gc.collect()
loop = asyncio.get_running_loop()
await loop.run_in_executor(
get_mlx_executor(),
lambda: (mx.synchronize(), mx.clear_cache()),
)
engine = BatchedEngine(
model_name=entry.model_path,
trust_remote_code=trc,
scheduler_config=self._scheduler_config,
model_settings=model_settings,
prefill_eviction_callback=prefill_eviction_callback,
)
try:
await engine.start()
except Exception as fallback_error:
raise RuntimeError(
f"VLM load failed: {start_error}; "
f"LLM fallback also failed: {fallback_error}"
) from start_error
entry.model_type = "llm"
entry.engine_type = "batched"
logger.info(
f"Successfully loaded {model_id} as LLM " f"(fallback from VLM)"
)
else:
raise
# Check if memory enforcer requested abort during loading
if entry.abort_loading:
logger.warning(f"Model load aborted by memory enforcer: {model_id}")
try:
await engine.stop()
except Exception as e:
logger.warning(f"Error stopping aborted engine for {model_id}: {e}")
gc.collect()
loop = asyncio.get_running_loop()
await loop.run_in_executor(
get_mlx_executor(),
lambda: (mx.synchronize(), mx.clear_cache()),
)
raise ModelLoadingError(
model_id,
f"Model '{model_id}' load aborted: process memory limit exceeded",
)
self._validate_llm_engine_ready(model_id, engine)
entry.engine = engine
entry.last_access = time.time()
self._current_model_memory += entry.estimated_size
load_completed = True
self._clear_load_failure(entry)
# VLM MTP: load MTP drafter (gemma4_assistant or qwen3_5_mtp) and attach to engine.
# Fail-soft -- drafter load issues never block the target engine.
if (
model_settings is not None
and getattr(model_settings, "vlm_mtp_enabled", False)
and getattr(model_settings, "vlm_mtp_draft_model", None)
and hasattr(engine, "set_vlm_mtp_drafter")
):
drafter_id = model_settings.vlm_mtp_draft_model
drafter_entry = self._entries.get(drafter_id)
drafter_path = drafter_entry.model_path if drafter_entry else drafter_id
def _load_drafter_sync(path: str = drafter_path):
from .speculative.vlm_mtp import load_vlm_mtp_drafter
return load_vlm_mtp_drafter(path)
loop = asyncio.get_running_loop()
try:
drafter = await loop.run_in_executor(
get_mlx_executor(), _load_drafter_sync
)
except Exception as e:
logger.warning(
f"VLM MTP drafter load raised for {model_id} "
f"(drafter={drafter_id}): {e} -- toggle ignored"
)
drafter = None
if drafter is not None:
engine.set_vlm_mtp_drafter(drafter)
logger.info(f"VLM MTP enabled for {model_id}, drafter={drafter_id}")
else:
logger.warning(
f"VLM MTP toggle on for {model_id} but drafter "
f"load failed; toggle ignored"
)
entry.runtime_settings_signature = self._engine_runtime_signature(
model_id,
model_settings,
loaded_engine=engine,
)
# Propagate memory limit to new engine's scheduler
if self._process_memory_enforcer is not None:
self._process_memory_enforcer._propagate_memory_limit()
# Release intermediate Metal buffers from model loading.
# mlx_lm.load() creates large temporaries (weight transforms,
# quantization intermediates) that stay in the Metal buffer pool
# because mx.set_cache_limit(total_mem) prevents automatic release.
# Without this, memory stays at ~2x model size until the first
# inference request triggers a clear. (#429)
loop = asyncio.get_running_loop()
await loop.run_in_executor(
get_mlx_executor(),
lambda: (mx.synchronize(), mx.clear_cache()),
)
post_load_memory = max(mx.get_active_memory(), get_phys_footprint())
observed_delta = max(0, post_load_memory - pre_load_memory)
entry.actual_size = observed_delta or entry.estimated_size
logger.info(
f"Loaded model: {model_id} "
f"(actual: {format_size(entry.actual_size)}, "
f"estimated: {format_size(entry.estimated_size)}, "
f"total: {format_size(self._current_model_memory)})"
)
except Exception as exc:
# A failed load can leave tens of GB of just-loaded weights
# reachable only through the propagating exception's traceback
# frames (loader-internal locals). Running gc/clear_cache
# synchronously here is useless -- the exception is still alive
# in the caller. Schedule a deferred reclaim that runs after the
# exception has been handled and dropped, so the buffers are
# actually released; otherwise the process footprint stays
# inflated and the memory-ceiling admission check rejects all
# subsequent loads until a server restart.
self._schedule_failed_load_reclaim(model_id, pre_load_memory)
if not entry.abort_loading:
self._mark_load_failure(entry, exc)
logger.exception(
"Model load failed for '%s'; caching failure until next discovery refresh",
model_id,
)
raise ModelUnavailableError(
model_id,
f"Model '{model_id}' failed to load: {entry.load_failure_message}. "
"Reload models after fixing the files to retry.",
) from exc
raise
finally:
if (
load_completed
and load_started_at is not None
and entry.estimated_size > 0
):
elapsed = max(0.0, time.monotonic() - load_started_at)
size_gb = entry.estimated_size / (1024**3)
if size_gb > 0 and elapsed > 0:
sample = elapsed / size_gb
if self._load_seconds_per_gb_ema is None:
self._load_seconds_per_gb_ema = sample
else:
self._load_seconds_per_gb_ema = (
self._load_seconds_per_gb_ema * 0.9 + sample * 0.1
)
self._load_time_observations += 1
logger.debug(
f"Observed model load speed: {sample:.2f}s/GB "
f"for {model_id} ({elapsed:.1f}s, {format_size(entry.estimated_size)}); "
f"EMA={self._load_seconds_per_gb_ema:.2f}s/GB"
)
entry.is_loading = False
entry.loading_started_at = None
entry.abort_loading = False
self._wake_process_memory_enforcer()
async def preload_pinned_models(self) -> None:
"""
Preload all pinned models at startup.
This ensures pinned models are always available.
"""
pinned_models = [
model_id for model_id, e in self._entries.items() if e.is_pinned
]
for model_id in pinned_models:
try:
logger.info(f"Preloading pinned model: {model_id}")
await self.get_engine(model_id)
except Exception as e:
logger.error(f"Failed to preload pinned model {model_id}: {e}")
async def shutdown(self) -> None:
"""Shutdown all engines gracefully."""
async with self._lock:
for model_id in list(self._entries.keys()):
entry = self._entries.get(model_id)
if entry and entry.engine is not None:
try:
await self._unload_engine(model_id)
except Exception as e:
logger.error(f"Error unloading {model_id} during shutdown: {e}")
logger.info("Engine pool shutdown complete")
def get_status(self) -> dict:
"""
Get pool status for monitoring endpoints.
Returns:
Dictionary with pool status information
"""
return {
"final_ceiling": self._current_ceiling(),
"current_model_memory": self._current_model_memory,
"model_count": len(self._entries),
"loaded_count": sum(
1 for e in self._entries.values() if e.engine is not None
),
"load_seconds_per_gb_estimate": self._load_seconds_per_gb_ema,
"load_time_observations": self._load_time_observations,
"models": [
{
"id": mid,
"model_path": e.model_path,
"loaded": e.engine is not None,
"is_loading": e.is_loading,
"loading_started_at": e.loading_started_at,
"estimated_size": e.estimated_size,
"actual_size": e.actual_size,
"pinned": e.is_pinned,
"engine_type": e.engine_type,
"model_type": e.model_type,
"config_model_type": e.config_model_type,
"is_helper": e.is_helper,
"thinking_default": e.thinking_default,
"preserve_thinking_default": e.preserve_thinking_default,
"source_type": e.source_type,
"source_repo_id": e.source_repo_id,
"last_access": e.last_access if e.last_access > 0 else None,
}
for mid, e in sorted(self._entries.items())
],
}
async def check_ttl_expirations(
self,
settings_manager: ModelSettingsManager,
global_idle_timeout_seconds: int | None = None,
) -> list[str]:
"""Check and unload models that have exceeded their TTL.
Pinned models are skipped (TTL is ignored for pinned models).
Models with active requests are skipped and their last_access is refreshed.
Suppressed during benchmark runs via _suppress_ttl flag.
Args:
settings_manager: The settings manager to read TTL values from.
global_idle_timeout_seconds: Global idle timeout fallback (None = no global TTL).
Returns:
List of model IDs that were unloaded.
"""
if self._suppress_ttl:
return []
now = time.time()
expired: list[str] = []
async with self._lock:
for model_id, entry in self._entries.items():
if entry.engine is None or entry.is_loading or entry.is_pinned:
continue
settings = settings_manager.get_settings(model_id)
effective_ttl = settings.ttl_seconds
if effective_ttl is None:
effective_ttl = global_idle_timeout_seconds
if effective_ttl is None:
continue
idle_time = now - entry.last_access
if idle_time < effective_ttl:
continue
# Check if model has active requests
has_active = entry.engine.has_active_requests() or entry.in_use > 0
if has_active:
entry.last_access = now
continue
logger.info(
f"TTL expired for model '{model_id}' "
f"(idle {idle_time:.0f}s > ttl {effective_ttl}s)"
)
await self._unload_engine(model_id)
expired.append(model_id)
return expired