Files
wehub-resource-sync 94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:38:16 +08:00

1283 lines
50 KiB
Python

"""MLX model runner for Apple Silicon.
Slot allocation and radix-trie prefix matching are handled by the
scheduler (``TokenToKVPoolAllocator`` / ``RadixCache``). This runner
reads cached attention KV from ``MlxAttentionKVPool``, restores any
native auxiliary layer state, runs the forward pass, and writes the new
cache state back. Each request keeps model-shaped cache entries:
attention layers use ``ContiguousAttentionKVCache`` and auxiliary layers
use native ``mlx-lm`` cache objects.
The module also exposes a lazy-eval (`*_start` / `*_finalize`) surface
used by the MLX overlap scheduler to pipeline CPU bookkeeping with
GPU execution. The lazy API is a thin split of the synchronous API:
``*_start`` builds the compute graph without materialising outputs,
``*_finalize`` blocks on the lazy token(s) and commits per-request
state.
"""
import logging
import time
from dataclasses import dataclass
from typing import Any
import mlx.core as mx
import psutil
from mlx.utils import tree_flatten
from mlx_lm import load as mlx_lm_load
from mlx_lm.utils import quantize_model as mlx_lm_quantize_model
from sglang.srt.environ import envs
from sglang.srt.hardware_backend.mlx.aot import (
MLX_AOT_KERNEL_REGISTRY,
MlxAOTKernelSet,
)
from sglang.srt.hardware_backend.mlx.kv_cache import (
AttentionOffsetCache,
BatchedDecodeContext,
ContiguousAttentionKVCache,
MlxAttentionKVPool,
MLXAttentionWrapper,
MlxModelCacheLayout,
PoolBackedAttentionKVCache,
clear_context,
find_attention_layers,
get_head_dim,
get_num_kv_heads,
patch_model_attention,
set_context,
uses_sliding_window_attention,
)
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.runtime_context import get_server_args
logger = logging.getLogger(__name__)
@dataclass
class MlxPendingPrefill:
"""Lazy prefill state, finalised after ``mx.eval``/``async_eval``.
``cache`` is the per-layer cache list that will
become ``_req_caches[req_id]`` once the request is committed. It
may have been converted from transient pool-backed attention caches
already, so its ``state`` arrays are safe to hand to ``async_eval``.
"""
lazy_token: mx.array
cache: list[Any]
req_id: str
full_token_ids: list[int]
req_pool_idx: int
synced_offset: int
@dataclass
class MlxPendingExtend:
"""Lazy chunked-prefill-continuation state for an existing request.
Mirrors :meth:`MlxModelRunner.extend` split into launch/finalize
halves. ``cache`` is the request's existing per-layer cache (not a
fresh one) so the graph writes extend onto the already-materialised
prefix.
"""
lazy_token: mx.array
req_id: str
new_token_ids: list[int]
new_synced_offset: int
@dataclass
class MlxPendingDecode:
"""Lazy decode state, finalised after ``mx.eval``/``async_eval``.
``caches`` is a per-request list of per-layer cache
references (``caches[req_idx][layer_idx]``). These are the same
objects the attention wrapper writes into during the forward pass,
so :meth:`decode_batch_start_chained` can launch the next step on
top of the same caches without materialising this step first.
"""
lazy_tokens: mx.array
req_ids: list[str]
caches: list[list[Any]]
_MLX_QUANTIZATION_PRESETS: dict[str, tuple[int, int]] = {
# name -> (bits, group_size). group_size=64 matches the mlx-community convention.
"mlx_q4": (4, 64),
"mlx_q8": (8, 64),
}
_MLX_KV_FLOAT_DTYPES = {mx.float16, mx.bfloat16, mx.float32}
class MlxModelRunner:
"""MLX model runner with radix-cache prefix sharing."""
def __init__(
self,
model_path: str,
trust_remote_code: bool = False,
disable_radix_cache: bool = False,
pool_size: int | None = None,
mem_fraction_static: float = 0.8,
quantization: str | None = None,
):
self.model_path = model_path
self.trust_remote_code = trust_remote_code
self.model = None
self.disable_radix_cache = disable_radix_cache
self._mem_fraction_static = mem_fraction_static
# Counter used to trigger periodic mx.clear_cache() calls.
self._decode_step_ct: int = 0
self._clear_steps = envs.SGLANG_MLX_CLEAR_CACHE_STEPS.get()
# On-the-fly quantization preset (e.g. "mlx_q4"). None = no on-load quantization.
# Pre-quantized HF repos load correctly regardless of this setting:
# mlx_lm.load() detects the config and instantiates QuantizedLinear
# modules directly.
self._quantization: str | None = quantization
self._load_model()
# Pin MLX allocations to prevent OS paging
device_info = mx.device_info()
max_wired = int(device_info.get("max_recommended_working_set_size", 0))
if max_wired > 0:
mx.set_wired_limit(max_wired)
logger.info(f"Wired memory limit set to {max_wired / (1024**3):.1f} GB")
patch_model_attention(self.model)
layer_list, attn_attrs = find_attention_layers(self.model)
self._cache_layout = MlxModelCacheLayout.from_attention_discovery(
layer_list,
attn_attrs,
)
if self._cache_layout.num_attention_layers == 0:
raise RuntimeError("MLX model has no supported attention layers")
if self._cache_layout.has_auxiliary_state and not hasattr(
self.model, "make_cache"
):
raise RuntimeError(
"MLX models with auxiliary cache state require model.make_cache()."
)
if self._cache_layout.has_auxiliary_state:
self._model_embed, self._model_norm, self._model_lm_head = (
self._extract_model_components()
)
self._max_seq_len = 4096 # doubles on overflow
self._req_caches: dict[str, list[Any]] = {}
self._req_token_ids: dict[str, list[int]] = {}
self._cache_pool: list[list[Any]] = [] # reusable full-attention caches
self._attention_kv_pool: MlxAttentionKVPool | None = None
self._req_to_token_pool: ReqToTokenPool | None = None
self._req_pool_idx: dict[str, int] = {}
self._req_synced_offset: dict[str, int] = {}
self._pool_size = self._compute_pool_size(pool_size)
self._aot_kernels = self._build_aot_kernels()
@staticmethod
def _extract_logits(model_output):
"""Extract logits from model output, handling both tuple and direct returns."""
if isinstance(model_output, tuple):
return model_output[0]
return model_output
def _new_cache_skeleton(self) -> list[Any]:
"""Create a model-shaped cache list before attention cache wiring."""
if self._cache_layout.has_auxiliary_state:
cache = self.model.make_cache()
if len(cache) != self._cache_layout.num_layers:
raise RuntimeError(
"model.make_cache() returned "
f"{len(cache)} entries for {self._cache_layout.num_layers} layers"
)
else:
cache = [None] * self._cache_layout.num_layers
return cache
def _new_native_cache(self) -> list[Any]:
"""Create a model-shaped cache list with attention KV adapters."""
cache = self._new_cache_skeleton()
for layer_idx in self._cache_layout.attention_layer_indices:
cache[layer_idx] = ContiguousAttentionKVCache(max_seq_len=self._max_seq_len)
return cache
def _acquire_cache(self) -> list[Any]:
"""Get a reusable cache list from the pool, or create a new one."""
if not self._cache_layout.has_auxiliary_state and self._cache_pool:
cache = self._cache_pool.pop()
for c in cache:
c.offset = 0
return cache
return self._new_native_cache()
def _release_cache(self, cache: list[Any]) -> None:
"""Return a cache list to the pool for reuse."""
if not self._cache_layout.has_auxiliary_state:
self._cache_pool.append(cache)
def _first_attention_cache(self, cache: list[Any]) -> Any:
return cache[self._cache_layout.first_attention_layer_index]
def _get_auxiliary_state_pool_index(self, req_pool_idx: int) -> Any | None:
if (
not self._cache_layout.has_auxiliary_state
or self._req_to_token_pool is None
or not hasattr(self._req_to_token_pool, "get_auxiliary_state_indices")
):
return None
return self._req_to_token_pool.get_auxiliary_state_indices(req_pool_idx)
def _get_auxiliary_state_pool(self) -> Any | None:
return getattr(self._req_to_token_pool, "auxiliary_state_pool", None)
def _restore_auxiliary_state(self, req_pool_idx: int, cache: list[Any]) -> bool:
pool_index = self._get_auxiliary_state_pool_index(req_pool_idx)
pool = self._get_auxiliary_state_pool()
if pool_index is None or not hasattr(pool, "restore_cache"):
return False
return pool.restore_cache(
pool_index,
cache,
self._cache_layout.auxiliary_layer_indices,
)
def _store_auxiliary_state(self, req_pool_idx: int, cache: list[Any]) -> None:
pool_index = self._get_auxiliary_state_pool_index(req_pool_idx)
pool = self._get_auxiliary_state_pool()
if pool_index is None or not hasattr(pool, "store_cache"):
return
pool.store_cache(
pool_index,
cache,
self._cache_layout.auxiliary_layer_indices,
)
def store_auxiliary_state_for_request(self, req_id: str) -> None:
"""Snapshot native auxiliary state before scheduler-owned radix insert."""
req_pool_idx = self._req_pool_idx.get(req_id)
cache = self._req_caches.get(req_id)
if req_pool_idx is None or cache is None:
return
self._store_auxiliary_state(req_pool_idx, cache)
def _select_auxiliary_state_track_len(
self,
*,
prefix_len: int,
new_token_count: int,
full_len: int,
req: Any | None,
) -> int | None:
if (
not self._cache_layout.has_auxiliary_state
or req is None
or new_token_count <= 0
):
return None
chunk_size = get_server_args().mamba_cache_chunk_size
track_len = prefix_len + (new_token_count // chunk_size) * chunk_size
branching_len = getattr(req, "mamba_branching_seqlen", None)
if (
branching_len is not None
and prefix_len < branching_len <= prefix_len + new_token_count
and (branching_len - prefix_len) % chunk_size == 0
):
track_len = branching_len
if track_len <= prefix_len or track_len > full_len:
return None
return track_len
def _store_tracked_auxiliary_state(
self,
req: Any | None,
cache: list[Any],
track_len: int | None,
) -> None:
if (
req is None
or track_len is None
or not self._cache_layout.has_auxiliary_state
):
return
pool = self._get_auxiliary_state_pool()
if pool is None or not hasattr(pool, "store_cache"):
return
track_buffer = getattr(req, "mamba_ping_pong_track_buffer", None)
if track_buffer is None:
track_buffer = pool.alloc(1)
if track_buffer is None:
logger.warning(
"MLX auxiliary-state track slot allocation failed; "
"falling back to leaf-only auxiliary-state radix caching."
)
return
req.mamba_ping_pong_track_buffer = track_buffer
req.mamba_next_track_idx = 0
pool.store_cache(
track_buffer[0],
cache,
self._cache_layout.auxiliary_layer_indices,
)
req.mamba_last_track_seqlen = track_len
def _cache_with_pool_backed_attention(
self, prefix_slot_ids: list[int], prefix_len: int
) -> list[Any]:
assert self._attention_kv_pool is not None
slot_ids_mx = mx.array(prefix_slot_ids, dtype=mx.int32)
cache = self._new_cache_skeleton()
for layer_idx in self._cache_layout.attention_layer_indices:
cache[layer_idx] = PoolBackedAttentionKVCache(
self._attention_kv_pool,
self._cache_layout.attention_pool_index(layer_idx),
slot_ids_mx,
prefix_len,
)
return cache
def _materialize_pool_backed_attention(self, cache: list[Any]) -> list[Any]:
contiguous_cache = self._acquire_cache()
for layer_idx in self._cache_layout.attention_layer_indices:
pbc = cache[layer_idx]
contiguous_cache[layer_idx].update_and_fetch(
pbc._full_keys, pbc._full_values
)
for layer_idx in self._cache_layout.auxiliary_layer_indices:
contiguous_cache[layer_idx] = cache[layer_idx]
return contiguous_cache
@staticmethod
def _cache_arrays(cache: Any) -> list[mx.array]:
"""Return every MLX array nested under ``cache.state``."""
arrays: list[mx.array] = []
def collect(value: Any) -> None:
if isinstance(value, mx.array):
arrays.append(value)
elif value is None:
return
elif isinstance(value, (list, tuple)):
for item in value:
collect(item)
elif isinstance(value, dict):
for item in value.values():
collect(item)
collect(getattr(cache, "state", ()))
return arrays
@staticmethod
def _eval_with_cache(token_result: mx.array, cache: list[Any]) -> None:
"""Evaluate token result and all cache buffers in one mx.eval call."""
mx.eval(
token_result,
*[s for c in cache for s in MlxModelRunner._cache_arrays(c)],
)
@staticmethod
def _cache_state_arrays(pending_caches: list[list[Any]]) -> list[mx.array]:
"""Flatten pending decode cache state list into an array list.
Safe to hand to ``mx.async_eval``.
"""
return [
s
for cache_list in pending_caches
for cache in cache_list
for s in MlxModelRunner._cache_arrays(cache)
]
def _load_model(self):
"""Load model using mlx_lm. If ``self._quantization`` requests a preset
(e.g. ``mlx_q4``), quantize fp16 weights in-place via
:func:`mlx_lm.utils.quantize_model` after load.
"""
logger.info(f"Loading MLX model: {self.model_path}")
start_time = time.time()
# We need the config dict to pass into quantize_model so it knows tied/embedding
# layout. return_config=True is cheap and ignored when no quantization is requested.
loaded = mlx_lm_load(
self.model_path,
tokenizer_config={"trust_remote_code": self.trust_remote_code},
return_config=True,
)
self.model, _tokenizer, config = loaded
if self._quantization in _MLX_QUANTIZATION_PRESETS:
bits, group_size = _MLX_QUANTIZATION_PRESETS[self._quantization]
# Skip if the model was already loaded quantized (pre-quantized HF repo);
# mlx_lm.load detects the config and instantiates QuantizedLinear directly,
# so applying the preset on top would be redundant.
if "quantization" in (config or {}):
logger.info(
"MLX model is already quantized by the HF repo; "
f"ignoring --quantization={self._quantization}"
)
else:
# Read weight-tensor totals from MLX array metadata (shape + dtype).
# This is zero-cost — neither materializes the lazy fp16 weights nor
# forces them to be peak-resident in memory at once (which on a 64 GB
# Mac running a 32 B model would put us within a few GB of OOM).
bytes_before = sum(
p.size * p.itemsize
for _, p in tree_flatten(self.model.parameters())
)
q_start = time.time()
logger.info(
f"Quantizing MLX model on-the-fly: bits={bits} "
f"group_size={group_size} (preset={self._quantization})"
)
self.model, _new_config = mlx_lm_quantize_model(
self.model,
config or {},
group_size=group_size,
bits=bits,
)
bytes_after = sum(
p.size * p.itemsize
for _, p in tree_flatten(self.model.parameters())
)
q_time = time.time() - q_start
pct_reduction = (1 - bytes_after / max(bytes_before, 1)) * 100
logger.info(
f"Quantization complete in {q_time:.2f}s — "
f"weight bytes: {bytes_before / 1024**3:.2f} GB -> "
f"{bytes_after / 1024**3:.2f} GB ({pct_reduction:.1f}% reduction)"
)
# Force-evaluate weights so mx.get_active_memory() reflects
# actual usage before attention KV pool sizing.
mx.eval(self.model.parameters())
load_time = time.time() - start_time
logger.info(f"MLX model loaded in {load_time:.2f}s")
# Optional: Path B fusion — keep up_proj/gate_proj weights separate
# (no matmul-kernel tile regression) but fuse the swiglu activation
# into the gate matmul via a custom Metal kernel. Activated by
# SGLANG_MLX_FUSE_SWIGLU=1. Mutually exclusive with FUSE_SWITCHGLU.
# See: python/sglang/srt/hardware_backend/mlx/moe/fused_swiglu.py
if envs.SGLANG_MLX_FUSE_SWIGLU.get():
from sglang.srt.hardware_backend.mlx.moe.fused_swiglu import (
patch_switch_glu_with_fused_swiglu,
)
n_patched = patch_switch_glu_with_fused_swiglu(self.model)
logger.info(
f"MLX SwiGLU activation fusion enabled: patched {n_patched} blocks"
)
def _attention_module_for_layer(self, layer_idx: int) -> Any:
attn = getattr(
self._cache_layout.layers[layer_idx],
self._cache_layout.attention_attr(layer_idx),
)
if isinstance(attn, MLXAttentionWrapper):
return attn._inner
return attn
def _attention_kv_config_for_layer(
self, layer_idx: int
) -> tuple[int, int, mx.Dtype]:
layer = self._cache_layout.layers[layer_idx]
sample_attn = self._attention_module_for_layer(layer_idx)
if uses_sliding_window_attention(layer, sample_attn):
raise NotImplementedError(
"MLX radix attention KV pool does not support sliding-window "
f"attention yet at layer {layer_idx}. Sliding-window KV needs "
"per-layer/window-aware pools."
)
n_kv_heads = get_num_kv_heads(sample_attn)
if n_kv_heads is None:
raise RuntimeError(
f"Cannot determine n_kv_heads from attention module at layer {layer_idx}"
)
head_dim = get_head_dim(sample_attn)
if head_dim is None:
raise RuntimeError(
f"Cannot determine head_dim from attention module at layer {layer_idx}"
)
dtype = mx.float16
if hasattr(sample_attn, "k_proj") and hasattr(sample_attn.k_proj, "weight"):
dtype = sample_attn.k_proj.weight.dtype
if dtype not in _MLX_KV_FLOAT_DTYPES:
# QuantizedLinear packs weights as integers, but the KV cache
# stores dequantized projection outputs, which are produced in
# the compute dtype carried by the quantization scales. Storing
# at that dtype instead of float32 halves pool bytes per slot
# and keeps prefix-hit forwards in the same dtype as the no-hit
# path (a float32 pool promoted every post-hit concat).
scales = getattr(sample_attn.k_proj, "scales", None)
if scales is not None and scales.dtype in _MLX_KV_FLOAT_DTYPES:
dtype = scales.dtype
else:
dtype = mx.float32
return n_kv_heads, head_dim, dtype
def _get_attn_config(self) -> tuple[int, int, mx.Dtype]:
"""Return the uniform attention KV config used by the shared MLX pool."""
if self._cache_layout.num_attention_layers == 0:
raise RuntimeError(
"Cannot determine attention config: no attention module found"
)
first_layer_idx = self._cache_layout.first_attention_layer_index
first_config = self._attention_kv_config_for_layer(first_layer_idx)
for layer_idx in self._cache_layout.attention_layer_indices[1:]:
config = self._attention_kv_config_for_layer(layer_idx)
if config != first_config:
raise NotImplementedError(
"MLX radix attention KV pool requires uniform softmax-attention "
"KV shape across layers. "
f"Layer {first_layer_idx} has {first_config}, "
f"but layer {layer_idx} has {config}. "
"Heterogeneous attention KV or sliding-window KV needs "
"per-layer pools."
)
return first_config
def _compute_pool_size(self, explicit_size: int | None) -> int:
"""Determine pool slot count (auto-size from available memory if needed)."""
if explicit_size is not None:
return explicit_size
n_kv_heads, head_dim, dtype = self._get_attn_config()
num_layers = self._cache_layout.num_attention_layers
sys_available = psutil.virtual_memory().available
mlx_limit = mx.device_info().get(
"max_recommended_working_set_size",
mx.device_info().get("memory_size", 0),
)
mlx_used = mx.get_active_memory()
mlx_usable = int(mlx_limit * self._mem_fraction_static)
kv_budget = min(
max(mlx_usable - mlx_used, 0),
int(sys_available * self._mem_fraction_static),
)
bytes_per_slot = 2 * num_layers * n_kv_heads * head_dim * dtype.size
pool_size = max(kv_budget // bytes_per_slot, 256)
logger.info(
f"Auto-sized attention KV pool: "
f"sys_available={sys_available / (1024**3):.2f} GB, "
f"mlx_limit={mlx_limit / (1024**3):.1f} GB, "
f"mlx_used={mlx_used / (1024**3):.2f} GB, "
f"kv_budget={kv_budget / (1024**3):.2f} GB, "
f"bytes_per_slot={bytes_per_slot}, pool_size={pool_size}"
)
return pool_size
@property
def pool_size(self) -> int:
return self._pool_size
def _build_aot_kernels(self) -> MlxAOTKernelSet:
"""Build model-level set of optional registered AOT kernels."""
if self._cache_layout.num_attention_layers == 0:
return MlxAOTKernelSet()
layer_idx = self._cache_layout.first_attention_layer_index
sample_attn = getattr(
self._cache_layout.layers[layer_idx],
self._cache_layout.attention_attr(layer_idx),
)
n_kv_heads, head_dim, _ = self._get_attn_config()
return MLX_AOT_KERNEL_REGISTRY.build_kernel_set(
sample_attn=sample_attn,
n_kv_heads=int(n_kv_heads),
head_dim=int(head_dim),
)
def init_cache_pools(self, req_to_token_pool: ReqToTokenPool | None) -> None:
"""Create attention KV pool (+1 for padding slot 0)."""
self._req_to_token_pool = req_to_token_pool
if self.disable_radix_cache:
return
n_kv_heads, head_dim, dtype = self._get_attn_config()
# +1 for padding slot 0
self._attention_kv_pool = MlxAttentionKVPool(
pool_size=self._pool_size + 1,
num_layers=self._cache_layout.num_attention_layers,
n_kv_heads=n_kv_heads,
head_dim=head_dim,
dtype=dtype,
)
logger.info(
f"Attention KV pool initialized: pool_size={self._pool_size} "
f"(buffer size {self._pool_size + 1} incl. padding slot 0), "
f"{self._cache_layout.num_attention_layers} attention layers, "
f"{n_kv_heads} kv_heads, {head_dim} head_dim"
)
def prefill(
self,
req_id: str,
new_token_ids: list[int],
full_token_ids: list[int],
prefix_slot_ids: list[int],
new_slot_ids: list[int],
req_pool_idx: int,
req: Any | None = None,
) -> int:
"""Prefill a request. Returns next_token_id."""
pending = self.prefill_start(
req_id=req_id,
new_token_ids=new_token_ids,
full_token_ids=full_token_ids,
prefix_slot_ids=prefix_slot_ids,
new_slot_ids=new_slot_ids,
req_pool_idx=req_pool_idx,
req=req,
)
self._eval_with_cache(pending.lazy_token, pending.cache)
return self.prefill_finalize(pending)
def extend(
self,
req_id: str,
new_token_ids: list[int],
new_slot_ids: list[int],
) -> int:
"""Continue prefill for a chunked request. Returns next_token_id."""
pending = self.extend_start(req_id, new_token_ids, new_slot_ids)
self._eval_with_cache(pending.lazy_token, self._req_caches[req_id])
return self.extend_finalize(pending)
def _sync_new_kv_to_pool(
self,
cache: list[Any],
cache_start: int,
slot_ids: list[int],
) -> None:
"""Sync attention KV from contiguous cache to pool at the given slots."""
if not slot_ids or self._attention_kv_pool is None:
return
end = cache_start + len(slot_ids)
slot_ids_mx = mx.array(slot_ids, dtype=mx.int32)
# TODO: Standardize ContiguousAttentionKVCache size to avoid transpose
# Transpose cache (1, n_kv_heads, S, head_dim) to pool (S, n_kv_heads, head_dim)
k_all = mx.stack(
[
cache[layer_idx].keys[0, :, cache_start:end, :].transpose(1, 0, 2)
for layer_idx in self._cache_layout.attention_layer_indices
]
)
v_all = mx.stack(
[
cache[layer_idx].values[0, :, cache_start:end, :].transpose(1, 0, 2)
for layer_idx in self._cache_layout.attention_layer_indices
]
)
self._attention_kv_pool.set_kv_all_layers(slot_ids_mx, k_all, v_all)
def _sync_decode_kv_to_pool(self, req_id: str) -> None:
"""Sync un-flushed decode KV for *req_id* to the shared pool."""
if self._attention_kv_pool is None or self._req_to_token_pool is None:
return
cache = self._req_caches.get(req_id)
if cache is None:
return
current_offset = self._first_attention_cache(cache).offset
synced_offset = self._req_synced_offset.get(req_id, 0)
if current_offset <= synced_offset:
return
req_pool_idx = self._req_pool_idx.get(req_id)
if req_pool_idx is None:
return
# Read slot IDs from scheduler's req_to_token_pool
slot_ids = (
self._req_to_token_pool.req_to_token[
req_pool_idx, synced_offset:current_offset
]
.to(dtype=int)
.tolist()
)
self._sync_new_kv_to_pool(cache, synced_offset, slot_ids)
self._req_synced_offset[req_id] = current_offset
def flush_all_decode_kv(self) -> None:
"""Sync all active requests' un-flushed decode KV to the pool."""
if self.disable_radix_cache or self._attention_kv_pool is None:
return
for req_id in list(self._req_caches.keys()):
self._sync_decode_kv_to_pool(req_id)
def decode_batch(
self,
req_ids: list[str],
) -> list[int]:
"""Decode one token per request."""
pending = self.decode_batch_start(req_ids)
# Evaluate lazy_tokens together with every affected cache buffer so
# the attention write-then-read ordering is materialised in one
# kernel submission.
cache_arrays = self._cache_state_arrays(pending.caches)
mx.eval(pending.lazy_tokens, *cache_arrays)
return self.decode_batch_finalize(pending)
def prefill_start(
self,
req_id: str,
new_token_ids: list[int],
full_token_ids: list[int],
prefix_slot_ids: list[int],
new_slot_ids: list[int],
req_pool_idx: int,
req: Any | None = None,
) -> MlxPendingPrefill:
"""Queue a prefill forward pass without evaluating.
Returns an :class:`MlxPendingPrefill` containing the lazy
next-token ``mx.array`` plus everything needed to commit the
request in :meth:`prefill_finalize`. The caller drives the GPU
by handing ``lazy_token`` (and cache state) to ``mx.async_eval``.
"""
prefix_len = len(prefix_slot_ids)
if req is not None:
req.mamba_last_track_seqlen = None
if self.disable_radix_cache:
cache = self._acquire_cache()
input_ids = mx.array([new_token_ids], dtype=mx.int32)
model_output = self.model(input_ids, cache=cache)
logits = self._extract_logits(model_output)
lazy_token = mx.argmax(logits[:, -1, :], axis=-1)
return MlxPendingPrefill(
lazy_token=lazy_token,
cache=cache,
req_id=req_id,
full_token_ids=list(full_token_ids),
req_pool_idx=req_pool_idx,
synced_offset=0,
)
assert self._attention_kv_pool is not None
new_token_count = len(new_token_ids)
track_len = self._select_auxiliary_state_track_len(
prefix_len=prefix_len,
new_token_count=new_token_count,
full_len=len(full_token_ids),
req=req,
)
if prefix_len > 0:
cache = self._cache_with_pool_backed_attention(prefix_slot_ids, prefix_len)
pool_backed_attention = True
restored_auxiliary_state = (
not self._cache_layout.has_auxiliary_state
or self._restore_auxiliary_state(req_pool_idx, cache)
)
if self._cache_layout.has_auxiliary_state and (
not restored_auxiliary_state or new_token_count == 0
):
# TODO(MLX): exact full-prefix hits need auxiliary state at
# prefix_len - 1 to recompute last-token logits. The unified
# tree stores state at the match boundary today, so use a
# full-prompt fallback for that edge while still syncing newly
# allocated attention KV below.
cache = self._acquire_cache()
input_ids = mx.array([full_token_ids or new_token_ids], dtype=mx.int32)
model_output = self.model(input_ids, cache=cache)
logits = self._extract_logits(model_output)
lazy_token = mx.argmax(logits[:, -1, :], axis=-1)
if new_slot_ids:
self._sync_new_kv_to_pool(cache, prefix_len, new_slot_ids)
return MlxPendingPrefill(
lazy_token=lazy_token,
cache=cache,
req_id=req_id,
full_token_ids=list(full_token_ids),
req_pool_idx=req_pool_idx,
synced_offset=prefix_len + len(new_slot_ids),
)
else:
cache = self._acquire_cache()
pool_backed_attention = False
if new_token_count > 0:
track_new_count = track_len - prefix_len if track_len is not None else None
if track_new_count is not None and 0 < track_new_count < new_token_count:
input_ids = mx.array([new_token_ids[:track_new_count]], dtype=mx.int32)
self.model(input_ids, cache=cache)
self._store_tracked_auxiliary_state(req, cache, track_len)
if pool_backed_attention:
cache = self._materialize_pool_backed_attention(cache)
pool_backed_attention = False
extend_tokens = new_token_ids[track_new_count:]
else:
extend_tokens = new_token_ids
else:
# Full cache hit - rerun last token to get next-token logits
extend_tokens = full_token_ids[-1:]
for c in cache:
c.offset = max(c.offset - 1, 0)
input_ids = mx.array([extend_tokens], dtype=mx.int32)
model_output = self.model(input_ids, cache=cache)
logits = self._extract_logits(model_output)
if track_len is not None and track_len == prefix_len + new_token_count:
self._store_tracked_auxiliary_state(req, cache, track_len)
last_logits = logits[:, -1, :]
lazy_token = mx.argmax(last_logits, axis=-1)
# Convert pool-backed attention KV to contiguous attention KV for decode.
# This appends a lazy slice-assign onto the forward graph; the
# arrays get materialised when the caller evaluates lazy_token.
if pool_backed_attention:
cache = self._materialize_pool_backed_attention(cache)
if new_slot_ids:
self._sync_new_kv_to_pool(cache, prefix_len, new_slot_ids)
return MlxPendingPrefill(
lazy_token=lazy_token,
cache=cache,
req_id=req_id,
full_token_ids=list(full_token_ids),
req_pool_idx=req_pool_idx,
synced_offset=prefix_len + len(new_slot_ids),
)
def prefill_finalize(self, pending: MlxPendingPrefill) -> int:
"""Materialise a pending prefill and commit per-request state.
Must be called *after* ``pending.lazy_token`` has been handed to
``mx.async_eval`` / ``mx.eval``. ``.item()`` here is blocking on
that specific lazy scalar.
"""
next_token = int(pending.lazy_token.item())
self._req_token_ids[pending.req_id] = list(pending.full_token_ids) + [
next_token
]
self._req_caches[pending.req_id] = pending.cache
self._req_pool_idx[pending.req_id] = pending.req_pool_idx
self._req_synced_offset[pending.req_id] = pending.synced_offset
self._store_auxiliary_state(pending.req_pool_idx, pending.cache)
return next_token
def extend_start(
self,
req_id: str,
new_token_ids: list[int],
new_slot_ids: list[int],
) -> MlxPendingExtend:
"""Queue chunked-prefill continuation without evaluating."""
assert (
req_id in self._req_caches
), f"extend_start called for unknown request {req_id}"
cache = self._req_caches[req_id]
input_ids = mx.array([new_token_ids], dtype=mx.int32)
model_output = self.model(input_ids, cache=cache)
logits = self._extract_logits(model_output)
lazy_token = mx.argmax(logits[:, -1, :], axis=-1)
if not self.disable_radix_cache and new_slot_ids:
synced = self._req_synced_offset[req_id]
self._sync_new_kv_to_pool(cache, synced, new_slot_ids)
new_synced_offset = synced + len(new_slot_ids)
else:
new_synced_offset = self._req_synced_offset.get(req_id, 0)
return MlxPendingExtend(
lazy_token=lazy_token,
req_id=req_id,
new_token_ids=list(new_token_ids),
new_synced_offset=new_synced_offset,
)
def extend_finalize(self, pending: MlxPendingExtend) -> int:
"""Materialise a pending extend and commit per-request state."""
next_token = int(pending.lazy_token.item())
prev_tokens = self._req_token_ids[pending.req_id]
if prev_tokens:
prev_tokens.pop() # remove stale intermediate token
prev_tokens.extend(pending.new_token_ids)
prev_tokens.append(next_token)
self._req_synced_offset[pending.req_id] = pending.new_synced_offset
self._store_auxiliary_state(
self._req_pool_idx[pending.req_id],
self._req_caches[pending.req_id],
)
return next_token
def _extract_model_components(self):
"""Cache embedding, norm, and lm_head for layer-by-layer hybrid forward."""
root = getattr(self.model, "language_model", self.model)
text_model = getattr(root, "model", root)
embed = text_model.embed_tokens
norm = text_model.norm
if hasattr(root, "lm_head"):
lm_head = root.lm_head
elif hasattr(root, "args") and getattr(root.args, "tie_word_embeddings", False):
lm_head = text_model.embed_tokens.as_linear
else:
lm_head = root.lm_head
return embed, norm, lm_head
def _decode_with_hybrid_batching(
self,
caches: list[list[Any]],
batched_input: mx.array,
req_ids: list[str],
) -> mx.array:
"""Layer-by-layer hybrid decode for attention plus auxiliary state.
Attention layers run with batched hidden states via
``BatchedDecodeContext``. Auxiliary layers run batched when their
native cache implements mlx-lm's merge/extract protocol, otherwise
they fall back to per-request execution.
"""
batch_size = len(caches)
hidden_states = self._model_embed(batched_input)
ctx = self._build_batched_decode_context(caches, req_ids)
seq_lens = ctx.seq_lens
max_offset = max(seq_lens)
set_context(ctx)
try:
for layer_idx in range(self._cache_layout.num_layers):
layer = self._cache_layout.layers[layer_idx]
if self._cache_layout.attention_attrs[layer_idx] is not None:
shim = AttentionOffsetCache(offset=max_offset)
hidden_states = layer(hidden_states, mask=None, cache=shim)
else:
layer_caches = [caches[i][layer_idx] for i in range(batch_size)]
hidden_states = self._decode_auxiliary_layer(
layer,
hidden_states,
layer_caches,
)
finally:
clear_context()
hidden_states = self._model_norm(hidden_states)
logits = self._extract_logits(self._model_lm_head(hidden_states))
return mx.argmax(logits[:, -1, :], axis=-1)
def _decode_auxiliary_layer(
self,
layer: Any,
hidden_states: mx.array,
layer_caches: list[Any],
) -> mx.array:
"""Decode one auxiliary layer, batching when native cache supports it."""
if self._can_batch_auxiliary_layer(layer, layer_caches):
return self._decode_auxiliary_layer_batched(
layer,
hidden_states,
layer_caches,
)
results = []
for i, cache in enumerate(layer_caches):
results.append(layer(hidden_states[i : i + 1], mask=None, cache=cache))
return mx.concatenate(results, axis=0)
@staticmethod
def _can_batch_auxiliary_layer(layer: Any, layer_caches: list[Any]) -> bool:
"""Return whether an auxiliary layer can run with merged cache state.
Qwen3.5/Qwen3-Next DeltaNet layers use the mlx-lm DecoderLayer shape
below with ``ArraysCache``. Its ``merge``/``extract`` helpers can batch
native state temporarily and split it back to per-request cache objects.
"""
if not layer_caches:
return False
if not (
getattr(layer, "is_linear", False)
and hasattr(layer, "input_layernorm")
and hasattr(layer, "linear_attn")
and hasattr(layer, "post_attention_layernorm")
and hasattr(layer, "mlp")
):
return False
cache_type = type(layer_caches[0])
if not callable(getattr(cache_type, "merge", None)) or not all(
isinstance(cache, cache_type) and callable(getattr(cache, "extract", None))
for cache in layer_caches
):
return False
return True
@staticmethod
def _decode_auxiliary_layer_batched(
layer: Any,
hidden_states: mx.array,
layer_caches: list[Any],
) -> mx.array:
residual = hidden_states
normed = layer.input_layernorm(hidden_states)
batched_cache = MlxModelRunner._merge_auxiliary_caches(layer_caches)
mixed = layer.linear_attn(normed, mask=None, cache=batched_cache)
extract = getattr(batched_cache, "extract", None)
if not callable(extract):
raise RuntimeError(
f"{type(batched_cache).__name__}.merge() returned a cache "
"without extract(); cannot split auxiliary decode state"
)
for i, cache in enumerate(layer_caches):
split_cache = extract(i)
MlxModelRunner._replace_cache_contents(cache, split_cache)
hidden_states = residual + mixed
return hidden_states + layer.mlp(layer.post_attention_layernorm(hidden_states))
@staticmethod
def _merge_auxiliary_caches(layer_caches: list[Any]) -> Any:
if MlxModelRunner._can_fast_merge_arrays_cache(layer_caches):
return MlxModelRunner._fast_merge_arrays_cache(layer_caches)
return type(layer_caches[0]).merge(layer_caches)
@staticmethod
def _can_fast_merge_arrays_cache(layer_caches: list[Any]) -> bool:
cache_type = type(layer_caches[0])
if cache_type.__name__ != "ArraysCache":
return False
return all(
type(cache) is cache_type
and isinstance(getattr(cache, "cache", None), list)
and getattr(cache, "lengths", None) is None
and getattr(cache, "left_padding", None) is None
for cache in layer_caches
)
@staticmethod
def _fast_merge_arrays_cache(layer_caches: list[Any]) -> Any:
"""Merge mlx-lm ArraysCache with concat instead of zero+slice writes."""
cache_type = type(layer_caches[0])
merged = cache_type(len(layer_caches[0].cache))
slots = []
for slot_idx in range(len(layer_caches[0].cache)):
values = [cache.cache[slot_idx] for cache in layer_caches]
first = next((value for value in values if value is not None), None)
if first is None:
slots.append(None)
continue
slots.append(
mx.concatenate(
[
value if value is not None else mx.zeros_like(first)
for value in values
],
axis=0,
)
)
merged.cache = slots
return merged
@staticmethod
def _replace_cache_contents(cache: Any, new_cache: Any) -> None:
"""Replace cache contents while preserving the original cache object."""
if type(cache) is type(new_cache) and hasattr(cache, "__dict__"):
cache.__dict__.clear()
cache.__dict__.update(new_cache.__dict__)
return
if hasattr(cache, "state") and hasattr(new_cache, "state"):
cache.state = new_cache.state
return
raise RuntimeError(
f"Cannot copy {type(new_cache).__name__} state into "
f"{type(cache).__name__}"
)
def _decode_with_native_cache(
self,
caches: list[list[Any]],
input_ids_by_request: list[mx.array],
) -> mx.array:
lazy_token_list = []
for input_ids, cache in zip(input_ids_by_request, caches):
model_output = self.model(input_ids, cache=cache)
logits = self._extract_logits(model_output)
lazy_token_list.append(mx.argmax(logits[:, -1, :], axis=-1))
return (
lazy_token_list[0]
if len(lazy_token_list) == 1
else mx.concatenate(lazy_token_list, axis=0)
)
def _decode_with_batched_attention(
self,
caches: list[list[Any]],
batched_input: mx.array,
req_ids: list[str],
) -> mx.array:
ctx = self._build_batched_decode_context(caches, req_ids)
seq_lens = ctx.seq_lens
set_context(ctx)
try:
max_offset = max(seq_lens)
shim_cache = [
AttentionOffsetCache(offset=max_offset)
for _ in range(self._cache_layout.num_layers)
]
model_output = self.model(batched_input, cache=shim_cache)
logits = self._extract_logits(model_output)
return mx.argmax(logits[:, -1, :], axis=-1)
finally:
clear_context()
def _build_batched_decode_context(
self,
caches: list[list[Any]],
req_ids: list[str],
) -> BatchedDecodeContext:
"""Build the shared attention/AOT context for one decode step."""
return BatchedDecodeContext.from_decode(
caches=caches,
req_ids=req_ids,
aot_kernels=self._aot_kernels,
kv_pool=self._attention_kv_pool,
req_pool_idx=self._req_pool_idx,
req_to_token_pool=self._req_to_token_pool,
attention_layer_indices=self._cache_layout.attention_layer_indices,
attention_pool_index_by_layer=(
self._cache_layout.attention_pool_index_by_layer
),
)
def decode_batch_start(self, req_ids: list[str]) -> MlxPendingDecode:
"""Queue a decode forward pass without evaluating.
The caller is responsible for calling ``mx.async_eval`` on the
returned ``lazy_tokens`` (and optionally per-cache state arrays)
to kick off GPU work before :meth:`decode_batch_finalize`.
"""
caches = [self._req_caches[rid] for rid in req_ids]
last_tokens = [self._req_token_ids[rid][-1] for rid in req_ids]
batched_input = mx.array(last_tokens, dtype=mx.int32)[:, None]
if self._cache_layout.has_auxiliary_state:
lazy_tokens = self._decode_with_hybrid_batching(
caches, batched_input, list(req_ids)
)
else:
lazy_tokens = self._decode_with_batched_attention(
caches, batched_input, list(req_ids)
)
return MlxPendingDecode(
lazy_tokens=lazy_tokens,
req_ids=list(req_ids),
caches=caches,
)
def decode_batch_start_chained(
self,
prev: MlxPendingDecode,
) -> MlxPendingDecode:
"""Build the next decode step on top of a still-lazy previous decode.
Feeds ``prev.lazy_tokens`` (an unevaluated ``mx.array`` of shape
``(B,)``) as the next step's input ids, reusing
``prev.caches`` in-place so that per-layer attention KV writes from
step N and step N+1 land in the same buffers. MLX
tracks the full dependency graph, so once ``mx.async_eval`` is
called the GPU executes N+1 immediately after N with no gap.
Caller contract:
* ``prev`` MUST refer to the same set of requests (same order) as
the batch the caller intends to run next. Composition changes
(finished reqs, new prefills) must break the chain instead.
* After calling this, finalise ``prev`` BEFORE finalising the
returned pending: state bookkeeping for step N has to happen
before step N+1's bookkeeping.
"""
caches = prev.caches
# After prev's graph ran, each attention KV cache offset was
# bumped by one per layer - attention wrapper's `write_token`
# mutates the Python offset synchronously at graph-build time.
# So layer-0 offsets reflect the position the NEW token will
# be written at in step N+1 (and equivalently the RoPE offset).
batched_input = prev.lazy_tokens[:, None]
if self._cache_layout.has_auxiliary_state:
lazy_tokens = self._decode_with_hybrid_batching(
caches, batched_input, prev.req_ids
)
else:
lazy_tokens = self._decode_with_batched_attention(
caches, batched_input, prev.req_ids
)
return MlxPendingDecode(
lazy_tokens=lazy_tokens,
req_ids=prev.req_ids,
caches=caches,
)
def decode_batch_finalize(
self,
pending: MlxPendingDecode,
) -> list[int]:
"""Materialise a pending decode and update per-request token lists.
``pending.lazy_tokens.tolist()`` implicitly blocks until that
specific lazy array (and its graph ancestors, including the
per-request cache writes for this step) is evaluated. The
caller should have previously handed this pending's lazy_tokens
to ``mx.async_eval`` (or to a subsequent chained step that will
be async_eval'd).
"""
raw = pending.lazy_tokens.tolist()
if not isinstance(raw, list):
raw = [raw]
next_tokens = [int(t) for t in raw]
for i, rid in enumerate(pending.req_ids):
self._req_token_ids[rid].append(next_tokens[i])
self._decode_step_ct += 1
if self._clear_steps > 0 and self._decode_step_ct % self._clear_steps == 0:
mx.clear_cache()
return next_tokens
def has_request(self, req_id: str) -> bool:
"""Check if a request has active state."""
return req_id in self._req_caches
def remove_request(self, req_id: str):
"""Sync remaining decode KV to pool, then release request state."""
if not self.disable_radix_cache:
self._sync_decode_kv_to_pool(req_id)
self._req_token_ids.pop(req_id, None)
cache = self._req_caches.pop(req_id, None)
if cache is not None:
self._release_cache(cache)
self._req_pool_idx.pop(req_id, None)
self._req_synced_offset.pop(req_id, None)
def clear(self):
"""Clear all request states."""
self._req_token_ids.clear()
for cache in self._req_caches.values():
self._release_cache(cache)
self._req_caches.clear()
self._req_pool_idx.clear()
self._req_synced_offset.clear()
if self._attention_kv_pool is not None:
self._attention_kv_pool.clear()