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

1282 lines
51 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Adapted from vllm-mlx (https://github.com/vllm-project/vllm-mlx).
"""
Engine Core for oMLX continuous batching.
This module provides the EngineCore class that coordinates:
- Model loading and management
- Request scheduling via Scheduler
- Async request processing
- Output streaming
The design follows vLLM's engine architecture adapted for MLX.
"""
import asyncio
import concurrent.futures
import gc
import logging
import os
import time
import uuid
from contextlib import suppress
from dataclasses import dataclass, field
from typing import (
Any,
AsyncIterator,
Awaitable,
Callable,
Dict,
List,
Optional,
Tuple,
Union,
)
import mlx.core as mx
from .exceptions import PrefillMemoryExceededError
from .model_registry import get_registry
from .output_collector import RequestOutputCollector, RequestStreamState
from .request import Request, RequestOutput, SamplingParams
from .scheduler import Scheduler, SchedulerConfig, _sync_and_clear_cache
from .utils.compile_cache import (
clear_thread_compile_cache,
compile_cache_clear_available,
)
from .utils.fatal import FATAL_TEARDOWN_TIMEOUT_S, fatal_exit
logger = logging.getLogger(__name__)
def _raise_request_output_error(output: RequestOutput) -> None:
if output.error_code == "prefill_memory_exceeded":
metadata = output.error_metadata or {}
request_id = metadata.get("request_id")
estimated_bytes = metadata.get("estimated_bytes")
limit_bytes = metadata.get("limit_bytes")
raise PrefillMemoryExceededError(
message=output.error or "Prefill memory exceeded",
request_id=str(request_id) if request_id is not None else output.request_id,
estimated_bytes=(
int(estimated_bytes) if estimated_bytes is not None else None
),
limit_bytes=int(limit_bytes) if limit_bytes is not None else None,
)
raise RuntimeError(output.error)
_global_mlx_executor: concurrent.futures.ThreadPoolExecutor | None = None
# Fallback only: used when the MLX compile-cache clear symbol is unavailable
# (see omlx/utils/compile_cache.py). In that case a per-engine MLX worker
# thread cannot exit safely (its thread_local ~CompilerCache would free
# @mx.compile graphs' Python objects without the GIL -> crash), so close()
# keeps the executor + stream alive here for the process lifetime instead of
# shutting the thread down. With the clear symbol present (the normal path)
# these stay empty and the worker threads shut down normally.
_immortal_mlx_executors: list = []
_immortal_mlx_streams: list = []
def _final_engine_thread_reclaim(stream: Any) -> None:
"""Drop Python cycles and reclaim MLX buffers on the engine worker thread."""
gc.collect()
_sync_and_clear_cache(stream)
gc.collect()
def _init_mlx_thread() -> None:
"""Replace generation_stream with a thread-local stream on the executor thread.
mlx-lm's module-level ``generation_stream`` is created at import time in
whichever thread imported it first (the main thread at server startup).
Arrays produced inside ``with mx.stream(generation_stream):`` blocks carry
that stream reference. If the stream was created on the main thread,
subsequent ``.item()`` / ``mx.synchronize()`` calls from the executor
thread fail with "There is no Stream(gpu, 0) in current thread".
Fix: create a thread-local stream HERE and replace the module-level
``generation_stream`` in mlx_lm.generate and omlx.scheduler.
"""
import sys
import mlx.core as mx
stream = mx.new_thread_local_stream(mx.default_device())
gen_mod = sys.modules.get("mlx_lm.generate")
if gen_mod is not None:
gen_mod.generation_stream = stream
sched_mod = sys.modules.get("omlx.scheduler")
if sched_mod is not None:
sched_mod.generation_stream = stream
logger.info(f"MLX executor thread initialized: generation_stream = {stream}")
def get_mlx_executor() -> concurrent.futures.ThreadPoolExecutor:
"""Get or create the global MLX executor (lazy singleton).
mlx-lm's BatchGenerator uses a module-level Metal stream
(generation_stream), so ALL MLX GPU operations across all models
MUST be serialized onto one thread to prevent Metal command buffer
races that cause segfaults. See issue #85.
"""
global _global_mlx_executor
if _global_mlx_executor is None:
_global_mlx_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="mlx-global",
initializer=_init_mlx_thread,
)
return _global_mlx_executor
@dataclass
class EngineConfig:
"""Configuration for the engine."""
model_name: str = ""
scheduler_config: Optional[SchedulerConfig] = None
step_interval: float = 0.05 # Idle wait timeout; requests wake the loop
stream_interval: int = 1 # Tokens to batch before streaming (1=every token)
prefill_eviction_callback: Optional[Callable[[Any], Awaitable[bool]]] = None
# Decode burst: run several scheduler.step() calls per run_in_executor
# hand-off instead of one. Each decode token otherwise bounces back to the
# event loop, ping-ponging the GIL with the asyncio loop + uvicorn on the
# main thread; bursting keeps the MLX thread holding the GIL continuously.
# scheduler.step() services aborts/admission/finish every step, so
# correctness is unchanged and memory is identical (same tokens decoded,
# same KV cache; only a small list of K SchedulerOutputs is held per
# burst). The budget is a TIME ceiling so the event-loop pause (and thus
# new-request admission / abort / HTTP latency) is bounded consistently
# across hardware, and a slow prefill-chunk step ends the burst.
#
# Adaptive: with a single active request (the common local/single-user
# case) there is no concurrent request to stay responsive to, so we burst
# aggressively (decode_burst_budget_single_s). Once concurrent, we use the
# tight decode_burst_budget_s to keep admission/abort latency low.
# max_steps is a safety cap (bounds the host-side output list), NOT a
# memory knob. Set both budgets <= 0, or max_steps <= 1, to disable.
decode_burst_max_steps: int = field(
default_factory=lambda: int(os.environ.get("OMLX_DECODE_BURST_MAX_STEPS", "64"))
)
decode_burst_budget_single_s: float = field(
default_factory=lambda: float(
os.environ.get("OMLX_DECODE_BURST_BUDGET_SINGLE_S", "0.1")
)
)
decode_burst_budget_s: float = field(
default_factory=lambda: float(
os.environ.get("OMLX_DECODE_BURST_BUDGET_S", "0.03")
)
)
class EngineCore:
"""
Core engine for oMLX inference with continuous batching.
This engine runs the generation loop and manages request lifecycle.
It provides both sync and async interfaces for request handling.
"""
def __init__(
self,
model: Any,
tokenizer: Any,
config: Optional[EngineConfig] = None,
engine_id: Optional[str] = None,
force_model_ownership: bool = True,
):
"""
Initialize the engine.
Args:
model: The MLX model
tokenizer: The tokenizer
config: Engine configuration
engine_id: Optional unique ID for this engine (auto-generated if None)
force_model_ownership: If True (default), forcibly take model ownership
from any existing engine. If False, raises
ModelOwnershipError if model is in use.
"""
self.model = model
self.tokenizer = tokenizer
self.config = config or EngineConfig()
self._engine_id = engine_id or str(uuid.uuid4())
self._owns_model = False
self._closed = False
# Acquire model ownership
registry = get_registry()
registry.acquire(
model=model,
engine=self,
engine_id=self._engine_id,
force=force_model_ownership,
)
self._owns_model = True
# Per-engine executor with dedicated mx.Stream (#1248).
# Each EngineCore gets its own thread + GPU stream so different
# models can run scheduler.step() concurrently.
self._mlx_stream = mx.new_thread_local_stream(mx.default_device())
self._mlx_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=1,
thread_name_prefix=f"mlx-engine-{self._engine_id[:8]}",
)
# Create scheduler with per-engine stream
scheduler_config = self.config.scheduler_config or SchedulerConfig()
self.scheduler = Scheduler(
model=model,
tokenizer=tokenizer,
config=scheduler_config,
stream=self._mlx_stream,
)
# Output collectors for low-latency streaming (vLLM pattern)
self._output_collectors: Dict[str, RequestOutputCollector] = {}
self._stream_states: Dict[str, RequestStreamState] = {}
self._finished_events: Dict[str, asyncio.Event] = {}
# Finish timestamps for orphan-collector reaping (#1154).
# Normally a consumer drains and removes its own collector, but if the
# client disconnects mid-stream the SSE generator chain is abandoned and
# its cleanup finally only runs at GC time, so the collector lingers and
# the dashboard keeps showing the request as "Generating".
self._finished_at: Dict[str, float] = {}
self._last_reap = 0.0
# Engine state
self._running = False
self._task: Optional[asyncio.Task] = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._wake_event: Optional[asyncio.Event] = None
self._start_time: Optional[float] = None
self._steps_executed = 0
# Drop transient aliases after ownership moves to the engine/scheduler
# graph, so close()/deep_reset() can make that graph unreachable.
model = None
tokenizer = None
logger.debug(f"Engine {self._engine_id} initialized")
async def start(self) -> None:
"""Start the engine loop."""
if self._running:
return
self._loop = asyncio.get_running_loop()
self._wake_event = asyncio.Event()
self._running = True
self._start_time = time.time()
self._task = asyncio.create_task(self._engine_loop())
logger.info("Engine started")
async def stop(self) -> None:
"""Stop the engine loop."""
self._running = False
if self._wake_event is not None:
self._wake_event.set()
if self._task:
self._task.cancel()
with suppress(asyncio.CancelledError):
await self._task
self._task = None
self._wake_event = None
self._loop = None
logger.info("Engine stopped")
def is_running(self) -> bool:
"""Check if engine is running."""
return self._running
def _wake_engine_loop(self) -> None:
"""Wake the idle engine loop after scheduler-visible state changes."""
event = getattr(self, "_wake_event", None)
loop = getattr(self, "_loop", None)
if event is None or loop is None or loop.is_closed():
return
try:
running_loop = asyncio.get_running_loop()
except RuntimeError:
running_loop = None
if running_loop is loop:
event.set()
else:
loop.call_soon_threadsafe(event.set)
def _step_burst(self) -> list:
"""Run scheduler.step() several times in one executor hand-off.
Each decode token otherwise bounces back to the event loop, which
ping-pongs the GIL with the asyncio loop + uvicorn on the main thread
(~1ms/token of contention). Chaining a few steps lets the MLX thread
hold the GIL continuously (in-process sync loop hits ~80 tok/s vs ~74
through the per-token async hand-off).
scheduler.step() services aborts/admission/finish every step, so
correctness is unchanged; the only cost is event-loop responsiveness,
bounded by decode_burst_budget_s. Stops early when no work remains, a
prefill eviction needs the (async) callback, or the budget elapses —
the budget also ends the burst when a slow prefill-chunk step lands.
Runs on the MLX executor thread. Returns the SchedulerOutputs in order.
"""
max_steps = self.config.decode_burst_max_steps
outputs = [self.scheduler.step()]
if max_steps <= 1:
return outputs
# Adaptive budget: single active request -> aggressive (nothing else to
# stay responsive to); concurrent -> tight to keep admission/abort low.
running = getattr(self.scheduler, "running", None)
single = running is None or len(running) <= 1
budget = (
self.config.decode_burst_budget_single_s
if single
else self.config.decode_burst_budget_s
)
if budget <= 0:
return outputs
deadline = time.monotonic() + budget
while len(outputs) < max_steps:
last = outputs[-1]
if (
not last.has_work # throttled/idle: stop and let the loop wait
or not self.scheduler.has_requests()
or last.prefill_eviction_request is not None
or time.monotonic() >= deadline
):
break
outputs.append(self.scheduler.step())
return outputs
async def _engine_loop(self) -> None:
"""Main engine loop - runs scheduler steps on the MLX executor.
All scheduler steps run on _mlx_executor (single-worker thread) to
guarantee that MLX GPU operations are never concurrent. VLM vision
encoding also runs on the same executor, so inline scheduler.step()
on the event loop would race with vision mx.eval() and segfault.
"""
loop = asyncio.get_running_loop()
step_interval = self.config.step_interval
stream_interval = self.config.stream_interval
use_simple_streaming = stream_interval == 1
while self._running:
try:
# Sweep collectors orphaned by client disconnects (throttled).
now = time.monotonic()
if now - self._last_reap >= 1.0:
self._last_reap = now
self._reap_orphaned_collectors(now)
if self.scheduler.has_requests():
step_outputs = await loop.run_in_executor(
self._mlx_executor, self._step_burst
)
self._steps_executed += len(step_outputs)
# Distribute every step's outputs to collectors (one or
# more decode tokens per burst). collector.put() runs on the
# loop thread, keeping the asyncio.Event signalling
# thread-safe and per-token streaming intact.
collectors = self._output_collectors
states = self._stream_states
eviction_request = None
distributed = False
for output in step_outputs:
if (
eviction_request is None
and output.prefill_eviction_request is not None
):
eviction_request = output.prefill_eviction_request
outputs = output.outputs
if not outputs:
continue
distributed = True
for req_output in outputs:
rid = req_output.request_id
collector = collectors.get(rid)
if collector is not None:
# Optimized: skip stream_interval check when interval=1
if use_simple_streaming:
collector.put(req_output)
else:
state = states.get(rid)
if state and state.should_send(
req_output.completion_tokens,
req_output.finished,
):
collector.put(req_output)
state.mark_sent(req_output.completion_tokens)
if req_output.finished:
self._mark_request_finished(rid)
# Cleanup normally happens in the consumer
# (stream_outputs()/generate()); collectors left
# behind by a disconnected client are swept by
# _reap_orphaned_collectors() via _finished_at.
if distributed:
# Always yield to prevent event loop starvation.
# Without this, orphaned requests (client disconnected but
# request still in scheduler) block the entire event loop,
# making the server unresponsive to all HTTP requests.
await asyncio.sleep(0)
if eviction_request is not None:
callback = self.config.prefill_eviction_callback
if callback is not None:
logger.info(
"Running prefill LRU eviction for request %s",
eviction_request.request_id,
)
evicted = await callback(eviction_request)
if evicted:
logger.info(
"Prefill LRU eviction completed for request %s",
eviction_request.request_id,
)
else:
logger.info(
"No idle model evicted for request %s; "
"scheduler will fall back to throttling",
eviction_request.request_id,
)
else:
logger.debug(
"Prefill eviction requested for %s but no callback "
"is configured",
eviction_request.request_id,
)
continue
if not step_outputs[-1].has_work:
# Requests may be queued while scheduler admission is
# intentionally throttled by async cache cleanup. Avoid
# spinning the engine loop, but still let new requests
# wake the wait immediately.
event = self._wake_event
if event is None:
await asyncio.sleep(step_interval)
else:
event.clear()
with suppress(TimeoutError):
await asyncio.wait_for(
event.wait(), timeout=step_interval
)
else:
event = self._wake_event
if event is None:
await asyncio.sleep(step_interval)
else:
event.clear()
# Avoid losing a request that arrived between
# has_requests() and clear().
if self.scheduler.has_requests():
continue
with suppress(TimeoutError):
await asyncio.wait_for(event.wait(), timeout=step_interval)
except asyncio.CancelledError:
break
except Exception as e:
import traceback
logger.error(f"Engine loop error: {e}\n{traceback.format_exc()}")
# Fail all requests and remove from scheduler to prevent
# infinite loop (has_requests() must return False).
failed_ids = await loop.run_in_executor(
self._mlx_executor, self.scheduler.fail_all_requests
)
for rid in failed_ids:
collector = self._output_collectors.get(rid)
if collector is not None:
collector.put(
RequestOutput(
request_id=rid,
finished=True,
finish_reason="error",
error=str(e),
)
)
self._mark_request_finished(rid)
await asyncio.sleep(0.1)
async def add_request(
self,
prompt: Union[str, List[int]],
sampling_params: Optional[SamplingParams] = None,
request_id: Optional[str] = None,
images: Optional[List[Any]] = None,
videos: Optional[List[Any]] = None,
vlm_inputs_embeds: Optional[Any] = None,
vlm_extra_kwargs: Optional[Dict[str, Any]] = None,
vlm_image_hash: Optional[str] = None,
vlm_cache_key_start: int = 0,
vlm_cache_key_ranges: Optional[List[Tuple[int, str]]] = None,
specprefill: Optional[bool] = None,
specprefill_keep_pct: Optional[float] = None,
specprefill_threshold: Optional[int] = None,
specprefill_system_end: Optional[int] = None,
) -> str:
"""
Add a request for processing.
Args:
prompt: Input prompt (string or token IDs)
sampling_params: Generation parameters
request_id: Optional custom request ID
images: Optional images for multimodal
videos: Optional videos for multimodal
vlm_inputs_embeds: Pre-computed vision+text embeddings for VLM
vlm_extra_kwargs: Model-specific VLM kwargs (e.g., position_ids)
vlm_image_hash: SHA256 hash of images for prefix cache
specprefill: Per-request SpecPrefill override (True/False/None)
specprefill_keep_pct: Per-request keep rate override
specprefill_threshold: Per-request threshold override (min tokens)
Returns:
The request ID
"""
if request_id is None:
request_id = str(uuid.uuid4())
if sampling_params is None:
sampling_params = SamplingParams()
request = Request(
request_id=request_id,
prompt=prompt,
sampling_params=sampling_params,
images=images,
videos=videos,
vlm_inputs_embeds=vlm_inputs_embeds,
vlm_extra_kwargs=vlm_extra_kwargs,
vlm_image_hash=vlm_image_hash,
vlm_cache_key_start=vlm_cache_key_start,
vlm_cache_key_ranges=vlm_cache_key_ranges,
)
# SpecPrefill: resolve per-request settings.
# The scheduler checks _specprefill_enabled to decide whether to score.
if specprefill is not None:
request._specprefill_enabled = specprefill
elif self.scheduler._specprefill_draft_model is not None:
# Draft model is loaded → enable by default
request._specprefill_enabled = True
if specprefill_keep_pct is not None:
request._specprefill_keep_pct = specprefill_keep_pct
if specprefill_threshold is not None:
request._specprefill_threshold = specprefill_threshold
if specprefill_system_end is not None and specprefill_system_end > 0:
request.specprefill_system_end = specprefill_system_end
# Setup output collector with stream_interval from config
self._output_collectors[request_id] = RequestOutputCollector(aggregate=True)
self._stream_states[request_id] = RequestStreamState(
stream_interval=self.config.stream_interval
)
self._finished_events[request_id] = asyncio.Event()
# Add to scheduler — route through the MLX executor so that
# prefix cache reconstruction (mx.load, mx.concatenate) never
# races with scheduler.step() on the Metal stream. See #95.
#
# The scheduler may raise (PrefillMemoryExceededError, or other
# validation errors) before the request enters self.waiting. In
# that case the consumer in stream_outputs / generate never sees
# the request_id and its finally-block cleanup never fires —
# without the explicit cleanup below the per-rejection leak
# accumulates one collector + one stream_state + one
# asyncio.Event per refused request. Re-raise after cleanup so
# the typed exception still reaches the FastAPI 400 handler.
loop = asyncio.get_running_loop()
try:
await loop.run_in_executor(
self._mlx_executor, self.scheduler.add_request, request
)
except BaseException:
# If the caller is cancelled here (e.g. the client disconnected
# before the SSE stream began) — or the insert fails — the request
# never reaches stream_outputs()/generate()'s try/finally, so
# nothing would mark it finished or clean it up. The collector
# created above would then linger forever as a phantom the reaper
# cannot see (it was never stamped _finished_at), and the dashboard
# would show it as "Generating" indefinitely (#1154).
# Drop the tracking and abort any partial scheduler insert (the
# deferred abort is idempotent and harmless if it never landed).
try:
self.scheduler.abort_request(request_id)
except Exception as abort_exc: # noqa: BLE001
logger.debug(
f"Abort of partial insert for {request_id} failed: {abort_exc}"
)
self._cleanup_request(request_id)
raise
self._wake_engine_loop()
return request_id
async def abort_request(self, request_id: str) -> bool:
"""Abort a request.
Uses deferred abort pattern: scheduler.abort_request() just enqueues
the request ID into a thread-safe set. The actual abort is processed
at the start of the next scheduler.step() call, ensuring it runs in
the same execution context as generation (no race conditions).
Signals the consumer (stream_outputs/generate) with an abort error
so it can exit gracefully. Cleanup is handled by the consumer's
finally block, NOT here -- calling _cleanup_request() immediately
after put() would clear the output before the consumer can drain it.
"""
scheduler = getattr(self, "scheduler", None)
if getattr(self, "_closed", False) or scheduler is None:
logger.debug(
"Skipping abort for request %s because engine is already closed",
request_id,
)
return False
result = scheduler.abort_request(request_id)
# Signal consumer with abort error so any waiting
# stream_outputs() / generate() can exit gracefully.
# Matches abort_all_requests() pattern.
collector = self._output_collectors.get(request_id)
if collector is not None:
collector.put(
RequestOutput(
request_id=request_id,
finished=True,
finish_reason="abort",
error="Request aborted",
)
)
self._mark_request_finished(request_id)
self._wake_engine_loop()
return result
async def abort_all_requests(self) -> int:
"""Abort all active requests without stopping the engine.
Sends error output to all active collectors and marks requests
for deferred abort in the scheduler. Cleanup is handled by
the consumer (stream_outputs/generate).
"""
from .utils.proc_memory import get_phys_footprint
request_ids = list(self._output_collectors.keys())
ceiling = 0
sched = self.scheduler
if sched is not None:
ceiling = int(getattr(sched, "_memory_hard_limit_bytes", 0) or 0)
usage = get_phys_footprint()
usage_gb = usage / (1024**3)
ceiling_gb = ceiling / (1024**3) if ceiling > 0 else 0.0
for rid in request_ids:
self.scheduler.abort_request(rid)
collector = self._output_collectors.get(rid)
if collector is not None:
if ceiling > 0:
error_msg = (
f"Request aborted: process memory limit exceeded "
f"(usage {usage_gb:.1f} GB, ceiling {ceiling_gb:.1f} GB). "
"Reduce context size or lower memory_guard_tier."
)
else:
error_msg = (
f"Request aborted: process memory limit exceeded "
f"(usage {usage_gb:.1f} GB). "
"Reduce context size or lower memory_guard_tier."
)
collector.put(
RequestOutput(
request_id=rid,
finished=True,
finish_reason="error",
new_text=f"\n\n[Error: {error_msg}]",
error=error_msg,
)
)
self._mark_request_finished(rid)
if request_ids:
logger.warning(
f"Aborted {len(request_ids)} requests due to memory pressure"
)
self._wake_engine_loop()
return len(request_ids)
def _mark_request_finished(self, request_id: str) -> None:
"""Signal the consumer a request finished and stamp the finish time.
The timestamp lets _reap_orphaned_collectors() drop collectors whose
consumer never cleaned up (e.g. the client disconnected mid-stream and
the SSE generator chain was abandoned rather than closed).
"""
self._finished_at.setdefault(request_id, time.monotonic())
event = self._finished_events.get(request_id)
if event is not None:
event.set()
def _reap_orphaned_collectors(self, now: float, grace: float = 5.0) -> int:
"""Drop tracking for finished requests whose consumer never cleaned up.
stream_outputs()/generate() normally remove their own collector via
_cleanup_request() once the final output is drained. But when a client
disconnects mid-stream the SSE generator chain is abandoned instead of
closed, so that finally block only runs at non-deterministic GC time —
the collector lingers in _output_collectors and the request shows as
"Generating" forever on the dashboard (#1154).
This sweep removes the dict tracking for any request finished more than
``grace`` seconds ago. It is intentionally pop-only and never calls
``collector.clear()``: stream_outputs()/generate() hold their own
reference to the collector object, so dropping the dict entry cannot
truncate a slow-but-live consumer's output — only an over-eager clear()
could (which is what made the earlier _delayed_cleanup() approach race).
The grace period guarantees a live consumer (which drains in the same
event-loop turn the request finishes) has already self-cleaned.
"""
if not self._finished_at:
return 0
stale = [rid for rid, ts in self._finished_at.items() if now - ts >= grace]
for rid in stale:
# pop-only — see docstring; never clear() the collector object.
self._output_collectors.pop(rid, None)
self._stream_states.pop(rid, None)
self._finished_events.pop(rid, None)
self._finished_at.pop(rid, None)
if stale:
logger.debug(
"Reaped %d orphaned output collector(s) after disconnect: %s",
len(stale),
stale,
)
return len(stale)
def _cleanup_request(self, request_id: str) -> None:
"""Clean up request tracking.
Only cleans engine-core level state (collectors, events).
Scheduler state is cleaned by _do_abort_request (deferred abort)
or _cleanup_finished (normal completion).
"""
collector = self._output_collectors.pop(request_id, None)
if collector:
collector.clear()
self._stream_states.pop(request_id, None)
self._finished_events.pop(request_id, None)
self._finished_at.pop(request_id, None)
async def _delayed_cleanup(self, request_id: str, delay: float = 5.0) -> None:
"""
Cleanup request after delay if not already cleaned.
This handles the case where a client disconnects before consuming
the stream_outputs() generator, which would prevent the finally
block from running.
"""
await asyncio.sleep(delay)
if request_id in self._output_collectors:
logger.debug(f"Delayed cleanup for request {request_id}")
self._cleanup_request(request_id)
async def stream_outputs(
self,
request_id: str,
timeout: Optional[float] = None,
) -> AsyncIterator[RequestOutput]:
"""
Stream outputs for a request with low-latency non-blocking pattern.
Uses the vLLM pattern: get_nowait() or await get()
This avoids unnecessary task switches when output is available.
Args:
request_id: The request ID
timeout: Optional timeout in seconds
Yields:
RequestOutput objects as tokens are generated
"""
collector = self._output_collectors.get(request_id)
if collector is None:
# Request might not be added yet or already cleaned up
return
try:
while True:
try:
# Non-blocking drain pattern from vLLM
# Try get_nowait first to avoid task switch if output ready
if timeout:
output = collector.get_nowait()
if output is None:
output = await asyncio.wait_for(
collector.get(), timeout=timeout
)
else:
output = collector.get_nowait() or await collector.get()
yield output
if output.error:
_raise_request_output_error(output)
if output.finished:
break
except asyncio.TimeoutError:
logger.warning(f"Timeout waiting for request {request_id}")
break
finally:
self._cleanup_request(request_id)
async def generate(
self,
prompt: Union[str, List[int]],
sampling_params: Optional[SamplingParams] = None,
request_id: Optional[str] = None,
**kwargs,
) -> RequestOutput:
"""
Generate a complete response (non-streaming).
This method is optimized to avoid streaming overhead when
you only need the final result.
Args:
prompt: Input prompt
sampling_params: Generation parameters
request_id: Optional request ID
Returns:
Final RequestOutput with complete text
"""
request_id = await self.add_request(
prompt=prompt,
sampling_params=sampling_params,
request_id=request_id,
**kwargs,
)
# Wait for completion using event instead of streaming
# This avoids the waiting_consumer tracking overhead
event = self._finished_events.get(request_id)
if event is None:
raise RuntimeError(f"No event for request {request_id}")
# Capture the collector reference BEFORE awaiting, mirroring
# stream_outputs(): the orphan reaper is pop-only and may drop the dict
# entry once the request is finished, but a held reference still drains.
# Re-fetching after the await would race the reaper if this coroutine is
# starved past the grace window under heavy load (#1154).
collector = self._output_collectors.get(request_id)
if collector is None:
raise RuntimeError(f"No collector for request {request_id}")
try:
# Wait for the request to finish
await event.wait()
except asyncio.CancelledError:
# Client disconnected or task was cancelled - abort the request
# to free scheduler/GPU resources (prevents orphaned requests)
logger.info(f"Request {request_id} cancelled, aborting")
await self.abort_request(request_id)
self._cleanup_request(request_id)
raise
# Drain all outputs and get the last one (using the captured reference)
final_output = None
while True:
output = collector.get_nowait()
if output is None:
break
final_output = output
# Cleanup
self._cleanup_request(request_id)
if final_output is None:
raise RuntimeError(f"No output for request {request_id}")
if final_output.error:
_raise_request_output_error(final_output)
return final_output
def generate_batch_sync(
self,
prompts: List[Union[str, List[int]]],
sampling_params: Optional[SamplingParams] = None,
) -> List[RequestOutput]:
"""
Generate responses synchronously for maximum throughput.
This bypasses the async engine loop entirely, running the scheduler
directly for optimal batching performance. Use this when you don't
need streaming and want maximum throughput.
Args:
prompts: List of input prompts
sampling_params: Generation parameters (same for all)
Returns:
List of RequestOutput in same order as prompts
"""
import uuid as uuid_module
from .request import Request
if sampling_params is None:
sampling_params = SamplingParams()
# Add all requests to scheduler
request_ids = []
for prompt in prompts:
request_id = str(uuid_module.uuid4())
request = Request(
request_id=request_id,
prompt=prompt,
sampling_params=sampling_params,
)
self.scheduler.add_request(request)
request_ids.append(request_id)
# Process until all done - direct scheduler access, no async overhead
results: Dict[str, RequestOutput] = {}
while self.scheduler.has_requests():
output = self.scheduler.step()
for req_output in output.outputs:
if req_output.finished:
results[req_output.request_id] = req_output
# Cleanup
for rid in request_ids:
self.scheduler.remove_finished_request(rid)
# Return in original order
return [results[rid] for rid in request_ids]
def get_stats(self) -> Dict[str, Any]:
"""Get engine statistics."""
scheduler_stats = self.scheduler.get_stats()
uptime = time.time() - self._start_time if self._start_time else 0
return {
"running": self._running,
"uptime_seconds": uptime,
"steps_executed": self._steps_executed,
"active_requests": len(self._output_collectors),
"stream_interval": self.config.stream_interval,
**scheduler_stats,
}
def get_cache_stats(self) -> Optional[Dict[str, Any]]:
"""Get prefix cache statistics."""
return self.scheduler.get_cache_stats()
def _release_model(self) -> None:
"""Release model ownership."""
if self._owns_model and not self._closed:
registry = get_registry()
registry.release(self.model, self._engine_id)
self._owns_model = False
logger.debug(f"Engine {self._engine_id} released model ownership")
def close(self) -> None:
"""
Explicitly close the engine and release resources.
This should be called when done using the engine, especially
if you plan to create another engine with the same model.
"""
if self._closed:
return
# Release model ownership BEFORE setting _closed
# (_release_model checks not self._closed)
if self._owns_model:
registry = get_registry()
registry.release(self.model, self._engine_id)
self._owns_model = False
logger.debug(f"Engine {self._engine_id} released model ownership")
self._closed = True
# Both shutdown() and deep_reset() touch the engine stream (directly
# or via _drain_pending_async_removes / _do_abort_request). The
# stream is bound to the engine's executor thread, so dispatch both
# through the executor; fall back to a direct call if the executor
# is already shut down.
for fn in (self.scheduler.shutdown, self.scheduler.deep_reset):
fn_name = getattr(fn, "__name__", repr(fn))
try:
self._mlx_executor.submit(fn).result(timeout=FATAL_TEARDOWN_TIMEOUT_S)
except concurrent.futures.TimeoutError:
fatal_exit(
f"Engine teardown timed out after "
f"{FATAL_TEARDOWN_TIMEOUT_S:.0f}s while running "
f"{fn_name} for engine {self._engine_id}"
)
except RuntimeError:
try:
fn()
except RuntimeError:
pass
except Exception:
logger.warning(
"Engine %s: %s raised during close() fallback",
self._engine_id,
getattr(fn, "__name__", fn),
exc_info=True,
)
except Exception:
# A failing shutdown/deep_reset must not abort close(), or the
# SSD cache manager below stays open and its writer thread keeps
# the manager (and its hot cache) alive until restart.
logger.warning(
"Engine %s: %s raised during close()",
self._engine_id,
getattr(fn, "__name__", fn),
exc_info=True,
)
# Drop the last bound-method reference from the teardown loop before
# the final GC/reclaim pass below.
fn = None
# Guarantee the SSD cache manager is released even if shutdown() did not
# reach its own close() above. The manager's writer thread holds a strong
# reference to it, so an unclosed manager leaks until restart.
manager = getattr(self.scheduler, "paged_ssd_cache_manager", None)
if manager is not None:
try:
manager.close()
except Exception:
logger.warning(
"Engine %s: SSD cache manager close() failed during teardown",
self._engine_id,
exc_info=True,
)
self.scheduler.paged_ssd_cache_manager = None
manager = None
# Clear output collectors before dropping model/scheduler references so
# any request-side caches they retain are eligible for the final reclaim.
for collector in self._output_collectors.values():
collector.clear()
self._output_collectors.clear()
self._stream_states.clear()
self._finished_events.clear()
self._finished_at.clear()
release_model_resources = getattr(self.model, "release_resources", None)
if callable(release_model_resources):
try:
release_model_resources()
except Exception:
logger.warning(
"Engine %s: model resource release failed during close()",
self._engine_id,
exc_info=True,
)
release_model_resources = None
# Release model, tokenizer, and scheduler references before the final
# MLX reclaim. The reclaim must run on this engine's worker thread and
# stream; clearing on the global executor cannot reliably return this
# thread/stream-local Metal memory to MLX.
self.model = None
self.tokenizer = None
self.scheduler = None
if self._mlx_executor is not None:
try:
self._mlx_executor.submit(
_final_engine_thread_reclaim, self._mlx_stream
).result(timeout=FATAL_TEARDOWN_TIMEOUT_S)
except concurrent.futures.TimeoutError:
fatal_exit(
f"Engine teardown timed out after "
f"{FATAL_TEARDOWN_TIMEOUT_S:.0f}s while reclaiming "
f"MLX memory for engine {self._engine_id}"
)
except RuntimeError:
pass
except Exception:
logger.warning(
"Engine %s: final MLX reclaim raised during close()",
self._engine_id,
exc_info=True,
)
# MLX's @mx.compile cache is a C++ thread_local CompilerCache. If
# this worker thread exits with a non-empty cache, ~CompilerCache
# frees the cached graphs' Python objects from a thread-exit handler
# WITHOUT the GIL -> "PyThreadState_Get: GIL is released" crash for
# models with module-scope @mx.compile graphs (DeepSeek V4 unload,
# ml-explore/mlx #3280). Clear the cache ON this worker thread (GIL
# held) before the thread is torn down so the destructor runs on an
# empty cache, then request shutdown without waiting indefinitely.
# See utils/compile_cache.py.
if compile_cache_clear_available():
try:
self._mlx_executor.submit(clear_thread_compile_cache).result(
timeout=FATAL_TEARDOWN_TIMEOUT_S
)
except concurrent.futures.TimeoutError:
fatal_exit(
f"Engine teardown timed out after "
f"{FATAL_TEARDOWN_TIMEOUT_S:.0f}s while clearing "
f"MLX compile cache for engine {self._engine_id}"
)
except RuntimeError:
pass
self._mlx_executor.shutdown(wait=False)
else:
# Fallback: the clear symbol is unavailable, so do NOT exit the
# worker thread (that would run the unsafe destructor). Keep it
# alive for the process lifetime via the module-global registry.
_immortal_mlx_executors.append(self._mlx_executor)
_immortal_mlx_streams.append(self._mlx_stream)
self._mlx_executor = None
logger.debug(f"Engine {self._engine_id} closed")
def __del__(self):
"""Cleanup on destruction."""
try:
self._release_model()
except Exception:
# Ignore errors during garbage collection
pass
@property
def engine_id(self) -> str:
"""Get the engine ID."""
return self._engine_id
class AsyncEngineCore:
"""
Async context manager wrapper for EngineCore.
Usage:
async with AsyncEngineCore(model, tokenizer) as engine:
request_id = await engine.add_request("Hello")
async for output in engine.stream_outputs(request_id):
print(output.new_text)
"""
def __init__(
self,
model: Any,
tokenizer: Any,
config: Optional[EngineConfig] = None,
):
self.engine = EngineCore(model, tokenizer, config)
# Drop wrapper-local aliases after EngineCore takes ownership.
model = None
tokenizer = None
@property
def _mlx_executor(self):
"""Expose the MLX executor for VLM vision encoding."""
return self.engine._mlx_executor
async def __aenter__(self) -> "AsyncEngineCore":
await self.engine.start()
return self
async def __aexit__(self, *args) -> None:
await self.stop()
def start(self) -> None:
"""Start engine (creates task in current loop)."""
asyncio.create_task(self.engine.start())
async def stop(self) -> None:
"""Stop the engine."""
engine = getattr(self, "engine", None)
if engine is None:
return
await engine.stop()
async def add_request(
self,
prompt: Union[str, List[int]],
sampling_params: Optional[SamplingParams] = None,
request_id: Optional[str] = None,
**kwargs,
) -> str:
"""Add a request."""
return await self.engine.add_request(
prompt=prompt,
sampling_params=sampling_params,
request_id=request_id,
**kwargs,
)
async def abort_request(self, request_id: str) -> bool:
"""Abort a request."""
engine = getattr(self, "engine", None)
if engine is None:
logger.debug(
"Skipping abort for request %s because async engine is closed",
request_id,
)
return False
return await engine.abort_request(request_id)
async def abort_all_requests(self) -> int:
"""Abort all active requests without stopping the engine."""
engine = getattr(self, "engine", None)
if engine is None:
return 0
return await engine.abort_all_requests()
async def stream_outputs(
self,
request_id: str,
timeout: Optional[float] = None,
) -> AsyncIterator[RequestOutput]:
"""Stream outputs."""
async for output in self.engine.stream_outputs(request_id, timeout):
yield output
async def generate(
self,
prompt: Union[str, List[int]],
sampling_params: Optional[SamplingParams] = None,
**kwargs,
) -> RequestOutput:
"""Generate complete response."""
return await self.engine.generate(
prompt=prompt,
sampling_params=sampling_params,
**kwargs,
)
def get_stats(self) -> Dict[str, Any]:
"""Get engine stats."""
return self.engine.get_stats()
def get_cache_stats(self) -> Optional[Dict[str, Any]]:
"""Get prefix cache statistics."""
return self.engine.get_cache_stats()