[MLX] Support Qwen3.5 (dense) Model (#25754)

Signed-off-by: Xiaodong Ye <yeahdongcn@gmail.com>
Co-authored-by: Alex Nails <alex.nails@radixark.ai>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
R0CKSTAR
2026-05-30 17:05:02 +08:00
committed by GitHub
co-authored by Alex Nails Claude Opus 4.6
parent 7c5708cba7
commit a952e9174f
23 changed files with 2943 additions and 291 deletions
+1
View File
@@ -1,3 +1,4 @@
[settings]
profile=black
known_first_party=sglang
known_third_party=mlx,mlx_lm
+1
View File
@@ -19,6 +19,7 @@ dependencies = ["aiohttp", "requests", "tqdm", "numpy", "IPython", "setproctitle
runtime_common = [
"IPython",
"aiohttp",
"apache-tvm-ffi",
"anthropic>=0.20.0",
"blobfile==3.0.0",
"av",
+1 -1
View File
@@ -536,7 +536,7 @@ class _MlxBenchRunner:
if server_args.max_total_tokens is not None:
init_kwargs["pool_size"] = server_args.max_total_tokens
self.mlx_runner = MlxModelRunner(**init_kwargs)
self.mlx_runner.init_kv_pool(req_to_token_pool=None)
self.mlx_runner.init_cache_pools(req_to_token_pool=None)
self.fake_torch_runner = model_runner
def clear(self):
+3 -2
View File
@@ -164,6 +164,7 @@ from sglang.srt.utils import (
add_prometheus_track_response_middleware,
delete_directory,
get_bool_env_var,
is_mps,
kill_process_tree,
set_uvicorn_logging_configs,
)
@@ -1903,8 +1904,8 @@ def _execute_server_warmup(server_args: ServerArgs):
model_info = res.json()
# Construct a warmup request
is_vlm = bool(model_info.get("has_image_understanding", False))
# Construct a warmup request (MLX: text warmup for VLM-advertising models; TODO: enable image warmup).
is_vlm = bool(model_info.get("has_image_understanding", False)) and not is_mps()
if model_info["is_generation"]:
if is_vlm and not server_args.skip_tokenizer_init:
request_name = "/v1/chat/completions"
+11 -4
View File
@@ -13,8 +13,8 @@ from sglang.srt.environ import envs
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from sglang.srt.hardware_backend.mlx.kv_cache.contiguous_cache import (
ContiguousKVCache,
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_cache import (
ContiguousAttentionKVCache,
)
@@ -123,6 +123,10 @@ class MlxAOTKernelRegistry:
def _build_rope_kernel(inputs: MlxAOTKernelBuildInputs) -> MlxAOTRoPEKernel:
from sglang.srt.hardware_backend.mlx.kv_cache.attention_contract import (
get_num_heads,
)
sample_attn = getattr(inputs.sample_attn, "_inner", inputs.sample_attn)
rope = getattr(sample_attn, "rope", None)
if rope is None or getattr(rope, "traditional", False):
@@ -136,10 +140,13 @@ def _build_rope_kernel(inputs: MlxAOTKernelBuildInputs) -> MlxAOTRoPEKernel:
return MlxAOTRoPEKernel()
base = float(getattr(rope, "base", 10000.0))
num_qo_heads = get_num_heads(sample_attn)
if num_qo_heads is None:
return MlxAOTRoPEKernel()
config = {
"head_dim": int(inputs.head_dim),
"rope_dim": rope_dim,
"num_qo_heads": int(sample_attn.n_heads),
"num_qo_heads": int(num_qo_heads),
"num_kv_heads": int(inputs.n_kv_heads),
}
try:
@@ -196,7 +203,7 @@ class MlxAOTKernelContext:
req_ids: list[str],
req_pool_idx: dict[str, int],
req_to_token_pool: Any | None,
layer_caches: list[list[ContiguousKVCache]],
layer_caches: list[list[ContiguousAttentionKVCache]],
) -> "MlxAOTKernelContext":
"""Build optional AOT context for one batched decode step."""
if not aot_kernels.rope.enabled or kv_pool is None:
@@ -1,5 +1,20 @@
"""KV cache components for the MLX backend."""
"""Cache components for the MLX backend."""
from sglang.srt.hardware_backend.mlx.kv_cache.attention_contract import (
get_head_dim,
get_num_heads,
get_num_kv_heads,
is_attention_module,
uses_sliding_window_attention,
)
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_cache import (
AttentionOffsetCache,
ContiguousAttentionKVCache,
PoolBackedAttentionKVCache,
)
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_pool import (
MlxAttentionKVPool,
)
from sglang.srt.hardware_backend.mlx.kv_cache.attention_wrapper import (
BatchedDecodeContext,
MLXAttentionWrapper,
@@ -7,12 +22,12 @@ from sglang.srt.hardware_backend.mlx.kv_cache.attention_wrapper import (
get_context,
set_context,
)
from sglang.srt.hardware_backend.mlx.kv_cache.contiguous_cache import (
ContiguousKVCache,
OffsetCache,
PoolBackedCache,
from sglang.srt.hardware_backend.mlx.kv_cache.auxiliary_state import (
MlxAuxiliaryStateComponent,
MlxAuxiliaryStatePool,
MlxAuxiliaryStateReqToTokenPool,
)
from sglang.srt.hardware_backend.mlx.kv_cache.kv_pool import MlxKVPool
from sglang.srt.hardware_backend.mlx.kv_cache.layout import MlxModelCacheLayout
from sglang.srt.hardware_backend.mlx.kv_cache.model_patching import (
find_attention_layers,
get_num_layers,
@@ -22,14 +37,23 @@ from sglang.srt.hardware_backend.mlx.kv_cache.model_patching import (
__all__ = [
"BatchedDecodeContext",
"clear_context",
"ContiguousKVCache",
"AttentionOffsetCache",
"ContiguousAttentionKVCache",
"find_attention_layers",
"get_head_dim",
"get_context",
"get_num_layers",
"get_num_heads",
"get_num_kv_heads",
"is_attention_module",
"MLXAttentionWrapper",
"MlxKVPool",
"OffsetCache",
"MlxAttentionKVPool",
"MlxAuxiliaryStateComponent",
"MlxAuxiliaryStatePool",
"MlxAuxiliaryStateReqToTokenPool",
"MlxModelCacheLayout",
"patch_model_attention",
"PoolBackedCache",
"PoolBackedAttentionKVCache",
"set_context",
"uses_sliding_window_attention",
]
@@ -0,0 +1,66 @@
"""Attention helpers based on duck typing for the MLX backend."""
from __future__ import annotations
from typing import Any, Iterable
# ``rope`` and ``scale`` are required by MLXAttentionWrapper. Keeping them in
# the contract also prevents recurrent mixers such as DeltaNet from being
# mistaken for softmax attention just because they expose projection layers.
ATTENTION_API_ATTRS = ("q_proj", "k_proj", "v_proj", "o_proj", "rope", "scale")
NUM_HEAD_ATTRS = ("n_heads", "num_heads", "num_attention_heads")
NUM_KV_HEAD_ATTRS = ("n_kv_heads", "num_k_heads", "num_kv_heads", "num_key_value_heads")
SLIDING_ATTENTION_ATTRS = (
"is_sliding",
"use_sliding",
"is_sliding_window",
"use_sliding_window",
"is_swa",
)
def first_present_attr(module: Any, names: Iterable[str]) -> Any | None:
"""Return the first present attribute value without treating 0 as absent."""
for name in names:
if hasattr(module, name):
return getattr(module, name)
return None
def get_num_heads(module: Any) -> int | None:
return first_present_attr(module, NUM_HEAD_ATTRS)
def get_num_kv_heads(module: Any) -> int | None:
return first_present_attr(module, NUM_KV_HEAD_ATTRS)
def get_head_dim(module: Any) -> int | None:
head_dim = first_present_attr(module, ("head_dim",))
if head_dim is not None:
return head_dim
n_kv_heads = get_num_kv_heads(module)
if n_kv_heads is None:
return None
if hasattr(module, "hidden_size") and hasattr(module, "num_k_heads"):
return module.hidden_size // module.num_k_heads
if hasattr(module, "k_proj") and hasattr(module.k_proj, "weight"):
return module.k_proj.weight.shape[0] // n_kv_heads
return None
def is_attention_module(module: Any) -> bool:
return (
all(hasattr(module, attr) for attr in ATTENTION_API_ATTRS)
and get_num_heads(module) is not None
and get_num_kv_heads(module) is not None
)
def uses_sliding_window_attention(*modules: Any) -> bool:
return any(
bool(getattr(module, attr, False))
for module in modules
for attr in SLIDING_ATTENTION_ATTRS
)
@@ -1,4 +1,4 @@
"""ContiguousKVCache, PoolBackedCache and OffsetCache for MLX backend."""
"""Attention KV cache adapters for the MLX backend."""
from __future__ import annotations
@@ -7,10 +7,12 @@ from typing import TYPE_CHECKING
import mlx.core as mx
if TYPE_CHECKING:
from sglang.srt.hardware_backend.mlx.kv_cache.kv_pool import MlxKVPool
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_pool import (
MlxAttentionKVPool,
)
class OffsetCache:
class AttentionOffsetCache:
"""Data-free shim satisfying mlx-lm's cache protocol.
Provides ``make_mask`` and ``state`` without storing actual K/V.
@@ -27,14 +29,14 @@ class OffsetCache:
return None if N == 1 else "causal"
def update_and_fetch(self, keys, values):
raise RuntimeError("OffsetCache should not store data")
raise RuntimeError("AttentionOffsetCache should not store data")
_DEFAULT_MAX_SEQ_LEN = 4096
class ContiguousKVCache:
"""Pre-allocated KV buffer for one request × one layer.
class ContiguousAttentionKVCache:
"""Pre-allocated attention KV buffer for one request and one layer.
Shape ``(1, n_kv_heads, max_seq_len, head_dim)``. Slice assignment
instead of ``mx.concatenate``. Lazy-allocated on first write.
@@ -119,12 +121,12 @@ class ContiguousKVCache:
return self.keys[:, :, : self.offset, :], self.values[:, :, : self.offset, :]
class PoolBackedCache:
"""Lazily gathers cached KV from the shared pool during forward pass.
class PoolBackedAttentionKVCache:
"""Lazily gathers cached attention KV from the shared pool during forward.
Each ``update_and_fetch`` gathers this layer's prefix from the pool
on demand, keeping operations in the lazy compute graph. Convert to
``ContiguousKVCache`` via ``to_contiguous`` after the forward pass.
``ContiguousAttentionKVCache`` via ``to_contiguous`` after the forward pass.
"""
__slots__ = (
@@ -140,7 +142,7 @@ class PoolBackedCache:
def __init__(
self,
pool: MlxKVPool,
pool: MlxAttentionKVPool,
layer_idx: int,
slots: mx.array,
prefix_len: int,
@@ -197,9 +199,9 @@ class PoolBackedCache:
self._new_values = values
return k_all, v_all
def to_contiguous(self, max_seq_len: int = 4096) -> ContiguousKVCache:
"""Convert to ContiguousKVCache reusing forward-pass arrays."""
cache = ContiguousKVCache(max_seq_len=max_seq_len)
def to_contiguous(self, max_seq_len: int = 4096) -> ContiguousAttentionKVCache:
"""Convert to contiguous attention KV reusing forward-pass arrays."""
cache = ContiguousAttentionKVCache(max_seq_len=max_seq_len)
if self._full_keys is not None:
cache.update_and_fetch(self._full_keys, self._full_values)
return cache
@@ -1,4 +1,10 @@
"""Flat KV pool with per-layer buffers of shape (pool_size, n_kv_heads, head_dim).
"""Flat attention KV pool for the MLX backend.
Each layer buffer has shape ``(pool_size, n_kv_heads, head_dim)``.
This v1 pool is intentionally uniform: every wrapped softmax-attention
layer must share the same KV shape and full-context KV semantics.
Heterogeneous KV shapes and sliding-window KV need per-layer/window-aware
pools before they can use MLX radix reuse.
Slot 0 is reserved as padding (1-based indexing).
"""
@@ -10,8 +16,8 @@ import mlx.core as mx
logger = logging.getLogger(__name__)
class MlxKVPool:
"""Pre-allocated KV pool indexed by integer slot IDs."""
class MlxAttentionKVPool:
"""Pre-allocated attention KV pool indexed by integer slot IDs."""
def __init__(
self,
@@ -27,7 +33,7 @@ class MlxKVPool:
self.head_dim = head_dim
self.dtype = dtype
# Per-layer buffers: (pool_size, n_kv_heads, head_dim)
# Per-attention-layer buffers: (pool_size, n_kv_heads, head_dim)
self.k_buffer: list[mx.array] = [
mx.zeros((pool_size, n_kv_heads, head_dim), dtype=dtype)
for _ in range(num_layers)
@@ -41,8 +47,8 @@ class MlxKVPool:
1024 * 1024
)
logger.info(
f"MlxKVPool: {pool_size} slots × {num_layers} layers "
f"× {n_kv_heads} heads × {head_dim} dim, "
f"MlxAttentionKVPool: {pool_size} slots x {num_layers} layers "
f"x {n_kv_heads} heads x {head_dim} dim, "
f"dtype={dtype}, ~{mem_mb:.1f} MB"
)
@@ -14,7 +14,14 @@ from sglang.srt.hardware_backend.mlx.aot import (
MlxAOTKernelSet,
MlxAOTRoPEContext,
)
from sglang.srt.hardware_backend.mlx.kv_cache.contiguous_cache import ContiguousKVCache
from sglang.srt.hardware_backend.mlx.kv_cache.attention_contract import (
get_head_dim,
get_num_heads,
get_num_kv_heads,
)
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_cache import (
ContiguousAttentionKVCache,
)
_thread_local = threading.local()
@@ -26,8 +33,9 @@ class BatchedDecodeContext:
batch_size: int
seq_lens: list[int] # per-request token count before the new token
# layer_caches[layer_idx][req_idx] = ContiguousKVCache
layer_caches: list[list[ContiguousKVCache]]
# attention_layer_caches[attention_pool_idx][req_idx] = ContiguousAttentionKVCache
attention_layer_caches: list[list[ContiguousAttentionKVCache]]
attention_pool_index_by_layer: dict[int, int] = field(default_factory=dict)
# Optional AOT kernel state. Keep kernel-specific fields out of the regular
# MLX decode path so future AOT kernels can be added without growing this
@@ -51,36 +59,46 @@ class BatchedDecodeContext:
self.needs_padding = min(seq_lens) < max_seq_len
self.pad_sizes = [max_seq_len - s for s in seq_lens]
self.positions = mx.arange(self.max_len) if self.needs_padding else None
if not self.attention_pool_index_by_layer:
self.attention_pool_index_by_layer = {
idx: idx for idx in range(len(self.attention_layer_caches))
}
@classmethod
def from_decode(
cls,
*,
caches: list[list[ContiguousKVCache]],
num_layers: int,
caches: list[list[Any]],
req_ids: list[str],
aot_kernels: MlxAOTKernelSet,
kv_pool: Any | None,
req_pool_idx: dict[str, int],
req_to_token_pool: Any | None,
attention_layer_indices: list[int] | None = None,
attention_pool_index_by_layer: dict[int, int] | None = None,
) -> "BatchedDecodeContext":
batch_size = len(req_ids)
seq_lens = [caches[i][0].offset for i in range(batch_size)]
layer_caches = [
if attention_layer_indices is None:
attention_layer_indices = list(range(len(caches[0])))
seq_lens = [
caches[i][attention_layer_indices[0]].offset for i in range(batch_size)
]
attention_layer_caches = [
[caches[i][layer_idx] for i in range(batch_size)]
for layer_idx in range(num_layers)
for layer_idx in attention_layer_indices
]
return cls(
batch_size=batch_size,
seq_lens=seq_lens,
layer_caches=layer_caches,
attention_layer_caches=attention_layer_caches,
attention_pool_index_by_layer=attention_pool_index_by_layer or {},
aot=MlxAOTKernelContext.from_decode(
aot_kernels=aot_kernels,
kv_pool=kv_pool,
req_ids=req_ids,
req_pool_idx=req_pool_idx,
req_to_token_pool=req_to_token_pool,
layer_caches=layer_caches,
layer_caches=attention_layer_caches,
),
)
@@ -119,15 +137,38 @@ class MLXAttentionWrapper(nn.Module):
inner = self._inner
layer_idx = self._layer_idx
B = ctx.batch_size
n_heads = get_num_heads(inner)
n_kv_heads = get_num_kv_heads(inner)
if n_heads is None or n_kv_heads is None:
raise RuntimeError(
f"Cannot determine attention head counts for {type(inner).__name__}"
)
queries = inner.q_proj(x)
q_proj_output = inner.q_proj(x)
keys = inner.k_proj(x)
values = inner.v_proj(x)
head_dim = queries.shape[-1] // inner.n_heads
queries = queries.reshape(B, 1, inner.n_heads, head_dim)
keys = keys.reshape(B, 1, inner.n_kv_heads, head_dim)
values = values.reshape(B, 1, inner.n_kv_heads, head_dim)
head_dim = get_head_dim(inner)
if head_dim is None:
head_dim = keys.shape[-1] // n_kv_heads
q_width = n_heads * head_dim
gate = None
if q_proj_output.shape[-1] == q_width:
queries = q_proj_output.reshape(B, 1, n_heads, head_dim)
elif q_proj_output.shape[-1] == 2 * q_width:
queries, gate = mx.split(
q_proj_output.reshape(B, 1, n_heads, 2 * head_dim), 2, axis=-1
)
gate = gate.reshape(B, 1, q_width)
else:
raise RuntimeError(
f"Unexpected q_proj output shape {q_proj_output.shape} for "
f"{type(inner).__name__}"
)
keys = keys.reshape(B, 1, n_kv_heads, head_dim)
values = values.reshape(B, 1, n_kv_heads, head_dim)
if hasattr(inner, "q_norm"):
queries = inner.q_norm(queries)
@@ -140,6 +181,7 @@ class MLXAttentionWrapper(nn.Module):
# Vectorized RoPE with per-batch offsets (cached on the context).
offsets = ctx.offsets
attention_pool_idx = ctx.attention_pool_index_by_layer[layer_idx]
if ctx.aot.rope is not None:
# AOT path: real .metallib RoPE + fused KV pool scatter.
@@ -148,7 +190,7 @@ class MLXAttentionWrapper(nn.Module):
keys,
values,
offsets,
layer_idx,
attention_pool_idx,
ctx.aot.rope,
)
else:
@@ -157,7 +199,7 @@ class MLXAttentionWrapper(nn.Module):
queries = inner.rope(queries, offset=offsets)
keys = inner.rope(keys, offset=offsets)
layer_caches = ctx.layer_caches[layer_idx]
layer_caches = ctx.attention_layer_caches[attention_pool_idx]
pad_sizes = ctx.pad_sizes
# TODO: replace per-request loop with native batched/ragged
@@ -173,12 +215,8 @@ class MLXAttentionWrapper(nn.Module):
pad = pad_sizes[i]
if pad > 0:
k_pad = mx.zeros(
(1, inner.n_kv_heads, pad, head_dim), dtype=k_all.dtype
)
v_pad = mx.zeros(
(1, inner.n_kv_heads, pad, head_dim), dtype=v_all.dtype
)
k_pad = mx.zeros((1, n_kv_heads, pad, head_dim), dtype=k_all.dtype)
v_pad = mx.zeros((1, n_kv_heads, pad, head_dim), dtype=v_all.dtype)
k_all = mx.concatenate([k_all, k_pad], axis=2)
v_all = mx.concatenate([v_all, v_pad], axis=2)
@@ -202,6 +240,8 @@ class MLXAttentionWrapper(nn.Module):
)
output = output.transpose(0, 2, 1, 3).reshape(B, 1, -1)
if gate is not None:
output = output * mx.sigmoid(gate)
return inner.o_proj(output)
@staticmethod
@@ -210,7 +250,7 @@ class MLXAttentionWrapper(nn.Module):
keys: mx.array,
values: mx.array,
positions: mx.array,
layer_idx: int,
attention_pool_idx: int,
rope_ctx: MlxAOTRoPEContext,
) -> tuple[mx.array, mx.array]:
"""AOT path: rotate Q/K and scatter K/V into the shared pool.
@@ -234,8 +274,8 @@ class MLXAttentionWrapper(nn.Module):
else:
slots = rope_ctx.new_token_slots.astype(mx.int32)
k_pool = rope_ctx.kv_pool.k_buffer[layer_idx]
v_pool = rope_ctx.kv_pool.v_buffer[layer_idx]
k_pool = rope_ctx.kv_pool.k_buffer[attention_pool_idx]
v_pool = rope_ctx.kv_pool.v_buffer[attention_pool_idx]
q_rot, k_rot, k_pool_new, v_pool_new = rope_ctx.kernel.rope_pool_fused(
q_flat,
@@ -251,8 +291,8 @@ class MLXAttentionWrapper(nn.Module):
rope_base=rope_ctx.kernel.base,
)
# Rebind pool buffers (zero-copy donation result).
rope_ctx.kv_pool.k_buffer[layer_idx] = k_pool_new
rope_ctx.kv_pool.v_buffer[layer_idx] = v_pool_new
rope_ctx.kv_pool.k_buffer[attention_pool_idx] = k_pool_new
rope_ctx.kv_pool.v_buffer[attention_pool_idx] = v_pool_new
# (B, n_heads, head_dim) -> (B, n_heads, 1, head_dim) for SDPA path
return q_rot[:, :, None, :], k_rot[:, :, None, :]
@@ -0,0 +1,390 @@
"""MLX auxiliary-state snapshots for unified radix cache.
Hybrid MLX models may include non-softmax-attention layers whose native
``mlx-lm`` cache state cannot be reconstructed from the attention KV pool.
The global scheduler exposes that state through its existing MAMBA component
contract, so this MLX adapter keeps those scheduler-facing field names while
storing model-agnostic native cache snapshots.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable, Optional
import mlx.core as mx
import torch
from sglang.srt.mem_cache.base_prefix_cache import EvictParams, InsertResult
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.mem_cache.unified_cache_components.mamba_component import (
MambaComponent,
)
from sglang.srt.mem_cache.unified_cache_components.tree_component import TreeComponent
_CACHE_ATTRS = ("offset", "lengths", "left_padding")
_MISSING = object()
def _clone_tree(value: Any) -> Any:
if isinstance(value, mx.array):
return mx.array(value)
if isinstance(value, list):
return [_clone_tree(item) for item in value]
if isinstance(value, tuple):
return tuple(_clone_tree(item) for item in value)
if isinstance(value, dict):
return {key: _clone_tree(item) for key, item in value.items()}
return value
def _arrays_in_tree(value: Any) -> list[mx.array]:
arrays: list[mx.array] = []
def collect(item: Any) -> None:
if isinstance(item, mx.array):
arrays.append(item)
elif isinstance(item, (list, tuple)):
for child in item:
collect(child)
elif isinstance(item, dict):
for child in item.values():
collect(child)
collect(value)
return arrays
@dataclass
class _CacheSnapshot:
state: Any
meta_state: Any = _MISSING
attrs: dict[str, Any] | None = None
def _snapshot_cache(cache: Any) -> _CacheSnapshot:
state = _clone_tree(getattr(cache, "state", ()))
meta_state = (
_clone_tree(cache.meta_state) if hasattr(cache, "meta_state") else _MISSING
)
attrs = {
name: _clone_tree(getattr(cache, name))
for name in _CACHE_ATTRS
if hasattr(cache, name)
}
arrays = _arrays_in_tree((state, meta_state, attrs))
if arrays:
mx.eval(*arrays)
return _CacheSnapshot(state=state, meta_state=meta_state, attrs=attrs)
def _restore_cache(cache: Any, snapshot: _CacheSnapshot) -> None:
cache.state = _clone_tree(snapshot.state)
if snapshot.meta_state is not _MISSING and hasattr(cache, "meta_state"):
cache.meta_state = _clone_tree(snapshot.meta_state)
for name, value in (snapshot.attrs or {}).items():
setattr(cache, name, _clone_tree(value))
class MlxAuxiliaryStatePool:
"""Index-addressable snapshots of native MLX auxiliary cache state."""
def __init__(self, size: int, device: str):
self.size = size
self.device = device
self.mamba_cache = None
self.mem_usage = 0
self._snapshots: dict[int, dict[int, _CacheSnapshot]] = {}
self.clear()
def _tensor(self, indices: Any) -> torch.Tensor:
return torch.as_tensor(indices, dtype=torch.int64, device=self.device).view(-1)
def _index(self, index: Any) -> int:
flat = self._tensor(index)
assert flat.numel() == 1
return int(flat.item())
def available_size(self) -> int:
return int(self.free_slots.numel())
def alloc(self, need_size: int) -> Optional[torch.Tensor]:
if need_size > self.available_size():
return None
slots = self.free_slots[:need_size].clone()
self.free_slots = self.free_slots[need_size:]
for slot in slots.tolist():
self._snapshots.pop(int(slot), None)
return slots
def free(self, indices: Any) -> None:
if indices is None:
return
indices = self._tensor(indices)
if indices.numel() == 0:
return
for slot in indices.tolist():
self._snapshots.pop(int(slot), None)
self.free_slots = torch.cat([self.free_slots, indices])
def clear(self) -> None:
self.free_slots = torch.arange(
1, self.size + 1, dtype=torch.int64, device=self.device
)
self._snapshots.clear()
def copy_from(self, src: Any, dst: Any) -> None:
src_indices = self._tensor(src)
dst_indices = self._tensor(dst)
assert src_indices.numel() == dst_indices.numel()
for src_idx, dst_idx in zip(src_indices.tolist(), dst_indices.tolist()):
snapshot = self._snapshots.get(int(src_idx))
if snapshot is None:
self._snapshots.pop(int(dst_idx), None)
else:
self._snapshots[int(dst_idx)] = {
layer_idx: _CacheSnapshot(
state=_clone_tree(cache_snapshot.state),
meta_state=_clone_tree(cache_snapshot.meta_state),
attrs=_clone_tree(cache_snapshot.attrs),
)
for layer_idx, cache_snapshot in snapshot.items()
}
def fork_from(self, src: Any) -> Optional[torch.Tensor]:
src_indices = self._tensor(src)
dst = self.alloc(src_indices.numel())
if dst is None:
return None
self.copy_from(src_indices, dst)
return dst
def store_cache(
self,
index: Any,
cache: list[Any],
layer_indices: Iterable[int],
) -> None:
self._snapshots[self._index(index)] = {
layer_idx: _snapshot_cache(cache[layer_idx]) for layer_idx in layer_indices
}
def restore_cache(
self,
index: Any,
cache: list[Any],
layer_indices: Iterable[int] | None = None,
) -> bool:
snapshot = self._snapshots.get(self._index(index))
if snapshot is None:
return False
selected_layers = set(layer_indices) if layer_indices is not None else None
for layer_idx, cache_snapshot in snapshot.items():
if selected_layers is not None and layer_idx not in selected_layers:
continue
_restore_cache(cache[layer_idx], cache_snapshot)
return True
def has_snapshot(self, index: Any) -> bool:
return self._index(index) in self._snapshots
class MlxAuxiliaryStateReqToTokenPool(ReqToTokenPool):
"""Req-to-token pool with MLX auxiliary-state slot bookkeeping."""
def __init__(
self,
*,
size: int,
max_context_len: int,
device: str,
enable_memory_saver: bool,
auxiliary_state_size: int,
):
super().__init__(
size=size,
max_context_len=max_context_len,
device=device,
enable_memory_saver=enable_memory_saver,
)
self.mamba_pool = MlxAuxiliaryStatePool(
size=auxiliary_state_size,
device=device,
)
# The unified radix base MAMBA component still reads ``mamba_pool``.
# Keep the MLX-owned name beside it so local code can avoid model-
# specific terminology.
self.auxiliary_state_pool = self.mamba_pool
self.enable_mamba_extra_buffer = False
self.req_index_to_auxiliary_state_index_mapping = torch.zeros(
self._alloc_size, dtype=torch.int32, device=device
)
def alloc(self, reqs):
select_index = super().alloc(reqs)
if select_index is None:
return None
auxiliary_state_indices = []
for req in reqs:
if getattr(req, "mamba_pool_idx", None) is not None:
mid = req.mamba_pool_idx
else:
allocated = self.auxiliary_state_pool.alloc(1)
assert allocated is not None, "Not enough MLX auxiliary state slots"
mid = allocated[0]
req.mamba_pool_idx = mid
auxiliary_state_indices.append(mid.to(dtype=torch.int32))
self.req_index_to_auxiliary_state_index_mapping[select_index] = torch.stack(
auxiliary_state_indices
)
return select_index
def get_auxiliary_state_indices(self, req_indices) -> torch.Tensor:
return self.req_index_to_auxiliary_state_index_mapping[req_indices]
def get_mamba_indices(self, req_indices) -> torch.Tensor:
return self.get_auxiliary_state_indices(req_indices)
def get_mamba_ping_pong_other_idx(self, mamba_next_track_idx: int) -> int:
return 0
def free_mamba_cache(self, req, mamba_ping_pong_track_buffer_to_keep=None):
if getattr(req, "mamba_pool_idx", None) is not None:
self.auxiliary_state_pool.free(req.mamba_pool_idx.unsqueeze(0))
req.mamba_pool_idx = None
track_buffer = getattr(req, "mamba_ping_pong_track_buffer", None)
if track_buffer is not None:
if mamba_ping_pong_track_buffer_to_keep is None:
self.auxiliary_state_pool.free(track_buffer)
req.mamba_ping_pong_track_buffer = None
req.mamba_next_track_idx = None
def free_auxiliary_state_cache(self, req, track_buffer_to_keep=None):
self.free_mamba_cache(
req,
mamba_ping_pong_track_buffer_to_keep=track_buffer_to_keep,
)
def free(self, req):
super().free(req)
def clear(self):
super().clear()
self.auxiliary_state_pool.clear()
self.req_index_to_auxiliary_state_index_mapping.zero_()
class MlxAuxiliaryStateComponent(MambaComponent):
"""Unified radix component for MLX native auxiliary-state snapshots."""
def __init__(self, cache, params):
if params.enable_mamba_extra_buffer:
raise NotImplementedError(
"MLX auxiliary-state radix cache does not support "
"enable_mamba_extra_buffer yet."
)
pool = getattr(cache.req_to_token_pool, "auxiliary_state_pool", None)
if not isinstance(pool, MlxAuxiliaryStatePool):
raise TypeError(
"MlxAuxiliaryStateComponent requires MlxAuxiliaryStatePool, "
f"got {type(pool)}"
)
TreeComponent.__init__(self, cache, params)
self.enable_mamba_extra_buffer = False
self._mamba_pool_host = None
@staticmethod
def _tracked_value(req) -> tuple[object | None, bool]:
track_buffer = getattr(req, "mamba_ping_pong_track_buffer", None)
track_len = getattr(req, "mamba_last_track_seqlen", None)
if track_buffer is not None and track_len is not None:
return track_buffer[0].unsqueeze(-1).clone(), True
if getattr(req, "mamba_pool_idx", None) is None:
return None, False
return req.mamba_pool_idx.unsqueeze(-1).clone(), False
def prepare_for_caching_req(
self,
req,
insert_params,
token_ids_len: int,
is_finished: bool,
) -> int | None:
cache_len = getattr(req, "mamba_last_track_seqlen", None)
auxiliary_value, uses_track_slot = self._tracked_value(req)
setattr(insert_params, "mlx_auxiliary_state_uses_track_slot", uses_track_slot)
if auxiliary_value is None:
return 0 if is_finished else None
if cache_len is None:
cache_len = token_ids_len
if is_finished:
insert_params.mamba_value = auxiliary_value
else:
source_value = auxiliary_value
forked_value = self.cache.req_to_token_pool.auxiliary_state_pool.fork_from(
source_value
)
if forked_value is None:
self.cache.evict(EvictParams(num_tokens=0, mamba_num=1))
forked_value = (
self.cache.req_to_token_pool.auxiliary_state_pool.fork_from(
source_value
)
)
assert forked_value is not None, "Can not alloc MLX auxiliary cache"
insert_params.mamba_value = forked_value
return cache_len
def cleanup_after_caching_req(
self,
req,
is_finished: bool,
insert_result: InsertResult | None = None,
insert_params=None,
) -> None:
if not is_finished:
if (
insert_params is not None
and insert_params.mamba_value is not None
and (insert_result is None or insert_result.mamba_exist)
):
self.cache.req_to_token_pool.auxiliary_state_pool.free(
insert_params.mamba_value
)
if bool(
getattr(insert_params, "mlx_auxiliary_state_uses_track_slot", False)
):
track_buffer = getattr(req, "mamba_ping_pong_track_buffer", None)
if track_buffer is not None:
self.cache.req_to_token_pool.auxiliary_state_pool.free(track_buffer)
req.mamba_ping_pong_track_buffer = None
req.mamba_next_track_idx = None
req.mamba_last_track_seqlen = None
return
auxiliary_value_exists = (
insert_result.mamba_exist if insert_result is not None else True
)
uses_track_slot = bool(
getattr(insert_params, "mlx_auxiliary_state_uses_track_slot", False)
)
if uses_track_slot:
keep_track_slot = not auxiliary_value_exists
self.cache.req_to_token_pool.free_auxiliary_state_cache(
req,
track_buffer_to_keep=0 if keep_track_slot else None,
)
elif auxiliary_value_exists:
self.cache.req_to_token_pool.free_auxiliary_state_cache(req)
else:
# The radix tree now owns the live auxiliary-state slot.
track_buffer = getattr(req, "mamba_ping_pong_track_buffer", None)
if track_buffer is not None:
self.cache.req_to_token_pool.auxiliary_state_pool.free(track_buffer)
req.mamba_ping_pong_track_buffer = None
req.mamba_next_track_idx = None
req.mamba_pool_idx = None
req.mamba_last_track_seqlen = None
@@ -0,0 +1,93 @@
"""Model cache layout helpers for the MLX backend."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Sequence
@dataclass(frozen=True)
class MlxModelCacheLayout:
"""Map model layers to MLX cache storage components.
Attention layers store softmax-attention KV in the MLX attention KV pool.
Auxiliary layers keep native ``mlx-lm`` cache state and are snapshotted by
the MLX auxiliary-state component.
"""
layers: tuple[Any, ...]
attention_attrs: tuple[str | None, ...]
attention_layer_indices: tuple[int, ...]
auxiliary_layer_indices: tuple[int, ...]
attention_pool_index_by_layer: dict[int, int]
@classmethod
def from_attention_discovery(
cls,
layers: Sequence[Any],
attention_attrs: Sequence[str | None],
) -> "MlxModelCacheLayout":
if len(layers) != len(attention_attrs):
raise ValueError(
"Layer count and attention attribute count differ: "
f"{len(layers)} != {len(attention_attrs)}"
)
attention_layer_indices = tuple(
idx for idx, attr in enumerate(attention_attrs) if attr is not None
)
auxiliary_layer_indices = tuple(
idx for idx, attr in enumerate(attention_attrs) if attr is None
)
attention_pool_index_by_layer = {
layer_idx: pool_idx
for pool_idx, layer_idx in enumerate(attention_layer_indices)
}
return cls(
layers=tuple(layers),
attention_attrs=tuple(attention_attrs),
attention_layer_indices=attention_layer_indices,
auxiliary_layer_indices=auxiliary_layer_indices,
attention_pool_index_by_layer=attention_pool_index_by_layer,
)
@property
def num_layers(self) -> int:
return len(self.layers)
@property
def num_attention_layers(self) -> int:
return len(self.attention_layer_indices)
@property
def has_auxiliary_state(self) -> bool:
return bool(self.auxiliary_layer_indices)
@property
def first_attention_layer_index(self) -> int:
if not self.attention_layer_indices:
raise RuntimeError("MLX model has no supported attention layers")
return self.attention_layer_indices[0]
def attention_pool_index(self, layer_idx: int) -> int:
try:
return self.attention_pool_index_by_layer[layer_idx]
except KeyError as exc:
raise KeyError(f"Layer {layer_idx} is not an attention layer") from exc
def attention_attr(self, layer_idx: int) -> str:
attr = self.attention_attrs[layer_idx]
if attr is None:
raise KeyError(f"Layer {layer_idx} is not an attention layer")
return attr
def attention_layer_caches(
self,
caches_by_request: list[list[Any]],
) -> list[list[Any]]:
"""Return layer-major attention caches for batched decode."""
return [
[request_cache[layer_idx] for request_cache in caches_by_request]
for layer_idx in self.attention_layer_indices
]
@@ -2,25 +2,38 @@
from typing import Any
import mlx.nn as nn
from sglang.srt.hardware_backend.mlx.kv_cache.attention_contract import (
is_attention_module,
)
from sglang.srt.hardware_backend.mlx.kv_cache.attention_wrapper import (
MLXAttentionWrapper,
)
def find_attention_layers(model: Any) -> tuple[list[Any], str]:
"""Find transformer layers and the attention attribute name."""
def _find_attention_attr(layer: Any) -> str | None:
"""Return the direct child name that satisfies the attention contract."""
if not isinstance(layer, nn.Module):
raise TypeError(f"Expected mlx.nn.Module layer, got {type(layer)}")
for name, module in layer.children().items():
if isinstance(module, MLXAttentionWrapper) or is_attention_module(module):
return name
return None
def find_attention_layers(model: Any) -> tuple[list[Any], list[str | None]]:
"""Find transformer layers and per-layer attention attribute names."""
root = getattr(model, "language_model", model)
container = getattr(root, "model", root)
layer_list = getattr(container, "layers", None) or getattr(root, "layers", [])
if layer_list:
sample = layer_list[0]
if hasattr(sample, "self_attn"):
return layer_list, "self_attn"
if hasattr(sample, "attention"):
return layer_list, "attention"
raise ValueError(f"No attention attribute in layer type {type(sample)}")
return layer_list, "self_attn"
attn_attrs = [_find_attention_attr(layer) for layer in layer_list]
if any(attr is not None for attr in attn_attrs):
return layer_list, attn_attrs
raise ValueError(f"No attention attribute in layer type {type(layer_list[0])}")
return layer_list, []
def patch_model_attention(model: Any) -> int:
@@ -29,9 +42,11 @@ def patch_model_attention(model: Any) -> int:
The wrapper delegates to the inner module when no BatchedDecodeContext
is set, so it is always installed and never removed.
"""
layer_list, attn_attr = find_attention_layers(model)
layer_list, attn_attrs = find_attention_layers(model)
patched = 0
for idx, layer in enumerate(layer_list):
for idx, (layer, attn_attr) in enumerate(zip(layer_list, attn_attrs)):
if attn_attr is None:
continue
attn = getattr(layer, attn_attr)
if isinstance(attn, MLXAttentionWrapper):
continue
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,9 @@ from typing import Tuple
import torch
from sglang.srt.hardware_backend.mlx.kv_cache.auxiliary_state import (
MlxAuxiliaryStateReqToTokenPool,
)
from sglang.srt.mem_cache.allocator import TokenToKVPoolAllocator
from sglang.srt.mem_cache.memory_pool import KVCache, ReqToTokenPool
from sglang.srt.model_executor.model_runner import ModelRunner
@@ -17,11 +20,11 @@ logger = logging.getLogger(__name__)
class _DummyKVCache(KVCache):
"""A KV cache that allocates no GPU memory.
"""Scheduler-facing KV cache that allocates no GPU memory.
Satisfies the KVCache interface so that TokenToKVPoolAllocator can be
constructed, but every buffer access raises — the MLX backend manages
its own KV cache internally.
constructed, but every buffer access raises. The MLX backend manages
attention KV and auxiliary state internally.
"""
def __init__(self, size: int, dtype: torch.dtype, device: str):
@@ -42,16 +45,16 @@ class _DummyKVCache(KVCache):
self.custom_mem_pool = None
def get_key_buffer(self, layer_id: int) -> torch.Tensor:
raise RuntimeError("_DummyKVCache has no key buffer (MLX manages KV cache)")
raise RuntimeError("_DummyKVCache has no key buffer (MLX manages cache)")
def get_value_buffer(self, layer_id: int) -> torch.Tensor:
raise RuntimeError("_DummyKVCache has no value buffer (MLX manages KV cache)")
raise RuntimeError("_DummyKVCache has no value buffer (MLX manages cache)")
def get_kv_buffer(self, layer_id: int) -> Tuple[torch.Tensor, torch.Tensor]:
raise RuntimeError("_DummyKVCache has no kv buffer (MLX manages KV cache)")
raise RuntimeError("_DummyKVCache has no kv buffer (MLX manages cache)")
def set_kv_buffer(self, layer, loc, cache_k, cache_v) -> None:
raise RuntimeError("_DummyKVCache cannot set kv buffer (MLX manages KV cache)")
raise RuntimeError("_DummyKVCache cannot set kv buffer (MLX manages cache)")
def get_kv_size_bytes(self):
return 0, 0
@@ -141,6 +144,18 @@ class MlxModelRunnerStub(ModelRunner):
self.is_hybrid_swa = False
# Create minimal pools
if self.mambaish_config is not None:
auxiliary_state_size = self.server_args.max_mamba_cache_size
if auxiliary_state_size is None:
auxiliary_state_size = self.max_running_requests * 4
self.req_to_token_pool = MlxAuxiliaryStateReqToTokenPool(
size=self.max_running_requests,
max_context_len=self.model_config.context_len,
device="cpu",
enable_memory_saver=False,
auxiliary_state_size=auxiliary_state_size,
)
else:
self.req_to_token_pool = ReqToTokenPool(
size=self.max_running_requests,
max_context_len=self.model_config.context_len,
@@ -6,11 +6,11 @@ the scheduler runs its CPU-side bookkeeping on the tokens of the
older one. The lazy-graph primitives live in
``hardware_backend/mlx/tp_worker.py`` and ``model_runner.py``.
Each request's KV lives ina set of per-request, per-layer ``ContiguousKVCache``
objects that the ``MLXAttentionWrapper`` mutates in place during the forward pass.
Chained decodes reuse the same cache objects: step N+1's graph reads
step N's lazy writes via MLX's dependency tracking, so the GPU runs
both steps back-to-back with no idle gap.
Each request's attention KV lives in per-request, per-layer
``ContiguousAttentionKVCache`` objects that ``MLXAttentionWrapper`` mutates
in place during the forward pass. Chained decodes reuse the same cache objects:
step N+1's graph reads step N's lazy writes via MLX's dependency tracking, so
the GPU runs both steps back-to-back with no idle gap.
"""
from __future__ import annotations
@@ -62,6 +62,9 @@ class MlxPendingJob:
time. Decoupled from the live batch so
``process_batch_result`` can update request state without
racing against the next scheduling decision.
schedule_batch: The full scheduler batch. Unlike ``batch_copy``,
this keeps allocator/cache fields needed when a prefill batch
becomes the next running decode batch.
reqs: Snapshot of ``batch.reqs`` at launch time. The overlap
loop uses this to check ``req.finished()`` on the previous
step's request list without holding a reference to the
@@ -74,12 +77,27 @@ class MlxPendingJob:
decode: Optional["MlxPendingDecode"]
mode: str
batch_copy: "ScheduleBatch"
schedule_batch: "ScheduleBatch"
reqs: List[Req]
class SchedulerMlxOverlapMixin:
"""Mixin that adds MLX overlap scheduling to :class:`Scheduler`."""
def _finalize_mlx_pending_job(self: "Scheduler", pending: MlxPendingJob):
result = self.tp_worker.finalize_mlx_result(
pending.prefills,
pending.extends,
pending.decode,
pending.mode,
pending.reqs,
)
if result.next_token_ids is not None:
pending.batch_copy.input_ids = result.next_token_ids
pending.schedule_batch.input_ids = result.next_token_ids
self.last_batch = pending.schedule_batch
self.process_batch_result(pending.batch_copy, result)
@DynamicGradMode()
def event_loop_overlap_mlx(self: "Scheduler"):
"""MLX-specific overlap loop modelled on ``mlx_lm.generate.generate_step``.
@@ -123,18 +141,6 @@ class SchedulerMlxOverlapMixin:
pending_curr: Optional[MlxPendingJob] = None
pending_next: Optional[MlxPendingJob] = None
def _finalize(pending: MlxPendingJob):
result = self.tp_worker.finalize_mlx_result(
pending.prefills,
pending.extends,
pending.decode,
pending.mode,
pending.reqs,
)
if result.next_token_ids is not None:
pending.batch_copy.input_ids = result.next_token_ids
self.process_batch_result(pending.batch_copy, result)
def _launch_fresh(batch: "ScheduleBatch") -> MlxPendingJob:
lazy_tokens, prefills, extends, decode, mode = (
self.tp_worker.async_forward_batch_generation_mlx(batch)
@@ -146,6 +152,7 @@ class SchedulerMlxOverlapMixin:
decode=decode,
mode=mode,
batch_copy=batch.copy(),
schedule_batch=batch,
reqs=list(batch.reqs),
)
@@ -164,6 +171,7 @@ class SchedulerMlxOverlapMixin:
decode=decode,
mode=mode,
batch_copy=prev.batch_copy.copy(),
schedule_batch=prev.schedule_batch,
reqs=prev.reqs,
)
@@ -191,7 +199,7 @@ class SchedulerMlxOverlapMixin:
# 2. Finalize/process on pending_curr's tokens. (GPU is already
# executing pending_next at this point.)
if pending_curr is not None:
_finalize(pending_curr)
self._finalize_mlx_pending_job(pending_curr)
self.result_queue.popleft()
pending_curr = None
@@ -208,8 +216,8 @@ class SchedulerMlxOverlapMixin:
):
pending_curr = pending_next
pending_next = None
self.cur_batch = pending_curr.batch_copy
self.last_batch = pending_curr.batch_copy
self.cur_batch = pending_curr.schedule_batch
self.last_batch = pending_curr.schedule_batch
if envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_BUSY.get():
self.invariant_checker.self_check_during_busy()
continue
@@ -217,7 +225,7 @@ class SchedulerMlxOverlapMixin:
# 4. Chain is broken. Finalise pending_next (if any), then
# schedule fresh.
if pending_next is not None:
_finalize(pending_next)
self._finalize_mlx_pending_job(pending_next)
self.result_queue.popleft()
pending_next = None
next_batch = self.get_next_batch_to_run()
@@ -2,7 +2,7 @@
Routes forward passes through the MLX model runner, bypassing PyTorch
MPS. A lightweight stub provides scheduler bookkeeping; the actual
KV data lives in MlxKVPool.
attention KV data lives in MlxAttentionKVPool.
The worker also exposes an async (lazy-eval) surface used by the MLX
overlap scheduler: ``async_forward_batch_generation_mlx`` launches a
@@ -87,9 +87,9 @@ class MlxTpModelWorker(TpModelWorker):
return None
def _ensure_mlx_pool_initialized(self):
"""Lazily initialize the MlxKVPool after the stub's pools are ready."""
"""Lazily initialize MLX cache pools after the stub pools are ready."""
if not self._mlx_pool_initialized:
self._mlx_runner.init_kv_pool(self._model_runner.req_to_token_pool)
self._mlx_runner.init_cache_pools(self._model_runner.req_to_token_pool)
self._mlx_pool_initialized = True
def forward_batch_generation(
@@ -124,6 +124,14 @@ class MlxTpModelWorker(TpModelWorker):
else:
self._mlx_active_rids |= current_rids
def prepare_for_kv_cache_release(self, req) -> None:
"""Snapshot MLX auxiliary state at the scheduler's radix insert point."""
if self._mlx_runner.has_request(req.rid):
self._mlx_runner.store_auxiliary_state_for_request(req.rid)
# Prefer the just-snapshotted live auxiliary state for the final
# insert. Any older tracked slot is released during component cleanup.
req.mamba_last_track_seqlen = None
def _forward_batch_generation_mlx(
self, batch: ScheduleBatch
) -> GenerationBatchResult:
@@ -144,7 +152,7 @@ class MlxTpModelWorker(TpModelWorker):
next_token_ids_list: list[int] = []
if forward_mode.is_extend():
# Ensure pool is up-to-date before PoolBackedCache reads it
# Ensure pool is up-to-date before pool-backed attention reads it
# for prefix-cached prefills. Only runs on extend batches.
self._mlx_runner.flush_all_decode_kv()
input_ids_cpu = batch.input_ids.cpu().tolist()
@@ -185,6 +193,7 @@ class MlxTpModelWorker(TpModelWorker):
prefix_slot_ids=prefix_slot_ids,
new_slot_ids=req_new_slots,
req_pool_idx=req.req_pool_idx,
req=req,
)
prefill_rids.append((req.rid, next_token))
@@ -270,7 +279,7 @@ class MlxTpModelWorker(TpModelWorker):
if forward_mode.is_extend():
# TODO (changminbark): Implement per-batch flushing using prefix_slot_ids
# Ensure the pool is up-to-date before any PoolBackedCache
# Ensure the pool is up-to-date before pool-backed attention
# reads it for prefix-cached prefills. Mirror the sync path.
self._mlx_runner.flush_all_decode_kv()
return self._async_extend_batch(batch)
@@ -330,6 +339,7 @@ class MlxTpModelWorker(TpModelWorker):
prefix_slot_ids=prefix_slot_ids,
new_slot_ids=req_new_slots,
req_pool_idx=req.req_pool_idx,
req=req,
)
)
@@ -377,7 +387,23 @@ class MlxTpModelWorker(TpModelWorker):
@staticmethod
def _cache_state(cache_list) -> list[mx.array]:
"""Flatten a per-layer cache list to its ``state`` arrays."""
return [s for c in cache_list for s in c.state]
arrays: list[mx.array] = []
def collect(value):
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)
for cache in cache_list:
collect(getattr(cache, "state", ()))
return arrays
def async_chained_decode_mlx(
self,
@@ -828,6 +828,11 @@ class SchedulerBatchResultProcessor:
else:
if self.server_args.enable_hisparse:
self.hisparse_coordinator.request_finished(req)
prepare_release = getattr(
self.model_worker, "prepare_for_kv_cache_release", None
)
if callable(prepare_release):
prepare_release(req)
release_kv_cache(req, self.tree_cache)
req.time_stats.set_completion_time()
@@ -9,6 +9,9 @@ if TYPE_CHECKING:
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.mem_cache.unified_cache_components import ComponentType
from sglang.srt.mem_cache.unified_cache_components.tree_component import (
TreeComponent,
)
@dataclasses.dataclass
@@ -44,3 +47,6 @@ class CacheInitParams:
cache_ttl_seconds: Optional[float] = None
tree_components: Optional[tuple[ComponentType, ...]] = None
component_registry_override: Optional[dict[ComponentType, type[TreeComponent]]] = (
None
)
+10 -1
View File
@@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any, Callable, Optional
from sglang.srt.environ import envs
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
from sglang.srt.utils.tensor_bridge import use_mlx
if TYPE_CHECKING:
from sglang.srt.configs.model_config import ModelConfig
@@ -94,7 +95,7 @@ def default_radix_cache_factory(ctx: TreeCacheBuildContext) -> BasePrefixCache:
logger.info("Using experimental C++ radix tree implementation.")
return RadixCacheCpp(params=params, server_args=server_args)
if envs.SGLANG_ENABLE_UNIFIED_RADIX_TREE.get():
if envs.SGLANG_ENABLE_UNIFIED_RADIX_TREE.get() or use_mlx():
from sglang.srt.mem_cache.unified_cache_components import ComponentType
from sglang.srt.mem_cache.unified_radix_cache import UnifiedRadixCache
@@ -104,6 +105,14 @@ def default_radix_cache_factory(ctx: TreeCacheBuildContext) -> BasePrefixCache:
ComponentType.SWA if ctx.is_hybrid_swa else ComponentType.MAMBA
)
params.tree_components = tuple(tree_components)
if use_mlx() and ctx.is_hybrid_ssm:
from sglang.srt.hardware_backend.mlx.kv_cache.auxiliary_state import (
MlxAuxiliaryStateComponent,
)
params.component_registry_override = {
ComponentType.MAMBA: MlxAuxiliaryStateComponent,
}
cache = UnifiedRadixCache(params)
if ctx.enable_hierarchical_cache:
cache.init_hicache(server_args, params)
@@ -258,8 +258,14 @@ class UnifiedRadixCache(KVCacheEventMixin, BasePrefixCache):
assert params.tree_components is not None
self.tree_components = tuple(params.tree_components)
component_registry = COMPONENT_REGISTRY
if params.component_registry_override:
component_registry = {
**COMPONENT_REGISTRY,
**params.component_registry_override,
}
self.components: dict[ComponentType, TreeComponent] = {
ct: COMPONENT_REGISTRY[ct](self, params) for ct in self.tree_components
ct: component_registry[ct](self, params) for ct in self.tree_components
}
self._components_tuple: tuple[TreeComponent, ...] = tuple(
self.components.values()
File diff suppressed because it is too large Load Diff
@@ -41,8 +41,11 @@ from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool, SWATokenToKVPoolAllo
from sglang.srt.mem_cache.unified_cache_components.tree_component import (
CacheTransferPhase,
ComponentType,
EvictLayer,
TreeComponent,
)
from sglang.srt.mem_cache.unified_radix_cache import (
COMPONENT_REGISTRY,
UnifiedRadixCache,
UnifiedTreeNode,
)
@@ -119,6 +122,52 @@ class CacheConfig:
return "_".join(parts)
class _FakeFullComponent(TreeComponent):
component_type = ComponentType.FULL
def create_match_validator(self, match_device_only: bool = False):
return lambda node: True
def redistribute_on_node_split(self, new_parent, child):
return None
def evict_component(
self, node, target: EvictLayer = EvictLayer.DEVICE
) -> tuple[int, int]:
return 0, 0
def drive_eviction(self, params: EvictParams, tracker: dict[ComponentType, int]):
return None
def acquire_component_lock(self, node, result):
return result
def release_component_lock(self, node, params):
return None
class TestUnifiedRadixComponentRegistryOverride(CustomTestCase):
def test_component_registry_override_is_instance_local(self):
params = CacheInitParams(
req_to_token_pool=ReqToTokenPool(
size=2,
max_context_len=8,
device="cpu",
enable_memory_saver=False,
),
token_to_kv_pool_allocator=None,
page_size=1,
disable=True,
tree_components=(ComponentType.FULL,),
component_registry_override={ComponentType.FULL: _FakeFullComponent},
)
tree = UnifiedRadixCache(params=params)
self.assertIsInstance(tree.components[ComponentType.FULL], _FakeFullComponent)
self.assertIsNot(COMPONENT_REGISTRY[ComponentType.FULL], _FakeFullComponent)
def build_fixture(cfg: CacheConfig, *, enable_kv_cache_events: bool = False):
"""Create (tree, allocator, req_to_token_pool) from a CacheConfig."""
server_args = ServerArgs(model_path="dummy", page_size=cfg.page_size)