[MLX] Window-bounded SWA KV storage and in-graph sampling (#34166)

Co-authored-by: Siming Deng <siming_deng_stat@163.com>
Co-authored-by: R0CKSTAR <yeahdongcn@gmail.com>
Co-authored-by: Jiminator <Jiminator@users.noreply.github.com>
Co-authored-by: damahua <damahua@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Nails
2026-08-09 21:28:51 -07:00
committed by GitHub
co-authored by Siming Deng R0CKSTAR Jiminator damahua Claude Opus 5
parent 449f0da78f
commit 2969ab3d41
27 changed files with 3510 additions and 526 deletions
@@ -673,6 +673,12 @@ Please consult the documentation below and [server_args.py](https://github.com/s
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>The random seed.</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}><code>None</code></td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Type: int</td>
</tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`--mlx-enable-sampling`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>MLX backend only: sample decode tokens (temperature / top-k / top-p / min-p) instead of greedy argmax. Sampling runs inside the lazy MLX graph, so it works with the overlap scheduler; first tokens from prefill/extend are sampled too. Greedy requests keep exact argmax behavior. Also enables on the MLX path: grammar vocab masks and custom logit processors (these break decode chaining per step; custom processors run on pure-decode steps only), logit_bias, output logprobs (sampled token / top-k / token_ids; prompt input logprobs are not computed), NaN sanitization (SGLANG_SANITIZE_NAN_LOGITS), and per-request sampling_seed under --enable-deterministic-inference (deterministic within MLX only). Penalties are not applied.</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>`False`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>bool flag (set to enable)</td>
</tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`--constrained-json-whitespace-pattern`</td>
+7 -2
View File
@@ -61,6 +61,7 @@ from sglang.srt.utils.common import (
is_xpu,
xpu_has_xmx_support,
)
from sglang.srt.utils.tensor_bridge import use_mlx
logger = logging.getLogger(__name__)
@@ -933,8 +934,12 @@ def _gpt_oss_overrides(server_args: Any, hf_config: Any) -> dict:
overrides["attention_backend"] = "intel_xpu"
elif is_hip():
overrides["attention_backend"] = "aiter"
elif not is_mps():
# No triton on macOS; MPS keeps the platform default.
elif not (is_mps() and use_mlx()):
# No triton on macOS, but only the MLX runner can actually serve
# gpt-oss there -- it owns attention, so it keeps the platform
# default. macOS *without* MLX must still fall through to triton
# and fail fast below: torch_native has neither sliding-window nor
# attention-sink support, so accepting it would silently mis-serve.
overrides["attention_backend"] = "triton"
if is_xpu():
# Check for bf16 dtype on Intel XPU. Reads the pristine dtype request,
@@ -38,6 +38,7 @@ from sglang.srt.constrained.base_grammar_backend import (
)
from sglang.srt.constrained.utils import is_legacy_structural_tag
from sglang.srt.utils import is_hip
from sglang.srt.utils.common import is_pin_memory_available
_is_hip = is_hip()
@@ -58,14 +59,16 @@ MAX_ROLLBACK_TOKENS = 200
def _allocate_token_bitmask(vocab_size: int, batch_size: int) -> torch.Tensor:
# Always allocate a pinned bitmask so the later H2D to the device can be a
# genuine non_blocking copy (a pageable source silently downgrades it to a
# blocking copy).
# Allocate a pinned bitmask where pinning exists so the later H2D to the
# device can be a genuine non_blocking copy (a pageable source silently
# downgrades it to a blocking copy). MPS torch has no pin-memory kernel
# and asserts on pin_memory=True; the MLX path consumes the mask on the
# CPU anyway.
return torch.full(
get_bitmask_shape(batch_size, vocab_size),
-1,
dtype=bitmask_dtype,
pin_memory=True,
pin_memory=is_pin_memory_available(),
)
@@ -132,6 +135,12 @@ class XGrammarGrammar(BaseGrammarObject):
import sgl_kernel_npu # noqa: F401
torch.ops.npu.apply_token_bitmask(logits, vocab_mask)
elif logits.device.type == "cpu":
# Used by the MLX backend, which builds its additive mask rows
# on the CPU before inserting them into the lazy graph.
from xgrammar import apply_token_bitmask_inplace
apply_token_bitmask_inplace(logits, vocab_mask, backend="cpu")
else:
raise RuntimeError(f"Unsupported device: {logits.device.type}")
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable, Optional
from typing import Any, Callable, Optional
import mlx.core as mx
@@ -12,11 +12,6 @@ from sglang.srt.environ import envs
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_cache import (
ContiguousAttentionKVCache,
)
def _load_metal_rope_pool_fused():
try:
@@ -219,7 +214,10 @@ class MlxAOTKernelContext:
req_ids: list[str],
req_pool_idx: dict[str, int],
req_to_token_pool: Any | None,
layer_caches: list[list[ContiguousAttentionKVCache]],
# Only .offset is read (absolute on every cache kind) and the slot
# lookup is layer-agnostic; the wrapper gates the fused pool scatter
# to full-attention layers.
layer_caches: list[list[Any]],
) -> MlxAOTKernelContext:
"""Build optional AOT context for one batched decode step."""
if not aot_kernels.rope.enabled or kv_pool is None:
@@ -14,6 +14,7 @@ from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_cache import (
AttentionOffsetCache,
ContiguousAttentionKVCache,
PoolBackedAttentionKVCache,
WindowedAttentionKVCache,
make_attention_mask,
)
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_pool import (
@@ -64,4 +65,5 @@ __all__ = [
"PoolBackedAttentionKVCache",
"set_context",
"uses_sliding_window_attention",
"WindowedAttentionKVCache",
]
@@ -68,11 +68,15 @@ def is_attention_module(module: Any) -> bool:
)
def language_model_container(model: Any) -> Any:
"""The mlx-lm container carrying the layer list and model-level config."""
root = getattr(model, "language_model", model)
return getattr(root, "model", root)
def get_container_window_size(model: Any) -> int | None:
"""The container-level scalar sliding window, if the model declares one."""
root = getattr(model, "language_model", model)
container = getattr(root, "model", root)
return first_present_attr(container, WINDOW_SIZE_ATTRS)
return first_present_attr(language_model_container(model), WINDOW_SIZE_ATTRS)
def get_layer_window_sizes(model: Any) -> dict[int, int | None]:
@@ -84,10 +88,9 @@ def get_layer_window_sizes(model: Any) -> dict[int, int | None]:
``{layer_idx: window or None}``, or ``{}`` when the model does not
follow the convention.
"""
root = getattr(model, "language_model", model)
container = getattr(root, "model", root)
container = language_model_container(model)
layer_types = getattr(container, "layer_types", None)
window_size = get_container_window_size(model)
window_size = first_present_attr(container, WINDOW_SIZE_ATTRS)
if not layer_types or window_size is None:
return {}
return {
@@ -21,8 +21,19 @@ def make_attention_mask(N, offset, return_array=False, window_size=None):
layers pass it, including for N == 1) or windowed models silently fall
back to full attention.
"""
if window_size is not None:
if window_size is not None and offset + N > window_size:
return create_causal_mask(N, offset, window_size=window_size)
# Either no window, or a window that cannot bind. The lowest query
# position is ``offset``, so ``offset + N <= window_size`` means every
# causally visible key is inside the band and the banded mask is
# elementwise identical to a plain causal one -- the shortcut mlx_lm's own
# RotatingKVCache.make_mask takes, and these shims stand in for exactly
# that cache on sliding layers. Worth the branch because a materialised
# mask forces mx.fast.scaled_dot_product_attention off its fused causal
# path: ~2x slower per layer, plus an N x (offset + N) allocation.
# It also survives a window-bounded store: offset + N <= window_size
# implies N + window_size - 1 >= offset + N, so no keys are dropped and
# the mask width still matches the returned key length.
if N == 1:
return None
if return_array:
@@ -141,9 +152,130 @@ class ContiguousAttentionKVCache:
self.values[:, :, self.offset : end, :] = v
self.offset = end
def get_kv(self) -> tuple[mx.array, mx.array]:
"""Return valid K/V: (1, n_kv_heads, offset, head_dim)."""
return self.keys[:, :, : self.offset, :], self.values[:, :, : self.offset, :]
def get_kv(self, window: int | None = None) -> tuple[mx.array, mx.array]:
"""Return valid K/V: (1, n_kv_heads, min(offset, window), head_dim).
``window`` keeps only the trailing window a sliding-window layer can
attend to. Slicing here rather than slicing the full history and then
slicing again costs one op instead of two per request per layer.
"""
start = 0 if window is None else max(0, self.offset - window)
return (
self.keys[:, :, start : self.offset, :],
self.values[:, :, start : self.offset, :],
)
def reset(self) -> None:
"""Reset for reuse, keeping allocated buffers."""
self.offset = 0
class WindowedAttentionKVCache:
"""Sliding-window attention KV buffer for one request and one layer.
Holds the trailing ``window`` tokens plus the in-flight chunk, in
temporal order, instead of the full sequence. ``offset`` stays
absolute (RoPE positions, decode bookkeeping); the dropped prefix
shows up only in the shorter arrays returned by
``update_and_fetch``/``get_kv`` and in the mask offset ``make_mask``
clamps to, so mask width always equals returned key length.
"""
__slots__ = ("keys", "values", "offset", "window", "_local")
def __init__(self, window: int):
self.window = window
self.keys: mx.array | None = None
self.values: mx.array | None = None
self.offset = 0 # absolute: every token ever written
self._local = 0 # tokens currently in the buffer
@property
def state(self):
"""Arrays for ``mx.eval`` unpacking."""
if self.keys is None:
return ()
return (self.keys, self.values)
def reset(self) -> None:
"""Reset for reuse, keeping allocated buffers."""
self.offset = 0
self._local = 0
def make_mask(self, N, return_array=False, window_size=None, **kwargs):
kept = min(self._local, self.window)
if window_size is None and self.offset > kept:
raise RuntimeError(
"WindowedAttentionKVCache holds only the trailing window and "
"cannot serve a full-context attention mask"
)
# No N == 1 shortcut here, tempting as it looks: mlx_lm's banded mask is
# ``linds < rinds + window_size`` (strict), so a window of W admits
# exactly W keys. Once ``kept == window`` this buffer returns W + 1 of
# them -- the trailing window plus the token just written -- and the
# oldest must still be masked out.
return make_attention_mask(
N, kept, return_array=return_array, window_size=window_size
)
def _append(self, keys: mx.array, values: mx.array) -> tuple[int, int]:
"""Append a chunk in place; return the (start, end) span it serves.
Split out from ``update_and_fetch`` so the decode path can skip
building the two return slices, which its caller discards in
favour of ``get_kv``.
"""
S = keys.shape[2]
kept = min(self._local, self.window)
capacity = self.window + max(S, self.window)
held = self.keys.shape[2] if self.keys is not None else 0
if self._local + S > held or held > capacity:
# Compact the trailing window into a right-sized buffer: this
# allocates on the first write, drops history when the buffer
# fills (amortised O(1) per decode token), and shrinks back to
# 2 * window once an oversized prefill chunk is behind us.
B, n_kv_heads, _, head_dim = keys.shape
new_k = mx.zeros((B, n_kv_heads, capacity, head_dim), dtype=keys.dtype)
new_v = mx.zeros((B, n_kv_heads, capacity, head_dim), dtype=keys.dtype)
if kept:
src = slice(self._local - kept, self._local)
new_k[:, :, :kept, :] = self.keys[:, :, src, :]
new_v[:, :, :kept, :] = self.values[:, :, src, :]
self.keys, self.values, self._local = new_k, new_v, kept
start, end = self._local - kept, self._local + S
self.keys[:, :, self._local : end, :] = keys
self.values[:, :, self._local : end, :] = values
self._local = end
self.offset += S
return start, end
def update_and_fetch(
self, keys: mx.array, values: mx.array
) -> tuple[mx.array, mx.array]:
"""Append a chunk and return the kept trailing window plus the chunk.
The kept prefix is ``min(local, window)``, matching what
``make_mask`` clamps to earlier in the same forward pass.
"""
start, end = self._append(keys, values)
return self.keys[:, :, start:end, :], self.values[:, :, start:end, :]
def write_token(self, k: mx.array, v: mx.array) -> None:
"""Write one token. k, v shape: (1, n_kv_heads, 1, head_dim)."""
self._append(k, v)
def get_kv(self, window: int | None = None) -> tuple[mx.array, mx.array]:
"""Return buffered trailing K/V: (1, n_kv_heads, kept, head_dim).
``window`` mirrors :meth:`ContiguousAttentionKVCache.get_kv`, but the
slice is buffer-relative: this buffer holds at most ``2 * window``
tokens, so the trailing window starts from ``_local``, not ``offset``.
"""
start = 0 if window is None else max(0, self._local - window)
return (
self.keys[:, :, start : self._local, :],
self.values[:, :, start : self._local, :],
)
class PoolBackedAttentionKVCache:
@@ -1,10 +1,10 @@
"""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.
The pool stores full-attention layers only and stays intentionally
uniform: every stored layer must share the same KV shape and
full-context KV semantics. Sliding-window layers keep window-bounded
per-request caches instead; heterogeneous KV shapes need per-layer pools.
Slot 0 is reserved as padding (1-based indexing).
"""
@@ -22,6 +22,7 @@ from sglang.srt.hardware_backend.mlx.kv_cache.attention_contract import (
)
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_cache import (
ContiguousAttentionKVCache,
WindowedAttentionKVCache,
)
_thread_local = threading.local()
@@ -34,9 +35,15 @@ class BatchedDecodeContext:
batch_size: int
seq_lens: list[int] # per-request token count before the new token
# attention_layer_caches[attention_pool_idx][req_idx] = ContiguousAttentionKVCache
attention_layer_caches: list[list[ContiguousAttentionKVCache]]
# attention_layer_caches[cache_idx][req_idx], dense over attention layers.
# Windowed caches hold only trailing-window KV, so read them through
# write_token/get_kv, never by slicing .keys at an absolute offset.
attention_layer_caches: list[
list[ContiguousAttentionKVCache | WindowedAttentionKVCache]
]
attention_pool_index_by_layer: dict[int, int] = field(default_factory=dict)
# Dense index into the shared pool's buffers; full-attention layers only.
full_kv_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
@@ -50,6 +57,11 @@ class BatchedDecodeContext:
needs_padding: bool = field(init=False)
pad_sizes: list[int] = field(init=False)
positions: Optional[mx.array] = field(init=False)
# Padding metadata memo, keyed by window size. It depends only on
# ``seq_lens`` and the window, so every layer sharing a window reuses one
# entry instead of rebuilding it (gpt-oss decodes 24 attention layers per
# step, in two window classes).
_padding_by_window: dict = field(init=False, default_factory=dict)
def __post_init__(self) -> None:
seq_lens = self.seq_lens
@@ -64,6 +76,56 @@ class BatchedDecodeContext:
self.attention_pool_index_by_layer = {
idx: idx for idx in range(len(self.attention_layer_caches))
}
if self.aot.rope is not None and not self.full_kv_pool_index_by_layer:
# No silent default here: the fused scatter addresses pool buffers
# by full-attention index, so falling back to the cache index would
# write the wrong buffer whenever sliding-window layers are
# interleaved. A model with a pool always has full layers to index.
raise ValueError(
"BatchedDecodeContext requires full_kv_pool_index_by_layer "
"when the fused AOT RoPE + pool-scatter kernel is active"
)
def decode_padding(
self, window: int | None
) -> tuple[list[int], Optional[mx.array]]:
"""Right-pad sizes and the keep-mask for one decode step.
Requests are padded to a common KV width so they can be batched into
one SDPA call. Without a window that width is ``max_len``; a
sliding-window layer only reads the trailing ``window`` keys, so its
width is ``max(min(seq_len + 1, window))`` instead -- which is why the
context's full-length metadata cannot be reused for it.
The mask is boolean (``True`` keeps the key), broadcast-shaped
``(B, 1, 1, width)``, and ``None`` when no request needs padding.
Cached per window: all layers in the step share one build.
"""
cached = self._padding_by_window.get(window, None)
if cached is not None:
return cached
if window is None:
pad_sizes = self.pad_sizes
keep = (
self.positions[None, :] < self.valid_lens[:, None]
if self.needs_padding
else None
)
else:
eff_lens = [min(n + 1, window) for n in self.seq_lens]
max_eff = max(eff_lens)
pad_sizes = [max_eff - n for n in eff_lens]
keep = (
mx.arange(max_eff)[None, :]
< mx.array(eff_lens, dtype=mx.int32)[:, None]
if min(eff_lens) < max_eff
else None
)
result = (pad_sizes, None if keep is None else keep[:, None, None, :])
self._padding_by_window[window] = result
return result
@classmethod
def from_decode(
@@ -77,10 +139,15 @@ class BatchedDecodeContext:
req_to_token_pool: Any | None,
attention_layer_indices: list[int] | None = None,
attention_pool_index_by_layer: dict[int, int] | None = None,
full_kv_pool_index_by_layer: dict[int, int] | None = None,
) -> BatchedDecodeContext:
batch_size = len(req_ids)
if attention_layer_indices is None:
attention_layer_indices = list(range(len(caches[0])))
# One arbitrary attention layer speaks for the whole step: every
# attention cache's ``offset`` is the ABSOLUTE sequence position, so
# they all agree even though a windowed cache stores far fewer
# tokens than that (see the class docstring's read-through rule).
seq_lens = [
caches[i][attention_layer_indices[0]].offset for i in range(batch_size)
]
@@ -93,6 +160,7 @@ class BatchedDecodeContext:
seq_lens=seq_lens,
attention_layer_caches=attention_layer_caches,
attention_pool_index_by_layer=attention_pool_index_by_layer or {},
full_kv_pool_index_by_layer=full_kv_pool_index_by_layer or {},
aot=MlxAOTKernelContext.from_decode(
aot_kernels=aot_kernels,
kv_pool=kv_pool,
@@ -122,9 +190,11 @@ class MLXAttentionWrapper(nn.Module):
When ``BatchedDecodeContext`` is set, performs per-request RoPE,
cache writes, and batched SDPA. Otherwise delegates to inner module.
``window_size`` marks a sliding-window layer: the pool keeps the full
KV history and the wrapper attends to the trailing window only, which
is numerically identical to a rotating cache.
``window_size`` marks a sliding-window layer: the wrapper attends to
the trailing window of the cached keys only, which is numerically
identical to a rotating cache. Both cache kinds keep KV in temporal
order and report absolute offsets, so the same trailing-window slice
works whether the cache holds full history or only the window.
"""
def __init__(
@@ -142,8 +212,27 @@ class MLXAttentionWrapper(nn.Module):
raise RuntimeError(
f"Cannot determine attention scale for {type(inner).__name__}"
)
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__}"
)
object.__setattr__(self, "_scale", scale)
object.__setattr__(self, "_sinks", getattr(inner, "sinks", None))
object.__setattr__(self, "_n_heads", n_heads)
object.__setattr__(self, "_n_kv_heads", n_kv_heads)
# None for modules that expose head_dim only through a projection
# shape; _batched_decode falls back to the runtime K shape.
object.__setattr__(self, "_head_dim", get_head_dim(inner))
object.__setattr__(self, "_has_q_norm", hasattr(inner, "q_norm"))
object.__setattr__(self, "_has_k_norm", hasattr(inner, "k_norm"))
# Only pass sinks when the module has them: the kwarg requires a
# recent mlx and must not constrain models without sinks.
sinks = getattr(inner, "sinks", None)
object.__setattr__(self, "_sinks", sinks)
object.__setattr__(
self, "_sink_kwargs", {} if sinks is None else {"sinks": sinks}
)
def __call__(self, x: mx.array, mask: Any = None, cache: Any = None) -> mx.array:
ctx = get_context()
@@ -155,18 +244,14 @@ 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__}"
)
n_heads = self._n_heads
n_kv_heads = self._n_kv_heads
q_proj_output = inner.q_proj(x)
keys = inner.k_proj(x)
values = inner.v_proj(x)
head_dim = get_head_dim(inner)
head_dim = self._head_dim
if head_dim is None:
head_dim = keys.shape[-1] // n_kv_heads
@@ -188,9 +273,9 @@ class MLXAttentionWrapper(nn.Module):
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"):
if self._has_q_norm:
queries = inner.q_norm(queries)
if hasattr(inner, "k_norm"):
if self._has_k_norm:
keys = inner.k_norm(keys)
queries = queries.transpose(0, 2, 1, 3)
@@ -199,36 +284,32 @@ 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]
cache_idx = ctx.attention_pool_index_by_layer[layer_idx]
window = self._window_size
if ctx.aot.rope is not None:
# AOT path: real .metallib RoPE + fused KV pool scatter.
if ctx.aot.rope is not None and window is None:
# AOT path: real .metallib RoPE + fused scatter into this layer's
# pool buffer.
queries, keys = self._rope_custom_aot(
queries,
keys,
values,
offsets,
attention_pool_idx,
ctx.full_kv_pool_index_by_layer[layer_idx],
ctx.aot.rope,
)
else:
# Fallback: MLX's built-in mx.fast.rope (used when the AOT kernel
# isn't built or the model uses an unsupported RoPE variant).
# Fallback: MLX's built-in mx.fast.rope. Used when the AOT kernel
# isn't built, the model uses an unsupported RoPE variant, or the
# layer is sliding-window (windowed KV never enters the pool).
queries = inner.rope(queries, offset=offsets)
keys = inner.rope(keys, offset=offsets)
layer_caches = ctx.attention_layer_caches[attention_pool_idx]
window = self._window_size
if window is None:
pad_sizes = ctx.pad_sizes
else:
# Sliding-window layer: the cache keeps the full history but the
# newest token only attends to the trailing ``window`` keys. The
# padding metadata shared on the context is full-length, so it is
# rebuilt locally for the windowed lengths.
eff_lens = [min(n + 1, window) for n in ctx.seq_lens]
max_eff = max(eff_lens)
pad_sizes = [max_eff - n for n in eff_lens]
layer_caches = ctx.attention_layer_caches[cache_idx]
# A sliding-window layer reads only the trailing ``window`` keys, so its
# padded width differs from the unwindowed one. Both are memoised on
# the context and shared by every layer of their kind.
pad_sizes, attn_mask = ctx.decode_padding(window)
# TODO: replace per-request loop with native batched/ragged
# attention once mx.fast.scaled_dot_product_attention supports
@@ -239,10 +320,7 @@ class MLXAttentionWrapper(nn.Module):
for i in range(B):
layer_caches[i].write_token(keys[i : i + 1], values[i : i + 1])
k_all, v_all = layer_caches[i].get_kv()
if window is not None and k_all.shape[2] > window:
k_all = k_all[:, :, -window:, :]
v_all = v_all[:, :, -window:, :]
k_all, v_all = layer_caches[i].get_kv(window)
pad = pad_sizes[i]
if pad > 0:
@@ -257,34 +335,13 @@ class MLXAttentionWrapper(nn.Module):
keys_b = mx.concatenate(all_k, axis=0)
values_b = mx.concatenate(all_v, axis=0)
pad_mask = None
if window is None:
if ctx.needs_padding:
pad_mask = ctx.positions[None, :] >= ctx.valid_lens[:, None]
elif max(pad_sizes) > 0:
eff = mx.array(eff_lens, dtype=mx.int32)
pad_mask = mx.arange(max_eff)[None, :] >= eff[:, None]
attn_mask = None
if pad_mask is not None:
attn_mask = mx.where(
pad_mask[:, None, None, :],
mx.array(mx.finfo(queries.dtype).min, dtype=queries.dtype),
mx.array(0.0, dtype=queries.dtype),
)
# Only pass sinks when the module has them: the kwarg requires a
# recent mlx and must not constrain models without sinks.
sink_kwargs = {}
if self._sinks is not None:
sink_kwargs["sinks"] = self._sinks
output = mx.fast.scaled_dot_product_attention(
queries,
keys_b,
values_b,
scale=self._scale,
mask=attn_mask,
**sink_kwargs,
**self._sink_kwargs,
)
output = output.transpose(0, 2, 1, 3).reshape(B, 1, -1)
@@ -298,14 +355,14 @@ class MLXAttentionWrapper(nn.Module):
keys: mx.array,
values: mx.array,
positions: mx.array,
attention_pool_idx: int,
full_pool_idx: int,
rope_ctx: MlxAOTRoPEContext,
) -> tuple[mx.array, mx.array]:
"""AOT path: rotate Q/K and scatter K/V into the shared pool.
The kernel call does RoPE on Q/K and scatters
rotated K + (untouched) V into ``kv_pool`` at ``new_token_slots``
for ``layer_idx``.
rotated K + (untouched) V into ``kv_pool`` buffer ``full_pool_idx``
at ``new_token_slots``.
If ``new_token_slots`` is None, slot=-1 sentinel is used (no pool
write, RoPE-only mode). Returns rotated (queries, keys) in the
@@ -322,8 +379,8 @@ class MLXAttentionWrapper(nn.Module):
else:
slots = rope_ctx.new_token_slots.astype(mx.int32)
k_pool = rope_ctx.kv_pool.k_buffer[attention_pool_idx]
v_pool = rope_ctx.kv_pool.v_buffer[attention_pool_idx]
k_pool = rope_ctx.kv_pool.k_buffer[full_pool_idx]
v_pool = rope_ctx.kv_pool.v_buffer[full_pool_idx]
q_rot, k_rot, k_pool_new, v_pool_new = rope_ctx.kernel.rope_pool_fused(
q_flat,
@@ -339,8 +396,8 @@ class MLXAttentionWrapper(nn.Module):
rope_base=rope_ctx.kernel.base,
)
# Rebind pool buffers (zero-copy donation result).
rope_ctx.kv_pool.k_buffer[attention_pool_idx] = k_pool_new
rope_ctx.kv_pool.v_buffer[attention_pool_idx] = v_pool_new
rope_ctx.kv_pool.k_buffer[full_pool_idx] = k_pool_new
rope_ctx.kv_pool.v_buffer[full_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, :]
@@ -2,7 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Sequence
@@ -10,9 +10,13 @@ from typing import Any, Sequence
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.
Full-attention layers store softmax-attention KV in the shared MLX
attention KV pool; sliding-window layers keep per-request windowed KV
only. Auxiliary layers keep native ``mlx-lm`` cache state.
``attention_pool_index_by_layer`` indexes per-request cache arrays
(dense over all attention layers); ``full_kv_pool_index_by_layer``
indexes the shared pool's buffers (dense over full-attention layers).
"""
layers: tuple[Any, ...]
@@ -20,12 +24,37 @@ class MlxModelCacheLayout:
attention_layer_indices: tuple[int, ...]
auxiliary_layer_indices: tuple[int, ...]
attention_pool_index_by_layer: dict[int, int]
# Per-layer sliding window (None or absent = full attention).
layer_window_sizes: dict[int, int | None] = field(default_factory=dict)
full_attention_layer_indices: tuple[int, ...] = field(init=False)
swa_attention_layer_indices: tuple[int, ...] = field(init=False)
full_kv_pool_index_by_layer: dict[int, int] = field(init=False)
def __post_init__(self) -> None:
full_indices = tuple(
idx
for idx in self.attention_layer_indices
if self.layer_window_sizes.get(idx) is None
)
swa_indices = tuple(
idx
for idx in self.attention_layer_indices
if self.layer_window_sizes.get(idx) is not None
)
object.__setattr__(self, "full_attention_layer_indices", full_indices)
object.__setattr__(self, "swa_attention_layer_indices", swa_indices)
object.__setattr__(
self,
"full_kv_pool_index_by_layer",
{layer_idx: pool_idx for pool_idx, layer_idx in enumerate(full_indices)},
)
@classmethod
def from_attention_discovery(
cls,
layers: Sequence[Any],
attention_attrs: Sequence[str | None],
layer_window_sizes: dict[int, int | None] | None = None,
) -> MlxModelCacheLayout:
if len(layers) != len(attention_attrs):
raise ValueError(
@@ -50,6 +79,7 @@ class MlxModelCacheLayout:
attention_layer_indices=attention_layer_indices,
auxiliary_layer_indices=auxiliary_layer_indices,
attention_pool_index_by_layer=attention_pool_index_by_layer,
layer_window_sizes=dict(layer_window_sizes or {}),
)
@property
@@ -60,6 +90,14 @@ class MlxModelCacheLayout:
def num_attention_layers(self) -> int:
return len(self.attention_layer_indices)
@property
def num_full_attention_layers(self) -> int:
return len(self.full_attention_layer_indices)
@property
def has_sliding_window_layers(self) -> bool:
return bool(self.swa_attention_layer_indices)
@property
def has_auxiliary_state(self) -> bool:
return bool(self.auxiliary_layer_indices)
@@ -70,12 +108,22 @@ class MlxModelCacheLayout:
raise RuntimeError("MLX model has no supported attention layers")
return self.attention_layer_indices[0]
def window_size(self, layer_idx: int) -> int | None:
"""Sliding window of *layer_idx*, or None for full attention."""
return self.layer_window_sizes.get(layer_idx)
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 full_kv_pool_index(self, layer_idx: int) -> int:
try:
return self.full_kv_pool_index_by_layer[layer_idx]
except KeyError as exc:
raise KeyError(f"Layer {layer_idx} is not a full-attention layer") from exc
def attention_attr(self, layer_idx: int) -> str:
attr = self.attention_attrs[layer_idx]
if attr is None:
@@ -5,8 +5,15 @@ 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.
full-attention layers use ``ContiguousAttentionKVCache``, sliding-window
layers use a fixed-size ``WindowedAttentionKVCache`` on both KV paths,
and auxiliary layers use native ``mlx-lm`` cache objects.
The shared pool stores full-attention layers only, so no cross-request
SWA prefix KV exists: a radix prefix hit on a sliding-window model
recomputes the whole prefix (a trailing-band rebuild is inexact because
window receptive fields chain backwards through layers). The
scheduler's slot bookkeeping is untouched.
The module also exposes a lazy-eval (`*_start` / `*_finalize`) surface
used by the MLX overlap scheduler to pipeline CPU bookkeeping with
@@ -22,6 +29,7 @@ from dataclasses import dataclass
from typing import Any
import mlx.core as mx
import numpy as np
import psutil
from mlx.utils import tree_flatten
from mlx_lm import load as mlx_lm_load
@@ -40,14 +48,29 @@ from sglang.srt.hardware_backend.mlx.kv_cache import (
MLXAttentionWrapper,
MlxModelCacheLayout,
PoolBackedAttentionKVCache,
WindowedAttentionKVCache,
clear_context,
find_attention_layers,
get_head_dim,
get_layer_window_sizes,
get_num_kv_heads,
patch_model_attention,
set_context,
uses_sliding_window_attention,
)
from sglang.srt.hardware_backend.mlx.sampling import (
GREEDY_PARAMS,
MlxLazyLogprobs,
MlxLogprobSpec,
MlxSamplingParams,
MlxStepLogprobs,
all_greedy,
compute_logprobs,
lazy_logprob_arrays,
sample_tokens,
sanitize_logits,
scale_by_temperature,
)
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.runtime_context import (
mamba_cache_chunk_size,
@@ -72,6 +95,7 @@ class MlxPendingPrefill:
full_token_ids: list[int]
req_pool_idx: int
synced_offset: int
lazy_logprobs: MlxLazyLogprobs | None = None
@dataclass
@@ -85,9 +109,11 @@ class MlxPendingExtend:
"""
lazy_token: mx.array
cache: list[Any]
req_id: str
new_token_ids: list[int]
new_synced_offset: int
lazy_logprobs: MlxLazyLogprobs | None = None
@dataclass
@@ -104,6 +130,13 @@ class MlxPendingDecode:
lazy_tokens: mx.array
req_ids: list[str]
caches: list[list[Any]]
lazy_logprobs: MlxLazyLogprobs | None = None
# Carried so a chained step recomputes the same logprob request.
logprob_spec: MlxLogprobSpec | None = None
# Carried so chained steps keep applying static logit_bias rows.
# Never holds a grammar mask on the chained path: grammar batches are
# not chain_safe, so their pendings never become a chain root.
edit_rows: mx.array | None = None
_MLX_QUANTIZATION_PRESETS: dict[str, tuple[int, int]] = {
@@ -117,6 +150,14 @@ _MLX_KV_FLOAT_DTYPES = {mx.float16, mx.bfloat16, mx.float32}
class MlxModelRunner:
"""MLX model runner with radix-cache prefix sharing."""
# Class defaults cover unit tests that build runners via object.__new__
# without running __init__/_load_model, which set the real values.
# ``_trunk`` is the headless trunk, resolved in _load_model.
_trunk = None
_enable_sampling = False
_sanitize_nan = False
_deterministic_seeding = False
def __init__(
self,
model_path: str,
@@ -125,12 +166,27 @@ class MlxModelRunner:
pool_size: int | None = None,
mem_fraction_static: float = 0.8,
quantization: str | None = None,
enable_sampling: bool = False,
sampling_rng_seed: int = 0,
deterministic_seeding: bool = False,
):
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
self._enable_sampling = enable_sampling
# --enable-deterministic-inference parity: seed every row (the
# sampling module's DEFAULT_SAMPLING_SEED when the request has no
# sampling_seed), like the pytorch backend.
self._deterministic_seeding = deterministic_seeding
self._sanitize_nan = envs.SGLANG_SANITIZE_NAN_LOGITS.get()
# RNG state for unseeded sampled rows; split at every sampling
# graph build, so runs are reproducible given the same seed and
# request schedule.
self._rng_key: mx.array | None = (
mx.random.key(sampling_rng_seed) if enable_sampling else None
)
# 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()
@@ -155,6 +211,8 @@ class MlxModelRunner:
self._cache_layout = MlxModelCacheLayout.from_attention_discovery(
layer_list,
attn_attrs,
# Per-layer sliding windows (container convention, e.g. gpt-oss).
layer_window_sizes=get_layer_window_sizes(self.model),
)
if self._cache_layout.num_attention_layers == 0:
raise RuntimeError("MLX model has no supported attention layers")
@@ -164,6 +222,16 @@ class MlxModelRunner:
raise RuntimeError(
"MLX models with auxiliary cache state require model.make_cache()."
)
if (
self._cache_layout.has_auxiliary_state
and self._cache_layout.has_sliding_window_layers
):
# Auxiliary-state restore assumes a prefix hit runs only the new
# tokens; an SWA prefix hit recomputes the prefix on a fresh cache.
raise NotImplementedError(
"MLX runner does not support models with both auxiliary "
"cache state and sliding-window attention layers."
)
if self._cache_layout.has_auxiliary_state:
self._model_embed, self._model_norm, self._model_lm_head = (
self._extract_model_components()
@@ -172,7 +240,9 @@ class MlxModelRunner:
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._req_sampling: dict[str, MlxSamplingParams] = {}
# Reusable cache lists, for models without auxiliary layer state.
self._cache_pool: list[list[Any]] = []
self._attention_kv_pool: MlxAttentionKVPool | None = None
self._req_to_token_pool: ReqToTokenPool | None = None
@@ -206,7 +276,12 @@ class MlxModelRunner:
"""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)
window = self._cache_layout.window_size(layer_idx)
cache[layer_idx] = (
WindowedAttentionKVCache(window)
if window is not None
else ContiguousAttentionKVCache(max_seq_len=self._max_seq_len)
)
return cache
def _acquire_cache(self) -> list[Any]:
@@ -214,7 +289,7 @@ class MlxModelRunner:
if not self._cache_layout.has_auxiliary_state and self._cache_pool:
cache = self._cache_pool.pop()
for c in cache:
c.offset = 0
c.reset()
return cache
return self._new_native_cache()
@@ -335,13 +410,18 @@ class MlxModelRunner:
def _cache_with_pool_backed_attention(
self, prefix_slot_ids: list[int], prefix_len: int
) -> list[Any]:
"""Build a prefill cache list gathering *prefix_len* pool tokens.
Only reachable without sliding-window layers (SWA prefix hits
recompute instead), so every attention layer here is pool-backed.
"""
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),
self._cache_layout.full_kv_pool_index(layer_idx),
slot_ids_mx,
prefix_len,
)
@@ -379,22 +459,15 @@ class MlxModelRunner:
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)],
)
def cache_state_arrays(caches: list[list[Any]]) -> list[mx.array]:
"""Flatten per-request cache lists (``caches[req][layer]``) to arrays.
@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``.
Pass ``[cache]`` for a single request. Safe to hand to
``mx.eval`` / ``mx.async_eval``.
"""
return [
s
for cache_list in pending_caches
for cache_list in caches
for cache in cache_list
for s in MlxModelRunner._cache_arrays(cache)
]
@@ -465,6 +538,17 @@ class MlxModelRunner:
load_time = time.time() - start_time
logger.info(f"MLX model loaded in {load_time:.2f}s")
# mlx-lm models expose the headless trunk as ``Model.model``; without
# it, non-final chunked-prefill chunks cannot skip the logit head.
trunk = getattr(self.model, "model", None)
self._trunk = trunk if callable(trunk) else None
if self._trunk is None:
logger.info(
"Model %s exposes no headless trunk (`.model`); non-final "
"chunked-prefill chunks will compute full vocab logits.",
type(self.model).__name__,
)
# 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
@@ -494,11 +578,13 @@ class MlxModelRunner:
) -> 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):
unsized_window = self._cache_layout.window_size(layer_idx) is None
if unsized_window and 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."
f"Attention layer {layer_idx} declares sliding-window "
"attention but the model exposes no per-layer window map "
"(container `layer_types` plus a scalar window), so the MLX "
"KV cache cannot bound its sliding-window KV."
)
n_kv_heads = get_num_kv_heads(sample_attn)
if n_kv_heads is None:
@@ -528,7 +614,11 @@ class MlxModelRunner:
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."""
"""Return the uniform KV config shared by every attention layer.
Sizes the shared pool and the AOT kernels; sliding-window layers
must match the same shape because they share the decode kernels.
"""
if self._cache_layout.num_attention_layers == 0:
raise RuntimeError(
"Cannot determine attention config: no attention module found"
@@ -539,12 +629,11 @@ class MlxModelRunner:
config = self._attention_kv_config_for_layer(layer_idx)
if config != first_config:
raise NotImplementedError(
"MLX radix attention KV pool requires uniform softmax-attention "
"MLX attention KV caching 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."
"Heterogeneous attention KV needs per-layer pools."
)
return first_config
@@ -553,7 +642,13 @@ class MlxModelRunner:
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
# Only full-attention layers occupy pool slots. All-SWA models have no
# pool at all and fall back to the all-layer formula purely to keep the
# scheduler's token budget finite.
num_layers = (
self._cache_layout.num_full_attention_layers
or self._cache_layout.num_attention_layers
)
sys_available = psutil.virtual_memory().available
mlx_limit = mx.device_info().get(
"max_recommended_working_set_size",
@@ -598,15 +693,44 @@ class MlxModelRunner:
)
def init_cache_pools(self, req_to_token_pool: ReqToTokenPool | None) -> None:
"""Create attention KV pool (+1 for padding slot 0)."""
"""Create the full-attention KV pool (+1 for padding slot 0)."""
self._req_to_token_pool = req_to_token_pool
if self.disable_radix_cache:
return
num_pool_layers = self._cache_layout.num_full_attention_layers
if self._cache_layout.has_sliding_window_layers:
# The pool exists to serve radix prefix hits, and an SWA prefix hit
# recomputes the prefix instead of gathering it (see prefill_start),
# so on any SWA model the pool has no reader: its sole consumer is
# PoolBackedAttentionKVCache, reachable only when
# trusted_prefix_len > 0, which requires no SWA layers. Allocating
# it anyway would burn the whole auto-sized KV budget
# (_compute_pool_size fills mem_fraction_static) on a buffer that is
# only ever written. Skipping it also disables the fused AOT
# RoPE + pool-scatter kernel for the full layers, whose scatter half
# is dead work here; that kernel is opt-in
# (SGLANG_MLX_USE_CUSTOM_ROPE, default off), so the default path
# loses nothing.
#
# Un-gate this together with the window-aware shared SWA pool that
# restores fast prefix hits: the seams it needs (the layout
# partition, the full-pool index, layer-type dispatch) are already
# in place.
logger.info(
"Model has %d sliding-window attention layers; skipping the "
"shared attention KV pool (an SWA prefix hit recomputes the "
"prefix, so the pool would never be read). Per-request "
"windowed caches only.",
len(self._cache_layout.swa_attention_layer_indices),
)
return
if num_pool_layers == 0:
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,
num_layers=num_pool_layers,
n_kv_heads=n_kv_heads,
head_dim=head_dim,
dtype=dtype,
@@ -614,7 +738,9 @@ class MlxModelRunner:
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"{num_pool_layers} full-attention layers "
f"({len(self._cache_layout.swa_attention_layer_indices)} "
"sliding-window layers stay per-request), "
f"{n_kv_heads} kv_heads, {head_dim} head_dim"
)
@@ -627,8 +753,14 @@ class MlxModelRunner:
new_slot_ids: list[int],
req_pool_idx: int,
req: Any | None = None,
needs_logits: bool = True,
) -> int:
"""Prefill a request. Returns next_token_id."""
"""Prefill a request. Returns next_token_id.
One-shot convenience wrapper around ``prefill_start`` /
``prefill_finalize``; logit edits and logprobs are only available
through that lazy surface.
"""
pending = self.prefill_start(
req_id=req_id,
new_token_ids=new_token_ids,
@@ -637,8 +769,9 @@ class MlxModelRunner:
new_slot_ids=new_slot_ids,
req_pool_idx=req_pool_idx,
req=req,
needs_logits=needs_logits,
)
self._eval_with_cache(pending.lazy_token, pending.cache)
self.eval_pending(pending)
return self.prefill_finalize(pending)
def extend(
@@ -646,10 +779,16 @@ class MlxModelRunner:
req_id: str,
new_token_ids: list[int],
new_slot_ids: list[int],
needs_logits: bool = True,
) -> 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])
"""Continue prefill for a chunked request. Returns next_token_id.
One-shot convenience wrapper; see :meth:`prefill`.
"""
pending = self.extend_start(
req_id, new_token_ids, new_slot_ids, needs_logits=needs_logits
)
self.eval_pending(pending)
return self.extend_finalize(pending)
def _sync_new_kv_to_pool(
@@ -658,9 +797,15 @@ class MlxModelRunner:
cache_start: int,
slot_ids: list[int],
) -> None:
"""Sync attention KV from contiguous cache to pool at the given slots."""
"""Sync full-attention KV from contiguous caches to the pool slots.
Sliding-window layers are skipped: they keep no pool KV, and their
buffers are window-local so the absolute slicing below would not
apply to them anyway.
"""
if not slot_ids or self._attention_kv_pool is None:
return
full_layer_indices = self._cache_layout.full_attention_layer_indices
end = cache_start + len(slot_ids)
slot_ids_mx = mx.array(slot_ids, dtype=mx.int32)
# TODO: Standardize ContiguousAttentionKVCache size to avoid transpose
@@ -668,13 +813,13 @@ class MlxModelRunner:
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
for layer_idx in full_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
for layer_idx in full_layer_indices
]
)
self._attention_kv_pool.set_kv_all_layers(slot_ids_mx, k_all, v_all)
@@ -711,17 +856,13 @@ class MlxModelRunner:
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."""
def decode_batch(self, req_ids: list[str]) -> list[int]:
"""Decode one token per request.
One-shot convenience wrapper; see :meth:`prefill`.
"""
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)
self.eval_pending(pending)
return self.decode_batch_finalize(pending)
def prefill_start(
@@ -733,6 +874,9 @@ class MlxModelRunner:
new_slot_ids: list[int],
req_pool_idx: int,
req: Any | None = None,
needs_logits: bool = True,
logit_edit_row: mx.array | None = None,
logprob_spec: MlxLogprobSpec | None = None,
) -> MlxPendingPrefill:
"""Queue a prefill forward pass without evaluating.
@@ -740,17 +884,28 @@ class MlxModelRunner:
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``.
``needs_logits=False`` marks the first chunk of a chunked prompt
(its next-token output is discarded); see :meth:`extend_start`.
"""
prefix_len = len(prefix_slot_ids)
if req is not None:
req.mamba_last_track_seqlen = None
if self._enable_sampling:
self._req_sampling[req_id] = (
MlxSamplingParams.from_req(
req, deterministic_seeding=self._deterministic_seeding
)
if req is not None
else GREEDY_PARAMS
)
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)
lazy_token, lazy_logprobs = self._forward_lazy_token(
input_ids, cache, needs_logits, req_id, logit_edit_row, logprob_spec
)
return MlxPendingPrefill(
lazy_token=lazy_token,
cache=cache,
@@ -758,9 +913,17 @@ class MlxModelRunner:
full_token_ids=list(full_token_ids),
req_pool_idx=req_pool_idx,
synced_offset=0,
lazy_logprobs=lazy_logprobs,
)
assert self._attention_kv_pool is not None
# A pool is required only where one can actually be read: a model with
# full-attention layers and no sliding-window layers. init_cache_pools
# skips it otherwise, and the gather path below is unreachable then.
assert (
self._attention_kv_pool is not None
or self._cache_layout.num_full_attention_layers == 0
or self._cache_layout.has_sliding_window_layers
)
new_token_count = len(new_token_ids)
track_len = self._select_auxiliary_state_track_len(
@@ -770,8 +933,22 @@ class MlxModelRunner:
req=req,
)
if prefix_len > 0:
cache = self._cache_with_pool_backed_attention(prefix_slot_ids, prefix_len)
# Sliding-window layers keep no pool KV, so a prefix hit has nothing to
# gather and re-runs the prefix. A trailing-band rebuild would not be
# exact: each rebuilt position needs its own window of exact hidden
# states, and that dependency chains back through every layer. Only the
# run is clamped -- slot ids and synced offsets stay unclamped.
if prefix_len > 0 and self._cache_layout.has_sliding_window_layers:
trusted_prefix_len = 0
run_token_ids = list(full_token_ids[:prefix_len]) + new_token_ids
else:
trusted_prefix_len = prefix_len
run_token_ids = new_token_ids
if trusted_prefix_len > 0:
cache = self._cache_with_pool_backed_attention(
prefix_slot_ids[:trusted_prefix_len], trusted_prefix_len
)
pool_backed_attention = True
restored_auxiliary_state = (
not self._cache_layout.has_auxiliary_state
@@ -787,9 +964,9 @@ class MlxModelRunner:
# 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)
lazy_token, lazy_logprobs = self._forward_lazy_token(
input_ids, cache, needs_logits, req_id, logit_edit_row, logprob_spec
)
if new_slot_ids:
self._sync_new_kv_to_pool(cache, prefix_len, new_slot_ids)
return MlxPendingPrefill(
@@ -799,39 +976,45 @@ class MlxModelRunner:
full_token_ids=list(full_token_ids),
req_pool_idx=req_pool_idx,
synced_offset=prefix_len + len(new_slot_ids),
lazy_logprobs=lazy_logprobs,
)
else:
cache = self._acquire_cache()
pool_backed_attention = False
if new_token_count > 0:
if run_token_ids:
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:
# aux + SWA is rejected at init, so run_token_ids is
# new_token_ids on this branch.
input_ids = mx.array([new_token_ids[:track_new_count]], dtype=mx.int32)
self.model(input_ids, cache=cache)
# Cache side effects only — this intermediate forward's
# output is never read, so skip the head when possible.
if self._trunk_forward(input_ids, cache) is None:
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
extend_tokens = run_token_ids
else:
# Full cache hit - rerun last token to get next-token logits
# Full cache hit - rerun last token to get next-token logits.
# Unreachable with SWA layers: a prefix rebuild always leaves run
# tokens whose final logits already predict the next token.
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)
lazy_token, lazy_logprobs = self._forward_lazy_token(
input_ids, cache, needs_logits, req_id, logit_edit_row, logprob_spec
)
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.
@@ -848,6 +1031,7 @@ class MlxModelRunner:
full_token_ids=list(full_token_ids),
req_pool_idx=req_pool_idx,
synced_offset=prefix_len + len(new_slot_ids),
lazy_logprobs=lazy_logprobs,
)
def prefill_finalize(self, pending: MlxPendingPrefill) -> int:
@@ -872,8 +1056,16 @@ class MlxModelRunner:
req_id: str,
new_token_ids: list[int],
new_slot_ids: list[int],
needs_logits: bool = True,
logit_edit_row: mx.array | None = None,
logprob_spec: MlxLogprobSpec | None = None,
) -> MlxPendingExtend:
"""Queue chunked-prefill continuation without evaluating."""
"""Queue chunked-prefill continuation without evaluating.
``needs_logits=False`` marks a non-final chunk whose next-token
output the scheduler discards; the logit head is skipped when the
model exposes a headless trunk.
"""
assert (
req_id in self._req_caches
), f"extend_start called for unknown request {req_id}"
@@ -881,9 +1073,9 @@ class MlxModelRunner:
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)
lazy_token, lazy_logprobs = self._forward_lazy_token(
input_ids, cache, needs_logits, req_id, logit_edit_row, logprob_spec
)
if not self.disable_radix_cache and new_slot_ids:
synced = self._req_synced_offset[req_id]
@@ -894,9 +1086,11 @@ class MlxModelRunner:
return MlxPendingExtend(
lazy_token=lazy_token,
cache=cache,
req_id=req_id,
new_token_ids=list(new_token_ids),
new_synced_offset=new_synced_offset,
lazy_logprobs=lazy_logprobs,
)
def extend_finalize(self, pending: MlxPendingExtend) -> int:
@@ -916,6 +1110,201 @@ class MlxModelRunner:
)
return next_token
def _trunk_forward(self, input_ids: mx.array, cache: list[Any]) -> mx.array | None:
"""Run the model WITHOUT its logit head, for cache side effects only.
Non-final chunked-prefill chunks discard their next-token output
(``extend_finalize`` pops it), yet the full model call still computes
vocab-sized float32 logits for every chunk position — for a 200K-vocab
model that is ~100x the useful head work and the largest transient
allocation in the process. ``self._trunk`` is resolved once at load;
returns None when the model exposes no headless trunk (caller falls
back to the full forward).
"""
if self._trunk is None:
return None
return self._trunk(input_ids, cache=cache)
def _forward_lazy_token(
self,
input_ids: mx.array,
cache: list[Any],
needs_logits: bool,
req_id: str,
logit_edit_row: mx.array | None = None,
logprob_spec: MlxLogprobSpec | None = None,
) -> tuple[mx.array, MlxLazyLogprobs | None]:
"""Forward one chunk, returning (lazy next-token, lazy logprobs).
Skips the logit head for discarded-output chunks when possible.
"""
if not needs_logits:
hidden = self._trunk_forward(input_ids, cache)
if hidden is not None:
return self._dummy_next_token(hidden), None
# Headless trunk unavailable: run the full model, but keep the
# discarded token on argmax — sampling here would consume RNG
# state and make final output depend on prefill chunking.
model_output = self.model(input_ids, cache=cache)
logits = self._extract_logits(model_output)
return mx.argmax(logits[:, -1, :], axis=-1), None
model_output = self.model(input_ids, cache=cache)
logits = self._extract_logits(model_output)
edits = logit_edit_row[None, :] if logit_edit_row is not None else None
return self._select_tokens_with_logprobs(
logits[:, -1, :], [req_id], [cache], edits, logprob_spec
)
def _select_tokens_with_logprobs(
self,
last_logits: mx.array,
req_ids: list[str],
caches: list[list[Any]],
edit_rows: mx.array | None = None,
logprob_spec: MlxLogprobSpec | None = None,
) -> tuple[mx.array, MlxLazyLogprobs | None]:
"""Pick one token per row of ``last_logits`` — lazily, inside the graph.
Greedy behavior (sampling disabled, or every row greedy with no
logit edits) is exactly the pre-sampling ``mx.argmax`` and consumes
no RNG state. ``edit_rows`` is the worker's pre-combined additive
[B, vocab] array (grammar mask + logit_bias), applied before token
selection and logprobs, mirroring the CUDA
``ModelRunner._preprocess_logits`` order. Positions for seeded rows
come from the attention cache offsets, which the just-built forward
has already advanced past the token being sampled — the same
``seq_len - 1`` the pytorch path feeds its sampler. They are
build-time Python ints, so this is chained-decode safe.
"""
if not self._enable_sampling:
return mx.argmax(last_logits, axis=-1), None
params = [self._req_sampling[rid] for rid in req_ids]
edited = self._edited_logits(last_logits, edit_rows)
greedy = all_greedy(params)
# Built once and shared: sampling and logprobs both start from
# logits/temperature, and MLX does not CSE the two identical graphs.
# Stays None when neither needs it (the greedy, no-logprob path).
scaled = (
scale_by_temperature(edited, params)
if not greedy or logprob_spec is not None
else None
)
if greedy:
tokens = mx.argmax(edited, axis=-1)
else:
positions = [self._first_attention_cache(c).offset - 1 for c in caches]
self._rng_key, key = mx.random.split(self._rng_key)
tokens = sample_tokens(
last_logits=edited,
params=params,
positions=positions,
key=key,
scaled=scaled,
)
lazy_logprobs = (
compute_logprobs(
last_logits=edited,
params=params,
tokens=tokens,
spec=logprob_spec,
scaled=scaled,
)
if logprob_spec is not None
else None
)
return tokens, lazy_logprobs
def _edited_logits(
self, last_logits: mx.array, edit_rows: mx.array | None
) -> mx.array:
"""Apply the additive logit edits and env-gated NaN sanitization."""
edited = last_logits
if edit_rows is not None:
# The edit rows are sized from SamplingBatchInfo.vocab_size while
# these logits come from the model's lm_head. A model whose head
# is padded past the tokenizer vocabulary would otherwise fail as
# an opaque broadcast error deep in the lazy graph.
if edit_rows.shape[-1] != last_logits.shape[-1]:
raise RuntimeError(
"Logit edit rows do not match the model's vocabulary: "
f"sampling_info.vocab_size={edit_rows.shape[-1]} vs "
f"lm_head width {last_logits.shape[-1]}"
)
edited = edited.astype(mx.float32) + edit_rows
if self._sanitize_nan:
edited = sanitize_logits(edited.astype(mx.float32))
return edited
def _run_logits_hook(self, last_logits: mx.array, logits_hook) -> mx.array:
"""Materialize logits and let the worker edit them on the CPU.
Used for custom logit processors (arbitrary torch callables) — the
one edit that cannot be expressed lazily. Synchronizes the graph;
callers gate this to fresh, pure-decode launches, so the chained
overlap pipeline never pays for it.
"""
logits32 = last_logits.astype(mx.float32)
mx.eval(logits32)
return mx.array(logits_hook(np.array(logits32)))
def collect_logprobs(
self, lazy_logprobs: MlxLazyLogprobs | None
) -> MlxStepLogprobs | None:
"""Materialize one step's lazy logprob arrays into Python lists."""
if lazy_logprobs is None:
return None
mx.eval(*lazy_logprob_arrays(lazy_logprobs))
spec = lazy_logprobs.spec
if lazy_logprobs.top_val is not None:
all_val = lazy_logprobs.top_val.tolist()
all_idx = lazy_logprobs.top_idx.tolist()
top_val = [all_val[i][:k] for i, k in enumerate(spec.top_ks)]
top_idx = [all_idx[i][:k] for i, k in enumerate(spec.top_ks)]
else:
top_val = [[] for _ in spec.top_ks]
top_idx = [[] for _ in spec.top_ks]
return MlxStepLogprobs(
chosen=lazy_logprobs.chosen.tolist(),
top_val=top_val,
top_idx=top_idx,
token_ids_val=[
a.tolist() if a is not None else [] for a in lazy_logprobs.token_ids_val
],
token_ids_idx=[list(ids) if ids else [] for ids in spec.token_ids],
)
def eval_pending(
self, pending: MlxPendingPrefill | MlxPendingExtend | MlxPendingDecode
) -> None:
"""Materialize a queued forward: token(s), cache writes and logprobs.
One ``mx.eval`` for the whole pending, so the attention
write-then-read ordering is materialised in a single kernel
submission. Prefill and extend carry one request's per-layer
cache; a decode carries one cache list per request.
"""
if isinstance(pending, MlxPendingDecode):
tokens, caches = pending.lazy_tokens, pending.caches
else:
tokens, caches = pending.lazy_token, [pending.cache]
mx.eval(
tokens,
*self.cache_state_arrays(caches),
*lazy_logprob_arrays(pending.lazy_logprobs),
)
@staticmethod
def _dummy_next_token(hidden: mx.array) -> mx.array:
"""Graph-connected placeholder token for a skipped-head chunk.
Value is always 0 (a valid vocab id); it is appended and then popped
as the "stale intermediate token" by the next chunk's finalize.
Deriving it from ``hidden`` keeps the trunk in the lazy graph handed
to ``mx.eval``/``mx.async_eval`` (cache arrays are also evaluated
explicitly by both call paths).
"""
return (hidden[:, -1, 0] * 0).astype(mx.int32)
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)
@@ -936,7 +1325,7 @@ class MlxModelRunner:
batched_input: mx.array,
req_ids: list[str],
) -> mx.array:
"""Layer-by-layer hybrid decode for attention plus auxiliary state.
"""Layer-by-layer hybrid decode; returns [B, vocab] last-token logits.
Attention layers run with batched hidden states via
``BatchedDecodeContext``. Auxiliary layers run batched when their
@@ -971,7 +1360,7 @@ class MlxModelRunner:
hidden_states = self._model_norm(hidden_states)
logits = self._extract_logits(self._model_lm_head(hidden_states))
return mx.argmax(logits[:, -1, :], axis=-1)
return logits[:, -1, :]
def _decode_auxiliary_layer(
self,
@@ -1107,15 +1496,15 @@ class MlxModelRunner:
caches: list[list[Any]],
input_ids_by_request: list[mx.array],
) -> mx.array:
lazy_token_list = []
lazy_logits_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))
lazy_logits_list.append(logits[:, -1, :])
return (
lazy_token_list[0]
if len(lazy_token_list) == 1
else mx.concatenate(lazy_token_list, axis=0)
lazy_logits_list[0]
if len(lazy_logits_list) == 1
else mx.concatenate(lazy_logits_list, axis=0)
)
def _decode_with_batched_attention(
@@ -1135,7 +1524,7 @@ class MlxModelRunner:
]
model_output = self.model(batched_input, cache=shim_cache)
logits = self._extract_logits(model_output)
return mx.argmax(logits[:, -1, :], axis=-1)
return logits[:, -1, :]
finally:
clear_context()
@@ -1156,9 +1545,16 @@ class MlxModelRunner:
attention_pool_index_by_layer=(
self._cache_layout.attention_pool_index_by_layer
),
full_kv_pool_index_by_layer=self._cache_layout.full_kv_pool_index_by_layer,
)
def decode_batch_start(self, req_ids: list[str]) -> MlxPendingDecode:
def decode_batch_start(
self,
req_ids: list[str],
edit_rows: mx.array | None = None,
logprob_spec: MlxLogprobSpec | None = None,
logits_hook=None,
) -> MlxPendingDecode:
"""Queue a decode forward pass without evaluating.
The caller is responsible for calling ``mx.async_eval`` on the
@@ -1170,18 +1566,31 @@ class MlxModelRunner:
batched_input = mx.array(last_tokens, dtype=mx.int32)[:, None]
if self._cache_layout.has_auxiliary_state:
lazy_tokens = self._decode_with_hybrid_batching(
last_logits = self._decode_with_hybrid_batching(
caches, batched_input, list(req_ids)
)
else:
lazy_tokens = self._decode_with_batched_attention(
last_logits = self._decode_with_batched_attention(
caches, batched_input, list(req_ids)
)
if logits_hook is not None:
# CUDA edit order: grammar mask + logit_bias first, custom
# processors second, sanitization last (inside selection).
if edit_rows is not None:
last_logits = last_logits.astype(mx.float32) + edit_rows
edit_rows = None
last_logits = self._run_logits_hook(last_logits, logits_hook)
lazy_tokens, lazy_logprobs = self._select_tokens_with_logprobs(
last_logits, list(req_ids), caches, edit_rows, logprob_spec
)
return MlxPendingDecode(
lazy_tokens=lazy_tokens,
req_ids=list(req_ids),
caches=caches,
lazy_logprobs=lazy_logprobs,
logprob_spec=logprob_spec,
edit_rows=edit_rows,
)
def decode_batch_start_chained(
@@ -1215,18 +1624,24 @@ class MlxModelRunner:
# 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(
last_logits = self._decode_with_hybrid_batching(
caches, batched_input, prev.req_ids
)
else:
lazy_tokens = self._decode_with_batched_attention(
last_logits = self._decode_with_batched_attention(
caches, batched_input, prev.req_ids
)
lazy_tokens, lazy_logprobs = self._select_tokens_with_logprobs(
last_logits, prev.req_ids, caches, prev.edit_rows, prev.logprob_spec
)
return MlxPendingDecode(
lazy_tokens=lazy_tokens,
req_ids=prev.req_ids,
caches=caches,
lazy_logprobs=lazy_logprobs,
logprob_spec=prev.logprob_spec,
edit_rows=prev.edit_rows,
)
def decode_batch_finalize(
@@ -1266,6 +1681,7 @@ class MlxModelRunner:
self._sync_decode_kv_to_pool(req_id)
self._req_token_ids.pop(req_id, None)
self._req_sampling.pop(req_id, None)
cache = self._req_caches.pop(req_id, None)
if cache is not None:
self._release_cache(cache)
@@ -1275,6 +1691,7 @@ class MlxModelRunner:
def clear(self):
"""Clear all request states."""
self._req_token_ids.clear()
self._req_sampling.clear()
for cache in self._req_caches.values():
self._release_cache(cache)
self._req_caches.clear()
@@ -0,0 +1,491 @@
"""MLX-native in-graph sampling for the MLX backend.
Token selection (temperature / top-k / top-p / min-p / per-request seed)
built entirely from ``mx`` ops, so it lives inside the same lazy graph as
the forward pass. This is what lets sampling coexist with the overlap
scheduler: ``decode_batch_start_chained`` feeds step N's still-unevaluated
sampled tokens as step N+1's input ids, exactly as it does for greedy
argmax, and the GPU runs both steps back-to-back with no host sync.
An earlier proposal (#25804) bridged MLX logits to the CPU pytorch
``Sampler`` instead. That design forces a host sync in the middle of the
graph-build window, which is precisely what the overlap scheduler exists
to avoid, so token selection is rebuilt from ``mx`` ops here.
Semantics mirror the sglang pytorch sampling backend
(``top_k_top_p_min_p_sampling_from_probs_torch`` /
``multinomial_with_seed`` in ``sglang/srt/layers/sampler.py``):
* ``probs = softmax(logits / temperature)`` per row.
* Descending sort, then zero out rank >= top_k, cumulative-prob mass
beyond top_p (the top token is always kept), and probs below
``max_prob * min_p``.
* Multinomial sampling via the Gumbel-max identity:
``argmax(log(weights) + gumbel_noise)`` over the masked, unnormalized
weights is distributed identically to ``torch.multinomial(weights)``
(normalization only shifts ``log`` by a per-row constant).
* When every row asks for a small enough ``top_k``
(:data:`MAX_BOUNDED_TOP_K`), everything after the sort runs on the
``[B, K]`` candidates instead of ``[B, vocab]``. Weights are zero
outside those K, so their ``log`` is ``-inf`` and the full-vocab
argmax could never have picked them — same token, a fraction of the
work. Any row without a bounded ``top_k`` sends the batch back to the
full-vocab chain.
* Rows with ``sampling_seed`` set use deterministic Gumbel noise derived
from the same MurmurHash3 formula as the CUDA kernel
(``sglang/kernels/ops/sampling/murmur_hash.py``): hash(seed, position,
token_id) -> uniform -> ``-log(-log(u))``. Seeded noise is keyed on
the token id in every branch (the full-vocab chain scatters the masked
weights back through the sort; the bounded chain hashes the candidate
ids), so a seeded row's token never depends on whether a batchmate
triggered top-k/top-p/min-p filtering or on which chain ran.
* Greedy rows (``top_k == 1`` after sglang normalization, which rewrites
``temperature < eps`` to ``temperature=1, top_k=1``) short-circuit to
``argmax`` and consume no randomness.
Seeds follow the same gate as every other backend: ``sampling_seed`` is
consumed only under ``--enable-deterministic-inference``, which then
seeds every row (:data:`DEFAULT_SAMPLING_SEED` for requests that did not
ask for one). See :meth:`MlxSamplingParams.from_req`.
Known deviations from the pytorch backend (not bugs):
* Seeded determinism is MLX-local: noise math runs in float32 (Metal has
no float64) and tie order follows MLX's sort, so the same seed on a
CUDA backend may pick a different token from the same distribution.
* Unseeded rows draw their Gumbel noise in whichever space the chain is
running (candidate or vocab), so the bounded top-K path consumes the
RNG differently from the full-vocab one. Seeded rows are unaffected —
they hash the token id — so ``--enable-deterministic-inference`` is
bit-for-bit identical either way.
* Penalties (frequency/presence/repetition) are not applied on the MLX
path (warned once per process). #25804 skips them as well.
* Custom logit processors run on pure-decode steps only: the first
generated token and decode steps mixed into an extend batch are not
processed (``apply_custom_logit_processor`` requires logits rows to
match the full ``sampling_info``). Same scope as #25804, which only
hooked the pure-decode path at all.
* Logprob output covers the sampled token, top-k, and requested token
ids for every generated token; prompt/input logprobs
(``logprob_start_len``) are not computed.
Logit edits (grammar vocab masks, ``logit_bias``) arrive as a
pre-combined additive [B, vocab] array built by the worker at graph
launch — grammar FSM state is always current at a fresh launch because
the previous token was finalized before scheduling, so the mask is known
at build time and the graph stays lazy. Grammar/custom-processor
batches must not CHAIN (the mask for step N+1 needs token N
materialized); the scheduler breaks the chain for them. NaN/inf
sanitization mirrors ``sanitize_nan_logits`` and is gated on the same
``SGLANG_SANITIZE_NAN_LOGITS`` env var.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
import mlx.core as mx
logger = logging.getLogger(__name__)
# Seed given to rows without an explicit ``sampling_seed`` when
# --enable-deterministic-inference is on. Mirrors the literal in
# ``SamplingBatchInfo.from_schedule_batch``.
DEFAULT_SAMPLING_SEED = 42
# Largest ``top_k`` that still takes the bounded candidate path in
# :func:`sample_tokens`. Past this the [B, K] chain stops being
# meaningfully cheaper than the [B, vocab] one it replaces.
MAX_BOUNDED_TOP_K = 1024
_warned_ignored_penalties = False
@dataclass(frozen=True)
class MlxSamplingParams:
"""Per-request sampling parameters, frozen at prefill registration."""
temperature: float
top_k: int
top_p: float
min_p: float
seed: int | None
@classmethod
def from_req(
cls, req: Any, deterministic_seeding: bool = False
) -> MlxSamplingParams:
sp = req.sampling_params
global _warned_ignored_penalties
if not _warned_ignored_penalties and (
sp.frequency_penalty != 0.0
or sp.presence_penalty != 0.0
or sp.repetition_penalty != 1.0
):
_warned_ignored_penalties = True
logger.warning(
"MLX sampling ignores frequency/presence/repetition penalties; "
"a request specified them. (Warning logged once.)"
)
# Seed contract, identical to every other backend: SamplingBatchInfo
# populates sampling_seed only under --enable-deterministic-inference,
# and then seeds every row (default DEFAULT_SAMPLING_SEED). Outside
# that flag a per-request seed is ignored, so it is ignored here too.
seed = None
if deterministic_seeding:
seed = (
sp.sampling_seed
if sp.sampling_seed is not None
else DEFAULT_SAMPLING_SEED
)
return cls(
temperature=sp.temperature,
top_k=sp.top_k,
top_p=sp.top_p,
min_p=sp.min_p,
seed=seed,
)
@property
def is_greedy(self) -> bool:
return self.top_k == 1
GREEDY_PARAMS = MlxSamplingParams(
temperature=1.0, top_k=1, top_p=1.0, min_p=0.0, seed=None
)
@dataclass(frozen=True)
class MlxLogprobSpec:
"""Per-row logprob request for one step.
Mirrors the CUDA ``OutputLogprobProcessor`` inputs: ``top_ks[i]`` is
the row's ``top_logprobs_num`` (0 = none) and ``token_ids[i]`` the
row's requested token ids (None = none).
"""
top_ks: tuple[int, ...]
token_ids: tuple[tuple[int, ...] | None, ...]
@dataclass
class MlxLazyLogprobs:
"""Lazy logprob arrays for one step; materialized at finalize."""
chosen: mx.array # [B]
top_val: mx.array | None # [B, max_k]
top_idx: mx.array | None # [B, max_k]
token_ids_val: list[mx.array | None] # per row
spec: MlxLogprobSpec
@dataclass
class MlxStepLogprobs:
"""Materialized per-step logprobs, per row, cut to the request shape."""
chosen: list[float]
top_val: list[list[float]]
top_idx: list[list[int]]
token_ids_val: list[list[float]]
token_ids_idx: list[list[int]]
def lazy_logprob_arrays(lazy_logprobs: MlxLazyLogprobs | None) -> list[mx.array]:
"""The mx arrays of a lazy logprob bundle, for eval/async_eval calls."""
if lazy_logprobs is None:
return []
arrays = [lazy_logprobs.chosen]
if lazy_logprobs.top_val is not None:
arrays += [lazy_logprobs.top_val, lazy_logprobs.top_idx]
arrays += [a for a in lazy_logprobs.token_ids_val if a is not None]
return arrays
def all_greedy(params: list[MlxSamplingParams]) -> bool:
return all(p.is_greedy for p in params)
def sanitize_logits(logits: mx.array) -> mx.array:
"""Lazy analogue of sglang's ``sanitize_nan_logits``: NaN -> -1e30,
+-inf -> +-1e30 (not dtype extremes — temperature division would
overflow those back to inf)."""
return mx.clip(mx.where(mx.isnan(logits), -1e30, logits), -1e30, 1e30)
def scale_by_temperature(
last_logits: mx.array, params: list[MlxSamplingParams]
) -> mx.array:
"""``logits / temperature`` per row, in float32.
Both :func:`sample_tokens` and :func:`compute_logprobs` start here. MLX
builds eager graphs and does not eliminate common subexpressions, so a
step that samples *and* reports logprobs would otherwise pay two
full-vocab divisions; callers needing both compute this once and pass it
to each.
"""
temps = mx.array([p.temperature for p in params], dtype=mx.float32)[:, None]
return last_logits.astype(mx.float32) / temps
def compute_logprobs(
last_logits: mx.array,
params: list[MlxSamplingParams],
tokens: mx.array,
spec: MlxLogprobSpec,
scaled: mx.array | None = None,
) -> MlxLazyLogprobs:
"""Lazy log-probabilities of this step's distribution.
Matches the pytorch sampler: the distribution is
``log_softmax(edited_logits / temperature)`` — after grammar-mask /
logit_bias / sanitization, before top-k/top-p/min-p filtering (the
filters affect which token is drawn, not the reported logprobs).
``scaled`` optionally supplies :func:`scale_by_temperature`'s result when
the caller already built it for :func:`sample_tokens`.
"""
if scaled is None:
scaled = scale_by_temperature(last_logits, params)
logp = scaled - mx.logsumexp(scaled, axis=-1, keepdims=True)
chosen = mx.take_along_axis(logp, tokens[:, None], axis=-1).squeeze(-1)
max_k = max(spec.top_ks) if spec.top_ks else 0
if max_k > 0:
top_idx = mx.argsort(-logp, axis=-1)[:, :max_k]
top_val = mx.take_along_axis(logp, top_idx, axis=-1)
else:
top_idx = None
top_val = None
token_ids_val: list[mx.array | None] = [
logp[row, mx.array(ids)] if ids else None
for row, ids in enumerate(spec.token_ids)
]
return MlxLazyLogprobs(
chosen=chosen,
top_val=top_val,
top_idx=top_idx,
token_ids_val=token_ids_val,
spec=spec,
)
def sample_tokens(
last_logits: mx.array,
params: list[MlxSamplingParams],
positions: list[int],
key: mx.array,
scaled: mx.array | None = None,
) -> mx.array:
"""Select one token per row of ``last_logits`` ([B, vocab], lazy ok).
Pure ``mx`` ops — the result stays inside the lazy graph. ``positions``
are the absolute sequence positions of the tokens being sampled (only
consumed for seeded rows). Callers should shortcut to ``mx.argmax``
when ``all_greedy(params)`` — this function assumes at least one row
samples. ``scaled`` optionally supplies
:func:`scale_by_temperature`'s result when the caller already built it
for :func:`compute_logprobs`.
"""
batch_size, vocab_size = last_logits.shape
logits32 = last_logits.astype(mx.float32)
if scaled is None:
scaled = scale_by_temperature(logits32, params)
filtering = any(
not p.is_greedy and (p.top_k < vocab_size or p.top_p < 1.0 or p.min_p > 0.0)
for p in params
)
# Token ids the Gumbel-max runs over: None means "the whole vocabulary",
# otherwise a [B, K] candidate array that the argmax result indexes into.
candidates: mx.array | None = None
if filtering:
probs = mx.softmax(scaled, axis=-1)
width = _candidate_width(params, vocab_size)
sorted_idx = mx.argsort(-probs, axis=-1)[:, :width]
p_sort = mx.take_along_axis(probs, sorted_idx, axis=-1)
ranks = mx.arange(width, dtype=mx.int32)[None, :]
# SamplingParams normalizes top_k=-1 to TOP_K_ALL and temperature
# below eps to (temperature=1, top_k=1), so min(top_k, width)
# is always >= 1: rank 0 survives and log(weights) is never all -inf.
top_ks = mx.array([min(p.top_k, width) for p in params], dtype=mx.int32)[
:, None
]
top_ps = mx.array([p.top_p for p in params], dtype=mx.float32)[:, None]
min_ps = mx.array([p.min_p for p in params], dtype=mx.float32)[:, None]
cum = mx.cumsum(p_sort, axis=-1)
masked_out = (
(ranks >= top_ks)
| ((cum - p_sort) > top_ps)
| (p_sort < p_sort[:, :1] * min_ps)
)
w_sort = mx.where(masked_out, 0.0, p_sort)
if width < vocab_size:
# Bounded top-K: every surviving rank is inside the K
# candidates, so run the rest of the chain in candidate space
# and map the winning rank back to its vocab id at the end.
log_weights = mx.log(w_sort)
candidates = sorted_idx
else:
# Scatter the masked weights back to vocab order: noise must be
# applied in vocab-id space in every branch, or a seeded row's
# token would change when a batchmate happens to need filtering.
weights = mx.put_along_axis(
mx.zeros_like(probs), sorted_idx, w_sort, axis=-1
)
log_weights = mx.log(weights)
else:
# Nothing is masked, so the weights are the plain softmax and
# ``log(softmax(scaled)) == scaled - logsumexp(scaled)``: a per-row
# constant offset, which the argmax below is invariant to. Feeding
# the scaled logits straight to the Gumbel-max drops two full-vocab
# passes (softmax, log) on the common temperature-only batch.
log_weights = scaled
noise = _gumbel_noise(
params=params,
positions=positions,
shape=log_weights.shape,
key=key,
columns=candidates,
)
# Gumbel-max over the UNNORMALIZED masked weights: normalization would
# only shift log(w) by a per-row constant, which argmax is invariant to.
# That is also why seed + min_p is well-defined here, and why the
# pytorch backend's `assert sampling_seed is None` under min-p (and its
# TODO at layers/sampler.py "probs_sort should be re-normalized for the
# use of multinomial_with_seed") has no analogue on this path.
sampled = mx.argmax(log_weights + noise, axis=-1)
if candidates is not None:
sampled = mx.take_along_axis(candidates, sampled[:, None], axis=-1).squeeze(-1)
greedy = [p.is_greedy for p in params]
if not any(greedy):
return sampled
# A batch that mixes greedy rows in still runs them through the sampled
# path above (the row exists either way); overwrite those rows with the
# unnoised argmax, which is what makes greedy rows consume no randomness.
return mx.where(mx.array(greedy), mx.argmax(logits32, axis=-1), sampled)
def _candidate_width(params: list[MlxSamplingParams], vocab_size: int) -> int:
"""Rank cut-off the filtered chain can run on, or ``vocab_size``.
Every rank at or beyond a row's ``top_k`` is masked to weight 0, whose
``log`` is ``-inf``, so the Gumbel-max can never select it. When every
row's ``top_k`` is small, the whole chain after the sort — gather,
cumsum, mask, log, noise, argmax — can therefore run on ``[B, K]``
instead of ``[B, vocab]`` and still pick exactly the same token.
Falls back to the full vocabulary as soon as one row wants more
candidates than :data:`MAX_BOUNDED_TOP_K` (or no top-k at all, which
``SamplingParams`` spells as ``top_k = TOP_K_ALL``).
"""
largest_top_k = max(p.top_k for p in params)
if largest_top_k > MAX_BOUNDED_TOP_K or largest_top_k >= vocab_size:
return vocab_size
return largest_top_k
def _gumbel_noise(
params: list[MlxSamplingParams],
positions: list[int],
shape: tuple[int, int],
key: mx.array,
columns: mx.array | None = None,
) -> mx.array:
"""Gumbel noise shaped like the weights: hashed if seeded, RNG otherwise.
``columns`` is the [B, K] token ids each weight column stands for on the
bounded top-K path; ``None`` means column j is token id j. Seeded rows
hash the token id, so their noise — and therefore their token — is the
same either way.
"""
seeded_rows = [p.seed is not None for p in params]
if not any(seeded_rows):
return mx.random.gumbel(shape=shape, key=key)
hashed = _murmur_hash32(
seeds=[p.seed if p.seed is not None else 0 for p in params],
positions=positions,
vocab_size=shape[1],
columns=columns,
)
u = hashed.astype(mx.float32) / float(0xFFFFFFFF)
# REQUIRED, not cosmetic: uint32(0xFFFFFFFF) rounds UP to 2**32 in
# float32, so the quotient can land just above 1.0 and make
# log(-log(u)) NaN; and an exact 1.0 gives -log(-log(1)) = +inf, which
# would deterministically force that token. Clamp both ends to the
# nearest representable interior values.
u = mx.clip(u, 2.0**-32, 1.0 - 2.0**-24)
hash_noise = -mx.log(-mx.log(u))
if all(seeded_rows):
return hash_noise
random_noise = mx.random.gumbel(shape=shape, key=key)
return mx.where(mx.array(seeded_rows)[:, None], hash_noise, random_noise)
def _murmur3_mix_py(h: int, k: int) -> int:
"""One MurmurHash3 block mix on Python ints (exact 32-bit semantics)."""
k = (k * 0xCC9E2D51) & 0xFFFFFFFF
k = ((k << 15) | (k >> 17)) & 0xFFFFFFFF
k = (k * 0x1B873593) & 0xFFFFFFFF
h ^= k
h = ((h << 13) | (h >> 19)) & 0xFFFFFFFF
h = (h * 5 + 0xE6546B64) & 0xFFFFFFFF
return h
def _murmur_hash32(
seeds: list[int],
positions: list[int],
vocab_size: int,
columns: mx.array | None = None,
) -> mx.array:
"""Port of ``murmur_hash32`` (Triton) to mx ops: [B, V] uint32.
Blocks mixed in kernel order: seed_low, seed_high, position, column.
The first three are per-row scalars, so they are folded exactly on the
CPU with Python ints; only the column block and finalization run as
vectorized uint32 ops (verified to wrap like the Triton kernel).
``columns`` hashes an explicit [B, K] set of token ids instead of every
id in ``[0, vocab_size)`` — same value per (row, token id) either way,
which is what keeps a seeded row's token identical on the bounded
top-K path.
"""
row_states = []
for seed, pos in zip(seeds, positions):
seed &= 0xFFFFFFFFFFFFFFFF
h = _murmur3_mix_py(0, seed & 0xFFFFFFFF)
h = _murmur3_mix_py(h, (seed >> 32) & 0xFFFFFFFF)
h = _murmur3_mix_py(h, pos & 0xFFFFFFFF)
row_states.append(h)
h = mx.array(row_states, dtype=mx.uint32)[:, None]
if columns is None:
k = mx.arange(vocab_size, dtype=mx.uint32)[None, :]
else:
k = columns.astype(mx.uint32)
# murmur3_mix on [B, V]
k = k * mx.array(0xCC9E2D51, dtype=mx.uint32)
k = (k << 15) | (k >> 17)
k = k * mx.array(0x1B873593, dtype=mx.uint32)
h = h ^ k
h = (h << 13) | (h >> 19)
h = h * mx.array(5, dtype=mx.uint32) + mx.array(0xE6546B64, dtype=mx.uint32)
# finalize: len = 16 bytes (seed + pos + col), then fmix32
h = h ^ mx.array(16, dtype=mx.uint32)
h = h ^ (h >> 16)
h = h * mx.array(0x85EBCA6B, dtype=mx.uint32)
h = h ^ (h >> 13)
h = h * mx.array(0xC2B2AE35, dtype=mx.uint32)
h = h ^ (h >> 16)
return h
@@ -17,23 +17,20 @@ from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, List, Optional
import mlx.core as mx
from sglang.srt.environ import envs
from sglang.srt.managers.overlap_utils import resolve_forward_inputs
from sglang.srt.runtime_context import get_device
from sglang.srt.utils import DynamicGradMode
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from sglang.srt.hardware_backend.mlx.model_runner import (
MlxPendingDecode,
MlxPendingExtend,
MlxPendingPrefill,
)
from sglang.srt.hardware_backend.mlx.tp_worker import MlxLaunch
from sglang.srt.managers.schedule_batch import Req, ScheduleBatch
from sglang.srt.managers.scheduler import Scheduler
@@ -43,23 +40,10 @@ class MlxPendingJob:
"""Unfinished MLX work and graphs queued on the GPU.
Attributes:
lazy_tokens: Lazily evaluated token IDs produced by the forward
pass. Unevaluated; calling ``.tolist()`` / ``.item()`` /
``mx.eval`` on it will block until the Metal kernel finishes.
``None`` for idle batches.
prefills: MLX prefill state returned by the model worker — one
entry per new request in an extend batch. Used by
``finalize_mlx_result`` to commit per-request caches. Empty
list for pure-decode steps.
extends: Chunked-prefill-continuation state, one entry per
already-active request whose extend seq_len > 1. Also empty
for pure-decode steps.
decode: Decode state covering full-decode mode AND mixed
single-token decodes inside an extend batch. Used as the
chaining root by :meth:`async_chained_decode_mlx`.
mode: One of ``"decode"``, ``"extend"``, ``"idle"`` describing
which forward pass produced this job. Drives finalise
dispatch and whether chaining is safe.
launch: The :class:`MlxLaunch` this job is waiting on — the lazy
token handle plus the prefill / extend / decode pendings the
forward produced, and the mode that drives finalise dispatch
and whether chaining is safe.
batch_copy: Snapshot of the :class:`ScheduleBatch` at launch
time. Decoupled from the live batch so
``process_batch_result`` can update request state without
@@ -73,19 +57,41 @@ class MlxPendingJob:
mutable batch object.
"""
lazy_tokens: Optional[mx.array]
prefills: list[MlxPendingPrefill]
extends: list[MlxPendingExtend]
decode: Optional[MlxPendingDecode]
mode: str
launch: MlxLaunch
batch_copy: ScheduleBatch
schedule_batch: ScheduleBatch
reqs: List[Req]
# False when the batch needs per-step CPU logit state (grammar vocab
# masks / custom logit processors under --mlx-enable-sampling): step
# N+1's mask needs token N materialized, so such batches must launch
# fresh every step instead of chaining.
chain_safe: bool = True
# Captured at launch when batch.return_logprob, exactly like
# Scheduler.run_batch does for the CUDA paths (the live values mutate
# before output processing).
extend_input_len_per_req: Optional[List[int]] = None
extend_logprob_start_len_per_req: Optional[List[int]] = None
class SchedulerMlxOverlapMixin:
"""Mixin that adds MLX overlap scheduling to :class:`Scheduler`."""
def _mlx_batch_chain_safe(self: Scheduler, batch: ScheduleBatch) -> bool:
"""False when per-step CPU logit state forbids chained decode.
Grammar vocab masks and custom logit processors depend on the
previous token being materialized; a chained step is built before
that, so those batches launch fresh every step.
"""
if not get_device().mlx_enable_sampling:
return True
sampling_info = batch.sampling_info
if sampling_info is None:
return True
# batch.has_grammar, not sampling_info.grammars: the latter is only
# populated at forward launch (see _build_logit_edit_rows).
return not (batch.has_grammar or sampling_info.has_custom_logit_processor)
def _prepare_mlx_launch(self: Scheduler, batch: ScheduleBatch):
"""Stamp scheduler bookkeeping before an MLX forward is launched."""
# Match run_batch's launch boundary. In particular, the profiler
@@ -98,12 +104,10 @@ class SchedulerMlxOverlapMixin:
self.profiler_manager._profile_batch_predicate(batch)
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,
result = self.tp_worker.finalize_mlx_result(pending.launch, pending.reqs)
result.extend_input_len_per_req = pending.extend_input_len_per_req
result.extend_logprob_start_len_per_req = (
pending.extend_logprob_start_len_per_req
)
if result.next_token_ids is not None:
pending.batch_copy.input_ids = result.next_token_ids
@@ -163,29 +167,31 @@ class SchedulerMlxOverlapMixin:
# loop must do it too, otherwise async_forward_batch_generation_mlx
# dereferences a None input_ids.
resolve_forward_inputs(batch, self.future_map)
# run_batch stamps launch_ts on every scheduler-built forward; the
# MLX overlap loop bypasses run_batch, and process_batch_result ->
# _record_step_counters subtracts launch_ts unconditionally for
# prefill/decode batches. ScheduleBatch.copy() below carries the
# stamp to process_batch_result.
lazy_tokens, prefills, extends, decode, mode = (
self.tp_worker.async_forward_batch_generation_mlx(batch)
)
launch = self.tp_worker.async_forward_batch_generation_mlx(batch)
extend_input_len_per_req = None
extend_logprob_start_len_per_req = None
if batch.return_logprob:
# Mirror Scheduler.run_batch's launch-time copy.
extend_input_len_per_req = [
req.extend_range.length if req.extend_range is not None else 0
for req in batch.reqs
]
extend_logprob_start_len_per_req = batch.extend_logprob_start_lens
return MlxPendingJob(
lazy_tokens=lazy_tokens,
prefills=prefills,
extends=extends,
decode=decode,
mode=mode,
launch=launch,
batch_copy=batch.copy(),
schedule_batch=batch,
reqs=list(batch.reqs),
chain_safe=self._mlx_batch_chain_safe(batch),
extend_input_len_per_req=extend_input_len_per_req,
extend_logprob_start_len_per_req=extend_logprob_start_len_per_req,
)
def _launch_chained(prev: MlxPendingJob) -> MlxPendingJob:
assert prev.decode is not None
# Composition is identical to prev: reuse a fresh batch copy
# of the same underlying ScheduleBatch so process_batch_result
assert prev.launch.decode is not None
# Composition is identical to prev: every scheduler-side field
# carries over, and only a fresh batch copy of the same
# underlying ScheduleBatch is needed so process_batch_result
# updates the same req objects with the new token.
batch_copy = prev.batch_copy.copy()
self._prepare_mlx_launch(batch_copy)
@@ -193,18 +199,10 @@ class SchedulerMlxOverlapMixin:
# chain breaks, prepare_for_decode() may run SWA maintenance
# before the next fresh launch gets a chance to re-stamp it.
prev.schedule_batch.forward_iter = batch_copy.forward_iter
lazy_tokens, prefills, extends, decode, mode = (
self.tp_worker.async_chained_decode_mlx(prev.decode)
)
return MlxPendingJob(
lazy_tokens=lazy_tokens,
prefills=prefills,
extends=extends,
decode=decode,
mode=mode,
return replace(
prev,
launch=self.tp_worker.async_chained_decode_mlx(prev.launch.decode),
batch_copy=batch_copy,
schedule_batch=prev.schedule_batch,
reqs=prev.reqs,
)
while True:
@@ -224,8 +222,9 @@ class SchedulerMlxOverlapMixin:
# build pending_next on top of it NOW — before we block on curr.
can_chain = (
pending_curr is not None
and pending_curr.mode == "decode"
and pending_curr.decode is not None
and pending_curr.launch.mode == "decode"
and pending_curr.launch.decode is not None
and pending_curr.chain_safe
and not self.waiting_queue
)
if can_chain and pending_next is None:
@@ -13,7 +13,8 @@ normal ``GenerationBatchResult``.
"""
import logging
from typing import Optional, Union
from dataclasses import dataclass
from typing import Optional
import mlx.core as mx
import torch
@@ -23,6 +24,11 @@ from sglang.srt.hardware_backend.mlx.model_runner import (
MlxPendingExtend,
MlxPendingPrefill,
)
from sglang.srt.hardware_backend.mlx.sampling import (
MlxLogprobSpec,
MlxStepLogprobs,
lazy_logprob_arrays,
)
from sglang.srt.managers.schedule_batch import ScheduleBatch
from sglang.srt.managers.tp_worker import TpModelWorker
from sglang.srt.managers.utils import GenerationBatchResult
@@ -31,11 +37,45 @@ from sglang.srt.model_executor.forward_batch_info import (
ForwardBatch,
PPProxyTensors,
)
from sglang.srt.runtime_context import get_memory, get_model, get_schedule
from sglang.srt.runtime_context import (
get_device,
get_exec,
get_memory,
get_model,
get_schedule,
)
logger = logging.getLogger(__name__)
@dataclass
class MlxLaunch:
"""One lazily launched MLX forward pass: its handle and its pending work.
Produced by :meth:`MlxTpModelWorker.async_forward_batch_generation_mlx`
and :meth:`MlxTpModelWorker.async_chained_decode_mlx`, consumed by
:meth:`MlxTpModelWorker.finalize_mlx_result`.
Attributes:
lazy_tokens: an ``mx.array`` that, when evaluated, forces
materialisation of the whole batch's outputs. ``None`` for
idle batches.
prefills: one :class:`MlxPendingPrefill` per new request in an
extend batch; empty for pure-decode steps.
extends: one :class:`MlxPendingExtend` per chunked-prefill
continuation; also empty for pure-decode steps.
decode: the :class:`MlxPendingDecode` covering full decode mode
AND mixed single-token decodes inside an extend batch.
mode: one of ``"idle"``, ``"decode"``, ``"extend"``.
"""
lazy_tokens: Optional[mx.array]
prefills: list[MlxPendingPrefill]
extends: list[MlxPendingExtend]
decode: Optional[MlxPendingDecode]
mode: str
class MlxTpModelWorker(TpModelWorker):
"""A tensor parallel model worker that routes inference through MLX.
@@ -59,6 +99,11 @@ class MlxTpModelWorker(TpModelWorker):
disable_radix_cache=get_memory().disable_radix_cache,
mem_fraction_static=get_schedule().mem_fraction_static,
quantization=get_model().quantization,
enable_sampling=get_device().mlx_enable_sampling,
sampling_rng_seed=get_device().random_seed,
deterministic_seeding=(
get_exec().deterministic.enable_deterministic_inference
),
)
if get_schedule().max_total_tokens is not None:
init_kwargs["pool_size"] = get_schedule().max_total_tokens
@@ -137,8 +182,8 @@ class MlxTpModelWorker(TpModelWorker):
def _route_extend_request(self, rid: str, decoding_rids: set[str]) -> str:
"""Classify a request within an extend / mixed batch.
Shared by the sync (:meth:`_forward_batch_generation_mlx`) and async
(:meth:`_async_extend_batch`) paths so both route identically.
Called once per request from :meth:`_async_extend_batch`, which both
the overlap loop and the synchronous entry point launch through.
Returns one of:
@@ -157,134 +202,220 @@ class MlxTpModelWorker(TpModelWorker):
return "decode"
return "continuation"
@staticmethod
def _chunk_needs_logits(req) -> bool:
"""False iff this extend chunk is a non-final chunked-prefill chunk.
The scheduler truncates a chunked request's extend range below the
tokens it already knows about; such a chunk's next-token output is
discarded (the runner pops it as the stale intermediate token), so
the runner may skip the logit head for it.
"""
if req.extend_range is None:
return True
return req.extend_range.end >= len(req.full_untruncated_fill_ids)
@staticmethod
def _sampling_active(batch: ScheduleBatch) -> bool:
return get_device().mlx_enable_sampling and batch.sampling_info is not None
def _build_logit_edit_rows(
self, batch: ScheduleBatch
) -> dict[str, mx.array] | None:
"""Pre-combine grammar vocab masks and logit_bias into one additive
[vocab] float32 row per request, ready to enter the lazy graph.
Grammar FSM state is current at every fresh launch — the previous
token was finalized before this batch was scheduled — so the mask
is knowable at graph-build time with no device sync. The
scheduler never chains grammar batches
(:attr:`MlxPendingJob.chain_safe`), so a chained step never needs
a stale mask. Mask application reuses the grammar backend's own
``apply_vocab_mask`` on a zeros tensor, which keeps this
backend-agnostic (xgrammar / llguidance / outlines).
"""
if not self._sampling_active(batch):
return None
sinfo = batch.sampling_info
# Mirror ForwardBatch.init_new's grammars population — the MLX paths
# never build a ForwardBatch, so without this the list stays None
# even when requests carry live grammar objects.
sinfo.grammars = (
[req.grammar for req in batch.reqs] if batch.has_grammar else None
)
has_grammar = bool(sinfo.grammars)
if not has_grammar and sinfo.logit_bias is None:
return None
if not has_grammar:
# logit_bias alone is already the dense [B, vocab] additive row we
# want; converting it directly skips a second [B, vocab] float32
# allocation and an add on every step (~6 MB of churn per step at
# vocab 200k, batch 8). Not mutated below, so no clone is needed.
combined = sinfo.logit_bias.to(device="cpu", dtype=torch.float32)
else:
combined = torch.zeros(
len(batch.reqs), sinfo.vocab_size, dtype=torch.float32
)
sinfo.update_regex_vocab_mask()
if sinfo.grammar_mask is not None:
grammar_mask = sinfo.grammar_mask
grammar_mask.grammar.apply_vocab_mask(
logits=combined,
vocab_mask=grammar_mask.vocab_mask.to("cpu"),
)
# Release promptly; mirrors the VRAM-leak note in the CUDA
# ModelRunner._preprocess_logits.
sinfo.grammar_mask = None
if sinfo.logit_bias is not None:
combined += sinfo.logit_bias.to("cpu")
rows = mx.array(combined.numpy())
return {req.rid: rows[i] for i, req in enumerate(batch.reqs)}
def _logprob_rows(
self, batch: ScheduleBatch
) -> dict[str, tuple[int, tuple[int, ...] | None]] | None:
"""Per-request (top_logprobs_num, token_ids) for logprob output."""
if not self._sampling_active(batch) or not batch.return_logprob:
return None
tops = batch.top_logprobs_nums or [0] * len(batch.reqs)
tids = batch.token_ids_logprobs or [None] * len(batch.reqs)
rows = {}
for req, top_k, token_ids in zip(batch.reqs, tops, tids):
if req.return_logprob:
rows[req.rid] = (
int(top_k or 0),
tuple(token_ids) if token_ids else None,
)
return rows or None
@staticmethod
def _logprob_spec_for(
rows: dict[str, tuple[int, tuple[int, ...] | None]] | None,
rids: list[str],
) -> MlxLogprobSpec | None:
if rows is None or not any(rid in rows for rid in rids):
return None
return MlxLogprobSpec(
top_ks=tuple(rows.get(rid, (0, None))[0] for rid in rids),
token_ids=tuple(rows.get(rid, (0, None))[1] for rid in rids),
)
def _custom_logits_hook(self, batch: ScheduleBatch):
"""CPU edit hook for custom logit processors, or None.
Only built for fresh pure-decode launches; the runner materializes
the logits for the hook, so these batches never chain.
"""
if not (
self._sampling_active(batch)
and batch.sampling_info.has_custom_logit_processor
):
return None
sinfo = batch.sampling_info
def hook(logits_np):
from sglang.srt.layers.sampler import apply_custom_logit_processor
# torch.from_numpy shares memory with logits_np, so the
# processors' in-place edits land in the returned array.
logits_t = torch.from_numpy(logits_np)
apply_custom_logit_processor(logits_t, sinfo)
return logits_np
return hook
@staticmethod
def _assemble_logprob_output(step_rows: dict[str, tuple], reqs: list):
"""Batch-ordered LogitsProcessorOutput from per-request logprob rows.
Field shapes follow what ``move_logprobs_to_cpu`` and
``add_logprob_return_values`` consume: tensors for values the
scheduler ``.tolist()``s, plain lists for token-id indices.
"""
from sglang.srt.layers.logits_processor import LogitsProcessorOutput
chosen, top_val, top_idx, tid_val, tid_idx = [], [], [], [], []
for req in reqs:
row = step_rows.get(req.rid)
if row is None:
row = (0.0, [], [], [], [])
chosen.append(row[0])
top_val.append(torch.tensor(row[1], dtype=torch.float32))
top_idx.append(torch.tensor(row[2], dtype=torch.long))
tid_val.append(torch.tensor(row[3], dtype=torch.float32))
tid_idx.append(list(row[4]))
return LogitsProcessorOutput(
next_token_logits=None,
next_token_logprobs=torch.tensor(chosen, dtype=torch.float32),
next_token_top_logprobs_val=top_val,
next_token_top_logprobs_idx=top_idx,
next_token_token_ids_logprobs_val=tid_val,
next_token_token_ids_logprobs_idx=tid_idx,
)
@staticmethod
def _step_logprob_rows(
step: Optional[MlxStepLogprobs], rids: list[str]
) -> dict[str, tuple]:
"""Split a step's batch logprobs into per-request rows."""
if step is None:
return {}
return {
rid: (
step.chosen[i],
step.top_val[i],
step.top_idx[i],
step.token_ids_val[i],
step.token_ids_idx[i],
)
for i, rid in enumerate(rids)
}
def _collect_step_logprobs(
self,
step_rows: dict[str, tuple],
lazy_logprobs,
rids: list[str],
) -> None:
"""Materialize one pending's lazy logprobs into ``step_rows``."""
step = self._mlx_runner.collect_logprobs(lazy_logprobs)
step_rows.update(self._step_logprob_rows(step, rids))
def _forward_batch_generation_mlx(
self, batch: ScheduleBatch
) -> GenerationBatchResult:
"""Run forward pass through the MLX model runner (greedy only)."""
from sglang.srt.layers.logits_processor import LogitsProcessorOutput
"""Run one forward pass through the MLX model runner, synchronously.
forward_mode = batch.forward_mode
reqs = batch.reqs
Reachable only under ``--disable-overlap-schedule``: the default MLX
loop drives :meth:`async_forward_batch_generation_mlx` /
:meth:`finalize_mlx_result` directly and never calls ``run_batch``.
Launching and finalising back-to-back IS the synchronous path — the
lazy graph is built exactly the same way, then blocked on
immediately — so routing, logit edits, logprob collection and
chunk-head skipping have one implementation rather than two that
must be kept in step. It is also strictly cheaper than evaluating
each request as it is queued: one ``mx.async_eval`` covers the whole
batch.
"""
launch = self.async_forward_batch_generation_mlx(batch)
return self.finalize_mlx_result(launch, batch.reqs)
if forward_mode.is_idle():
return GenerationBatchResult(
logits_output=LogitsProcessorOutput(next_token_logits=None),
can_run_cuda_graph=False,
)
@staticmethod
def _stacked_edit_rows(
edit_rows: dict[str, mx.array] | None, req_ids: list[str]
) -> Optional[mx.array]:
"""Stack the per-request additive edit rows for a decode sub-batch."""
if not edit_rows:
return None
return mx.stack([edit_rows[rid] for rid in req_ids])
self._cleanup_stale_rids(forward_mode, {req.rid for req in reqs})
next_token_ids_list: list[int] = []
if forward_mode.is_extend():
# 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()
out_cache_loc_cpu = batch.out_cache_loc.cpu().tolist()
extend_seq_lens = batch.extend_lens
offset = 0 # into input_ids_cpu
slot_offset = 0 # into out_cache_loc_cpu
prefill_rids: list[tuple[str, int]] = []
extend_rids: list[tuple[str, int]] = []
decode_rids: list[str] = []
# Genuine decode steps mixed into this extend batch; see
# _route_extend_request.
decoding_rids = {r.rid for r in (batch.decoding_reqs or [])}
for i, req in enumerate(reqs):
seq_len = extend_seq_lens[i]
req_token_ids = input_ids_cpu[offset : offset + seq_len]
req_new_slots = out_cache_loc_cpu[slot_offset : slot_offset + seq_len]
offset += seq_len
slot_offset += seq_len
route = self._route_extend_request(req.rid, decoding_rids)
if route == "continuation":
next_token = self._mlx_runner.extend(
req.rid, req_token_ids, req_new_slots
)
extend_rids.append((req.rid, next_token))
elif route == "decode":
decode_rids.append(req.rid)
else: # "prefill"
prefix_slot_ids = req.prefix_indices.tolist()
full_token_ids = list(req.get_fill_ids())
next_token = self._mlx_runner.prefill(
req_id=req.rid,
new_token_ids=req_token_ids,
full_token_ids=full_token_ids,
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))
# Batch decode all existing requests at once
if decode_rids:
decode_results = self._mlx_runner.decode_batch(decode_rids)
decode_map = dict(zip(decode_rids, decode_results))
else:
decode_map = {}
prefill_map = dict(prefill_rids)
extend_map = dict(extend_rids)
for req in reqs:
if req.rid in decode_map:
next_token_ids_list.append(decode_map[req.rid])
elif req.rid in extend_map:
next_token_ids_list.append(extend_map[req.rid])
else:
next_token_ids_list.append(prefill_map[req.rid])
elif forward_mode.is_decode():
req_ids = [req.rid for req in reqs]
next_token_ids_list = self._mlx_runner.decode_batch(req_ids)
else:
raise ValueError(
f"MLX runner does not support forward mode: {forward_mode}"
)
next_token_ids = torch.tensor(
next_token_ids_list, dtype=torch.long, device="cpu"
)
return GenerationBatchResult(
logits_output=LogitsProcessorOutput(next_token_logits=None),
next_token_ids=next_token_ids,
can_run_cuda_graph=False,
)
def async_forward_batch_generation_mlx(self, batch: ScheduleBatch) -> tuple[
Union[mx.array, None],
list[MlxPendingPrefill],
list[MlxPendingExtend],
Optional[MlxPendingDecode],
str,
]:
def async_forward_batch_generation_mlx(self, batch: ScheduleBatch) -> MlxLaunch:
"""Start an async (lazy) forward pass through the MLX model runner.
Returns ``(lazy_result, prefills, extends, decode, mode)``:
* ``lazy_result`` — an ``mx.array`` that, when evaluated, forces
materialisation of the whole batch's outputs. ``None`` for
idle batches.
* ``prefills`` — list of :class:`MlxPendingPrefill` for new
requests in an extend batch.
* ``extends`` — list of :class:`MlxPendingExtend` for chunked
prefill continuations in an extend batch.
* ``decode`` — :class:`MlxPendingDecode` for the decode
sub-batch (covers full decode mode AND mixed decodes inside
an extend batch).
* ``mode`` — one of ``"idle"``, ``"decode"``, ``"extend"``.
The caller must make sure the returned pendings are fed into a
subsequent ``mx.async_eval`` or ``.item()`` / ``.tolist()`` call
— :meth:`finalize_mlx_result` does that.
See :class:`MlxLaunch` for the returned fields. The caller must
make sure the launch's pendings are fed into a subsequent
``mx.async_eval`` or ``.item()`` / ``.tolist()`` call —
:meth:`finalize_mlx_result` does that.
"""
self._ensure_mlx_pool_initialized()
@@ -292,15 +423,33 @@ class MlxTpModelWorker(TpModelWorker):
reqs = batch.reqs
if forward_mode.is_idle():
return None, [], [], None, "idle"
return MlxLaunch(
lazy_tokens=None, prefills=[], extends=[], decode=None, mode="idle"
)
self._cleanup_stale_rids(forward_mode, {req.rid for req in reqs})
if forward_mode.is_decode():
req_ids = [req.rid for req in reqs]
pending_decode = self._mlx_runner.decode_batch_start(req_ids)
mx.async_eval(pending_decode.lazy_tokens)
return pending_decode.lazy_tokens, [], [], pending_decode, "decode"
pending_decode = self._mlx_runner.decode_batch_start(
req_ids,
edit_rows=self._stacked_edit_rows(
self._build_logit_edit_rows(batch), req_ids
),
logprob_spec=self._logprob_spec_for(self._logprob_rows(batch), req_ids),
logits_hook=self._custom_logits_hook(batch),
)
mx.async_eval(
pending_decode.lazy_tokens,
*lazy_logprob_arrays(pending_decode.lazy_logprobs),
)
return MlxLaunch(
lazy_tokens=pending_decode.lazy_tokens,
prefills=[],
extends=[],
decode=pending_decode,
mode="decode",
)
if forward_mode.is_extend():
# TODO (changminbark): Implement per-batch flushing using prefix_slot_ids
@@ -313,18 +462,14 @@ class MlxTpModelWorker(TpModelWorker):
f"MLX async runner does not support forward mode: {forward_mode}"
)
def _async_extend_batch(self, batch: ScheduleBatch) -> tuple[
Union[mx.array, None],
list[MlxPendingPrefill],
list[MlxPendingExtend],
Optional[MlxPendingDecode],
str,
]:
def _async_extend_batch(self, batch: ScheduleBatch) -> MlxLaunch:
"""Launch each request in an EXTEND batch lazily and kick GPU work."""
reqs = batch.reqs
input_ids_cpu = batch.input_ids.cpu().tolist()
out_cache_loc_cpu = batch.out_cache_loc.cpu().tolist()
extend_seq_lens = batch.extend_lens
edit_rows = self._build_logit_edit_rows(batch)
logprob_rows = self._logprob_rows(batch)
offset = 0
slot_offset = 0
@@ -349,6 +494,9 @@ class MlxTpModelWorker(TpModelWorker):
req_id=req.rid,
new_token_ids=req_token_ids,
new_slot_ids=req_new_slots,
needs_logits=self._chunk_needs_logits(req),
logit_edit_row=edit_rows[req.rid] if edit_rows else None,
logprob_spec=self._logprob_spec_for(logprob_rows, [req.rid]),
)
)
elif route == "decode":
@@ -365,13 +513,18 @@ class MlxTpModelWorker(TpModelWorker):
new_slot_ids=req_new_slots,
req_pool_idx=req.req_pool_idx,
req=req,
needs_logits=self._chunk_needs_logits(req),
logit_edit_row=edit_rows[req.rid] if edit_rows else None,
logprob_spec=self._logprob_spec_for(logprob_rows, [req.rid]),
)
)
pending_mixed_decode: Optional[MlxPendingDecode] = None
if mixed_decode_rids:
pending_mixed_decode = self._mlx_runner.decode_batch_start(
mixed_decode_rids
mixed_decode_rids,
edit_rows=self._stacked_edit_rows(edit_rows, mixed_decode_rids),
logprob_spec=self._logprob_spec_for(logprob_rows, mixed_decode_rids),
)
# Stack lazy tokens so the caller has a single handle to evaluate
@@ -389,51 +542,28 @@ class MlxTpModelWorker(TpModelWorker):
else:
lazy_stacked = None
for p in pending_prefills:
async_args.extend(self._cache_state(p.cache))
for e in pending_extends:
async_args.extend(self._cache_state(self._mlx_runner._req_caches[e.req_id]))
for pending in (*pending_prefills, *pending_extends):
async_args.extend(self._mlx_runner.cache_state_arrays([pending.cache]))
async_args.extend(lazy_logprob_arrays(pending.lazy_logprobs))
if pending_mixed_decode is not None:
async_args.append(pending_mixed_decode.lazy_tokens)
for c_list in pending_mixed_decode.caches:
async_args.extend(self._cache_state(c_list))
async_args.extend(lazy_logprob_arrays(pending_mixed_decode.lazy_logprobs))
async_args.extend(
self._mlx_runner.cache_state_arrays(pending_mixed_decode.caches)
)
if async_args:
mx.async_eval(*async_args)
return (
lazy_stacked,
pending_prefills,
pending_extends,
pending_mixed_decode,
"extend",
return MlxLaunch(
lazy_tokens=lazy_stacked,
prefills=pending_prefills,
extends=pending_extends,
decode=pending_mixed_decode,
mode="extend",
)
@staticmethod
def _cache_state(cache_list) -> list[mx.array]:
"""Flatten a per-layer cache list to its ``state`` arrays."""
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,
prev_pending: MlxPendingDecode,
) -> tuple[mx.array, list, list, MlxPendingDecode, str]:
def async_chained_decode_mlx(self, prev_pending: MlxPendingDecode) -> MlxLaunch:
"""Launch a decode step that chains off a still-lazy previous decode.
This is the "no idle gap" pipelining primitive: build the next
@@ -455,22 +585,21 @@ class MlxTpModelWorker(TpModelWorker):
* ``prev_pending`` should be finalised BEFORE the returned
pending, so per-request token lists are appended in order.
Returns a 5-tuple matching
:meth:`async_forward_batch_generation_mlx` for the decode case:
``(lazy_tokens, [], [], pending_decode, "decode")``. The empty
prefill/extend lists are always absent for chained decodes.
Returns an :class:`MlxLaunch` in ``"decode"`` mode; its prefill
and extend lists are always empty for a chained decode.
"""
pending = self._mlx_runner.decode_batch_start_chained(prev_pending)
mx.async_eval(pending.lazy_tokens)
return pending.lazy_tokens, [], [], pending, "decode"
return MlxLaunch(
lazy_tokens=pending.lazy_tokens,
prefills=[],
extends=[],
decode=pending,
mode="decode",
)
def finalize_mlx_result(
self,
prefills: list[MlxPendingPrefill],
extends: list[MlxPendingExtend],
decode: Optional[MlxPendingDecode],
mode: str,
reqs: list,
self, launch: MlxLaunch, reqs: list
) -> GenerationBatchResult:
"""Materialise a lazy MLX result into a :class:`GenerationBatchResult`.
@@ -480,28 +609,40 @@ class MlxTpModelWorker(TpModelWorker):
"""
from sglang.srt.layers.logits_processor import LogitsProcessorOutput
if mode == "idle":
decode = launch.decode
if launch.mode == "idle":
return GenerationBatchResult(
logits_output=LogitsProcessorOutput(next_token_logits=None),
can_run_cuda_graph=False,
)
if mode == "decode":
step_logprob_rows: dict[str, tuple] = {}
if launch.mode == "decode":
assert decode is not None
next_tokens_list = self._mlx_runner.decode_batch_finalize(decode)
self._collect_step_logprobs(
step_logprob_rows, decode.lazy_logprobs, decode.req_ids
)
elif mode == "extend":
elif launch.mode == "extend":
prefill_map: dict[str, int] = {}
for pending_p in prefills:
for pending_p in launch.prefills:
prefill_map[pending_p.req_id] = self._mlx_runner.prefill_finalize(
pending_p
)
self._collect_step_logprobs(
step_logprob_rows, pending_p.lazy_logprobs, [pending_p.req_id]
)
extend_map: dict[str, int] = {}
for pending_e in extends:
for pending_e in launch.extends:
extend_map[pending_e.req_id] = self._mlx_runner.extend_finalize(
pending_e
)
self._collect_step_logprobs(
step_logprob_rows, pending_e.lazy_logprobs, [pending_e.req_id]
)
decode_map: dict[str, int] = {}
if decode is not None:
@@ -509,6 +650,9 @@ class MlxTpModelWorker(TpModelWorker):
decode_map = {
rid: tok for rid, tok in zip(decode.req_ids, mixed_tokens)
}
self._collect_step_logprobs(
step_logprob_rows, decode.lazy_logprobs, decode.req_ids
)
next_tokens_list = []
for req in reqs:
@@ -520,11 +664,16 @@ class MlxTpModelWorker(TpModelWorker):
next_tokens_list.append(prefill_map[req.rid])
else:
raise ValueError(f"Unknown MLX async mode: {mode}")
raise ValueError(f"Unknown MLX async mode: {launch.mode}")
next_token_ids = torch.tensor(next_tokens_list, dtype=torch.long, device="cpu")
logits_output = (
self._assemble_logprob_output(step_logprob_rows, reqs)
if step_logprob_rows
else LogitsProcessorOutput(next_token_logits=None)
)
return GenerationBatchResult(
logits_output=LogitsProcessorOutput(next_token_logits=None),
logits_output=logits_output,
next_token_ids=next_token_ids,
can_run_cuda_graph=False,
)
+24 -1
View File
@@ -276,7 +276,12 @@ from sglang.srt.observability.trace import process_tracing_init, trace_set_threa
from sglang.srt.parser.reasoning_parser import ReasoningParser
from sglang.srt.platforms import current_platform
from sglang.srt.plugins import load_plugins
from sglang.srt.runtime_context import get_context, get_parallel, publish
from sglang.srt.runtime_context import (
get_context,
get_device,
get_parallel,
publish,
)
from sglang.srt.sampling.sampling_batch_info import SamplingBatchInfo
from sglang.srt.sampling.sampling_params import TOP_K_ALL
from sglang.srt.server_args import PortArgs, ServerArgs
@@ -2605,6 +2610,24 @@ class Scheduler(
self._add_request_to_queue(req)
return
if (
get_device().mlx_enable_sampling
and req.return_logprob
and 0 <= req.logprob_start_len < len(req.origin_input_ids)
):
# The MLX sampling path computes output logprobs only; the
# prefill result carries no input_token_logprobs, so letting
# this through would crash output processing.
error_msg = (
"Prompt input logprobs (logprob_start_len) are not supported "
"on the MLX sampling path; omit logprob_start_len to get "
"output logprobs."
)
req.logprob_start_len = -1
req.set_finish_with_abort(error_msg)
self._add_request_to_queue(req)
return
if recv_req.return_routed_experts:
error_msg = None
if recv_req.routed_experts_start_len < 0:
@@ -327,7 +327,13 @@ class SchedulerLogprobResultProcessor:
else:
self._initialize_empty_logprob_containers(req)
if req.logprob.top_logprobs_num > 0:
if (
req.logprob.top_logprobs_num > 0
and output.next_token_top_logprobs_val is not None
):
# Guarded like next_token_logprobs above: a backend may leave the
# top-logprob fields unset even for a request that asked for them
# (indexing None raises TypeError).
req.logprob.output_top_logprobs_val.append(
output.next_token_top_logprobs_val[i]
)
+26 -3
View File
@@ -1198,6 +1198,25 @@ class ServerArgs:
NS("device"),
] = 1
random_seed: A[Optional[int], "The random seed.", NS("device")] = None
mlx_enable_sampling: A[
bool,
(
"MLX backend only: sample decode tokens (temperature / top-k / "
"top-p / min-p) instead of greedy argmax. Sampling runs inside "
"the lazy MLX graph, so it works with the overlap scheduler; "
"first tokens from prefill/extend are sampled too. Greedy "
"requests keep exact argmax behavior. Also enables on the MLX "
"path: grammar vocab masks and custom logit processors (these "
"break decode chaining per step; custom processors run on "
"pure-decode steps only), logit_bias, output logprobs (sampled "
"token / top-k / token_ids; prompt input logprobs are not "
"computed), NaN sanitization (SGLANG_SANITIZE_NAN_LOGITS), and "
"per-request sampling_seed under "
"--enable-deterministic-inference (deterministic within MLX "
"only). Penalties are not applied."
),
NS("device"),
] = False
watchdog_timeout: A[
float,
"Set watchdog timeout in seconds. If a forward batch takes longer than this, the server will crash to prevent hanging.",
@@ -5359,9 +5378,13 @@ class ServerArgs:
elif model_arch in ["GptOssForCausalLM"]:
# Attention backend selection + XPU dtype validation moved to the
# override registry (arg_groups/overrides.py: _gpt_oss_overrides).
# None of these backends exist on MPS; attention_backend is still
# unset there at this point (the torch_native default fills later).
if not is_mps():
# None of these backends exist on MPS, and under MLX attention
# runs inside the MLX runner, so attention_backend is still unset
# at this point (the torch_native default fills later). macOS
# *without* MLX is not exempt: it has no runner of its own, so it
# must still be held to the supported-backend list instead of
# silently landing on torch_native (no SWA, no sinks).
if not (is_mps() and use_mlx()):
supported_backends = [
"triton",
"trtllm_mha",
@@ -5,8 +5,9 @@ uses per-head attention sinks, so it exercises the MLX backend's
sliding-window path end to end. Two guards:
1. ``TestGptOssMlxCorrectness`` black-box serving smoke against a running
server, including a >128-token prompt so the sliding window actually
engages.
server with the radix cache enabled (the default KV path), including a
>128-token prompt so the sliding window actually engages and a repeated
prompt so a radix prefix hit must reproduce the cold greedy output.
2. ``TestGptOssMlxReferenceCorrectness`` token-for-token equivalence of
``MlxModelRunner`` greedy decoding against raw, unpatched mlx_lm greedy
generation. SGLang keeps full KV and applies banded masks /
@@ -117,10 +118,14 @@ class TestGptOssMlxCorrectness(CustomTestCase):
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=[
# Radix cache stays enabled (the default): sliding-window
# layers keep windowed per-request KV, the shared pool holds
# full-attention layers, and prefix hits recompute the
# prefix, so serving must stay correct without
# --disable-radix-cache.
"--trust-remote-code",
"--tp-size",
"1",
"--disable-radix-cache",
"--disable-cuda-graph",
"--mem-fraction-static",
MEM_FRACTION_STATIC,
@@ -189,6 +194,24 @@ class TestGptOssMlxCorrectness(CustomTestCase):
)
self.assertIn("BLUEBERRY", text.upper())
def test_radix_prefix_hit_reproduces_greedy_output(self):
# The server runs with the radix cache enabled. Sending the same
# >128-token prompt twice makes the second request hit the cached
# prefix; on sliding-window models the runner recomputes the prefix
# (windowed KV keeps no pool history), and greedy output must be
# identical to the cold request.
messages = [
{"role": "system", "content": "You are a concise assistant."},
{
"role": "user",
"content": _NUMBER_LIST
+ ". Which number comes right after 41? Answer briefly.",
},
]
cold = self._chat(messages, max_tokens=48)
hit = self._chat(messages, max_tokens=48)
self.assertEqual(cold, hit)
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
class TestGptOssMlxReferenceCorrectness(CustomTestCase):
@@ -46,6 +46,7 @@ if _HAS_MLX:
MlxPendingJob,
SchedulerMlxOverlapMixin,
)
from sglang.srt.hardware_backend.mlx.tp_worker import MlxLaunch
from sglang.srt.managers.scheduler_components import (
batch_result_processor as batch_result_processor_module,
)
@@ -309,7 +310,7 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
new_slot_ids=[4],
req_pool_idx=0,
)
MlxModelRunner._eval_with_cache(pending.lazy_token, pending.cache)
runner.eval_pending(pending)
mx.eval(*runner._attention_kv_pool.all_buffers())
runner.prefill_finalize(pending)
@@ -350,7 +351,8 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
calls.append(
(len(caches), batched_input.tolist(), list(helper_req_ids))
)
return mx.array(list(range(len(caches))), dtype=mx.int32)
# Last-token logits whose argmax is the row index.
return mx.eye(len(caches), 8, dtype=mx.float32)
def fail_native(*args, **kwargs):
raise AssertionError("dense decode should use batched attention")
@@ -386,7 +388,8 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
def fake_batched(caches, batched_input, helper_req_ids):
calls.append((len(caches), batched_input.tolist(), list(helper_req_ids)))
return mx.array([8], dtype=mx.int32)
# Last-token logits whose argmax is token 8.
return mx.arange(9, dtype=mx.float32)[None, :]
def fail_native(*args, **kwargs):
raise AssertionError("dense chained decode should use batched attention")
@@ -502,12 +505,13 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
]
]
lazy_tokens = runner._decode_with_batched_attention(
lazy_logits = runner._decode_with_batched_attention(
cache,
mx.array([[7]], dtype=mx.int32),
["r0"],
)
mx.eval(lazy_tokens, *MlxModelRunner._cache_state_arrays(cache))
lazy_tokens = mx.argmax(lazy_logits, axis=-1)
mx.eval(lazy_tokens, *MlxModelRunner.cache_state_arrays(cache))
self.assertEqual(lazy_tokens.tolist(), [0])
self.assertEqual(cache[0][0].offset, 1)
@@ -558,6 +562,9 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
req_pool_idx={"r0": 0, "r1": 1},
req_to_token_pool=req_to_token_pool,
attention_layer_indices=[0],
# The fused scatter addresses pool buffers by full-attention index,
# so the context requires the map whenever the RoPE kernel is live.
full_kv_pool_index_by_layer={0: 0},
)
self.assertEqual(ctx.seq_lens, [1, 2])
@@ -578,7 +585,8 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
def fake_hybrid(caches, batched_input, helper_req_ids):
calls.append((len(caches), batched_input.tolist(), list(helper_req_ids)))
return mx.array([4, 5], dtype=mx.int32)
# Last-token logits whose argmax is 4 for row 0, 5 for row 1.
return mx.eye(8, dtype=mx.float32)[4:6]
def fail_batched(*args, **kwargs):
raise AssertionError(
@@ -713,7 +721,7 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
new_slot_ids=[4],
req_pool_idx=req.req_pool_idx,
)
MlxModelRunner._eval_with_cache(pending.lazy_token, pending.cache)
runner.eval_pending(pending)
runner.prefill_finalize(pending)
self.assertEqual(runner.model.seen_inputs, [[[13]]])
@@ -770,7 +778,7 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
req_pool_idx=req.req_pool_idx,
req=req,
)
MlxModelRunner._eval_with_cache(pending.lazy_token, pending.cache)
runner.eval_pending(pending)
runner.prefill_finalize(pending)
tracked = [FakeNativeCache(), None]
runner._req_to_token_pool.auxiliary_state_pool.restore_cache(
@@ -832,7 +840,7 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
req_pool_idx=req.req_pool_idx,
req=req,
)
MlxModelRunner._eval_with_cache(pending.lazy_token, pending.cache)
runner.eval_pending(pending)
runner.prefill_finalize(pending)
tracked = [FakeNativeCache(), None]
runner._req_to_token_pool.auxiliary_state_pool.restore_cache(
@@ -1094,11 +1102,13 @@ class TestMlxOverlapScheduler(unittest.TestCase):
scheduler.last_batch = stale_batch
pending = MlxPendingJob(
lazy_tokens=None,
prefills=["prefill"],
extends=[],
decode=None,
mode="extend",
launch=MlxLaunch(
lazy_tokens=None,
prefills=["prefill"],
extends=[],
decode=None,
mode="extend",
),
batch_copy=batch_copy,
schedule_batch=schedule_batch,
reqs=[SimpleNamespace(rid="r0")],
@@ -203,8 +203,14 @@ class TestSchedulerProfilerManagerMPS(unittest.TestCase):
mgr._init_profile(output_dir, None, None, None, None, None, False, "test")
return mgr
# MetalCaptureProfiler has two strategies: start_mlx drives
# mx.metal.start_capture, start_mps drives torch.mps.profiler.metal_capture.
# This manager takes the MPS one, so that is the symbol to stand in for --
# patching mx.metal here leaves the real Metal capture running, which fails
# with "Capture layer is not inserted" unless MTL_CAPTURE_ENABLED=1 is set
# in the environment.
def test_start_profile_failure_does_not_crash(self):
import mlx.core as mx
import torch
from sglang.srt.hardware_backend.mlx.profiler import (
apply_metal_profiler_patches,
@@ -215,20 +221,22 @@ class TestSchedulerProfilerManagerMPS(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
mgr = self._make_manager(tmp)
with patch.object(
mx.metal,
"start_capture",
torch.mps.profiler,
"metal_capture",
side_effect=RuntimeError("Capture layer is not inserted"),
):
result = mgr._start_profile()
self.assertFalse(result.success)
self.assertIn("Capture layer is not inserted", result.message)
self.assertFalse(mgr.profile_in_progress)
self.assertIsNone(mgr.torch_profiler)
def test_start_profile_success_with_mock_capture(self):
from unittest.mock import MagicMock
from unittest.mock import patch as mock_patch
import mlx.core as mx
import torch
from sglang.srt.hardware_backend.mlx.profiler import (
apply_metal_profiler_patches,
@@ -238,14 +246,17 @@ class TestSchedulerProfilerManagerMPS(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
mgr = self._make_manager(tmp)
with mock_patch.object(mx.metal, "start_capture"), mock_patch.object(
mx.metal, "stop_capture"
capture_ctx = MagicMock()
with mock_patch.object(
torch.mps.profiler, "metal_capture", return_value=capture_ctx
), mock_patch("torch.distributed.barrier"):
result = mgr._start_profile()
self.assertTrue(result.success)
self.assertTrue(result.success, result.message)
self.assertTrue(mgr.profile_in_progress)
capture_ctx.__enter__.assert_called_once()
mgr._stop_profile()
self.assertFalse(mgr.profile_in_progress)
capture_ctx.__exit__.assert_called_once()
if __name__ == "__main__":
@@ -202,6 +202,13 @@ class TestMlxReferenceCorrectness(CustomTestCase):
self.runner.remove_request(rid)
return out
def _truncate_at_eos(self, seq):
"""``seq`` up to and including its first EOS (whole seq if none)."""
for i, tok in enumerate(seq):
if tok in self.eos_ids:
return seq[: i + 1]
return list(seq)
def _diff_msg(self, prompt, ref, sgl):
horizon = min(len(ref), len(sgl))
first = next((j for j in range(horizon) if ref[j] != sgl[j]), horizon)
@@ -246,10 +253,18 @@ class TestMlxReferenceCorrectness(CustomTestCase):
for rid in rids:
self.runner.remove_request(rid)
# Compare up to and including the first EOS. The horizon is fixed so
# the batch composition never changes mid-run, which walks past EOS on
# short answers -- and there the distribution is near-degenerate, so
# batched and solo argmax can pick different tokens from a numerical
# tie. That is float reduction order (a padded batched SDPA vs an
# unpadded solo one), not state bleed: any cache crossover would show
# up while the model still has an opinion. Measured on this fixture,
# case 1 reaches EOS at index 2 and first differs at index 6.
for i, (prompt, _, _) in enumerate(self.cases):
self.assertEqual(
batched[i], solo[i], self._diff_msg(prompt, solo[i], batched[i])
)
want = self._truncate_at_eos(solo[i])
got = self._truncate_at_eos(batched[i])
self.assertEqual(got, want, self._diff_msg(prompt, want, got))
if __name__ == "__main__":
@@ -0,0 +1,728 @@
"""Unit tests for MLX in-graph sampling (hardware_backend/mlx/sampling.py)."""
from __future__ import annotations
import importlib.util
import unittest
from collections import Counter
from sglang.test.ci.ci_register import register_cpu_ci, register_mlx_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=2, suite="base-a-test-cpu")
register_mlx_ci(est_time=20, suite="stage-a-unit-test-mlx")
_HAS_MLX = importlib.util.find_spec("mlx") is not None
_SKIP_REASON = "requires mlx"
if _HAS_MLX:
import mlx.core as mx
from sglang.srt.hardware_backend.mlx.sampling import (
DEFAULT_SAMPLING_SEED,
GREEDY_PARAMS,
MAX_BOUNDED_TOP_K,
MlxLogprobSpec,
MlxSamplingParams,
_candidate_width,
_gumbel_noise,
_murmur_hash32,
all_greedy,
compute_logprobs,
sample_tokens,
sanitize_logits,
)
def _reference_murmur3(seed: int, pos: int, col: int) -> int:
"""Pure-Python MurmurHash3 mirroring the Triton kernel in
sglang/kernels/ops/sampling/murmur_hash.py: blocks seed_low,
seed_high, position, column; length-16 finalization; fmix32."""
def mix(h: int, k: int) -> int:
k = (k * 0xCC9E2D51) & 0xFFFFFFFF
k = ((k << 15) | (k >> 17)) & 0xFFFFFFFF
k = (k * 0x1B873593) & 0xFFFFFFFF
h ^= k
h = ((h << 13) | (h >> 19)) & 0xFFFFFFFF
return (h * 5 + 0xE6546B64) & 0xFFFFFFFF
seed &= 0xFFFFFFFFFFFFFFFF
h = mix(0, seed & 0xFFFFFFFF)
h = mix(h, (seed >> 32) & 0xFFFFFFFF)
h = mix(h, pos & 0xFFFFFFFF)
h = mix(h, col & 0xFFFFFFFF)
h ^= 16
h ^= h >> 16
h = (h * 0x85EBCA6B) & 0xFFFFFFFF
h ^= h >> 13
h = (h * 0xC2B2AE35) & 0xFFFFFFFF
h ^= h >> 16
return h
def _params(temperature=1.0, top_k=1 << 30, top_p=1.0, min_p=0.0, seed=None):
return MlxSamplingParams(
temperature=temperature, top_k=top_k, top_p=top_p, min_p=min_p, seed=seed
)
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
class TestMurmurHashPort(CustomTestCase):
def test_matches_pure_python_reference(self):
"""Guards the mx uint32 port of the CUDA murmur kernel: any drift in
wraparound/shift/block-order semantics changes seeded sampling."""
seeds = [0, 1, 42, 2**31, 2**63 + 12345]
positions = [0, 7, 1023, 2**31 - 1, 5]
vocab = 64
hashed = _murmur_hash32(seeds=seeds, positions=positions, vocab_size=vocab)
mx.eval(hashed)
for row, (seed, pos) in enumerate(zip(seeds, positions)):
for col in (0, 1, vocab // 2, vocab - 1):
self.assertEqual(
int(hashed[row, col].item()),
_reference_murmur3(seed, pos, col),
msg=f"mismatch at seed={seed} pos={pos} col={col}",
)
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
class TestSampleTokens(CustomTestCase):
VOCAB = 32
def _logits(self, batch_size: int, key_int: int = 0) -> mx.array:
return (
mx.random.normal(shape=(batch_size, self.VOCAB), key=mx.random.key(key_int))
* 3.0
)
def _draw(self, logits, params, positions=None, n=200, key_start=100):
"""Sample n times with distinct keys, return per-row token Counters."""
batch_size = logits.shape[0]
positions = positions if positions is not None else [5] * batch_size
counters = [Counter() for _ in range(batch_size)]
for i in range(n):
toks = sample_tokens(
last_logits=logits,
params=params,
positions=positions,
key=mx.random.key(key_start + i),
)
mx.eval(toks)
for row, t in enumerate(toks.tolist()):
counters[row][int(t)] += 1
return counters
@staticmethod
def _reference_support(probs, top_k, top_p, min_p):
"""Independent replica of the mask, in pure Python, on sorted probs."""
order = sorted(range(len(probs)), key=lambda i: (-probs[i], i))
keep, cum = [], 0.0
for rank, idx in enumerate(order):
p = probs[idx]
masked = (
rank >= min(top_k, len(probs))
or cum > top_p
or p < probs[order[0]] * min_p
)
cum += p
if not masked:
keep.append(idx)
return set(keep)
def _probs(self, logits):
probs = mx.softmax(logits.astype(mx.float32), axis=-1)
mx.eval(probs)
return probs[0].tolist()
def test_greedy_rows_match_argmax_in_mixed_batch(self):
"""A greedy row must return exactly argmax even when other rows in
the batch sample guards the where() row-select and the sglang
greedy convention (top_k == 1)."""
logits = self._logits(3)
expected = mx.argmax(logits, axis=-1).tolist()
params = [_params(top_k=1), _params(temperature=0.7), _params(top_k=1)]
counters = self._draw(logits, params, n=25)
self.assertEqual(set(counters[0]), {expected[0]})
self.assertEqual(set(counters[2]), {expected[2]})
def test_filter_supports_match_reference(self):
"""The sampled support must stay inside the independently computed
mask for each filter and for their combination guards the rank
mask, the nucleus exclusion, the min_p threshold, and the
sorted->vocab index map."""
cases = [
("top_k=2", dict(top_k=2)),
("top_p=0.6", dict(top_p=0.6)),
("min_p=0.3", dict(min_p=0.3)),
("top_k=8,top_p=0.7,min_p=0.05", dict(top_k=8, top_p=0.7, min_p=0.05)),
]
for label, kwargs in cases:
with self.subTest(label):
logits = self._logits(1, key_int=3)
support = self._reference_support(
self._probs(logits),
kwargs.get("top_k", 1 << 30),
kwargs.get("top_p", 1.0),
kwargs.get("min_p", 0.0),
)
drawn = set(self._draw(logits, [_params(**kwargs)], n=400)[0])
self.assertTrue(drawn <= support, f"{label}: extra {drawn - support}")
self.assertTrue(drawn, f"{label}: nothing sampled")
def test_seeded_row_is_deterministic_and_key_independent(self):
"""A row with sampling_seed must produce the same token regardless
of the RNG key or batch composition the murmur-gumbel path only
depends on (seed, position, logits)."""
logits = self._logits(2, key_int=5)
seeded = _params(temperature=1.0, seed=1234)
tok_solo = sample_tokens(
last_logits=logits[:1],
params=[seeded],
positions=[9],
key=mx.random.key(0),
)
tok_other_key = sample_tokens(
last_logits=logits[:1],
params=[seeded],
positions=[9],
key=mx.random.key(999),
)
tok_in_batch = sample_tokens(
last_logits=logits,
params=[seeded, _params(temperature=0.8)],
positions=[9, 3],
key=mx.random.key(7),
)
mx.eval(tok_solo, tok_other_key, tok_in_batch)
self.assertEqual(tok_solo.tolist(), tok_other_key.tolist())
self.assertEqual(int(tok_in_batch[0].item()), int(tok_solo[0].item()))
def test_seeded_row_unaffected_by_batchmate_filtering(self):
"""A seeded row's token must not change when a batchmate triggers
the top-k/top-p sort path guards the vocab-id-space noise
contract (regression: noise was applied in sorted-rank space when
any row filtered, so batch composition changed seeded tokens)."""
# Near-uniform seeded row: the Gumbel noise decides the token, so
# a change of noise index space is guaranteed to show up.
seeded_logits = (
mx.random.normal(shape=(1, self.VOCAB), key=mx.random.key(8)) * 0.05
)
mate_logits = (
mx.random.normal(shape=(1, self.VOCAB), key=mx.random.key(9)) * 3.0
)
logits = mx.concatenate([seeded_logits, mate_logits], axis=0)
seeded = _params(seed=4321)
solo = sample_tokens(
last_logits=logits[:1],
params=[seeded],
positions=[6],
key=mx.random.key(0),
)
with_filtering_mate = sample_tokens(
last_logits=logits,
params=[seeded, _params(temperature=1.2, top_k=2)],
positions=[6, 11],
key=mx.random.key(55),
)
mx.eval(solo, with_filtering_mate)
self.assertEqual(int(with_filtering_mate[0].item()), int(solo[0].item()))
def test_bounded_top_k_picks_the_same_token_as_the_full_vocab_chain(self):
"""The bounded top-K chain is an optimization, not a policy change:
a seeded row must pick the same token whether or not the batch is
eligible for it. A batchmate without a finite top_k pushes the
whole batch back onto the full-vocab chain, so the same seeded row
is sampled both ways here."""
logits = self._logits(2, key_int=11)
seeded = _params(top_k=4, seed=2024)
bounded = sample_tokens(
last_logits=logits[:1],
params=[seeded],
positions=[6],
key=mx.random.key(0),
)
# min_p alone leaves top_k at TOP_K_ALL, so this batch falls back.
full_vocab = sample_tokens(
last_logits=logits,
params=[seeded, _params(min_p=0.1)],
positions=[6, 2],
key=mx.random.key(3),
)
mx.eval(bounded, full_vocab)
self.assertEqual(int(full_vocab[0].item()), int(bounded[0].item()))
def test_candidate_width_gates_the_bounded_chain(self):
"""Only a batch whose widest top_k fits inside both the bound and
the vocabulary may shrink the chain; anything else must return the
full vocab size (which selects the scatter-back path)."""
vocab = 4096
for label, params, expected in [
("under the bound", [_params(top_k=64)], 64),
("at the bound", [_params(top_k=MAX_BOUNDED_TOP_K)], MAX_BOUNDED_TOP_K),
("past the bound", [_params(top_k=MAX_BOUNDED_TOP_K + 1)], vocab),
("no top_k (TOP_K_ALL)", [_params()], vocab),
("top_k == vocab", [_params(top_k=vocab)], vocab),
("widest row wins", [_params(top_k=4), _params(top_k=64)], 64),
("one unbounded row", [_params(top_k=4), _params(top_p=0.9)], vocab),
]:
with self.subTest(label):
self.assertEqual(_candidate_width(params, vocab), expected)
def test_seeded_row_varies_with_position(self):
"""Positions feed the hash, so a fixed seed must not freeze the
distribution across steps: over many positions the sampled tokens
must not all collapse to one value (vocab of near-uniform probs)."""
logits = mx.zeros((1, self.VOCAB)) # uniform distribution
seeded = [_params(seed=77)]
toks = set()
for pos in range(40):
t = sample_tokens(
last_logits=logits,
params=seeded,
positions=[pos],
key=mx.random.key(0),
)
mx.eval(t)
toks.add(int(t[0].item()))
self.assertGreater(len(toks), 5, toks)
def test_seeded_noise_is_finite(self):
"""The uniform draw is clamped to [2**-32, 1 - 2**-24] before the
double log: uint32(0xFFFFFFFF) rounds UP to 2**32 in float32, so an
unclamped u can exceed 1 and make log(-log u) NaN and u == 1 gives
+inf, which would deterministically force that token."""
# Sanity-check the hazard the clamp exists for.
u_max = mx.array([0xFFFFFFFF], dtype=mx.uint32).astype(mx.float32) / float(
0xFFFFFFFF
)
mx.eval(u_max)
self.assertGreaterEqual(float(u_max.item()), 1.0)
self.assertFalse(bool(mx.isfinite(-mx.log(-mx.log(u_max))).item()))
noise = _gumbel_noise(
params=[_params(seed=1), _params(seed=2**63 - 1)],
positions=[0, 4096],
shape=(2, 1 << 16),
key=mx.random.key(0),
)
mx.eval(noise)
self.assertTrue(bool(mx.all(mx.isfinite(noise)).item()))
def test_seed_with_min_p_is_supported(self):
"""seed + min_p is well defined here (the pytorch backend asserts on
the combination): Gumbel-max over unnormalized masked weights is
invariant to the missing renormalization, which is exactly the TODO
at layers/sampler.py's multinomial_with_seed path."""
logits = self._logits(1, key_int=4)
support = self._reference_support(self._probs(logits), 1 << 30, 1.0, 0.3)
tok = sample_tokens(
last_logits=logits,
params=[_params(seed=1234, min_p=0.3)],
positions=[9],
key=mx.random.key(0),
)
mx.eval(tok)
self.assertIn(int(tok[0].item()), support)
def test_temperature_sharpens_distribution(self):
"""Lower temperature must concentrate mass on the argmax token —
guards the per-row temperature division (e.g. broadcasting bugs
that apply one row's temperature to all rows)."""
logits = self._logits(2, key_int=6)
expected0 = int(mx.argmax(logits[0]).item())
params = [_params(temperature=0.05), _params(temperature=5.0)]
counters = self._draw(logits, params, n=200)
self.assertGreater(counters[0][expected0] / 200.0, 0.95)
self.assertGreater(len(counters[1]), 5, "high temp should spread mass")
def test_greedy_helpers(self):
self.assertTrue(all_greedy([GREEDY_PARAMS, _params(top_k=1)]))
self.assertFalse(all_greedy([GREEDY_PARAMS, _params(temperature=0.9)]))
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
class TestSanitizeAndLogprobs(CustomTestCase):
VOCAB = 16
def test_sanitize_matches_nan_to_num_semantics(self):
"""Guards the port of sanitize_nan_logits' exact replacement values
(+-1e30, not dtype extremes temperature division would overflow
dtype extremes back to inf and softmax them to NaN)."""
import struct
def f32(v):
return struct.unpack("f", struct.pack("f", v))[0]
x = mx.array([[1.0, float("nan"), float("inf"), -float("inf")]])
out = sanitize_logits(x)
mx.eval(out)
self.assertEqual(out.tolist(), [[1.0, f32(-1e30), f32(1e30), f32(-1e30)]])
def test_logprobs_match_reference_and_row_shapes(self):
"""compute_logprobs must equal log_softmax(logits/temp) per row and
cut top-k / token-ids to each row's requested shape — guards the
per-row temperature broadcast and the spec row alignment."""
import math
logits = mx.random.normal(shape=(2, self.VOCAB), key=mx.random.key(11))
params = [_params(temperature=0.5), _params(temperature=2.0)]
tokens = mx.array([3, 7], dtype=mx.uint32)
spec = MlxLogprobSpec(top_ks=(2, 0), token_ids=(None, (1, 4)))
lp = compute_logprobs(logits, params, tokens, spec)
mx.eval(*[a for a in [lp.chosen, lp.top_val, lp.top_idx] if a is not None])
raw = logits.tolist()
for row, temp in ((0, 0.5), (1, 2.0)):
scaled = [v / temp for v in raw[row]]
m = max(scaled)
lse = m + math.log(sum(math.exp(v - m) for v in scaled))
ref = [v - lse for v in scaled]
chosen_token = int(tokens[row].item())
self.assertAlmostEqual(
float(lp.chosen[row].item()), ref[chosen_token], places=4
)
if row == 0:
expect_top = sorted(ref, reverse=True)[:2]
got = lp.top_val[row].tolist()[:2]
for a, b in zip(got, expect_top):
self.assertAlmostEqual(a, b, places=4)
if row == 1:
got = lp.token_ids_val[1].tolist()
self.assertAlmostEqual(got[0], ref[1], places=4)
self.assertAlmostEqual(got[1], ref[4], places=4)
self.assertIsNone(lp.token_ids_val[0])
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
class TestRunnerSelectTokens(CustomTestCase):
"""_select_tokens_with_logprobs lifecycle on a bare runner (object.__new__)."""
class _FakeCache:
def __init__(self, offset):
self.offset = offset
class _FakeLayout:
has_auxiliary_state = False
first_attention_layer_index = 0
def _runner(self, enable_sampling):
from sglang.srt.hardware_backend.mlx.model_runner import MlxModelRunner
runner = object.__new__(MlxModelRunner)
runner._enable_sampling = enable_sampling
runner._cache_layout = self._FakeLayout()
runner._req_sampling = {}
runner._rng_key = mx.random.key(0) if enable_sampling else None
return runner
def test_disabled_and_greedy_paths_consume_no_rng(self):
"""Flag-off and all-greedy batches must return exact argmax and
leave the RNG key untouched guards the byte-exact greedy
contract that the e2e temp=0 test relies on."""
logits = mx.random.normal(shape=(2, 16), key=mx.random.key(1))
expected = mx.argmax(logits, axis=-1).tolist()
caches = [[self._FakeCache(4)], [self._FakeCache(9)]]
disabled = self._runner(enable_sampling=False)
toks = disabled._select_tokens_with_logprobs(logits, ["a", "b"], caches)[0]
self.assertEqual(toks.tolist(), expected)
enabled = self._runner(enable_sampling=True)
enabled._req_sampling = {"a": GREEDY_PARAMS, "b": _params(top_k=1)}
key_before = enabled._rng_key
toks = enabled._select_tokens_with_logprobs(logits, ["a", "b"], caches)[0]
self.assertEqual(toks.tolist(), expected)
self.assertIs(enabled._rng_key, key_before)
def test_discarded_chunk_without_trunk_stays_greedy_and_consumes_no_rng(self):
"""A needs_logits=False chunk on a model without a headless trunk
must not sample: consuming RNG for a discarded token would make
the final output depend on prefill chunking."""
def full_model_only(input_ids, cache=None):
return mx.zeros((1, input_ids.shape[1], 16))
runner = self._runner(enable_sampling=True)
runner.model = full_model_only # no .model attr -> no trunk
runner._req_sampling = {"a": _params(temperature=1.0)}
key_before = runner._rng_key
tok, lazy_logprobs = runner._forward_lazy_token(
mx.array([[3, 4]], dtype=mx.int32),
[self._FakeCache(2)],
needs_logits=False,
req_id="a",
)
self.assertIsNone(lazy_logprobs)
mx.eval(tok)
self.assertEqual(tok.tolist(), [0]) # argmax of zeros
self.assertIs(runner._rng_key, key_before)
def test_logit_edits_gate_greedy_and_sampled_and_logprobs(self):
"""An additive -inf edit row must exclude a token from greedy argmax,
from sampling, AND from the reported logprob distribution guards
the edits-before-selection ordering (a regression that samples raw
logits would pass every other test on near-uniform inputs)."""
runner = self._runner(enable_sampling=True)
runner._req_sampling = {"g": GREEDY_PARAMS, "s": _params(temperature=1.0)}
caches = [[self._FakeCache(4)], [self._FakeCache(4)]]
logits = mx.zeros((2, 8))
logits = mx.put_along_axis(
logits,
mx.array([[7], [7]], dtype=mx.uint32),
mx.array([[5.0], [5.0]]),
axis=-1,
) # token 7 dominates both rows
edits = mx.zeros((2, 8))
edits = mx.put_along_axis(
edits,
mx.array([[7], [7]], dtype=mx.uint32),
mx.array([[-float("inf")], [-float("inf")]]),
axis=-1,
) # ...but is masked out for both
spec = MlxLogprobSpec(top_ks=(1, 1), token_ids=(None, None))
for _ in range(10):
tokens, lp = runner._select_tokens_with_logprobs(
logits, ["g", "s"], caches, edits, spec
)
mx.eval(tokens, lp.chosen, lp.top_val)
self.assertNotIn(7, tokens.tolist())
self.assertNotIn(7, [row[0] for row in lp.top_idx.tolist()])
def test_seed_is_gated_on_deterministic_inference(self):
"""Upstream seed contract: SamplingBatchInfo only populates
sampling_seed under --enable-deterministic-inference, and then seeds
every row (default 42). A per-request seed outside that flag is
ignored by every other backend, so it is ignored here too."""
from types import SimpleNamespace
def make_req(sampling_seed):
return SimpleNamespace(
sampling_params=SimpleNamespace(
temperature=0.8,
top_k=1 << 30,
top_p=1.0,
min_p=0.0,
sampling_seed=sampling_seed,
frequency_penalty=0.0,
presence_penalty=0.0,
repetition_penalty=1.0,
)
)
self.assertIsNone(MlxSamplingParams.from_req(make_req(7)).seed)
self.assertIsNone(
MlxSamplingParams.from_req(make_req(None), deterministic_seeding=False).seed
)
self.assertEqual(
MlxSamplingParams.from_req(make_req(7), deterministic_seeding=True).seed, 7
)
self.assertEqual(
MlxSamplingParams.from_req(make_req(None), deterministic_seeding=True).seed,
DEFAULT_SAMPLING_SEED,
)
def test_chained_decode_keeps_logit_bias(self):
"""A chained decode step must keep applying the batch's static
logit_bias rows regression: the chained path passed edits=None,
silently dropping the bias after the first (fresh) step."""
runner = self._runner(enable_sampling=True)
runner._req_sampling = {"a": GREEDY_PARAMS}
runner._req_caches = {"a": [self._FakeCache(3)]}
runner._req_token_ids = {"a": [1]}
# token 2 dominates; the edit row bans it -> argmax must fall to 1
logits = mx.array([[0.0, 3.0, 5.0, 0.0]])
runner._decode_with_batched_attention = lambda caches, x, rids: logits
edits = mx.array([[0.0, 0.0, -float("inf"), 0.0]])
fresh = runner.decode_batch_start(["a"], edit_rows=edits)
chained = runner.decode_batch_start_chained(fresh)
mx.eval(fresh.lazy_tokens, chained.lazy_tokens)
self.assertEqual(fresh.lazy_tokens.tolist(), [1])
self.assertEqual(chained.lazy_tokens.tolist(), [1])
def test_logits_hook_bridge_roundtrip(self):
"""The custom-logit-processor hook must see materialized float32
logits and its in-place edits must re-enter the graph guards the
mx->numpy->mx bridge (a copy-semantics change would drop edits)."""
runner = self._runner(enable_sampling=True)
logits = mx.zeros((1, 8), dtype=mx.bfloat16)
def hook(arr):
assert arr.dtype.name == "float32"
arr[0, 5] = 99.0
return arr
edited = runner._run_logits_hook(logits, hook)
mx.eval(edited)
self.assertEqual(int(mx.argmax(edited, axis=-1)[0].item()), 5)
def test_sampling_path_advances_rng_key(self):
"""Consecutive sampling builds must consume distinct keys, or every
chained decode step would draw identical noise."""
logits = mx.zeros((1, 16))
runner = self._runner(enable_sampling=True)
runner._req_sampling = {"a": _params(temperature=1.0)}
caches = [[self._FakeCache(3)]]
toks = set()
for _ in range(20):
t = runner._select_tokens_with_logprobs(logits, ["a"], caches)[0]
mx.eval(t)
toks.add(int(t[0].item()))
self.assertGreater(len(toks), 3, toks)
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
class TestWorkerSamplingExtras(CustomTestCase):
"""Worker-side builders: logit-edit rows, logprob specs, output assembly."""
VOCAB = 8
@classmethod
def setUpClass(cls):
from sglang.srt.runtime_context import get_context
# The worker reads --mlx-enable-sampling off the device config bag,
# which fails closed before a publish.
cls._config = get_context().override_server_args(mlx_enable_sampling=True)
cls._config.install()
cls.addClassCleanup(cls._config.restore)
@staticmethod
def _worker():
from sglang.srt.hardware_backend.mlx.tp_worker import MlxTpModelWorker
return MlxTpModelWorker.__new__(MlxTpModelWorker)
def _batch(self, sinfo, n=2, return_logprob=False, has_grammar=False):
from types import SimpleNamespace
return SimpleNamespace(
reqs=[
SimpleNamespace(
rid=f"r{i}", return_logprob=return_logprob, grammar=None
)
for i in range(n)
],
sampling_info=sinfo,
return_logprob=return_logprob,
top_logprobs_nums=None,
token_ids_logprobs=None,
has_grammar=has_grammar,
)
def test_edit_rows_combine_grammar_mask_and_bias(self):
"""The grammar mask must be applied through the backend's own
apply_vocab_mask on a zeros base and summed with logit_bias
guards the backend-agnostic zeros-trick, the combine order, and the
ForwardBatch.init_new grammars-population mirror (regression: the
MLX paths never build a ForwardBatch, so sinfo.grammars stayed None
and live grammar objects produced no mask at all)."""
from types import SimpleNamespace
import torch
class FakeGrammar:
def apply_vocab_mask(self, logits, vocab_mask):
logits[0, 3] = -float("inf") # row 0 forbids token 3
sinfo = SimpleNamespace(
grammars=None, # not yet populated, as on the real MLX path
logit_bias=torch.zeros(2, self.VOCAB).index_put_(
(torch.tensor([1]), torch.tensor([5])), torch.tensor([2.5])
),
vocab_size=self.VOCAB,
grammar_mask=None,
)
def update_mask():
sinfo.grammar_mask = SimpleNamespace(
grammar=FakeGrammar(), vocab_mask=torch.zeros(2, 1)
)
sinfo.update_regex_vocab_mask = update_mask
batch = self._batch(sinfo, has_grammar=True)
batch.reqs[0].grammar = object()
rows = self._worker()._build_logit_edit_rows(batch)
self.assertEqual(
[g is not None for g in sinfo.grammars],
[True, False],
"worker must mirror ForwardBatch.init_new's grammars population",
)
mx.eval(rows["r0"], rows["r1"])
self.assertEqual(rows["r0"].tolist()[3], -float("inf"))
self.assertEqual(rows["r1"].tolist()[5], 2.5)
self.assertIsNone(sinfo.grammar_mask, "mask must be released after use")
def test_edit_rows_none_when_nothing_to_edit(self):
from types import SimpleNamespace
sinfo = SimpleNamespace(grammars=None, logit_bias=None, vocab_size=self.VOCAB)
self.assertIsNone(self._worker()._build_logit_edit_rows(self._batch(sinfo)))
def test_logprob_spec_subset_alignment(self):
"""Spec rows must align to the rid subset order, not batch order —
guards mixed-batch decode sub-batches."""
from sglang.srt.hardware_backend.mlx.tp_worker import MlxTpModelWorker
rows = {"a": (3, None), "c": (0, (7, 9))}
spec = MlxTpModelWorker._logprob_spec_for(rows, ["c", "b", "a"])
self.assertEqual(spec.top_ks, (0, 0, 3))
self.assertEqual(spec.token_ids, ((7, 9), None, None))
self.assertIsNone(MlxTpModelWorker._logprob_spec_for(rows, ["x"]))
@unittest.skipUnless(
importlib.util.find_spec("xgrammar") is not None, "requires xgrammar"
)
def test_xgrammar_wrapper_supports_cpu_logits(self):
"""The MLX edit-row builder feeds CPU logits to the grammar
backend's apply_vocab_mask — regression: the xgrammar wrapper
raised 'Unsupported device: cpu' (its dispatch stopped at
cuda/xpu/musa/npu), so every grammar request crashed the worker."""
import math
import numpy as np
import torch
from sglang.srt.constrained.xgrammar_backend import XGrammarGrammar
logits = torch.zeros(1, 40)
blocks = math.ceil(40 / 32)
bitmask = torch.full((1, blocks), -1, dtype=torch.int32)
bitmask[0, 0] = int(np.int32(np.uint32(0xFFFFFFFF & ~(1 << 7))))
XGrammarGrammar.apply_vocab_mask(None, logits, bitmask)
self.assertEqual(logits[0, 7].item(), -float("inf"))
self.assertEqual(logits[0, 6].item(), 0.0)
def test_assemble_logprob_output_matches_scheduler_contract(self):
"""Field shapes must survive the scheduler's move_logprobs_to_cpu
(`.tolist()` on the batch tensor and on every per-row val/idx entry)
and add_logprob_return_values indexing guards the external
LogitsProcessorOutput consumption contract, including rows without
logprob requests getting empty-but-tolistable fills."""
from types import SimpleNamespace
from sglang.srt.hardware_backend.mlx.tp_worker import MlxTpModelWorker
step_rows = {"a": (-1.5, [-0.1, -0.2], [4, 2], [-3.0], [9])}
reqs = [SimpleNamespace(rid="a"), SimpleNamespace(rid="b")]
out = MlxTpModelWorker._assemble_logprob_output(step_rows, reqs)
self.assertEqual(out.next_token_logprobs.tolist(), [-1.5, 0.0])
self.assertEqual(
[v.tolist() for v in out.next_token_top_logprobs_val],
[[-0.10000000149011612, -0.20000000298023224], []],
)
self.assertEqual(
[v.tolist() for v in out.next_token_top_logprobs_idx], [[4, 2], []]
)
self.assertEqual(
[v.tolist() for v in out.next_token_token_ids_logprobs_val],
[[-3.0], []],
)
self.assertEqual(out.next_token_token_ids_logprobs_idx, [[9], []])
if __name__ == "__main__":
unittest.main()
@@ -129,6 +129,7 @@ class TestOverlapLoopStampsLaunchTs(unittest.TestCase):
from sglang.srt.hardware_backend.mlx.scheduler_mixin import (
SchedulerMlxOverlapMixin,
)
from sglang.srt.hardware_backend.mlx.tp_worker import MlxLaunch
scheduler = self._make_scheduler(recv_side_effect=[[], _StopLoop()])
@@ -148,7 +149,13 @@ class TestOverlapLoopStampsLaunchTs(unittest.TestCase):
scheduler.tp_worker.async_forward_batch_generation_mlx.side_effect = (
lambda _batch: (
events.append("forward"),
(None, [], [], None, "extend"),
MlxLaunch(
lazy_tokens=None,
prefills=[],
extends=[],
decode=None,
mode="extend",
),
)[1]
)
@@ -173,6 +180,7 @@ class TestOverlapLoopStampsLaunchTs(unittest.TestCase):
from sglang.srt.hardware_backend.mlx.scheduler_mixin import (
SchedulerMlxOverlapMixin,
)
from sglang.srt.hardware_backend.mlx.tp_worker import MlxLaunch
# Iteration 1: fresh decode launch. Iteration 2: chain a second
# decode on top of it. Iteration 3: stop.
@@ -193,16 +201,22 @@ class TestOverlapLoopStampsLaunchTs(unittest.TestCase):
scheduler.get_next_batch_to_run.return_value = plan
pending_decode = MagicMock()
scheduler.tp_worker.async_forward_batch_generation_mlx.return_value = (
MagicMock(),
[],
[],
pending_decode,
"decode",
scheduler.tp_worker.async_forward_batch_generation_mlx.return_value = MlxLaunch(
lazy_tokens=MagicMock(),
prefills=[],
extends=[],
decode=pending_decode,
mode="decode",
)
scheduler.tp_worker.async_chained_decode_mlx.side_effect = lambda _decode: (
events.append("chained_forward"),
(MagicMock(), [], [], MagicMock(), "decode"),
MlxLaunch(
lazy_tokens=MagicMock(),
prefills=[],
extends=[],
decode=MagicMock(),
mode="decode",
),
)[1]
launch_times = iter((1.0, 2.0))
@@ -7,10 +7,13 @@ pin the three seams that make such models work on the MLX backend:
1. The attention contract accepts ``sm_scale`` and exposes per-layer window
sizes read from the mlx-lm container convention (``layer_types`` +
``window_size``).
2. The cache shims' ``make_mask`` mirrors mlx_lm's
``cache.create_attention_mask`` exactly in particular ``window_size``
2. The cache shims' ``make_mask`` matches mlx_lm's
``cache.create_attention_mask`` semantically in particular ``window_size``
must produce a banded mask (including for N == 1) instead of being
silently dropped, or sliding-window layers degrade to full attention.
Where the window provably cannot bind (``offset + N <= window_size``) the
band equals plain causal, and the shims return the cheap form instead, as
mlx_lm's own ``RotatingKVCache.make_mask`` does.
3. ``MLXAttentionWrapper._batched_decode`` applies the window by truncating
each request's KV to the trailing window, passes ``sinks`` through, and
uses the contract scale helper.
@@ -173,7 +176,22 @@ class TestGptOssAttentionContract(CustomTestCase):
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
class TestShimMakeMask(CustomTestCase):
"""The shims must return exactly what mlx_lm's own KVCache.make_mask returns."""
"""The shims must be semantically equal to mlx_lm's own KVCache.make_mask.
Equal *content*, not equal representation: where the window provably
cannot bind the shims return the cheap ``"causal"`` / ``None`` form that
mlx_lm's RotatingKVCache.make_mask also returns, so the comparison
densifies both sides first.
"""
def _dense(self, mask, N, offset):
"""Dense boolean form of any of the three mask representations."""
if mask is None:
return mx.ones((N, offset + N), dtype=mx.bool_)
if isinstance(mask, str):
self.assertEqual(mask, "causal")
return create_causal_mask(N, offset)
return mask
def _shims(self, offset):
contig = ContiguousAttentionKVCache(
@@ -185,14 +203,13 @@ class TestShimMakeMask(CustomTestCase):
)
return (AttentionOffsetCache(offset=offset), contig, pool_backed)
def _assert_same_mask(self, got, ref, msg):
if ref is None or isinstance(ref, str):
self.assertEqual(got, ref, msg)
else:
self.assertTrue(
isinstance(got, mx.array) and mx.array_equal(got, ref).item(),
msg,
)
def _assert_same_mask(self, got, ref, msg, N, offset):
self.assertTrue(
mx.array_equal(
self._dense(got, N, offset), self._dense(ref, N, offset)
).item(),
msg,
)
def test_shims_match_mlx_lm_reference(self):
cases = [
@@ -213,8 +230,34 @@ class TestShimMakeMask(CustomTestCase):
ref,
f"{type(shim).__name__} mismatch for N={N} offset={offset} "
f"window={window} return_array={return_array}",
N,
offset,
)
def test_non_binding_window_returns_the_cheap_mask(self):
# offset + N <= window: no query can reach past the window, so the
# band equals plain causal and materialising it only costs time (a
# mask array forces sdpa off its fused causal path, ~2x per layer).
self.assertIsNone(make_attention_mask(1, 0, window_size=4))
self.assertIsNone(make_attention_mask(1, 3, window_size=4))
self.assertEqual(make_attention_mask(4, 0, window_size=4), "causal")
# ...and one position past the boundary the band is required again.
self.assertIsInstance(make_attention_mask(4, 1, window_size=4), mx.array)
self.assertIsInstance(make_attention_mask(1, 4, window_size=4), mx.array)
def test_non_binding_window_matches_the_band_it_replaces(self):
# The shortcut is only legal because the two forms are elementwise
# identical; pin that against mlx_lm's own band builder.
for N, offset, window in ((1, 0, 4), (1, 3, 4), (4, 0, 4), (8, 0, 16)):
band = create_causal_mask(N, offset, window_size=window)
cheap = self._dense(
make_attention_mask(N, offset, window_size=window), N, offset
)
self.assertTrue(
mx.array_equal(band, cheap).item(),
f"N={N} offset={offset} window={window}",
)
def test_windowed_mask_is_banded_including_self(self):
# Query at absolute position 6 with W=4 may attend to keys 3..6
# (j in [i - W + 1, i], the window includes the query itself).
@@ -0,0 +1,380 @@
"""Unit tests for sliding-window layers on the MLX radix/pool KV path.
The shared ``MlxAttentionKVPool`` stores full-attention layers only,
sliding-window layers keep window-bounded per-request storage, and a
radix prefix hit on an SWA model recomputes the whole prefix. Scheduler
bookkeeping stays in the unclamped coordinates, so these tests drive
``MlxModelRunner`` directly with hand-built slot ids, mirroring how the
tp_worker calls it.
"""
from __future__ import annotations
import importlib.util
import unittest
from sglang.test.ci.ci_register import register_cpu_ci, register_mlx_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=10, suite="base-a-test-cpu")
register_mlx_ci(est_time=10, suite="stage-a-unit-test-mlx")
_HAS_MLX = (
importlib.util.find_spec("mlx") is not None
and importlib.util.find_spec("mlx_lm") is not None
)
_SKIP_REASON = "requires mlx + mlx_lm"
if _HAS_MLX:
import mlx.core as mx
from mlx_lm.models import gpt_oss
from sglang.srt.hardware_backend.mlx.aot import (
MlxAOTKernelContext,
MlxAOTKernelSet,
MlxAOTRoPEContext,
MlxAOTRoPEKernel,
)
from sglang.srt.hardware_backend.mlx.kv_cache import (
BatchedDecodeContext,
ContiguousAttentionKVCache,
MlxAttentionKVPool,
MLXAttentionWrapper,
WindowedAttentionKVCache,
find_attention_layers,
get_layer_window_sizes,
patch_model_attention,
)
from sglang.srt.hardware_backend.mlx.kv_cache.layout import MlxModelCacheLayout
from sglang.srt.hardware_backend.mlx.model_runner import MlxModelRunner
TINY_WINDOW = 8
def _tiny_gpt_oss_model():
"""Randomly initialized 4-layer gpt_oss with alternating sliding/full layers.
Mirrors test_windowed_kv_cache.py's builder (kept local: the registered
unit-test directory is not an importable package).
"""
args = gpt_oss.ModelArgs(
num_hidden_layers=4,
num_local_experts=8,
num_experts_per_tok=2,
vocab_size=128,
hidden_size=64,
intermediate_size=64,
head_dim=16,
num_attention_heads=4,
num_key_value_heads=2,
sliding_window=TINY_WINDOW,
rope_theta=150000,
rope_scaling={
"rope_type": "yarn",
"factor": 32.0,
"beta_fast": 32.0,
"beta_slow": 1.0,
"original_max_position_embeddings": 4096,
"truncate": False,
},
)
return gpt_oss.Model(args)
def _stub_runner(model, disable_radix_cache, pool_size=64):
"""Surgically build a runner around an already-loaded tiny model."""
layers, attrs = find_attention_layers(model)
runner = MlxModelRunner.__new__(MlxModelRunner)
runner.model = model
runner.disable_radix_cache = disable_radix_cache
runner._cache_layout = MlxModelCacheLayout.from_attention_discovery(
layers, attrs, layer_window_sizes=get_layer_window_sizes(model)
)
runner._max_seq_len = 64
runner._cache_pool = []
runner._req_caches = {}
runner._req_token_ids = {}
runner._req_sampling = {}
runner._req_pool_idx = {}
runner._req_synced_offset = {}
runner._req_to_token_pool = None
runner._attention_kv_pool = None
runner._decode_step_ct = 0
runner._clear_steps = 0
runner._aot_kernels = MlxAOTKernelSet()
runner._pool_size = pool_size
if not disable_radix_cache:
runner.init_cache_pools(None)
return runner
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
class TestSwaLayoutAndPoolContract(CustomTestCase):
def _layout(self, with_windows=True):
model = _tiny_gpt_oss_model()
layers, attrs = find_attention_layers(model)
return MlxModelCacheLayout.from_attention_discovery(
layers, attrs, get_layer_window_sizes(model) if with_windows else None
)
def test_partition_and_dense_full_pool_index(self):
layout = self._layout()
self.assertEqual(layout.attention_layer_indices, (0, 1, 2, 3))
self.assertEqual(layout.swa_attention_layer_indices, (0, 2))
self.assertEqual(layout.full_attention_layer_indices, (1, 3))
# Dense over full layers only, so it differs from the cache index.
self.assertEqual(layout.full_kv_pool_index_by_layer, {1: 0, 3: 1})
self.assertEqual(layout.attention_pool_index_by_layer, {0: 0, 1: 1, 2: 2, 3: 3})
with self.assertRaises(KeyError):
layout.full_kv_pool_index(0)
# Without a window map the two indices coincide (pre-SWA behavior).
plain = self._layout(with_windows=False)
self.assertFalse(plain.has_sliding_window_layers)
self.assertEqual(
plain.full_kv_pool_index_by_layer, plain.attention_pool_index_by_layer
)
def test_sliding_window_model_gets_no_pool(self):
# An SWA prefix hit recomputes the prefix instead of gathering it, so
# the shared pool would have no reader. Allocating it would burn the
# whole auto-sized KV budget on a write-only buffer.
runner = _stub_runner(_tiny_gpt_oss_model(), disable_radix_cache=False)
self.assertTrue(runner._cache_layout.has_sliding_window_layers)
self.assertIsNone(runner._attention_kv_pool)
# The layer-type split still resolves -- it is the seam the shared
# window-aware SWA pool will build on.
self.assertEqual(runner._cache_layout.full_kv_pool_index_by_layer, {1: 0, 3: 1})
def test_pool_covers_every_layer_without_sliding_windows(self):
model = _tiny_gpt_oss_model()
layers, attrs = find_attention_layers(model)
runner = _stub_runner(model, disable_radix_cache=True)
runner._cache_layout = MlxModelCacheLayout.from_attention_discovery(
layers, attrs
)
runner.disable_radix_cache = False
runner.init_cache_pools(None)
self.assertEqual(runner._attention_kv_pool.num_layers, 4)
self.assertEqual(runner._attention_kv_pool.pool_size, 65)
def test_all_sliding_window_model_gets_no_pool(self):
# An all-SWA model has nothing to pool. Pool construction must skip
# out, and pool sizing must still land on a finite slot count rather
# than dividing by zero bytes per slot.
runner = _stub_runner(_tiny_gpt_oss_model(), disable_radix_cache=False)
layers, attrs = find_attention_layers(runner.model)
runner._cache_layout = MlxModelCacheLayout.from_attention_discovery(
layers, attrs, {idx: TINY_WINDOW for idx in range(4)}
)
self.assertEqual(runner._cache_layout.full_attention_layer_indices, ())
self.assertEqual(runner._cache_layout.full_kv_pool_index_by_layer, {})
runner._attention_kv_pool = None
runner.init_cache_pools(None)
self.assertIsNone(runner._attention_kv_pool)
runner._mem_fraction_static = 0.5
self.assertGreater(runner._compute_pool_size(None), 0)
def test_sliding_flag_without_window_map_still_rejected(self):
model = _tiny_gpt_oss_model()
patch_model_attention(model)
model.model.layers[0].self_attn._inner.is_sliding = True
runner = _stub_runner(model, disable_radix_cache=True)
# With the container window map the flagged layer is bounded: fine.
runner._get_attn_config()
# Without a resolvable window the layer cannot be bounded: reject.
layers, attrs = find_attention_layers(model)
runner._cache_layout = MlxModelCacheLayout.from_attention_discovery(
layers, attrs
)
with self.assertRaises(NotImplementedError):
runner._get_attn_config()
def test_sync_writes_full_layers_only(self):
runner = _stub_runner(_tiny_gpt_oss_model(), disable_radix_cache=False)
# init_cache_pools skips the pool on an SWA model (see
# test_sliding_window_model_gets_no_pool), so attach one by hand: the
# layer-type filtering in _sync_new_kv_to_pool is what the shared
# window-aware SWA pool will rely on, and it must stay correct.
self.assertIsNone(runner._attention_kv_pool)
runner._attention_kv_pool = MlxAttentionKVPool(
pool_size=runner._pool_size + 1,
num_layers=runner._cache_layout.num_full_attention_layers,
n_kv_heads=2,
head_dim=16,
dtype=mx.float32,
)
cache = runner._new_native_cache()
per_layer_k = {}
for layer_idx in range(4):
k = mx.full((1, 2, 5, 16), float(layer_idx + 1))
cache[layer_idx].update_and_fetch(k, -k)
per_layer_k[layer_idx] = k
slot_ids = [7, 9, 11]
runner._sync_new_kv_to_pool(cache, cache_start=2, slot_ids=slot_ids)
for layer_idx, pool_idx in ((1, 0), (3, 1)):
got_k, got_v = runner._attention_kv_pool.get_kv(
pool_idx, mx.array(slot_ids, dtype=mx.int32)
)
want = per_layer_k[layer_idx][0, :, 2:5, :].transpose(1, 0, 2)
self.assertTrue(mx.array_equal(got_k, want).item())
self.assertTrue(mx.array_equal(got_v, -want).item())
# Untouched pool slots stay zero (nothing wrote outside the slots).
rest_k, _ = runner._attention_kv_pool.get_kv(
0, mx.array([1, 2, 3], dtype=mx.int32)
)
self.assertEqual(mx.abs(rest_k).max().item(), 0.0)
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
class TestSwaRadixPath(CustomTestCase):
"""Radix-path prefill/decode is token-identical to the per-request path.
Both runners share one tiny gpt-oss (same random weights). The
reference runner uses the ``disable_radix_cache`` per-request path
pinned by test_windowed_kv_cache.py; the radix runner replays the
same requests through pool sync, prefix hits, and prefix recomputes.
"""
DECODE_STEPS = 6
@classmethod
def setUpClass(cls):
mx.random.seed(7)
cls.model = _tiny_gpt_oss_model()
patch_model_attention(cls.model)
def setUp(self):
self.reference = _stub_runner(self.model, disable_radix_cache=True)
self.radix = _stub_runner(self.model, disable_radix_cache=False)
def _greedy(self, runner, rid, full_ids, new_ids, prefix_slots, new_slots):
tokens = [
runner.prefill(
req_id=rid,
new_token_ids=list(new_ids),
full_token_ids=list(full_ids),
prefix_slot_ids=list(prefix_slots),
new_slot_ids=list(new_slots),
req_pool_idx=0,
)
]
for _ in range(self.DECODE_STEPS):
tokens.extend(runner.decode_batch([rid]))
return tokens
def _reference_stream(self, prompt):
tokens = self._greedy(
self.reference, "ref", prompt, prompt, prefix_slots=(), new_slots=()
)
self.reference.remove_request("ref")
return tokens
def _seed(self, prompt, slots):
self._greedy(self.radix, "seed", prompt, prompt, (), slots)
self.radix.remove_request("seed")
def _assert_windowed_bounded(self, rid):
cache = self.radix._req_caches[rid]
for layer_idx in self.radix._cache_layout.swa_attention_layer_indices:
entry = cache[layer_idx]
self.assertIsInstance(entry, WindowedAttentionKVCache)
self.assertLessEqual(entry.get_kv()[0].shape[2], 2 * TINY_WINDOW)
def test_cold_prefill_matches_reference(self):
prompt = [(i * 7 + 3) % 128 for i in range(20)]
want = self._reference_stream(prompt)
got = self._greedy(
self.radix, "cold", prompt, prompt, (), range(1, len(prompt) + 1)
)
self.assertEqual(got, want)
self._assert_windowed_bounded("cold")
def test_partial_prefix_hit_recomputes_exactly(self):
# Prefix (20) is well past the window (8): the hit must recompute the
# whole prefix rather than gather it, a chunked extend continues on
# top, and the stream must match one cold reference over the same
# tokens with every cache left at the unclamped absolute position.
prefix = [(i * 7 + 3) % 128 for i in range(20)]
chunk_a, chunk_b = [9, 42, 77, 5], [11, 13, 17]
prefix_slots = list(range(1, len(prefix) + 1))
self._seed(prefix, prefix_slots)
want = self._reference_stream(prefix + chunk_a + chunk_b)
gathers = []
self.radix._cache_with_pool_backed_attention = lambda slots, n: gathers.append(
n
)
self.radix.prefill(
req_id="hit",
new_token_ids=chunk_a,
full_token_ids=prefix + chunk_a,
prefix_slot_ids=prefix_slots,
new_slot_ids=list(range(30, 34)),
req_pool_idx=0,
)
self.assertEqual(gathers, [], "SWA prefix hits must recompute, not gather")
got = [self.radix.extend("hit", chunk_b, list(range(34, 37)))]
for _ in range(self.DECODE_STEPS):
got.extend(self.radix.decode_batch(["hit"]))
self.assertEqual(got, want)
self._assert_windowed_bounded("hit")
expected = len(prefix + chunk_a + chunk_b) + self.DECODE_STEPS
for layer_idx in range(4):
self.assertEqual(self.radix._req_caches["hit"][layer_idx].offset, expected)
def test_full_prefix_hit_without_new_tokens(self):
# An exact hit leaves no extend tokens: the prefix rebuild supplies
# run tokens ending on the last prefix token, whose logits predict
# the next token.
prompt = [(i * 5 + 11) % 128 for i in range(20)]
prefix_slots = list(range(1, len(prompt) + 1))
self._seed(prompt, prefix_slots)
want = self._reference_stream(prompt)
got = self._greedy(self.radix, "exact", prompt, [], prefix_slots, ())
self.assertEqual(got, want)
def test_fused_aot_kernel_serves_full_layers_by_full_pool_index(self):
# The fused RoPE+pool-scatter kernel must skip sliding-window layers
# and address the pool by the full-attention index, not the cache one.
sliding_wrapper = self.model.model.layers[0].self_attn
full_wrapper = self.model.model.layers[1].self_attn
recorded = []
original = MLXAttentionWrapper._rope_custom_aot
def _recording_rope(queries, keys, values, positions, pool_idx, rope_ctx):
recorded.append(pool_idx)
return queries, keys
MLXAttentionWrapper._rope_custom_aot = staticmethod(_recording_rope)
try:
win = WindowedAttentionKVCache(TINY_WINDOW)
contig = ContiguousAttentionKVCache(
n_kv_heads=2, head_dim=16, max_seq_len=32, dtype=mx.float32
)
ctx = BatchedDecodeContext(
batch_size=1,
seq_lens=[0],
attention_layer_caches=[[win], [contig]],
attention_pool_index_by_layer={0: 0, 1: 1},
full_kv_pool_index_by_layer={1: 0},
aot=MlxAOTKernelContext(
rope=MlxAOTRoPEContext(kernel=MlxAOTRoPEKernel(), kv_pool=None)
),
)
x = mx.random.normal((1, 1, 64))
mx.eval(sliding_wrapper._batched_decode(x, ctx))
self.assertEqual(recorded, [], "SWA layer must not hit the fused kernel")
mx.eval(full_wrapper._batched_decode(x, ctx))
# Cache index for layer 1 is 1; its full-pool index is 0.
self.assertEqual(recorded, [0], "full layer needs the full-pool index")
finally:
MLXAttentionWrapper._rope_custom_aot = original
if __name__ == "__main__":
unittest.main()
@@ -15,11 +15,15 @@ correct discriminator is ``batch.decoding_reqs``, not the chunk length.
The routing decision was duplicated across the sync and async paths (the bug
therefore existed in both). It now lives in the shared
``MlxTpModelWorker._route_extend_request`` helper. These tests cover:
``MlxTpModelWorker._route_extend_request`` helper, and the sync entry point
launches through the async one rather than re-implementing it. These tests
cover:
* the helper decision directly (both paths delegate to it);
* the sync wiring, by driving ``_forward_batch_generation_mlx``;
* the async wiring, by driving ``_async_extend_batch``.
* the helper decision directly;
* the async wiring, by driving ``_async_extend_batch``;
* the sync entry point, by driving ``_forward_batch_generation_mlx`` --
which also guards the delegation, since a divergence there would show up
as a routing or token-ordering difference between the two.
They mock the MLX runner and load no model. Apple-Silicon-only because
``tp_worker`` imports ``mlx.core`` at module load.
@@ -35,7 +39,9 @@ from types import SimpleNamespace
import torch
from sglang.srt.model_executor.forward_batch_info import ForwardMode
from sglang.srt.runtime_context import get_context
from sglang.test.ci.ci_register import register_cpu_ci, register_mlx_ci
from sglang.test.test_utils import CustomTestCase
# CPU marker is AST-parsed "this test exists"; actual CPU-side execution is
# gated by the @skipUnless guard below. MLX marker runs for real on the MLX
@@ -49,11 +55,15 @@ _SKIP_REASON = "Apple-Silicon-only (tp_worker imports mlx.core at module load)"
class _FakeRunner:
"""Records which routing path each request took (sync + async surfaces)."""
"""Records which routing path each request took (both worker paths
drive the runner through the same start/finalize surface)."""
def __init__(self, known_rids):
self._known = set(known_rids)
self.calls: list[tuple[str, str]] = [] # (op, rid)
# (op, rid) -> needs_logits as received; guards the worker's
# chunk-finality derivation reaching the runner intact.
self.logits_flags: dict[tuple[str, str], bool] = {}
self._req_caches: dict[str, list] = {}
self._counter = 0
@@ -73,37 +83,27 @@ class _FakeRunner:
return SimpleNamespace(state=[mx.array([0.0], dtype=mx.float32)])
# --- sync surface ---
def extend(self, rid, new_token_ids, new_slot_ids):
self.calls.append(("extend", rid))
self._counter += 1
return 1000 + self._counter
def decode_batch(self, rids):
for rid in rids:
self.calls.append(("decode", rid))
return [2000 + i for i in range(len(rids))]
def prefill(
# --- start/finalize surface (shared by the sync and async worker paths) ---
def extend_start(
self,
req_id,
new_token_ids,
full_token_ids,
prefix_slot_ids,
new_slot_ids,
req_pool_idx,
req=None,
needs_logits=True,
logit_edit_row=None,
logprob_spec=None,
):
self.calls.append(("prefill", req_id))
return 3000
# --- async surface ---
def extend_start(self, req_id, new_token_ids, new_slot_ids):
import mlx.core as mx
self.calls.append(("extend_start", req_id))
self.logits_flags[("extend_start", req_id)] = needs_logits
self._req_caches[req_id] = [self._fake_cache_layer()]
return SimpleNamespace(lazy_token=mx.array([0], dtype=mx.int32), req_id=req_id)
return SimpleNamespace(
lazy_token=mx.array([0], dtype=mx.int32),
cache=self._req_caches[req_id],
req_id=req_id,
lazy_logprobs=None,
)
def prefill_start(
self,
@@ -114,17 +114,24 @@ class _FakeRunner:
new_slot_ids,
req_pool_idx,
req=None,
needs_logits=True,
logit_edit_row=None,
logprob_spec=None,
):
import mlx.core as mx
self.calls.append(("prefill_start", req_id))
self.logits_flags[("prefill_start", req_id)] = needs_logits
return SimpleNamespace(
lazy_token=mx.array([0], dtype=mx.int32),
cache=[self._fake_cache_layer()],
req_id=req_id,
lazy_logprobs=None,
)
def decode_batch_start(self, rids):
def decode_batch_start(
self, rids, edit_rows=None, logprob_spec=None, logits_hook=None
):
import mlx.core as mx
for rid in rids:
@@ -133,8 +140,29 @@ class _FakeRunner:
lazy_tokens=mx.array([0] * len(rids), dtype=mx.int32),
caches=[[self._fake_cache_layer()] for _ in rids],
req_ids=list(rids),
lazy_logprobs=None,
)
def prefill_finalize(self, pending):
return 3000
def extend_finalize(self, pending):
self._counter += 1
return 1000 + self._counter
def decode_batch_finalize(self, pending):
return [2000 + i for i in range(len(pending.req_ids))]
def collect_logprobs(self, lazy_logprobs):
return None
def eval_pending(self, pending):
pass
@staticmethod
def cache_state_arrays(caches):
return [s for cache_list in caches for c in cache_list for s in c.state]
class _FakeReq:
def __init__(self, rid, req_pool_idx=0):
@@ -142,6 +170,11 @@ class _FakeReq:
self.prefix_indices = torch.empty(0, dtype=torch.long)
self.fill_ids = [0]
self.req_pool_idx = req_pool_idx
# Mirrors Req's chunk-finality contract read by
# MlxTpModelWorker._chunk_needs_logits: extend_range=None means
# "not truncated" (final chunk / plain prefill).
self.extend_range = None
self.full_untruncated_fill_ids = self.fill_ids
def get_fill_ids(self):
return self.fill_ids
@@ -154,15 +187,26 @@ class _FakeBatch:
self.reqs = reqs
self.extend_lens = list(extend_lens)
self.decoding_reqs = decoding_reqs
self.sampling_info = None
self.return_logprob = False
# Arbitrary but correctly-sized token / slot arrays.
self.input_ids = torch.arange(total, dtype=torch.long)
self.out_cache_loc = torch.arange(total, dtype=torch.long)
@unittest.skipUnless(_IS_APPLE_SILICON and _HAS_MLX, _SKIP_REASON)
class TestMlxExtendRouting(unittest.TestCase):
class TestMlxExtendRouting(CustomTestCase):
"""Routing contract for MlxTpModelWorker: shared helper + sync + async."""
@classmethod
def setUpClass(cls):
# The worker reads --mlx-enable-sampling off the device config bag,
# which fails closed before a publish. Routing itself is orthogonal
# to sampling, so pin it off for the whole case.
cls._config = get_context().override_server_args(mlx_enable_sampling=False)
cls._config.install()
cls.addClassCleanup(cls._config.restore)
@staticmethod
def _worker(known_rids):
from sglang.srt.hardware_backend.mlx.tp_worker import MlxTpModelWorker
@@ -170,6 +214,10 @@ class TestMlxExtendRouting(unittest.TestCase):
worker = MlxTpModelWorker.__new__(MlxTpModelWorker)
worker._mlx_runner = _FakeRunner(known_rids)
worker._mlx_active_rids = set()
# The sync entry point delegates to the async launch, which guards
# pool creation behind this flag; forward_batch_generation has
# already run it for real by the time either path is reached.
worker._mlx_pool_initialized = True
return worker
# ---------- the shared decision helper ----------
@@ -200,13 +248,28 @@ class TestMlxExtendRouting(unittest.TestCase):
def test_sync_one_token_continuation_routes_to_extend(self):
"""THE REGRESSION (sync): a 1-token continuation must extend, not decode."""
runner = self._run_sync([_FakeReq("r1")], [1], {"r1"}, None, ForwardMode.EXTEND)
self.assertEqual(runner.ops_for("r1"), ["extend"])
self.assertEqual(runner.ops_for("r1"), ["extend_start"])
# Untruncated (extend_range None) => final chunk => logits required.
self.assertIs(runner.logits_flags[("extend_start", "r1")], True)
def test_sync_non_final_chunk_skips_logits(self):
"""Head-skip derivation: a scheduler-truncated chunk (extend_range.end
below the request's full untruncated length) reaches the runner with
needs_logits=False; its next-token output is popped as the stale
intermediate token, so computing the vocab head for it is pure waste.
Everything else about routing is unchanged."""
req = _FakeReq("r1")
req.full_untruncated_fill_ids = list(range(8))
req.extend_range = SimpleNamespace(start=0, end=4) # 4 < 8: non-final
runner = self._run_sync([req], [4], {"r1"}, None, ForwardMode.EXTEND)
self.assertEqual(runner.ops_for("r1"), ["extend_start"])
self.assertIs(runner.logits_flags[("extend_start", "r1")], False)
def test_sync_genuine_mixed_decode_routes_to_decode(self):
p, d = _FakeReq("p1"), _FakeReq("d1")
runner = self._run_sync([p, d], [4, 1], {"d1"}, [d], ForwardMode.MIXED)
self.assertEqual(runner.ops_for("p1"), ["prefill"])
self.assertEqual(runner.ops_for("d1"), ["decode"])
self.assertEqual(runner.ops_for("p1"), ["prefill_start"])
self.assertEqual(runner.ops_for("d1"), ["decode_start"])
# ---------- async path: _async_extend_batch ----------
@@ -216,26 +279,34 @@ class TestMlxExtendRouting(unittest.TestCase):
worker = MlxTpModelWorker.__new__(MlxTpModelWorker)
worker._mlx_runner = _FakeRunner(known_rids)
batch = _FakeBatch(forward_mode, reqs, extend_lens, decoding_reqs)
# returns (lazy_stacked, pending_prefills, pending_extends,
# pending_mixed_decode, mode)
result = worker._async_extend_batch(batch)
return worker._mlx_runner, result
launch = worker._async_extend_batch(batch)
return worker._mlx_runner, launch
def test_async_one_token_continuation_routes_to_extend(self):
"""THE REGRESSION (async): a 1-token continuation must extend, not decode."""
runner, result = self._run_async(
runner, launch = self._run_async(
[_FakeReq("r1")], [1], {"r1"}, None, ForwardMode.EXTEND
)
self.assertEqual(runner.ops_for("r1"), ["extend_start"])
self.assertEqual(len(result[2]), 1) # one pending extend
self.assertIsNone(result[3]) # no mixed decode
self.assertIs(runner.logits_flags[("extend_start", "r1")], True)
self.assertEqual(len(launch.extends), 1) # one pending extend
self.assertIsNone(launch.decode) # no mixed decode
def test_async_non_final_chunk_skips_logits(self):
"""Async twin of the head-skip derivation guard."""
req = _FakeReq("r1")
req.full_untruncated_fill_ids = list(range(8))
req.extend_range = SimpleNamespace(start=0, end=4)
runner, _ = self._run_async([req], [4], {"r1"}, None, ForwardMode.EXTEND)
self.assertEqual(runner.ops_for("r1"), ["extend_start"])
self.assertIs(runner.logits_flags[("extend_start", "r1")], False)
def test_async_genuine_mixed_decode_routes_to_decode(self):
p, d = _FakeReq("p1"), _FakeReq("d1")
runner, result = self._run_async([p, d], [4, 1], {"d1"}, [d], ForwardMode.MIXED)
runner, launch = self._run_async([p, d], [4, 1], {"d1"}, [d], ForwardMode.MIXED)
self.assertEqual(runner.ops_for("p1"), ["prefill_start"])
self.assertEqual(runner.ops_for("d1"), ["decode_start"])
self.assertIsNotNone(result[3]) # pending mixed decode present
self.assertIsNotNone(launch.decode) # pending mixed decode present
if __name__ == "__main__":
@@ -0,0 +1,321 @@
"""Unit tests for the MLX windowed per-request attention KV cache.
``WindowedAttentionKVCache`` keeps only the trailing ``window`` tokens of a
sliding-window layer. Every level is pinned against the full-history path it
replaces: the cache arrays against a ``ContiguousAttentionKVCache`` trailing
slice, the container forward against full-history caches, and
``MLXAttentionWrapper`` batched decode against the same wrapper driven by
full-history caches.
Sliding-window layers use this storage on both KV paths; how it composes
with the shared pool and radix prefix hits is pinned in
test_swa_radix_pool.py.
"""
from __future__ import annotations
import importlib.util
import unittest
from sglang.test.ci.ci_register import register_cpu_ci, register_mlx_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=10, suite="base-a-test-cpu")
register_mlx_ci(est_time=10, suite="stage-a-unit-test-mlx")
_HAS_MLX = (
importlib.util.find_spec("mlx") is not None
and importlib.util.find_spec("mlx_lm") is not None
)
_SKIP_REASON = "requires mlx + mlx_lm"
if _HAS_MLX:
import mlx.core as mx
from mlx_lm.models import gpt_oss
from mlx_lm.models.base import create_causal_mask
from sglang.srt.hardware_backend.mlx.kv_cache import (
BatchedDecodeContext,
ContiguousAttentionKVCache,
MLXAttentionWrapper,
WindowedAttentionKVCache,
find_attention_layers,
get_layer_window_sizes,
make_attention_mask,
)
from sglang.srt.hardware_backend.mlx.kv_cache.layout import MlxModelCacheLayout
WINDOW = 8
HIDDEN, N_KV_HEADS, HEAD_DIM = 64, 2, 16
def _dense_mask(mask, n_queries: int, offset: int):
"""Densify the cheap ``"causal"`` / ``None`` mask forms.
``make_attention_mask`` returns those instead of a materialised band
whenever the window cannot bind, so their width lives in the key tensor
rather than in the mask. Densifying keeps width and content checkable
for both forms.
"""
if mask is None or isinstance(mask, str):
return create_causal_mask(n_queries, offset)
return mask
def _tiny_gpt_oss_model():
"""Random-weight 4-layer gpt_oss, alternating sliding/full layers."""
return gpt_oss.Model(
gpt_oss.ModelArgs(
num_hidden_layers=4,
num_local_experts=8,
num_experts_per_tok=2,
vocab_size=128,
hidden_size=HIDDEN,
intermediate_size=64,
head_dim=HEAD_DIM,
num_attention_heads=4,
num_key_value_heads=N_KV_HEADS,
sliding_window=WINDOW,
)
)
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
class TestWindowedCacheEquivalence(CustomTestCase):
"""Storage equivalence with the full-history trailing slice.
Both caches receive identical K/V and only copy it, so the comparisons
are exact (``mx.array_equal``), not tolerance-based.
"""
W, H, D = 4, 2, 8
def _kv(self, S):
return mx.random.normal((1, self.H, S, self.D))
def test_chunk_patterns_match_full_trailing_slice(self):
"""Windowed storage == trailing slice of full history, everywhere.
Also pins what the forward pass depends on: the mask built before
``update_and_fetch`` is exactly as wide as the keys it then returns,
the kept prefix still covers a full window, ``offset`` stays
absolute, and the decode buffer stays bounded by ``2 * window``.
"""
mx.random.seed(0)
for chunks in [
(3,), # stays inside the window
(4,), # lands exactly on the window
(5,), # first chunk already crosses the window
(6, 3), # second chunk forces prefix normalisation
(2, 2, 2, 2), # repeated small chunks
(1, 1, 1), # degenerate single-token chunks
(10, 1, 7), # chunk larger than 2*window, then mixed
]:
full = ContiguousAttentionKVCache(max_seq_len=128)
win = WindowedAttentionKVCache(self.W)
for S in chunks:
k, v = self._kv(S), self._kv(S)
mask = win.make_mask(S, window_size=self.W) # runs before update
fk, fv = full.update_and_fetch(k, v)
wk, wv = win.update_and_fetch(k, v)
at = f"chunk S={S} of {chunks}"
self.assertTrue(mx.array_equal(wk, fk[:, :, -wk.shape[2] :, :]), at)
self.assertTrue(mx.array_equal(wv, fv[:, :, -wv.shape[2] :, :]), at)
# When the window cannot bind, make_mask returns the cheap
# "causal"/None form whose width is implicit in the key
# tensor; densify so the invariant stays checkable either way.
dense = _dense_mask(mask, S, wk.shape[2] - S)
self.assertEqual(dense.shape[-1], wk.shape[2], f"mask width, {at}")
self.assertGreaterEqual(
wk.shape[2] - S, min(win.offset - S, self.W), f"prefix, {at}"
)
self.assertEqual(win.offset, full.offset, at)
for step in range(4 * self.W):
k, v = self._kv(1), self._kv(1)
full.write_token(k, v)
win.write_token(k, v)
fk, _ = full.get_kv()
wk, _ = win.get_kv()
t = min(win.offset, self.W)
at = f"decode step {step} after {chunks}"
self.assertTrue(mx.array_equal(wk[:, :, -t:, :], fk[:, :, -t:, :]), at)
self.assertEqual(win.offset, full.offset, at)
self.assertLessEqual(win.keys.shape[2], 2 * self.W, at)
def test_decode_reallocates_amortised_not_per_token(self):
"""Compaction must stay amortised O(1) on both write paths."""
for write in ("write_token", "update_and_fetch"):
win = WindowedAttentionKVCache(self.W)
big = self._kv(5 * self.W)
win.update_and_fetch(big, big)
buf, reallocs = win.keys, 0
for _ in range(10 * self.W):
getattr(win, write)(self._kv(1), self._kv(1))
if win.keys is not buf:
buf, reallocs = win.keys, reallocs + 1
self.assertLessEqual(reallocs, 12, f"{write} reallocated {reallocs}x")
def test_full_context_mask_raises_once_history_is_unservable(self):
win = WindowedAttentionKVCache(self.W)
win.update_and_fetch(self._kv(3), self._kv(3))
self.assertEqual(win.make_mask(2), "causal") # nothing dropped yet
# One oversized chunk is enough: the next update normalises the
# prefix to the window, so full context is already unservable.
win.update_and_fetch(self._kv(10), self._kv(10))
with self.assertRaises(RuntimeError):
win.make_mask(2)
def test_reset_keeps_buffers_and_replays(self):
win = WindowedAttentionKVCache(self.W)
win.update_and_fetch(self._kv(6), self._kv(6))
win.reset()
self.assertEqual(win.offset, 0)
self.assertIsNotNone(win.keys) # buffer kept for reuse
k = self._kv(2)
out, _ = win.update_and_fetch(k, k)
self.assertEqual(out.shape[2], 2) # no stale prefix survived
self.assertTrue(mx.array_equal(out, k))
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
class TestWindowedModelForward(CustomTestCase):
"""Container path: chunked prefill + greedy decode on a tiny gpt-oss."""
def test_chunked_prefill_and_greedy_decode_match_full_history(self):
mx.random.seed(0)
model = _tiny_gpt_oss_model()
windows = get_layer_window_sizes(model)
self.assertEqual([windows[i] for i in range(4)], [WINDOW, None, WINDOW, None])
ids = (mx.arange(20) * 7 + 3) % 128 # 2.5x window
split = 12 # second chunk starts beyond the window
tokens = {}
for name in ("windowed", "full"):
cache = [
(
WindowedAttentionKVCache(windows[i])
if name == "windowed" and windows[i] is not None
else ContiguousAttentionKVCache(max_seq_len=64)
)
for i in range(4)
]
model(ids[None, :split], cache=cache)
out = model(ids[None, split:], cache=cache)
seq = []
for _ in range(2 * WINDOW): # crosses the compaction boundary
token = mx.argmax(out[:, -1, :], axis=-1)
seq.append(token.item())
out = model(token[None], cache=cache)
tokens[name] = seq
self.assertEqual(
tokens["windowed"],
tokens["full"],
"windowed caches diverge from full history",
)
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
class TestWindowedBatchedDecode(CustomTestCase):
"""The production decode path (``MLXAttentionWrapper._batched_decode``).
Windowed and full-history caches are driven through the *same* wrapper
with the same inputs. The wrapper slices the trailing window off
whatever ``get_kv`` returns, so both runs must build byte-identical SDPA
inputs and the outputs must be bit-equal, not merely close.
"""
def test_chained_decode_across_compaction_boundary(self):
"""Decode steps built in still-lazy pairs, riding a compaction.
Pairs mirror ``decode_batch_start_chained``: step N+1's graph is
built before step N materialises. Compaction allocates a fresh
buffer instead of mutating in place, so step N's returned views must
stay valid. Prefill lengths 15 and 6 put the oversized-chunk shrink
in the first pair and a steady-state rebuild in a later one.
"""
mx.random.seed(1)
attn = _tiny_gpt_oss_model().model.layers[0].self_attn
wrapper = MLXAttentionWrapper(attn, layer_idx=0, window_size=WINDOW)
wins, fulls = [], []
for length in (15, 6):
x = mx.random.normal((1, length, HIDDEN))
win = WindowedAttentionKVCache(WINDOW)
full = ContiguousAttentionKVCache(max_seq_len=64)
for cache in (win, full):
attn(x, make_attention_mask(length, 0, window_size=WINDOW), cache=cache)
wins.append(win)
fulls.append(full)
def build_step(x_step, caches):
ctx = BatchedDecodeContext(
batch_size=len(caches),
seq_lens=[c.offset for c in caches],
attention_layer_caches=[caches],
)
return wrapper._batched_decode(x_step, ctx)
for pair in range(6):
steps = [mx.random.normal((len(wins), 1, HIDDEN)) for _ in range(2)]
# Both graphs are built before either materialises.
got = [(build_step(x, wins), build_step(x, fulls)) for x in steps]
mx.eval(got)
for tag, (windowed, full) in zip("ab", got):
self.assertTrue(
mx.array_equal(windowed, full), f"pair {pair}{tag} diverges"
)
for win, full in zip(wins, fulls):
self.assertEqual(win.offset, full.offset)
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
class TestModelRunnerCacheWiring(CustomTestCase):
"""``_new_native_cache``/``_acquire_cache`` wiring without loading weights."""
def _stub_runner(self, window_map):
from sglang.srt.hardware_backend.mlx.model_runner import MlxModelRunner
layers, attrs = find_attention_layers(_tiny_gpt_oss_model())
runner = MlxModelRunner.__new__(MlxModelRunner)
runner._cache_layout = MlxModelCacheLayout.from_attention_discovery(
layers, attrs, layer_window_sizes=window_map
)
runner._max_seq_len = 4096
runner._cache_pool = []
return runner
def test_windowed_only_for_sliding_layers_and_reset_on_reuse(self):
runner = self._stub_runner(get_layer_window_sizes(_tiny_gpt_oss_model()))
cache = runner._new_native_cache()
self.assertEqual(
[type(c) for c in cache],
[
WindowedAttentionKVCache,
ContiguousAttentionKVCache,
WindowedAttentionKVCache,
ContiguousAttentionKVCache,
],
)
self.assertEqual(cache[0].window, WINDOW)
# Models without container windows have an empty map, so every
# attention layer keeps a contiguous full-history cache.
for c in self._stub_runner({})._new_native_cache():
self.assertIsInstance(c, ContiguousAttentionKVCache)
k = mx.random.normal((1, N_KV_HEADS, 10, HEAD_DIM))
cache[0].update_and_fetch(k, k)
cache[1].update_and_fetch(k, k)
runner._release_cache(cache)
reused = runner._acquire_cache()
self.assertIs(reused, cache)
for c in reused:
self.assertEqual(c.offset, 0)
# A stale local buffer would prepend the previous request's KV.
out, _ = reused[0].update_and_fetch(k, k)
self.assertEqual(out.shape[2], 10)
if __name__ == "__main__":
unittest.main()