# SPDX-License-Identifier: Apache-2.0 """ Scheduler for oMLX continuous batching. This module provides a Scheduler class that manages request scheduling using mlx-lm's BatchGenerator for efficient continuous batching. The scheduler follows vLLM's design with: - Waiting queue for pending requests - Running set for active requests - Continuous batching via BatchGenerator """ import concurrent.futures import copy import gc import importlib import logging import os import threading import time from collections import OrderedDict, defaultdict, deque from collections.abc import Callable from contextlib import contextmanager from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import Any, NamedTuple, Optional import mlx.core as mx from mlx_lm.generate import ( BatchGenerator, GenerationBatch, PromptProcessingBatch, SequenceStateMachine, generation_stream, ) from mlx_lm.models.cache import ( KVCache as _MLXKVCache, ) from mlx_lm.models.cache import ( RotatingKVCache as _MLXRotatingKVCache, ) from mlx_lm.models.cache import ( make_prompt_cache, ) from mlx_lm.sample_utils import make_logits_processors from .cache.observability import CacheRateTracker from .cache.paged_cache import PagedCacheManager from .cache.prefix_cache import BlockAwarePrefixCache from .exceptions import PrefillMemoryExceededError, is_cache_corruption_error from .patches.sdpa256_attention import set_unfused_headroom_provider from .prefill_progress import get_prefill_tracker from .prefill_transient_tracker import PrefillTransientTracker from .request import Request, RequestOutput, RequestStatus, SamplingParams from .speculative.vlm_mtp import VLMMTPDrafter, run_vlm_mtp_decode from .utils.fatal import FATAL_TEARDOWN_TIMEOUT_S, fatal_exit from .utils.generation_config import load_generation_config_token_ids from .utils.proc_memory import get_phys_footprint from .utils.sampling import make_sampler as omlx_make_sampler from .utils.tokenizer import create_streaming_detokenizer # Module-level alias so Scheduler.__init__ can fall back to mlx-lm's default # stream when no per-engine stream is provided. _default_generation_stream = generation_stream def _apply_suppress_token_ids(logits: Any, suppress_token_ids: tuple[int, ...]) -> Any: if suppress_token_ids: logits[..., list(suppress_token_ids)] = mx.array(float("-inf")) return logits def _make_suppress_logits_processor( suppress_token_ids: set[int], ) -> Callable[[Any, Any], Any] | None: suppress_tuple = tuple(sorted(int(t) for t in suppress_token_ids)) if not suppress_tuple: return None def _suppress_logits(tokens: Any, logits: Any) -> Any: return _apply_suppress_token_ids(logits, suppress_tuple) return _suppress_logits def _make_suppressing_sampler( sampler: Callable[[Any], Any], suppress_token_ids: set[int], ) -> Callable[[Any], Any]: suppress_tuple = tuple(sorted(int(t) for t in suppress_token_ids)) if not suppress_tuple: return sampler def _sample(logits: Any) -> Any: return sampler(_apply_suppress_token_ids(logits, suppress_tuple)) return _sample @dataclass class _PreflightRejection: """Typed return for ``_preflight_memory_check`` / its token-count helper. Carries the human-readable diagnostic plus the numeric estimated / limit bytes so callers can populate ``PrefillMemoryExceededError`` without parsing the string. Same shim as ``preflight_or_raise`` (restored on main after the upstream merge dropped it); the typed shape is what ``tests/test_scheduler_prefill_memory_guard.py`` asserts against, and what PR #1452 carries upstream. """ message: str estimated_bytes: int limit_bytes: int @dataclass class _VLMMTPDecodeState: """Per-request state for vlm_mtp decode that bypasses BatchGenerator. The wrapper generator yields plain Python ints (single-request mode). Scheduler iterates it one token per ``step()`` and feeds each token into ``_process_batch_responses`` via a synthesized ``_VLMMTPResponse``. """ generator: Any # Generator[int, None, None] from run_vlm_mtp_decode request: Request prompt_cache: list[Any] sampler: Callable[[Any], Any] state_machine: Any max_tokens: int # Plain stop-token set (EOS + request-specific) for direct membership # check; mlx-lm's SequenceStateMachine doesn't expose a "did the last # token finish" helper, so we keep a copy. stop_token_ids: set[int] = field(default_factory=set) emitted: int = 0 finished: bool = False @dataclass class _VLMMTPResponse: """BatchGenerator.Response shim emitted by the vlm_mtp decode loop. Same field surface used by ``_process_batch_responses``: ``uid``, ``token``, ``finish_reason``, ``logprobs``, and an optional ``prompt_cache`` returned on the terminal yield so paged-cache reuse keeps working. """ uid: int token: int finish_reason: Optional[str] = None logprobs: Any = None prompt_cache: Any = None # Serializes Metal buffer-protocol access from the async store-cache worker # against inference-thread mx.clear_cache / mx.synchronize calls that can # invalidate the underlying buffer pool. Closes a SIGABRT path where # _async_store_cache_worker reads tensor bytes via memoryview while the # inference thread concurrently issues a reclaim-triggering mx op. # See: https://github.com/jundot/omlx/issues/1106 _mx_buffer_access_lock = threading.RLock() def _sync_and_clear_cache(stream=None): """Synchronize in-flight GPU work before clearing the Metal buffer cache. Without synchronization, mx.clear_cache() can release Metal buffers that are still referenced by in-flight command buffers submitted via mx.async_eval(). This causes the GPU driver to hit a 'completeMemory() prepare count underflow' kernel panic on M4 hardware (and SIGSEGV/SIGABRT on M3). Held under _mx_buffer_access_lock so the async store-cache worker cannot observe a half-reclaimed Metal buffer pool while it is in the middle of reading tensor bytes via the Python buffer protocol (#1106). See: https://github.com/jundot/omlx/issues/300, #888, #1106 """ with _mx_buffer_access_lock: # The engine stream may not have in-flight work on the current thread # (for example, during teardown before that thread submits work). On # some MLX builds mx.synchronize raises "There is no Stream(gpu, 0) in # current thread" in that case; swallow it since there is nothing to # drain. target = stream if stream is not None else _default_generation_stream try: mx.synchronize(target) except RuntimeError: pass mx.synchronize() # default stream mx.clear_cache() def _safe_sync_stream(stream=None): """mx.synchronize(stream) that tolerates cross-thread calls. The per-engine stream is owned by the engine's executor thread. Teardown paths that run on the main thread (via EngineCore.close) hit "no Stream in current thread" RuntimeError. Swallow that specific case so cleanup can proceed; re-raise anything else so real GPU errors stay visible. """ target = stream if stream is not None else _default_generation_stream try: mx.synchronize(target) except RuntimeError as e: if "no Stream" not in str(e): raise def _env_int(name: str, default: int = 0) -> int: value = os.environ.get(name) if value is None or value == "": return default try: return int(value) except ValueError: logger.warning("Ignoring invalid integer env %s=%r", name, value) return default def _collect_mx_arrays(value, out: list[mx.array]) -> None: if isinstance(value, mx.array): out.append(value) elif isinstance(value, dict): for item in value.values(): _collect_mx_arrays(item, out) elif isinstance(value, (list, tuple)): for item in value: _collect_mx_arrays(item, out) def _eval_generation_batch_cache(batch_generator) -> int: generation_batch = getattr(batch_generator, "_generation_batch", None) prompt_cache = getattr(generation_batch, "prompt_cache", None) if not prompt_cache: return 0 arrays: list[mx.array] = [] for cache in prompt_cache: state = getattr(cache, "state", None) if state is not None: _collect_mx_arrays(state, arrays) if arrays: mx.eval(*arrays) return len(arrays) class _StoreCacheGate: """Non-blocking counter that bounds in-flight store-cache submissions. Tracks how many KV caches are alive in the post-completion store-cache pipeline. _cleanup_finished records each submission with note_submitted() and _drain_pending_async_removes clears it with note_done() after the deferred batch_generator.remove() releases the request cache references; neither blocks the generation step. Backpressure is applied at admission instead — _schedule_waiting declines to admit new prefills while in_flight >= cap (see has_capacity), so token generation never stalls waiting for an SSD write (#1496). cap still bounds the concurrent extracted-KV count, which is the OOM guard for the burst-finish RAM growth reported in #1383. It is adjusted at runtime from ProcessMemoryEnforcer so the pipeline shrinks under memory pressure on smaller systems. """ def __init__(self, cap: int) -> None: self._cap = max(1, cap) self._in_flight = 0 self._lock = threading.Lock() def note_submitted(self) -> None: """Record a store-cache job handed to the executor (never blocks).""" with self._lock: self._in_flight += 1 def note_done(self) -> None: """Record a store-cache job finished (future done callback).""" with self._lock: if self._in_flight > 0: self._in_flight -= 1 def set_cap(self, cap: int) -> None: with self._lock: self._cap = max(1, cap) @property def cap(self) -> int: with self._lock: return self._cap @property def in_flight(self) -> int: with self._lock: return self._in_flight @property def has_capacity(self) -> bool: """True when another submission would stay within cap. Read by _schedule_waiting to decide whether to admit a new prefill. """ with self._lock: return self._in_flight < self._cap # Import tiered cache components try: from .cache.boundary_snapshot_store import BoundarySnapshotSSDStore from .cache.paged_ssd_cache import PagedSSDCacheManager from .memory_monitor import MemoryMonitor, estimate_mla_kv_bytes_per_token HAS_TIERED_CACHE = True except ImportError: PagedSSDCacheManager = None BoundarySnapshotSSDStore = None MemoryMonitor = None estimate_mla_kv_bytes_per_token = None HAS_TIERED_CACHE = False # Import cache type handlers for hybrid cache support try: from .cache.hybrid_cache import ModelCacheConfig from .cache.type_registry import CacheTypeRegistry HAS_CACHE_TYPE_HANDLERS = True except ImportError: CacheTypeRegistry = None ModelCacheConfig = None HAS_CACHE_TYPE_HANDLERS = False # Import protocol-specific output parser support try: from .adapter.output_parser import ( OutputParserFactory, OutputParserSession, detect_output_parser, ) HAS_OUTPUT_PARSER = True except ImportError: OutputParserFactory = None OutputParserSession = None detect_output_parser = None HAS_OUTPUT_PARSER = False logger = logging.getLogger(__name__) class _PrefillAbortedError(Exception): """Raised when prefill is interrupted by a pending abort.""" def __init__(self, aborted_uids: list[int], processed_tokens: int): self.aborted_uids = aborted_uids self.processed_tokens = processed_tokens super().__init__( f"Prefill aborted for UIDs {aborted_uids} " f"at {processed_tokens} tokens" ) @dataclass class PrefillEvictionRequest: """Internal request for async LRU model eviction before prefill.""" request_id: str model_id: str current_bytes: int target_cap_bytes: int predicted_transient_bytes: int requested_tokens: int reason: str class _PrefillEvictionNeeded(Exception): """Raised inside scheduler.step() to pause and request async eviction.""" def __init__(self, request: PrefillEvictionRequest): super().__init__(request.reason) self.request = request def _prefill_memory_error_output( request_id: str, message: str, *, estimated_bytes: int | None = None, limit_bytes: int | None = None, ) -> RequestOutput: metadata: dict[str, int | str] = {"request_id": request_id} if estimated_bytes is not None: metadata["estimated_bytes"] = estimated_bytes if limit_bytes is not None: metadata["limit_bytes"] = limit_bytes return RequestOutput( request_id=request_id, finished=True, finish_reason="error", error=message, error_code="prefill_memory_exceeded", error_metadata=metadata, ) def _prefill_memory_exception_output( request_id: str, exc: PrefillMemoryExceededError, ) -> RequestOutput: return _prefill_memory_error_output( request_id, str(exc), estimated_bytes=exc.estimated_bytes, limit_bytes=exc.limit_bytes, ) @dataclass class _PrefillState: """Intermediate state for a request undergoing chunked prefill. When chunked_prefill=True, a long prefill is spread across multiple step() calls (one prefill_step_size chunk per step). This dataclass holds all the state needed to resume prefill between steps. """ request: Any cache: list # Accumulated prompt_cache (mutated in-place by each chunk) tokens_remaining: Any # mx.array shape (1, N) — tokens not yet prefilled last_token: list # tokens[-1:] — passed to batch_generator.insert() tokens_processed: int # Cumulative count for boundary snapshot math base_size: int # Prefix cache offset at prefill start (for alignment) emitted_boundaries: dict # {request_id: int} — last emitted boundary count boundary_enabled: bool # Whether boundary snapshots are active block_size: int # Copied from config.paged_cache_block_size total_length: int # len(original tokens) for completeness # Pre-built insert-time params (set by _schedule_waiting before enqueuing) sampler: Any = None sm: Any = None per_row_lps: Any = None @dataclass class _InflightStoreInfo: tokens: list[int] extra_keys: tuple[Any, ...] | None = None extra_key_token_start: int | None = None extra_key_ranges: list[tuple[int, tuple[Any, ...]]] | None = None @dataclass class _CacheFreshnessWait: store_request_id: str future: concurrent.futures.Future common_prefix: int prompt_len: int deadline_s: float # --------------------------------------------------------------------------- # Monkey-patch GenerationBatch._step to call grammar accept_token() after # sampling. In the pipelined _step(), logits processors fill the bitmask # (constrain NEXT token) but can't know which token was just sampled. # After _original_step returns, self._next_tokens holds the freshly sampled # tokens. We eval them synchronously and accept in grammar processors. # --------------------------------------------------------------------------- # Authoritative per-uid row state for the generation batch. # # mlx-lm keeps ``samplers`` / ``logits_processors`` as positional lists that # must stay aligned with ``uids``. Heterogeneous continuous batching # (extend/filter/split across prompt and generation batches) can leave stale # or offset row slots behind; #1799 made the step crash-safe by normalising # ``None`` slots, but a misaligned row silently runs the WRONG sampler and # logits processors (e.g. a grammar/thinking-budget request decoding with no # constraints at all). The registry below records, at insert time, what each # uid is supposed to run; the step chokepoint realigns the positional lists # from it. Bounded so a missing cleanup can never grow it unbounded. class _RegisteredRow(NamedTuple): """What a uid is supposed to run, recorded at request insert.""" sampler: Any logits_processors: list _UID_ROW_REGISTRY_MAX = 4096 # Keyed by (id(model), uid): mlx-lm's BatchGenerator numbers uids per # instance starting at 0, so two engines serving concurrently (or an engine # reload) produce colliding uid sequences. The model object is the one # identity both the insert sites and the step chokepoint can see. _uid_row_registry: "OrderedDict[tuple[int, int], _RegisteredRow]" = OrderedDict() # Engines run on separate executor threads and share this module-level # registry; a plain OrderedDict is not safe under concurrent mutation. _uid_row_registry_lock = threading.Lock() # Drift corrections are worth one log line each, but a pathological batching # pattern could correct on every merge; cap the WARNING rate and route the # rest to DEBUG so the signal survives without flooding the logs. _UID_ROW_DRIFT_WARNING_INTERVAL_S = 60.0 _uid_row_drift_last_warning = float("-inf") def _register_uid_rows(model, uids, samplers, lps_rows) -> None: """Record the sampler and logits processors each freshly-inserted uid must run. Each (model, uid) key is inserted exactly once per request, so plain insertion order is enough for the oldest-first backstop eviction. """ with _uid_row_registry_lock: for uid, sampler, lps in zip(uids, samplers, lps_rows): _uid_row_registry[(id(model), uid)] = _RegisteredRow( sampler, list(lps or ()) ) while len(_uid_row_registry) > _UID_ROW_REGISTRY_MAX: _uid_row_registry.popitem(last=False) def _unregister_uid_row(model, uid) -> None: """Drop a finished request's row so heavy processors are not pinned until FIFO eviction; the bounded size stays as the backstop.""" with _uid_row_registry_lock: _uid_row_registry.pop((id(model), uid), None) def _unregister_uid_rows_for_model(model) -> None: """Drop every registry row for a model (generator reset, recovery, shutdown). The recovery and reset paths clear the uid maps wholesale instead of finishing requests one by one; releasing by model covers them, and leaves nothing behind that a later engine load could match if ``id(model)`` were recycled. """ model_id = id(model) with _uid_row_registry_lock: for key in [key for key in _uid_row_registry if key[0] == model_id]: del _uid_row_registry[key] def _row_drifted(current_lps, expected_lps) -> bool: """True when a slot's processors genuinely differ from the registered row. Two distinct empty lists are equivalent — the #1799 normalisation mints fresh ``[]`` objects every step — so only differing content counts. The caller's identity check is the steady-state fast path; this only runs past it. """ if not current_lps and not expected_lps: return False return current_lps != expected_lps def _log_drift_correction(uids, slot_count) -> None: """Log a corrected drift: one WARNING per window, the rest at DEBUG.""" global _uid_row_drift_last_warning now = time.monotonic() rate_limited = now - _uid_row_drift_last_warning < _UID_ROW_DRIFT_WARNING_INTERVAL_S if not rate_limited: _uid_row_drift_last_warning = now (logger.debug if rate_limited else logger.warning)( "Realigned generation-batch row state from the uid registry " f"(uids={list(uids)}, had {slot_count} processor slots); " "stale or offset slots would have run the wrong sampler/processors." ) def _realigned_rows(model, uids, cur_samplers, cur_lps): """Rebuild the positional row lists in uid order from the registry. Registered uids take their recorded row; unregistered uids keep their current slot (the #1799 fallback), padding when the lists are shorter than ``uids``. Returns ``(samplers, logits_processors, drift)`` — drift only drives logging, the rebuilt lists are always installed. In steady state the slots already are the registry lists, so the identity check skips any comparison work. """ model_id = id(model) with _uid_row_registry_lock: rows = [_uid_row_registry.get((model_id, uid)) for uid in uids] drift = len(cur_lps) != len(uids) samplers, lps = [], [] for i, row in enumerate(rows): if row is not None: if not drift: if i >= len(cur_samplers): drift = row.sampler is not None elif cur_samplers[i] is not row.sampler: drift = True if ( not drift and i < len(cur_lps) and cur_lps[i] is not row.logits_processors ): drift = _row_drifted(cur_lps[i], row.logits_processors) samplers.append(row.sampler) lps.append(row.logits_processors) else: samplers.append(cur_samplers[i] if i < len(cur_samplers) else None) lps.append(cur_lps[i] if i < len(cur_lps) else []) return samplers, lps, drift def _omlx_realign_generation_batch_rows(self) -> None: """Realign positional row state with ``uids`` before any decode path reads it.""" if self.logits_processors is None: self.logits_processors = [] else: self.logits_processors = [ procs if procs is not None else [] for procs in self.logits_processors ] uids = getattr(self, "uids", None) or [] if not uids: return new_samplers, new_lps, drift = _realigned_rows( getattr(self, "model", None), uids, getattr(self, "samplers", None) or [], self.logits_processors, ) if drift: _log_drift_correction(uids, len(self.logits_processors)) self.logits_processors = new_lps self.samplers = new_samplers _original_generation_batch_step = GenerationBatch._step def _patched_generation_batch_step(self): # Build per-batch mRoPE deltas from UID mapping before each step. # This handles batch size changes during prompt split/generate. model = self.model if ( getattr(model, "_uses_mrope", False) and getattr(model, "_uid_rope_deltas", None) and self.uids ): deltas = [model._uid_rope_deltas.get(uid, 0.0) for uid in self.uids] model.set_batch_rope_deltas(mx.array(deltas)) # Defensive: mlx-lm's GenerationBatch._step does `any(self.logits_processors)` # and `for p in self.logits_processors[e]`, both of which crash when a row # slot is None. Normalise the whole list AND every per-row slot to [] here, # at the single consumption chokepoint, so the original step and the # grammar-accept loop below are both safe regardless of slot origin. # # The insert call sites already wrap each request's processors as a list, # but that is not enough: on a heterogeneous continuous-batch merge, # mlx-lm's GenerationBatch.extend() re-introduces None slots via # `if not any(self.logits_processors): self.logits_processors = # [None] * len(self.uids)`. `any([[], []])` is False, so empty-list slots # collapse back to None whenever a batch with no *active* processor merges # with a grammar-constrained one (e.g. a plain chat request joining a batch # that is serving a structured json_schema request). Per-row normalisation # at this chokepoint is the only place that covers both insert and merge. # See #934 / #1747. _omlx_realign_generation_batch_rows(self) result = _original_generation_batch_step(self) # self._next_tokens contains the just-sampled tokens (async eval pending). # We need to accept them NOW so the next __call__ fills the correct bitmask. if any(self.logits_processors): from .api.grammar import GrammarConstraintProcessor has_grammar = any( isinstance(p, GrammarConstraintProcessor) for procs in self.logits_processors for p in procs ) if has_grammar: # Force eval of the sampled tokens so we can read them. mx.eval(self._next_tokens) sampled = self._next_tokens.tolist() for e in range(len(self.uids)): for proc in self.logits_processors[e]: if isinstance(proc, GrammarConstraintProcessor): proc.accept_token(sampled[e]) return result GenerationBatch._omlx_realign_rows = _omlx_realign_generation_batch_rows GenerationBatch._step = _patched_generation_batch_step # --------------------------------------------------------------------------- # Monkey-patch GenerationBatch.filter to keep logits_processors aligned with # uids. mlx-lm's filter only reindexes the processor list when at least one # row has an active processor: # # if any(self.logits_processors): # self.logits_processors = [self.logits_processors[idx] for idx in keep] # # There is no else branch (unlike the prompt-batch class, which resets to # ``[[]] * len(keep)``), so when every slot is empty — the normal state after # serving requests without per-request processors — the stale list survives # while uids/tokens shrink. A later extend() then appends the next request's # processors BEHIND its own row index: the row reads a leftover empty slot and # the real processor (thinking budget, grammar constraint) is silently never # applied. Which requests are affected depends on insertion/removal order, # and alignment self-heals once the broken request finishes, so the symptom # is an intermittently ignored thinking_budget or grammar. See #934/#1747 # for the sibling None-slot collapse handled in _patched_generation_batch_step. _original_generation_batch_filter = GenerationBatch.filter def _patched_generation_batch_filter(self, keep): lps = self.logits_processors lps_inert = not lps or not any(lps) if lps is None: # ``any(None)`` inside the original filter raises TypeError. self.logits_processors = [] _original_generation_batch_filter(self, keep) if lps_inert: # Original filter skipped the reindex; reset to one empty slot per # surviving row so extend() appends at the correct indices. self.logits_processors = [[] for _ in keep] GenerationBatch.filter = _patched_generation_batch_filter _TQ_SINGLETON_CACHE_TYPE: type[Any] | None = None # Monkey-patch TurboQuantKVCache.merge so _merge_caches() works try: from mlx_vlm.turboquant import TurboQuantKVCache as _TQCache from .turboquant_kv import BatchTurboQuantKVCache as _BTQCache _TQ_SINGLETON_CACHE_TYPE = _TQCache if not hasattr(_TQCache, "merge"): _TQCache.merge = _BTQCache.merge except ImportError: pass # Regular singleton KV caches are already the fastest decode representation. # mlx-lm's default _merge_caches([cache]) turns them into BatchKVCache even # when there is only one active row, which slows text-only VLM decode. Install # the minimal BatchGenerator methods needed while the row count remains one; # _patched_extend_cache converts them back to batched caches before a second # row is appended. def _batch_indices_len(batch_indices: Any) -> int: try: return len(batch_indices) except TypeError: return int(getattr(batch_indices, "shape", (0,))[0] or 0) def _regular_kv_filter_singleton(self, batch_indices): n = _batch_indices_len(batch_indices) if n == 0: self.keys = None self.values = None self.offset = 0 return if n == 1: return raise NotImplementedError( f"{type(self).__name__}.filter only supports singleton pass-through; " "convert to a batched cache before keeping multiple rows." ) def _regular_rotating_kv_filter_singleton(self, batch_indices): n = _batch_indices_len(batch_indices) if n == 0: self.keys = None self.values = None self.offset = 0 self._idx = 0 return if n == 1: return raise NotImplementedError( f"{type(self).__name__}.filter only supports singleton pass-through; " "convert to a batched cache before keeping multiple rows." ) def _regular_cache_extract_singleton(self, idx: int): if int(idx) != 0: raise IndexError(f"{type(self).__name__} singleton cache only has row 0") return self def _regular_cache_extend_singleton(self, other): raise NotImplementedError( f"{type(self).__name__}.extend requires batched conversion first" ) def _turboquant_filter_singleton(self, batch_indices): n = _batch_indices_len(batch_indices) if n == 0: self.keys = None self.values = None self.offset = 0 self._cached_state = None self._cached_state_offset = -1 if hasattr(self, "_shadow_keys"): self._shadow_keys = None if hasattr(self, "_shadow_values"): self._shadow_values = None return if n == 1: return raise NotImplementedError( f"{type(self).__name__}.filter only supports singleton pass-through; " "convert to a batched cache before keeping multiple rows." ) if not hasattr(_MLXKVCache, "filter"): _MLXKVCache.filter = _regular_kv_filter_singleton if not hasattr(_MLXKVCache, "extract"): _MLXKVCache.extract = _regular_cache_extract_singleton if not hasattr(_MLXKVCache, "extend"): _MLXKVCache.extend = _regular_cache_extend_singleton if not hasattr(_MLXRotatingKVCache, "filter"): _MLXRotatingKVCache.filter = _regular_rotating_kv_filter_singleton if not hasattr(_MLXRotatingKVCache, "extract"): _MLXRotatingKVCache.extract = _regular_cache_extract_singleton if not hasattr(_MLXRotatingKVCache, "extend"): _MLXRotatingKVCache.extend = _regular_cache_extend_singleton if _TQ_SINGLETON_CACHE_TYPE is not None: if not hasattr(_TQ_SINGLETON_CACHE_TYPE, "filter"): _TQ_SINGLETON_CACHE_TYPE.filter = _turboquant_filter_singleton if not hasattr(_TQ_SINGLETON_CACHE_TYPE, "extract"): _TQ_SINGLETON_CACHE_TYPE.extract = _regular_cache_extract_singleton if not hasattr(_TQ_SINGLETON_CACHE_TYPE, "extend"): _TQ_SINGLETON_CACHE_TYPE.extend = _regular_cache_extend_singleton _mlx_lm_generate_module = importlib.import_module("mlx_lm.generate") _original_merge_caches = _mlx_lm_generate_module._merge_caches _original_ppb_split = PromptProcessingBatch.split _REGULAR_SINGLETON_CACHE_TYPES = (_MLXKVCache, _MLXRotatingKVCache) def _cache_layer_supports_singleton_passthrough(cache_obj: Any) -> bool: sub_caches = getattr(cache_obj, "caches", None) if isinstance(sub_caches, (list, tuple)): return all(_cache_layer_supports_singleton_passthrough(c) for c in sub_caches) return hasattr(cache_obj, "filter") and hasattr(cache_obj, "extract") def _to_batched_cache_layer(cache_obj: Any) -> Any: sub_caches = getattr(cache_obj, "caches", None) if isinstance(sub_caches, (list, tuple)): converted = tuple(_to_batched_cache_layer(c) for c in sub_caches) if all(a is b for a, b in zip(sub_caches, converted)): return cache_obj return type(cache_obj)(*converted) if isinstance(cache_obj, _REGULAR_SINGLETON_CACHE_TYPES): return cache_obj.merge([cache_obj]) if ( _TQ_SINGLETON_CACHE_TYPE is not None and type(cache_obj) is _TQ_SINGLETON_CACHE_TYPE ): return cache_obj.merge([cache_obj]) return cache_obj def _extend_cache_layer(cache_a: Any, cache_b: Any) -> Any: sub_a = getattr(cache_a, "caches", None) sub_b = getattr(cache_b, "caches", None) if isinstance(sub_a, (list, tuple)) and isinstance(sub_b, (list, tuple)): cache_a.caches = tuple( _extend_cache_layer(ca, cb) for ca, cb in zip(sub_a, sub_b) ) return cache_a cache_a = _to_batched_cache_layer(cache_a) cache_b = _to_batched_cache_layer(cache_b) cache_a.extend(cache_b) return cache_a def _patched_merge_caches(caches): if not caches: return [] if len(caches) == 1: merged = [] for layer_cache in caches[0]: if _cache_layer_supports_singleton_passthrough(layer_cache): merged.append(layer_cache) elif hasattr(layer_cache, "merge"): merged.append(layer_cache.merge([layer_cache])) else: raise ValueError( f"{type(layer_cache)} does not yet support batching with history" ) return merged return _original_merge_caches(caches) def _patched_extend_cache(cache_a, cache_b): if not cache_a: return cache_b if not cache_b: return cache_a return [_extend_cache_layer(ca, cb) for ca, cb in zip(cache_a, cache_b)] def _patched_ppb_split(self, indices): sorted_indices = sorted(indices) if sorted_indices and sorted_indices == list(range(len(self.uids))): new_batch = self.__class__.__new__(self.__class__) new_batch.model = self.model new_batch.uids = self.uids new_batch.prompt_cache = self.prompt_cache new_batch.tokens = self.tokens new_batch.prefill_step_size = self.prefill_step_size new_batch.samplers = self.samplers new_batch.fallback_sampler = self.fallback_sampler # Defensive: normalise None → [] to avoid mlx-lm crash in _step lps = self.logits_processors if self.logits_processors is not None else [] new_batch.logits_processors = lps new_batch.state_machines = self.state_machines new_batch.max_tokens = self.max_tokens if hasattr(self, "_omlx_glm_dsa_adaptive_prefill"): new_batch._omlx_glm_dsa_adaptive_prefill = ( self._omlx_glm_dsa_adaptive_prefill ) self.uids = [] self.prompt_cache = [] self.tokens = [] self.samplers = [] self.logits_processors = [] self.state_machines = [] self.max_tokens = [] return new_batch return _original_ppb_split(self, indices) _mlx_lm_generate_module._merge_caches = _patched_merge_caches _mlx_lm_generate_module._extend_cache = _patched_extend_cache PromptProcessingBatch.split = _patched_ppb_split # Monkey-patch ChunkedKVCache for Llama-4 (Scout / Maverick): mlx_lm's # ChunkedKVCache lacks the batch-aware methods (`merge`, `filter`, `extract`, # `size`, `extend`) that BatchGenerator's continuous-batching code path # expects, so any chat completion targeting a Llama-4 model raises # `Cache corruption not recoverable: does not yet support # batching with history` and returns 500. # # Real continuous batching with chunked attention is unimplemented upstream; # this patch installs batch=1 pass-throughs so serialized requests work. # Run the server with `--max-concurrent-requests 1` to honor the assumption. try: from mlx_lm.models.cache import ChunkedKVCache as _CKVCache _ckvcache_methods_skipped: list[str] = [] if not hasattr(_CKVCache, "merge"): @classmethod def _ckvcache_merge_passthrough(cls, caches): if len(caches) == 1: return caches[0] raise NotImplementedError( "ChunkedKVCache.merge for batch_size > 1 is not implemented. " "Run with --max-concurrent-requests 1 when serving Llama-4." ) _CKVCache.merge = _ckvcache_merge_passthrough else: _ckvcache_methods_skipped.append("merge") if not hasattr(_CKVCache, "filter"): def _ckvcache_filter_passthrough(self, batch_indices): try: n = len(batch_indices) except TypeError: n = int(getattr(batch_indices, "shape", (0,))[0] or 0) if n == 0: self.keys = None self.values = None self.offset = 0 self.start_position = 0 return if n == 1: return raise NotImplementedError( f"ChunkedKVCache.filter with batch_size={n} > 1 is not " "implemented. Run with --max-concurrent-requests 1 when " "serving Llama-4." ) _CKVCache.filter = _ckvcache_filter_passthrough else: _ckvcache_methods_skipped.append("filter") if not hasattr(_CKVCache, "extract"): def _ckvcache_extract_passthrough(self, idx): return self _CKVCache.extract = _ckvcache_extract_passthrough else: _ckvcache_methods_skipped.append("extract") if not hasattr(_CKVCache, "size"): def _ckvcache_size(self): return max(0, self.offset - self.start_position) _CKVCache.size = _ckvcache_size else: _ckvcache_methods_skipped.append("size") if not hasattr(_CKVCache, "extend"): def _ckvcache_extend_passthrough(self, other): if other is None or other.empty(): return if self.empty(): self.keys = other.keys self.values = other.values self.offset = other.offset self.start_position = other.start_position return raise NotImplementedError( "ChunkedKVCache.extend across non-empty caches is not " "supported. Run with --max-concurrent-requests 1." ) _CKVCache.extend = _ckvcache_extend_passthrough else: _ckvcache_methods_skipped.append("extend") if _ckvcache_methods_skipped: # Upstream may have landed implementations between mlx_lm upgrades. # Surface which ones so a regression in Llama-4 batching is visible # to operators without diffing the patch against installed mlx_lm. logger.info( "ChunkedKVCache patch: methods already present upstream, " "skipped: %s", ", ".join(_ckvcache_methods_skipped), ) except ImportError: pass # --------------------------------------------------------------------------- # Monkey-patch PromptProcessingBatch.prompt to set mRoPE deltas before the # prompt processing loop. Without this, batched VLM prompt processing # (e.g. the 1-token final prompt after external prefill) would use # per-request offsets without rope_deltas, corrupting attention masks # for concurrent VLM requests. # --------------------------------------------------------------------------- _original_ppb_prompt = PromptProcessingBatch.prompt def _patched_ppb_prompt(self, tokens): model = self.model if ( getattr(model, "_uses_mrope", False) and getattr(model, "_uid_rope_deltas", None) and self.uids ): deltas = [model._uid_rope_deltas.get(uid, 0.0) for uid in self.uids] model.set_batch_rope_deltas(mx.array(deltas)) return _original_ppb_prompt(self, tokens) PromptProcessingBatch.prompt = _patched_ppb_prompt # Cache class names known to be sliceable (no boundary snapshots needed). # ChunkedKVCache is included once the batch=1 patch above installs its # extract/filter/size pass-throughs; without it Llama-4 requests fall # back to the snapshot path unnecessarily. _KNOWN_SLICEABLE_CACHE_TYPES = frozenset( { "KVCache", "BatchKVCache", "QuantizedKVCache", "TurboQuantKVCache", "BatchTurboQuantKVCache", "ChunkedKVCache", "MiniMaxM3KVCache", } ) _TURBOQUANT_KV_CACHE_TYPES = frozenset( { "TurboQuantKVCache", "BatchTurboQuantKVCache", } ) def _is_turboquant_kv_cache(cache_obj: Any) -> bool: return type(cache_obj).__name__ in _TURBOQUANT_KV_CACHE_TYPES def _is_turboquant_kv_family_cache(cache_obj: Any) -> bool: """Cache layer counted by TurboQuant's skip-last full-attention rule.""" return isinstance(cache_obj, _MLXKVCache) or _is_turboquant_kv_cache(cache_obj) class _BoundaryStoreUnavailable(Exception): """No boundary-aligned snapshot exists for non-sliceable cache state. Raised inside the final-response store path when the model needs boundary snapshots but none were captured (e.g. every capture was skipped by the speculative-decode skew guard). Storing the live state instead would label off-boundary recurrent/rotating state with a block-boundary token count and corrupt later prefix hits; the handler skips the store and releases the block references. """ def _first_leaf_cache_offset(cache_obj: Any) -> int | None: """First integer ``offset`` found walking into composite caches. CacheList-style wrappers (DeepSeek-V4 / GLM per-layer caches) expose no ``offset`` themselves — the token position lives on their leading sub-cache (RotatingKVCache / KVCache, whose ``offset`` counts forwarded tokens). Walk depth-first so that leading leaf decides; later sub-caches may count something else entirely (PoolingCache's ``offset`` is the pooled-window count). """ subs = getattr(cache_obj, "caches", None) if isinstance(subs, (list, tuple)): for sub in subs: offset = _first_leaf_cache_offset(sub) if offset is not None: return offset return None offset = getattr(cache_obj, "offset", None) if offset is None: return None try: return int(offset) except Exception: return None def _prompt_cache_needs_snapshots(prompt_cache: list[Any]) -> bool: """Return True if any layer cache is non-sliceable (needs snapshots). Checks the cache objects created during prefill. If all layers are known-sliceable types (e.g. KVCache), boundary snapshots are unnecessary and can be skipped entirely. """ for cache_obj in prompt_cache: sub_caches = getattr(cache_obj, "caches", None) if isinstance(sub_caches, (list, tuple)): for sub in sub_caches: if type(sub).__name__ not in _KNOWN_SLICEABLE_CACHE_TYPES: return True elif type(cache_obj).__name__ not in _KNOWN_SLICEABLE_CACHE_TYPES: return True return False def _batch_generator_all_tokens(request: Any) -> list[int]: """Seed tokens for mlx-lm's TokenBuffer before the kickoff token.""" token_ids = getattr(request, "prompt_token_ids", None) if token_ids is None: return [] return list(token_ids[:-1]) def _cache_layer_token_count(cache_obj: Any) -> int: """Return the number of tokens stored in a single cache layer.""" sub_caches = getattr(cache_obj, "caches", None) if isinstance(sub_caches, (list, tuple)) and sub_caches: return max(_cache_layer_token_count(sub_cache) for sub_cache in sub_caches) offset = getattr(cache_obj, "offset", None) if isinstance(offset, (int, float)): return int(offset) size_fn = getattr(cache_obj, "size", None) if callable(size_fn): try: return int(size_fn()) except Exception: return 0 return 0 def _cache_base_sizes(caches: list[Any]) -> int: """Return the base token count of a single-request cache list.""" if not caches: return 0 try: return max(_cache_layer_token_count(c) for c in caches) except Exception: return 0 def _collect_cache_storage_arrays(cache_obj: Any) -> list[mx.array]: """Collect concrete backing arrays from cache objects, not state slices.""" arrays: list[mx.array] = [] if isinstance(cache_obj, mx.array): return [cache_obj] sub_caches = getattr(cache_obj, "caches", None) if isinstance(sub_caches, (list, tuple)): for sub_cache in sub_caches: arrays.extend(_collect_cache_storage_arrays(sub_cache)) array_cache = getattr(cache_obj, "cache", None) if isinstance(array_cache, (list, tuple)): for item in array_cache: arrays.extend(_collect_cache_storage_arrays(item)) for attr in ("keys", "values", "left_padding", "lengths"): value = getattr(cache_obj, attr, None) if isinstance(value, mx.array): arrays.append(value) return arrays def _materialize_cache_storage(cache_list: list[Any]) -> None: """Force restored cache backing arrays concrete before decode begins.""" arrays: list[mx.array] = [] for cache_obj in cache_list: arrays.extend(_collect_cache_storage_arrays(cache_obj)) if arrays: with _mx_buffer_access_lock: mx.eval(*arrays) def _seed_text_only_mrope_delta_for_cached_prefill(model: Any, request: Any) -> None: """Seed zero mRoPE delta after clearing text-only cached-prefix state.""" if getattr(request, "cached_tokens", 0) <= 0: return lm = getattr(model, "_language_model", None) if lm is None or not hasattr(lm, "_rope_deltas"): return lm._rope_deltas = mx.zeros((1, 1), dtype=mx.int64) def _vlm_extra_seq_slice(val: mx.array, s: slice) -> mx.array: """Slice a VLM extra tensor along its seq dimension. Standard layout (batch=1, seq, ...): seq at dim 1. Special layout (e.g. mRoPE (3, batch, seq)): seq at last dim. """ if val.ndim >= 3 and val.shape[0] == 1: return val[:, s] if val.ndim >= 3: return val[..., s] return val[:, s] def _slice_vlm_extra(extra: dict[str, Any], n: int) -> dict[str, Any]: """Slice VLM extra kwargs to first n tokens along seq dimension.""" sliced: dict[str, Any] = {} for key, val in extra.items(): if isinstance(val, mx.array) and val.ndim >= 2: sliced[key] = _vlm_extra_seq_slice(val, slice(None, n)) else: sliced[key] = val return sliced def _advance_vlm_extra(extra: dict[str, Any], n: int) -> dict[str, Any]: """Advance VLM extra kwargs past first n tokens along seq dimension.""" advanced: dict[str, Any] = {} for key, val in extra.items(): if isinstance(val, mx.array) and val.ndim >= 2: advanced[key] = _vlm_extra_seq_slice(val, slice(n, None)) else: advanced[key] = val return advanced def _get_attr_or_key(obj: Any, name: str) -> Any: if isinstance(obj, dict): return obj.get(name) try: value = getattr(obj, name) except Exception: return None if type(value).__module__.startswith("unittest.mock"): return None return value def _model_declares_llama4(model: Any) -> bool: """Return True if the loaded model/config tree declares Llama 4.""" seen: set[int] = set() stack = [model] while stack: obj = stack.pop() if obj is None: continue obj_id = id(obj) if obj_id in seen: continue seen.add(obj_id) if _get_attr_or_key(obj, "model_type") == "llama4": return True for attr in ("config", "args", "text_config", "language_config", "llm_config"): child = _get_attr_or_key(obj, attr) if child is not None and not isinstance( child, (str, bytes, int, float, bool) ): stack.append(child) return False class SchedulingPolicy(Enum): """Scheduling policy for request ordering.""" FCFS = "fcfs" # First-Come-First-Served PRIORITY = "priority" # Priority-based @dataclass class SchedulerConfig: """Configuration for the scheduler.""" # Maximum number of concurrent requests in the batch max_num_seqs: int = 256 # Maximum tokens to process per step (for prefill chunking) max_num_batched_tokens: int = 8192 # Scheduling policy policy: SchedulingPolicy = SchedulingPolicy.FCFS # BatchGenerator settings (passed directly to mlx-lm) completion_batch_size: int = 32 # Per-forward embedding input chunk size embedding_batch_size: int = 32 prefill_step_size: int = 2048 # When True, long prefills are processed one chunk per step() call, # interleaved with decode steps for already-running requests. This # reduces TTFT for concurrent requests but adds per-step overhead. chunked_prefill: bool = False # Paged cache settings (internal defaults) paged_cache_block_size: int = 256 # Tokens per block max_cache_blocks: int | None = ( None # Auto-calculated from available KV cache memory ) initial_cache_blocks: int = ( 256 # Starting blocks (grows dynamically to max_cache_blocks) ) # paged SSD cache settings (oMLX only supports paged SSD-based caching) # When paged_ssd_cache_dir is set, oMLX stores KV cache on paged SSD for prefix reuse. # When None, no oMLX caching (mlx-lm BatchGenerator manages KV internally). paged_ssd_cache_dir: str | None = ( None # Path for paged SSD cache storage (None = disabled) ) hot_cache_only: bool = False paged_ssd_cache_max_size: int = 100 * 1024 * 1024 * 1024 # 100GB default hot_cache_max_size: int = 0 # In-memory hot cache size in bytes (0 = disabled) hot_cache_budget: Any | None = None # Shared process-wide hot cache budget # Model identification (for cache isolation between different models) model_name: str = "" # OpenAI API model name (e.g., "mlx-community/Llama-3.2-3B") model_path: str = "" # Filesystem path to the model (e.g., "/cache/models--Org--Name/snapshots/abc123") # GC/cleanup settings (memory optimization) gc_cleanup_interval: int = 0 # Steps between gc.collect() calls (0=disabled) mlx_cache_cleanup_interval: int = 512 # Steps between mx.clear_cache() calls @dataclass class SchedulerOutput: """ Output from a scheduling step. Contains information about what was scheduled and results. """ # Requests scheduled in this step scheduled_request_ids: list[str] = field(default_factory=list) # Total tokens scheduled num_scheduled_tokens: int = 0 # Requests that finished in this step finished_request_ids: set[str] = field(default_factory=set) # Request outputs (tokens generated) outputs: list[RequestOutput] = field(default_factory=list) # Internal signal consumed by EngineCore; not part of any API response. prefill_eviction_request: PrefillEvictionRequest | None = None # Whether any work was done has_work: bool = False class _BoundarySnapshotProvider: """Dict-like loader for extracted boundary snapshots. Used by ``store_cache()`` to load snapshots from SSD one block at a time or serve pre-extracted in-memory snapshots. In-memory snapshots must already be in the ``_extract_cache_states`` dict format so this provider can be used safely from the async store-cache worker without touching raw MLX cache objects on the wrong thread. """ def __init__( self, store: Any, # Optional[BoundarySnapshotSSDStore] request_id: str, valid_tcs: list[int], in_memory_snapshots: dict[int, Any], ) -> None: self._store = store self._request_id = request_id self._valid_tcs = set(valid_tcs) self._in_memory = in_memory_snapshots def __contains__(self, tc: int) -> bool: return tc in self._valid_tcs def __getitem__(self, tc: int) -> Any: snap = self._in_memory.get(tc) if snap is not None: return snap if self._store is not None: return self._store.load(self._request_id, tc) return None def __len__(self) -> int: return len(self._valid_tcs) def __bool__(self) -> bool: return bool(self._valid_tcs) def iter_in_memory_extracted(self): """Yield pre-extracted in-memory snapshots for pre-evaluation.""" for tc in sorted(self._valid_tcs): snap = self._in_memory.get(tc) if snap is not None: yield snap class Scheduler: """ Scheduler for continuous batching using mlx-lm BatchGenerator. This scheduler manages the lifecycle of requests: 1. Requests arrive and are added to the waiting queue 2. Scheduler moves requests from waiting to running (via BatchGenerator) 3. BatchGenerator processes all running requests together 4. Finished requests are removed and outputs returned .. note:: ``_DEFERRED_CLEAR_DELAY`` controls how many generation steps to wait after the last request completion before calling ``mx.clear_cache()``. Immediate clearing races with IOKit's asynchronous ``completeMemory()`` callbacks, causing 'prepare count underflow' kernel panics (#435). 8 steps (~10-40 ms at typical generation speeds) gives IOKit ample time to process those callbacks while still reclaiming Metal buffers fast enough to prevent TTFT spikes (#411). The key insight is that mlx-lm's BatchGenerator already implements continuous batching at the token level, so we use it as the backend. """ _DEFERRED_CLEAR_DELAY: int = 8 _GENERATION_OVERFLOW_PATTERN = "__next_prime overflow" _MAX_GENERATION_OVERFLOW_RETRIES = 1 def __init__( self, model: Any, tokenizer: Any, config: SchedulerConfig | None = None, stream: Any | None = None, ): """ Initialize the scheduler. Args: model: The MLX model tokenizer: The tokenizer config: Scheduler configuration stream: Optional mx.Stream for this engine. Falls back to the module-level _default_generation_stream when not provided. """ self.model = model # Deep-copy the tokenizer so the scheduler owns an independent Rust # tokenizer backend. Without this, concurrent access from the asyncio # event loop (encode/apply_chat_template in engine handlers) and the # MLX executor thread (scheduler.step) causes # "RuntimeError: Already borrowed" from the HuggingFace tokenizers # Rust RefCell. See: https://github.com/huggingface/tokenizers/issues/537 self.tokenizer = copy.deepcopy(tokenizer) self.config = copy.copy(config) if config else SchedulerConfig() self._stream = stream if stream is not None else _default_generation_stream self._serialize_llama4_requests = _model_declares_llama4(model) if self._serialize_llama4_requests and self.config.max_num_seqs > 1: logger.info( "Llama 4 detected; serializing requests because ChunkedKVCache " "does not support multi-row batching yet" ) # Load additional EOS tokens from generation_config.json. # Some models (e.g. GLM-4.6V) define multiple EOS tokens there # that are not in tokenizer.eos_token_id. self._generation_config_eos: set[int] | None = ( self._load_generation_config_eos() ) # Load generation_config.suppress_tokens once and apply them on every # sampling path. Gemma 4 uses this to suppress multimodal close markers. self._model_suppress_tokens: set[int] = self._load_model_suppress_tokens() # For strict RotatingKVCache reuse, align paged cache block size to # the model's rotating window size when paged cache is enabled. self._align_block_size_with_rotating_window() # For ArraysCache-only models (no RotatingKVCache), use a larger block # size to reduce boundary snapshot overhead during prefill. self._enlarge_block_size_for_arrays_cache() # TurboQuant KV cache (set by engine if model_settings has it enabled) self._turboquant_kv_bits: float | None = None self._turboquant_skip_last: bool = True # Memoized MLA-architecture detection (see _model_uses_mla / #1613). self._mla_model: bool | None = None self._glm_dsa_adaptive_prefill = None try: from .patches.glm_moe_dsa.generate_patch import ( _glm_dsa_adaptive_prefill_config, ) self._glm_dsa_adaptive_prefill = _glm_dsa_adaptive_prefill_config( model, self.config.prefill_step_size ) except Exception: logger.debug("GLM DSA adaptive prefill config unavailable", exc_info=True) if self._glm_dsa_adaptive_prefill is not None: logger.info( "GLM DSA adaptive scheduler prefill enabled: step=%d after=%d " "min_remaining=%d", self._glm_dsa_adaptive_prefill.step_size, self._glm_dsa_adaptive_prefill.after, self._glm_dsa_adaptive_prefill.min_remaining, ) self._minimax_m3_adaptive_prefill = None try: from .patches.minimax_m3.generate_patch import ( _minimax_m3_adaptive_prefill_config, ) self._minimax_m3_adaptive_prefill = _minimax_m3_adaptive_prefill_config( model, self.config.prefill_step_size, getattr(self.config, "model_name", None), ) except Exception: logger.debug( "MiniMax M3 adaptive prefill config unavailable", exc_info=True ) if self._minimax_m3_adaptive_prefill is not None: logger.info( "MiniMax M3 adaptive scheduler prefill enabled: step=%d after=%d " "min_remaining=%d", self._minimax_m3_adaptive_prefill.step_size, self._minimax_m3_adaptive_prefill.after, self._minimax_m3_adaptive_prefill.min_remaining, ) # Request management - following vLLM's design self.waiting: deque[Request] = deque() # Waiting queue (FCFS) self.running: dict[str, Request] = {} # Running requests by ID # Chunked prefill queue: requests whose prefill spans multiple steps. # Populated when chunked_prefill=True and prompt exceeds prefill_step_size. self.prefilling: deque[Request] = deque() self._prefill_states: dict[str, _PrefillState] = {} self.requests: dict[str, Request] = {} # All requests by ID self.finished_req_ids: set[str] = set() # Recently finished self._generation_overflow_recovery_ids: set[str] = set() # Thread-safe set for deferred aborts (main thread → executor thread) # CPython GIL guarantees set.add() and `x in set` are atomic. self._pending_abort_ids: set[str] = set() # Deferred between-turn Metal reclaim, requested by the (asyncio-thread) # ProcessMemoryEnforcer under pinned-model memory pressure. A bare bool # is GIL-atomic to set; it is drained on the inference thread at the top # of step() (same cross-thread idiom as _pending_abort_ids) because the # enforcer must never touch Metal directly. self._pending_reclaim_request: bool = False # Lock-free admin snapshot. Published at the end of each step() while # the engine thread is the sole writer of running/waiting; the admin # endpoint reads the dict reference atomically (GIL) and never iterates # the live mutable structures. self._admin_snapshot: dict[str, Any] = { "running_by_id": {}, "waiting": [], } # Memory limits for inline prefill checking. # Set by ProcessMemoryEnforcer; propagated to BatchGenerator. self._memory_limit_bytes: int = 0 # soft limit (dynamic, jittery) self._memory_hard_limit_bytes: int = 0 # dynamic ceiling (throttle target) # Stable physical cap = min(static_ceiling, metal_cap). Used ONLY to # abort an in-flight prefill, so a transient dynamic-ceiling dip can't # kill a near-complete request that actually fits. 0 => fall back to # _memory_hard_limit_bytes (pre-propagation / old enforcer). self._memory_abort_limit_bytes: int = 0 # Last mx.get_active_memory() sample taken on this scheduler's MLX # executor thread. The background memory enforcer reads this cached # value during active decode instead of touching MLX/Metal directly. self._last_mlx_active_memory_bytes: int = 0 # Component ceilings — propagated alongside the hard limit so the # rejection-path error message can identify which constraint is # binding and suggest the right remedy (close apps / raise tier / # raise iogpu.wired_limit_mb / reduce context). 0 = not set yet. self._memory_static_ceiling_bytes: int = 0 self._memory_dynamic_ceiling_bytes: int = 0 self._memory_metal_cap_bytes: int = 0 self._memory_hot_cache_reserved_bytes: int = 0 # Tier name propagated alongside the breakdown. For ``custom`` the # "dynamic" ceiling is the user-pinned ``custom_ceiling_bytes`` # rather than computed reclaimable memory, so the advice ladder # must steer the user to that knob instead of "close other apps". self._memory_guard_tier: str = "balanced" self._prefill_memory_guard: bool = False # set by ProcessMemoryEnforcer # Set to True by ProcessMemoryEnforcer when phys_footprint crosses # soft_threshold. Schedulers stop admitting new prefills while this is # set; in-flight requests proceed. self._admission_paused: bool = False # Adaptive prefill throttle params, propagated from enforcer. # Until set, _adaptive_chunk_size is a no-op (returns requested as-is). self._prefill_headroom_safety: float = self._PREFILL_HEADROOM_SAFETY self._prefill_safe_zone_ratio: float = 0.80 self._prefill_min_chunk_tokens: int = 256 self._prefill_abort_margin: float = self._PREFILL_ABORT_MARGIN self._pending_prefill_eviction_request: PrefillEvictionRequest | None = None self._memory_admission_blocked_request_id: str | None = None self._memory_admission_blocked_since: float = 0.0 self._store_cache_admission_blocked_request_id: str | None = None self._store_cache_admission_blocked_since: float = 0.0 # EWMA estimator of per-token chunk transient bytes, used by # _adaptive_chunk_size in the caution zone. Owned per-scheduler. _tracker_model_id = "" if config is not None and config.model_name: _tracker_model_id = config.model_name self._prefill_transient_tracker = PrefillTransientTracker( model_id=_tracker_model_id ) # Let the sdpa256 head_dim-256 prefill route ask for live guard # headroom so it only takes the slow O(L) tiled pass when the faster # unfused fallback would not fit (issue #2204). Weakly held; harmless # when the patch is never applied. set_unfused_headroom_provider(self._sdpa256_unfused_headroom) # SpecPrefill: draft model for attention-based sparse prefill self._specprefill_draft_model: Any | None = None self._draft_paged_ssd_cache_manager: Any | None = None # Track active specprefill request for RoPE cleanup self._specprefill_active_request_id: str | None = None # DEBUG-only prefix-cache divergence probe (issue #1003): recent # stored cache sequences, so a miss can be traced to the exact # token where the new prompt diverges from what was cached. # Populated only when debug logging is enabled — zero cost otherwise. self._cache_probe_seqs: deque[tuple[str, list[int]]] = deque(maxlen=4) model_name_lower = (self.config.model_name or "").lower() default_kv_eval_interval = 256 if "minimax" in model_name_lower else 0 self._decode_eval_kv_cache_interval: int = max( 0, _env_int( "OMLX_DECODE_EVAL_KV_CACHE_INTERVAL", default_kv_eval_interval, ), ) self._tokens_since_kv_cache_eval: int = 0 if self._decode_eval_kv_cache_interval > 0: logger.info( "Decode KV cache materialization interval set to %d tokens", self._decode_eval_kv_cache_interval, ) # VLM MTP: gemma4_assistant drafter attached by VLMBatchedEngine. # When set, eligible requests bypass mlx-lm BatchGenerator for decode # and run through mlx-vlm's _mtp_rounds round loop instead. self._vlm_mtp_drafter: VLMMTPDrafter | None = None # Active vlm_mtp decode generators keyed by synthesized negative uid # (negative to make collision with BatchGenerator uids impossible). self._vlm_mtp_active: dict[int, _VLMMTPDecodeState] = {} self._vlm_mtp_next_uid: int = -1 # Per-request settings snapshot for vlm_mtp routing (block size etc.). # Injected by VLMBatchedEngine.set_vlm_mtp_drafter alongside the drafter. self._vlm_mtp_draft_block_size: int | None = None # Phase timing instrumentation for cache-on overhead diagnostics. # Accumulated wall-time per phase + invocation count, dumped at request # end or via get_phase_stats(). Adds ~100ns per measurement. self._phase_total_ms: dict[str, float] = defaultdict(float) self._phase_count: dict[str, int] = defaultdict(int) # Async store_cache executor (G2-async). Offloads the post-finish # bulk memcpy (28GB+ per 32k request) off the inference thread so # response streaming isn't blocked by it. self._store_cache_executor: concurrent.futures.ThreadPoolExecutor | None = None # Gate that caps in-flight store-cache submissions. Set only when # tiered cache is enabled (alongside _store_cache_executor). self._store_cache_gate: _StoreCacheGate | None = None # Pending (uid, request_id, future) entries waiting for async store # to finish before batch_generator.remove() can safely run. Drained # at the start of every step. self._pending_async_removes: deque = deque() # Track in-flight store futures per request_id for lookup wait / # shutdown wait. self._inflight_store_futures: dict[str, concurrent.futures.Future] = {} self._inflight_store_info: dict[str, _InflightStoreInfo] = {} # Admission-only cache freshness waits. A waiting request can pause at # the front of the queue for a relevant in-flight store without # blocking the scheduler step that continues existing decode/prefill. self._cache_freshness_waits: dict[str, _CacheFreshnessWait] = {} self._prefix_cache_prepared: set[str] = set() # Mapping between our request IDs and BatchGenerator UIDs self.request_id_to_uid: dict[str, int] = {} self.uid_to_request_id: dict[int, str] = {} # BatchGenerator - the actual batching engine self.batch_generator: BatchGenerator | None = None self._current_sampler_params: tuple | None = None # Boundary cache snapshots for stateful non-sliceable caches (e.g., ArraysCache). # request_id -> {token_count -> snapshot_cache_or_None} # Multiple snapshots per request to support per-block ArraysCache state storage. # Values are None when offloaded to SSD via _boundary_snapshot_store. self._boundary_cache_snapshots: dict[str, dict[int, Any]] = {} # Lazy detection flag: True/False once determined, None before first check. self._boundary_snapshot_required: bool | None = None # SSD store for offloading boundary snapshots (initialized in _init_tiered_cache). self._boundary_snapshot_store: BoundarySnapshotSSDStore | None = None # paged SSD cache for KV state persistence (oMLX only supports paged SSD-based caching) self.paged_cache_manager: PagedCacheManager | None = None self.block_aware_cache: BlockAwarePrefixCache | None = None self.paged_ssd_cache_manager: PagedSSDCacheManager | None = None self._cache_rate_tracker = CacheRateTracker() # Prefill-peak estimator used by ``_preflight_memory_check`` / # ``preflight_or_raise``. Only the estimator path is exercised # here (it reads head_dim / num_layers / num_kv_heads via # ``set_model_info`` below); ``eviction_enabled=False`` so the # monitor does not gate on ``max_kv_cache_memory`` — paged SSD # mode never wants this monitor making eviction decisions, and # we have no real value to pass for that field at this point. # # This auto-init was wired up in b6a69c4 then silently dropped # by an upstream merge (same pattern as ``preflight_or_raise`` # in d40ab80). Without it the guard short-circuits at the # ``memory_monitor is None`` gate for every request — Pi prompts # that should be rejected by the configured hard limit instead # sail straight into chunked prefill and OOM at the Metal cap. if MemoryMonitor is not None: self.memory_monitor: MemoryMonitor | None = MemoryMonitor( max_kv_cache_memory=None, eviction_enabled=False, ) self._set_model_info_for_monitor() else: self.memory_monitor: MemoryMonitor | None = None # Initialize paged SSD cache if paged_ssd_cache_dir is specified if self.config.paged_ssd_cache_dir: # Calculate max_blocks automatically if not specified if self.config.max_cache_blocks is not None: max_blocks = self.config.max_cache_blocks else: max_blocks = self._calculate_max_blocks() # Initialize paged cache manager for block metadata self.paged_cache_manager = PagedCacheManager( block_size=self.config.paged_cache_block_size, max_blocks=max_blocks, model_name=self.config.model_name, initial_blocks=self.config.initial_cache_blocks, ) self.block_aware_cache = BlockAwarePrefixCache( model=model, paged_cache_manager=self.paged_cache_manager, ) # Initialize paged SSD cache. If the backing directory is not # usable (for example, an external cache drive is disconnected), # continue with cache disabled instead of leaving partial state. cache_initialized = self._init_tiered_cache() # Set cold restore callback for prefix cache if cache_initialized and self.paged_ssd_cache_manager is not None: self.block_aware_cache.set_cold_restore_callback( self._restore_block_from_cold ) if self.config.hot_cache_only: logger.info( f"hot-cache-only mode enabled: " f"block_size={self.config.paged_cache_block_size}, " f"max_blocks={max_blocks}" ) else: logger.info( f"paged SSD cache enabled: {self.config.paged_ssd_cache_dir}, " f"block_size={self.config.paged_cache_block_size}, " f"max_blocks={max_blocks}" ) # Async store_cache executor: single worker so submissions are # serialized (matches the original synchronous order) and we # never have two stores racing on the same paged_ssd index. self._store_cache_executor = concurrent.futures.ThreadPoolExecutor( max_workers=1, thread_name_prefix="omlx-store-cache", ) # Gate caps the post-completion store-cache pipeline so a burst # of finishes cannot pile up unbounded KV caches in memory while # the single writer drains. Cap starts at max_concurrent_requests # and is shrunk by ProcessMemoryEnforcer under pressure (#1383). self._store_cache_gate = _StoreCacheGate(cap=self.config.max_num_seqs) else: self._disable_paged_cache_components() logger.info( "oMLX cache disabled after paged SSD cache initialization failed" ) else: logger.info( "oMLX cache disabled (mlx-lm BatchGenerator manages KV internally)" ) # Streaming detokenizers for proper UTF-8 handling (one per active request) # NOTE: No pooling - each request gets a fresh instance to prevent state contamination self._request_detokenizers: dict[str, Any] = ( {} ) # request_id → active detokenizer # Protocol-specific output parser support (e.g. Harmony, Gemma 4) self._output_parser_factory: OutputParserFactory | None = None self._output_parser_kind: str | None = None self._output_parser_sessions: dict[str, OutputParserSession] = {} self._is_harmony_model: bool = False if HAS_OUTPUT_PARSER and detect_output_parser is not None: try: model_config = None if hasattr(model, "config"): # model.config may be a Pydantic model or dict try: if hasattr(model.config, "model_dump"): model_config = model.config.model_dump() elif hasattr(model.config, "dict"): model_config = model.config.dict() elif isinstance(model.config, dict): model_config = model.config else: # Try to convert to dict via __dict__ model_config = getattr(model.config, "__dict__", None) except Exception as e: logger.debug(f"Failed to extract model.config: {e}") elif hasattr(model, "args"): try: if hasattr(model.args, "model_dump"): model_config = model.args.model_dump() elif hasattr(model.args, "__dict__"): model_config = model.args.__dict__ except Exception as e: logger.debug(f"Failed to extract model.args: {e}") self._output_parser_factory = detect_output_parser( self.config.model_name, self.tokenizer, model_config, model_path=self.config.model_path, ) if self._output_parser_factory is not None: self._output_parser_kind = self._output_parser_factory.kind self._is_harmony_model = self._output_parser_kind == "harmony" logger.info( "Output parser detected: %s for %s, stop_tokens=%s", self._output_parser_kind, self.config.model_name, sorted(self._output_parser_factory.stop_token_ids), ) except Exception as e: logger.warning(f"Error detecting output parser: {e}, assuming none") self._output_parser_factory = None self._output_parser_kind = None self._is_harmony_model = False # Statistics self.num_requests_processed = 0 self.total_prompt_tokens = 0 self.total_completion_tokens = 0 # Step counter for periodic cleanup self._step_counter = 0 # Deferred Metal cache cleanup after request completion. # Immediate mx.clear_cache() after request completion races with # IOKit's asynchronous completeMemory() callbacks, causing # 'prepare count underflow' kernel panics. Deferring the clear # by a few generation steps gives IOKit time to process callbacks. # # Stored as the absolute step number at which the clear should fire, # rather than a countdown integer. This avoids the burst-completion # bug (#557): with max_num_seqs > 1 two requests can finish in the # same batch. The old "only set if None" guard meant the second # completion never extended the window, so the first request's KV # cache blocks could be re-allocated before IOKit finished its # completeMemory() callbacks. Using max() ensures the window always # covers the *latest* completion. # None = no deferred clear pending; int = step at which to fire. self._deferred_clear_at: int | None = None # Cache XTC special tokens (newline + EOS) — stable per tokenizer. # Must be after _is_harmony_model / _generation_config_eos init # since _get_xtc_special_tokens() delegates to _get_stop_tokens(). self._xtc_special_tokens: list[int] = self._get_xtc_special_tokens() # Drop transient aliases after ownership moves to scheduler fields; # close()/deep_reset() clear those fields during teardown. model = None tokenizer = None @contextmanager def _phase_timer(self, phase: str): """Lightweight wall-time accumulator for cache-on overhead diagnostics. Tracks total ms and invocation count per named phase. Intended for boundary capture / store_cache / hot cache eviction hot paths. """ t0 = time.perf_counter() try: yield finally: self._phase_total_ms[phase] += (time.perf_counter() - t0) * 1000.0 self._phase_count[phase] += 1 def get_phase_stats(self) -> dict[str, dict[str, float]]: """Return accumulated phase timings for diagnostics. Returns dict of phase -> {total_ms, count, avg_ms}. """ result = {} for phase, total in self._phase_total_ms.items(): count = self._phase_count.get(phase, 0) result[phase] = { "total_ms": total, "count": count, "avg_ms": total / count if count else 0.0, } return result def _periodic_clear_threshold_bytes(self) -> int: """Cache-bytes threshold above which the periodic clear runs. Defaults to memory_limit/3 when a process memory limit is set, otherwise an absolute 2 GiB floor. Each periodic clear releases the entire MLX buffer pool in one batch; gating it on accumulated bytes avoids producing IOGPUFamily refcount bursts when the pool is already small. """ if self._memory_limit_bytes > 0: return max(self._memory_limit_bytes // 3, 2 * 1024**3) return 2 * 1024**3 def _should_periodic_clear_cache(self) -> bool: """Decide whether the per-step periodic clear should fire. Returns False unless ``mlx_cache_cleanup_interval`` is configured, the step counter just landed on the interval boundary, AND the MLX buffer pool exceeds the threshold. See #978 / #1040 for the kernel panic class this gating is meant to mitigate. """ interval = self.config.mlx_cache_cleanup_interval if interval <= 0 or self._step_counter % interval != 0: return False return mx.get_cache_memory() > self._periodic_clear_threshold_bytes() @staticmethod def _collect_arrays_from_extracted_cache( extracted_cache: list[Any], ) -> list[Any]: """Collect lazy mx.array references from an _extracted_cache payload. Used by G2-async to force a single batched mx.eval on the inference thread before handing the cache off to the store_cache worker. The worker can then call _extract_tensor_bytes safely (no further Metal graph evaluation needed for non-bfloat16, no-op for already-evaluated). Walks the per-layer dict format produced by _extract_cache_states: each layer is {state, meta_state, class_name, cache_type}, where state is a tuple of mx.arrays (or nested for CacheList / TurboQuant). """ arrays: list[Any] = [] for layer in extracted_cache or []: if not isinstance(layer, dict): continue state = layer.get("state", ()) if isinstance(state, mx.array): arrays.append(state) continue if not isinstance(state, (list, tuple)): continue for item in state: if isinstance(item, mx.array): arrays.append(item) elif isinstance(item, (list, tuple)): for sub in item: if isinstance(sub, mx.array): arrays.append(sub) elif hasattr(sub, "_fields"): # NamedTuple state (TurboQuant). Walk fields. for fname in sub._fields: val = getattr(sub, fname, None) if isinstance(val, mx.array): arrays.append(val) elif hasattr(item, "_fields"): for fname in item._fields: val = getattr(item, fname, None) if isinstance(val, mx.array): arrays.append(val) return arrays def _async_store_cache_worker( self, request_id: str, token_sequence_to_store: list[int], cache_to_store: list[Any], model_cache_config: Any | None, intermediate_snapshots: dict[int, list[Any]] | None, extra_keys: tuple[Any, ...] | None, extra_key_token_start: int | None, extra_key_ranges: list[tuple[int, tuple[Any, ...]]] | None, hot_cache_write_back: bool = True, ) -> None: """Run store_cache + paged_cache cleanup off the inference thread. Pre-conditions enforced by the caller (_cleanup_finished): - mx.eval() (a FULL, blocking eval — NOT async_eval) was called on the inference thread for all KV cache arrays in cache_to_store, so they are fully materialized concrete buffers before this worker runs. This is the load-bearing invariant: MLX streams ARE thread-local (each engine's generation stream is created via mx.new_thread_local_stream on the inference thread). A KV array left lazy and bound to self._stream cannot be materialized on this worker thread — _extract_block_tensor_slice (slices) and _extract_tensor_bytes (bf16 -> uint16 view) would re-dispatch the source op to self._stream's index, which does NOT exist on this thread, aborting the process ("There is no Stream(gpu, N) in current thread"). Boundary snapshots get the same treatment via _eval_snapshot_cache at capture time. - Because the sources are concrete, the worker's own slice/view ops consume materialized buffers and bind their new ops to the always-present default stream (gpu,0). The _safe_sync_stream call below is now belt-and-suspenders (the owner already drained the work); correctness no longer depends on its cross-thread mx.synchronize, whose "no Stream" RuntimeError it tolerantly swallows. - All mx-buffer access here is held under _mx_buffer_access_lock, serializing the bf16 view+eval and the buffer-protocol reads against inference-thread _sync_and_clear_cache (which also takes that lock), so mx.clear_cache cannot reclaim a buffer mid-read. - batch_generator.remove(uid) is deferred until this worker completes (handled by _drain_pending_async_removes). paged_cache_manager and block_aware_cache rely on threading.RLock so concurrent access from main and worker is safe. """ try: # Hold _mx_buffer_access_lock across the worker's mx-buffer # access. store_cache eventually drives _extract_tensor_bytes, # which reads raw bytes via the buffer protocol; serializing # against inference-thread mx.clear_cache / mx.synchronize calls # prevents a SIGABRT when those reclaim the underlying Metal # buffer pool mid-read (#1106). with _mx_buffer_access_lock: with self._phase_timer("store_cache_worker_sync"): _safe_sync_stream(self._stream) if hot_cache_write_back: block_table = self.block_aware_cache.store_cache( request_id, token_sequence_to_store, cache_to_store, model_cache_config=model_cache_config, boundary_snapshots=intermediate_snapshots, extra_keys=extra_keys, extra_key_token_start=extra_key_token_start, extra_key_ranges=extra_key_ranges, ) else: block_table = self.block_aware_cache.store_cache( request_id, token_sequence_to_store, cache_to_store, model_cache_config=model_cache_config, boundary_snapshots=intermediate_snapshots, extra_keys=extra_keys, extra_key_token_start=extra_key_token_start, extra_key_ranges=extra_key_ranges, hot_cache_write_back=False, ) if block_table is None and self.paged_cache_manager is not None: block_table = self.paged_cache_manager.get_block_table(request_id) if block_table and self.paged_cache_manager is not None: self.paged_cache_manager.release_for_eviction(block_table.block_ids) if self.block_aware_cache is not None: self.block_aware_cache.clear_request_entry(request_id) except Exception as e: logger.warning("Async store_cache failed for %s: %s", request_id, e) def _drain_pending_async_removes(self) -> bool: """Process deferred batch_generator.remove() calls from prior steps. Called at the start of every step. For each pending entry whose async store_cache future has finished, perform batch_generator.remove() on the inference thread (Metal-safe) and finalize cleanup state. Entries whose futures are still in flight are kept for a later step, but they do not block later completed entries from releasing cache references. """ if not self._pending_async_removes: return False drained = False pending: deque = deque() while self._pending_async_removes: uid, request_id, future = self._pending_async_removes.popleft() if future is not None and not future.done(): # Worker still busy. Keep it for the next step, but continue # scanning so later completed futures can release memory now. pending.append((uid, request_id, future)) continue # Surface worker exceptions for visibility (don't crash step loop). if future is not None: try: exc = future.exception() except concurrent.futures.CancelledError: logger.warning("Async store_cache for %s was cancelled", request_id) else: if exc is not None: logger.warning( "Async store_cache for %s raised: %s", request_id, exc ) try: # Run batch_generator.remove on the inference thread. try: _safe_sync_stream(self._stream) self._remove_uid_from_active_batch(uid) if hasattr(self.model, "unregister_rope_delta"): self.model.unregister_rope_delta(uid) except Exception as e: logger.warning( "Deferred batch_generator.remove(uid=%s) failed: %s", uid, e, ) # Cleanup uid maps now that the slot is reclaimable. _unregister_uid_row(self.model, uid) if uid in self.uid_to_request_id: del self.uid_to_request_id[uid] if request_id in self.request_id_to_uid: del self.request_id_to_uid[request_id] self._inflight_store_futures.pop(request_id, None) self._inflight_store_info.pop(request_id, None) self._clear_request_admission_bookkeeping(request_id) # Boundary snapshots were kept on disk for the worker; safe to # delete now that the future has completed. Cleanup was # deferred from _cleanup_finished to avoid racing the worker's # boundary_snapshot_store.load() calls with rmtree. if self._boundary_snapshot_store is not None: self._boundary_snapshot_store.cleanup_request(request_id) # Worker no longer holds extracted_cache — pop request from # self.requests and drop the cache buffer references so MLX # arrays can be freed. req_to_remove = self.requests.pop(request_id, None) if req_to_remove is not None: req_to_remove._extracted_cache = None req_to_remove.prompt_cache = None finally: gate = self._store_cache_gate if gate is not None: gate.note_done() drained = True self._pending_async_removes = pending return drained def _calculate_max_blocks(self) -> int: """ Calculate maximum cache blocks for paged SSD-only mode. In paged SSD-only mode, blocks don't consume GPU memory (data is on paged SSD), so we use a large default that can be limited by SSD capacity. Returns: Maximum number of cache blocks to allocate. """ # In paged SSD-only mode, use a large default since blocks don't consume GPU memory # The actual limit is SSD capacity (paged_ssd_cache_max_size) max_blocks = 100000 # Large default for paged SSD-only mode block_size = self.config.paged_cache_block_size logger.info( f"paged SSD-only mode: max_blocks={max_blocks}, block_size={block_size} tokens" ) return max_blocks def _collect_rotating_window_sizes( self, cache_obj: Any, window_sizes: set[int], ) -> None: """Collect rotating window sizes recursively from cache objects.""" sub_caches = getattr(cache_obj, "caches", None) if isinstance(sub_caches, (list, tuple)): for sub_cache in sub_caches: self._collect_rotating_window_sizes(sub_cache, window_sizes) class_name = type(cache_obj).__name__ is_rotating_cache = class_name in ("RotatingKVCache", "BatchRotatingKVCache") if HAS_CACHE_TYPE_HANDLERS and CacheTypeRegistry is not None: is_rotating_cache = ( is_rotating_cache or CacheTypeRegistry.is_rotating_family(class_name) ) if is_rotating_cache: max_size = getattr(cache_obj, "max_size", 0) if isinstance(max_size, int) and max_size > 0: window_sizes.add(max_size) def _detect_rotating_window_sizes(self) -> set[int]: """Detect rotating window sizes from model.make_cache() if available.""" if not hasattr(self.model, "make_cache"): return set() try: cache_list = self.model.make_cache() except Exception as e: logger.debug(f"Failed to inspect model rotating window sizes: {e}") return set() if cache_list is None: return set() window_sizes: set[int] = set() for cache_obj in cache_list: self._collect_rotating_window_sizes(cache_obj, window_sizes) return window_sizes # Target range for RotatingKVCache block size alignment. # Using a multiple of window_size within this range reduces SSD I/O # overhead (fewer, larger block files) while keeping cache restore # reprocessing reasonable. _ROTATING_BLOCK_SIZE_MIN = 512 _ROTATING_BLOCK_SIZE_MAX = 1024 def _align_block_size_with_rotating_window(self) -> None: """ Align paged cache block size to a multiple of RotatingKVCache window size, targeting 512-1024 tokens per block. Block size must be a multiple of window_size so that block boundaries align with rotation boundaries. When window_size is small (e.g. 128), using it directly as block_size creates too many small files. Instead we pick the smallest multiple of window_size that falls within [_ROTATING_BLOCK_SIZE_MIN, _ROTATING_BLOCK_SIZE_MAX]. """ if not self.config.paged_ssd_cache_dir: return window_sizes = self._detect_rotating_window_sizes() if not window_sizes: return if len(window_sizes) > 1: raise ValueError( "Multiple RotatingKVCache window sizes detected " f"({sorted(window_sizes)}). Set a single aligned block size or " "disable paged cache for this model." ) window_size = next(iter(window_sizes)) # Find the smallest multiple of window_size >= _ROTATING_BLOCK_SIZE_MIN. # If window_size itself is already >= max, just use window_size. lo = self._ROTATING_BLOCK_SIZE_MIN hi = self._ROTATING_BLOCK_SIZE_MAX if window_size >= hi or window_size >= lo: target_block_size = window_size else: # window_size < lo: pick smallest multiple in [lo, hi] multiplier = (lo + window_size - 1) // window_size # ceil(lo / ws) target_block_size = multiplier * window_size if target_block_size > hi: # Fall back to largest multiple <= hi target_block_size = (hi // window_size) * window_size if target_block_size < window_size: target_block_size = window_size if self.config.paged_cache_block_size != target_block_size: logger.info( "Aligning paged cache block_size=%s to %s " "(RotatingKVCache window_size=%s, multiplier=%sx)", self.config.paged_cache_block_size, target_block_size, window_size, target_block_size // window_size, ) self.config.paged_cache_block_size = target_block_size # Default block size for ArraysCache-only hybrid models. # Match prefill_step_size (2048) so that boundary caching ON/OFF # produces identical prefill chunk sizes, eliminating float32↔dtype # roundtrip differences in GatedDeltaNet recurrent state. _ARRAYS_CACHE_BLOCK_SIZE = 2048 def _enlarge_block_size_for_arrays_cache(self) -> None: """Enlarge block size for ArraysCache-only hybrid models. When a model uses ArraysCache (GatedDeltaNet) but not RotatingKVCache, a larger block size reduces the number of boundary snapshot stops during prefill while still storing valid per-block recurrent state. This is skipped if RotatingKVCache was already detected (block size was aligned to its window size) or if the user explicitly set a block size larger than the default. """ if not self.config.paged_ssd_cache_dir: return # Skip if RotatingKVCache already adjusted block size. rotating_sizes = self._detect_rotating_window_sizes() if rotating_sizes: return # Detect ArraysCache from model.make_cache() if not hasattr(self.model, "make_cache"): return try: cache_list = self.model.make_cache() except Exception: return if cache_list is None: return has_arrays_cache = any( self._cache_tree_has_arrays_cache(cache_obj) for cache_obj in cache_list ) if not has_arrays_cache: return target = self._ARRAYS_CACHE_BLOCK_SIZE if self.config.paged_cache_block_size >= target: return logger.info( "Enlarging paged cache block_size=%s to %s for " "ArraysCache hybrid model (reduces boundary snapshot overhead)", self.config.paged_cache_block_size, target, ) self.config.paged_cache_block_size = target @staticmethod def _cache_tree_has_arrays_cache(cache_obj: Any) -> bool: """Return True if cache_obj contains ArraysCache (recursively).""" sub_caches = getattr(cache_obj, "caches", None) if isinstance(sub_caches, (list, tuple)): return any( Scheduler._cache_tree_has_arrays_cache(sub) for sub in sub_caches ) return type(cache_obj).__name__ in ("ArraysCache", "SizedArraysCache") def _load_generation_config_eos(self) -> set[int] | None: """Load EOS token IDs from generation_config.json if available.""" try: model_ref = getattr(self.tokenizer, "name_or_path", None) or getattr( self.config, "model_name", None ) if not model_ref: return None result = load_generation_config_token_ids(model_ref, "eos_token_id") if result is None: return None # Only return if there are tokens beyond what tokenizer already provides tokenizer_eos = getattr(self.tokenizer, "eos_token_id", None) if tokenizer_eos is not None: existing = ( {tokenizer_eos} if isinstance(tokenizer_eos, int) else set(tokenizer_eos) ) extra = result - existing if extra: logger.info( f"Loaded {len(extra)} additional EOS token(s) from " f"generation_config.json: {extra}" ) return result return result except Exception as e: logger.debug(f"Could not load generation_config.json: {e}") return None def _load_model_suppress_tokens(self) -> set[int]: """Load suppress_tokens from generation_config.json if available. These tokens are set to -inf during generation. For Gemma 4 unified, generation_config marks the multimodal close markers (, ) this way. """ try: model_ref = getattr(self.tokenizer, "name_or_path", None) or getattr( self.config, "model_name", None ) if not model_ref: return set() result = load_generation_config_token_ids(model_ref, "suppress_tokens") if not result: return set() logger.info( f"Loaded {len(result)} suppress token(s) from " f"generation_config.json: {result}" ) return result except Exception as e: logger.debug(f"Could not load suppress_tokens from generation_config: {e}") return set() def _get_stop_tokens(self) -> set[int]: """Get stop token IDs from tokenizer and generation_config.""" stop_tokens = set() if ( hasattr(self.tokenizer, "eos_token_id") and self.tokenizer.eos_token_id is not None ): if isinstance(self.tokenizer.eos_token_id, list): stop_tokens.update(self.tokenizer.eos_token_id) else: stop_tokens.add(self.tokenizer.eos_token_id) if ( hasattr(self.tokenizer, "eos_token_ids") and self.tokenizer.eos_token_ids is not None ): eos_ids = self.tokenizer.eos_token_ids if isinstance(eos_ids, int): stop_tokens.add(eos_ids) else: stop_tokens.update(eos_ids) # Include end-of-turn token for models that use turn-based # conversation delimiters (e.g. Gemma 4 with ). Without # this the model generates the full next turn after its response. eot_token_id = getattr(self.tokenizer, "eot_token_id", None) if eot_token_id is not None: if isinstance(eot_token_id, list): stop_tokens.update(eot_token_id) else: stop_tokens.add(eot_token_id) elif hasattr(self.tokenizer, "eot_token") and self.tokenizer.eot_token: # Encode the string value if eot_token_id isn't directly exposed try: encoded = self.tokenizer.encode( self.tokenizer.eot_token, add_special_tokens=False ) if encoded: stop_tokens.update(encoded) except Exception: pass # Read additional EOS tokens from generation_config.json. # Some models (e.g. GLM-4.6V) define multiple EOS tokens there # that are not reflected in tokenizer.eos_token_id. if self._generation_config_eos is not None: stop_tokens.update(self._generation_config_eos) # Protocol parsers need to observe their own stop tokens so they can # apply channel-aware handling (for example, Harmony analysis end # should continue into the final channel). return stop_tokens # _update_stop_tokens deleted — per-request stop tokens are now # handled via SequenceStateMachine passed to insert(). def _get_detokenizer(self, request_id: str): """Get or create a streaming detokenizer for a request. This enables proper UTF-8 handling for multi-byte characters (Korean, Chinese, Japanese, etc.) during streaming. NOTE: Each request gets a fresh detokenizer instance. Pooling was removed because internal state (byte buffers) can leak between requests even after finalize()/reset(), causing text corruption (e.g., spaces inserted in paths, character swaps like 'features' -> 'featurse'). """ if request_id not in self._request_detokenizers: # Always create a fresh detokenizer - no pooling to prevent state contamination detok = create_streaming_detokenizer( self.tokenizer, model_path=self.config.model_path, ) if detok is None: # Fallback: return None, we'll use decode([token]) return None detok.reset() self._request_detokenizers[request_id] = detok return self._request_detokenizers[request_id] def _cleanup_detokenizer(self, request_id: str): """Clean up detokenizer for a finished request. NOTE: Detokenizers are NOT pooled - each request gets a fresh instance to prevent state contamination that causes text corruption. """ self._request_detokenizers.pop(request_id, None) # Let GC collect - no pooling to prevent state contamination def _get_output_parser_session( self, request_id: str ) -> Optional["OutputParserSession"]: """Get or create a protocol-specific output parser session.""" if self._output_parser_factory is None: return None if request_id not in self._output_parser_sessions: self._output_parser_sessions[request_id] = ( self._output_parser_factory.create_session(self.tokenizer) ) return self._output_parser_sessions[request_id] def _cleanup_output_parser_session(self, request_id: str): """Remove any per-request protocol parser session.""" self._output_parser_sessions.pop(request_id, None) def _get_xtc_special_tokens(self) -> list[int]: """Get special tokens to exclude from XTC sampling. Parser-owned stop tokens stay out of BatchGenerator stop-token matching so protocol parsers can handle them channel-aware, but XTC must still protect them from diversity masking. """ tokens = self.tokenizer.encode("\n") tokens.extend(self._get_stop_tokens()) if self._output_parser_factory is not None: tokens.extend(self._output_parser_factory.stop_token_ids) return tokens def _create_batch_generator( self, sampling_params: SamplingParams ) -> BatchGenerator: """Create a BatchGenerator with the given sampling parameters.""" sampler = omlx_make_sampler( temp=sampling_params.temperature, top_p=sampling_params.top_p, min_p=sampling_params.min_p, top_k=sampling_params.top_k, xtc_probability=sampling_params.xtc_probability, xtc_threshold=sampling_params.xtc_threshold, xtc_special_tokens=self._xtc_special_tokens, ) # Create logits processors for repetition/presence/frequency penalties logits_processors = make_logits_processors( repetition_penalty=( sampling_params.repetition_penalty if sampling_params.repetition_penalty != 1.0 else None ), presence_penalty=( sampling_params.presence_penalty if sampling_params.presence_penalty != 0.0 else None ), frequency_penalty=( sampling_params.frequency_penalty if sampling_params.frequency_penalty != 0.0 else None ), ) suppress_processor = _make_suppress_logits_processor( self._model_suppress_tokens ) if suppress_processor is not None: logits_processors.append(suppress_processor) # Convert stop tokens from Set[int] to Sequence[Sequence[int]] # for the new BatchGenerator API (each stop token is a sequence). stop_tokens_set = self._get_stop_tokens() if sampling_params.stop_token_ids: stop_tokens_set.update(sampling_params.stop_token_ids) stop_tokens_seq = [[t] for t in stop_tokens_set] if stop_tokens_set else None bg = BatchGenerator( model=self.model, max_tokens=sampling_params.max_tokens, stop_tokens=stop_tokens_seq, sampler=sampler, logits_processors=logits_processors if logits_processors else [], prefill_batch_size=1, completion_batch_size=self.config.completion_batch_size, prefill_step_size=self.config.prefill_step_size, stream=self._stream, ) return bg def _on_prompt_progress(self, updates: list[tuple[int, int, int]]) -> None: """Callback from BatchGenerator's prefill loop. Called once per prefill chunk (default 2048 tokens) with a list of (uid, processed_tokens, total_tokens) tuples. Updates the global PrefillProgressTracker so the admin dashboard can display per-request prefill progress. Only touches CPU counters — zero GPU overhead. """ tracker = get_prefill_tracker() model_id = self.config.model_name for uid, processed, total in updates: request_id = self.uid_to_request_id.get(uid) if request_id is None: continue tracker.update( request_id=request_id, processed=processed, total=total, model_id=model_id, ) # ------------------------------------------------------------------ # External prefill (composition pattern — replaces _process_prompts) # ------------------------------------------------------------------ def _model_uses_mla(self) -> bool: """Detect Multi-head Latent Attention models (DeepSeek-V2/V3/V4, GLM-4-MoE / GLM-4.7-Flash, Kimi-K2, ...). MLA compresses K/V into a low-rank latent plus a separate rope key and reads the *fetched* cache tensors directly — e.g. ``kv_latent, k_pe = cache.update_and_fetch(...)`` then ``k_pe.swapaxes(-1, -2)`` (mlx_lm/models/glm4_moe_lite.py). TurboQuant replaces the cache state with quantized NamedTuples that have no array methods, so that ``.swapaxes`` raises ``AttributeError`` (#1613). MLA also stores keys/values with mismatched head dims, which the codec does not support. Such models stay fp16 — no crash, no TurboQuant. Result is memoized: the model never changes for a scheduler instance. """ cached = getattr(self, "_mla_model", None) if cached is not None: return cached detected = False model = getattr(self, "model", None) # kv_lora_rank is the defining MLA hyperparameter and is an int on real # models. It may sit on the top-level config or be nested under a # text/LM sub-config (VLM MLA, e.g. kimi_vl -> text_config). The # isinstance(int) check guards against mocks where it is a sentinel. def _cfg_has_kv_lora(cfg: Any, depth: int = 0) -> bool: if cfg is None or depth > 3: return False if isinstance(getattr(cfg, "kv_lora_rank", None), int): return True return any( _cfg_has_kv_lora(getattr(cfg, sub, None), depth + 1) for sub in ( "text_config", "llm_config", "language_config", "thinker_config", ) ) # Config signal. For VLMs the scheduler sees VLMModelAdapter, whose # .args delegates to the language model; also probe (_)language_model. for holder in ( model, getattr(model, "_language_model", None), getattr(model, "language_model", None), ): if holder is None: continue if _cfg_has_kv_lora(getattr(holder, "args", None)) or _cfg_has_kv_lora( getattr(holder, "config", None) ): detected = True break # Architecture signal: an attention submodule carrying the MLA # down-projection, latent layernorm, or latent rank. Covers models # whose config does not surface kv_lora_rank where the scheduler can # see it (e.g. a directly-loaded VLM with a nested text config). if not detected and model is not None and hasattr(model, "modules"): try: for m in model.modules(): if ( hasattr(m, "kv_a_proj_with_mqa") or hasattr(m, "kv_a_layernorm") or isinstance(getattr(m, "kv_lora_rank", None), int) ): detected = True break except Exception: pass if detected: logger.info( "TurboQuant disabled: model uses Multi-head Latent Attention " "(MLA), which is incompatible with quantized KV cache states; " "keeping fp16 KV cache (#1613)." ) self._mla_model = detected return detected def _model_uses_attention_sinks(self) -> bool: """Detect models whose attention path passes sink logits to SDPA. TurboQuant's quantized attention kernels currently do not implement the sink term used by attention-sink models. Ignoring it silently changes the model's attention distribution, so these models must keep fp16 KV unless the attention patch falls back to dequantized sink-aware SDPA. """ cached = getattr(self, "_attention_sink_model", None) if cached is not None: return cached detected = False model = getattr(self, "model", None) def _has_real_sink_attr(obj: Any) -> bool: for name in ("sinks", "attention_sink_bias", "attn_sink"): value = None if isinstance(obj, dict): value = obj.get(name) if value is None: data = getattr(obj, "__dict__", {}) if isinstance(data, dict): value = data.get(name) if isinstance(value, mx.array): return True if value is not None and isinstance(value, (int, float, list, tuple)): return True return False try: modules = getattr(model, "modules", None) except Exception: modules = None if type(modules).__module__.startswith("unittest.mock"): modules = None if not detected and callable(modules): try: for m in modules(): if _has_real_sink_attr(m): detected = True break except Exception: pass if detected: logger.info( "TurboQuant disabled: model uses attention sinks, which are " "not supported by TurboQuant's quantized attention kernels; " "keeping fp16 KV cache." ) self._attention_sink_model = detected return detected def _turboquant_eligible(self, prompt_cache: list[Any]) -> bool: """True if this cache layout can safely mix TQ and pass-through caches. Plain KVCache layers are TurboQuant-convertible. State-array caches and rotating/sliding-window caches are pass-through: they stay in their native form while adjacent full-attention KVCache layers are converted. MLA models (DeepSeek / GLM-4.7-Flash) and attention-sink models are excluded because their attention paths need semantics TurboQuant's quantized cache states/kernels do not currently provide. """ from mlx_lm.models.cache import ArraysCache, CacheList, KVCache if self._model_uses_mla(): return False if self._model_uses_attention_sinks(): return False def _ok(c: Any) -> bool: if isinstance(c, KVCache): return True if isinstance(c, ArraysCache): return True class_name = type(c).__name__ if class_name in ( "SizedArraysCache", "RotatingKVCache", "BatchRotatingKVCache", "PrefillReadyRotatingKVCache", "BufferedRotatingKVCache", "TurboQuantKVCache", "BatchTurboQuantKVCache", ): return True if class_name in ("MiniMaxM3KVCache", "MiniMaxM3BatchKVCache"): return False if isinstance(c, CacheList): return all(_ok(inner) for inner in c.caches) return False return bool(prompt_cache) and all(_ok(c) for c in prompt_cache) def _apply_turboquant_kv_empty(self, prompt_cache: list[Any]) -> None: """Replace empty KVCache layers with empty TurboQuantKVCache. Tokens are quantized on the fly during update_and_fetch, avoiding the peak memory spike from storing full-precision KV then converting. Used only when there is no prefill history to preserve (the single last token is quantized during insert()'s prompt step). Skips the last KVCache layer if turboquant_skip_last is set. """ from mlx_lm.models.cache import CacheList, KVCache from mlx_vlm.turboquant import TurboQuantKVCache kv_indices = [ i for i, c in enumerate(prompt_cache) if _is_turboquant_kv_family_cache(c) ] skip_last = self._turboquant_skip_last and len(kv_indices) > 1 last_kv_idx = kv_indices[-1] if skip_last else -1 converted = 0 bits = float(self._turboquant_kv_bits) for i, cache_obj in enumerate(prompt_cache): if isinstance(cache_obj, KVCache): if i == last_kv_idx: continue prompt_cache[i] = TurboQuantKVCache(bits=bits) converted += 1 elif isinstance(cache_obj, CacheList): new_caches = [] for c in cache_obj.caches: if isinstance(c, KVCache): new_caches.append(TurboQuantKVCache(bits=bits)) converted += 1 else: new_caches.append(c) cache_obj.caches = tuple(new_caches) if converted > 0: skip_msg = ", skipped last KVCache layer" if skip_last else "" logger.info( f"TurboQuant: {converted}/{len(prompt_cache)} " f"cache layers set to {bits}-bit{skip_msg}" ) def _apply_turboquant_kv_convert(self, prompt_cache: list[Any]) -> None: """Convert populated KVCache data to TurboQuantKVCache via from_cache(). Called AFTER fp16 prefill completes (or on an SSD-restored fp16 cache): the completed full-precision KV is quantized once, so prefill hidden states stay exact and quantization error only enters at decode-time reads. This is the key difference from #717/#771, which quantized on the fly during prefill and corrupted hidden states. """ from mlx_lm.models.cache import CacheList, KVCache from mlx_vlm.turboquant import TurboQuantKVCache kv_indices = [ i for i, c in enumerate(prompt_cache) if _is_turboquant_kv_family_cache(c) ] skip_last = self._turboquant_skip_last and len(kv_indices) > 1 last_kv_idx = kv_indices[-1] if skip_last else -1 converted = 0 bits = float(self._turboquant_kv_bits) for i, cache_obj in enumerate(prompt_cache): if isinstance(cache_obj, KVCache): if i == last_kv_idx: continue prompt_cache[i] = TurboQuantKVCache.from_cache(cache_obj, bits=bits) converted += 1 elif isinstance(cache_obj, CacheList): new_caches = [] for c in cache_obj.caches: if isinstance(c, KVCache): new_caches.append(TurboQuantKVCache.from_cache(c, bits=bits)) converted += 1 else: new_caches.append(c) cache_obj.caches = tuple(new_caches) if converted > 0: skip_msg = ", skipped last KVCache layer" if skip_last else "" logger.info( f"TurboQuant: converted {converted}/{len(prompt_cache)} " f"cache layers to {bits}-bit{skip_msg}" ) def _do_external_prefill( self, request: "Request", tokens: list[int], existing_cache: list[Any] | None, vlm_embeds: tuple[mx.array, dict[str, Any], int] | None = None, ) -> tuple[list[Any], list[int]]: """Run prefill externally (outside BatchGenerator) for a single request. Processes tokens[0:N-1] through the model. The last token tokens[N-1] is NOT processed here — it will be passed to BatchGenerator.insert() so that the first decode step produces the correct logit. Args: request: The request being prefilled. tokens: Full token list to prefill. existing_cache: Restored cache from paged SSD (or None). vlm_embeds: Optional (inputs_embeds, extra_kwargs, start_offset) tuple for VLM requests. Returns: (prefilled_cache, last_token_list) where last_token_list contains the single last token to pass to insert(). Raises: _PrefillAbortedError: If prefill is interrupted by a pending abort. RuntimeError: If memory limit exceeded during prefill. """ n_tokens = len(tokens) if n_tokens <= 1: # Nothing to prefill, return cache + tokens as-is. cache = existing_cache or make_prompt_cache(self.model) # TurboQuant: a TQ cache here makes _merge_caches() build a # BatchTurboQuantKVCache (via the monkey-patched merge), so the # one decode token quantizes against TQ history. An empty fresh # cache gets empty TQ layers; a restored cache preserves its data. if self._turboquant_kv_bits is not None and self._turboquant_eligible( cache ): if existing_cache is None: self._apply_turboquant_kv_empty(cache) else: self._apply_turboquant_kv_convert(cache) return cache, tokens # Create or reuse cache if existing_cache is not None: prompt_cache = existing_cache else: prompt_cache = make_prompt_cache(self.model) # Fresh TurboQuant requests run fp16 during the cold prefill loop and # are quantized once at the end. Restored TurboQuant prefix caches stay # quantized while pre-filling the uncached suffix, then keep using TQ for # decode. Rotating/sliding-window layers remain native pass-through # caches; only full-attention KVCache layers are converted. # Clear stale mRoPE position state for text-only requests. if vlm_embeds is None and hasattr(self.model, "clear_vlm_position_state"): self.model.clear_vlm_position_state() _seed_text_only_mrope_delta_for_cached_prefill(self.model, request) # Boundary snapshot setup block_size = self.config.paged_cache_block_size boundary_enabled = ( block_size > 0 and self.block_aware_cache is not None and _prompt_cache_needs_snapshots(prompt_cache) ) base_size = _cache_base_sizes(prompt_cache) if boundary_enabled else 0 # Sanity check: base_size from cache offsets should match the number # of tokens actually cached. A mismatch indicates stale meta_state # in a restored RotatingKVCache (e.g. shared layer_meta_states from # an earlier store_cache bug). Use cached_tokens which is always # derived from block_table.num_tokens and therefore trustworthy. if ( boundary_enabled and hasattr(request, "cached_tokens") and request.cached_tokens > 0 ): if base_size != request.cached_tokens: logger.debug( "Cache base_size mismatch: computed %d, expected %d " "(cached_tokens). Using cached_tokens for boundary " "alignment.", base_size, request.cached_tokens, ) base_size = request.cached_tokens # Prepare VLM embeddings for prefill embeds_array: mx.array | None = None extra_kwargs: dict[str, Any] | None = None if vlm_embeds is not None: embeds_array, extra_kwargs, start_offset = vlm_embeds embeds_array = embeds_array[:, start_offset:] # skip cached portion if start_offset > 0 and extra_kwargs: extra_kwargs = _advance_vlm_extra(extra_kwargs, start_offset) # Force _position_ids path in language model for cached VLM # prefill. Without this, the delta approach gives sequential # positions to image tokens that need 3D mRoPE positions. # Setting _rope_deltas=None makes the language model use # _position_ids (set by get_input_embeddings) instead. # Saved and restored after prefill for decode rope_deltas capture. # Only applies to mRoPE VLMs (Qwen2-VL, Qwen2.5-VL, GLM-4V, etc.); # non-mRoPE VLMs like Gemma 4 have no _rope_deltas attribute. _saved_rope_deltas = None if start_offset > 0: lm = getattr(self.model, "_language_model", None) if lm is not None and hasattr(lm, "_rope_deltas"): _saved_rope_deltas = lm._rope_deltas lm._rope_deltas = None # Stash so the #1405 requeue path can restore it if this prefill # raises before the normal restore below runs. request._prefill_saved_rope_deltas = _saved_rope_deltas # Prefill tokens[0:N-1] (leave last token for insert()) prefill_tokens = tokens[:-1] last_token = tokens[-1:] total_length = len(tokens) # Build the input row on the engine stream: the chunk forwards below # run inside mx.stream(self._stream), and a worker-default-stream # view would split the chunk eval graph across two streams (see the # loop comment below). with mx.stream(self._stream): input_arr = mx.array(prefill_tokens)[None] # (1, seq_len) processed_tokens = 0 uid = self.request_id_to_uid.get(request.request_id) emitted_boundaries: dict[int, int] = {} while input_arr.shape[1] > 0: remaining = input_arr.shape[1] prefill_step_size = self._prefill_step_size_for_progress( processed_tokens, remaining ) n_to_process = min(prefill_step_size, remaining) if processed_tokens == 0: _sync_and_clear_cache(self._stream) # Boundary-limited step size if boundary_enabled and block_size > 0: current_total = base_size + processed_tokens next_boundary = ((current_total // block_size) + 1) * block_size target_boundary_prefill = next_boundary - base_size delta = target_boundary_prefill - processed_tokens if delta > 0: n_to_process = min(n_to_process, delta) n_to_process = max(1, n_to_process) # Adaptive throttle: shrink chunk when entering the caution zone # so the hard cap is honored before the chunk-end check. Raises # RuntimeError if the min chunk would exceed the cap — the # #1405 cleanup path catches it and emits an error to the client. n_to_process = self._adaptive_chunk_size( n_to_process, request_id=request.request_id, loop_label="external", kv_len=base_size + processed_tokens, ) # Pre-chunk safety guard: NEVER submit a chunk whose predicted peak # would breach the prefill safety cap. The Metal command-buffer # OOM is an async, uncatchable SIGABRT, so it must be prevented # before submission — a post-chunk check is too late. Falls back to # min_chunk after a reclaim; raises gracefully only if even the # floor can't fit (caught by the #1405 path → requeue/clean error). n_to_process = self._guard_prefill_chunk( n_to_process, kv_len=base_size + processed_tokens, progress=processed_tokens, loop_label="external", request_id=request.request_id, ) _throttle_pre = get_phys_footprint() # External prefill bypasses BatchGenerator, so it must establish # the per-engine stream context itself. Native lazy primitives # otherwise bind to the worker's unrelated default stream and can # fail at mx.eval with "There is no Stream(gpu, X) in current # thread" (issue #2170). The chunk views (input slices, VLM embed # slices, and the advance views for the next chunk) stay inside # the same context: a worker-default-stream view splits the chunk # eval graph across two streams and adds a per-chunk cross-stream # fence, the synchronization pattern implicated in the #2197 and # #2183 engine hangs on macOS 26. with mx.stream(self._stream): model_kwargs: dict[str, Any] = {} if embeds_array is not None and embeds_array.shape[1] > 0: model_kwargs["inputs_embeds"] = embeds_array[:, :n_to_process] if extra_kwargs: model_kwargs["vlm_extra_kwargs"] = _slice_vlm_extra( extra_kwargs, n_to_process ) self.model( input_arr[:, :n_to_process], cache=prompt_cache, **model_kwargs, ) mx.eval([c.state for c in prompt_cache]) input_arr = input_arr[:, n_to_process:] if embeds_array is not None: embeds_array = embeds_array[:, n_to_process:] if extra_kwargs: extra_kwargs = _advance_vlm_extra(extra_kwargs, n_to_process) _throttle_post = get_phys_footprint() self._record_chunk_transient( n_to_process, _throttle_pre, _throttle_post, request_id=request.request_id, loop_label="external", ) processed_tokens += n_to_process # Progress callback if uid is not None: self._on_prompt_progress([(uid, processed_tokens, total_length)]) # Boundary snapshot emission if boundary_enabled: total_tokens = base_size + processed_tokens if ( total_tokens > 0 and total_tokens % block_size == 0 and emitted_boundaries.get(request.request_id, -1) < total_tokens ): self._emit_prefill_boundary_snapshot( request, prompt_cache, total_tokens ) emitted_boundaries[request.request_id] = total_tokens # Memory monitoring — use max(active, phys_footprint) so MLX # cache pool and IOAccelerator-backed allocations that don't # show in mx.get_active_memory() still trigger the guard. # See utils/proc_memory.py for why phys_footprint matters. if self._memory_limit_bytes > 0: current = self._current_usage_bytes() _hard = self._memory_hard_limit_bytes _soft = self._memory_limit_bytes # Only log when crossing the soft watermark — that's the # caution zone where adaptive throttle decisions matter. # Skipped on healthy traffic to keep the log quiet. if current > _soft: logger.debug( "[memcheck:external] rid=%s n=%d processed=%d " "current=%.3fGB soft=%.3fGB hard=%.3fGB %s", request.request_id, n_to_process, processed_tokens, current / 1024**3, _soft / 1024**3, _hard / 1024**3, "OVER_HARD" if _hard > 0 and current > _hard else "OVER_SOFT", ) # Abort decision uses the STABLE physical cap, not the jittery # dynamic ceiling: only kill an in-flight prefill if it would # breach what Metal actually allows. Throttling above still # targets the dynamic ceiling. Falls back to the dynamic hard # limit if the abort limit hasn't been propagated yet. _abort = self._memory_abort_limit_bytes or self._memory_hard_limit_bytes if _abort > 0 and current > _abort: # Reclaim the just-computed chunk's Metal transients before # giving up — they are still resident at this pre-clear # check and are usually what tipped us over the cap. current = self._reclaim_prefill_headroom() if current > _abort: logger.warning( f"Prefill force-stopped at {processed_tokens} " f"tokens: memory {current / 1024**3:.1f}GB " f"exceeds physical cap " f"{_abort / 1024**3:.1f}GB (after reclaim)" ) raise RuntimeError("Memory limit exceeded during prefill") logger.info( "Prefill recovered after reclaim at %d tokens " "(%.1fGB <= cap %.1fGB)", processed_tokens, current / 1024**3, _abort / 1024**3, ) elif current > self._memory_limit_bytes: logger.warning( f"Prefill above max_bytes at " f"{processed_tokens} tokens: " f"{current / 1024**3:.1f}GB > " f"{self._memory_limit_bytes / 1024**3:.1f}GB " f"(ceiling: " f"{self._memory_hard_limit_bytes / 1024**3:.1f}GB)" ) # Check for pending aborts between prefill chunks. abort_uids = self._check_pending_aborts_for_uids( [uid] if uid is not None else [] ) if abort_uids: logger.info( f"Prefill interrupted at {processed_tokens}/" f"{total_length} tokens: " f"{len(abort_uids)} request(s) aborted" ) if vlm_embeds is not None and _saved_rope_deltas is not None: self.model._language_model._rope_deltas = _saved_rope_deltas request._prefill_saved_rope_deltas = None # Drop partial-prefill references before clearing the Metal pool. # Otherwise the traceback frame can keep large KV/cache arrays # alive until after the abort handler returns. input_arr = None embeds_array = None extra_kwargs = None model_kwargs = {} prompt_cache = None _sync_and_clear_cache(self._stream) raise _PrefillAbortedError(abort_uids, processed_tokens) # Reclaim Metal intermediates between prefill chunks. _sync_and_clear_cache(self._stream) # Emit final boundary snapshot if prompt lands exactly on boundary. if boundary_enabled: total_tokens = base_size + processed_tokens if ( total_tokens > 0 and total_tokens % block_size == 0 and emitted_boundaries.get(request.request_id, -1) < total_tokens ): self._emit_prefill_boundary_snapshot( request, prompt_cache, total_tokens ) _sync_and_clear_cache(self._stream) # Restore _rope_deltas after cached VLM prefill (for decode capture) if vlm_embeds is not None and _saved_rope_deltas is not None: self.model._language_model._rope_deltas = _saved_rope_deltas request._prefill_saved_rope_deltas = None # Quantize the completed fp16 KV cache to TurboQuant for decode. # Done here (after the prefill loop, after boundary snapshots are # captured fp16) so prefill hidden states stay exact and the paged-SSD # format is unchanged. _merge_caches() then builds a # BatchTurboQuantKVCache when this request is inserted. Gated to dense # KVCache models — chunked/rotating caches stay fp16. if self._turboquant_kv_bits is not None and self._turboquant_eligible( prompt_cache ): self._apply_turboquant_kv_convert(prompt_cache) if getattr(request, "cached_tokens", 0) > 0: with mx.stream(self._stream): _materialize_cache_storage(prompt_cache) return prompt_cache, last_token # ------------------------------------------------------------------ # Adaptive prefill throttle # ------------------------------------------------------------------ # Discrete step sizes used by the watermark-based throttle. Each tier # halves SDPA-fallback transient (∝ query_len × kv_len), so crossing # one tier under memory pressure roughly doubles the available # headroom for the next chunk's intermediates. _PREFILL_STEP_TIERS: tuple[int, ...] = (1024, 512) # Safety margin applied to the headroom (hard_cap - current) when sizing # a chunk predictively. The remaining 10% absorbs estimator error and # Metal command-buffer overhead above the modeled SDPA + KV growth. _PREFILL_HEADROOM_SAFETY: float = 0.90 # Default fraction of the physical abort cap we allow a chunk's predicted # PEAK to reach. ProcessMemoryEnforcer can override this per tier. The # remaining headroom is reserved for Metal command-buffer overhead: a chunk # whose peak lands on the wired limit can make Metal abort the command # buffer asynchronously (kIOGPUCommandBufferCallbackError OutOfMemory) — # an uncatchable SIGABRT — so we keep a hard margin below it. _PREFILL_ABORT_MARGIN: float = 0.90 # Safety multiplier on the predicted per-chunk transient. The transient # scales with query_len * kv_len, so per-token cost grows with context # length; this covers one chunk's worth of growth + measurement noise. _PREFILL_TRANSIENT_SAFETY: float = 1.3 _MEMORY_ADMISSION_STALL_TIMEOUT_S: float = 60.0 _STORE_CACHE_ADMISSION_STALL_TIMEOUT_S: float = 60.0 def _predicted_chunk_transient(self, n_tokens: int, kv_len: int) -> float: """Conservative predicted Metal peak growth for one prefill chunk. The per-chunk SDPA/MoE transient scales with ``query_len * kv_len``, so the per-token cost GROWS with context length. A long-run EWMA average lags that growth and underestimates the next chunk — the cause of the Metal command-buffer OOM crash at large kv_len. We therefore take the MAX of three signals and apply a safety factor: - the most recently MEASURED per-token growth (last_delta / last_n) — anchored on reality at the current kv_len regime, - the long-run EWMA (model-specific constants the static misses), - the kv_len-aware static estimate (SDPA transient + this chunk's newly allocated KV). Returns 0 only when nothing is known (first chunk, no model info). """ if n_tokens <= 0: return 0.0 per_token = 0.0 tracker = self._prefill_transient_tracker if tracker is not None: if tracker.last_n_tokens > 0 and tracker.last_delta_bytes > 0: per_token = max( per_token, tracker.last_delta_bytes / tracker.last_n_tokens ) if tracker.bytes_per_token > 0: per_token = max(per_token, tracker.bytes_per_token) if self.memory_monitor is not None: static = self.memory_monitor.estimate_chunk_transient_bytes( n_tokens, kv_len + n_tokens ) static += self.memory_monitor.estimate_prompt_kv_bytes(n_tokens) per_token = max(per_token, float(static) / n_tokens) return per_token * n_tokens * self._PREFILL_TRANSIENT_SAFETY def _prefill_abort_cap(self) -> int: """Safety cap a chunk's predicted peak must stay under. Uses the stable abort limit (min(static, metal_cap)) with a margin so we never submit a chunk that could trip the async Metal OOM. Falls back to the dynamic hard limit before the abort limit is propagated. """ cap = self._memory_abort_limit_bytes or self._memory_hard_limit_bytes return int(cap * self._prefill_abort_margin) if cap > 0 else 0 def _prefill_abort_description(self) -> tuple[int, int, float]: """Return (base cap, safety cap, margin) for diagnostics.""" base_cap = self._memory_abort_limit_bytes or self._memory_hard_limit_bytes safety_cap = self._prefill_abort_cap() return base_cap, safety_cap, self._prefill_abort_margin def _sdpa256_unfused_headroom(self) -> int: """Live headroom (bytes) for one unfused SDPA transient, under the same target the adaptive prefill throttle enforces (hard ceiling x headroom safety, clamped by the abort cap). Negative when no ceiling is active (enforcer not propagated yet / guard disabled), which tells the sdpa256 route to keep its memory-safe tiled default. Called from the route gate on the MLX step thread mid-prefill, where refreshing the active-memory sample is safe (issue #2204).""" hard_cap = self._memory_hard_limit_bytes if hard_cap <= 0: return -1 headroom_safety = getattr( self, "_prefill_headroom_safety", self._PREFILL_HEADROOM_SAFETY ) target = int(hard_cap * headroom_safety) abort_cap = self._prefill_abort_cap() if abort_cap > 0: target = min(target, abort_cap) return target - self._current_usage_bytes() _MAX_PREFILL_EVICTION_RETRIES = 1 def _raise_prefill_eviction_if_available( self, *, request_id: str, current: int, target_cap: int, predicted_transient: int, requested_tokens: int, reason: str, ) -> None: """Pause a request once so EngineCore can evict idle LRU models.""" request = self.requests.get(request_id) if request is None: return max_retries = getattr( self, "_MAX_PREFILL_EVICTION_RETRIES", Scheduler._MAX_PREFILL_EVICTION_RETRIES, ) if request.prefill_eviction_retries >= max_retries: return if target_cap <= 0 or predicted_transient <= 0: return request.prefill_eviction_retries += 1 config = getattr(self, "config", None) eviction_request = PrefillEvictionRequest( request_id=request_id, model_id=getattr(config, "model_name", ""), current_bytes=int(current), target_cap_bytes=int(target_cap), predicted_transient_bytes=int(predicted_transient), requested_tokens=int(requested_tokens), reason=reason, ) logger.info( "Request %s needs prefill headroom before throttling " "(reason=%s, current=%.2fGB, predicted=%.2fGB, target=%.2fGB)", request_id, reason, current / 1024**3, predicted_transient / 1024**3, target_cap / 1024**3, ) raise _PrefillEvictionNeeded(eviction_request) def _guard_prefill_chunk( self, n_tokens: int, *, kv_len: int, progress: int, loop_label: str, request_id: str | None = None, ) -> int: """Clamp/abort a prefill chunk so its predicted peak can never reach the physical Metal cap (the uncatchable async OOM crash). Returns a chunk size whose predicted peak fits under the margined cap (possibly shrunk from ``n_tokens``). If even the minimum chunk would not fit after a reclaim, raises a clean RuntimeError — the context is genuinely too large for available memory. That message intentionally does NOT contain "Memory limit exceeded", so ``_requeue_or_fail_prefill`` fails it fast with a clear error rather than looping a doomed retry. """ base_cap, cap, margin = self._prefill_abort_description() if cap <= 0: return n_tokens min_chunk = max(1, self._prefill_min_chunk_tokens) current = self._current_usage_bytes() if current + self._predicted_chunk_transient(n_tokens, kv_len) <= cap: return n_tokens # Predicted to breach — reclaim transients and re-measure once. current = self._reclaim_prefill_headroom() min_transient = self._predicted_chunk_transient(min_chunk, kv_len) if current + min_transient > cap: maybe_raise_eviction = getattr( self, "_raise_prefill_eviction_if_available", None ) if request_id is not None and callable(maybe_raise_eviction): maybe_raise_eviction( request_id=request_id, current=current, target_cap=cap, predicted_transient=int(min_transient), requested_tokens=min_chunk, reason="prefill_safety_cap", ) logger.warning( "[guard:%s] context too large at progress=%d kv_len=%d: " "%.2fGB + min-chunk transient exceeds prefill safety cap " "%.2fGB (%d%% of effective ceiling %.2fGB)", loop_label, progress, kv_len, current / 1024**3, cap / 1024**3, round(margin * 100), base_cap / 1024**3, ) message = ( "Prefill context too large for available memory " f"(pre-chunk guard at {progress} tokens, kv_len={kv_len}): " "predicted peak would exceed prefill safety cap " f"{cap / 1024**3:.1f}GB " f"({round(margin * 100)}% of effective ceiling " f"{base_cap / 1024**3:.1f}GB)" ) raise PrefillMemoryExceededError( message=message, request_id=request_id, estimated_bytes=int(current + min_transient), limit_bytes=int(cap), ) # The floor fits — pick the largest chunk that still fits under the cap. per_token = self._predicted_chunk_transient(n_tokens, kv_len) / n_tokens safe_n = int((cap - current) / per_token) if per_token > 0 else n_tokens n_fit = max(min_chunk, min(n_tokens, safe_n)) if n_fit < n_tokens: logger.debug( "[guard:%s] shrink %d -> %d at progress=%d kv_len=%d " "(current=%.2fGB cap=%.2fGB)", loop_label, n_tokens, n_fit, progress, kv_len, current / 1024**3, cap / 1024**3, ) return n_fit def _adaptive_chunk_size( self, requested: int, *, request_id: str, loop_label: str, kv_len: int = 0, ) -> int: """Size the next prefill chunk so its predicted peak stays under a safety margin below the hard cap. The chunk is sized so that ``current + predicted_transient(n) <= hard_cap * safety``. If the full requested chunk already fits, it runs unchanged — no behavior change on healthy traffic. Crucially the gate is on the *predicted peak*, not on current memory crossing the soft watermark: a single large chunk's transient (e.g. MoE prefill at tens of MB/token) can blow the ceiling from a low baseline before current ever reaches the watermark, which is the failure this prevents. Two predictors feed the sizing: - Measured: once the per-scheduler EWMA has samples, use its ``bytes_per_token`` (× the same 1.2 safety factor ``predict()`` applies) — this is measurement-based and model-agnostic. - First chunk (no samples yet): fall back to the static SDPA + KV growth estimate for the requested candidate chunk. ``kv_len`` is the current context span (cached prefix + already-prefilled tokens), so a large prefix-cache hit with a small suffix is throttled correctly without classifying large prefill chunks as vector-path traffic. The discrete watermark tiers are retained as a *secondary clamp* — they only ever shrink further, never enlarge the predicted size. The chunk-end memory check (``self._memory_hard_limit_bytes`` comparison in the prefill loops) remains the safety net: if memory still exceeds the cap after this shrink, the loop attempts reclaim (``_reclaim_prefill_headroom``) and, failing that, raises so the #1405 cleanup path can requeue or emit ``finish_reason="error"``. Args: requested: The chunk size the caller would have used without throttle (already clamped by boundary alignment). request_id: For debug log correlation. loop_label: "external" or "chunked_step", used only for debug log identification. kv_len: Current context span (base/cached + processed tokens) used for the first-chunk static peak-growth estimate. Returns: The chunk size to actually process (>= 1, <= requested). """ soft_base = self._memory_limit_bytes hard_cap = self._memory_hard_limit_bytes if soft_base <= 0 or hard_cap <= 0 or requested <= 0: return requested current = self._current_usage_bytes() min_chunk = max(1, self._prefill_min_chunk_tokens) # Conservative per-token peak growth (measured-last / EWMA / static, × # safety) — see _predicted_chunk_transient. Anchored on the most recent # measurement so it tracks growth with kv_len instead of lagging behind # a long-run average. per_token = self._predicted_chunk_transient(requested, kv_len) / requested predictor = "measured" if per_token > 0 else "none" # Keep each chunk's predicted peak under the LOWER of the dynamic # throttle target and the prefill safety cap, so the peak can never # reach the Metal wall (the uncatchable async OOM). headroom_safety = getattr( self, "_prefill_headroom_safety", self._PREFILL_HEADROOM_SAFETY ) safe_target = int(hard_cap * headroom_safety) abort_cap = self._prefill_abort_cap() target = min(safe_target, abort_cap) if abort_cap > 0 else safe_target soft_watermark = int(soft_base * self._prefill_safe_zone_ratio) if per_token <= 0: # No usable predictor (e.g. model info unavailable). Fall back to # the legacy watermark gate so we never run unbounded. if current < soft_watermark: return requested n_fit = requested else: # Predicted-peak gate: if the FULL requested chunk fits under the # target it runs unchanged (covers all healthy traffic). Gated on # the predicted peak, not on current crossing the soft watermark — # a single big chunk's transient can blow the cap from a low # baseline (MoE prefill at tens of MB/token), the failure this # prevents. if current + per_token * requested <= target: return requested maybe_raise_eviction = getattr( self, "_raise_prefill_eviction_if_available", None ) if callable(maybe_raise_eviction): maybe_raise_eviction( request_id=request_id, current=current, target_cap=target, predicted_transient=int(per_token * requested), requested_tokens=requested, reason="adaptive_prefill_throttle", ) headroom = max(target - current, 0) n_fit = int(headroom / per_token) n = max(min_chunk, min(requested, n_fit)) # Secondary clamp: once in the watermark caution zone, cap by the # discrete tiers so a mispredicting EWMA can't run an oversized chunk # in deep pressure. Skipped below the watermark so a low-baseline chunk # with ample headroom isn't needlessly shrunk. band_ratio = -1.0 if current >= soft_watermark and hard_cap > soft_watermark: band = hard_cap - soft_watermark band_ratio = max(0.0, min(1.0, (current - soft_watermark) / band)) if band_ratio < 0.50: bucket = self._PREFILL_STEP_TIERS[0] # 1024 else: bucket = self._PREFILL_STEP_TIERS[1] # 512 n = max(min_chunk, min(n, bucket)) if n < requested: logger.debug( "[throttle:%s] shrink rid=%s chunk %d -> %d " "(predictor=%s per_token=%.1fKB current=%.2fGB " "safe_target=%.2fGB ceiling=%.2fGB kv_len=%d band_ratio=%.2f)", loop_label, request_id, requested, n, predictor, per_token / 1024, current / 1024**3, safe_target / 1024**3, hard_cap / 1024**3, kv_len, band_ratio, ) return n def get_cached_mlx_active_memory_bytes(self) -> int: """Return the last MLX active-memory sample taken on the executor.""" return self._last_mlx_active_memory_bytes def _hot_cache_cpu_bytes(self) -> int: """Return serialized hot-cache bytes safe to exclude from phys guard.""" config = getattr(self, "config", None) budget = getattr(config, "hot_cache_budget", None) if budget is not None: try: return max(0, int(getattr(budget, "total_bytes", 0))) except Exception: logger.debug("Failed to read shared hot-cache byte budget") return 0 manager = getattr(self, "paged_ssd_cache_manager", None) if manager is None: return 0 try: stats = manager.get_stats() return max(0, int(getattr(stats, "hot_cache_size_bytes", 0))) except Exception: try: return max(0, int(getattr(manager, "_hot_cache_total_bytes", 0))) except Exception: logger.debug("Failed to read local hot-cache byte counter") return 0 def _current_usage_bytes(self, *, refresh_mlx_active: bool = True) -> int: """Current memory usage for scheduler-side guard checks. Scheduler steps run on the MLX executor thread, so they can refresh mx.get_active_memory() safely. Event-loop callers such as early preflight use the cached executor sample and phys_footprint instead. """ active = self._last_mlx_active_memory_bytes if refresh_mlx_active: active = max(0, int(mx.get_active_memory())) self._last_mlx_active_memory_bytes = active hot_cache_cpu_bytes = getattr(self, "_hot_cache_cpu_bytes", None) if callable(hot_cache_cpu_bytes): hot_cache_bytes = hot_cache_cpu_bytes() else: hot_cache_bytes = Scheduler._hot_cache_cpu_bytes(self) phys = max(0, int(get_phys_footprint()) - hot_cache_bytes) return max(active, phys) def get_active_hot_cache_block_hashes(self) -> set[bytes]: """Return hot-cache block hashes owned by active in-flight requests.""" manager = getattr(self, "paged_cache_manager", None) if manager is None: return set() hashes: set[bytes] = set() active_requests = list(self.running.values()) + list(self.prefilling) for request in active_requests: block_table = getattr(request, "block_table", None) if block_table is None: continue for block_id in getattr(block_table, "block_ids", []) or []: try: block = manager.blocks[block_id] block_hash = getattr(block, "block_hash", None) except Exception: continue if block_hash is not None: hashes.add(bytes(block_hash)) return hashes def _clear_memory_admission_blocker(self, request_id: str | None = None) -> None: if ( request_id is not None and request_id != self._memory_admission_blocked_request_id ): return self._memory_admission_blocked_request_id = None self._memory_admission_blocked_since = 0.0 def _clear_store_cache_admission_blocker( self, request_id: str | None = None ) -> None: if ( request_id is not None and request_id != self._store_cache_admission_blocked_request_id ): return self._store_cache_admission_blocked_request_id = None self._store_cache_admission_blocked_since = 0.0 def _clear_request_admission_bookkeeping(self, request_id: str) -> None: self._cache_freshness_waits.pop(request_id, None) self._prefix_cache_prepared.discard(request_id) self._clear_memory_admission_blocker(request_id) self._clear_store_cache_admission_blocker(request_id) def _memory_admission_stall_output(self, reason: str) -> RequestOutput | None: """Fail one head-of-line request after persistent memory admission stall.""" if not self.waiting: self._clear_memory_admission_blocker() return None request = self.waiting[0] request_id = request.request_id now = time.monotonic() if request_id != self._memory_admission_blocked_request_id: self._memory_admission_blocked_request_id = request_id self._memory_admission_blocked_since = now return None timeout = getattr( self, "_MEMORY_ADMISSION_STALL_TIMEOUT_S", Scheduler._MEMORY_ADMISSION_STALL_TIMEOUT_S, ) if now - self._memory_admission_blocked_since < timeout: return None stalled_for = now - self._memory_admission_blocked_since self.waiting.popleft() self._release_paged_cache_for_request(request_id) self.requests.pop(request_id, None) self._clear_request_admission_bookkeeping(request_id) get_prefill_tracker().remove(request_id) self._clear_memory_admission_blocker(request_id) message = ( "Request could not be admitted because memory pressure persisted " f"for {stalled_for:.1f}s ({reason}). Reduce context length, free " "memory, lower hot_cache_max_size, or loosen memory_guard_tier." ) logger.warning("Memory admission stalled for %s: %s", request_id, message) return RequestOutput( request_id=request_id, finished=True, finish_reason="error", error=message, error_code="memory_admission_stalled", error_metadata={ "request_id": request_id, "reason": reason, "stalled_seconds": int(stalled_for), }, ) def _store_cache_admission_stall_output( self, reason: str, *, gate_in_flight: int, gate_cap: int, pending_cleanups: int, ) -> RequestOutput | None: """Fail one head-of-line request after persistent store-cache stall.""" if not self.waiting: self._clear_store_cache_admission_blocker() return None request = self.waiting[0] request_id = request.request_id now = time.monotonic() if request_id != self._store_cache_admission_blocked_request_id: self._store_cache_admission_blocked_request_id = request_id self._store_cache_admission_blocked_since = now return None timeout = getattr( self, "_STORE_CACHE_ADMISSION_STALL_TIMEOUT_S", Scheduler._STORE_CACHE_ADMISSION_STALL_TIMEOUT_S, ) if now - self._store_cache_admission_blocked_since < timeout: return None stalled_for = now - self._store_cache_admission_blocked_since self.waiting.popleft() self._release_paged_cache_for_request(request_id) self.requests.pop(request_id, None) self._clear_request_admission_bookkeeping(request_id) get_prefill_tracker().remove(request_id) message = ( "Request could not be admitted because store-cache cleanup stayed " f"full for {stalled_for:.1f}s ({reason}). The previous response " "cache is still being persisted; retry after the cache writer drains " "or reduce cache/write pressure." ) logger.warning( "Store-cache admission stalled for %s: %s " "(in_flight=%d pending_cleanups=%d cap=%d)", request_id, message, gate_in_flight, pending_cleanups, gate_cap, ) return RequestOutput( request_id=request_id, finished=True, finish_reason="error", error=message, error_code="store_cache_admission_stalled", error_metadata={ "request_id": request_id, "reason": reason, "stalled_seconds": int(stalled_for), "store_cache_in_flight": gate_in_flight, "pending_store_cleanups": pending_cleanups, "store_cache_cap": gate_cap, }, ) def _bypass_hot_cache_under_pressure(self) -> bool: """Return True when SSD-backed hot-cache acceleration should be bypassed.""" if not self._prefill_memory_guard: return False if self._memory_limit_bytes <= 0: return False config = getattr(self, "config", None) if config is None: return False if getattr(config, "hot_cache_only", False): return False if int(getattr(config, "hot_cache_max_size", 0) or 0) <= 0: return False if getattr(self, "paged_ssd_cache_manager", None) is None: return False try: current = self._current_usage_bytes() except Exception: logger.debug("Failed to sample memory for hot-cache pressure bypass") return False return current >= self._memory_limit_bytes def _record_chunk_transient( self, n_tokens: int, pre_bytes: int, post_bytes: int, *, request_id: str, loop_label: str, ) -> None: """Feed one chunk's measured transient into the EWMA tracker.""" delta = post_bytes - pre_bytes min_chunk = max(1, self._prefill_min_chunk_tokens) if n_tokens < min_chunk: logger.debug( "[throttle:%s] measure rid=%s n=%d delta=%.2fMB " "(skipped: tail < min_chunk=%d)", loop_label, request_id, n_tokens, delta / 1024**2, min_chunk, ) return if delta <= 0: logger.debug( "[throttle:%s] measure rid=%s n=%d delta=%dB (skipped: <=0)", loop_label, request_id, n_tokens, delta, ) return self._prefill_transient_tracker.update(n_tokens, delta) logger.debug( "[throttle:%s] measure rid=%s n=%d transient=%.2fMB " "per_token=%.1fKB ewma=%.1fKB samples=%d", loop_label, request_id, n_tokens, delta / 1024**2, (delta / max(n_tokens, 1)) / 1024, self._prefill_transient_tracker.bytes_per_token / 1024, self._prefill_transient_tracker.samples, ) def _reclaim_prefill_headroom(self) -> int: """Reclaim Metal headroom mid-prefill and return the re-measured usage. The prefill loops measure the hard-limit at the chunk boundary, which is *before* the per-chunk ``_sync_and_clear_cache`` runs — so the just-completed forward pass's SDPA intermediates are still resident when the limit is checked. Synchronizing and clearing the Metal buffer cache here releases those transients, which is exactly the spike that drives prefill OOM (observed: 42.8GB at the check → 24.6GB after the buffers are reclaimed). This is the only lever that actually lowers the physical footprint: paged-cache block eviction merely recycles ``CacheBlock`` metadata back into the free queue (the pool never shrinks, see ``PagedCacheManager._grow_blocks``), so it is deliberately not attempted here — it would drop reusable prefix-cache entries for no memory benefit. Returns: ``max(active, phys_footprint)`` after reclaim. """ _sync_and_clear_cache(self._stream) return self._current_usage_bytes() # ------------------------------------------------------------------ # Chunked prefill helpers (used when config.chunked_prefill=True) # ------------------------------------------------------------------ def _prefill_step_size_for_progress( self, processed_tokens: int, remaining_tokens: int ) -> int: """Return the scheduler prefill chunk size for the current progress.""" adaptive_prefill = self._glm_dsa_adaptive_prefill if adaptive_prefill is not None: from .patches.glm_moe_dsa.generate_patch import ( _prefill_step_size_for_progress, ) return _prefill_step_size_for_progress( self.config.prefill_step_size, processed_tokens, remaining_tokens, adaptive_prefill, ) adaptive_prefill = getattr(self, "_minimax_m3_adaptive_prefill", None) if adaptive_prefill is None: return self.config.prefill_step_size from .patches.minimax_m3.generate_patch import ( _prefill_step_size_for_progress as _minimax_prefill_step_size, ) return _minimax_prefill_step_size( self.config.prefill_step_size, processed_tokens, remaining_tokens, adaptive_prefill, ) def _begin_prefill( self, request: "Request", tokens: list[int], existing_cache: "list[Any] | None", ) -> _PrefillState: """Initialise a _PrefillState for a non-VLM request. Performs all once-per-request setup (cache creation, boundary config, token splitting) without running any model forward passes. """ if hasattr(self.model, "clear_vlm_position_state"): self.model.clear_vlm_position_state() _seed_text_only_mrope_delta_for_cached_prefill(self.model, request) prompt_cache = ( existing_cache if existing_cache is not None else make_prompt_cache(self.model) ) block_size = self.config.paged_cache_block_size boundary_enabled = ( block_size > 0 and self.block_aware_cache is not None and _prompt_cache_needs_snapshots(prompt_cache) ) base_size = _cache_base_sizes(prompt_cache) if boundary_enabled else 0 if ( boundary_enabled and hasattr(request, "cached_tokens") and request.cached_tokens > 0 and base_size != request.cached_tokens ): logger.debug( "Cache base_size mismatch: computed %d, expected %d " "(cached_tokens). Using cached_tokens for boundary alignment.", base_size, request.cached_tokens, ) base_size = request.cached_tokens prefill_tokens = tokens[:-1] last_token = tokens[-1:] # Build the input row on the engine stream so chunk eval graphs stay # single-stream (see _do_external_prefill, #2197/#2183). with mx.stream(self._stream): input_arr = mx.array(prefill_tokens)[None] # (1, N-1) return _PrefillState( request=request, cache=prompt_cache, tokens_remaining=input_arr, last_token=last_token, tokens_processed=0, base_size=base_size, emitted_boundaries={}, boundary_enabled=boundary_enabled, block_size=block_size, total_length=len(tokens), ) def _step_prefill_chunk(self, state: _PrefillState) -> bool: """Process one prefill chunk from *state*. Runs the model on at most prefill_step_size tokens, evals the cache, emits any due boundary snapshot, updates the prefill progress tracker, and clears Metal intermediates. Returns: True when all tokens_remaining have been consumed (prefill done). Raises: RuntimeError: If the hard memory limit is exceeded. """ if state.tokens_remaining.shape[1] == 0: return True remaining = state.tokens_remaining.shape[1] prefill_step_size = self._prefill_step_size_for_progress( state.tokens_processed, remaining ) n = min(prefill_step_size, remaining) if state.tokens_processed == 0: _sync_and_clear_cache(self._stream) # Clamp to the next block boundary so boundary snapshots fire exactly. if state.boundary_enabled and state.block_size > 0: current_total = state.base_size + state.tokens_processed next_boundary = ((current_total // state.block_size) + 1) * state.block_size delta = (next_boundary - state.base_size) - state.tokens_processed if delta > 0: n = min(n, delta) n = max(1, n) # Adaptive throttle — see _adaptive_chunk_size docstring. Raises # if even prefill_min_chunk_tokens would exceed the cap; #1405 # cleanup paths in _schedule_waiting / _advance_chunked_prefills # convert that into a finish_reason="error" output for the client. n = self._adaptive_chunk_size( n, request_id=state.request.request_id, loop_label="chunked_step", kv_len=state.base_size + state.tokens_processed, ) # Pre-chunk safety guard (mirrors the external loop): never submit a # chunk whose predicted peak would trip the uncatchable async Metal OOM. n = self._guard_prefill_chunk( n, kv_len=state.base_size + state.tokens_processed, progress=state.tokens_processed, loop_label="chunked_step", request_id=state.request.request_id, ) _throttle_pre = get_phys_footprint() # Chunked prefill also bypasses BatchGenerator and must establish the # same per-engine stream context as the regular external prefill path. # The chunk views stay inside it for the same reason (single-stream # chunk eval graph, #2197/#2183). with mx.stream(self._stream): chunk = state.tokens_remaining[:, :n] state.tokens_remaining = state.tokens_remaining[:, n:] self.model(chunk, cache=state.cache) mx.eval([c.state for c in state.cache]) _throttle_post = get_phys_footprint() self._record_chunk_transient( n, _throttle_pre, _throttle_post, request_id=state.request.request_id, loop_label="chunked_step", ) state.tokens_processed += n # Boundary snapshot if state.boundary_enabled: total_tokens = state.base_size + state.tokens_processed rid = state.request.request_id if ( total_tokens > 0 and total_tokens % state.block_size == 0 and state.emitted_boundaries.get(rid, -1) < total_tokens ): self._emit_prefill_boundary_snapshot( state.request, state.cache, total_tokens ) state.emitted_boundaries[rid] = total_tokens # Progress callback so the admin UI prefilling list advances during # chunked prefill. _do_external_prefill calls _on_prompt_progress # via the temp_uid mapping; the chunked path has no temp uid so we # talk to the tracker directly with the request_id. get_prefill_tracker().update( state.request.request_id, state.tokens_processed, state.total_length - 1, ( self.config.model_name if self.config.model_name else "" ), ) # Memory monitoring — use max(active, phys_footprint) so MLX cache # pool and IOAccelerator-backed allocations that don't show up in # mx.get_active_memory() still trigger the guard. Matches the # _do_external_prefill check; on macOS jetsam watches # phys_footprint, so the active-only check could miss the page # before the kernel kills us. if self._memory_limit_bytes > 0: current = self._current_usage_bytes() _hard = self._memory_hard_limit_bytes _soft = self._memory_limit_bytes # Caution-zone-only memcheck log (see external loop counterpart). if current > _soft: logger.debug( "[memcheck:chunked_step] rid=%s n=%d processed=%d/%d " "current=%.3fGB soft=%.3fGB hard=%.3fGB %s", state.request.request_id, n, state.tokens_processed, state.total_length - 1, current / 1024**3, _soft / 1024**3, _hard / 1024**3, "OVER_HARD" if _hard > 0 and current > _hard else "OVER_SOFT", ) # Abort on the stable physical cap, not the jittery dynamic ceiling # (mirrors the external prefill loop). _abort = self._memory_abort_limit_bytes or self._memory_hard_limit_bytes if _abort > 0 and current > _abort: # Reclaim the just-computed chunk's Metal transients before # giving up (mirrors the external prefill loop). current = self._reclaim_prefill_headroom() if current > _abort: raise RuntimeError( f"Memory limit exceeded during chunked prefill at " f"{state.tokens_processed}/{state.total_length - 1} tokens: " f"{current / 1024**3:.1f}GB exceeds physical cap " f"{_abort / 1024**3:.1f}GB (after reclaim)" ) logger.info( "Chunked prefill recovered after reclaim at %d/%d tokens " "(%.1fGB <= cap %.1fGB)", state.tokens_processed, state.total_length - 1, current / 1024**3, _abort / 1024**3, ) elif current > self._memory_limit_bytes: logger.warning( f"Chunked prefill above max_bytes at " f"{state.tokens_processed} tokens: " f"{current / 1024**3:.1f}GB > " f"{self._memory_limit_bytes / 1024**3:.1f}GB " f"(ceiling: " f"{self._memory_hard_limit_bytes / 1024**3:.1f}GB)" ) _sync_and_clear_cache(self._stream) return state.tokens_remaining.shape[1] == 0 def _emit_final_boundary_if_needed(self, state: _PrefillState) -> None: """Emit a final boundary snapshot if the prefill landed on a boundary.""" if not state.boundary_enabled: return total_tokens = state.base_size + state.tokens_processed rid = state.request.request_id if ( total_tokens > 0 and total_tokens % state.block_size == 0 and state.emitted_boundaries.get(rid, -1) < total_tokens ): self._emit_prefill_boundary_snapshot( state.request, state.cache, total_tokens ) def _finalize_chunked_prefill_cache_for_insert( self, request: "Request", prompt_cache: list[Any] | None ) -> None: """Mirror external prefill's post-prefill cache epilogue.""" if not prompt_cache or self._turboquant_kv_bits is None: return if not self._turboquant_eligible(prompt_cache): return self._apply_turboquant_kv_convert(prompt_cache) if getattr(request, "cached_tokens", 0) > 0: with mx.stream(self._stream): _materialize_cache_storage(prompt_cache) _sync_and_clear_cache(self._stream) def _insert_prefilled_request( self, request: "Request", state: _PrefillState, scheduled: "list[Request]", ) -> None: """Insert a fully-prefilled request into BatchGenerator. Handles the batch_generator.insert() call, uid bookkeeping, and moving the request to self.running. Called from both the inline chunked path (first chunk completed immediately) and _advance_chunked_prefills() (last chunk completed across steps). Precondition: state.sampler, state.sm, state.per_row_lps are set. """ self._finalize_chunked_prefill_cache_for_insert(request, state.cache) if request.sampling_params.seed is not None: mx.random.seed(request.sampling_params.seed) per_row_lps = state.per_row_lps if state.per_row_lps is not None else [] uids = self.batch_generator.insert( [state.last_token], max_tokens=[request.sampling_params.max_tokens], caches=[state.cache] if state.cache else None, all_tokens=[_batch_generator_all_tokens(request)], samplers=[state.sampler], logits_processors=[per_row_lps], state_machines=[state.sm], ) if uids: _register_uid_rows(self.model, uids, [state.sampler], [per_row_lps]) uid = uids[0] self.request_id_to_uid[request.request_id] = uid self.uid_to_request_id[uid] = request.request_id now = time.monotonic() request.batch_uid = uid request.status = RequestStatus.RUNNING request.generation_started_at = now request.last_activity_at = now self.running[request.request_id] = request scheduled.append(request) if hasattr(self.model, "register_rope_delta"): self.model.register_rope_delta(uid, request.rope_deltas) self.total_prompt_tokens += request.num_prompt_tokens cache_info = ( f", {request.cached_tokens} cached" if request.cached_tokens > 0 else "" ) logger.debug( "Scheduled chunked-prefill request %s (uid=%d) " "with %d tokens (%d total)%s", request.request_id, uid, len(state.last_token), request.num_prompt_tokens, cache_info, ) def _advance_chunked_prefills( self, scheduled: "list[Request]", rejected: "list[RequestOutput]", ) -> None: """Process one prefill chunk per in-flight chunked-prefill request. Called at the start of each step() before _schedule_waiting(). Each call advances every request in self.prefilling by one prefill_step_size chunk. When a request's prefill completes it is inserted into BatchGenerator and moved to self.running. Args: scheduled: The step's running list of newly-scheduled requests; completed chunked-prefill requests are appended here. rejected: Per-step rejected outputs. A chunked prefill that hits the memory hard limit emits a finish_reason="error" entry here so the engine can surface the failure to the client. """ if not self.prefilling: return pending_prefills = list(self.prefilling) still_prefilling: deque[Request] = deque() for index, request in enumerate(pending_prefills): rid = request.request_id state = self._prefill_states.get(rid) # State missing means the request was aborted and cleaned up by # _do_abort_request() between steps — just skip it. if state is None: continue try: done = self._step_prefill_chunk(state) except _PrefillAbortedError: # Request aborted mid-chunk. Discard state; the abort will # be fully processed by _process_pending_aborts() next step. self._prefill_states.pop(rid, None) _sync_and_clear_cache(self._stream) continue except _PrefillEvictionNeeded as e: self._pending_prefill_eviction_request = e.request still_prefilling.append(request) still_prefilling.extend(pending_prefills[index + 1 :]) logger.info( "Paused chunked prefill request %s for LRU eviction " "(reason=%s)", rid, e.request.reason, ) break except PrefillMemoryExceededError as e: logger.error("Chunked prefill capacity rejected for %s: %s", rid, e) self._prefill_states.pop(rid, None) self._release_paged_cache_for_request(rid) self.requests.pop(rid, None) self._clear_request_admission_bookkeeping(rid) get_prefill_tracker().remove(rid) _sync_and_clear_cache() rejected.append(_prefill_memory_exception_output(rid, e)) continue except RuntimeError as e: logger.error("Chunked prefill failed for %s: %s", rid, e) self._prefill_states.pop(rid, None) self._release_paged_cache_for_request(rid) self.requests.pop(rid, None) self._clear_request_admission_bookkeeping(rid) get_prefill_tracker().remove(rid) # Drop Metal cache pool buffers held by the aborted chunk's # forward / mx.eval transients. Without this, enforcer keeps # seeing the burst footprint until the next mx.clear_cache(). _sync_and_clear_cache() # Try a bounded requeue before surfacing the failure: a # memory-pressure prefill gets a fresh, better-throttled # attempt. Only after the retry budget is exhausted (or for # non-memory errors) do we emit the client-facing error. if self._requeue_or_fail_prefill(request, e): continue # Surface the failure to the engine. Without this, the # request is silently dropped and the client hangs. rejected.append( RequestOutput( request_id=rid, finished=True, finish_reason="error", error=str(e), ) ) continue if not done: still_prefilling.append(request) continue # Prefill complete — emit final boundary snapshot and insert. self._prefill_states.pop(rid, None) self._emit_final_boundary_if_needed(state) _sync_and_clear_cache(self._stream) # Ensure a BatchGenerator exists (may not if all requests were # previously in chunked prefill with no running decode). self._ensure_batch_generator(request.sampling_params) if self.batch_generator is None: # Unlikely, but if BG creation fails put request back. logger.error( "BatchGenerator unavailable at chunked-prefill completion " "for %s; requeueing.", rid, ) still_prefilling.append(request) self._prefill_states[rid] = state continue # Clean up the prefill-progress tracker entry. get_prefill_tracker().remove(rid) self._insert_prefilled_request(request, state, scheduled) self.prefilling = still_prefilling def _build_state_machine(self, request: "Request") -> SequenceStateMachine: """Build a SequenceStateMachine for per-request stop tokens. Combines base stop tokens (EOS, Harmony) with request-specific stop_token_ids and tokenized stop strings into a single state machine that tells BatchGenerator when to stop generating for this request. """ stop_tokens_set = self._get_stop_tokens() if request.sampling_params.stop_token_ids: stop_tokens_set.update(request.sampling_params.stop_token_ids) transitions: dict[str, list] = { "normal": [([t], None) for t in stop_tokens_set] } # Tokenize stop strings into token sequences. mlx-lm's # SequenceStateMachine uses Aho-Corasick, so per-token match # cost stays O(1) regardless of how many sequences are added. # BPE merge edge cases (where a stop string boundary lands # mid-token) may miss; that is a known limitation. for stop_str in request.sampling_params.stop or []: if not isinstance(stop_str, str) or not stop_str: continue try: seq = self.tokenizer.encode(stop_str, add_special_tokens=False) except TypeError: seq = self.tokenizer.encode(stop_str) if seq: transitions["normal"].append((list(seq), None)) if transitions["normal"]: return SequenceStateMachine(transitions, initial="normal") return SequenceStateMachine({}, initial="normal") def _emit_prefill_boundary_snapshot( self, request: "Request", prompt_cache: list[Any], total_tokens: int, ) -> None: """Capture boundary snapshot from individual (non-batch) cache. During external prefill we have direct access to per-layer cache objects (not BatchKVCache). Extract non-sliceable layers for boundary snapshot storage. Pass ``request_id`` directly. The request is mid-prefill and has not been inserted into ``BatchGenerator`` yet, so ``request_id_to_uid`` has no entry for it. The earlier shape routed through ``self.request_id_to_uid.get(request_id, -1)`` → ``uid_to_request_id.get(-1)`` → ``None`` → silent return, dropping every snapshot. For ArraysCache / GDN / hybrid models that made every non-last block store a placeholder, and the next identical-prompt request rejected the cache and re- prefilled from scratch. """ snapshot_cache = [ c if type(c).__name__ not in _KNOWN_SLICEABLE_CACHE_TYPES else None for c in prompt_cache ] self._on_prefill_boundary_snapshot( request.request_id, snapshot_cache, total_tokens, ) def _build_sampler_and_processors( self, sampling_params: SamplingParams, request: Any = None ) -> tuple[Callable[[mx.array], mx.array], list[Callable]]: """Build per-request sampler and logits processors.""" # Use omlx.utils.sampling.make_sampler instead of mlx_lm.sample_utils. # The mlx-lm version decorates categorical_sampling and apply_* with # @partial(mx.compile, inputs=mx.random.state, outputs=mx.random.state), # which fails to advance the RNG state after the first call in this # server environment. Identical prompts then produce identical output # even at temperature > 1. sampler = omlx_make_sampler( temp=sampling_params.temperature, top_p=sampling_params.top_p, min_p=sampling_params.min_p, top_k=sampling_params.top_k, xtc_probability=sampling_params.xtc_probability, xtc_threshold=sampling_params.xtc_threshold, xtc_special_tokens=self._xtc_special_tokens, ) logits_processors = make_logits_processors( repetition_penalty=( sampling_params.repetition_penalty if sampling_params.repetition_penalty != 1.0 else None ), presence_penalty=( sampling_params.presence_penalty if sampling_params.presence_penalty != 0.0 else None ), frequency_penalty=( sampling_params.frequency_penalty if sampling_params.frequency_penalty != 0.0 else None ), ) suppress_processor = _make_suppress_logits_processor( self._model_suppress_tokens ) if suppress_processor is not None: logits_processors.append(suppress_processor) # Add thinking budget processor for reasoning models if ( sampling_params.thinking_budget is not None and request is not None and ( getattr(request, "needs_think_prefix", False) or self._get_output_parser_thinking_end_text() is not None ) ): think_end_ids = self._resolve_think_end_token_ids() if think_end_ids: from .api.thinking import ThinkingBudgetProcessor think_start_id = self._get_think_token_id("think_start_id") leading_ids, trailing_ids = self._resolve_think_close_pattern( self._get_output_parser_thinking_end_text() ) parser_trailing_ids = ( self._resolve_output_parser_thinking_trailing_ids() ) if parser_trailing_ids is not None: trailing_ids = parser_trailing_ids processor = ThinkingBudgetProcessor( think_end_token_ids=think_end_ids, budget=sampling_params.thinking_budget, think_start_token_id=think_start_id, leading_token_ids=leading_ids, trailing_token_ids=trailing_ids, token_to_piece=self._thinking_budget_token_to_piece, ) logits_processors.append(processor) # Add grammar constraint processor for structured output. # Phase awareness (thinking vs output) is handled by the compiled # grammar itself via xgrammar structural tags, so we don't need # think_end_ids here. if sampling_params.compiled_grammar is not None: try: from .api.grammar import GrammarConstraintProcessor vocab_size = self._get_model_vocab_size() if vocab_size is not None: processor = GrammarConstraintProcessor( compiled_grammar=sampling_params.compiled_grammar, vocab_size=vocab_size, ) logits_processors.append(processor) else: logger.warning( "Cannot determine vocab_size; skipping grammar constraint" ) except ImportError: logger.warning("xgrammar not installed; skipping grammar constraint") return sampler, logits_processors def _get_model_vocab_size(self) -> int | None: """Return vocab_size from model config, or None if unavailable.""" from .utils.tokenizer import resolve_vocab_size return resolve_vocab_size(self.model) def _get_think_token_id(self, attr: str) -> int | None: """Safely read a think token id from the tokenizer. mlx-lm tokenizers expose ``think_start_id`` / ``think_end_id`` as properties that may raise ``ValueError`` (multi-token sequence) or ``TypeError`` (``_think_start_tokens`` is ``None`` for models without thinking support, e.g. context-1 / harmony parser). Returns the token id, or ``None`` when unavailable. """ try: return getattr(self.tokenizer, attr, None) except (ValueError, TypeError): return None def _get_output_parser_thinking_end_text(self) -> str | None: """Return parser-provided thinking close text, if the parser has one.""" factory = getattr(self, "_output_parser_factory", None) if factory is None: return None return getattr(factory, "thinking_end_text", None) def _get_output_parser_thinking_start_text(self) -> str | None: """Return parser-provided thinking open text, if the parser has one.""" factory = getattr(self, "_output_parser_factory", None) if factory is None: return None return getattr(factory, "thinking_start_text", None) def _get_output_parser_thinking_start_output_text(self) -> str | None: """Return normalized text to prepend when parser thinking starts in prompt.""" factory = getattr(self, "_output_parser_factory", None) if factory is None: return None return getattr(factory, "thinking_start_output_text", None) def _encode_thinking_marker(self, text: str) -> list[int] | None: """Encode a parser/tokenizer thinking marker into token IDs.""" try: ids = self.tokenizer.encode(text, add_special_tokens=False) except TypeError: try: ids = self.tokenizer.encode(text) except Exception: return None except Exception: return None if ids: return list(ids) return None def _thinking_budget_token_to_piece(self, token_id: int) -> str | bytes | None: """Best-effort token piece lookup for UTF-8-safe budget forcing.""" try: token = self.tokenizer.convert_ids_to_tokens(token_id) if token is not None: byte_piece = self._token_piece_to_bytes(token) return byte_piece if byte_piece is not None else token except (AttributeError, KeyError, TypeError, ValueError): pass try: return self.tokenizer.decode([token_id], skip_special_tokens=False) except TypeError: try: return self.tokenizer.decode([token_id]) except Exception: return None except Exception: return None def _token_piece_to_bytes(self, token: str) -> bytes | None: """Convert byte-fallback tokenizer pieces to raw bytes when possible.""" import re byte_fallback = re.fullmatch(r"(?:<0x[0-9A-Fa-f]{2}>)+", token) if byte_fallback is not None: return bytes( int(match.group(1), 16) for match in re.finditer(r"<0x([0-9A-Fa-f]{2})>", token) ) byte_decoder = getattr(self.tokenizer, "byte_decoder", None) if isinstance(byte_decoder, dict) and token: try: return bytes(byte_decoder[ch] for ch in token) except (KeyError, TypeError, ValueError): pass return None def _resolve_output_parser_thinking_trailing_ids(self) -> list[int] | None: """Resolve parser-provided tokens that should follow a forced close.""" factory = getattr(self, "_output_parser_factory", None) if factory is None: return None trailing_text = getattr(factory, "thinking_end_trailing_text", None) if not trailing_text: return None return self._encode_thinking_marker(trailing_text) def _resolve_think_end_token_ids(self) -> list[int] | None: """Resolve token ID(s) for the close-think tag. Uses mlx-lm's built-in think_end_id which supports both and automatically. """ parser_think_end = self._get_output_parser_thinking_end_text() if parser_think_end is not None: return self._encode_thinking_marker(parser_think_end) # Tier 1: mlx-lm tokenizer attribute (covers all known think variants) think_end_id = self._get_think_token_id("think_end_id") if think_end_id is not None: return [think_end_id] # Tier 2: encode the think_end string think_end_str = getattr(self.tokenizer, "think_end", "") try: ids = self.tokenizer.encode(think_end_str, add_special_tokens=False) if ids: return list(ids) except Exception: pass # Tier 3: direct token lookup try: tid = self.tokenizer.convert_tokens_to_ids("") if tid != getattr(self.tokenizer, "unk_token_id", None): return [tid] except (AttributeError, KeyError, TypeError): pass return None def _resolve_think_close_pattern( self, think_end_str: str | None = None ) -> tuple[list[int] | None, list[int] | None]: """Detect leading/trailing tokens around from the chat template. Different models use different patterns: - Qwen3/3.5, MiniMax: ``\\n\\n\\n`` - DeepSeek V3.2, GLM-5: ```` (no newlines) - GLM-4.6V: ``\\n`` - Step-3.5-Flash: ``\\n\\n`` Returns (leading_token_ids, trailing_token_ids) or (None, None). """ import re if think_end_str is None: think_end_str = getattr(self.tokenizer, "think_end", None) or "" # Try to get the chat template text template_text = self._get_chat_template_text() if not template_text: return None, None # Find the close pattern in the template, e.g. \n\n\n # Look for the think_end_str surrounded by whitespace/newlines in string literals escaped = re.escape(think_end_str) # Match patterns like: \n\n\n or in template strings match = re.search( r"(\\n|\\r|[\n\r])*" + escaped + r"((?:\\n|\\r|[\n\r])*)", template_text, ) if not match: return None, None # Extract raw leading/trailing whitespace, converting \n escapes to actual newlines raw_leading = ( match.group(0) .split(think_end_str)[0] .replace("\\n", "\n") .replace("\\r", "\r") ) raw_trailing = ( match.group(0) .split(think_end_str)[1] .replace("\\n", "\n") .replace("\\r", "\r") ) # Encode to token IDs leading_ids = None trailing_ids = None if raw_leading: try: ids = self.tokenizer.encode(raw_leading, add_special_tokens=False) if ids: leading_ids = list(ids) except Exception: pass if raw_trailing: try: ids = self.tokenizer.encode(raw_trailing, add_special_tokens=False) if ids: trailing_ids = list(ids) except Exception: pass return leading_ids, trailing_ids def _get_chat_template_text(self) -> str | None: """Get chat template text from the tokenizer or model directory.""" # Try tokenizer's chat_template attribute (Jinja string) ct = getattr(self.tokenizer, "_chat_template", None) if ct: return ct if isinstance(ct, str) else str(ct) ct = getattr(self.tokenizer, "chat_template", None) if ct: return ct if isinstance(ct, str) else str(ct) # Try reading the .jinja file from model directory import os model_path = getattr(self.config, "model_path", None) or "" jinja_path = os.path.join(model_path, "chat_template.jinja") if os.path.isfile(jinja_path): try: with open(jinja_path, encoding="utf-8") as f: return f.read() except Exception: pass return None def _detect_needs_think_prefix(self, request: "Request") -> bool: """Detect if prompt ends with an open tag (thinking enabled). Returns False for disabled-thinking patterns like where immediately follows in the prompt tail. """ think_start_ids = None think_start_id = self._get_think_token_id("think_start_id") if think_start_id is not None: think_start_ids = [think_start_id] else: think_start_text = ( self._get_output_parser_thinking_start_text() or "" ) try: token_id = self.tokenizer.convert_tokens_to_ids(think_start_text) if isinstance(token_id, int) and token_id != getattr( self.tokenizer, "unk_token_id", None ): think_start_ids = [token_id] except (AttributeError, KeyError, TypeError): think_start_ids = None if think_start_ids is None: think_start_ids = self._encode_thinking_marker(think_start_text) if not think_start_ids or not request.prompt_token_ids: return False lookback = max(3, len(think_start_ids) + 2) last_tokens = list(request.prompt_token_ids[-lookback:]) last_idx = None for idx in range(len(last_tokens) - len(think_start_ids), -1, -1): if last_tokens[idx : idx + len(think_start_ids)] == think_start_ids: last_idx = idx break if last_idx is None: return False # found. Check if follows it (disabled thinking pattern). after_start = last_tokens[last_idx + len(think_start_ids) :] if after_start: think_end_ids = self._resolve_think_end_token_ids() if think_end_ids and len(after_start) >= len(think_end_ids): for idx in range(len(after_start) - len(think_end_ids) + 1): if after_start[idx : idx + len(think_end_ids)] == think_end_ids: return False elif think_end_ids and think_end_ids[0] in after_start: return False return True def _ensure_batch_generator(self, sampling_params: SamplingParams) -> None: """Ensure BatchGenerator exists with compatible settings.""" # Only create once; per-request samplers are passed at insert time. if self.batch_generator is None: self.batch_generator = self._create_batch_generator(sampling_params) # Track latest params for debugging/metrics. self._current_sampler_params = ( sampling_params.temperature, sampling_params.top_p, sampling_params.min_p, sampling_params.top_k, sampling_params.repetition_penalty, ) def _cache_tree_has_stateful_non_sliceable(self, cache_obj: Any) -> bool: """Detect non-sliceable recurrent cache layers requiring snapshots.""" # None placeholders from boundary snapshots (sliceable layers replaced). if cache_obj is None: return False # CacheList nests multiple cache objects. sub_caches = getattr(cache_obj, "caches", None) if isinstance(sub_caches, (list, tuple)): return any( self._cache_tree_has_stateful_non_sliceable(sub_cache) for sub_cache in sub_caches ) class_name = type(cache_obj).__name__ # Known sliceable cache types — no boundary snapshots needed. if class_name in ( "KVCache", "BatchKVCache", "QuantizedKVCache", ): return False # Stateful non-sliceable caches require boundary-safe snapshots. is_rotating_cache = class_name in ( "RotatingKVCache", "BatchRotatingKVCache", ) if HAS_CACHE_TYPE_HANDLERS and CacheTypeRegistry is not None: is_rotating_cache = ( is_rotating_cache or CacheTypeRegistry.is_rotating_family(class_name) ) if is_rotating_cache or class_name in ("ArraysCache", "SizedArraysCache"): return True if HAS_CACHE_TYPE_HANDLERS and CacheTypeRegistry is not None: handler = CacheTypeRegistry.get_handler_by_class_name(class_name) if not handler.supports_block_slicing: return True # Best-effort fallback for unknown recurrent cache structures. state_list = getattr(cache_obj, "cache", None) if isinstance(state_list, list): return True return False def _cache_list_needs_boundary_snapshot(self, cache_list: list[Any]) -> bool: """Return True if any layer cache requires boundary snapshots.""" if not cache_list: return False return any( self._cache_tree_has_stateful_non_sliceable(layer_cache) for layer_cache in cache_list ) def _eval_snapshot_cache(self, snapshot_cache: list[Any]) -> None: """Force the leaf KV tensors of an in-memory boundary snapshot concrete. Runs on the capturing (owner/inference) thread. The store-cache worker later re-extracts and slices these via _BoundarySnapshotProvider; MLX streams are thread-local, so any leaf op still lazy at that point would re-dispatch to this thread's stream index, which does not exist on the worker -> SIGABRT. Extracting + eval'ing the leaves here (under this thread's stream, where that stream lives) makes the worker's slicing operate exclusively on already-materialized buffers, which bind their new ops to the always-present default stream (gpu,0). """ if not snapshot_cache: return extracted, _ = self._extract_cache_states(snapshot_cache) leaves = self._collect_arrays_from_extracted_cache(extracted) if leaves: with mx.stream(self._stream): mx.eval(*leaves) def _on_prefill_boundary_snapshot( self, request_id: str, snapshot_cache: list[Any], token_count: int, ) -> None: """Record boundary snapshots captured during prefill processing. Called from ``_emit_prefill_boundary_snapshot`` at each block boundary crossed during prefill. Keyed by ``request_id`` rather than ``uid`` because the request has not been inserted into ``BatchGenerator`` yet and the uid mapping does not exist — routing through it dropped every snapshot silently (#TBD). """ if self.block_aware_cache is None: return block_size = self.config.paged_cache_block_size if block_size <= 0 or token_count <= 0 or token_count % block_size != 0: return if not self._cache_list_needs_boundary_snapshot(snapshot_cache): return if request_id not in self._boundary_cache_snapshots: self._boundary_cache_snapshots[request_id] = {} # Skip if we already have a snapshot at this token count if token_count in self._boundary_cache_snapshots[request_id]: return # Offload snapshot to SSD if store is available, keeping only a # None marker in the dict. Falls back to in-memory storage when # the SSD store is unavailable or the write fails. if self._boundary_snapshot_store is not None: saved = self._boundary_snapshot_store.save( request_id, token_count, snapshot_cache, self._extract_cache_states, ) if saved: self._boundary_cache_snapshots[request_id][token_count] = None else: # In-memory fallback: this snapshot will be sliced later on the # store-cache worker thread (via _BoundarySnapshotProvider -> # _extract_cache_states). MLX streams are thread-local, so the # worker cannot materialize a lazy op bound to THIS (owner) thread's # stream. Force it concrete now, on the capturing thread, so the # worker only ever slices already-evaluated buffers. self._eval_snapshot_cache(snapshot_cache) self._boundary_cache_snapshots[request_id][token_count] = snapshot_cache else: self._eval_snapshot_cache(snapshot_cache) self._boundary_cache_snapshots[request_id][token_count] = snapshot_cache self._boundary_snapshot_required = True logger.debug( "Captured prefill boundary cache snapshot for %s at %s tokens", request_id, token_count, ) def _detect_boundary_snapshot_need(self) -> bool: """ Determine whether boundary snapshots are needed for the current model. Evaluated lazily by inspecting model.make_cache() output instead of the active batch (which no longer exists in the new API). """ if self._boundary_snapshot_required is not None: return self._boundary_snapshot_required if not hasattr(self.model, "make_cache"): self._boundary_snapshot_required = False return False try: cache_list = self.model.make_cache() except Exception: self._boundary_snapshot_required = False return False if not cache_list: self._boundary_snapshot_required = False return False try: self._boundary_snapshot_required = any( self._cache_tree_has_stateful_non_sliceable(layer_cache) for layer_cache in cache_list ) except TypeError: # make_cache() returned something non-iterable (stub models in # tests); treat as not snapshot-needing. self._boundary_snapshot_required = False return False if self._boundary_snapshot_required: logger.info( "Enabled boundary cache snapshots for stateful non-sliceable " "cache layers" ) # Ask the speculative (MTP) decode path to land its commits on # block boundaries: its emit queue otherwise leaves the cache a # few tokens ahead of the emitted count when a boundary token # surfaces, which forces the consistency guard in # _extract_boundary_snapshot to skip most captures. block = int(self.config.paged_cache_block_size or 0) if block > 0: try: self.model._omlx_mtp_commit_align = block except Exception: pass else: logger.debug( "Boundary cache snapshots disabled (no stateful non-sliceable " "cache layers detected)" ) return self._boundary_snapshot_required def _extract_boundary_snapshot( self, uid: int, expected_tokens: int | None = None ) -> list[Any] | None: """Extract a per-request prompt cache snapshot via extract_cache(). Uses BatchGenerator.extract_cache() which returns Dict[uid, (cache_list, tokens_list)]. ``expected_tokens`` guards positional consistency: a snapshot labeled "state at N tokens" must be extracted while the cache holds exactly N forwarded tokens. The standard decode step always satisfies this at emit time, but speculative (MTP) decode advances the cache in bursts and emits from a queue, so the cache can be a few tokens ahead of — or one behind — the emitted count when the boundary token surfaces. A skewed snapshot would pair block-aligned KV with recurrent (SSM) state from a different position and corrupt later prefix-cache hits on hybrid models; skipping the capture merely costs a reuse opportunity. """ if self.batch_generator is None: return None try: # Synchronize pending engine stream operations before # accessing batch cache tensors. with self._phase_timer("boundary_capture_sync"): _safe_sync_stream(self._stream) with self._phase_timer("boundary_capture_extract"): with mx.stream(self._stream): result = self.batch_generator.extract_cache([uid]) if uid not in result: return None cache_list, _tokens = result[uid] if expected_tokens is not None: # Walk into CacheList wrappers: DeepSeek-V4/GLM layer # caches carry their token offset on a sub-cache, and # checking only the wrapper would silently pass a # skewed capture through. for c in cache_list: offset = _first_leaf_cache_offset(c) if offset is None: continue if offset != expected_tokens: logger.debug( "Skipping boundary snapshot for uid=%s: " "cache offset %d != boundary %d " "(speculative decode skew)", uid, offset, expected_tokens, ) return None break # Only extract non-sliceable layers to avoid costly # deep-copy accumulation (same rationale as prefill path). return [ ( c if type(c).__name__ not in _KNOWN_SLICEABLE_CACHE_TYPES else None ) for c in cache_list ] except Exception as e: logger.debug( f"Failed to extract boundary cache snapshot for uid={uid}: {e}" ) return None def _maybe_capture_boundary_snapshot(self, request: Request, uid: int) -> None: """Capture cache snapshot exactly at block boundaries for safe reuse.""" if self.block_aware_cache is None: return block_size = self.config.paged_cache_block_size if block_size <= 0: return total_tokens = request.num_tokens if total_tokens <= 0 or total_tokens % block_size != 0: return if not self._detect_boundary_snapshot_need(): return snapshot_cache = self._extract_boundary_snapshot( uid, expected_tokens=total_tokens ) if not snapshot_cache: return if request.request_id not in self._boundary_cache_snapshots: self._boundary_cache_snapshots[request.request_id] = {} # Offload to SSD with in-memory fallback. if self._boundary_snapshot_store is not None: with self._phase_timer("boundary_snapshot_save"): saved = self._boundary_snapshot_store.save( request.request_id, total_tokens, snapshot_cache, self._extract_cache_states, ) if saved: self._boundary_cache_snapshots[request.request_id][total_tokens] = None else: # In-memory fallback: the store-cache worker slices this snapshot # off-thread (via _BoundarySnapshotProvider -> _extract_cache_states). # MLX streams are thread-local, so force the leaves concrete now on # the capturing thread; otherwise the worker re-dispatches a lazy op # to this thread's stream index -> "no Stream(gpu, N)" -> SIGABRT. # Mirrors _on_prefill_boundary_snapshot's in-memory fallback. self._eval_snapshot_cache(snapshot_cache) self._boundary_cache_snapshots[request.request_id][ total_tokens ] = snapshot_cache else: self._eval_snapshot_cache(snapshot_cache) self._boundary_cache_snapshots[request.request_id][ total_tokens ] = snapshot_cache logger.debug( f"Captured boundary cache snapshot for {request.request_id} at " f"{total_tokens} tokens" ) def _get_boundary_store_override( self, request_id: str, full_token_sequence: list[int], ) -> ( tuple[ list[int], list[dict[str, Any]], Optional["ModelCacheConfig"], dict[int, list[dict[str, Any]]], ] | None ): """ Return boundary-aligned cache payload when final request ends on partial block. Returns: Tuple of (truncated_tokens, extracted_cache, model_cache_config, intermediate_snapshots) where intermediate_snapshots maps token_count -> extracted cache states for per-block storage. """ snapshots = self._boundary_cache_snapshots.get(request_id) if not snapshots: return None total_tokens = len(full_token_sequence) block_size = self.config.paged_cache_block_size # Find all valid boundary-aligned snapshot token counts valid_counts = sorted( tc for tc in snapshots.keys() if 0 < tc <= total_tokens and tc % block_size == 0 ) if not valid_counts: return None # Find the latest snapshot that leaves trailing partial tokens # (or equals total if it's block-aligned). latest_tc = valid_counts[-1] if latest_tc < total_tokens: # Trailing partial tokens exist — use this snapshot for truncation pass elif latest_tc == total_tokens and total_tokens % block_size == 0: # Exactly block-aligned — no truncation needed but we still # provide intermediate snapshots for per-block storage. latest_tc = total_tokens else: return None # Load latest snapshot — may be on SSD (None marker) or in memory. # # In-memory snapshots are raw mlx-lm cache objects. They must be # converted to the extracted dict format here on the engine MLX thread. # The async store-cache worker does not own the generation stream; if it # touches raw Rotating/Arrays cache state, MLX can abort with # "There is no Stream(gpu, X) in current thread" (#1568). latest_snapshot = snapshots[latest_tc] if latest_snapshot is None and self._boundary_snapshot_store is not None: # Offloaded to SSD — load back. extracted_cache = self._boundary_snapshot_store.load(request_id, latest_tc) if not extracted_cache: return None # Build model_cache_config from the main request cache config # since the SSD snapshot doesn't carry it. model_cache_config = getattr( self.requests.get(request_id), "_model_cache_config", None ) elif latest_snapshot is not None: extracted_cache, model_cache_config = self._extract_cache_states( latest_snapshot ) if not extracted_cache: return None else: return None # Build provider for intermediate snapshots. SSD-backed snapshots remain # lazy-loaded, but in-memory snapshots are extracted eagerly on this # engine thread before the provider is handed to the async worker. intermediate_tcs = [tc for tc in valid_counts if tc != latest_tc] provider_tcs: list[int] = [] extracted_in_memory: dict[int, list[dict[str, Any]]] = {} for tc in intermediate_tcs: snap = snapshots.get(tc) if snap is None: if self._boundary_snapshot_store is not None: provider_tcs.append(tc) continue extracted_snapshot, _ = self._extract_cache_states(snap) if extracted_snapshot: extracted_in_memory[tc] = extracted_snapshot provider_tcs.append(tc) intermediate_snapshots = _BoundarySnapshotProvider( store=self._boundary_snapshot_store, request_id=request_id, valid_tcs=provider_tcs, in_memory_snapshots=extracted_in_memory, ) token_sequence = ( full_token_sequence[:latest_tc] if latest_tc < total_tokens else full_token_sequence ) return ( token_sequence, extracted_cache, model_cache_config, intermediate_snapshots, ) @staticmethod def _merge_boundary_with_full_cache( boundary_cache: list[dict[str, Any]], full_cache: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Fill placeholder layers in boundary cache from full extracted cache. Boundary snapshots skip sliceable (KVCache) layers to save memory, leaving them as ``{'state': (), ...}`` placeholders. For block storage the KV tensors are needed, so we copy them from the full extracted cache (which contains the complete sequence). """ if not full_cache or len(boundary_cache) != len(full_cache): return boundary_cache merged = [] for bc, fc in zip(boundary_cache, full_cache): state = bc.get("state", ()) # Placeholder layers have state == () (empty tuple). if isinstance(state, tuple) and len(state) == 0: # Take full cache layer instead. merged.append(fc) else: merged.append(bc) return merged @staticmethod def _is_empty_boundary_placeholder(layer_state: Any) -> bool: if not isinstance(layer_state, dict): return False state = layer_state.get("state", ()) return isinstance(state, tuple) and len(state) == 0 @staticmethod def _extracted_layer_type_name(layer_state: dict[str, Any]) -> str: class_name = str(layer_state.get("class_name") or "") if class_name: return class_name return str(layer_state.get("cache_type") or "") @staticmethod def _is_sliceable_extracted_layer(layer_state: dict[str, Any]) -> bool: type_name = Scheduler._extracted_layer_type_name(layer_state) if type_name in _KNOWN_SLICEABLE_CACHE_TYPES: return True if HAS_CACHE_TYPE_HANDLERS and CacheTypeRegistry is not None and type_name: try: if type_name not in CacheTypeRegistry.list_known_class_names(): return False handler = CacheTypeRegistry.get_handler_by_class_name(type_name) return bool(handler.supports_block_slicing) except Exception: return False return False def _fill_boundary_placeholders_from_live_cache( self, boundary_cache: list[dict[str, Any]], live_cache: list[dict[str, Any]], ) -> list[dict[str, Any]] | None: """Fill boundary placeholders only with proven sliceable live layers.""" if ( not boundary_cache or not live_cache or len(boundary_cache) != len(live_cache) ): return None merged: list[dict[str, Any]] = [] for layer_idx, (boundary_layer, live_layer) in enumerate( zip(boundary_cache, live_cache) ): if not self._is_empty_boundary_placeholder(boundary_layer): merged.append(boundary_layer) continue if not isinstance( live_layer, dict ) or not self._is_sliceable_extracted_layer(live_layer): logger.debug( "Cannot fill boundary placeholder for layer %s from non-sliceable " "live cache type %s", layer_idx, ( self._extracted_layer_type_name(live_layer) if isinstance(live_layer, dict) else type(live_layer).__name__ ), ) return None merged.append(live_layer) if any(self._is_empty_boundary_placeholder(layer) for layer in merged): return None return merged def _extract_live_request_cache_for_store( self, request_id: str, uid: int, expected_tokens: list[int], ) -> tuple[list[dict[str, Any]], Optional["ModelCacheConfig"]] | None: """Extract live cache only when its token prefix matches exactly.""" if self.batch_generator is None or uid is None or uid < 0: return None try: _safe_sync_stream(self._stream) with mx.stream(self._stream): result = self.batch_generator.extract_cache([uid]) if uid not in result: logger.debug( "Cannot extract live cache for %s: uid %s not present", request_id, uid, ) return None live_cache, live_tokens = result[uid] live_tokens_list = list(live_tokens) if live_tokens is not None else [] if len(live_tokens_list) < len(expected_tokens) or ( live_tokens_list[: len(expected_tokens)] != expected_tokens ): logger.debug( "Skipping parser-stop cache store for %s: live cache tokens do " "not match prompt boundary prefix (%s/%s tokens)", request_id, min(len(live_tokens_list), len(expected_tokens)), len(expected_tokens), ) return None extracted_cache, model_cache_config = self._extract_cache_states(live_cache) if not extracted_cache: return None return extracted_cache, model_cache_config except Exception as e: logger.debug( "Failed to extract live cache for parser-stop cache store %s: %s", request_id, e, ) return None def _prepare_prompt_boundary_cache_store( self, request_id: str, request: Request, uid: int, ) -> ( tuple[ list[int], list[dict[str, Any]], Optional["ModelCacheConfig"], Any | None, ] | None ): """Prepare a prompt-only cache payload when a scheduler stop skipped it. Parser-side stops (for example tool-call end markers) can finish a request after a normal streaming token response. That response has no ``prompt_cache``, so the usual final-response cache extraction never runs. This fallback stores only prompt tokens up to a prefill block boundary, never generated output tokens. """ if self.block_aware_cache is None: return None if request.specprefill_indices is not None: return None block_size = self.config.paged_cache_block_size if block_size <= 0: return None prompt_tokens = list(request.prompt_token_ids or []) boundary_len = (len(prompt_tokens) // block_size) * block_size if boundary_len <= 0: return None token_sequence = prompt_tokens[:boundary_len] boundary_override = self._get_boundary_store_override(request_id, prompt_tokens) if boundary_override is not None: ( token_sequence, boundary_cache, boundary_model_config, intermediate_snapshots, ) = boundary_override live_payload = None if any( self._is_empty_boundary_placeholder(layer) for layer in boundary_cache ): live_payload = self._extract_live_request_cache_for_store( request_id, uid, token_sequence, ) if live_payload is None: return None live_cache, live_model_config = live_payload cache_to_store = self._fill_boundary_placeholders_from_live_cache( boundary_cache, live_cache, ) if cache_to_store is None: return None model_cache_config = boundary_model_config or live_model_config else: cache_to_store = boundary_cache model_cache_config = boundary_model_config logger.info( "Using prompt boundary cache snapshot for %s: storing %s/%s prompt " "tokens after scheduler-side stop (skipping output tokens, %s " "intermediate snapshots)", request_id, len(token_sequence), len(prompt_tokens), len(intermediate_snapshots) if intermediate_snapshots else 0, ) return ( token_sequence, cache_to_store, model_cache_config, intermediate_snapshots, ) # Pure sliceable cache models do not need boundary snapshots. For models # that do need snapshots, a missing snapshot means the non-sliceable state # at this boundary is unavailable, so skip rather than storing an unsafe # live decode tail. if self._detect_boundary_snapshot_need(): return None live_payload = self._extract_live_request_cache_for_store( request_id, uid, token_sequence, ) if live_payload is None: return None live_cache, live_model_config = live_payload if not all(self._is_sliceable_extracted_layer(layer) for layer in live_cache): logger.debug( "Skipping parser-stop cache store for %s: live cache has " "non-sliceable layers but no boundary snapshot", request_id, ) return None logger.info( "Using live prompt cache for %s: storing %s/%s prompt tokens after " "scheduler-side stop (skipping output tokens)", request_id, len(token_sequence), len(prompt_tokens), ) return token_sequence, live_cache, live_model_config, None def _validate_cache(self, cache: Any) -> bool: """ Validate that a cache object is usable. This prevents NoneType errors when mlx-lm's BatchKVCache contains invalid/stale references. Args: cache: The cache object to validate Returns: True if cache is valid and usable """ if cache is None: return False # Check if it's a list of cache layers if isinstance(cache, list): if len(cache) == 0: return False # Check each layer for layer_cache in cache: if layer_cache is None: return False # Check if layer has expected structure # RotatingKVCache may have keys=None (legacy) or zero-length # keys (hybrid window padding). Both are valid empty states # that will be filled during padding reprocessing. if hasattr(layer_cache, "keys") and layer_cache.keys is None: if hasattr(layer_cache, "max_size"): continue # Valid empty RotatingKVCache (keys=None) return False if hasattr(layer_cache, "values") and layer_cache.values is None: if hasattr(layer_cache, "max_size"): continue # Valid empty RotatingKVCache (values=None) return False # Check BatchKVCache structure if hasattr(cache, "caches"): if cache.caches is None: return False for c in cache.caches: if c is None: return False return True def _normalize_rotating_snapshot_state( self, layer_cache: Any, state: tuple[Any, Any], meta_state: Any, layer_idx: int | None = None, ) -> tuple[tuple[Any, Any], tuple[str, str, str, str]]: """ Normalize RotatingKVCache state into merge-safe canonical form. Boundary snapshots captured mid-prefill can expose oversized rotating buffers (e.g., max_size + chunk_size - 1). Those states are valid for in-flight prefill but break BatchRotatingKVCache.merge() after SSD restore because merge expects per-request rotating buffers capped to max_size. This method canonicalizes to the latest max_size tokens. """ if not isinstance(state, (list, tuple)) or len(state) < 2: return state, ( tuple(meta_state) if isinstance(meta_state, (list, tuple)) else () ) keys = state[0] values = state[1] if keys is None or values is None or not hasattr(keys, "shape"): return state, ( tuple(meta_state) if isinstance(meta_state, (list, tuple)) else () ) try: keep = ( int(meta_state[0]) if meta_state and len(meta_state) >= 1 else int(getattr(layer_cache, "keep", 0)) ) max_size = ( int(meta_state[1]) if meta_state and len(meta_state) >= 2 else int(getattr(layer_cache, "max_size", keys.shape[2])) ) offset = ( int(meta_state[2]) if meta_state and len(meta_state) >= 3 else int(getattr(layer_cache, "offset", keys.shape[2])) ) idx = ( int(meta_state[3]) if meta_state and len(meta_state) >= 4 else int(getattr(layer_cache, "_idx", keys.shape[2])) ) except Exception: return state, ( tuple(meta_state) if isinstance(meta_state, (list, tuple)) else () ) ordered_keys = keys ordered_values = values temporal_order = getattr(layer_cache, "_temporal_order", None) if callable(temporal_order): try: ordered_keys = temporal_order(keys) ordered_values = temporal_order(values) except Exception: ordered_keys = keys ordered_values = values original_len = int(ordered_keys.shape[2]) if len(ordered_keys.shape) >= 3 else 0 normalized_keys = ordered_keys normalized_values = ordered_values if max_size > 0 and original_len > max_size: if keep > 0 and keep < max_size: tail_len = max_size - keep normalized_keys = mx.concatenate( [ ordered_keys[..., :keep, :], ordered_keys[..., -tail_len:, :], ], axis=2, ) normalized_values = mx.concatenate( [ ordered_values[..., :keep, :], ordered_values[..., -tail_len:, :], ], axis=2, ) else: normalized_keys = ordered_keys[..., -max_size:, :] normalized_values = ordered_values[..., -max_size:, :] try: normalized_keys = mx.contiguous(normalized_keys) normalized_values = mx.contiguous(normalized_values) except Exception: pass normalized_len = ( int(normalized_keys.shape[2]) if len(normalized_keys.shape) >= 3 else 0 ) # Force case 1 of _temporal_order: _idx == keys.shape[2] means the # buffer is already in temporal order (which is exactly what the # oversized trim above produces — the contiguous tail of the most # recent tokens). Anything else lets _temporal_order re-slice the # buffer in the rotated branch (case 2), which is wasted work and # obscures the merge contract. See cache.py:431-447 for the branches. normalized_idx = normalized_len normalized_meta = ( str(keep), str(max_size), str(offset), str(normalized_idx), ) if original_len != normalized_len or idx != normalized_idx: layer_tag = f"layer {layer_idx}: " if layer_idx is not None else "" logger.debug( "%sNormalized RotatingKVCache snapshot: len %s->%s, idx %s->%s, " "offset=%s, max_size=%s", layer_tag, original_len, normalized_len, idx, normalized_idx, offset, max_size, ) return (normalized_keys, normalized_values), normalized_meta def _extract_cache_states( self, raw_cache: list[Any], ) -> tuple[list[dict[str, Any]], Optional["ModelCacheConfig"]]: """ Extract actual tensor state from each layer cache. This extracts the real KV data using mlx-lm's cache.state property, allowing the data to be stored and reconstructed later even after the BatchGenerator is recreated. Also creates a ModelCacheConfig with per-layer type information to support hybrid cache models (e.g., KVCache + ArraysCache). Args: raw_cache: List of cache objects from mlx-lm (KVCache, ArraysCache, etc.) Returns: Tuple of: - List of dicts with {state, meta_state, class_name, cache_type} - ModelCacheConfig with per-layer type information (or None) """ if not raw_cache: return [], None # Build ModelCacheConfig for type information. # Skip if raw_cache contains None entries (boundary snapshots with # sliceable layers replaced by None) — from_cache_list expects real # cache objects and would log noisy NoneType warnings. model_cache_config = None has_none_layers = any(c is None for c in raw_cache) if ( HAS_CACHE_TYPE_HANDLERS and ModelCacheConfig is not None and not has_none_layers ): try: model_cache_config = ModelCacheConfig.from_cache_list( raw_cache, model_name=self.model_name if hasattr(self, "model_name") else "", ) except Exception as e: logger.debug(f"Failed to build ModelCacheConfig: {e}") extracted = [] for layer_idx, layer_cache in enumerate(raw_cache): # Boundary snapshots may contain None for sliceable layers # (KVCache) that were skipped during capture to save memory. # Insert a placeholder to preserve layer index alignment. if layer_cache is None: extracted.append( { "state": (), "meta_state": (), "class_name": "KVCache", "cache_type": "KVCache", } ) continue try: class_name = type(layer_cache).__name__ # Determine cache type using registry if available cache_type_name = class_name handler = None if HAS_CACHE_TYPE_HANDLERS and CacheTypeRegistry is not None: try: cache_type = CacheTypeRegistry.detect_cache_type(layer_cache) cache_type_name = cache_type.value handler = CacheTypeRegistry.get_handler(cache_type) except Exception: pass # CacheList: composite cache with multiple sub-caches if cache_type_name == "CacheList" or class_name == "CacheList": if HAS_CACHE_TYPE_HANDLERS and CacheTypeRegistry is not None: try: handler = CacheTypeRegistry.get_handler_by_class_name( "CacheList" ) state_dict = handler.extract_state(layer_cache) sub_states = list(state_dict.get("sub_states", [])) sub_class_names = list( state_dict.get("sub_class_names", []) ) sub_meta_states = list( state_dict.get("sub_meta_states", []) ) sub_caches = getattr(layer_cache, "caches", ()) for sub_idx, sub_cache in enumerate(sub_caches): if sub_idx >= len(sub_states): break sub_class_name = type(sub_cache).__name__ if sub_class_name in ( "RotatingKVCache", "BatchRotatingKVCache", "PrefillReadyRotatingKVCache", ): normalized_state, normalized_meta = ( self._normalize_rotating_snapshot_state( sub_cache, sub_states[sub_idx], ( sub_meta_states[sub_idx] if sub_idx < len(sub_meta_states) else getattr( sub_cache, "meta_state", () ) ), layer_idx=layer_idx, ) ) sub_states[sub_idx] = normalized_state if sub_idx < len(sub_meta_states): sub_meta_states[sub_idx] = normalized_meta extracted.append( { "state": sub_states, "meta_state": ( sub_class_names, sub_meta_states, ), "class_name": "CacheList", "cache_type": "CacheList", } ) except Exception as e: logger.debug(f"CacheList handler extraction failed: {e}") extracted.append( { "state": [], "meta_state": ([], []), "class_name": "CacheList", "cache_type": "CacheList", } ) else: # Fallback: extract sub-cache state/meta without handlers # MUST append to extracted to prevent layer count mismatch (Issue #1) sub_caches = getattr(layer_cache, "caches", ()) sub_states = [] sub_class_names = [] sub_meta_states = [] for sc in sub_caches: sub_states.append(sc.state if hasattr(sc, "state") else ()) sub_class_names.append(type(sc).__name__) sub_meta_states.append(getattr(sc, "meta_state", ())) extracted.append( { "state": sub_states, "meta_state": (sub_class_names, sub_meta_states), "class_name": "CacheList", "cache_type": "CacheList", } ) continue if hasattr(layer_cache, "state"): if handler is not None and class_name in ( "MiniMaxM3KVCache", "MiniMaxM3BatchKVCache", ): state = handler.serialize_state(layer_cache) meta = handler.serialize_meta_state(layer_cache) else: state = layer_cache.state meta = getattr(layer_cache, "meta_state", ()) is_rotating_cache = class_name in ( "RotatingKVCache", "BatchRotatingKVCache", "PrefillReadyRotatingKVCache", "BufferedRotatingKVCache", ) if HAS_CACHE_TYPE_HANDLERS and CacheTypeRegistry is not None: is_rotating_cache = ( is_rotating_cache or CacheTypeRegistry.is_rotating_family(class_name) ) if is_rotating_cache: state, meta = self._normalize_rotating_snapshot_state( layer_cache, state, meta, layer_idx=layer_idx, ) # Preserve the full state tuple regardless of length. # Legacy 2-tuple caches (KVCache, RotatingKVCache, ...) # surface as (keys, values); 3-tuple caches like # PoolingCache surface as (buf_kv, buf_gate, pooled); # 4-tuple caches like BatchKVCache surface with the # extra offset/padding metadata. Downstream # serialization (paged_ssd_cache, boundary_snapshot) # is N-tuple aware after the cache architecture # refactor — see Section 6 of the implementation # plan. if isinstance(state, (list, tuple)) and len(state) >= 1: # Validate non-None for legacy KV-style caches only. # PoolingCache's buf_kv may legitimately be None # (fresh cache before any update), so skip the # null guard for non-KV cache classes. if ( class_name in ("KVCache", "RotatingKVCache", "BatchKVCache") or ( HAS_CACHE_TYPE_HANDLERS and CacheTypeRegistry is not None and CacheTypeRegistry.is_rotating_family(class_name) ) ) and len(state) >= 2: if state[0] is None or state[1] is None: logger.debug( f"Layer {layer_idx} ({class_name}) has None keys/values, " f"skipping cache extraction" ) return [], None # Return empty - cache is corrupted extracted.append( { "state": tuple(state), "meta_state": meta, "class_name": class_name, "cache_type": cache_type_name, } ) else: # Unexpected state format (e.g. a non-tuple scalar). logger.debug( f"Layer {layer_idx} ({class_name}) has unexpected state format" ) meta = getattr(layer_cache, "meta_state", ()) # Wrap the scalar so downstream code still gets a # tuple-shaped state. This path is essentially dead # in practice — kept defensive only. extracted.append( { "state": (state,), "meta_state": meta, "class_name": class_name, "cache_type": cache_type_name, } ) elif hasattr(layer_cache, "cache"): # ArraysCache style: state stored in .cache list cache_list = layer_cache.cache if isinstance(cache_list, list) and len(cache_list) >= 2: state = (cache_list[0], cache_list[1]) meta = getattr(layer_cache, "meta_state", ()) extracted.append( { "state": state, "meta_state": meta, "class_name": class_name, "cache_type": cache_type_name, } ) else: logger.debug( f"Layer {layer_idx} ({class_name}) has invalid cache list" ) continue else: logger.debug( f"Layer {layer_idx} ({class_name}) has no state or cache attribute" ) continue except Exception as e: logger.debug( f"Failed to extract state from cache layer {layer_idx}: {e}" ) continue if len(extracted) != len(raw_cache): logger.debug( f"Incomplete cache extraction: {len(extracted)}/{len(raw_cache)} layers" ) return [], None return extracted, model_cache_config @staticmethod def _common_prefix_len(a: list[int], b: list[int]) -> int: n = min(len(a), len(b)) for i in range(n): if a[i] != b[i]: return i return n def _log_prefix_divergence(self, request: Request) -> None: """DEBUG-only prefix-cache miss diagnostic (issue #1003). Compares the new prompt against recently stored cache sequences and logs the first divergent token offset with decoded context on both sides, so an always-miss report can be traced to the exact prompt position (template re-render drift, client echo changes, eviction) instead of guessing from hit counters. """ prompt = request.prompt_token_ids or [] if not prompt or not self._cache_probe_seqs: return best_id, best_seq, best_p = None, None, -1 for ref_id, seq in list(self._cache_probe_seqs): p = self._common_prefix_len(prompt, seq) if p > best_p: best_id, best_seq, best_p = ref_id, seq, p if best_seq is None: return cached = request.cached_tokens or 0 reusable = min(len(prompt), len(best_seq)) block = self.config.paged_cache_block_size logger.debug( f"Request {request.request_id}: prefix probe vs stored {best_id}: " f"common_prefix={best_p}/{reusable} tokens " f"(~{best_p // max(1, block)} blocks of {block}), " f"served cached_tokens={cached}, prompt={len(prompt)}" ) if best_p < reusable: lo = max(0, best_p - 12) hi = best_p + 12 try: stored_ctx = self.tokenizer.decode(best_seq[lo:hi]) prompt_ctx = self.tokenizer.decode(prompt[lo:hi]) except Exception: stored_ctx = prompt_ctx = "" logger.debug( f"Request {request.request_id}: first divergence at token " f"{best_p}: stored=...{stored_ctx!r} vs prompt=...{prompt_ctx!r}" ) _CACHE_FRESHNESS_WAIT_MIN_PROMPT_TOKENS = 8192 _CACHE_FRESHNESS_WAIT_MIN_COMMON_TOKENS = 8192 _CACHE_FRESHNESS_WAIT_MIN_PROMPT_RATIO = 0.30 _CACHE_FRESHNESS_WAIT_TIMEOUT_S = 4.0 @staticmethod def _store_extra_keys_match( info: _InflightStoreInfo, request: Request, ) -> bool: return ( info.extra_keys == request.vlm_extra_keys_for_cache and info.extra_key_token_start == request.vlm_extra_key_token_start_for_cache and info.extra_key_ranges == request.vlm_extra_key_ranges_for_cache ) def _find_relevant_inflight_store( self, request: Request, ) -> tuple[str, concurrent.futures.Future, int] | None: """Find a pending store_cache job worth waiting for before lookup.""" if not self._inflight_store_futures: return None prompt = request.prompt_token_ids or [] if len(prompt) < self._CACHE_FRESHNESS_WAIT_MIN_PROMPT_TOKENS: return None best_rid: str | None = None best_future: concurrent.futures.Future | None = None best_common = 0 for rid, future in list(self._inflight_store_futures.items()): if future.done(): continue info = self._inflight_store_info.get(rid) if info is None or not self._store_extra_keys_match(info, request): continue common = self._common_prefix_len(prompt, info.tokens) if common > best_common: best_rid = rid best_future = future best_common = common if best_future is None or best_rid is None: return None if not ( best_common >= self._CACHE_FRESHNESS_WAIT_MIN_COMMON_TOKENS or best_common / len(prompt) >= self._CACHE_FRESHNESS_WAIT_MIN_PROMPT_RATIO ): return None return best_rid, best_future, best_common def _should_defer_for_cache_freshness(self, request: Request) -> bool: """Defer only this waiting request until a relevant store is visible. This intentionally does not call Future.result(). The scheduler step can return immediately, so running decode rows and chunked prefills continue on subsequent steps while the waiting head holds admission order. """ if self.block_aware_cache is None: return False now = time.monotonic() wait = self._cache_freshness_waits.get(request.request_id) if wait is not None: if wait.future.done(): self._cache_freshness_waits.pop(request.request_id, None) try: exc = wait.future.exception() except concurrent.futures.CancelledError: logger.debug( "Cache freshness deferral saw cancelled store_cache %s " "before prefix lookup for %s", wait.store_request_id, request.request_id, ) else: if exc is None: logger.debug( "Completed cache freshness deferral for store_cache %s " "before prefix lookup for %s", wait.store_request_id, request.request_id, ) else: logger.debug( "Cache freshness deferral saw failed store_cache %s " "before prefix lookup for %s: %s", wait.store_request_id, request.request_id, exc, ) return False if now >= wait.deadline_s: self._cache_freshness_waits.pop(request.request_id, None) logger.debug( "Timed out cache freshness deferral for store_cache %s before " "prefix lookup for %s (common_prefix=%d/%d)", wait.store_request_id, request.request_id, wait.common_prefix, wait.prompt_len, ) return False return True match = self._find_relevant_inflight_store(request) if match is None: return False store_request_id, future, common_prefix = match prompt_len = len(request.prompt_token_ids or []) timeout = self._CACHE_FRESHNESS_WAIT_TIMEOUT_S self._cache_freshness_waits[request.request_id] = _CacheFreshnessWait( store_request_id=store_request_id, future=future, common_prefix=common_prefix, prompt_len=prompt_len, deadline_s=now + timeout, ) logger.debug( "Deferring admission up to %.1fs for in-flight store_cache %s before " "prefix lookup for %s (common_prefix=%d/%d running=%d prefilling=%d)", timeout, store_request_id, request.request_id, common_prefix, prompt_len, len(self.running), len(self.prefilling), ) return True def _prepare_prefix_cache_for_request(self, request: Request) -> None: if request.request_id in self._prefix_cache_prepared: return # Check prefix cache for cached KV state if self.block_aware_cache is not None: # Use paged cache block_table, remaining = self.block_aware_cache.fetch_cache( request.request_id, request.prompt_token_ids, extra_keys=request.vlm_extra_keys_for_cache, extra_key_token_start=request.vlm_extra_key_token_start_for_cache, extra_key_ranges=request.vlm_extra_key_ranges_for_cache, ) if block_table and block_table.num_tokens > 0: bypass_hot_cache = self._bypass_hot_cache_under_pressure() if bypass_hot_cache: logger.info( "Skipping hot-cache preload for %s under memory pressure", request.request_id, ) else: self.block_aware_cache.preload_blocks(block_table) # Reconstruct actual KVCache objects from stored tensor data # Note: reconstruct_cache may modify block_table in-place if # partial reconstruction occurs (some blocks invalid) original_tokens = block_table.num_tokens if bypass_hot_cache: reconstructed = self.block_aware_cache.reconstruct_cache( block_table, promote_to_hot_cache=False, ) else: reconstructed = self.block_aware_cache.reconstruct_cache( block_table ) if reconstructed: request.prompt_cache = reconstructed request.block_table = block_table request.cached_tokens = block_table.num_tokens request.shared_prefix_blocks = len(block_table.block_ids) # Recalculate remaining_tokens in case block_table was truncated request.remaining_tokens = request.prompt_token_ids[ block_table.num_tokens : ] if self._align_minimax_m3_partial_cache_to_prefill_step(request): request.cached_tokens = block_table.num_tokens request.shared_prefix_blocks = len(block_table.block_ids) request.remaining_tokens = request.prompt_token_ids[ block_table.num_tokens : ] # For exact prefix hits we need cache state at (N-1) and the # last prompt token as input to produce the first decode logit. # Reusing cache state at N and feeding the last token again # shifts the model state and can change greedy output. if len(request.remaining_tokens) == 0 and request.cached_tokens > 0: if self._cache_list_needs_boundary_snapshot( request.prompt_cache ): # Stateful non-sliceable caches (Rotating/Arrays) # cannot be safely converted from N to N-1 state # without cache-type-specific logic. if self.paged_cache_manager is not None: self.paged_cache_manager.delete_block_table( request.request_id ) request.prompt_cache = None request.block_table = None request.cached_tokens = 0 request.shared_prefix_blocks = 0 request.remaining_tokens = request.prompt_token_ids logger.debug( f"Request {request.request_id}: exact cache hit with " f"stateful cache type, falling back to full prefill " f"for deterministic kickoff" ) elif self._trim_prompt_cache_for_generation( request.prompt_cache ): request.cached_tokens = max(0, request.cached_tokens - 1) request.remaining_tokens = request.prompt_token_ids[-1:] logger.debug( f"Request {request.request_id}: exact cache hit adjusted " f"to N-1 state for generation kickoff " f"(cached_tokens={request.cached_tokens}, " f"remaining={len(request.remaining_tokens)})" ) else: # Fallback to full recompute when cache layers cannot # be safely trimmed by one token (e.g., non-trimmable # recurrent state caches). if self.paged_cache_manager is not None: self.paged_cache_manager.delete_block_table( request.request_id ) request.prompt_cache = None request.block_table = None request.cached_tokens = 0 request.shared_prefix_blocks = 0 request.remaining_tokens = request.prompt_token_ids logger.debug( f"Request {request.request_id}: exact cache hit could " f"not be trimmed safely, falling back to full prefill" ) if block_table.num_tokens < original_tokens: logger.debug( f"Request {request.request_id}: partial cache hit, " f"{request.cached_tokens} tokens in {request.shared_prefix_blocks} blocks " f"(originally {original_tokens} tokens), " f"{len(request.remaining_tokens)} tokens remaining" ) else: logger.debug( f"Request {request.request_id}: paged cache hit, " f"{request.cached_tokens} tokens in {request.shared_prefix_blocks} blocks, " f"{len(request.remaining_tokens)} tokens remaining, cache reconstructed" ) else: # Reconstruction failed, treat as cache miss if self.paged_cache_manager is not None: self.paged_cache_manager.delete_block_table(request.request_id) request.remaining_tokens = request.prompt_token_ids logger.debug( f"Request {request.request_id}: paged cache reconstruction failed, " "released shared blocks" ) else: request.remaining_tokens = request.prompt_token_ids else: # No paged SSD cache configured - process all tokens request.remaining_tokens = request.prompt_token_ids # DEBUG-only: trace where this prompt diverges from recently stored # cache sequences (issue #1003 always-miss diagnosis). if logger.isEnabledFor(logging.DEBUG): self._log_prefix_divergence(request) # SpecPrefill: score remaining tokens with draft model if applicable. # Must run AFTER prefix cache check (scoring applies only to uncached suffix). self._try_specprefill_scoring(request) self._prefix_cache_prepared.add(request.request_id) def add_request(self, request: Request) -> None: """ Add a new request to the scheduler. Raises SchedulerQueueFullError when the waiting queue is at or above the configured cap (max(max_num_seqs * 4, 32)). Server layer maps this to HTTP 503 + Retry-After. Args: request: The request to add """ if request.request_id in self.requests: raise ValueError(f"Request {request.request_id} already exists") # Cap the waiting queue so client-side polling can't accumulate # unbounded work and the scheduler can apply backpressure via 503. max_waiting = max(self.config.max_num_seqs * 4, 32) if len(self.waiting) >= max_waiting: from .exceptions import SchedulerQueueFullError raise SchedulerQueueFullError( current_depth=len(self.waiting), max_depth=max_waiting, ) # Tokenize if needed if request.prompt_token_ids is None: if isinstance(request.prompt, str): request.prompt_token_ids = self.tokenizer.encode(request.prompt) else: request.prompt_token_ids = list(request.prompt) request.num_prompt_tokens = len(request.prompt_token_ids) # Prefix-cache lookup is intentionally delayed until admission. That # lets a same-prefix request wait for a relevant in-flight store_cache # without blocking the scheduler lane that continues decode/prefill. # # Keep the immediate preflight only when no prefix cache lookup can # change cached_tokens. With a block-aware cache, the in-stream # _preflight_memory_check runs after lookup with the final cache state. if self.block_aware_cache is None: request.remaining_tokens = request.prompt_token_ids try: self.preflight_or_raise( num_prompt_tokens=request.num_prompt_tokens, cached_tokens=request.cached_tokens or 0, request_id=request.request_id, ) except Exception: self._release_paged_cache_for_request(request.request_id) raise # Add to tracking self.requests[request.request_id] = request self.waiting.append(request) logger.debug( f"Added request {request.request_id} with {request.num_prompt_tokens} prompt tokens" ) def set_specprefill_draft_model( self, draft_model: Any, draft_model_name: str | None = None ) -> None: """Set the draft model for SpecPrefill scoring. Creates separate block and SSD cache managers for the draft model so target TurboQuant signatures cannot invalidate draft cache blocks. """ if not self._close_specprefill_draft_cache_manager(): raise RuntimeError( "Could not close the previous SpecPrefill draft SSD cache manager" ) self._specprefill_draft_model = draft_model self._draft_prefix_cache: Any | None = None if not draft_model_name: logger.info( "SpecPrefill: draft model set without a stable model name " "(no SSD cache)" ) return if ( self.paged_cache_manager is not None and self.paged_ssd_cache_manager is not None ): try: from .cache.paged_cache import PagedCacheManager from .cache.prefix_cache import BlockAwarePrefixCache name = draft_model_name draft_cache_list = make_prompt_cache(draft_model) draft_layer_cache_types = None if HAS_CACHE_TYPE_HANDLERS and ModelCacheConfig is not None: try: draft_model_cache_config = ModelCacheConfig.from_cache_list( draft_cache_list, model_name=name, ) draft_layer_cache_types = draft_model_cache_config.get_type_names() except Exception as e: logger.debug( "Could not infer SpecPrefill draft cache layout: %s", e ) draft_paged = PagedCacheManager( block_size=self.config.paged_cache_block_size, max_blocks=self.paged_cache_manager.max_blocks, model_name=name, ) draft_ssd = PagedSSDCacheManager( cache_dir=Path(self.config.paged_ssd_cache_dir), max_size_bytes=self.config.paged_ssd_cache_max_size, hot_cache_max_bytes=self.config.hot_cache_max_size, hot_cache_only=self.config.hot_cache_only, hot_cache_budget=self.config.hot_cache_budget, expected_model_name=name, expected_num_layers=len(draft_cache_list), expected_block_size=self.config.paged_cache_block_size, expected_block_size_tokens=self.config.paged_cache_block_size, expected_kv_bytes_per_token=getattr( self.paged_ssd_cache_manager, "_expected_kv_bytes_per_token", 200_000, ), expected_layer_cache_types=draft_layer_cache_types, ) self._draft_paged_ssd_cache_manager = draft_ssd draft_paged.set_paged_ssd_cache_manager(draft_ssd) self._draft_prefix_cache = BlockAwarePrefixCache( model=draft_model, paged_cache_manager=draft_paged, paged_ssd_cache_manager=draft_ssd, ) self._draft_prefix_cache.set_cold_restore_callback( self._restore_block_from_cold ) logger.info( f"SpecPrefill: draft model set with SSD cache (model_name={name})" ) except Exception as e: self._draft_prefix_cache = None self._close_specprefill_draft_cache_manager() logger.warning(f"SpecPrefill: draft SSD cache setup failed: {e}") logger.info("SpecPrefill: draft model set (no SSD cache)") else: logger.info("SpecPrefill: draft model set (no SSD cache)") def _close_specprefill_draft_cache_manager(self) -> bool: manager = self._draft_paged_ssd_cache_manager if manager is None: return True try: manager.close() except Exception as e: logger.warning("SpecPrefill draft SSD cache shutdown error: %s", e) return False writer_thread = getattr(manager, "_writer_thread", None) if writer_thread is not None and writer_thread.is_alive(): logger.warning( "SpecPrefill draft SSD cache writer remains active after shutdown" ) return False self._draft_paged_ssd_cache_manager = None return True def set_vlm_mtp_drafter( self, drafter: VLMMTPDrafter | None, draft_block_size: int | None = None, ) -> None: """Attach an MTP drafter for VLM MTP speculative decode. Called by ``VLMBatchedEngine.set_vlm_mtp_drafter`` once the drafter artifact is loaded. Supports any drafter that mlx-vlm's ``load_drafter()`` resolves to ``kind="mtp"`` (gemma4_assistant, qwen3_5_mtp, etc.). ``None`` clears the toggle. """ self._vlm_mtp_drafter = drafter self._vlm_mtp_draft_block_size = draft_block_size if drafter is not None: logger.info( "VLM MTP drafter attached to scheduler (block_size=%s)", draft_block_size, ) def _route_to_vlm_mtp( self, request: Request, prefilled_cache: list[Any], last_tokens: list[int], sampler: Callable[[Any], Any], state_machine: Any, ) -> int | None: """Bypass BatchGenerator and stand up a vlm_mtp generator instead. Runs the final forward on ``last_tokens`` with ``return_hidden=True`` and ``return_shared_kv=True`` so the drafter has the targets it needs, samples the first bonus token from the resulting logits, and returns a synthesized uid that ``step()`` will drive. Returns ``None`` if the eligibility check fails at the last second (drafter missing, language model lacks rollback hook, etc.) so the caller can fall back to the normal BatchGenerator path. """ drafter = self._vlm_mtp_drafter if drafter is None: return None # Gemma4AssistantDraftModel keeps ``_shared_kv`` / ``_input_embed`` on # the module instance, so multiple in-flight ``_mtp_rounds`` generators # share one drafter and effectively serialize on it: each round has # to ``set_shared_kv`` for its own request before ``draft_block`` runs. # Output stays correct because target-side verify is the source of # truth in speculative decoding (a stale-drafter round just rejects # everything and falls back to a target-only step), but the # per-request tok/s is roughly halved under concurrency. Empirically # at 4 concurrent, vlm_mtp gives ~14 tok/s each vs BatchGenerator's # ~27 tok/s each — BG's batched matmul beats serialized speculative # rounds. So we route only the first eligible request through # vlm_mtp and let subsequent concurrent requests fall back. A future # commit can swap this gate for true batched MTP via # ``_mtp_rounds_batch`` if and when omlx prefill exposes batched # hidden/shared_kv outputs. if self._vlm_mtp_active: logger.info( "vlm_mtp routing skipped for %s: drafter is busy with %d " "request(s); falling back to BatchGenerator", request.request_id, len(self._vlm_mtp_active), ) return None lm = getattr(self.model, "_language_model", None) if lm is None or not hasattr(lm, "rollback_speculative_cache"): logger.warning( "vlm_mtp toggle on but model lacks _language_model with " "rollback_speculative_cache (model=%s); falling back to " "standard decode for request %s", type(self.model).__name__, request.request_id, ) return None target_model = self.model if not last_tokens: logger.warning( "vlm_mtp routing skipped: last_tokens empty for request %s", request.request_id, ) return None mtp_sampler = _make_suppressing_sampler(sampler, self._model_suppress_tokens) last_arr = mx.array(last_tokens)[None] # (1, len_last) try: with mx.stream(self._stream): set_batch_rope = getattr(target_model, "set_batch_rope_deltas", None) if callable(set_batch_rope): set_batch_rope(mx.array([request.rope_deltas])) out = target_model( last_arr, cache=prefilled_cache, return_hidden=True, return_shared_kv=True, ) mx.eval([c.state for c in prefilled_cache]) except Exception as e: logger.warning( "vlm_mtp final-prefill forward failed (%s); falling back " "to standard decode for request %s", e, request.request_id, ) return None # Handle current LanguageModelOutput and legacy tuple # (logits, hidden, gdn_states) MTP runtime patch returns. if isinstance(out, tuple): logits = out[0][:, -1, :] hidden_raw = out[1] else: logits = out.logits[:, -1, :] hidden_raw = out.hidden_states first_bonus_arr = mtp_sampler(logits) # mx.array shape [1] mx.eval(first_bonus_arr) if isinstance(hidden_raw, list): hidden = hidden_raw[-1] else: hidden = hidden_raw # Slice to last position so the drafter sees a [B, 1, H] tensor # regardless of how many tokens this forward processed. if hidden.shape[1] > 1: hidden = hidden[:, -1:, :] # Combine base stop tokens (EOS, Harmony, generation_config) with # request-specific stop_token_ids — same shape as _build_state_machine. eos_ids: set[int] = self._get_stop_tokens() if request.sampling_params.stop_token_ids: eos_ids.update(request.sampling_params.stop_token_ids) try: generator = run_vlm_mtp_decode( target_language_model=target_model, drafter=drafter, prompt_cache=prefilled_cache, hidden=hidden, shared_kv_states=( getattr(out, "shared_kv_states", {}) if not isinstance(out, tuple) else {} ), first_bonus=int(first_bonus_arr.item()), max_tokens=request.sampling_params.max_tokens, sampler=mtp_sampler, draft_block_size=self._vlm_mtp_draft_block_size, token_dtype=mx.int32, eos_token_ids=eos_ids or None, ) except Exception as e: logger.warning( "vlm_mtp generator setup failed (%s); falling back for %s", e, request.request_id, ) return None uid = self._vlm_mtp_next_uid self._vlm_mtp_next_uid -= 1 self._vlm_mtp_active[uid] = _VLMMTPDecodeState( generator=generator, request=request, prompt_cache=prefilled_cache, sampler=mtp_sampler, state_machine=state_machine, max_tokens=request.sampling_params.max_tokens, stop_token_ids=set(eos_ids), ) logger.info( "vlm_mtp decode started: request=%s uid=%d block_size=%s", request.request_id, uid, self._vlm_mtp_draft_block_size, ) return uid def _log_vlm_mtp_stats( self, state: "_VLMMTPDecodeState", finish_reason: str ) -> None: """Emit one INFO line per finished vlm_mtp request with the drafter acceptance rate measured for that request. Reads ``Gemma4AssistantDraftModel.accept_lens`` — a list of accepted draft counts per round, populated inside mlx-vlm's ``_mtp_rounds``. The drafter mutates this in place and ``reset()`` (called at the start of every new round-loop entry) clears it, so we have to read before the next eligible request lands. The serialized routing in ``_route_to_vlm_mtp`` guarantees one in-flight vlm_mtp generator at a time, so the value we read here belongs to ``state.request``. """ drafter = self._vlm_mtp_drafter if drafter is None: return accept_lens = getattr(drafter.model, "accept_lens", None) if not accept_lens: return try: lens = [int(x) for x in accept_lens] except Exception: return rounds = len(lens) if rounds == 0: return total_accepted = sum(lens) block_size = self._vlm_mtp_draft_block_size or int( getattr(drafter.model.config, "block_size", 4) ) max_per_round = max(1, block_size - 1) acceptance_rate = total_accepted / (rounds * max_per_round) avg_tokens_per_round = (total_accepted + rounds) / rounds logger.info( "vlm_mtp stats: request=%s finish=%s rounds=%d " "accepted=%d/%d (%.1f%%) tokens_per_round=%.2f " "emitted=%d block_size=%d", state.request.request_id, finish_reason, rounds, total_accepted, rounds * max_per_round, acceptance_rate * 100, avg_tokens_per_round, state.emitted, block_size, ) def _step_vlm_mtp(self) -> list[_VLMMTPResponse]: """Advance every active vlm_mtp generator by one yield. Returns the synthesized responses for ``_process_batch_responses``. Mirrors mlx-lm BatchGenerator's per-step contract: one ``GenerationBatch.Response``-shaped object per active uid. """ if not self._vlm_mtp_active: return [] responses: list[_VLMMTPResponse] = [] for uid, state in list(self._vlm_mtp_active.items()): try: with mx.stream(self._stream): token_val = next(state.generator) except StopIteration: # Round loop exited naturally — terminate with prompt cache # so the prefix-cache layer can keep using it. self._log_vlm_mtp_stats(state, "length") responses.append( _VLMMTPResponse( uid=uid, token=0, finish_reason="length", prompt_cache=state.prompt_cache, ) ) state.finished = True continue # Single-request mode yields ints; batch mode (not yet routed # by omlx) would yield a list. Guard so the path stays robust # if we widen routing later. if isinstance(token_val, list): # Take the first row (we only route singles for now). tok = next((t for t in token_val if t is not None), None) if tok is None: responses.append( _VLMMTPResponse( uid=uid, token=0, finish_reason="length", prompt_cache=state.prompt_cache, ) ) state.finished = True continue token = int(tok) else: token = int(token_val) state.emitted += 1 finish_reason: str | None = None if state.stop_token_ids and token in state.stop_token_ids: finish_reason = "stop" elif state.emitted >= state.max_tokens: finish_reason = "length" if finish_reason is not None: self._log_vlm_mtp_stats(state, finish_reason) responses.append( _VLMMTPResponse( uid=uid, token=token, finish_reason=finish_reason, prompt_cache=( state.prompt_cache if finish_reason is not None else None ), ) ) if finish_reason is not None: state.finished = True # Drop finished entries. for uid in [u for u, s in self._vlm_mtp_active.items() if s.finished]: del self._vlm_mtp_active[uid] return responses def _try_specprefill_scoring(self, request: Request) -> None: """Score tokens with draft model if SpecPrefill is applicable. Uses paged SSD cache for the draft model: if the prompt prefix was already scored in a previous turn, the draft cache is restored and only the new suffix is prefilled through the draft model. """ if self._specprefill_draft_model is None: return specprefill_enabled = getattr(request, "_specprefill_enabled", False) if not specprefill_enabled: return if request.vlm_inputs_embeds is not None: return remaining = request.remaining_tokens or request.prompt_token_ids if remaining is None: return from .patches.specprefill import DEFAULT_KEEP_RATE, DEFAULT_THRESHOLD from .specprefill.policy import plan_specprefill_scoring # Apply deterministic admission before the draft workflow. plan = plan_specprefill_scoring( remaining_tokens=remaining, system_prompt_end=request.specprefill_system_end, cached_tokens=request.cached_tokens, requested_threshold=getattr(request, "_specprefill_threshold", None), requested_keep_pct=getattr(request, "_specprefill_keep_pct", None), default_threshold=DEFAULT_THRESHOLD, default_keep_pct=DEFAULT_KEEP_RATE, ) if plan is None: return from .specprefill.draft import run_specprefill_draft_scoring run_specprefill_draft_scoring( request=request, plan=plan, draft_model=self._specprefill_draft_model, draft_prefix_cache=self._draft_prefix_cache, model_id=self.config.model_name, prefill_step_size=self.config.prefill_step_size, stream=self._stream, extract_cache_states=self._extract_cache_states, sync_and_clear_cache=lambda: _sync_and_clear_cache(self._stream), log=logger, ) def _cleanup_specprefill(self, request_id: str) -> None: """Clean up SpecPrefill RoPE patches when a request finishes.""" if self._specprefill_active_request_id == request_id: from .patches.specprefill import cleanup_rope cleanup_rope(self.model) self._specprefill_active_request_id = None logger.debug( f"SpecPrefill: RoPE restored for finished request {request_id}" ) def _trim_prompt_cache_for_generation(self, cache_list: list[Any]) -> bool: """Trim each cache layer by one token for exact-hit generation kickoff.""" return self._trim_prompt_cache_by_tokens(cache_list, 1) def _trim_prompt_cache_by_tokens(self, cache_list: list[Any], n: int) -> bool: """Trim each cache layer by n tokens.""" if not cache_list: return False if n <= 0: return True for cache_obj in cache_list: if not self._trim_cache_tree_by_tokens(cache_obj, n): return False return True def _trim_cache_tree_by_one(self, cache_obj: Any) -> bool: """Trim one token from cache object (recursively for CacheList).""" return self._trim_cache_tree_by_tokens(cache_obj, 1) def _trim_cache_tree_by_tokens(self, cache_obj: Any, n: int) -> bool: """Trim n tokens from cache object (recursively for CacheList).""" sub_caches = getattr(cache_obj, "caches", None) if isinstance(sub_caches, (list, tuple)): return all( self._trim_cache_tree_by_tokens(sub_cache, n) for sub_cache in sub_caches ) trim_fn = getattr(cache_obj, "trim", None) if not callable(trim_fn): return False try: trimmed = trim_fn(n) if trimmed is None: return True return int(trimmed) >= n except Exception: return False def _cache_tree_has_class_name( self, cache_obj: Any, class_names: frozenset[str], ) -> bool: """Return True when a cache tree contains one of the named cache classes.""" if type(cache_obj).__name__ in class_names: return True sub_caches = getattr(cache_obj, "caches", None) if isinstance(sub_caches, (list, tuple)): return any( self._cache_tree_has_class_name(sub_cache, class_names) for sub_cache in sub_caches ) return False def _align_minimax_m3_partial_cache_to_prefill_step( self, request: "Request", ) -> bool: """Align MiniMax M3 partial hits to external prefill chunk boundaries.""" cache_list = request.prompt_cache block_table = request.block_table prompt_tokens = request.prompt_token_ids or [] if not cache_list or block_table is None or not block_table.block_ids: return False if block_table.num_tokens <= 0 or block_table.num_tokens >= len(prompt_tokens): return False minimax_m3_names = frozenset({"MiniMaxM3KVCache"}) has_minimax_m3 = any( self._cache_tree_has_class_name(cache_obj, minimax_m3_names) for cache_obj in cache_list ) if not has_minimax_m3: return False block_size = int(getattr(self.config, "paged_cache_block_size", 0) or 0) prefill_step = int(getattr(self.config, "prefill_step_size", 0) or 0) if block_size <= 0 or prefill_step <= block_size: return False aligned_tokens = (block_table.num_tokens // prefill_step) * prefill_step aligned_tokens = (aligned_tokens // block_size) * block_size if aligned_tokens <= 0 or aligned_tokens >= block_table.num_tokens: return False target_block_count = 0 target_tokens = 0 for block_id in block_table.block_ids: block = ( self.paged_cache_manager.allocated_blocks.get(block_id) if self.paged_cache_manager is not None else None ) token_count = int(getattr(block, "token_count", block_size) or block_size) if target_tokens + token_count > aligned_tokens: break target_tokens += token_count target_block_count += 1 if target_tokens != aligned_tokens: logger.debug( "MiniMax M3 partial cache alignment skipped for %s: cannot align " "block table from %d to %d tokens", request.request_id, block_table.num_tokens, aligned_tokens, ) return False trim_tokens = block_table.num_tokens - aligned_tokens if not self._trim_prompt_cache_by_tokens(cache_list, trim_tokens): logger.debug( "MiniMax M3 partial cache alignment skipped for %s: cache trim " "by %d tokens failed", request.request_id, trim_tokens, ) return False dropped_block_ids = block_table.block_ids[target_block_count:] if self.paged_cache_manager is not None: for block_id in dropped_block_ids: self.paged_cache_manager.free_block(block_id) block_table.block_ids = block_table.block_ids[:target_block_count] block_table.num_tokens = aligned_tokens logger.info( "MiniMax M3 partial cache aligned to prefill step for %s: " "%d -> %d tokens, dropped %d block(s)", request.request_id, aligned_tokens + trim_tokens, aligned_tokens, len(dropped_block_ids), ) return True def _remove_uid_from_active_batch(self, uid: int) -> None: """Remove UID from BatchGenerator safely. vlm_mtp uses negative uids that BatchGenerator never sees; the per-uid generator state is owned by ``_vlm_mtp_active`` and gets dropped when ``_step_vlm_mtp`` marks the entry finished. """ if uid < 0: return if self.batch_generator is None: return self.batch_generator.remove([uid]) def _check_pending_aborts_for_uids(self, uids: list[int]) -> list[int]: """Return UIDs that have pending aborts. Called during prefill to detect aborted requests between chunks. GIL guarantees thread-safe reads of _pending_abort_ids from the executor thread. """ if not self._pending_abort_ids: return [] aborted = [] for uid in uids: request_id = self.uid_to_request_id.get(uid) if request_id and request_id in self._pending_abort_ids: aborted.append(uid) return aborted def abort_request(self, request_id: str) -> bool: """ Enqueue a request for deferred abort. The actual abort is processed at the start of the next step() call, ensuring thread safety with the hybrid executor pattern. CPython GIL guarantees set.add() is atomic. Args: request_id: The request ID to abort Returns: True (abort is always enqueued) """ self._pending_abort_ids.add(request_id) logger.debug(f"Enqueued deferred abort for request {request_id}") return True def _process_pending_aborts(self) -> None: """Drain and process pending abort requests. Called from step() to ensure aborts are processed in the same execution context as generation (thread-safe). """ while self._pending_abort_ids: request_id = self._pending_abort_ids.pop() self._do_abort_request(request_id) def _cleanup_prefill_abort_request( self, request: "Request", temp_uid: int | None = None ) -> None: """Finish cleanup for a request aborted while it was being prefetched. External prefill removes the request from ``waiting`` before it has a real BatchGenerator UID. If a client abort arrives at that point, the normal next-step deferred abort can be stranded because ``has_requests`` no longer sees queued work. Finish it synchronously on the scheduler thread instead. """ if temp_uid is not None: self.uid_to_request_id.pop(temp_uid, None) self.request_id_to_uid.pop(request.request_id, None) self._pending_abort_ids.discard(request.request_id) self._do_abort_request(request.request_id) def request_idle_reclaim(self) -> None: """Enqueue a between-turn Metal reclaim (thread-safe, no Metal touch). Called by ProcessMemoryEnforcer (asyncio thread) when memory pressure is hard but every loaded model is pinned and no load is in progress — the case where there is nothing to evict. Setting the flag is GIL-atomic; the actual ``_sync_and_clear_cache`` runs on the inference thread when step() drains it, and only when the scheduler is idle. """ self._pending_reclaim_request = True def _process_pending_reclaim(self) -> None: """Drain a deferred idle reclaim request (inference-thread side). Only reclaims when truly idle (no running / prefilling / waiting work) so we never clear Metal buffers an in-flight decode or prefill still references. """ if not self._pending_reclaim_request: return self._pending_reclaim_request = False if self.running or self.prefilling or self.waiting: return before = self._current_usage_bytes() after = self._reclaim_prefill_headroom() logger.info( "Idle reclaim: trimmed Metal transients between turns " "(%.1fGB -> %.1fGB)", before / 1024**3, after / 1024**3, ) def _do_abort_request(self, request_id: str) -> bool: """ Actually abort a request. Must be called from the step() context. Args: request_id: The request ID to abort Returns: True if request was found and aborted, False otherwise """ request = self.requests.get(request_id) if request is None: return False self._clear_request_admission_bookkeeping(request_id) # Remove from waiting queue if request.status == RequestStatus.WAITING: try: self.waiting.remove(request) except ValueError: pass # Remove from chunked-prefill queue (if mid-prefill) if request_id in self._prefill_states: self._prefill_states.pop(request_id, None) self.prefilling = deque( r for r in self.prefilling if r.request_id != request_id ) # Remove from running (BatchGenerator) if request.request_id in self.request_id_to_uid: uid = self.request_id_to_uid[request.request_id] # Synchronize in-flight GPU work before modifying batch state. # batch_generator.remove() triggers lazy KV cache array slicing # that replaces references to arrays still used by in-flight # Metal command buffers. Without this barrier the Metal driver # can hit 'completeMemory() prepare count underflow'. _safe_sync_stream(self._stream) self._remove_uid_from_active_batch(uid) if hasattr(self.model, "unregister_rope_delta"): self.model.unregister_rope_delta(uid) if uid < 0: mtp_state = self._vlm_mtp_active.pop(uid, None) if mtp_state is not None: close = getattr(mtp_state.generator, "close", None) if callable(close): close() _unregister_uid_row(self.model, uid) del self.uid_to_request_id[uid] del self.request_id_to_uid[request.request_id] if request_id in self.running: del self.running[request_id] # Release blocks for eviction (same as _cleanup_finished) if self.paged_cache_manager is not None: block_table = self.paged_cache_manager.get_block_table(request_id) if block_table is None and hasattr(request, "block_table"): block_table = request.block_table if block_table: released = self.paged_cache_manager.release_for_eviction( block_table.block_ids ) if released > 0: logger.debug( f"Released {released} blocks for eviction on abort " f"(request {request_id})" ) # Clear request entry from block_aware_cache if self.block_aware_cache is not None: self.block_aware_cache.clear_request_entry(request_id) # Clean up streaming detokenizer to prevent state contamination self._cleanup_detokenizer(request_id) # Clean up protocol-specific output parser session self._cleanup_output_parser_session(request_id) # Clean up VLM adapter state to prevent contamination if hasattr(self.model, "clear_vlm_position_state"): self.model.clear_vlm_position_state() if hasattr(self.model, "clear_pending_embeddings"): self.model.clear_pending_embeddings() # Drop any boundary snapshot for this request. self._boundary_cache_snapshots.pop(request_id, None) if self._boundary_snapshot_store is not None: self._boundary_snapshot_store.cleanup_request(request_id) # Remove from prefill progress tracker. get_prefill_tracker().remove(request_id) # Mark as aborted request.set_finished(RequestStatus.FINISHED_ABORTED) self.finished_req_ids.add(request_id) # Remove from requests dict and clear cache references to release # MLX arrays promptly (mirrors _cleanup_finished behavior). # _cleanup_request (engine_core) no longer calls remove_finished_request, # so this is the single cleanup point for aborted requests. req_to_remove = self.requests.pop(request_id, None) if req_to_remove is not None: req_to_remove._extracted_cache = None req_to_remove.prompt_cache = None logger.debug(f"Aborted request {request_id}") return True def has_requests(self) -> bool: """Check if there are any pending or running requests. Also returns True when a deferred Metal cache clear is pending, so that the engine loop keeps calling step() until the clear fires. Without this, an idle server would never reach the target step and stale buffers would accumulate indefinitely. """ return bool( self.waiting or self.prefilling or self.running or self._pending_async_removes or self._deferred_clear_at is not None ) def _refresh_generation_overflow_recovery_ids(self) -> None: """Drop serial-retry markers once the affected requests leave the scheduler.""" if not self._generation_overflow_recovery_ids: return active_ids = set(self.running) active_ids.update(request.request_id for request in self.waiting) active_ids.update(request.request_id for request in self.prefilling) self._generation_overflow_recovery_ids.intersection_update(active_ids) def _effective_max_num_seqs(self) -> int: """Current admission cap, narrowed for models that require serial decode.""" self._refresh_generation_overflow_recovery_ids() if self._serialize_llama4_requests or self._generation_overflow_recovery_ids: return 1 return max(1, self.config.max_num_seqs) def fail_all_requests(self) -> list[str]: """Remove all running and waiting requests after unrecoverable error. Used as a safety net by engine_core when step() raises an unexpected exception, to prevent infinite loops. Only resets batch_generator (not full cache) because this method is called for non-corruption errors — corruption is already handled inside step(). Returns: List of failed request IDs. """ failed_ids: list[str] = [] for request_id in list(self.running): failed_ids.append(request_id) req = self.requests.pop(request_id, None) self._clear_request_admission_bookkeeping(request_id) if req is not None: req._extracted_cache = None req.prompt_cache = None self.running.clear() for request in list(self.prefilling): failed_ids.append(request.request_id) req = self.requests.pop(request.request_id, None) self._clear_request_admission_bookkeeping(request.request_id) if req is not None: req._extracted_cache = None req.prompt_cache = None self.prefilling.clear() self._prefill_states.clear() for request in list(self.waiting): failed_ids.append(request.request_id) req = self.requests.pop(request.request_id, None) self._clear_request_admission_bookkeeping(request.request_id) if req is not None: req._extracted_cache = None req.prompt_cache = None self.waiting.clear() # Catch in-flight orphans: a request popped from self.waiting but # not yet added to self.running (or self.prefilling) sits as a # local in _schedule_waiting. If _do_external_prefill raises, the # request is unreachable through the three queues but still lives # in self.requests (and the engine_core collector / finished_event # for its id is still waiting). Without this pass, fail_all_requests # returns an incomplete list and the HTTP request hangs forever. # # Exclude finished requests still awaiting async cache-store cleanup # (those have an entry in ``_inflight_store_futures`` — see # ``_cleanup_finished`` line ~5267). They have already emitted a # ``finished=True`` output to their collector; ``_drain_pending_async_removes`` # pops them from ``self.requests`` after the store future completes. # Failing them here would append an error output that wins over the # success for non-streaming ``generate()`` callers (engine_core # returns the last queued output). for request_id in list(self.requests): if request_id in self._inflight_store_futures: continue failed_ids.append(request_id) req = self.requests.pop(request_id, None) self._clear_request_admission_bookkeeping(request_id) if req is not None: req._extracted_cache = None req.prompt_cache = None # Clear stale uid mappings for every failed id. Running requests hold # real uids; the in-flight orphan above holds the temp_uid assigned at # _schedule_waiting (id(request)) that its success-path cleanup never # reached. batch_generator is reset below, so these mappings are dead # either way. failed_ids excludes _inflight_store_futures ids, so the # async-cleanup uids that _drain_pending_async_removes still needs are # left intact. for rid in failed_ids: uid = self.request_id_to_uid.pop(rid, None) if uid is not None: self.uid_to_request_id.pop(uid, None) self._generation_overflow_recovery_ids.difference_update(failed_ids) # Reset batch generator only (cache is not corrupted). Every row dies # with it; survivors re-register at re-insert. _unregister_uid_rows_for_model(self.model) self.batch_generator = None self._current_sampler_params = None # Reclaim fragmented Metal buffers after generation failure. # Without this, subsequent requests may hit the same resource # limit even though Python references have been cleared. # Wrapped in try-except because Metal may already be in an error # state — mx.synchronize() or mx.clear_cache() can throw a C++ # exception that causes SIGABRT if uncaught (#435). try: _sync_and_clear_cache(self._stream) except Exception as e: logger.warning(f"Metal cache clear failed during error recovery: {e}") # Requests failed mid-prefill leave PrefillProgressTracker entries # behind (auto-removal only fires at processed >= total). The local # RuntimeError handlers in the prefill paths cover the common memory # errors (#1405), but any other exception type bubbles up here and # would leak a phantom "PP" row on the dashboard. tracker = get_prefill_tracker() for rid in failed_ids: tracker.remove(rid) # Republish the admin snapshot now that the queues are empty. The # snapshot is normally published at the end of a successful step(); # when step() raises, the last published snapshot still lists the # failed requests, so the dashboard and the macOS app keep showing # them as "generating" until the next successful step (#2126). self._publish_admin_snapshot() return failed_ids def get_num_waiting(self) -> int: """Get number of waiting requests.""" return len(self.waiting) def get_num_running(self) -> int: """Get number of running requests.""" return len(self.running) def _num_admitted_requests(self) -> int: """Return requests already occupying scheduler capacity.""" return len(self.running) + len(self.prefilling) def _preflight_memory_check( self, request: "Request" ) -> "_PreflightRejection | None": """ Estimate whether prefill would exceed memory limits. Computes worst-case peak memory for the last prefill chunk (model weights + KV cache + SDPA activation/scratch) and rejects if it would exceed the hard limit. Mirrors MLX SDPA dispatch closely enough that unsupported prefill head dimensions are charged for the unfused fp32 score matrix. Returns: ``_PreflightRejection`` carrying the message + numeric estimated / limit bytes if the request should be rejected, otherwise ``None``. The structured return lets the server layer populate ``PrefillMemoryExceededError.estimated_bytes`` / ``limit_bytes`` without parsing the human string. """ if not self._prefill_memory_guard: return None if self._memory_hard_limit_bytes <= 0: return None if self.memory_monitor is None: return None prompt_tokens = request.num_prompt_tokens cached_tokens = request.cached_tokens or 0 new_tokens = max(prompt_tokens - cached_tokens, 0) if new_tokens == 0: return None peak = self.memory_monitor.estimate_prefill_peak_bytes( new_tokens, self.config.prefill_step_size, cached_tokens=cached_tokens ) if peak == 0: return None # can't estimate, skip current = self._current_usage_bytes() estimated = current + peak hard_limit = self._memory_hard_limit_bytes if estimated > hard_limit: # Try LRU eviction first (upstream's predictive-throttle # path): if eviction can free enough headroom this raises # ``_PrefillEvictionNeeded`` and the request is paused for # retry. If eviction can't help (already retried, no idle # models), the call is a no-op and we fall through to the # typed rejection. self._raise_prefill_eviction_if_available( request_id=request.request_id, current=current, target_cap=hard_limit, predicted_transient=peak, requested_tokens=min(new_tokens, self.config.prefill_step_size), reason="prefill_preflight", ) message = self._format_rejection_message( estimated=estimated, current=current, peak=peak, hard_limit=hard_limit, ) return _PreflightRejection( message=message, estimated_bytes=int(estimated), limit_bytes=int(hard_limit), ) safety_rejection = self._preflight_safety_rejection( num_prompt_tokens=prompt_tokens, cached_tokens=cached_tokens, current_usage_bytes=current, ) if safety_rejection is not None: requested_tokens = min(max(1, self._prefill_min_chunk_tokens), new_tokens) self._raise_prefill_eviction_if_available( request_id=request.request_id, current=current, target_cap=safety_rejection.limit_bytes, predicted_transient=max( 0, int(safety_rejection.estimated_bytes) - int(current) ), requested_tokens=requested_tokens, reason="prefill_safety_cap", ) return safety_rejection return None def _memory_component_limit_for_rejection(self, component_limit: int) -> int: if component_limit <= 0: return 0 hot_reserved = max(0, int(self._memory_hot_cache_reserved_bytes or 0)) if hot_reserved <= 0: return component_limit return max(1, component_limit - hot_reserved) def _format_rejection_message( self, *, estimated: int, current: int, peak: int, hard_limit: int, ) -> str: """Build the prefill-rejection diagnostic. Identifies which of static / dynamic / metal_cap is binding so the message can steer the user to the right remedy (close apps for dynamic, raise sysctl for metal_cap, raise tier or reduce context for static). Component ceilings are propagated by ``ProcessMemoryEnforcer._propagate_memory_limit``; if a caller wired this scheduler outside that path the components stay 0 and we fall back to a generic message. """ from .utils.hardware import format_bytes static = self._memory_static_ceiling_bytes dynamic = self._memory_dynamic_ceiling_bytes metal_cap = self._memory_metal_cap_bytes binding: list[str] = [] if static and self._memory_component_limit_for_rejection(static) == hard_limit: binding.append("static") if ( dynamic and self._memory_component_limit_for_rejection(dynamic) == hard_limit ): binding.append("dynamic") if ( metal_cap and self._memory_component_limit_for_rejection(metal_cap) == hard_limit ): binding.append("metal_cap") binding_str = "/".join(binding) if binding else "effective" # Order remedies by likelihood of helping for the binding cause. # Dynamic-bound on a reclaim tier (safe/balanced/aggressive) means # reclaimable memory is low right now even though the static cap # has room — closing apps raises ``free`` / ``inactive`` and a # more aggressive ``memory_guard_tier`` raises the active-reclaim # ratio. Dynamic-bound under ``custom`` means the user pinned the # ceiling there; the only knob that helps is raising # ``custom_ceiling_bytes`` itself. Metal-cap bound means the # kernel sysctl is the ceiling, so raising ``iogpu.wired_limit_mb`` # is the only knob that helps. Static-bound (or no breakdown # available) leaves ``memory_guard_tier`` / context length as the # levers. is_custom = self._memory_guard_tier == "custom" if "dynamic" in binding and is_custom: advice = ( f"raise custom_ceiling_bytes in admin Memory settings " f"(currently pinned at {format_bytes(dynamic)}), " f"or reduce context length" ) elif "dynamic" in binding and static and static > dynamic: headroom = max(0, dynamic - current) advice = ( f"close other apps to free RAM " f"(static cap is {format_bytes(static)} but only " f"{format_bytes(headroom)} is reclaimable right now), " f"raise memory_guard_tier (safe → balanced → aggressive), " f"or reduce context length" ) elif "metal_cap" in binding: advice = ( f"raise kernel iogpu.wired_limit_mb in Terminal " f"(currently caps Metal at {format_bytes(metal_cap)}), " f"or reduce context length" ) else: advice = ( "reduce context length or raise memory_guard_tier " "(safe → balanced → aggressive)" ) advice = advice[:1].upper() + advice[1:] return ( f"Prefill would require ~{format_bytes(estimated)} peak " f"(current {format_bytes(current)} + KV+SDPA {format_bytes(peak)}) " f"but {binding_str} ceiling is {format_bytes(hard_limit)}. " f"{advice}." ) def preflight_or_raise( self, *, num_prompt_tokens: int, cached_tokens: int = 0, request_id: str | None = None, ) -> None: """Pre-StreamingResponse prefill memory check. Called from the engine's ``preflight_chat`` / ``preflight_completion`` before the FastAPI route wraps the body in a ``StreamingResponse``, so the typed exception can be mapped to HTTP 400 by the registered handler. A no-op when the guard is disabled or the request fits. Mirrors the ``_preflight_memory_check`` math but takes token counts directly (no Request object) and raises instead of returning a message — the in-stream re-check inside ``_schedule_waiting`` remains as defense-in-depth. """ if not self._prefill_memory_guard: return if self._memory_hard_limit_bytes <= 0: return if self.memory_monitor is None: return new_tokens = max(int(num_prompt_tokens) - max(int(cached_tokens), 0), 0) if new_tokens == 0: return peak = self.memory_monitor.estimate_prefill_peak_bytes( new_tokens, self.config.prefill_step_size, cached_tokens=cached_tokens ) if peak == 0: return current = self._current_usage_bytes(refresh_mlx_active=False) if not request_id: import uuid as _uuid request_id = f"preflight-{_uuid.uuid4().hex[:8]}" if current + peak > self._memory_hard_limit_bytes: message = self._format_rejection_message( estimated=current + peak, current=current, peak=peak, hard_limit=self._memory_hard_limit_bytes, ) logger.warning( "Preflight rejected (%d tokens, cached=%d, request_id=%s): %s", num_prompt_tokens, cached_tokens, request_id, message, ) raise PrefillMemoryExceededError( message=message, request_id=request_id, estimated_bytes=int(current + peak), limit_bytes=int(self._memory_hard_limit_bytes), ) safety_rejection = self._preflight_safety_rejection( num_prompt_tokens=num_prompt_tokens, cached_tokens=cached_tokens, current_usage_bytes=current, ) if safety_rejection is None: return logger.warning( "Preflight safety-cap rejected (%d tokens, cached=%d, " "request_id=%s): %s", num_prompt_tokens, cached_tokens, request_id, safety_rejection.message, ) raise PrefillMemoryExceededError( message=safety_rejection.message, request_id=request_id, estimated_bytes=safety_rejection.estimated_bytes, limit_bytes=safety_rejection.limit_bytes, ) def preflight_eviction_request( self, *, num_prompt_tokens: int, cached_tokens: int = 0, request_id: str | None = None, ) -> PrefillEvictionRequest | None: """Return an idle-model eviction request for route-level preflight. ``preflight_or_raise`` runs before a ``Request`` is admitted, so it cannot use the request-bound ``_raise_prefill_eviction_if_available``. The API-facing engines call this first, run the async pool callback if needed, then call ``preflight_or_raise`` to re-measure and reject only if eviction did not create enough headroom. """ if not self._prefill_memory_guard: return None if self._memory_hard_limit_bytes <= 0: return None if self.memory_monitor is None: return None new_tokens = max(int(num_prompt_tokens) - max(int(cached_tokens), 0), 0) if new_tokens == 0: return None current = self._current_usage_bytes(refresh_mlx_active=False) request_id = request_id or "preflight" peak = self.memory_monitor.estimate_prefill_peak_bytes( new_tokens, self.config.prefill_step_size, cached_tokens=cached_tokens ) if peak and current + peak > self._memory_hard_limit_bytes: return PrefillEvictionRequest( request_id=request_id, model_id=getattr(self.config, "model_name", ""), current_bytes=int(current), target_cap_bytes=int(self._memory_hard_limit_bytes), predicted_transient_bytes=int(peak), requested_tokens=int(min(new_tokens, self.config.prefill_step_size)), reason="prefill_preflight", ) safety_rejection = self._preflight_safety_rejection( num_prompt_tokens=num_prompt_tokens, cached_tokens=cached_tokens, current_usage_bytes=current, ) if safety_rejection is None: return None requested_tokens = min(max(1, self._prefill_min_chunk_tokens), new_tokens) return PrefillEvictionRequest( request_id=request_id, model_id=getattr(self.config, "model_name", ""), current_bytes=int(current), target_cap_bytes=int(safety_rejection.limit_bytes), predicted_transient_bytes=max( 0, int(safety_rejection.estimated_bytes) - int(current) ), requested_tokens=int(requested_tokens), reason="prefill_safety_cap", ) def _preflight_safety_rejection( self, *, num_prompt_tokens: int, cached_tokens: int = 0, current_usage_bytes: int, ) -> _PreflightRejection | None: """Predict whether even the safety floor chunk cannot fit. This mirrors the mid-prefill ``_guard_prefill_chunk`` rejection, but runs before the route returns a ``StreamingResponse``. It charges the resident KV that will be allocated by the prompt plus the minimum chunk transient at the full prompt context length. """ if self.memory_monitor is None: return None base_cap, cap, margin = self._prefill_abort_description() if cap <= 0: return None new_tokens = max(int(num_prompt_tokens) - max(int(cached_tokens), 0), 0) if new_tokens == 0: return None floor_chunk = min(max(1, self._prefill_min_chunk_tokens), new_tokens) kv_len = max(int(num_prompt_tokens) - 1, 1) kv_growth = self.memory_monitor.estimate_prompt_kv_bytes(new_tokens) min_transient = self._predicted_chunk_transient(floor_chunk, kv_len) if kv_growth <= 0 and min_transient <= 0: return None estimated = int(current_usage_bytes + kv_growth + min_transient) if estimated <= cap: return None from .utils.hardware import format_bytes message = ( "Prefill context too large for available memory " f"(preflight safety guard, kv_len={kv_len}, " f"min_chunk={floor_chunk}): predicted peak would require " f"~{format_bytes(estimated)} " f"(current {format_bytes(current_usage_bytes)} + " f"KV {format_bytes(kv_growth)} + " f"min-chunk transient {format_bytes(min_transient)}) " f"but prefill safety cap is {format_bytes(cap)} " f"({round(margin * 100)}% of effective ceiling " f"{format_bytes(base_cap)}). Reduce context length, free system " "memory, or loosen memory_guard_tier (safe → balanced → aggressive)." ) return _PreflightRejection( message=message, estimated_bytes=estimated, limit_bytes=int(cap), ) def _schedule_waiting( self, ) -> tuple[list["Request"], list[RequestOutput]]: """ Move requests from waiting queue to running. Each request is prefilled externally before being inserted into BatchGenerator, so prefill_batch_size=1 is always used. Cache status homogeneity tracking is kept for safety since it affects how we handle the existing_cache argument. Returns: Tuple of (scheduled requests, rejected error outputs) """ scheduled = [] rejected_outputs: list[RequestOutput] = [] # Track cache status of first scheduled request to ensure homogeneity # None = not determined yet, True = has cache, False = no cache batch_cache_status: bool | None = None # Track VLM status: VLM and text-only requests cannot be in the same prefill batch # None = not determined yet, True = VLM request, False = text-only request batch_vlm_status: bool | None = None # Track SpecPrefill: these requests must be alone (RoPE patching affects whole model) batch_specprefill_status: bool | None = None while ( self.waiting and self._num_admitted_requests() < self._effective_max_num_seqs() ): # Admission pause: set by ProcessMemoryEnforcer when phys # crosses soft_threshold. New prefills wait; in-flight requests # continue. First request always passes (no admitted work yet) # so admission can recover by completing the current generation. admitted = self._num_admitted_requests() if self._admission_paused and admitted: logger.debug( "Admission paused by memory pressure, %d admitted", admitted, ) stalled = self._memory_admission_stall_output("admission_paused") if stalled is not None: rejected_outputs.append(stalled) break # Store-cache backpressure: when the post-completion pipeline is # at its cleanup cap, defer admitting new prefills instead of # blocking the generation step on the store-cache write (#1496). # The cap bounds concurrent extracted-KV copies (the #1383 OOM # guard) and shrinks under memory pressure via # adjust_store_cache_cap. This also applies between sequential # turns: a new prefill must not start while async store-cache # cleanup still owns too many large cache payloads (#1684). gate = self._store_cache_gate pending_store_cleanups = len(self._pending_async_removes) if gate is not None and ( not gate.has_capacity or pending_store_cleanups >= gate.cap ): logger.debug( "Admission deferred: store-cache pipeline full " "(in_flight=%d pending_cleanups=%d cap=%d), %d running", gate.in_flight, pending_store_cleanups, gate.cap, len(self.running), ) memory_related_gate = self._admission_paused if ( not memory_related_gate and self._prefill_memory_guard and self._memory_limit_bytes > 0 ): try: memory_related_gate = ( self._current_usage_bytes() >= self._memory_limit_bytes ) except Exception: memory_related_gate = False if memory_related_gate: if self.waiting: self._clear_store_cache_admission_blocker( self.waiting[0].request_id ) stalled = self._memory_admission_stall_output( "store_cache_backpressure" ) if stalled is not None: rejected_outputs.append(stalled) else: if self.waiting: self._clear_memory_admission_blocker(self.waiting[0].request_id) stalled = self._store_cache_admission_stall_output( "store_cache_backpressure", gate_in_flight=gate.in_flight, gate_cap=gate.cap, pending_cleanups=pending_store_cleanups, ) if stalled is not None: rejected_outputs.append(stalled) break # Generation memory guard: when requests are already admitted, # defer scheduling if memory pressure is high to prevent # Metal allocation failures during batch_generator.next(). # First request always passes (no admitted work yet). if self._prefill_memory_guard and self._memory_limit_bytes > 0 and admitted: current = self._current_usage_bytes() if current > self._memory_limit_bytes: logger.debug( "Generation memory guard: deferring scheduling " "(%s > %s), %d admitted", current, self._memory_limit_bytes, admitted, ) stalled = self._memory_admission_stall_output( "generation_memory_guard" ) if stalled is not None: rejected_outputs.append(stalled) break request = self.waiting[0] self._clear_memory_admission_blocker(request.request_id) self._clear_store_cache_admission_blocker(request.request_id) if self._should_defer_for_cache_freshness(request): break request = self.waiting.popleft() self._cache_freshness_waits.pop(request.request_id, None) self._clear_memory_admission_blocker(request.request_id) self._clear_store_cache_admission_blocker(request.request_id) # Ensure we have a batch generator self._ensure_batch_generator(request.sampling_params) if self.batch_generator is None: # Put back and try again later self.waiting.appendleft(request) break self._prepare_prefix_cache_for_request(request) # Determine tokens to process and cache to use # Note: Don't use `remaining_tokens or prompt_token_ids` because empty list # is falsy in Python. For exact cache match, remaining_tokens=[] but we should # pass just the last token so BatchGenerator can start generation. if ( request.remaining_tokens is not None and len(request.remaining_tokens) == 0 ): # Exact cache match - pass only last token for generation kickoff tokens_to_process = request.prompt_token_ids[-1:] elif request.remaining_tokens: tokens_to_process = request.remaining_tokens else: tokens_to_process = request.prompt_token_ids cache_to_use = request.prompt_cache # May be None # Validate cache before using it if cache_to_use is not None and not self._validate_cache(cache_to_use): logger.debug( f"Request {request.request_id}: invalid cache detected, " f"proceeding without cache" ) cache_to_use = None request.prompt_cache = None request.cached_tokens = 0 request.remaining_tokens = request.prompt_token_ids tokens_to_process = request.prompt_token_ids # SpecPrefill requests must be alone in the batch (RoPE patching # affects the entire model). Also block scheduling if another # specprefill request is already running (offset RoPE active). request_is_specprefill = request.specprefill_indices is not None if ( self._specprefill_active_request_id is not None and not request_is_specprefill ): # A specprefill request is running — defer all others until it finishes self.waiting.appendleft(request) break if batch_specprefill_status is None: batch_specprefill_status = request_is_specprefill elif batch_specprefill_status != request_is_specprefill: self.waiting.appendleft(request) break if request_is_specprefill and len(scheduled) > 0: # SpecPrefill request must be alone self.waiting.appendleft(request) break # Check VLM status homogeneity: VLM and text-only requests use # different prefill paths (embeddings vs token IDs) request_is_vlm = request.vlm_inputs_embeds is not None if batch_vlm_status is None: batch_vlm_status = request_is_vlm elif batch_vlm_status != request_is_vlm: # VLM status mismatch - defer this request to next batch self.waiting.appendleft(request) logger.debug( f"Deferring request {request.request_id} to next batch " f"(VLM status mismatch: batch={batch_vlm_status}, request={request_is_vlm})" ) break # Check cache status homogeneity (kept for consistent prefill behavior) request_has_cache = cache_to_use is not None if batch_cache_status is None: batch_cache_status = request_has_cache elif batch_cache_status != request_has_cache: # Cache status mismatch - defer this request to next batch self.waiting.appendleft(request) logger.debug( f"Deferring request {request.request_id} to next batch " f"(cache status mismatch: batch={batch_cache_status}, request={request_has_cache})" ) break # Mark as Harmony model if applicable (before think detection) if self._is_harmony_model: request.is_harmony_model = True # Check if prompt ends with token for reasoning models. # Must happen before _build_sampler_and_processors so the thinking # budget processor can check needs_think_prefix. if self._detect_needs_think_prefix(request): request.needs_think_prefix = True # Per-request sampler/logits processors to avoid BatchGenerator recreation. sampler, logits_processors = self._build_sampler_and_processors( request.sampling_params, request ) # Pre-flight memory guard: estimate peak memory for this request # and reject if it would exceed the hard limit. The check # may raise ``_PrefillEvictionNeeded`` (upstream's # predictive-throttle path) to pause and retry under # eviction headroom; only if eviction can't help does the # typed rejection propagate. try: preflight_rejection = self._preflight_memory_check(request) except _PrefillEvictionNeeded as e: self._pause_for_prefill_eviction(request, e.request) break if preflight_rejection is not None: logger.warning( f"Request {request.request_id} rejected by prefill " f"memory guard: {preflight_rejection.message}" ) self._release_paged_cache_for_request(request.request_id) self.requests.pop(request.request_id, None) self._clear_request_admission_bookkeeping(request.request_id) rejected_outputs.append( _prefill_memory_error_output( request.request_id, preflight_rejection.message, estimated_bytes=preflight_rejection.estimated_bytes, limit_bytes=preflight_rejection.limit_bytes, ) ) continue # SpecPrefill: replace tokens with selected subset and pre-fill # cache via sparse_prefill before inserting into BatchGenerator. # # Key design: sparse_prefill processes selected tokens (excluding # the last prompt token). BatchGenerator then processes the last # prompt token to produce generation logits. This avoids: # - Double-processing the last token (Bug #2) # - Off-by-one RoPE positions (Bug #1) # # Position math: # sparse_prefill: N' tokens, adjustment = M - N' # We subtract 1: adjustment = M - N' - 1 # BatchGenerator last token: pos = N' + (M - N' - 1) = M - 1 # First gen token: pos = (N'+1) + (M - N' - 1) = M if request.specprefill_indices is not None: tracker = get_prefill_tracker() model_id = self.config.model_name total_pp = 0 try: sys_count = getattr(request, "_specprefill_system_tokens", 0) all_tokens = tokens_to_process from .specprefill.planning import plan_specprefill_target target_plan = plan_specprefill_target( all_tokens=all_tokens, system_token_count=sys_count, selected_indices=request.specprefill_indices.tolist(), position_offset=request.specprefill_position_offset, ) m_pre = target_plan.conversation_token_count n_eff = target_plan.sparse_selected_token_count total_pp = target_plan.total_tracker_prefill_count tracker.update(request.request_id, 0, total_pp, model_id) spec_sparse_extra = { "prompt_tokens": request.num_prompt_tokens, "system_tokens": request.specprefill_system_end, "conversation_tokens": request.num_prompt_tokens - request.specprefill_system_end, "cached_tokens": request.cached_tokens, "scored_tokens": m_pre, "selected_tokens": n_eff, "keep_percent": ( round(n_eff / m_pre * 100) if m_pre > 0 else 0 ), } def _check_specprefill_abort(processed: int) -> None: if request.request_id in self._pending_abort_ids: logger.info( f"SpecPrefill interrupted at {processed}/{total_pp} " f"tokens: request aborted" ) tracker.remove(request.request_id) self.waiting.appendleft(request) raise _PrefillAbortedError([], processed) def _report_system_progress(processed: int, total: int) -> None: tracker.update( request.request_id, min(processed, total_pp - 1), total_pp, model_id, phase="specprefill_system", detail="system prompt prefill", extra=spec_sparse_extra, ) def _report_sparse_progress(processed: int, total: int) -> None: _check_specprefill_abort(sys_count + processed) tracker.update( request.request_id, min(sys_count + processed, total_pp - 1), total_pp, model_id, phase="specprefill_sparse", detail="sparse target prefill", extra={ "scored_tokens": m_pre, "selected_tokens": n_eff, "keep_percent": ( round(n_eff / m_pre * 100) if m_pre > 0 else 0 ), "prompt_tokens": request.num_prompt_tokens, "system_tokens": request.specprefill_system_end, "conversation_tokens": request.num_prompt_tokens - request.specprefill_system_end, "cached_tokens": request.cached_tokens, }, ) from .specprefill.target import run_specprefill_target_prefill target_result = run_specprefill_target_prefill( target_model=self.model, request=request, plan=target_plan, all_tokens=all_tokens, selected_indices=request.specprefill_indices, prefill_step_size=self.config.prefill_step_size, stream=self._stream, check_abort=_check_specprefill_abort, report_system_progress=_report_system_progress, report_sparse_progress=_report_sparse_progress, sync_and_clear_cache=lambda: _sync_and_clear_cache(self._stream), log=logger, ) cache_to_use = target_result.prompt_cache tokens_to_process = target_result.tokens_to_process self._specprefill_active_request_id = request.request_id # Mark spec-prefill complete (auto-removes tracker entry). tracker.update(request.request_id, total_pp, total_pp, model_id) except _PrefillAbortedError: from .patches.specprefill import cleanup_rope cleanup_rope(self.model) request.specprefill_indices = None tracker.remove(request.request_id) _sync_and_clear_cache(self._stream) self._cleanup_prefill_abort_request(request) continue except Exception as e: from .patches.specprefill import cleanup_rope logger.error(f"SpecPrefill sparse prefill failed: {e}") cleanup_rope(self.model) request.specprefill_indices = None tracker.remove(request.request_id) # Fall through to normal prefill # External prefill: process tokens[0:N-1] outside BatchGenerator. # Only the last token goes to insert() for the first decode step. # SpecPrefill already handled its own prefill above, so skip for those. if request.specprefill_indices is None and len(tokens_to_process) > 1: vlm_embeds = None if request.vlm_inputs_embeds is not None: vlm_embeds = ( request.vlm_inputs_embeds, request.vlm_extra_kwargs or {}, request.cached_tokens, ) # Chunked prefill: non-VLM prompts longer than one step are # spread across multiple step() calls. The first chunk is run # here; subsequent chunks run in _advance_chunked_prefills(). if ( self.config.chunked_prefill and vlm_embeds is None and len(tokens_to_process) > self.config.prefill_step_size + 1 ): sm = self._build_state_machine(request) per_row_lps = list(logits_processors) if logits_processors else [] state = self._begin_prefill( request, tokens_to_process, cache_to_use ) state.sampler = sampler state.sm = sm state.per_row_lps = per_row_lps try: done = self._step_prefill_chunk(state) except _PrefillAbortedError: _sync_and_clear_cache(self._stream) self._cleanup_prefill_abort_request(request) continue except _PrefillEvictionNeeded as e: # Raised by the adaptive throttle before the first # chunk's forward pass, so a reconstructed prefix # (prompt_cache / block_table / cached_tokens) is # still valid. Keep it attached across the pause: # if no idle model gets evicted, the retry prefills # only the uncached suffix under the adaptive # throttle instead of recomputing the whole prompt # cold (#2180). Mirrors the in-flight pause in # _advance_chunked_prefills, which also keeps state. self._pause_for_prefill_eviction(request, e.request) break except PrefillMemoryExceededError as e: logger.error( "Chunked prefill (first chunk) capacity rejected " "for %s: %s", request.request_id, e, ) self._release_paged_cache_for_request(request.request_id) self.requests.pop(request.request_id, None) self._clear_request_admission_bookkeeping(request.request_id) get_prefill_tracker().remove(request.request_id) _sync_and_clear_cache() rejected_outputs.append( _prefill_memory_exception_output(request.request_id, e) ) continue except RuntimeError as e: # Hard memory limit hit on the first chunk. # _step_prefill_chunk updates the PrefillProgressTracker # before the limit check, so without this catch the # tracker entry leaks and stays in the dashboard # forever (#1405). Mirrors the cleanup in # _advance_chunked_prefills (d736bfd). logger.error( "Chunked prefill (first chunk) failed for %s: %s", request.request_id, e, ) self._release_paged_cache_for_request(request.request_id) self.requests.pop(request.request_id, None) self._clear_request_admission_bookkeeping(request.request_id) get_prefill_tracker().remove(request.request_id) # Drop Metal cache pool buffers held by the aborted # first chunk's forward / mx.eval transients. _sync_and_clear_cache() if self._requeue_or_fail_prefill(request, e): continue rejected_outputs.append( RequestOutput( request_id=request.request_id, finished=True, finish_reason="error", error=str(e), ) ) continue if done: self._emit_final_boundary_if_needed(state) _sync_and_clear_cache(self._stream) get_prefill_tracker().remove(request.request_id) self._insert_prefilled_request(request, state, scheduled) else: self.prefilling.append(request) self._prefill_states[request.request_id] = state continue # Skip normal prefill + insert path # Normal (non-chunked) full prefill path. # Assign a temporary UID so progress callbacks can map # uid→request_id during external prefill. Replaced by the # real UID returned from insert(). temp_uid = id(request) # unique, won't collide with BatchGenerator UIDs self.request_id_to_uid[request.request_id] = temp_uid self.uid_to_request_id[temp_uid] = request.request_id try: prefilled_cache, last_token = self._do_external_prefill( request, tokens_to_process, cache_to_use, vlm_embeds=vlm_embeds, ) except _PrefillAbortedError: self._cleanup_prefill_abort_request(request, temp_uid=temp_uid) continue except _PrefillEvictionNeeded as e: self.uid_to_request_id.pop(temp_uid, None) self.request_id_to_uid.pop(request.request_id, None) self._release_paged_cache_for_request(request.request_id) get_prefill_tracker().remove(request.request_id) self._pause_for_prefill_eviction(request, e.request) break except PrefillMemoryExceededError as e: logger.error( "Prefill capacity rejected for %s: %s", request.request_id, e, ) self.uid_to_request_id.pop(temp_uid, None) self.request_id_to_uid.pop(request.request_id, None) self._release_paged_cache_for_request(request.request_id) self.requests.pop(request.request_id, None) self._clear_request_admission_bookkeeping(request.request_id) get_prefill_tracker().remove(request.request_id) _sync_and_clear_cache() rejected_outputs.append( _prefill_memory_exception_output(request.request_id, e) ) continue except RuntimeError as e: # Hard memory limit hit during external prefill. Without # this catch, the exception bubbles up to step() and then # engine_core's fail_all_requests(), which pops # self.requests but cannot reach the PrefillProgressTracker # singleton, so the dashboard entry leaks across model # reload (#1405). Mirrors the cleanup in # _advance_chunked_prefills (d736bfd). logger.error("Prefill failed for %s: %s", request.request_id, e) self.uid_to_request_id.pop(temp_uid, None) self.request_id_to_uid.pop(request.request_id, None) self._release_paged_cache_for_request(request.request_id) self.requests.pop(request.request_id, None) self._clear_request_admission_bookkeeping(request.request_id) get_prefill_tracker().remove(request.request_id) # Drop Metal cache pool buffers held by the aborted # chunk's forward / mx.eval transients. _sync_and_clear_cache() if self._requeue_or_fail_prefill(request, e): continue rejected_outputs.append( RequestOutput( request_id=request.request_id, finished=True, finish_reason="error", error=str(e), ) ) continue # Clean up temp UID mapping del self.uid_to_request_id[temp_uid] del self.request_id_to_uid[request.request_id] # Prefill complete: remove from progress tracker so dashboard # shows "generating" instead of "PP" during decode. get_prefill_tracker().remove(request.request_id) cache_to_use = prefilled_cache tokens_to_process = last_token # Capture per-request mRoPE rope_deltas for decode. # Prefer _captured_rope_deltas from per-request extra_kwargs # (set during get_input_embeddings), since the global # _rope_deltas may be stale when explicit position_ids are used. if request.vlm_inputs_embeds is not None: extra = request.vlm_extra_kwargs or {} captured = extra.get("_captured_rope_deltas") if captured is not None: if hasattr(captured, "item"): request.rope_deltas = float(captured.item()) else: request.rope_deltas = float(captured) elif hasattr(self.model, "get_last_rope_deltas"): request.rope_deltas = self.model.get_last_rope_deltas() # Build per-request state machine for stop tokens sm = self._build_state_machine(request) # Set random seed for reproducible generation (best-effort). # This affects global MLX random state, so concurrent requests # may interfere. Matches OpenAI's best-effort seed semantics. if request.sampling_params.seed is not None: mx.random.seed(request.sampling_params.seed) # TurboQuant KV is quantized at the end of _do_external_prefill # (fp16 prefill → quantize once); _merge_caches() turns the per # request TQ cache into a BatchTurboQuantKVCache on insert. # VLM MTP routing: if a gemma4_assistant drafter is attached, run # an extra last-token forward to capture hidden + shared_kv_states, # sample the first bonus, and hand the request to a vlm_mtp # generator instead of BatchGenerator. Falls through on any # eligibility issue so other speculative paths stay intact. if self._vlm_mtp_drafter is not None and cache_to_use is not None: vlm_mtp_uid = self._route_to_vlm_mtp( request, cache_to_use, tokens_to_process, sampler, sm ) if vlm_mtp_uid is not None: self.request_id_to_uid[request.request_id] = vlm_mtp_uid self.uid_to_request_id[vlm_mtp_uid] = request.request_id now = time.monotonic() request.batch_uid = vlm_mtp_uid request.status = RequestStatus.RUNNING request.generation_started_at = now request.last_activity_at = now self.running[request.request_id] = request scheduled.append(request) self.total_prompt_tokens += request.num_prompt_tokens logger.debug( f"Scheduled request {request.request_id} via vlm_mtp " f"(uid={vlm_mtp_uid}, {request.num_prompt_tokens} prompt tokens)" ) continue # Insert into BatchGenerator with pre-filled cache + last token. # BatchGenerator only handles decode from here. # # IMPORTANT: ``logits_processors`` MUST be passed as a per-row # list (possibly empty), never None. mlx-lm's # GenerationBatch._step does ``for p in self.logits_processors[e]`` # in any branch where ``any(self.logits_processors)`` is True # (e.g., heterogeneous merge with another row that has a # processor). A None slot crashes that loop with # ``TypeError: 'NoneType' object is not iterable``, which then # bubbles into the engine retry loop and presents as a hang. # See vllm-mlx-patched commit 8d4052b for the same root cause # in a sibling project, and #934 for the user-visible symptom. per_row_lps = list(logits_processors) if logits_processors else [] uids = self.batch_generator.insert( [tokens_to_process], max_tokens=[request.sampling_params.max_tokens], caches=[cache_to_use] if cache_to_use else None, all_tokens=[_batch_generator_all_tokens(request)], samplers=[sampler], logits_processors=[per_row_lps], state_machines=[sm], ) if uids: _register_uid_rows(self.model, uids, [sampler], [per_row_lps]) uid = uids[0] self.request_id_to_uid[request.request_id] = uid self.uid_to_request_id[uid] = request.request_id now = time.monotonic() request.batch_uid = uid request.status = RequestStatus.RUNNING request.generation_started_at = now request.last_activity_at = now self.running[request.request_id] = request scheduled.append(request) # Register per-UID rope_delta for mRoPE decode. if hasattr(self.model, "register_rope_delta"): self.model.register_rope_delta(uid, request.rope_deltas) self.total_prompt_tokens += request.num_prompt_tokens cache_info = ( f", {request.cached_tokens} cached" if request.cached_tokens > 0 else "" ) cache_used = "with cache" if cache_to_use else "no cache" logger.debug( f"Scheduled request {request.request_id} (uid={uid}) " f"with {len(tokens_to_process)} tokens to process " f"({request.num_prompt_tokens} total){cache_info}, {cache_used}" ) return scheduled, rejected_outputs def _process_batch_responses( self, responses: list[Any] ) -> tuple[list[RequestOutput], set[str]]: """ Process responses from BatchGenerator. Args: responses: List of BatchGenerator.Response objects Returns: Tuple of (outputs, finished_request_ids) """ outputs = [] finished_ids = set() step_now = time.monotonic() generated_at = time.perf_counter() for response in responses: request_id = self.uid_to_request_id.get(response.uid) if request_id is None: continue request = self.running.get(request_id) if request is None: continue request.last_activity_at = step_now completion_tokens_before = request.num_output_tokens # Release VLM embeddings after first decode token (prefill is done) if request.vlm_inputs_embeds is not None: request.vlm_inputs_embeds = None request.vlm_extra_kwargs = None # Check finish reason first - don't include EOS token in output # (following mlx-lm's batch_generate behavior) is_stop = response.finish_reason == "stop" is_length = response.finish_reason == "length" is_finished = response.finish_reason is not None # Only append token if not stopping due to EOS token new_text = "" # Check if this request uses a protocol-specific output parser parser_session = self._get_output_parser_session(request_id) if parser_session is not None and not is_stop: parser_result = parser_session.process_token(response.token) new_text = parser_result.stream_text if parser_result.visible_text: request.output_text += parser_result.visible_text # Parser-defined stop token can override finish reason if parser_result.is_stop and not is_finished: is_finished = True is_stop = True response.finish_reason = "stop" should_record_token = ( parser_result.record_token if parser_result.record_token is not None else not is_stop ) if should_record_token: request.append_output_token(response.token) elif not is_stop: # Standard processing without a protocol parser request.append_output_token(response.token) # Decode the new token using streaming detokenizer for proper UTF-8 handling detokenizer = self._get_detokenizer(request_id) if detokenizer is not None: detokenizer.add_token(response.token) new_text = detokenizer.last_segment else: # Fallback to single-token decode new_text = self.tokenizer.decode([response.token]) # Text-level stop-string fallback. Catches BPE edge cases # where the tokenized stop sequence does not match the # model's actual output tokens (e.g. " delta" vs "delta"). # Only scans the tail to keep cost O(stop_len) per step. stop_strs = request.sampling_params.stop or [] if stop_strs and not is_finished and detokenizer is not None: full_text = detokenizer.text prev_len = len(full_text) - len(new_text) for ss in stop_strs: if not ss: continue scan_start = max(0, prev_len - len(ss) + 1) idx_in_tail = full_text.find(ss, scan_start) if idx_in_tail < 0: continue is_finished = True is_stop = True response.finish_reason = "stop" if idx_in_tail >= prev_len: new_text = new_text[: idx_in_tail - prev_len] else: new_text = "" break # Prepend tag for first chunk if this is a reasoning model. # Protocol parsers may expose a normalized prefix when their prompt # uses a model-specific open-think marker (e.g. MiniMax ). if getattr(request, "needs_think_prefix", False): if not getattr(request, "think_prefix_sent", False): if parser_session is None: think_tag = getattr(self.tokenizer, "think_start", "") prefix_text = think_tag + "\n" else: prefix_text = ( self._get_output_parser_thinking_start_output_text() or "" ) if prefix_text: new_text = prefix_text + new_text if parser_session is not None: request.output_text = prefix_text + request.output_text request.think_prefix_sent = True # Immediately discard logprobs if not requested to free memory (~800KB per response) # This prevents accumulation of large MLX arrays during streaming if ( hasattr(response, "logprobs") and response.logprobs is not None and not request.sampling_params.logprobs ): response.logprobs = None # Create output output_generated_at = ( generated_at if request.num_output_tokens > completion_tokens_before else None ) output = RequestOutput( request_id=request_id, new_token_ids=[response.token] if not is_stop else [], new_text=new_text, output_token_ids=list(request.output_token_ids), prompt_tokens=request.num_prompt_tokens, completion_tokens=request.num_output_tokens, generated_at=output_generated_at, generated_until=output_generated_at, cached_tokens=request.cached_tokens, ) if not is_finished: self._maybe_capture_boundary_snapshot(request, response.uid) # Handle finished requests if is_finished: if is_stop: request.set_finished(RequestStatus.FINISHED_STOPPED) elif is_length: request.set_finished(RequestStatus.FINISHED_LENGTH_CAPPED) output.finished = True output.finish_reason = response.finish_reason finished_ids.add(request_id) if parser_session is not None: final_result = parser_session.finalize() if final_result.stream_text: output.new_text += final_result.stream_text if final_result.visible_text: request.output_text += final_result.visible_text if final_result.output_text_prefix: request.output_text = ( final_result.output_text_prefix + request.output_text ) if final_result.tool_calls: output.tool_calls = final_result.tool_calls if final_result.finish_reason: output.finish_reason = final_result.finish_reason output.output_text = request.output_text else: # Standard finalization without a protocol parser # Finalize detokenizer to flush any remaining bytes detokenizer = self._get_detokenizer(request_id) if detokenizer is not None: detokenizer.finalize() final_segment = detokenizer.last_segment if final_segment: output.new_text += final_segment # Decode full output output.output_text = self.tokenizer.decode(request.output_token_ids) request.output_text = output.output_text # Trim accumulated output text at the first stop string # match so non-streaming responses do not include the # stop sequence itself (matches OpenAI semantics). if is_stop: stop_strs = request.sampling_params.stop or [] for ss in stop_strs: if not ss: continue cut = output.output_text.find(ss) if cut >= 0: output.output_text = output.output_text[:cut] request.output_text = output.output_text break # Extract cache for future reuse. # In the new API, prompt_cache is a direct value (not callable). raw_cache = getattr(response, "prompt_cache", None) if raw_cache is not None: try: # SpecPrefill: sparse KV data can't be stored in # paged cache (hash mismatch with full token IDs). if request.specprefill_indices is not None: raw_cache = None # For paged cache, extract actual tensor states # This allows cache to survive BatchGenerator recreation elif self.block_aware_cache is not None: extracted_cache, model_cache_config = ( self._extract_cache_states(raw_cache) ) if extracted_cache: request._extracted_cache = extracted_cache request._model_cache_config = model_cache_config logger.debug( f"Extracted {len(extracted_cache)} layer states " f"for request {request_id}" ) else: # Standard cache stores object references request._extracted_cache = raw_cache request._model_cache_config = None except Exception as e: logger.debug(f"Failed to extract cache for {request_id}: {e}") self.total_completion_tokens += request.num_output_tokens self.num_requests_processed += 1 logger.debug( f"Request {request_id} finished: {response.finish_reason}, " f"{request.num_output_tokens} tokens" ) logger.log( 5, "Request %s generated text:\n%s", request_id, output.output_text ) outputs.append(output) return outputs, finished_ids def _release_paged_cache_for_request(self, request_id: str) -> None: """Drop a request's paged-cache footprint on rejection paths. ``add_request`` routes through ``block_aware_cache.fetch_cache`` which records the request in ``_request_tables`` and increments ref counts on every prefix-matched paged-cache block. The normal completion path releases that state in ``_cleanup_finished``; the prefill-rejection paths in ``_advance_chunked_prefills`` / ``_schedule_waiting`` must do the same or rejected requests leak block refs (pinning the paged cache and compounding the very memory pressure that triggered the rejection) and orphan ``_request_tables`` entries. """ if self.block_aware_cache is not None: self.block_aware_cache.release_cache(request_id) elif self.paged_cache_manager is not None: self.paged_cache_manager.delete_block_table(request_id) # SpecPrefill primes an independent ``_draft_prefix_cache`` in # ``_try_specprefill_scoring`` whose block refs are tracked # separately from the target ``block_aware_cache``. Without # releasing it on the rejection path a rejected SpecPrefill # request leaks every draft-block ref symmetric to the # target-cache leak the main branch above guards against. draft_cache = getattr(self, "_draft_prefix_cache", None) if draft_cache is not None: try: draft_cache.release_cache(request_id) except Exception: # noqa: BLE001 logger.debug( "Draft prefix cache release_cache(%s) raised; ignoring", request_id, exc_info=True, ) def _cleanup_finished(self, finished_ids: set[str]) -> None: """Clean up finished requests and store caches for reuse.""" # Synchronize pending engine stream operations before cache storage. # store_cache -> mx.save_safetensors triggers implicit mx.eval() which # can conflict with async Metal operations on the generation stream. if finished_ids: with self._phase_timer("cleanup_finished_sync"): _safe_sync_stream(self._stream) # SpecPrefill: restore original RoPE if active request finished for rid in finished_ids: self._cleanup_specprefill(rid) # Remove finished requests from prefill progress tracker. tracker = get_prefill_tracker() for rid in finished_ids: tracker.remove(rid) for request_id in finished_ids: request = self.running.get(request_id) # Store cache for future reuse (G2-async): submit to background # executor so the post-finish 28GB+ memcpy doesn't block response # streaming. The inference thread does mx.synchronize + # boundary merge + a single batched mx.eval here; the worker # handles _extract_tensor_bytes (CPU memcpy) + index/queue # registration. batch_generator.remove(uid) is deferred and # picked up at the next step's _drain_pending_async_removes. store_future = None if request is not None and request.prompt_token_ids: if self.block_aware_cache is not None: if ( hasattr(request, "_extracted_cache") and request._extracted_cache is not None ): prompt_boundary_store = None else: uid_for_store = self.request_id_to_uid.get(request_id, -1) prompt_boundary_store = ( self._prepare_prompt_boundary_cache_store( request_id, request, uid_for_store, ) ) if ( hasattr(request, "_extracted_cache") and request._extracted_cache is not None ) or prompt_boundary_store is not None: try: full_token_sequence = list(request.prompt_token_ids) + list( request.output_token_ids ) if prompt_boundary_store is not None: ( token_sequence_to_store, cache_to_store, model_cache_config, intermediate_snapshots, ) = prompt_boundary_store cacheable_sequence = list(token_sequence_to_store) else: # For reasoning models, only cache prompt tokens. # Output contains tokens that the API layer # strips before the next turn, so they never match. if getattr(request, "needs_think_prefix", False): cacheable_sequence = list(request.prompt_token_ids) else: cacheable_sequence = full_token_sequence token_sequence_to_store = cacheable_sequence cache_to_store = request._extracted_cache model_cache_config = getattr( request, "_model_cache_config", None ) intermediate_snapshots = None # Inference-thread store_cache prep, timed as # three sub-phases (boundary / collect / dispatch) # mirroring boundary_capture_* granularity. # The dispatch phase does a FULL mx.eval (not # async_eval) so the KV arrays are concrete on THIS # thread before the store-cache worker slices/views # them. MLX streams are thread-local; a lazy op left # for the worker to materialize re-dispatches to this # thread's stream index, which is absent on the worker # -> SIGABRT. See the dispatch-phase comment below. with mx.stream(self._stream): with self._phase_timer("store_cache_main_boundary"): if prompt_boundary_store is None: boundary_override = ( self._get_boundary_store_override( request_id, cacheable_sequence, ) ) if ( boundary_override is None and self._detect_boundary_snapshot_need() ): # Non-sliceable cache state is only # storable from boundary-aligned # snapshots; the live state sits at # the current decode offset (which # speculative decode can leave off # the emitted count entirely). raise _BoundaryStoreUnavailable() if boundary_override is not None: ( token_sequence_to_store, boundary_cache, boundary_model_config, intermediate_snapshots, ) = boundary_override cache_to_store = ( self._merge_boundary_with_full_cache( boundary_cache, request._extracted_cache, ) ) if boundary_model_config is not None: model_cache_config = ( boundary_model_config ) logger.info( f"Using boundary cache snapshot for {request_id}: " f"storing {len(token_sequence_to_store)}/" f"{len(full_token_sequence)} tokens " f"(skipping trailing partial block, " f"{len(intermediate_snapshots) if intermediate_snapshots else 0} " f"intermediate snapshots)" ) # DEBUG-only divergence probe (issue #1003). # Record the exact token sequence submitted to # store_cache, including boundary truncation. if logger.isEnabledFor(logging.DEBUG): self._cache_probe_seqs.append( ( request.request_id, list(token_sequence_to_store), ) ) with self._phase_timer("store_cache_main_collect"): pre_eval_arrays = ( self._collect_arrays_from_extracted_cache( cache_to_store ) ) if intermediate_snapshots is not None: for ( snapshot_cache ) in ( intermediate_snapshots.iter_in_memory_extracted() ): pre_eval_arrays.extend( self._collect_arrays_from_extracted_cache( snapshot_cache ) ) with self._phase_timer("store_cache_main_dispatch"): if pre_eval_arrays: # FULL eval (not async_eval) on the owner # thread. MLX streams are thread-local: # these KV arrays carry self._stream # (a per-engine ThreadLocalStream created # on THIS thread). The store-cache worker # later slices them (_extract_block_tensor_slice) # and views bf16->uint16 (_extract_tensor_bytes); # if the source op is still LAZY at that # point, materializing it on the worker # re-dispatches to self._stream's index, # which does not exist on the worker thread # -> "There is no Stream(gpu, N) in current # thread" -> std::terminate -> SIGABRT. # Forcing concrete materialization here # means every downstream worker op consumes # an already-evaluated buffer and binds its # own new ops to the always-present default # stream (gpu,0). The big host memcpy # (bytes(memoryview(...))) and the disk # write stay on the worker — only the GPU # completion fence moves onto this thread. mx.eval(*pre_eval_arrays) hot_cache_write_back = ( not self._bypass_hot_cache_under_pressure() ) if not hot_cache_write_back: logger.info( "Using SSD write-through for %s " "under memory pressure", request_id, ) if self._store_cache_executor is not None: # Hand host memcpy and disk write to the # background executor after the owner thread # has materialized KV arrays. The gate counts # cleanup slots that still own extracted cache # references; backpressure is applied at # admission in _schedule_waiting so cache # persistence does not wait in the token loop # after submission (#1496). note_submitted is # called before submit, and note_done happens in # _drain_pending_async_removes after the request # cache references are released. gate = self._store_cache_gate if gate is not None: gate.note_submitted() try: store_future = self._store_cache_executor.submit( self._async_store_cache_worker, request_id, token_sequence_to_store, cache_to_store, model_cache_config, intermediate_snapshots, request.vlm_extra_keys_for_cache, request.vlm_extra_key_token_start_for_cache, request.vlm_extra_key_ranges_for_cache, hot_cache_write_back, ) except BaseException: if gate is not None: gate.note_done() raise self._inflight_store_futures[request_id] = store_future self._inflight_store_info[request_id] = ( _InflightStoreInfo( tokens=list(token_sequence_to_store), extra_keys=request.vlm_extra_keys_for_cache, extra_key_token_start=( request.vlm_extra_key_token_start_for_cache ), extra_key_ranges=( request.vlm_extra_key_ranges_for_cache ), ) ) else: # Executor unavailable — synchronous fallback. self._async_store_cache_worker( request_id, token_sequence_to_store, cache_to_store, model_cache_config, intermediate_snapshots, request.vlm_extra_keys_for_cache, request.vlm_extra_key_token_start_for_cache, request.vlm_extra_key_ranges_for_cache, hot_cache_write_back, ) logger.debug( f"Submitted async store_cache for {request_id} " f"({len(token_sequence_to_store)} tokens, " f"{len(full_token_sequence)} total: " f"{len(request.prompt_token_ids)} prompt + " f"{len(request.output_token_ids)} output)" ) except _BoundaryStoreUnavailable: logger.debug( "Skipping cache store for %s: no boundary-aligned " "snapshot for non-sliceable cache state (all " "captures skipped, e.g. by the speculative-decode " "skew guard); storing live state would corrupt " "later prefix hits", request_id, ) block_table = None if self.paged_cache_manager: block_table = self.paged_cache_manager.get_block_table( request_id ) if block_table and self.paged_cache_manager: self.paged_cache_manager.release_for_eviction( block_table.block_ids ) self.block_aware_cache.clear_request_entry(request_id) except Exception as e: logger.debug( f"Failed to submit async store for {request_id}: {e}" ) else: # No extracted_cache to store, but ensure block leak guard. block_table = None if self.paged_cache_manager: block_table = self.paged_cache_manager.get_block_table( request_id ) if block_table is None and hasattr(request, "block_table"): block_table = request.block_table if block_table and self.paged_cache_manager: self.paged_cache_manager.release_for_eviction( block_table.block_ids ) self.block_aware_cache.clear_request_entry(request_id) # Remove from running if request_id in self.running: del self.running[request_id] # batch_generator.remove(uid): defer until the async store_cache # worker finishes so the BatchKVCache slot isn't reused while the # worker is still reading buffer references via cache_to_store. # _drain_pending_async_removes (next step) handles the actual # mx.synchronize + remove + uid_maps cleanup. If we have no async # store (no extracted_cache, executor missing, fallback fail), # fall back to immediate remove for back-compat behavior. if request_id in self.request_id_to_uid: uid = self.request_id_to_uid[request_id] if store_future is not None: self._pending_async_removes.append((uid, request_id, store_future)) else: # Synchronize in-flight GPU work before modifying batch state. # batch_generator.remove() triggers lazy KV cache array slicing # (BatchKVCache.filter) that replaces references to arrays still # used by in-flight Metal command buffers from the previous # batch_generator.next() call. Without this barrier the Metal # driver can hit 'completeMemory() prepare count underflow'. _safe_sync_stream(self._stream) self._remove_uid_from_active_batch(uid) if hasattr(self.model, "unregister_rope_delta"): self.model.unregister_rope_delta(uid) _unregister_uid_row(self.model, uid) if uid in self.uid_to_request_id: del self.uid_to_request_id[uid] del self.request_id_to_uid[request_id] # Clean up streaming detokenizer self._cleanup_detokenizer(request_id) # Clean up protocol-specific output parser session self._cleanup_output_parser_session(request_id) # Clean up VLM adapter state (position_ids, rope_deltas, pending embeddings) if hasattr(self.model, "clear_vlm_position_state"): self.model.clear_vlm_position_state() if hasattr(self.model, "clear_pending_embeddings"): self.model.clear_pending_embeddings() # Drop any boundary snapshot for this request. The in-memory # dict pop is safe — the async store worker holds its own # reference to the snapshot dict via _BoundarySnapshotProvider. self._boundary_cache_snapshots.pop(request_id, None) # cleanup_request rmtree's the on-disk snapshot directory and # races the worker's boundary_snapshot_store.load() calls. If # an async store_future is in flight, defer cleanup until the # worker finishes (handled in _drain_pending_async_removes). if self._boundary_snapshot_store is not None and store_future is None: self._boundary_snapshot_store.cleanup_request(request_id) # Track as finished self.finished_req_ids.add(request_id) # Remove from requests dict to prevent memory leak. # When async store_cache is in flight, keep _extracted_cache alive # until the worker finishes — the worker holds a reference via # cache_to_store argument, but request._extracted_cache pointing # to the same data is the canonical owner. We pop here only when # no future is pending; the future's done callback (or # _drain_pending_async_removes) clears the request later. if store_future is None: req_to_remove = self.requests.pop(request_id, None) self._clear_request_admission_bookkeeping(request_id) if req_to_remove is not None: req_to_remove._extracted_cache = None req_to_remove.prompt_cache = None else: # Drop request from running but keep in self.requests so the # async worker keeps the cache buffers alive via reachability. # Cleanup happens in _drain_pending_async_removes. pass # Emit phase timing diagnostics when accumulated counts are meaningful. # Helps diagnose cache-on overhead (boundary capture / store_cache / # hot cache eviction). Logged at info level so operators can see it # without enabling debug. if finished_ids and self._phase_total_ms: stats_parts = [] for phase, total_ms in sorted(self._phase_total_ms.items()): count = self._phase_count.get(phase, 0) if count == 0: continue stats_parts.append(f"{phase}={total_ms:.1f}ms/{count}") if stats_parts: logger.info("Cache phase timings: %s", ", ".join(stats_parts)) # Schedule deferred Metal cache cleanup after request completion. if finished_ids: # Schedule deferred Metal cache cleanup instead of clearing immediately. # Immediate mx.clear_cache() after request completion races with IOKit's # asynchronous completeMemory() callbacks — the kernel-level GPU memory # reference counting can still be in-flight even after mx.synchronize() # returns, causing 'prepare count underflow' kernel panics (#435). # Deferring by _DEFERRED_CLEAR_DELAY generation steps (~10-40 ms) gives # IOKit time to process callbacks while still reclaiming buffers fast # enough to prevent TTFT spikes from pool bloat (#411). # # Use max() so that concurrent completions (max_num_seqs > 1) each get # a full _DEFERRED_CLEAR_DELAY window counted from *their own* finish # step. The old "only set if None" guard meant the second request's # window was anchored to the first request's finish step, allowing the # second request's KV cache blocks to be re-allocated before IOKit # finished their completeMemory() callbacks (#557). target = self._step_counter + self._DEFERRED_CLEAR_DELAY if self._deferred_clear_at is None or target > self._deferred_clear_at: self._deferred_clear_at = target def _is_cache_corruption_error(self, error: Exception) -> bool: """Check if an error indicates cache corruption.""" return is_cache_corruption_error(error) def _is_generation_overflow_error(self, error: Exception) -> bool: """Check for MLX/libc++ unordered-container overflow during decode.""" return isinstance( error, OverflowError ) and self._GENERATION_OVERFLOW_PATTERN in str(error) def _recover_from_cache_error(self) -> None: """Recover from cache corruption error.""" # Clear batch generator (this is the source of the corruption) self.batch_generator = None self._current_sampler_params = None self._boundary_cache_snapshots.clear() if self._boundary_snapshot_store is not None: self._boundary_snapshot_store.cleanup_all() self._boundary_snapshot_required = None # Clear stale VLM position state to prevent re-corruption on retry if hasattr(self.model, "clear_vlm_position_state"): self.model.clear_vlm_position_state() # Clear pending VLM embeddings if hasattr(self.model, "clear_pending_embeddings"): self.model.clear_pending_embeddings() # Clear caches if self.block_aware_cache is not None: self.block_aware_cache.clear() self._cache_rate_tracker.clear() # Clear UID mappings _unregister_uid_rows_for_model(self.model) self.request_id_to_uid.clear() self.uid_to_request_id.clear() # Cancel any pending deferred Metal cache clear self._deferred_clear_at = None # Clear detokenizer state to prevent contamination after recovery self._request_detokenizers.clear() # Clear protocol-specific output parser sessions self._output_parser_sessions.clear() logger.info("Cache recovery completed") def _recover_from_generation_overflow_error(self) -> None: """Reset decode state after MLX __next_prime overflow.""" self.batch_generator = None self._current_sampler_params = None self._boundary_snapshot_required = None active_specprefill = self._specprefill_active_request_id if active_specprefill is not None: self._cleanup_specprefill(active_specprefill) if hasattr(self.model, "clear_vlm_position_state"): self.model.clear_vlm_position_state() if hasattr(self.model, "clear_pending_embeddings"): self.model.clear_pending_embeddings() _unregister_uid_rows_for_model(self.model) self.request_id_to_uid.clear() self.uid_to_request_id.clear() self._deferred_clear_at = None self._request_detokenizers.clear() self._output_parser_sessions.clear() try: _sync_and_clear_cache(self._stream) except Exception as e: logger.warning( "Metal cache clear failed during generation overflow recovery: %s", e, ) logger.info("Generation overflow recovery completed") def _reset_request_for_reprefill(self, request: Request) -> None: """Reset request-owned decode state so it can be prefilled again.""" request.status = RequestStatus.WAITING request.batch_uid = None request.prompt_cache = None request.cached_tokens = 0 request.remaining_tokens = request.prompt_token_ids request.block_table = None request.shared_prefix_blocks = 0 request.output_token_ids = [] request.output_text = "" request.num_computed_tokens = 0 request._extracted_cache = None request._model_cache_config = None request.think_prefix_sent = False def _reschedule_running_requests( self, is_corruption: bool = False, max_corruption_retries: int = 3 ) -> list[str]: """Move running requests back to waiting queue for retry. Args: is_corruption: If True, increment corruption retry counter and fail requests that exceed max_corruption_retries. max_corruption_retries: Max corruption retries before failing a request. Returns: List of request IDs that exceeded max retries (corruption only). """ failed_ids: list[str] = [] count = 0 for request_id, request in list(self.running.items()): if is_corruption: request.cache_corruption_retries += 1 if request.cache_corruption_retries > max_corruption_retries: failed_ids.append(request_id) del self.running[request_id] # Clean up from requests dict (prevent memory leak) req = self.requests.pop(request_id, None) self._clear_request_admission_bookkeeping(request_id) if req is not None: req._extracted_cache = None req.prompt_cache = None continue self._reset_request_for_reprefill(request) # Move to waiting queue (at front for priority) self.waiting.appendleft(request) del self.running[request_id] count += 1 if count > 0: logger.info(f"Rescheduled {count} requests for re-prefill") return failed_ids def _reschedule_generation_overflow_requests( self, max_generation_overflow_retries: int = _MAX_GENERATION_OVERFLOW_RETRIES, ) -> list[str]: """Retry active requests serially after MLX generation overflow.""" retry_candidates: list[Request] = [] seen: set[str] = set() prefilling_ids = {request.request_id for request in self.prefilling} waiting_ids = {request.request_id for request in self.waiting} def collect(request: Request | None) -> None: if request is None: return request_id = request.request_id if request_id in seen or request_id in self._inflight_store_futures: return if request.is_finished(): return seen.add(request_id) retry_candidates.append(request) for request in self.running.values(): collect(request) for request in self.prefilling: collect(request) for request_id, request in self.requests.items(): if request_id in self.running or request_id in prefilling_ids: continue if request_id in waiting_ids: continue collect(request) collected_ids = {request.request_id for request in retry_candidates} for request_id in collected_ids: self.running.pop(request_id, None) self._prefill_states.pop(request_id, None) if collected_ids: self.prefilling = deque( request for request in self.prefilling if request.request_id not in collected_ids ) failed_ids: list[str] = [] retryable: list[Request] = [] self._generation_overflow_recovery_ids.clear() for request in retry_candidates: request.generation_overflow_retries += 1 request_id = request.request_id self._boundary_cache_snapshots.pop(request_id, None) if self._boundary_snapshot_store is not None: self._boundary_snapshot_store.cleanup_request(request_id) get_prefill_tracker().remove(request_id) if request.generation_overflow_retries > max_generation_overflow_retries: failed_ids.append(request_id) req = self.requests.pop(request_id, None) self._clear_request_admission_bookkeeping(request_id) if req is not None: req._extracted_cache = None req.prompt_cache = None continue self._reset_request_for_reprefill(request) retryable.append(request) self._generation_overflow_recovery_ids.add(request_id) for request in reversed(retryable): self.waiting.appendleft(request) if retryable: logger.info( "Rescheduled %d request(s) for serial generation-overflow retry", len(retryable), ) return failed_ids # Max times a single request is requeued after a prefill memory-pressure # failure before we give up and emit a clean error to the client. _MAX_PREFILL_OOM_RETRIES = 2 def _requeue_or_fail_prefill(self, request: "Request", error: Exception) -> bool: """Decide whether to requeue a prefill that hit the memory ceiling. The three #1405 catch sites have already torn the request down (released paged cache, popped ``self.requests``, removed the prefill tracker entry, cleared Metal). This either resets the request and puts it back on the waiting queue for a fresh attempt (returns ``True`` — caller continues without emitting an error), or — when the retry budget is exhausted or the failure is not a memory-pressure error — returns ``False`` so the caller emits the clean ``finish_reason="error"``. Only memory-limit failures are retried; any other RuntimeError fails immediately so genuine model errors don't loop. """ if "Memory limit exceeded" not in str(error): return False if request.prefill_oom_retries >= self._MAX_PREFILL_OOM_RETRIES: logger.warning( "Prefill for %s exhausted %d memory-pressure retries; " "failing with a clean error.", request.request_id, self._MAX_PREFILL_OOM_RETRIES, ) return False request.prefill_oom_retries += 1 # Reclaim before requeue so the retry starts from a lower baseline. self._reclaim_prefill_headroom() # Clear any SpecPrefill RoPE patch tied to this request so the retry # re-scores cleanly. if self._specprefill_active_request_id == request.request_id: self._specprefill_active_request_id = None # Restore mRoPE deltas if an external VLM prefill was interrupted before # its own restore ran (value stashed on the request in # _do_external_prefill). Benign for non-VLM requests (stash is None). saved = getattr(request, "_prefill_saved_rope_deltas", None) if saved is not None: lm = getattr(self.model, "_language_model", None) if lm is not None and hasattr(lm, "_rope_deltas"): lm._rope_deltas = saved request._prefill_saved_rope_deltas = None # Reset scheduling + cache + output state to a clean pre-prefill state # (mirrors _reschedule_running_requests). We deliberately drop # cached_tokens / block_table so the retry does a cold full prefill and # does not re-attach the large cached prefix that produced the same # oversized SDPA span. VLM inputs/embeds are preserved. request.status = RequestStatus.WAITING request.batch_uid = None request.prompt_cache = None request.cached_tokens = 0 request.remaining_tokens = request.prompt_token_ids request.block_table = None request.shared_prefix_blocks = 0 request.output_token_ids = [] request.output_text = "" request.num_computed_tokens = 0 request._extracted_cache = None request._model_cache_config = None request.think_prefix_sent = False # Re-register (the catch site popped it) and requeue at the front. The # retry is throttled from its first chunk by the now-populated transient # EWMA, so it is strictly better-informed than this attempt. self.requests[request.request_id] = request self.waiting.appendleft(request) logger.warning( "Requeued %s for prefill retry %d/%d after memory pressure.", request.request_id, request.prefill_oom_retries, self._MAX_PREFILL_OOM_RETRIES, ) return True def _pause_for_prefill_eviction( self, request: "Request", eviction: PrefillEvictionRequest, ) -> None: """Hold a request until EngineCore can evict idle models asynchronously. The request's prefix-cache state (prompt_cache, block_table, cached_tokens, remaining_tokens) is deliberately left untouched so a reconstructed prefix survives the pause and the retry prefills only the uncached suffix instead of recomputing the prompt cold (#2180). """ self._pending_prefill_eviction_request = eviction request.status = RequestStatus.WAITING request.batch_uid = None self.waiting.appendleft(request) logger.info( "Paused request %s for prefill LRU eviction (reason=%s)", request.request_id, eviction.reason, ) def step(self) -> SchedulerOutput: """ Execute one scheduling step with automatic error recovery. This method: 1. Schedules waiting requests into the batch 2. Runs one generation step via BatchGenerator 3. Processes outputs and handles finished requests 4. On cache corruption: clears all cache and reschedules requests for re-prefill (no error raised to caller) Returns: SchedulerOutput with results of this step """ output = SchedulerOutput() # Process pending aborts FIRST (thread-safe with hybrid executor) self._process_pending_aborts() # Drain a deferred between-turn reclaim requested by the memory # enforcer (only acts when the scheduler is idle). self._process_pending_reclaim() # Drain async store_cache completions from prior steps. Each completed # entry triggers the deferred batch_generator.remove(uid) on the # inference thread. Inflight entries are left for a later step. drained_async_removes = self._drain_pending_async_removes() if drained_async_removes: output.has_work = True # Check memory pressure and evict if needed (tiered cache) if self.memory_monitor is not None: self._check_memory_pressure() try: # Advance in-flight chunked prefills (one chunk per request). # Must run before _schedule_waiting() so that completing prefills # are inserted into BatchGenerator before the decode step. chunked_scheduled: list[Request] = [] chunked_rejected: list[RequestOutput] = [] if self.prefilling: self._advance_chunked_prefills(chunked_scheduled, chunked_rejected) # Schedule waiting requests scheduled, rejected = self._schedule_waiting() # Merge chunked-prefill completions into the scheduled list. if chunked_scheduled: scheduled = chunked_scheduled + scheduled output.scheduled_request_ids = [r.request_id for r in scheduled] output.num_scheduled_tokens = sum(r.num_prompt_tokens for r in scheduled) if chunked_rejected: output.outputs.extend(chunked_rejected) output.has_work = True if rejected: output.outputs.extend(rejected) output.has_work = True if self._pending_prefill_eviction_request is not None: output.prefill_eviction_request = self._pending_prefill_eviction_request self._pending_prefill_eviction_request = None output.has_work = True # Run generation step if we have running requests. # Use next_generated() which returns only GenerationBatch.Response # objects (prefill is handled externally before insert). if ( self.batch_generator is not None or self._vlm_mtp_active ) and self.running: if self.batch_generator is not None: responses = list(self.batch_generator.next_generated()) else: responses = [] # Drive vlm_mtp generators alongside BatchGenerator. Order # matters only for log determinism; _process_batch_responses # is per-uid. if self._vlm_mtp_active: responses.extend(self._step_vlm_mtp()) output.has_work = True if responses: outputs, finished_ids = self._process_batch_responses(responses) output.outputs.extend(outputs) output.finished_request_ids.update(finished_ids) # Periodic decode cache materialization for models whose # KV cache update graph can otherwise grow for thousands of # tokens. MiniMax-M3 has one lazy cache-update chain per # layer; evaluating the cache state periodically cuts those # references before Metal's resource-count limit is hit. self._tokens_since_kv_cache_eval = getattr( self, "_tokens_since_kv_cache_eval", 0 ) + len(responses) kv_eval_interval = self._decode_eval_kv_cache_interval if ( kv_eval_interval > 0 and self._tokens_since_kv_cache_eval >= kv_eval_interval ): with mx.stream(self._stream): evaluated = _eval_generation_batch_cache( self.batch_generator ) logger.debug( "Materialized decode KV cache state: %d arrays", evaluated, ) self._tokens_since_kv_cache_eval = 0 self._cleanup_finished(finished_ids) # Periodic Metal allocator cleanup during long decodes. # mx.random.categorical inside the sampler allocates a # tiny scalar via gumbel → uniform on every call. # omlx ships its own non-compiled sampler # (omlx/utils/sampling.py) so that RNG state actually # advances in the server, but the trade-off is that # those scalars accumulate in the IOGPU residency set # — macOS aborts at ~4096 entries. Long contexts # (50k+) decoding thousands of tokens hit that limit # mid-stream. Synchronise the generation stream first # so any in-flight Metal command buffer that still # references buffers we're about to drop has # completed; the allocator only releases pool entries # whose ref count is zero, but the sync guarantees # there is no race window. Decode-only path — # next_generated() returns nothing during prefill, so # we never disrupt prefill activation buffers. self._tokens_since_clear_cache = getattr( self, "_tokens_since_clear_cache", 0 ) + len(responses) if self._tokens_since_clear_cache >= 1024: _sync_and_clear_cache(self._stream) self._tokens_since_clear_cache = 0 except _PrefillAbortedError: # Prefill was interrupted by a pending abort. # BatchGenerator is in an inconsistent state (partial # prefill), so reset it entirely. Pending aborts will # be processed at the start of the next step(). self.batch_generator = None self._current_sampler_params = None self._boundary_cache_snapshots.clear() if self._boundary_snapshot_store is not None: self._boundary_snapshot_store.cleanup_all() self._boundary_snapshot_required = None # Move any running requests back to waiting so they # can be rescheduled with a fresh BatchGenerator. self._reschedule_running_requests() except (TypeError, AttributeError, ValueError) as e: if self._is_cache_corruption_error(e): import traceback logger.warning( f"Cache corruption detected: {e}, " f"clearing cache and re-prefilling..." ) logger.debug(f"Cache corruption traceback:\n{traceback.format_exc()}") # Full reset: clear batch generator, all caches, VLM state self._recover_from_cache_error() # Reschedule requests for re-prefill from scratch. # Requests exceeding max corruption retries are failed. failed_ids = self._reschedule_running_requests(is_corruption=True) for rid in failed_ids: output.outputs.append( RequestOutput( request_id=rid, finished=True, finish_reason="error", error=( f"Cache corruption not recoverable " f"after retries: {e}" ), ) ) output.finished_request_ids.add(rid) else: raise except OverflowError as e: if self._is_generation_overflow_error(e): import traceback logger.warning( "Generation overflow detected: %s; resetting decode state " "and retrying affected requests serially", e, ) logger.debug( "Generation overflow traceback:\n%s", traceback.format_exc() ) self._recover_from_generation_overflow_error() failed_ids = self._reschedule_generation_overflow_requests() for rid in failed_ids: output.outputs.append( RequestOutput( request_id=rid, finished=True, finish_reason="error", error=( "Generation overflow not recoverable after " f"serial retry: {e}" ), ) ) output.finished_request_ids.add(rid) output.has_work = True else: raise except Exception as e: import traceback logger.error( f"Error in batch generation step: {e}\n" f"{traceback.format_exc()}" ) raise # Clear finished tracking for next step self.finished_req_ids = set() self._refresh_generation_overflow_recovery_ids() # Periodic Metal cache cleanup self._step_counter += 1 should_clear = self._should_periodic_clear_cache() # Deferred post-completion cleanup: fire once the step counter reaches # the target set by _cleanup_finished() (#435, #557). if ( self._deferred_clear_at is not None and self._step_counter >= self._deferred_clear_at ): should_clear = True self._deferred_clear_at = None if should_clear: _sync_and_clear_cache(self._stream) if ( self.config.gc_cleanup_interval > 0 and self._step_counter % self.config.gc_cleanup_interval == 0 ): gc.collect() self._publish_admin_snapshot() return output def _publish_admin_snapshot(self) -> None: """Atomically publish a fresh admin-visible snapshot. Called from step() on the engine thread, where running/waiting are not concurrently mutated. The admin endpoint reads the reference via snapshot_for_admin() and never iterates the live structures. """ self._admin_snapshot = { "running_by_id": dict(self.running), "waiting": list(self.waiting), } def snapshot_for_admin(self) -> dict[str, Any]: """Return the most recently published admin snapshot. Reference read is GIL-atomic; the dict itself is no longer mutated after publication. May be one step stale, which is fine for dashboard polling. """ return self._admin_snapshot def get_request(self, request_id: str) -> Request | None: """Get a request by ID.""" return self.requests.get(request_id) def remove_finished_request(self, request_id: str) -> Request | None: """Remove a finished request from tracking.""" request = self.requests.pop(request_id, None) self._clear_request_admission_bookkeeping(request_id) return request def get_stats(self) -> dict[str, Any]: """Get scheduler statistics.""" stats = { "num_waiting": len(self.waiting), "num_prefilling": len(self.prefilling), "num_running": len(self.running), "num_requests_processed": self.num_requests_processed, "total_prompt_tokens": self.total_prompt_tokens, "total_completion_tokens": self.total_completion_tokens, } # Include cache stats if self.block_aware_cache is not None: stats["ssd_cache"] = self.block_aware_cache.get_stats() return stats def get_cache_stats(self) -> dict[str, Any] | None: """Get cache statistics.""" if self.block_aware_cache is not None: return self.block_aware_cache.get_stats() return None def reset(self) -> None: """Reset the scheduler state.""" # Drain any pending deferred aborts self._pending_abort_ids.clear() # Abort all requests directly (reset is synchronous) for request_id in list(self.requests.keys()): self._do_abort_request(request_id) self.waiting.clear() self.prefilling.clear() self._prefill_states.clear() self.running.clear() self.requests.clear() self.finished_req_ids.clear() _unregister_uid_rows_for_model(self.model) self.request_id_to_uid.clear() self.uid_to_request_id.clear() self._generation_overflow_recovery_ids.clear() # Async store_cache bookkeeping. shutdown() drains these before us, # but clear here too so reset() is safe to call standalone (e.g. tests # or recovery paths) without leaking Request refs through stale futures. self._pending_async_removes.clear() self._inflight_store_futures.clear() self._inflight_store_info.clear() self._cache_freshness_waits.clear() self._prefix_cache_prepared.clear() self.batch_generator = None self._current_sampler_params = None self._boundary_cache_snapshots.clear() if self._boundary_snapshot_store is not None: self._boundary_snapshot_store.cleanup_all() self._boundary_snapshot_required = None # Clear caches if self.block_aware_cache is not None: self.block_aware_cache.clear() self._cache_rate_tracker.clear() # Clear detokenizers self._request_detokenizers.clear() # Clear protocol-specific output parser sessions self._output_parser_sessions.clear() # Cancel any pending deferred Metal cache clear self._deferred_clear_at = None def deep_reset(self) -> None: """ Deep reset that clears ALL cache state including model-level caches. This is more aggressive than reset() and should be used when switching engines or recovering from errors. """ # Standard reset first self.reset() # Clear any model-level cache state # MLX models may have internal cache references if hasattr(self.model, "cache"): self.model.cache = None # Some MLX models store cache in layers if hasattr(self.model, "layers"): for layer in self.model.layers: if hasattr(layer, "cache"): layer.cache = None if hasattr(layer, "self_attn") and hasattr(layer.self_attn, "cache"): layer.self_attn.cache = None # Release model and tokenizer references for GC self.model = None self.tokenizer = None # Release all cache-related references for GC if self._boundary_snapshot_store is not None: try: self._boundary_snapshot_store.shutdown() except Exception as e: logger.warning("Boundary snapshot store shutdown error: %s", e) self._close_specprefill_draft_cache_manager() self.paged_cache_manager = None self._draft_prefix_cache = None self._specprefill_draft_model = None self.block_aware_cache = None self.memory_monitor = None self._boundary_snapshot_store = None # Force garbage collection of any lingering cache objects import gc gc.collect() logger.info("Deep reset completed - all caches cleared") def shutdown(self) -> None: """ Graceful shutdown. Flushes hot cache to SSD and closes the background writer. paged SSD cache files are NOT cleared to allow reuse on reload. """ logger.info("Scheduler shutdown initiated...") # The store-cache gate is a non-blocking counter (#1496), so there is # no step-thread caller to wake here. Inflight futures are drained # below before the executor is asked to shut down. # Wait for any inflight async store_cache futures + drain pending # batch_generator removes so the writer thread / underlying paged SSD # cache see all blocks before close(). if self._store_cache_executor is not None: try: inflight = list(self._inflight_store_futures.values()) if inflight: logger.info( "Waiting for %d inflight async store_cache future(s)...", len(inflight), ) _done, not_done = concurrent.futures.wait( inflight, timeout=FATAL_TEARDOWN_TIMEOUT_S ) if not_done: fatal_exit( "Scheduler shutdown timed out after " f"{FATAL_TEARDOWN_TIMEOUT_S:.0f}s waiting for " f"{len(not_done)} async store_cache future(s)" ) self._drain_pending_async_removes() self._store_cache_executor.shutdown(wait=False) # Final drain after the bounded wait. If all workers finished # before the timeout, skipped entries are now drainable. If not, # fatal_exit() above terminates the process instead of leaving # a partially torn-down engine alive. self._drain_pending_async_removes() except Exception as e: logger.warning(f"Async store_cache shutdown error: {e}") self._store_cache_executor = None self._store_cache_gate = None self._inflight_store_futures.clear() self._inflight_store_info.clear() self._cache_freshness_waits.clear() self._prefix_cache_prepared.clear() if self._boundary_snapshot_store is not None: try: self._boundary_snapshot_store.cleanup_all() except Exception as e: logger.warning("Boundary snapshot store cleanup error: %s", e) try: self._boundary_snapshot_store.shutdown() except Exception as e: logger.warning("Boundary snapshot store shutdown error: %s", e) self._boundary_snapshot_store = None self._close_specprefill_draft_cache_manager() self._draft_prefix_cache = None self._specprefill_draft_model = None if self.paged_ssd_cache_manager is not None: self.paged_ssd_cache_manager.close() self.paged_ssd_cache_manager = None # Release whatever the per-path unregisters did not reach, so nothing # survives this engine in the module-level row registry. _unregister_uid_rows_for_model(self.model) logger.info("Scheduler shutdown completed") def adjust_store_cache_cap(self, pressure_level: str) -> None: """Resize the store-cache gate based on memory pressure (#1383). Called from ProcessMemoryEnforcer on every poll. The cap walks one step per poll toward its target so transient spikes don't oscillate the cap. Bounded by [1, max_num_seqs]: - ok pressure: grow cap back toward max_num_seqs. - soft/hard pressure: shrink cap so KV cache backlog fits the system. """ gate = self._store_cache_gate if gate is None: return current = gate.cap if pressure_level == "ok": new = min(self.config.max_num_seqs, current + 1) else: new = max(1, current - 1) if new != current: gate.set_cap(new) logger.debug( "store-cache queue cap: %d -> %d (pressure=%s)", current, new, pressure_level, ) # ========================================================================= # SSD Cache Methods # ========================================================================= def _set_model_info_for_monitor(self) -> None: """Extract model info and set it on memory monitor for estimation.""" if self.memory_monitor is None: return try: # Try to get model config config = None if hasattr(self.model, "config"): config = self.model.config elif hasattr(self.model, "args"): config = self.model.args if config is None: logger.debug("Could not extract model config for memory estimation") return def _cfg_get(obj: Any, key: str, default: Any = None) -> Any: if isinstance(obj, dict): return obj.get(key, default) return getattr(obj, key, default) # VLM / multimodal configs (e.g. Qwen3.6-VL, Gemma-4) nest the # language-model dimensions under a sub-config. Prefer # ``text_config`` / ``language_config`` / ``llm_config`` when ANY # of them exposes the LM layer count, even if the top-level config # also has one — on some VLM packs (older Gemma-3, certain Llava / # HF auto-wrappers) the top-level field refers to the *vision # encoder*, not the LM, and accepting it silently miscalibrates # the SDPA-peak estimate by a constant factor (a 40-layer LM # wrapped in a 33-layer vision tower under-estimates by ~20 %). # Probe both ``num_hidden_layers`` and the legacy ``n_layer`` alias # so a GPT-style nested config is also picked up. Falls back to the # top-level config only when no sub-config has either field. for sub_attr in ("text_config", "language_config", "llm_config"): sub = _cfg_get(config, sub_attr) if sub is not None and ( _cfg_get(sub, "num_hidden_layers") or _cfg_get(sub, "n_layer") ): config = sub break # Extract KV cache dimensions num_layers = _cfg_get(config, "num_hidden_layers") or _cfg_get( config, "n_layer" ) num_kv_heads = ( _cfg_get(config, "num_key_value_heads") or _cfg_get(config, "num_attention_heads") or _cfg_get(config, "n_head") ) head_dim = _cfg_get(config, "head_dim") hidden_size = _cfg_get(config, "hidden_size") or _cfg_get(config, "n_embd") # Calculate head_dim if not directly available if head_dim is None and hidden_size and num_kv_heads: num_heads = _cfg_get(config, "num_attention_heads") or num_kv_heads head_dim = hidden_size // num_heads # Determine base dtype size for uncompressed KV cache elements. base_dtype_size: float = 2 # Default float16/bfloat16 if hasattr(self.model, "dtype"): if self.model.dtype == mx.float32: base_dtype_size = 4 elif self.model.dtype == mx.bfloat16: base_dtype_size = 2 dtype_size = base_dtype_size # Extract num_attention_heads (query heads) for SDPA peak estimation num_attention_heads = ( _cfg_get(config, "num_attention_heads") or _cfg_get(config, "n_head") or num_kv_heads ) # Count KVCache layers for hybrid models cache_list_for_tq = None actual_kv_cache_layers = None num_kv_cache_layers = num_layers if not hasattr(self.model, "make_cache"): actual_kv_cache_layers = num_layers else: try: cache_list = self.model.make_cache() cache_list_for_tq = cache_list from mlx_lm.models.cache import CacheList, KVCache def _count_kv(c: Any) -> int: if type(c) is KVCache: return 1 if isinstance(c, CacheList): return sum(_count_kv(inner) for inner in c.caches) return 0 actual_kv_cache_layers = sum(_count_kv(c) for c in cache_list) num_kv_cache_layers = actual_kv_cache_layers if num_kv_cache_layers == 0: num_kv_cache_layers = num_layers # fallback except Exception: pass if ( self._turboquant_kv_bits is not None and isinstance(head_dim, int) and not isinstance(head_dim, bool) and head_dim > 0 and isinstance(actual_kv_cache_layers, int) and actual_kv_cache_layers > 0 and ( self._turboquant_eligible(cache_list_for_tq) if cache_list_for_tq is not None else not ( self._model_uses_mla() or self._model_uses_attention_sinks() ) ) ): tq_dtype_size = float(self._turboquant_kv_bits) / 8.0 + (2.0 / head_dim) if ( self._turboquant_skip_last and not isinstance(actual_kv_cache_layers, bool) and actual_kv_cache_layers > 1 ): dtype_size = ( (actual_kv_cache_layers - 1) * tq_dtype_size + base_dtype_size ) / actual_kv_cache_layers else: dtype_size = tq_dtype_size kv_bytes_per_token = ( estimate_mla_kv_bytes_per_token( config, cache_list_for_tq, base_dtype_size, ) if estimate_mla_kv_bytes_per_token is not None else None ) # Truthiness alone isn't enough — MagicMock proxies leaking # through the descent (test scaffolds that don't fully spec # ``model.config``) are truthy but fail any later numeric # comparison (``> 128`` etc.) deep inside MemoryMonitor. # Insist on real positive integers before calling. def _pos_int(v: Any) -> bool: return isinstance(v, int) and not isinstance(v, bool) and v > 0 if _pos_int(num_layers) and _pos_int(num_kv_heads) and _pos_int(head_dim): self.memory_monitor.set_model_info( num_layers=num_layers, num_kv_heads=num_kv_heads, head_dim=head_dim, dtype_size=dtype_size, num_attention_heads=num_attention_heads, num_kv_cache_layers=num_kv_cache_layers, # SDPA scores are materialized at the compute/activation # dtype, not the (possibly fractional TurboQuant) KV width. compute_dtype_size=base_dtype_size, kv_bytes_per_token=kv_bytes_per_token, ) logger.debug( f"Model info for memory estimation: " f"layers={num_layers} ({num_kv_cache_layers} KVCache), " f"kv_heads={num_kv_heads}, q_heads={num_attention_heads}, " f"head_dim={head_dim}, dtype_size={dtype_size}" ) else: logger.debug( f"Incomplete model info: layers={num_layers}, " f"kv_heads={num_kv_heads}, head_dim={head_dim}" ) except Exception as e: logger.debug(f"Failed to extract model info: {e}") def _infer_live_layer_cache_types( self, ) -> tuple[list[str], float | None] | None: """Infer the layer-cache signature that future SSD saves will use. Returns ``(layer_cache_types, turboquant_kv_bits)`` — the predicted per-layer type names plus the depth requests will quantize at (None when TurboQuant is inactive or ineligible) — or None when no signature can be inferred. """ if not HAS_CACHE_TYPE_HANDLERS or ModelCacheConfig is None: return None # Build the cache list the same way the request path does # (make_prompt_cache defers to model.make_cache when the model # defines one, and falls back to plain per-layer KVCache otherwise). # Requiring model.make_cache here made the refresh a silent no-op # for every plain dense model, so the manager never learned the # TurboQuant layout or bit depth (#2045). try: cache_list = make_prompt_cache(self.model) except Exception as e: logger.debug("Failed to build cache list for SSD signature: %s", e) return None if not isinstance(cache_list, (list, tuple)) or not cache_list: return None cache_list = list(cache_list) try: model_cache_config = ModelCacheConfig.from_cache_list( cache_list, model_name=self.config.model_name or "", ) layer_cache_types = model_cache_config.get_type_names() except Exception as e: logger.debug("Failed to infer SSD layer cache signature: %s", e) return None if not layer_cache_types: return None if self._turboquant_kv_bits is None: return layer_cache_types, None try: eligible = self._turboquant_eligible(cache_list) except Exception as e: # Fail safe: committing an un-rewritten (plain-KV) layout here # would make the stale-signature sweep evict every valid # TurboQuant block, so refuse to infer rather than guess. logger.debug("Failed to evaluate TurboQuant SSD signature: %s", e) return None if not eligible: return layer_cache_types, None kv_indices = [ i for i, c in enumerate(cache_list) if _is_turboquant_kv_family_cache(c) ] skip_last = self._turboquant_skip_last and len(kv_indices) > 1 last_kv_idx = kv_indices[-1] if skip_last else -1 for idx in kv_indices: if idx != last_kv_idx and idx < len(layer_cache_types): layer_cache_types[idx] = "TurboQuantKVCache" # The depth is keyed off the same eligibility gate the request path # uses, not the rewritten names: models whose convertible caches sit # inside CacheList layers report bare "CacheList" names at every # depth, so the bits field is the only signature discriminator for # them (#2045). return layer_cache_types, float(self._turboquant_kv_bits) def refresh_ssd_layer_signature(self) -> list[str] | None: """Set the SSD manager's live layer signature before prefix lookup.""" manager = self.paged_ssd_cache_manager if manager is None: return None inferred = self._infer_live_layer_cache_types() if inferred is None: if self._turboquant_kv_bits is not None: logger.warning( "Could not infer the SSD cache layer signature; " "TurboQuant bit-depth compatibility checks stay disabled " "for this session (stale-depth blocks are not swept)." ) return None layer_cache_types, turboquant_kv_bits = inferred try: set_signature = getattr(manager, "set_expected_layer_signature", None) if callable(set_signature): set_signature( layer_cache_types, turboquant_kv_bits=turboquant_kv_bits, ) else: manager.adopt_layer_signature_if_unset(layer_cache_types) manager.invalidate_stale_layer_signature() except Exception as e: logger.warning("Failed to refresh SSD layer cache signature: %s", e) return None return layer_cache_types def _init_tiered_cache(self) -> bool: """Initialize paged SSD cache components if configured. In paged SSD-only mode: - All KV cache data is stored on paged SSD via PagedSSDCacheManager - PagedCacheManager only stores block metadata (no GPU memory for cache data) - BatchGenerator handles GPU memory for active inference """ if not HAS_TIERED_CACHE: if self.config.paged_ssd_cache_dir: logger.warning( "paged SSD cache requested but ssd_cache/memory_monitor modules " "not available. Install required dependencies." ) return False # In paged SSD-only mode, paged_ssd_cache_dir is required if not self.config.paged_ssd_cache_dir: logger.debug( "paged SSD cache not configured (no --ssd-cache-dir specified)" ) return False try: cache_dir = ( Path(self.config.paged_ssd_cache_dir) if self.config.paged_ssd_cache_dir else None ) # Pass current model identity so stale blocks from a prior model # version (e.g., 30-layer cache after an upgrade to 40 layers via # #1404) are unlinked at startup instead of triggering a layer # mismatch reject on every prefix lookup. See #1413. expected_num_layers = ( self.block_aware_cache.expected_num_layers if self.block_aware_cache is not None else 0 ) # Pending-writes queue sizing depends on per-block bytes # (block_size × per-token KV). Pass the *final* scheduler # block size — possibly adjusted from the config default by # RotatingKVCache / ArraysCache logic earlier in # ``__init__`` — and a model-derived per-token KV estimate # from the memory monitor. # # Gate on ``has_model_info()`` rather than just non-None: # ``estimate_block_memory(1)`` silently substitutes a # 7B-class fiction (32 layers × 8 KV heads × 128 head_dim # ≈ 128 KB/token) when dims were never set, and feeding # that "default" value into the writer-queue formula gives # the wrong cap on real workloads. When dims are missing # (test fixtures with skeletal model.config, unusual VLM # packs the nested-config walk doesn't recognise), pass # the PagedSSDCacheManager's 200 KB default explicitly so # the cap math degrades to a known constant instead of a # model-class fiction. The auto-init in ``Scheduler.__init__`` # paired with ``_set_model_info_for_monitor()`` means the # happy path here is ``has_model_info() is True``; this # else branch only fires for skeletal test fixtures. if self.memory_monitor is not None and self.memory_monitor.has_model_info(): # ``estimate_block_memory(1)`` returns all-layers K+V # bytes for a single token at the dtype the monitor was # configured with — exactly the per-token cost the # queue cap needs to weigh. expected_kv_bytes_per_token = self.memory_monitor.estimate_block_memory( 1 ) else: expected_kv_bytes_per_token = 200_000 # PagedSSDCacheManager default # Initialize paged SSD cache manager for SSD storage self.paged_ssd_cache_manager = PagedSSDCacheManager( cache_dir=cache_dir, max_size_bytes=self.config.paged_ssd_cache_max_size, hot_cache_max_bytes=self.config.hot_cache_max_size, hot_cache_only=self.config.hot_cache_only, hot_cache_budget=self.config.hot_cache_budget, expected_model_name=self.config.model_name or "", expected_num_layers=expected_num_layers, expected_block_size=self.config.paged_cache_block_size, expected_block_size_tokens=self.config.paged_cache_block_size, expected_kv_bytes_per_token=expected_kv_bytes_per_token, ) # Connect paged SSD cache manager to PagedCacheManager if self.paged_cache_manager is not None: self.paged_cache_manager.set_paged_ssd_cache_manager( self.paged_ssd_cache_manager ) # Connect paged SSD cache manager to BlockAwarePrefixCache for paged SSD-only mode if self.block_aware_cache is not None: self.block_aware_cache.set_paged_ssd_cache_manager( self.paged_ssd_cache_manager ) # Initialize boundary snapshot SSD store for offloading # non-sliceable cache snapshots during prefill. # Skip in hot_cache_only mode since snapshots would never be written. if BoundarySnapshotSSDStore is not None and not self.config.hot_cache_only: try: self._boundary_snapshot_store = BoundarySnapshotSSDStore( base_dir=Path(self.config.paged_ssd_cache_dir) ) except Exception as e: logger.debug( "Failed to initialize boundary snapshot SSD store: %s", e ) if self.config.hot_cache_only: logger.info( f"hot-cache-only mode enabled: " f"hot_cache_max={self._format_bytes(self.config.hot_cache_max_size)}, " f"block_size={self.config.paged_cache_block_size} tokens" ) else: logger.info( f"paged SSD cache enabled: " f"cache_dir={self.config.paged_ssd_cache_dir}, " f"max_size={self._format_bytes(self.config.paged_ssd_cache_max_size)}, " f"block_size={self.config.paged_cache_block_size} tokens" ) return True except Exception as e: logger.error(f"Failed to initialize paged SSD cache: {e}") self.paged_ssd_cache_manager = None return False def _disable_paged_cache_components(self) -> None: """Clear paged-cache runtime state after SSD cache setup fails.""" if self.paged_ssd_cache_manager is not None: try: self.paged_ssd_cache_manager.close() except Exception as e: logger.debug("Failed to close paged SSD cache manager: %s", e) self.paged_ssd_cache_manager = None self.paged_cache_manager = None self.block_aware_cache = None self._boundary_snapshot_store = None def _check_memory_pressure(self) -> None: """Check memory and evict blocks if needed. In paged SSD-only mode, memory pressure is not monitored since KV cache data is stored on paged SSD, not GPU memory. """ # In paged SSD-only mode, memory_monitor is not used # All KV cache data is on paged SSD, so no GPU memory pressure from PagedCache pass def _evict_blocks_permanently(self, bytes_to_free: int) -> int: """ Evict LRU blocks permanently (metadata cleanup). In paged SSD-only mode, blocks don't store data in GPU memory. This method just removes block metadata to free up slots. Args: bytes_to_free: Target bytes to free (used for estimation). Returns: Number of bytes freed (estimated). """ if self.paged_cache_manager is None or self.memory_monitor is None: return 0 # Estimate how many blocks to evict block_size = self.config.paged_cache_block_size num_blocks_to_evict = self.memory_monitor.estimate_blocks_to_free( bytes_to_free, block_size ) # Get evictable blocks in LRU order evictable = self.paged_cache_manager.get_evictable_blocks(num_blocks_to_evict) if not evictable: logger.debug("No evictable blocks found for permanent eviction") return 0 freed = 0 evicted_count = 0 for block in evictable: # In paged SSD-only mode, just clear metadata (data is on paged SSD) if self.paged_cache_manager.evict_block_permanently(block.block_id): freed += self.memory_monitor.estimate_block_memory(block_size) evicted_count += 1 if freed >= bytes_to_free: break if evicted_count > 0: logger.info( f"Evicted {evicted_count} blocks permanently " f"(~{self._format_bytes(freed)} estimated)" ) return freed def _evict_blocks_to_cold(self, bytes_to_free: int) -> int: """ Evict LRU blocks (with paged SSD cache configured). In paged SSD-only mode, data is already on paged SSD, so this just evicts block metadata from the index. The data remains on paged SSD and can be re-discovered if the same token sequence is requested. Args: bytes_to_free: Target bytes to free (used for estimation). Returns: Number of bytes freed (estimated). """ if self.paged_cache_manager is None or self.paged_ssd_cache_manager is None: return 0 if self.memory_monitor is None: return 0 # Estimate how many blocks to evict block_size = self.config.paged_cache_block_size num_blocks_to_evict = self.memory_monitor.estimate_blocks_to_free( bytes_to_free, block_size ) # Get evictable blocks in LRU order evictable = self.paged_cache_manager.get_evictable_blocks(num_blocks_to_evict) if not evictable: logger.debug("No evictable blocks found") return 0 evicted_count = 0 for block in evictable: # In paged SSD-only mode, data is already on paged SSD # Just evict the block metadata if self.paged_cache_manager.evict_block_permanently(block.block_id): evicted_count += 1 # Estimate bytes freed based on block count estimated_freed = evicted_count * self.memory_monitor.estimate_block_memory( block_size ) if evicted_count > 0: logger.info( f"Evicted {evicted_count} blocks from index " f"(data preserved on paged SSD, ~{self._format_bytes(estimated_freed)} metadata freed)" ) return estimated_freed def _restore_block_from_cold(self, block_id: int, block_hash: bytes) -> bool: """ Restore a block from cold storage (deprecated in paged SSD-only mode). In paged SSD-only mode, blocks don't store cache_data. Data is loaded directly from SSD when needed via reconstruct_cache(). Kept for API compatibility. Args: block_id: Block ID to restore. block_hash: Block's content hash. Returns: True if block exists in cold storage. """ if self.paged_ssd_cache_manager is None or self.paged_cache_manager is None: return False # In paged SSD-only mode, just verify block exists on paged SSD if not self.paged_ssd_cache_manager.has_block(block_hash): logger.warning(f"Block {block_id} not found in cold storage") return False # Touch the block to update LRU block = ( self.paged_cache_manager.blocks[block_id] if block_id < len(self.paged_cache_manager.blocks) else None ) if block: block.touch() logger.debug( f"Block {block_id} verified on paged SSD (hash={block_hash.hex()[:16]}...)" ) return True def restore_cold_blocks_for_request(self, request_id: str) -> int: """ Verify all blocks needed for a request exist on paged SSD. In paged SSD-only mode, blocks don't store cache_data. This method just verifies that blocks exist on paged SSD. Args: request_id: Request ID. Returns: Number of blocks verified on paged SSD. """ if self.paged_cache_manager is None or self.paged_ssd_cache_manager is None: return 0 if self.block_aware_cache is None: return 0 # Get block table for request block_table = self.paged_cache_manager.request_tables.get(request_id) if block_table is None: return 0 verified = 0 for block_id in block_table.block_ids: block = self.paged_cache_manager.blocks[block_id] if block.block_hash is not None: if self._restore_block_from_cold(block_id, block.block_hash): verified += 1 return verified def _collect_cache_counters(self) -> dict[str, int] | None: if self.block_aware_cache is None: return None prefix_stats = self.block_aware_cache.get_stats() counters = { "prefix_hits": prefix_stats.hits, "prefix_misses": prefix_stats.misses, "prefix_tokens_matched": prefix_stats.tokens_matched_total, "prefix_tokens_requested": prefix_stats.tokens_requested_total, "prefix_tokens_saved": prefix_stats.tokens_saved, "evictions": prefix_stats.evictions, } if self.paged_ssd_cache_manager is not None: ssd = self.paged_ssd_cache_manager.get_stats() hot_hits = ssd.hot_cache_hits total_loads = ssd.loads counters.update( { "ssd_hot_hits": hot_hits, "ssd_disk_loads": max(0, total_loads - hot_hits), "ssd_saves": ssd.saves, "ssd_errors": ssd.errors, "hot_cache_evictions": ssd.hot_cache_evictions, "hot_cache_promotions": ssd.hot_cache_promotions, } ) return counters def get_ssd_cache_stats(self) -> dict[str, Any] | None: """Get paged SSD + prefix cache observability statistics.""" stats = {} if self.paged_ssd_cache_manager is not None: stats["ssd_cache"] = self.paged_ssd_cache_manager.get_stats() if self.paged_cache_manager is not None: stats["indexed_blocks"] = self.paged_cache_manager.cold_block_count stats["block_size"] = self.config.paged_cache_block_size if self.block_aware_cache is not None: stats["prefix_cache"] = self.block_aware_cache.get_stats_dict() counters = self._collect_cache_counters() if counters: stats["cache_rates"] = self._cache_rate_tracker.snapshot_and_get_rates( counters ) return stats if stats else None # Alias for backwards compatibility get_tiered_cache_stats = get_ssd_cache_stats @staticmethod def _format_bytes(bytes_value: int) -> str: """Format bytes as human-readable string.""" if bytes_value >= 1024**3: return f"{bytes_value / 1024**3:.2f} GB" elif bytes_value >= 1024**2: return f"{bytes_value / 1024**2:.2f} MB" elif bytes_value >= 1024: return f"{bytes_value / 1024:.2f} KB" else: return f"{bytes_value} B"