[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:
co-authored by
Siming Deng
R0CKSTAR
Jiminator
damahua
Claude Opus 5
parent
449f0da78f
commit
2969ab3d41
@@ -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.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.02)"}}><code>None</code></td>
|
||||||
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Type: int</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>
|
||||||
<tr>
|
<tr>
|
||||||
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`--constrained-json-whitespace-pattern`</td>
|
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`--constrained-json-whitespace-pattern`</td>
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ from sglang.srt.utils.common import (
|
|||||||
is_xpu,
|
is_xpu,
|
||||||
xpu_has_xmx_support,
|
xpu_has_xmx_support,
|
||||||
)
|
)
|
||||||
|
from sglang.srt.utils.tensor_bridge import use_mlx
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -933,8 +934,12 @@ def _gpt_oss_overrides(server_args: Any, hf_config: Any) -> dict:
|
|||||||
overrides["attention_backend"] = "intel_xpu"
|
overrides["attention_backend"] = "intel_xpu"
|
||||||
elif is_hip():
|
elif is_hip():
|
||||||
overrides["attention_backend"] = "aiter"
|
overrides["attention_backend"] = "aiter"
|
||||||
elif not is_mps():
|
elif not (is_mps() and use_mlx()):
|
||||||
# No triton on macOS; MPS keeps the platform default.
|
# 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"
|
overrides["attention_backend"] = "triton"
|
||||||
if is_xpu():
|
if is_xpu():
|
||||||
# Check for bf16 dtype on Intel XPU. Reads the pristine dtype request,
|
# 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.constrained.utils import is_legacy_structural_tag
|
||||||
from sglang.srt.utils import is_hip
|
from sglang.srt.utils import is_hip
|
||||||
|
from sglang.srt.utils.common import is_pin_memory_available
|
||||||
|
|
||||||
_is_hip = is_hip()
|
_is_hip = is_hip()
|
||||||
|
|
||||||
@@ -58,14 +59,16 @@ MAX_ROLLBACK_TOKENS = 200
|
|||||||
|
|
||||||
|
|
||||||
def _allocate_token_bitmask(vocab_size: int, batch_size: int) -> torch.Tensor:
|
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
|
# Allocate a pinned bitmask where pinning exists so the later H2D to the
|
||||||
# genuine non_blocking copy (a pageable source silently downgrades it to a
|
# device can be a genuine non_blocking copy (a pageable source silently
|
||||||
# blocking copy).
|
# 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(
|
return torch.full(
|
||||||
get_bitmask_shape(batch_size, vocab_size),
|
get_bitmask_shape(batch_size, vocab_size),
|
||||||
-1,
|
-1,
|
||||||
dtype=bitmask_dtype,
|
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
|
import sgl_kernel_npu # noqa: F401
|
||||||
|
|
||||||
torch.ops.npu.apply_token_bitmask(logits, vocab_mask)
|
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:
|
else:
|
||||||
raise RuntimeError(f"Unsupported device: {logits.device.type}")
|
raise RuntimeError(f"Unsupported device: {logits.device.type}")
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
import mlx.core as mx
|
import mlx.core as mx
|
||||||
|
|
||||||
@@ -12,11 +12,6 @@ from sglang.srt.environ import envs
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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():
|
def _load_metal_rope_pool_fused():
|
||||||
try:
|
try:
|
||||||
@@ -219,7 +214,10 @@ class MlxAOTKernelContext:
|
|||||||
req_ids: list[str],
|
req_ids: list[str],
|
||||||
req_pool_idx: dict[str, int],
|
req_pool_idx: dict[str, int],
|
||||||
req_to_token_pool: Any | None,
|
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:
|
) -> MlxAOTKernelContext:
|
||||||
"""Build optional AOT context for one batched decode step."""
|
"""Build optional AOT context for one batched decode step."""
|
||||||
if not aot_kernels.rope.enabled or kv_pool is None:
|
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,
|
AttentionOffsetCache,
|
||||||
ContiguousAttentionKVCache,
|
ContiguousAttentionKVCache,
|
||||||
PoolBackedAttentionKVCache,
|
PoolBackedAttentionKVCache,
|
||||||
|
WindowedAttentionKVCache,
|
||||||
make_attention_mask,
|
make_attention_mask,
|
||||||
)
|
)
|
||||||
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_pool import (
|
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_pool import (
|
||||||
@@ -64,4 +65,5 @@ __all__ = [
|
|||||||
"PoolBackedAttentionKVCache",
|
"PoolBackedAttentionKVCache",
|
||||||
"set_context",
|
"set_context",
|
||||||
"uses_sliding_window_attention",
|
"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:
|
def get_container_window_size(model: Any) -> int | None:
|
||||||
"""The container-level scalar sliding window, if the model declares one."""
|
"""The container-level scalar sliding window, if the model declares one."""
|
||||||
root = getattr(model, "language_model", model)
|
return first_present_attr(language_model_container(model), WINDOW_SIZE_ATTRS)
|
||||||
container = getattr(root, "model", root)
|
|
||||||
return first_present_attr(container, WINDOW_SIZE_ATTRS)
|
|
||||||
|
|
||||||
|
|
||||||
def get_layer_window_sizes(model: Any) -> dict[int, int | None]:
|
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
|
``{layer_idx: window or None}``, or ``{}`` when the model does not
|
||||||
follow the convention.
|
follow the convention.
|
||||||
"""
|
"""
|
||||||
root = getattr(model, "language_model", model)
|
container = language_model_container(model)
|
||||||
container = getattr(root, "model", root)
|
|
||||||
layer_types = getattr(container, "layer_types", None)
|
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:
|
if not layer_types or window_size is None:
|
||||||
return {}
|
return {}
|
||||||
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
|
layers pass it, including for N == 1) or windowed models silently fall
|
||||||
back to full attention.
|
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)
|
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:
|
if N == 1:
|
||||||
return None
|
return None
|
||||||
if return_array:
|
if return_array:
|
||||||
@@ -141,9 +152,130 @@ class ContiguousAttentionKVCache:
|
|||||||
self.values[:, :, self.offset : end, :] = v
|
self.values[:, :, self.offset : end, :] = v
|
||||||
self.offset = end
|
self.offset = end
|
||||||
|
|
||||||
def get_kv(self) -> tuple[mx.array, mx.array]:
|
def get_kv(self, window: int | None = None) -> tuple[mx.array, mx.array]:
|
||||||
"""Return valid K/V: (1, n_kv_heads, offset, head_dim)."""
|
"""Return valid K/V: (1, n_kv_heads, min(offset, window), head_dim).
|
||||||
return self.keys[:, :, : self.offset, :], self.values[:, :, : self.offset, :]
|
|
||||||
|
``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:
|
class PoolBackedAttentionKVCache:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Flat attention KV pool for the MLX backend.
|
"""Flat attention KV pool for the MLX backend.
|
||||||
|
|
||||||
Each layer buffer has shape ``(pool_size, n_kv_heads, head_dim)``.
|
Each layer buffer has shape ``(pool_size, n_kv_heads, head_dim)``.
|
||||||
This v1 pool is intentionally uniform: every wrapped softmax-attention
|
The pool stores full-attention layers only and stays intentionally
|
||||||
layer must share the same KV shape and full-context KV semantics.
|
uniform: every stored layer must share the same KV shape and
|
||||||
Heterogeneous KV shapes and sliding-window KV need per-layer/window-aware
|
full-context KV semantics. Sliding-window layers keep window-bounded
|
||||||
pools before they can use MLX radix reuse.
|
per-request caches instead; heterogeneous KV shapes need per-layer pools.
|
||||||
|
|
||||||
Slot 0 is reserved as padding (1-based indexing).
|
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 (
|
from sglang.srt.hardware_backend.mlx.kv_cache.attention_kv_cache import (
|
||||||
ContiguousAttentionKVCache,
|
ContiguousAttentionKVCache,
|
||||||
|
WindowedAttentionKVCache,
|
||||||
)
|
)
|
||||||
|
|
||||||
_thread_local = threading.local()
|
_thread_local = threading.local()
|
||||||
@@ -34,9 +35,15 @@ class BatchedDecodeContext:
|
|||||||
|
|
||||||
batch_size: int
|
batch_size: int
|
||||||
seq_lens: list[int] # per-request token count before the new token
|
seq_lens: list[int] # per-request token count before the new token
|
||||||
# attention_layer_caches[attention_pool_idx][req_idx] = ContiguousAttentionKVCache
|
# attention_layer_caches[cache_idx][req_idx], dense over attention layers.
|
||||||
attention_layer_caches: list[list[ContiguousAttentionKVCache]]
|
# 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)
|
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
|
# 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
|
# 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)
|
needs_padding: bool = field(init=False)
|
||||||
pad_sizes: list[int] = field(init=False)
|
pad_sizes: list[int] = field(init=False)
|
||||||
positions: Optional[mx.array] = 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:
|
def __post_init__(self) -> None:
|
||||||
seq_lens = self.seq_lens
|
seq_lens = self.seq_lens
|
||||||
@@ -64,6 +76,56 @@ class BatchedDecodeContext:
|
|||||||
self.attention_pool_index_by_layer = {
|
self.attention_pool_index_by_layer = {
|
||||||
idx: idx for idx in range(len(self.attention_layer_caches))
|
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
|
@classmethod
|
||||||
def from_decode(
|
def from_decode(
|
||||||
@@ -77,10 +139,15 @@ class BatchedDecodeContext:
|
|||||||
req_to_token_pool: Any | None,
|
req_to_token_pool: Any | None,
|
||||||
attention_layer_indices: list[int] | None = None,
|
attention_layer_indices: list[int] | None = None,
|
||||||
attention_pool_index_by_layer: dict[int, 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:
|
) -> BatchedDecodeContext:
|
||||||
batch_size = len(req_ids)
|
batch_size = len(req_ids)
|
||||||
if attention_layer_indices is None:
|
if attention_layer_indices is None:
|
||||||
attention_layer_indices = list(range(len(caches[0])))
|
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 = [
|
seq_lens = [
|
||||||
caches[i][attention_layer_indices[0]].offset for i in range(batch_size)
|
caches[i][attention_layer_indices[0]].offset for i in range(batch_size)
|
||||||
]
|
]
|
||||||
@@ -93,6 +160,7 @@ class BatchedDecodeContext:
|
|||||||
seq_lens=seq_lens,
|
seq_lens=seq_lens,
|
||||||
attention_layer_caches=attention_layer_caches,
|
attention_layer_caches=attention_layer_caches,
|
||||||
attention_pool_index_by_layer=attention_pool_index_by_layer or {},
|
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=MlxAOTKernelContext.from_decode(
|
||||||
aot_kernels=aot_kernels,
|
aot_kernels=aot_kernels,
|
||||||
kv_pool=kv_pool,
|
kv_pool=kv_pool,
|
||||||
@@ -122,9 +190,11 @@ class MLXAttentionWrapper(nn.Module):
|
|||||||
When ``BatchedDecodeContext`` is set, performs per-request RoPE,
|
When ``BatchedDecodeContext`` is set, performs per-request RoPE,
|
||||||
cache writes, and batched SDPA. Otherwise delegates to inner module.
|
cache writes, and batched SDPA. Otherwise delegates to inner module.
|
||||||
|
|
||||||
``window_size`` marks a sliding-window layer: the pool keeps the full
|
``window_size`` marks a sliding-window layer: the wrapper attends to
|
||||||
KV history and the wrapper attends to the trailing window only, which
|
the trailing window of the cached keys only, which is numerically
|
||||||
is numerically identical to a rotating cache.
|
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__(
|
def __init__(
|
||||||
@@ -142,8 +212,27 @@ class MLXAttentionWrapper(nn.Module):
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Cannot determine attention scale for {type(inner).__name__}"
|
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, "_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:
|
def __call__(self, x: mx.array, mask: Any = None, cache: Any = None) -> mx.array:
|
||||||
ctx = get_context()
|
ctx = get_context()
|
||||||
@@ -155,18 +244,14 @@ class MLXAttentionWrapper(nn.Module):
|
|||||||
inner = self._inner
|
inner = self._inner
|
||||||
layer_idx = self._layer_idx
|
layer_idx = self._layer_idx
|
||||||
B = ctx.batch_size
|
B = ctx.batch_size
|
||||||
n_heads = get_num_heads(inner)
|
n_heads = self._n_heads
|
||||||
n_kv_heads = get_num_kv_heads(inner)
|
n_kv_heads = self._n_kv_heads
|
||||||
if n_heads is None or n_kv_heads is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Cannot determine attention head counts for {type(inner).__name__}"
|
|
||||||
)
|
|
||||||
|
|
||||||
q_proj_output = inner.q_proj(x)
|
q_proj_output = inner.q_proj(x)
|
||||||
keys = inner.k_proj(x)
|
keys = inner.k_proj(x)
|
||||||
values = inner.v_proj(x)
|
values = inner.v_proj(x)
|
||||||
|
|
||||||
head_dim = get_head_dim(inner)
|
head_dim = self._head_dim
|
||||||
if head_dim is None:
|
if head_dim is None:
|
||||||
head_dim = keys.shape[-1] // n_kv_heads
|
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)
|
keys = keys.reshape(B, 1, n_kv_heads, head_dim)
|
||||||
values = values.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)
|
queries = inner.q_norm(queries)
|
||||||
if hasattr(inner, "k_norm"):
|
if self._has_k_norm:
|
||||||
keys = inner.k_norm(keys)
|
keys = inner.k_norm(keys)
|
||||||
|
|
||||||
queries = queries.transpose(0, 2, 1, 3)
|
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).
|
# Vectorized RoPE with per-batch offsets (cached on the context).
|
||||||
offsets = ctx.offsets
|
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:
|
if ctx.aot.rope is not None and window is None:
|
||||||
# AOT path: real .metallib RoPE + fused KV pool scatter.
|
# AOT path: real .metallib RoPE + fused scatter into this layer's
|
||||||
|
# pool buffer.
|
||||||
queries, keys = self._rope_custom_aot(
|
queries, keys = self._rope_custom_aot(
|
||||||
queries,
|
queries,
|
||||||
keys,
|
keys,
|
||||||
values,
|
values,
|
||||||
offsets,
|
offsets,
|
||||||
attention_pool_idx,
|
ctx.full_kv_pool_index_by_layer[layer_idx],
|
||||||
ctx.aot.rope,
|
ctx.aot.rope,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback: MLX's built-in mx.fast.rope (used when the AOT kernel
|
# Fallback: MLX's built-in mx.fast.rope. Used when the AOT kernel
|
||||||
# isn't built or the model uses an unsupported RoPE variant).
|
# 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)
|
queries = inner.rope(queries, offset=offsets)
|
||||||
keys = inner.rope(keys, offset=offsets)
|
keys = inner.rope(keys, offset=offsets)
|
||||||
|
|
||||||
layer_caches = ctx.attention_layer_caches[attention_pool_idx]
|
layer_caches = ctx.attention_layer_caches[cache_idx]
|
||||||
window = self._window_size
|
# A sliding-window layer reads only the trailing ``window`` keys, so its
|
||||||
if window is None:
|
# padded width differs from the unwindowed one. Both are memoised on
|
||||||
pad_sizes = ctx.pad_sizes
|
# the context and shared by every layer of their kind.
|
||||||
else:
|
pad_sizes, attn_mask = ctx.decode_padding(window)
|
||||||
# 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]
|
|
||||||
|
|
||||||
# TODO: replace per-request loop with native batched/ragged
|
# TODO: replace per-request loop with native batched/ragged
|
||||||
# attention once mx.fast.scaled_dot_product_attention supports
|
# attention once mx.fast.scaled_dot_product_attention supports
|
||||||
@@ -239,10 +320,7 @@ class MLXAttentionWrapper(nn.Module):
|
|||||||
for i in range(B):
|
for i in range(B):
|
||||||
layer_caches[i].write_token(keys[i : i + 1], values[i : i + 1])
|
layer_caches[i].write_token(keys[i : i + 1], values[i : i + 1])
|
||||||
|
|
||||||
k_all, v_all = layer_caches[i].get_kv()
|
k_all, v_all = layer_caches[i].get_kv(window)
|
||||||
if window is not None and k_all.shape[2] > window:
|
|
||||||
k_all = k_all[:, :, -window:, :]
|
|
||||||
v_all = v_all[:, :, -window:, :]
|
|
||||||
|
|
||||||
pad = pad_sizes[i]
|
pad = pad_sizes[i]
|
||||||
if pad > 0:
|
if pad > 0:
|
||||||
@@ -257,34 +335,13 @@ class MLXAttentionWrapper(nn.Module):
|
|||||||
keys_b = mx.concatenate(all_k, axis=0)
|
keys_b = mx.concatenate(all_k, axis=0)
|
||||||
values_b = mx.concatenate(all_v, 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(
|
output = mx.fast.scaled_dot_product_attention(
|
||||||
queries,
|
queries,
|
||||||
keys_b,
|
keys_b,
|
||||||
values_b,
|
values_b,
|
||||||
scale=self._scale,
|
scale=self._scale,
|
||||||
mask=attn_mask,
|
mask=attn_mask,
|
||||||
**sink_kwargs,
|
**self._sink_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
output = output.transpose(0, 2, 1, 3).reshape(B, 1, -1)
|
output = output.transpose(0, 2, 1, 3).reshape(B, 1, -1)
|
||||||
@@ -298,14 +355,14 @@ class MLXAttentionWrapper(nn.Module):
|
|||||||
keys: mx.array,
|
keys: mx.array,
|
||||||
values: mx.array,
|
values: mx.array,
|
||||||
positions: mx.array,
|
positions: mx.array,
|
||||||
attention_pool_idx: int,
|
full_pool_idx: int,
|
||||||
rope_ctx: MlxAOTRoPEContext,
|
rope_ctx: MlxAOTRoPEContext,
|
||||||
) -> tuple[mx.array, mx.array]:
|
) -> tuple[mx.array, mx.array]:
|
||||||
"""AOT path: rotate Q/K and scatter K/V into the shared pool.
|
"""AOT path: rotate Q/K and scatter K/V into the shared pool.
|
||||||
|
|
||||||
The kernel call does RoPE on Q/K and scatters
|
The kernel call does RoPE on Q/K and scatters
|
||||||
rotated K + (untouched) V into ``kv_pool`` at ``new_token_slots``
|
rotated K + (untouched) V into ``kv_pool`` buffer ``full_pool_idx``
|
||||||
for ``layer_idx``.
|
at ``new_token_slots``.
|
||||||
|
|
||||||
If ``new_token_slots`` is None, slot=-1 sentinel is used (no pool
|
If ``new_token_slots`` is None, slot=-1 sentinel is used (no pool
|
||||||
write, RoPE-only mode). Returns rotated (queries, keys) in the
|
write, RoPE-only mode). Returns rotated (queries, keys) in the
|
||||||
@@ -322,8 +379,8 @@ class MLXAttentionWrapper(nn.Module):
|
|||||||
else:
|
else:
|
||||||
slots = rope_ctx.new_token_slots.astype(mx.int32)
|
slots = rope_ctx.new_token_slots.astype(mx.int32)
|
||||||
|
|
||||||
k_pool = rope_ctx.kv_pool.k_buffer[attention_pool_idx]
|
k_pool = rope_ctx.kv_pool.k_buffer[full_pool_idx]
|
||||||
v_pool = rope_ctx.kv_pool.v_buffer[attention_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_rot, k_rot, k_pool_new, v_pool_new = rope_ctx.kernel.rope_pool_fused(
|
||||||
q_flat,
|
q_flat,
|
||||||
@@ -339,8 +396,8 @@ class MLXAttentionWrapper(nn.Module):
|
|||||||
rope_base=rope_ctx.kernel.base,
|
rope_base=rope_ctx.kernel.base,
|
||||||
)
|
)
|
||||||
# Rebind pool buffers (zero-copy donation result).
|
# Rebind pool buffers (zero-copy donation result).
|
||||||
rope_ctx.kv_pool.k_buffer[attention_pool_idx] = k_pool_new
|
rope_ctx.kv_pool.k_buffer[full_pool_idx] = k_pool_new
|
||||||
rope_ctx.kv_pool.v_buffer[attention_pool_idx] = v_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
|
# (B, n_heads, head_dim) -> (B, n_heads, 1, head_dim) for SDPA path
|
||||||
return q_rot[:, :, None, :], k_rot[:, :, None, :]
|
return q_rot[:, :, None, :], k_rot[:, :, None, :]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Sequence
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
|
||||||
@@ -10,9 +10,13 @@ from typing import Any, Sequence
|
|||||||
class MlxModelCacheLayout:
|
class MlxModelCacheLayout:
|
||||||
"""Map model layers to MLX cache storage components.
|
"""Map model layers to MLX cache storage components.
|
||||||
|
|
||||||
Attention layers store softmax-attention KV in the MLX attention KV pool.
|
Full-attention layers store softmax-attention KV in the shared MLX
|
||||||
Auxiliary layers keep native ``mlx-lm`` cache state and are snapshotted by
|
attention KV pool; sliding-window layers keep per-request windowed KV
|
||||||
the MLX auxiliary-state component.
|
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, ...]
|
layers: tuple[Any, ...]
|
||||||
@@ -20,12 +24,37 @@ class MlxModelCacheLayout:
|
|||||||
attention_layer_indices: tuple[int, ...]
|
attention_layer_indices: tuple[int, ...]
|
||||||
auxiliary_layer_indices: tuple[int, ...]
|
auxiliary_layer_indices: tuple[int, ...]
|
||||||
attention_pool_index_by_layer: dict[int, 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
|
@classmethod
|
||||||
def from_attention_discovery(
|
def from_attention_discovery(
|
||||||
cls,
|
cls,
|
||||||
layers: Sequence[Any],
|
layers: Sequence[Any],
|
||||||
attention_attrs: Sequence[str | None],
|
attention_attrs: Sequence[str | None],
|
||||||
|
layer_window_sizes: dict[int, int | None] | None = None,
|
||||||
) -> MlxModelCacheLayout:
|
) -> MlxModelCacheLayout:
|
||||||
if len(layers) != len(attention_attrs):
|
if len(layers) != len(attention_attrs):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -50,6 +79,7 @@ class MlxModelCacheLayout:
|
|||||||
attention_layer_indices=attention_layer_indices,
|
attention_layer_indices=attention_layer_indices,
|
||||||
auxiliary_layer_indices=auxiliary_layer_indices,
|
auxiliary_layer_indices=auxiliary_layer_indices,
|
||||||
attention_pool_index_by_layer=attention_pool_index_by_layer,
|
attention_pool_index_by_layer=attention_pool_index_by_layer,
|
||||||
|
layer_window_sizes=dict(layer_window_sizes or {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -60,6 +90,14 @@ class MlxModelCacheLayout:
|
|||||||
def num_attention_layers(self) -> int:
|
def num_attention_layers(self) -> int:
|
||||||
return len(self.attention_layer_indices)
|
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
|
@property
|
||||||
def has_auxiliary_state(self) -> bool:
|
def has_auxiliary_state(self) -> bool:
|
||||||
return bool(self.auxiliary_layer_indices)
|
return bool(self.auxiliary_layer_indices)
|
||||||
@@ -70,12 +108,22 @@ class MlxModelCacheLayout:
|
|||||||
raise RuntimeError("MLX model has no supported attention layers")
|
raise RuntimeError("MLX model has no supported attention layers")
|
||||||
return self.attention_layer_indices[0]
|
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:
|
def attention_pool_index(self, layer_idx: int) -> int:
|
||||||
try:
|
try:
|
||||||
return self.attention_pool_index_by_layer[layer_idx]
|
return self.attention_pool_index_by_layer[layer_idx]
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise KeyError(f"Layer {layer_idx} is not an attention layer") from 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:
|
def attention_attr(self, layer_idx: int) -> str:
|
||||||
attr = self.attention_attrs[layer_idx]
|
attr = self.attention_attrs[layer_idx]
|
||||||
if attr is None:
|
if attr is None:
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ scheduler (``TokenToKVPoolAllocator`` / ``RadixCache``). This runner
|
|||||||
reads cached attention KV from ``MlxAttentionKVPool``, restores any
|
reads cached attention KV from ``MlxAttentionKVPool``, restores any
|
||||||
native auxiliary layer state, runs the forward pass, and writes the new
|
native auxiliary layer state, runs the forward pass, and writes the new
|
||||||
cache state back. Each request keeps model-shaped cache entries:
|
cache state back. Each request keeps model-shaped cache entries:
|
||||||
attention layers use ``ContiguousAttentionKVCache`` and auxiliary layers
|
full-attention layers use ``ContiguousAttentionKVCache``, sliding-window
|
||||||
use native ``mlx-lm`` cache objects.
|
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
|
The module also exposes a lazy-eval (`*_start` / `*_finalize`) surface
|
||||||
used by the MLX overlap scheduler to pipeline CPU bookkeeping with
|
used by the MLX overlap scheduler to pipeline CPU bookkeeping with
|
||||||
@@ -22,6 +29,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import mlx.core as mx
|
import mlx.core as mx
|
||||||
|
import numpy as np
|
||||||
import psutil
|
import psutil
|
||||||
from mlx.utils import tree_flatten
|
from mlx.utils import tree_flatten
|
||||||
from mlx_lm import load as mlx_lm_load
|
from mlx_lm import load as mlx_lm_load
|
||||||
@@ -40,14 +48,29 @@ from sglang.srt.hardware_backend.mlx.kv_cache import (
|
|||||||
MLXAttentionWrapper,
|
MLXAttentionWrapper,
|
||||||
MlxModelCacheLayout,
|
MlxModelCacheLayout,
|
||||||
PoolBackedAttentionKVCache,
|
PoolBackedAttentionKVCache,
|
||||||
|
WindowedAttentionKVCache,
|
||||||
clear_context,
|
clear_context,
|
||||||
find_attention_layers,
|
find_attention_layers,
|
||||||
get_head_dim,
|
get_head_dim,
|
||||||
|
get_layer_window_sizes,
|
||||||
get_num_kv_heads,
|
get_num_kv_heads,
|
||||||
patch_model_attention,
|
patch_model_attention,
|
||||||
set_context,
|
set_context,
|
||||||
uses_sliding_window_attention,
|
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.mem_cache.memory_pool import ReqToTokenPool
|
||||||
from sglang.srt.runtime_context import (
|
from sglang.srt.runtime_context import (
|
||||||
mamba_cache_chunk_size,
|
mamba_cache_chunk_size,
|
||||||
@@ -72,6 +95,7 @@ class MlxPendingPrefill:
|
|||||||
full_token_ids: list[int]
|
full_token_ids: list[int]
|
||||||
req_pool_idx: int
|
req_pool_idx: int
|
||||||
synced_offset: int
|
synced_offset: int
|
||||||
|
lazy_logprobs: MlxLazyLogprobs | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -85,9 +109,11 @@ class MlxPendingExtend:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
lazy_token: mx.array
|
lazy_token: mx.array
|
||||||
|
cache: list[Any]
|
||||||
req_id: str
|
req_id: str
|
||||||
new_token_ids: list[int]
|
new_token_ids: list[int]
|
||||||
new_synced_offset: int
|
new_synced_offset: int
|
||||||
|
lazy_logprobs: MlxLazyLogprobs | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -104,6 +130,13 @@ class MlxPendingDecode:
|
|||||||
lazy_tokens: mx.array
|
lazy_tokens: mx.array
|
||||||
req_ids: list[str]
|
req_ids: list[str]
|
||||||
caches: list[list[Any]]
|
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]] = {
|
_MLX_QUANTIZATION_PRESETS: dict[str, tuple[int, int]] = {
|
||||||
@@ -117,6 +150,14 @@ _MLX_KV_FLOAT_DTYPES = {mx.float16, mx.bfloat16, mx.float32}
|
|||||||
class MlxModelRunner:
|
class MlxModelRunner:
|
||||||
"""MLX model runner with radix-cache prefix sharing."""
|
"""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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
model_path: str,
|
model_path: str,
|
||||||
@@ -125,12 +166,27 @@ class MlxModelRunner:
|
|||||||
pool_size: int | None = None,
|
pool_size: int | None = None,
|
||||||
mem_fraction_static: float = 0.8,
|
mem_fraction_static: float = 0.8,
|
||||||
quantization: str | None = None,
|
quantization: str | None = None,
|
||||||
|
enable_sampling: bool = False,
|
||||||
|
sampling_rng_seed: int = 0,
|
||||||
|
deterministic_seeding: bool = False,
|
||||||
):
|
):
|
||||||
self.model_path = model_path
|
self.model_path = model_path
|
||||||
self.trust_remote_code = trust_remote_code
|
self.trust_remote_code = trust_remote_code
|
||||||
self.model = None
|
self.model = None
|
||||||
self.disable_radix_cache = disable_radix_cache
|
self.disable_radix_cache = disable_radix_cache
|
||||||
self._mem_fraction_static = mem_fraction_static
|
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.
|
# Counter used to trigger periodic mx.clear_cache() calls.
|
||||||
self._decode_step_ct: int = 0
|
self._decode_step_ct: int = 0
|
||||||
self._clear_steps = envs.SGLANG_MLX_CLEAR_CACHE_STEPS.get()
|
self._clear_steps = envs.SGLANG_MLX_CLEAR_CACHE_STEPS.get()
|
||||||
@@ -155,6 +211,8 @@ class MlxModelRunner:
|
|||||||
self._cache_layout = MlxModelCacheLayout.from_attention_discovery(
|
self._cache_layout = MlxModelCacheLayout.from_attention_discovery(
|
||||||
layer_list,
|
layer_list,
|
||||||
attn_attrs,
|
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:
|
if self._cache_layout.num_attention_layers == 0:
|
||||||
raise RuntimeError("MLX model has no supported attention layers")
|
raise RuntimeError("MLX model has no supported attention layers")
|
||||||
@@ -164,6 +222,16 @@ class MlxModelRunner:
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"MLX models with auxiliary cache state require model.make_cache()."
|
"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:
|
if self._cache_layout.has_auxiliary_state:
|
||||||
self._model_embed, self._model_norm, self._model_lm_head = (
|
self._model_embed, self._model_norm, self._model_lm_head = (
|
||||||
self._extract_model_components()
|
self._extract_model_components()
|
||||||
@@ -172,7 +240,9 @@ class MlxModelRunner:
|
|||||||
|
|
||||||
self._req_caches: dict[str, list[Any]] = {}
|
self._req_caches: dict[str, list[Any]] = {}
|
||||||
self._req_token_ids: dict[str, list[int]] = {}
|
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._attention_kv_pool: MlxAttentionKVPool | None = None
|
||||||
self._req_to_token_pool: ReqToTokenPool | 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."""
|
"""Create a model-shaped cache list with attention KV adapters."""
|
||||||
cache = self._new_cache_skeleton()
|
cache = self._new_cache_skeleton()
|
||||||
for layer_idx in self._cache_layout.attention_layer_indices:
|
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
|
return cache
|
||||||
|
|
||||||
def _acquire_cache(self) -> list[Any]:
|
def _acquire_cache(self) -> list[Any]:
|
||||||
@@ -214,7 +289,7 @@ class MlxModelRunner:
|
|||||||
if not self._cache_layout.has_auxiliary_state and self._cache_pool:
|
if not self._cache_layout.has_auxiliary_state and self._cache_pool:
|
||||||
cache = self._cache_pool.pop()
|
cache = self._cache_pool.pop()
|
||||||
for c in cache:
|
for c in cache:
|
||||||
c.offset = 0
|
c.reset()
|
||||||
return cache
|
return cache
|
||||||
return self._new_native_cache()
|
return self._new_native_cache()
|
||||||
|
|
||||||
@@ -335,13 +410,18 @@ class MlxModelRunner:
|
|||||||
def _cache_with_pool_backed_attention(
|
def _cache_with_pool_backed_attention(
|
||||||
self, prefix_slot_ids: list[int], prefix_len: int
|
self, prefix_slot_ids: list[int], prefix_len: int
|
||||||
) -> list[Any]:
|
) -> 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
|
assert self._attention_kv_pool is not None
|
||||||
slot_ids_mx = mx.array(prefix_slot_ids, dtype=mx.int32)
|
slot_ids_mx = mx.array(prefix_slot_ids, dtype=mx.int32)
|
||||||
cache = self._new_cache_skeleton()
|
cache = self._new_cache_skeleton()
|
||||||
for layer_idx in self._cache_layout.attention_layer_indices:
|
for layer_idx in self._cache_layout.attention_layer_indices:
|
||||||
cache[layer_idx] = PoolBackedAttentionKVCache(
|
cache[layer_idx] = PoolBackedAttentionKVCache(
|
||||||
self._attention_kv_pool,
|
self._attention_kv_pool,
|
||||||
self._cache_layout.attention_pool_index(layer_idx),
|
self._cache_layout.full_kv_pool_index(layer_idx),
|
||||||
slot_ids_mx,
|
slot_ids_mx,
|
||||||
prefix_len,
|
prefix_len,
|
||||||
)
|
)
|
||||||
@@ -379,22 +459,15 @@ class MlxModelRunner:
|
|||||||
return arrays
|
return arrays
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _eval_with_cache(token_result: mx.array, cache: list[Any]) -> None:
|
def cache_state_arrays(caches: list[list[Any]]) -> list[mx.array]:
|
||||||
"""Evaluate token result and all cache buffers in one mx.eval call."""
|
"""Flatten per-request cache lists (``caches[req][layer]``) to arrays.
|
||||||
mx.eval(
|
|
||||||
token_result,
|
|
||||||
*[s for c in cache for s in MlxModelRunner._cache_arrays(c)],
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
Pass ``[cache]`` for a single request. Safe to hand to
|
||||||
def _cache_state_arrays(pending_caches: list[list[Any]]) -> list[mx.array]:
|
``mx.eval`` / ``mx.async_eval``.
|
||||||
"""Flatten pending decode cache state list into an array list.
|
|
||||||
|
|
||||||
Safe to hand to ``mx.async_eval``.
|
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
s
|
s
|
||||||
for cache_list in pending_caches
|
for cache_list in caches
|
||||||
for cache in cache_list
|
for cache in cache_list
|
||||||
for s in MlxModelRunner._cache_arrays(cache)
|
for s in MlxModelRunner._cache_arrays(cache)
|
||||||
]
|
]
|
||||||
@@ -465,6 +538,17 @@ class MlxModelRunner:
|
|||||||
load_time = time.time() - start_time
|
load_time = time.time() - start_time
|
||||||
logger.info(f"MLX model loaded in {load_time:.2f}s")
|
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
|
# Optional: Path B fusion — keep up_proj/gate_proj weights separate
|
||||||
# (no matmul-kernel tile regression) but fuse the swiglu activation
|
# (no matmul-kernel tile regression) but fuse the swiglu activation
|
||||||
# into the gate matmul via a custom Metal kernel. Activated by
|
# into the gate matmul via a custom Metal kernel. Activated by
|
||||||
@@ -494,11 +578,13 @@ class MlxModelRunner:
|
|||||||
) -> tuple[int, int, mx.Dtype]:
|
) -> tuple[int, int, mx.Dtype]:
|
||||||
layer = self._cache_layout.layers[layer_idx]
|
layer = self._cache_layout.layers[layer_idx]
|
||||||
sample_attn = self._attention_module_for_layer(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(
|
raise NotImplementedError(
|
||||||
"MLX radix attention KV pool does not support sliding-window "
|
f"Attention layer {layer_idx} declares sliding-window "
|
||||||
f"attention yet at layer {layer_idx}. Sliding-window KV needs "
|
"attention but the model exposes no per-layer window map "
|
||||||
"per-layer/window-aware pools."
|
"(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)
|
n_kv_heads = get_num_kv_heads(sample_attn)
|
||||||
if n_kv_heads is None:
|
if n_kv_heads is None:
|
||||||
@@ -528,7 +614,11 @@ class MlxModelRunner:
|
|||||||
return n_kv_heads, head_dim, dtype
|
return n_kv_heads, head_dim, dtype
|
||||||
|
|
||||||
def _get_attn_config(self) -> tuple[int, int, mx.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:
|
if self._cache_layout.num_attention_layers == 0:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Cannot determine attention config: no attention module found"
|
"Cannot determine attention config: no attention module found"
|
||||||
@@ -539,12 +629,11 @@ class MlxModelRunner:
|
|||||||
config = self._attention_kv_config_for_layer(layer_idx)
|
config = self._attention_kv_config_for_layer(layer_idx)
|
||||||
if config != first_config:
|
if config != first_config:
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"MLX radix attention KV pool requires uniform softmax-attention "
|
"MLX attention KV caching requires uniform softmax-attention "
|
||||||
"KV shape across layers. "
|
"KV shape across layers. "
|
||||||
f"Layer {first_layer_idx} has {first_config}, "
|
f"Layer {first_layer_idx} has {first_config}, "
|
||||||
f"but layer {layer_idx} has {config}. "
|
f"but layer {layer_idx} has {config}. "
|
||||||
"Heterogeneous attention KV or sliding-window KV needs "
|
"Heterogeneous attention KV needs per-layer pools."
|
||||||
"per-layer pools."
|
|
||||||
)
|
)
|
||||||
return first_config
|
return first_config
|
||||||
|
|
||||||
@@ -553,7 +642,13 @@ class MlxModelRunner:
|
|||||||
if explicit_size is not None:
|
if explicit_size is not None:
|
||||||
return explicit_size
|
return explicit_size
|
||||||
n_kv_heads, head_dim, dtype = self._get_attn_config()
|
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
|
sys_available = psutil.virtual_memory().available
|
||||||
mlx_limit = mx.device_info().get(
|
mlx_limit = mx.device_info().get(
|
||||||
"max_recommended_working_set_size",
|
"max_recommended_working_set_size",
|
||||||
@@ -598,15 +693,44 @@ class MlxModelRunner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def init_cache_pools(self, req_to_token_pool: ReqToTokenPool | None) -> None:
|
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
|
self._req_to_token_pool = req_to_token_pool
|
||||||
if self.disable_radix_cache:
|
if self.disable_radix_cache:
|
||||||
return
|
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()
|
n_kv_heads, head_dim, dtype = self._get_attn_config()
|
||||||
# +1 for padding slot 0
|
# +1 for padding slot 0
|
||||||
self._attention_kv_pool = MlxAttentionKVPool(
|
self._attention_kv_pool = MlxAttentionKVPool(
|
||||||
pool_size=self._pool_size + 1,
|
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,
|
n_kv_heads=n_kv_heads,
|
||||||
head_dim=head_dim,
|
head_dim=head_dim,
|
||||||
dtype=dtype,
|
dtype=dtype,
|
||||||
@@ -614,7 +738,9 @@ class MlxModelRunner:
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Attention KV pool initialized: pool_size={self._pool_size} "
|
f"Attention KV pool initialized: pool_size={self._pool_size} "
|
||||||
f"(buffer size {self._pool_size + 1} incl. padding slot 0), "
|
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"
|
f"{n_kv_heads} kv_heads, {head_dim} head_dim"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -627,8 +753,14 @@ class MlxModelRunner:
|
|||||||
new_slot_ids: list[int],
|
new_slot_ids: list[int],
|
||||||
req_pool_idx: int,
|
req_pool_idx: int,
|
||||||
req: Any | None = None,
|
req: Any | None = None,
|
||||||
|
needs_logits: bool = True,
|
||||||
) -> int:
|
) -> 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(
|
pending = self.prefill_start(
|
||||||
req_id=req_id,
|
req_id=req_id,
|
||||||
new_token_ids=new_token_ids,
|
new_token_ids=new_token_ids,
|
||||||
@@ -637,8 +769,9 @@ class MlxModelRunner:
|
|||||||
new_slot_ids=new_slot_ids,
|
new_slot_ids=new_slot_ids,
|
||||||
req_pool_idx=req_pool_idx,
|
req_pool_idx=req_pool_idx,
|
||||||
req=req,
|
req=req,
|
||||||
|
needs_logits=needs_logits,
|
||||||
)
|
)
|
||||||
self._eval_with_cache(pending.lazy_token, pending.cache)
|
self.eval_pending(pending)
|
||||||
return self.prefill_finalize(pending)
|
return self.prefill_finalize(pending)
|
||||||
|
|
||||||
def extend(
|
def extend(
|
||||||
@@ -646,10 +779,16 @@ class MlxModelRunner:
|
|||||||
req_id: str,
|
req_id: str,
|
||||||
new_token_ids: list[int],
|
new_token_ids: list[int],
|
||||||
new_slot_ids: list[int],
|
new_slot_ids: list[int],
|
||||||
|
needs_logits: bool = True,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Continue prefill for a chunked request. Returns next_token_id."""
|
"""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])
|
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)
|
return self.extend_finalize(pending)
|
||||||
|
|
||||||
def _sync_new_kv_to_pool(
|
def _sync_new_kv_to_pool(
|
||||||
@@ -658,9 +797,15 @@ class MlxModelRunner:
|
|||||||
cache_start: int,
|
cache_start: int,
|
||||||
slot_ids: list[int],
|
slot_ids: list[int],
|
||||||
) -> None:
|
) -> 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:
|
if not slot_ids or self._attention_kv_pool is None:
|
||||||
return
|
return
|
||||||
|
full_layer_indices = self._cache_layout.full_attention_layer_indices
|
||||||
end = cache_start + len(slot_ids)
|
end = cache_start + len(slot_ids)
|
||||||
slot_ids_mx = mx.array(slot_ids, dtype=mx.int32)
|
slot_ids_mx = mx.array(slot_ids, dtype=mx.int32)
|
||||||
# TODO: Standardize ContiguousAttentionKVCache size to avoid transpose
|
# TODO: Standardize ContiguousAttentionKVCache size to avoid transpose
|
||||||
@@ -668,13 +813,13 @@ class MlxModelRunner:
|
|||||||
k_all = mx.stack(
|
k_all = mx.stack(
|
||||||
[
|
[
|
||||||
cache[layer_idx].keys[0, :, cache_start:end, :].transpose(1, 0, 2)
|
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(
|
v_all = mx.stack(
|
||||||
[
|
[
|
||||||
cache[layer_idx].values[0, :, cache_start:end, :].transpose(1, 0, 2)
|
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)
|
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()):
|
for req_id in list(self._req_caches.keys()):
|
||||||
self._sync_decode_kv_to_pool(req_id)
|
self._sync_decode_kv_to_pool(req_id)
|
||||||
|
|
||||||
def decode_batch(
|
def decode_batch(self, req_ids: list[str]) -> list[int]:
|
||||||
self,
|
"""Decode one token per request.
|
||||||
req_ids: list[str],
|
|
||||||
) -> list[int]:
|
One-shot convenience wrapper; see :meth:`prefill`.
|
||||||
"""Decode one token per request."""
|
"""
|
||||||
pending = self.decode_batch_start(req_ids)
|
pending = self.decode_batch_start(req_ids)
|
||||||
# Evaluate lazy_tokens together with every affected cache buffer so
|
self.eval_pending(pending)
|
||||||
# the attention write-then-read ordering is materialised in one
|
|
||||||
# kernel submission.
|
|
||||||
cache_arrays = self._cache_state_arrays(pending.caches)
|
|
||||||
mx.eval(pending.lazy_tokens, *cache_arrays)
|
|
||||||
return self.decode_batch_finalize(pending)
|
return self.decode_batch_finalize(pending)
|
||||||
|
|
||||||
def prefill_start(
|
def prefill_start(
|
||||||
@@ -733,6 +874,9 @@ class MlxModelRunner:
|
|||||||
new_slot_ids: list[int],
|
new_slot_ids: list[int],
|
||||||
req_pool_idx: int,
|
req_pool_idx: int,
|
||||||
req: Any | None = None,
|
req: Any | None = None,
|
||||||
|
needs_logits: bool = True,
|
||||||
|
logit_edit_row: mx.array | None = None,
|
||||||
|
logprob_spec: MlxLogprobSpec | None = None,
|
||||||
) -> MlxPendingPrefill:
|
) -> MlxPendingPrefill:
|
||||||
"""Queue a prefill forward pass without evaluating.
|
"""Queue a prefill forward pass without evaluating.
|
||||||
|
|
||||||
@@ -740,17 +884,28 @@ class MlxModelRunner:
|
|||||||
next-token ``mx.array`` plus everything needed to commit the
|
next-token ``mx.array`` plus everything needed to commit the
|
||||||
request in :meth:`prefill_finalize`. The caller drives the GPU
|
request in :meth:`prefill_finalize`. The caller drives the GPU
|
||||||
by handing ``lazy_token`` (and cache state) to ``mx.async_eval``.
|
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)
|
prefix_len = len(prefix_slot_ids)
|
||||||
if req is not None:
|
if req is not None:
|
||||||
req.mamba_last_track_seqlen = 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:
|
if self.disable_radix_cache:
|
||||||
cache = self._acquire_cache()
|
cache = self._acquire_cache()
|
||||||
input_ids = mx.array([new_token_ids], dtype=mx.int32)
|
input_ids = mx.array([new_token_ids], dtype=mx.int32)
|
||||||
model_output = self.model(input_ids, cache=cache)
|
lazy_token, lazy_logprobs = self._forward_lazy_token(
|
||||||
logits = self._extract_logits(model_output)
|
input_ids, cache, needs_logits, req_id, logit_edit_row, logprob_spec
|
||||||
lazy_token = mx.argmax(logits[:, -1, :], axis=-1)
|
)
|
||||||
return MlxPendingPrefill(
|
return MlxPendingPrefill(
|
||||||
lazy_token=lazy_token,
|
lazy_token=lazy_token,
|
||||||
cache=cache,
|
cache=cache,
|
||||||
@@ -758,9 +913,17 @@ class MlxModelRunner:
|
|||||||
full_token_ids=list(full_token_ids),
|
full_token_ids=list(full_token_ids),
|
||||||
req_pool_idx=req_pool_idx,
|
req_pool_idx=req_pool_idx,
|
||||||
synced_offset=0,
|
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)
|
new_token_count = len(new_token_ids)
|
||||||
track_len = self._select_auxiliary_state_track_len(
|
track_len = self._select_auxiliary_state_track_len(
|
||||||
@@ -770,8 +933,22 @@ class MlxModelRunner:
|
|||||||
req=req,
|
req=req,
|
||||||
)
|
)
|
||||||
|
|
||||||
if prefix_len > 0:
|
# Sliding-window layers keep no pool KV, so a prefix hit has nothing to
|
||||||
cache = self._cache_with_pool_backed_attention(prefix_slot_ids, prefix_len)
|
# 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
|
pool_backed_attention = True
|
||||||
restored_auxiliary_state = (
|
restored_auxiliary_state = (
|
||||||
not self._cache_layout.has_auxiliary_state
|
not self._cache_layout.has_auxiliary_state
|
||||||
@@ -787,9 +964,9 @@ class MlxModelRunner:
|
|||||||
# allocated attention KV below.
|
# allocated attention KV below.
|
||||||
cache = self._acquire_cache()
|
cache = self._acquire_cache()
|
||||||
input_ids = mx.array([full_token_ids or new_token_ids], dtype=mx.int32)
|
input_ids = mx.array([full_token_ids or new_token_ids], dtype=mx.int32)
|
||||||
model_output = self.model(input_ids, cache=cache)
|
lazy_token, lazy_logprobs = self._forward_lazy_token(
|
||||||
logits = self._extract_logits(model_output)
|
input_ids, cache, needs_logits, req_id, logit_edit_row, logprob_spec
|
||||||
lazy_token = mx.argmax(logits[:, -1, :], axis=-1)
|
)
|
||||||
if new_slot_ids:
|
if new_slot_ids:
|
||||||
self._sync_new_kv_to_pool(cache, prefix_len, new_slot_ids)
|
self._sync_new_kv_to_pool(cache, prefix_len, new_slot_ids)
|
||||||
return MlxPendingPrefill(
|
return MlxPendingPrefill(
|
||||||
@@ -799,15 +976,21 @@ class MlxModelRunner:
|
|||||||
full_token_ids=list(full_token_ids),
|
full_token_ids=list(full_token_ids),
|
||||||
req_pool_idx=req_pool_idx,
|
req_pool_idx=req_pool_idx,
|
||||||
synced_offset=prefix_len + len(new_slot_ids),
|
synced_offset=prefix_len + len(new_slot_ids),
|
||||||
|
lazy_logprobs=lazy_logprobs,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cache = self._acquire_cache()
|
cache = self._acquire_cache()
|
||||||
pool_backed_attention = False
|
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
|
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:
|
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)
|
input_ids = mx.array([new_token_ids[:track_new_count]], dtype=mx.int32)
|
||||||
|
# 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.model(input_ids, cache=cache)
|
||||||
self._store_tracked_auxiliary_state(req, cache, track_len)
|
self._store_tracked_auxiliary_state(req, cache, track_len)
|
||||||
if pool_backed_attention:
|
if pool_backed_attention:
|
||||||
@@ -815,23 +998,23 @@ class MlxModelRunner:
|
|||||||
pool_backed_attention = False
|
pool_backed_attention = False
|
||||||
extend_tokens = new_token_ids[track_new_count:]
|
extend_tokens = new_token_ids[track_new_count:]
|
||||||
else:
|
else:
|
||||||
extend_tokens = new_token_ids
|
extend_tokens = run_token_ids
|
||||||
else:
|
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:]
|
extend_tokens = full_token_ids[-1:]
|
||||||
for c in cache:
|
for c in cache:
|
||||||
c.offset = max(c.offset - 1, 0)
|
c.offset = max(c.offset - 1, 0)
|
||||||
|
|
||||||
input_ids = mx.array([extend_tokens], dtype=mx.int32)
|
input_ids = mx.array([extend_tokens], dtype=mx.int32)
|
||||||
model_output = self.model(input_ids, cache=cache)
|
lazy_token, lazy_logprobs = self._forward_lazy_token(
|
||||||
logits = self._extract_logits(model_output)
|
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:
|
if track_len is not None and track_len == prefix_len + new_token_count:
|
||||||
self._store_tracked_auxiliary_state(req, cache, track_len)
|
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.
|
# Convert pool-backed attention KV to contiguous attention KV for decode.
|
||||||
# This appends a lazy slice-assign onto the forward graph; the
|
# This appends a lazy slice-assign onto the forward graph; the
|
||||||
# arrays get materialised when the caller evaluates lazy_token.
|
# arrays get materialised when the caller evaluates lazy_token.
|
||||||
@@ -848,6 +1031,7 @@ class MlxModelRunner:
|
|||||||
full_token_ids=list(full_token_ids),
|
full_token_ids=list(full_token_ids),
|
||||||
req_pool_idx=req_pool_idx,
|
req_pool_idx=req_pool_idx,
|
||||||
synced_offset=prefix_len + len(new_slot_ids),
|
synced_offset=prefix_len + len(new_slot_ids),
|
||||||
|
lazy_logprobs=lazy_logprobs,
|
||||||
)
|
)
|
||||||
|
|
||||||
def prefill_finalize(self, pending: MlxPendingPrefill) -> int:
|
def prefill_finalize(self, pending: MlxPendingPrefill) -> int:
|
||||||
@@ -872,8 +1056,16 @@ class MlxModelRunner:
|
|||||||
req_id: str,
|
req_id: str,
|
||||||
new_token_ids: list[int],
|
new_token_ids: list[int],
|
||||||
new_slot_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:
|
) -> 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 (
|
assert (
|
||||||
req_id in self._req_caches
|
req_id in self._req_caches
|
||||||
), f"extend_start called for unknown request {req_id}"
|
), f"extend_start called for unknown request {req_id}"
|
||||||
@@ -881,9 +1073,9 @@ class MlxModelRunner:
|
|||||||
cache = self._req_caches[req_id]
|
cache = self._req_caches[req_id]
|
||||||
|
|
||||||
input_ids = mx.array([new_token_ids], dtype=mx.int32)
|
input_ids = mx.array([new_token_ids], dtype=mx.int32)
|
||||||
model_output = self.model(input_ids, cache=cache)
|
lazy_token, lazy_logprobs = self._forward_lazy_token(
|
||||||
logits = self._extract_logits(model_output)
|
input_ids, cache, needs_logits, req_id, logit_edit_row, logprob_spec
|
||||||
lazy_token = mx.argmax(logits[:, -1, :], axis=-1)
|
)
|
||||||
|
|
||||||
if not self.disable_radix_cache and new_slot_ids:
|
if not self.disable_radix_cache and new_slot_ids:
|
||||||
synced = self._req_synced_offset[req_id]
|
synced = self._req_synced_offset[req_id]
|
||||||
@@ -894,9 +1086,11 @@ class MlxModelRunner:
|
|||||||
|
|
||||||
return MlxPendingExtend(
|
return MlxPendingExtend(
|
||||||
lazy_token=lazy_token,
|
lazy_token=lazy_token,
|
||||||
|
cache=cache,
|
||||||
req_id=req_id,
|
req_id=req_id,
|
||||||
new_token_ids=list(new_token_ids),
|
new_token_ids=list(new_token_ids),
|
||||||
new_synced_offset=new_synced_offset,
|
new_synced_offset=new_synced_offset,
|
||||||
|
lazy_logprobs=lazy_logprobs,
|
||||||
)
|
)
|
||||||
|
|
||||||
def extend_finalize(self, pending: MlxPendingExtend) -> int:
|
def extend_finalize(self, pending: MlxPendingExtend) -> int:
|
||||||
@@ -916,6 +1110,201 @@ class MlxModelRunner:
|
|||||||
)
|
)
|
||||||
return next_token
|
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):
|
def _extract_model_components(self):
|
||||||
"""Cache embedding, norm, and lm_head for layer-by-layer hybrid forward."""
|
"""Cache embedding, norm, and lm_head for layer-by-layer hybrid forward."""
|
||||||
root = getattr(self.model, "language_model", self.model)
|
root = getattr(self.model, "language_model", self.model)
|
||||||
@@ -936,7 +1325,7 @@ class MlxModelRunner:
|
|||||||
batched_input: mx.array,
|
batched_input: mx.array,
|
||||||
req_ids: list[str],
|
req_ids: list[str],
|
||||||
) -> mx.array:
|
) -> 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
|
Attention layers run with batched hidden states via
|
||||||
``BatchedDecodeContext``. Auxiliary layers run batched when their
|
``BatchedDecodeContext``. Auxiliary layers run batched when their
|
||||||
@@ -971,7 +1360,7 @@ class MlxModelRunner:
|
|||||||
|
|
||||||
hidden_states = self._model_norm(hidden_states)
|
hidden_states = self._model_norm(hidden_states)
|
||||||
logits = self._extract_logits(self._model_lm_head(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(
|
def _decode_auxiliary_layer(
|
||||||
self,
|
self,
|
||||||
@@ -1107,15 +1496,15 @@ class MlxModelRunner:
|
|||||||
caches: list[list[Any]],
|
caches: list[list[Any]],
|
||||||
input_ids_by_request: list[mx.array],
|
input_ids_by_request: list[mx.array],
|
||||||
) -> mx.array:
|
) -> mx.array:
|
||||||
lazy_token_list = []
|
lazy_logits_list = []
|
||||||
for input_ids, cache in zip(input_ids_by_request, caches):
|
for input_ids, cache in zip(input_ids_by_request, caches):
|
||||||
model_output = self.model(input_ids, cache=cache)
|
model_output = self.model(input_ids, cache=cache)
|
||||||
logits = self._extract_logits(model_output)
|
logits = self._extract_logits(model_output)
|
||||||
lazy_token_list.append(mx.argmax(logits[:, -1, :], axis=-1))
|
lazy_logits_list.append(logits[:, -1, :])
|
||||||
return (
|
return (
|
||||||
lazy_token_list[0]
|
lazy_logits_list[0]
|
||||||
if len(lazy_token_list) == 1
|
if len(lazy_logits_list) == 1
|
||||||
else mx.concatenate(lazy_token_list, axis=0)
|
else mx.concatenate(lazy_logits_list, axis=0)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _decode_with_batched_attention(
|
def _decode_with_batched_attention(
|
||||||
@@ -1135,7 +1524,7 @@ class MlxModelRunner:
|
|||||||
]
|
]
|
||||||
model_output = self.model(batched_input, cache=shim_cache)
|
model_output = self.model(batched_input, cache=shim_cache)
|
||||||
logits = self._extract_logits(model_output)
|
logits = self._extract_logits(model_output)
|
||||||
return mx.argmax(logits[:, -1, :], axis=-1)
|
return logits[:, -1, :]
|
||||||
finally:
|
finally:
|
||||||
clear_context()
|
clear_context()
|
||||||
|
|
||||||
@@ -1156,9 +1545,16 @@ class MlxModelRunner:
|
|||||||
attention_pool_index_by_layer=(
|
attention_pool_index_by_layer=(
|
||||||
self._cache_layout.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.
|
"""Queue a decode forward pass without evaluating.
|
||||||
|
|
||||||
The caller is responsible for calling ``mx.async_eval`` on the
|
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]
|
batched_input = mx.array(last_tokens, dtype=mx.int32)[:, None]
|
||||||
|
|
||||||
if self._cache_layout.has_auxiliary_state:
|
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)
|
caches, batched_input, list(req_ids)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
lazy_tokens = self._decode_with_batched_attention(
|
last_logits = self._decode_with_batched_attention(
|
||||||
caches, batched_input, list(req_ids)
|
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(
|
return MlxPendingDecode(
|
||||||
lazy_tokens=lazy_tokens,
|
lazy_tokens=lazy_tokens,
|
||||||
req_ids=list(req_ids),
|
req_ids=list(req_ids),
|
||||||
caches=caches,
|
caches=caches,
|
||||||
|
lazy_logprobs=lazy_logprobs,
|
||||||
|
logprob_spec=logprob_spec,
|
||||||
|
edit_rows=edit_rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
def decode_batch_start_chained(
|
def decode_batch_start_chained(
|
||||||
@@ -1215,18 +1624,24 @@ class MlxModelRunner:
|
|||||||
# be written at in step N+1 (and equivalently the RoPE offset).
|
# be written at in step N+1 (and equivalently the RoPE offset).
|
||||||
batched_input = prev.lazy_tokens[:, None]
|
batched_input = prev.lazy_tokens[:, None]
|
||||||
if self._cache_layout.has_auxiliary_state:
|
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
|
caches, batched_input, prev.req_ids
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
lazy_tokens = self._decode_with_batched_attention(
|
last_logits = self._decode_with_batched_attention(
|
||||||
caches, batched_input, prev.req_ids
|
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(
|
return MlxPendingDecode(
|
||||||
lazy_tokens=lazy_tokens,
|
lazy_tokens=lazy_tokens,
|
||||||
req_ids=prev.req_ids,
|
req_ids=prev.req_ids,
|
||||||
caches=caches,
|
caches=caches,
|
||||||
|
lazy_logprobs=lazy_logprobs,
|
||||||
|
logprob_spec=prev.logprob_spec,
|
||||||
|
edit_rows=prev.edit_rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
def decode_batch_finalize(
|
def decode_batch_finalize(
|
||||||
@@ -1266,6 +1681,7 @@ class MlxModelRunner:
|
|||||||
self._sync_decode_kv_to_pool(req_id)
|
self._sync_decode_kv_to_pool(req_id)
|
||||||
|
|
||||||
self._req_token_ids.pop(req_id, None)
|
self._req_token_ids.pop(req_id, None)
|
||||||
|
self._req_sampling.pop(req_id, None)
|
||||||
cache = self._req_caches.pop(req_id, None)
|
cache = self._req_caches.pop(req_id, None)
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
self._release_cache(cache)
|
self._release_cache(cache)
|
||||||
@@ -1275,6 +1691,7 @@ class MlxModelRunner:
|
|||||||
def clear(self):
|
def clear(self):
|
||||||
"""Clear all request states."""
|
"""Clear all request states."""
|
||||||
self._req_token_ids.clear()
|
self._req_token_ids.clear()
|
||||||
|
self._req_sampling.clear()
|
||||||
for cache in self._req_caches.values():
|
for cache in self._req_caches.values():
|
||||||
self._release_cache(cache)
|
self._release_cache(cache)
|
||||||
self._req_caches.clear()
|
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 logging
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
import mlx.core as mx
|
import mlx.core as mx
|
||||||
|
|
||||||
from sglang.srt.environ import envs
|
from sglang.srt.environ import envs
|
||||||
from sglang.srt.managers.overlap_utils import resolve_forward_inputs
|
from sglang.srt.managers.overlap_utils import resolve_forward_inputs
|
||||||
|
from sglang.srt.runtime_context import get_device
|
||||||
from sglang.srt.utils import DynamicGradMode
|
from sglang.srt.utils import DynamicGradMode
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sglang.srt.hardware_backend.mlx.model_runner import (
|
from sglang.srt.hardware_backend.mlx.tp_worker import MlxLaunch
|
||||||
MlxPendingDecode,
|
|
||||||
MlxPendingExtend,
|
|
||||||
MlxPendingPrefill,
|
|
||||||
)
|
|
||||||
from sglang.srt.managers.schedule_batch import Req, ScheduleBatch
|
from sglang.srt.managers.schedule_batch import Req, ScheduleBatch
|
||||||
from sglang.srt.managers.scheduler import Scheduler
|
from sglang.srt.managers.scheduler import Scheduler
|
||||||
|
|
||||||
@@ -43,23 +40,10 @@ class MlxPendingJob:
|
|||||||
"""Unfinished MLX work and graphs queued on the GPU.
|
"""Unfinished MLX work and graphs queued on the GPU.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
lazy_tokens: Lazily evaluated token IDs produced by the forward
|
launch: The :class:`MlxLaunch` this job is waiting on — the lazy
|
||||||
pass. Unevaluated; calling ``.tolist()`` / ``.item()`` /
|
token handle plus the prefill / extend / decode pendings the
|
||||||
``mx.eval`` on it will block until the Metal kernel finishes.
|
forward produced, and the mode that drives finalise dispatch
|
||||||
``None`` for idle batches.
|
and whether chaining is safe.
|
||||||
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.
|
|
||||||
batch_copy: Snapshot of the :class:`ScheduleBatch` at launch
|
batch_copy: Snapshot of the :class:`ScheduleBatch` at launch
|
||||||
time. Decoupled from the live batch so
|
time. Decoupled from the live batch so
|
||||||
``process_batch_result`` can update request state without
|
``process_batch_result`` can update request state without
|
||||||
@@ -73,19 +57,41 @@ class MlxPendingJob:
|
|||||||
mutable batch object.
|
mutable batch object.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
lazy_tokens: Optional[mx.array]
|
launch: MlxLaunch
|
||||||
prefills: list[MlxPendingPrefill]
|
|
||||||
extends: list[MlxPendingExtend]
|
|
||||||
decode: Optional[MlxPendingDecode]
|
|
||||||
mode: str
|
|
||||||
batch_copy: ScheduleBatch
|
batch_copy: ScheduleBatch
|
||||||
schedule_batch: ScheduleBatch
|
schedule_batch: ScheduleBatch
|
||||||
reqs: List[Req]
|
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:
|
class SchedulerMlxOverlapMixin:
|
||||||
"""Mixin that adds MLX overlap scheduling to :class:`Scheduler`."""
|
"""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):
|
def _prepare_mlx_launch(self: Scheduler, batch: ScheduleBatch):
|
||||||
"""Stamp scheduler bookkeeping before an MLX forward is launched."""
|
"""Stamp scheduler bookkeeping before an MLX forward is launched."""
|
||||||
# Match run_batch's launch boundary. In particular, the profiler
|
# Match run_batch's launch boundary. In particular, the profiler
|
||||||
@@ -98,12 +104,10 @@ class SchedulerMlxOverlapMixin:
|
|||||||
self.profiler_manager._profile_batch_predicate(batch)
|
self.profiler_manager._profile_batch_predicate(batch)
|
||||||
|
|
||||||
def _finalize_mlx_pending_job(self: Scheduler, pending: MlxPendingJob):
|
def _finalize_mlx_pending_job(self: Scheduler, pending: MlxPendingJob):
|
||||||
result = self.tp_worker.finalize_mlx_result(
|
result = self.tp_worker.finalize_mlx_result(pending.launch, pending.reqs)
|
||||||
pending.prefills,
|
result.extend_input_len_per_req = pending.extend_input_len_per_req
|
||||||
pending.extends,
|
result.extend_logprob_start_len_per_req = (
|
||||||
pending.decode,
|
pending.extend_logprob_start_len_per_req
|
||||||
pending.mode,
|
|
||||||
pending.reqs,
|
|
||||||
)
|
)
|
||||||
if result.next_token_ids is not None:
|
if result.next_token_ids is not None:
|
||||||
pending.batch_copy.input_ids = result.next_token_ids
|
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
|
# loop must do it too, otherwise async_forward_batch_generation_mlx
|
||||||
# dereferences a None input_ids.
|
# dereferences a None input_ids.
|
||||||
resolve_forward_inputs(batch, self.future_map)
|
resolve_forward_inputs(batch, self.future_map)
|
||||||
# run_batch stamps launch_ts on every scheduler-built forward; the
|
launch = self.tp_worker.async_forward_batch_generation_mlx(batch)
|
||||||
# MLX overlap loop bypasses run_batch, and process_batch_result ->
|
extend_input_len_per_req = None
|
||||||
# _record_step_counters subtracts launch_ts unconditionally for
|
extend_logprob_start_len_per_req = None
|
||||||
# prefill/decode batches. ScheduleBatch.copy() below carries the
|
if batch.return_logprob:
|
||||||
# stamp to process_batch_result.
|
# Mirror Scheduler.run_batch's launch-time copy.
|
||||||
lazy_tokens, prefills, extends, decode, mode = (
|
extend_input_len_per_req = [
|
||||||
self.tp_worker.async_forward_batch_generation_mlx(batch)
|
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(
|
return MlxPendingJob(
|
||||||
lazy_tokens=lazy_tokens,
|
launch=launch,
|
||||||
prefills=prefills,
|
|
||||||
extends=extends,
|
|
||||||
decode=decode,
|
|
||||||
mode=mode,
|
|
||||||
batch_copy=batch.copy(),
|
batch_copy=batch.copy(),
|
||||||
schedule_batch=batch,
|
schedule_batch=batch,
|
||||||
reqs=list(batch.reqs),
|
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:
|
def _launch_chained(prev: MlxPendingJob) -> MlxPendingJob:
|
||||||
assert prev.decode is not None
|
assert prev.launch.decode is not None
|
||||||
# Composition is identical to prev: reuse a fresh batch copy
|
# Composition is identical to prev: every scheduler-side field
|
||||||
# of the same underlying ScheduleBatch so process_batch_result
|
# 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.
|
# updates the same req objects with the new token.
|
||||||
batch_copy = prev.batch_copy.copy()
|
batch_copy = prev.batch_copy.copy()
|
||||||
self._prepare_mlx_launch(batch_copy)
|
self._prepare_mlx_launch(batch_copy)
|
||||||
@@ -193,18 +199,10 @@ class SchedulerMlxOverlapMixin:
|
|||||||
# chain breaks, prepare_for_decode() may run SWA maintenance
|
# chain breaks, prepare_for_decode() may run SWA maintenance
|
||||||
# before the next fresh launch gets a chance to re-stamp it.
|
# before the next fresh launch gets a chance to re-stamp it.
|
||||||
prev.schedule_batch.forward_iter = batch_copy.forward_iter
|
prev.schedule_batch.forward_iter = batch_copy.forward_iter
|
||||||
lazy_tokens, prefills, extends, decode, mode = (
|
return replace(
|
||||||
self.tp_worker.async_chained_decode_mlx(prev.decode)
|
prev,
|
||||||
)
|
launch=self.tp_worker.async_chained_decode_mlx(prev.launch.decode),
|
||||||
return MlxPendingJob(
|
|
||||||
lazy_tokens=lazy_tokens,
|
|
||||||
prefills=prefills,
|
|
||||||
extends=extends,
|
|
||||||
decode=decode,
|
|
||||||
mode=mode,
|
|
||||||
batch_copy=batch_copy,
|
batch_copy=batch_copy,
|
||||||
schedule_batch=prev.schedule_batch,
|
|
||||||
reqs=prev.reqs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -224,8 +222,9 @@ class SchedulerMlxOverlapMixin:
|
|||||||
# build pending_next on top of it NOW — before we block on curr.
|
# build pending_next on top of it NOW — before we block on curr.
|
||||||
can_chain = (
|
can_chain = (
|
||||||
pending_curr is not None
|
pending_curr is not None
|
||||||
and pending_curr.mode == "decode"
|
and pending_curr.launch.mode == "decode"
|
||||||
and pending_curr.decode is not None
|
and pending_curr.launch.decode is not None
|
||||||
|
and pending_curr.chain_safe
|
||||||
and not self.waiting_queue
|
and not self.waiting_queue
|
||||||
)
|
)
|
||||||
if can_chain and pending_next is None:
|
if can_chain and pending_next is None:
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ normal ``GenerationBatchResult``.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Union
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import mlx.core as mx
|
import mlx.core as mx
|
||||||
import torch
|
import torch
|
||||||
@@ -23,6 +24,11 @@ from sglang.srt.hardware_backend.mlx.model_runner import (
|
|||||||
MlxPendingExtend,
|
MlxPendingExtend,
|
||||||
MlxPendingPrefill,
|
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.schedule_batch import ScheduleBatch
|
||||||
from sglang.srt.managers.tp_worker import TpModelWorker
|
from sglang.srt.managers.tp_worker import TpModelWorker
|
||||||
from sglang.srt.managers.utils import GenerationBatchResult
|
from sglang.srt.managers.utils import GenerationBatchResult
|
||||||
@@ -31,11 +37,45 @@ from sglang.srt.model_executor.forward_batch_info import (
|
|||||||
ForwardBatch,
|
ForwardBatch,
|
||||||
PPProxyTensors,
|
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__)
|
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):
|
class MlxTpModelWorker(TpModelWorker):
|
||||||
"""A tensor parallel model worker that routes inference through MLX.
|
"""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,
|
disable_radix_cache=get_memory().disable_radix_cache,
|
||||||
mem_fraction_static=get_schedule().mem_fraction_static,
|
mem_fraction_static=get_schedule().mem_fraction_static,
|
||||||
quantization=get_model().quantization,
|
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:
|
if get_schedule().max_total_tokens is not None:
|
||||||
init_kwargs["pool_size"] = get_schedule().max_total_tokens
|
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:
|
def _route_extend_request(self, rid: str, decoding_rids: set[str]) -> str:
|
||||||
"""Classify a request within an extend / mixed batch.
|
"""Classify a request within an extend / mixed batch.
|
||||||
|
|
||||||
Shared by the sync (:meth:`_forward_batch_generation_mlx`) and async
|
Called once per request from :meth:`_async_extend_batch`, which both
|
||||||
(:meth:`_async_extend_batch`) paths so both route identically.
|
the overlap loop and the synchronous entry point launch through.
|
||||||
|
|
||||||
Returns one of:
|
Returns one of:
|
||||||
|
|
||||||
@@ -157,134 +202,220 @@ class MlxTpModelWorker(TpModelWorker):
|
|||||||
return "decode"
|
return "decode"
|
||||||
return "continuation"
|
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(
|
def _forward_batch_generation_mlx(
|
||||||
self, batch: ScheduleBatch
|
self, batch: ScheduleBatch
|
||||||
) -> GenerationBatchResult:
|
) -> GenerationBatchResult:
|
||||||
"""Run forward pass through the MLX model runner (greedy only)."""
|
"""Run one forward pass through the MLX model runner, synchronously.
|
||||||
from sglang.srt.layers.logits_processor import LogitsProcessorOutput
|
|
||||||
|
|
||||||
forward_mode = batch.forward_mode
|
Reachable only under ``--disable-overlap-schedule``: the default MLX
|
||||||
reqs = batch.reqs
|
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():
|
@staticmethod
|
||||||
return GenerationBatchResult(
|
def _stacked_edit_rows(
|
||||||
logits_output=LogitsProcessorOutput(next_token_logits=None),
|
edit_rows: dict[str, mx.array] | None, req_ids: list[str]
|
||||||
can_run_cuda_graph=False,
|
) -> 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})
|
def async_forward_batch_generation_mlx(self, batch: ScheduleBatch) -> MlxLaunch:
|
||||||
|
|
||||||
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,
|
|
||||||
]:
|
|
||||||
"""Start an async (lazy) forward pass through the MLX model runner.
|
"""Start an async (lazy) forward pass through the MLX model runner.
|
||||||
|
|
||||||
Returns ``(lazy_result, prefills, extends, decode, mode)``:
|
See :class:`MlxLaunch` for the returned fields. The caller must
|
||||||
|
make sure the launch's pendings are fed into a subsequent
|
||||||
* ``lazy_result`` — an ``mx.array`` that, when evaluated, forces
|
``mx.async_eval`` or ``.item()`` / ``.tolist()`` call —
|
||||||
materialisation of the whole batch's outputs. ``None`` for
|
:meth:`finalize_mlx_result` does that.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
self._ensure_mlx_pool_initialized()
|
self._ensure_mlx_pool_initialized()
|
||||||
|
|
||||||
@@ -292,15 +423,33 @@ class MlxTpModelWorker(TpModelWorker):
|
|||||||
reqs = batch.reqs
|
reqs = batch.reqs
|
||||||
|
|
||||||
if forward_mode.is_idle():
|
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})
|
self._cleanup_stale_rids(forward_mode, {req.rid for req in reqs})
|
||||||
|
|
||||||
if forward_mode.is_decode():
|
if forward_mode.is_decode():
|
||||||
req_ids = [req.rid for req in reqs]
|
req_ids = [req.rid for req in reqs]
|
||||||
pending_decode = self._mlx_runner.decode_batch_start(req_ids)
|
pending_decode = self._mlx_runner.decode_batch_start(
|
||||||
mx.async_eval(pending_decode.lazy_tokens)
|
req_ids,
|
||||||
return pending_decode.lazy_tokens, [], [], pending_decode, "decode"
|
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():
|
if forward_mode.is_extend():
|
||||||
# TODO (changminbark): Implement per-batch flushing using prefix_slot_ids
|
# 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}"
|
f"MLX async runner does not support forward mode: {forward_mode}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _async_extend_batch(self, batch: ScheduleBatch) -> tuple[
|
def _async_extend_batch(self, batch: ScheduleBatch) -> MlxLaunch:
|
||||||
Union[mx.array, None],
|
|
||||||
list[MlxPendingPrefill],
|
|
||||||
list[MlxPendingExtend],
|
|
||||||
Optional[MlxPendingDecode],
|
|
||||||
str,
|
|
||||||
]:
|
|
||||||
"""Launch each request in an EXTEND batch lazily and kick GPU work."""
|
"""Launch each request in an EXTEND batch lazily and kick GPU work."""
|
||||||
reqs = batch.reqs
|
reqs = batch.reqs
|
||||||
input_ids_cpu = batch.input_ids.cpu().tolist()
|
input_ids_cpu = batch.input_ids.cpu().tolist()
|
||||||
out_cache_loc_cpu = batch.out_cache_loc.cpu().tolist()
|
out_cache_loc_cpu = batch.out_cache_loc.cpu().tolist()
|
||||||
extend_seq_lens = batch.extend_lens
|
extend_seq_lens = batch.extend_lens
|
||||||
|
edit_rows = self._build_logit_edit_rows(batch)
|
||||||
|
logprob_rows = self._logprob_rows(batch)
|
||||||
|
|
||||||
offset = 0
|
offset = 0
|
||||||
slot_offset = 0
|
slot_offset = 0
|
||||||
@@ -349,6 +494,9 @@ class MlxTpModelWorker(TpModelWorker):
|
|||||||
req_id=req.rid,
|
req_id=req.rid,
|
||||||
new_token_ids=req_token_ids,
|
new_token_ids=req_token_ids,
|
||||||
new_slot_ids=req_new_slots,
|
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":
|
elif route == "decode":
|
||||||
@@ -365,13 +513,18 @@ class MlxTpModelWorker(TpModelWorker):
|
|||||||
new_slot_ids=req_new_slots,
|
new_slot_ids=req_new_slots,
|
||||||
req_pool_idx=req.req_pool_idx,
|
req_pool_idx=req.req_pool_idx,
|
||||||
req=req,
|
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
|
pending_mixed_decode: Optional[MlxPendingDecode] = None
|
||||||
if mixed_decode_rids:
|
if mixed_decode_rids:
|
||||||
pending_mixed_decode = self._mlx_runner.decode_batch_start(
|
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
|
# Stack lazy tokens so the caller has a single handle to evaluate
|
||||||
@@ -389,51 +542,28 @@ class MlxTpModelWorker(TpModelWorker):
|
|||||||
else:
|
else:
|
||||||
lazy_stacked = None
|
lazy_stacked = None
|
||||||
|
|
||||||
for p in pending_prefills:
|
for pending in (*pending_prefills, *pending_extends):
|
||||||
async_args.extend(self._cache_state(p.cache))
|
async_args.extend(self._mlx_runner.cache_state_arrays([pending.cache]))
|
||||||
for e in pending_extends:
|
async_args.extend(lazy_logprob_arrays(pending.lazy_logprobs))
|
||||||
async_args.extend(self._cache_state(self._mlx_runner._req_caches[e.req_id]))
|
|
||||||
if pending_mixed_decode is not None:
|
if pending_mixed_decode is not None:
|
||||||
async_args.append(pending_mixed_decode.lazy_tokens)
|
async_args.append(pending_mixed_decode.lazy_tokens)
|
||||||
for c_list in pending_mixed_decode.caches:
|
async_args.extend(lazy_logprob_arrays(pending_mixed_decode.lazy_logprobs))
|
||||||
async_args.extend(self._cache_state(c_list))
|
async_args.extend(
|
||||||
|
self._mlx_runner.cache_state_arrays(pending_mixed_decode.caches)
|
||||||
|
)
|
||||||
|
|
||||||
if async_args:
|
if async_args:
|
||||||
mx.async_eval(*async_args)
|
mx.async_eval(*async_args)
|
||||||
|
|
||||||
return (
|
return MlxLaunch(
|
||||||
lazy_stacked,
|
lazy_tokens=lazy_stacked,
|
||||||
pending_prefills,
|
prefills=pending_prefills,
|
||||||
pending_extends,
|
extends=pending_extends,
|
||||||
pending_mixed_decode,
|
decode=pending_mixed_decode,
|
||||||
"extend",
|
mode="extend",
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
def async_chained_decode_mlx(self, prev_pending: MlxPendingDecode) -> MlxLaunch:
|
||||||
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]:
|
|
||||||
"""Launch a decode step that chains off a still-lazy previous decode.
|
"""Launch a decode step that chains off a still-lazy previous decode.
|
||||||
|
|
||||||
This is the "no idle gap" pipelining primitive: build the next
|
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
|
* ``prev_pending`` should be finalised BEFORE the returned
|
||||||
pending, so per-request token lists are appended in order.
|
pending, so per-request token lists are appended in order.
|
||||||
|
|
||||||
Returns a 5-tuple matching
|
Returns an :class:`MlxLaunch` in ``"decode"`` mode; its prefill
|
||||||
:meth:`async_forward_batch_generation_mlx` for the decode case:
|
and extend lists are always empty for a chained decode.
|
||||||
``(lazy_tokens, [], [], pending_decode, "decode")``. The empty
|
|
||||||
prefill/extend lists are always absent for chained decodes.
|
|
||||||
"""
|
"""
|
||||||
pending = self._mlx_runner.decode_batch_start_chained(prev_pending)
|
pending = self._mlx_runner.decode_batch_start_chained(prev_pending)
|
||||||
mx.async_eval(pending.lazy_tokens)
|
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(
|
def finalize_mlx_result(
|
||||||
self,
|
self, launch: MlxLaunch, reqs: list
|
||||||
prefills: list[MlxPendingPrefill],
|
|
||||||
extends: list[MlxPendingExtend],
|
|
||||||
decode: Optional[MlxPendingDecode],
|
|
||||||
mode: str,
|
|
||||||
reqs: list,
|
|
||||||
) -> GenerationBatchResult:
|
) -> GenerationBatchResult:
|
||||||
"""Materialise a lazy MLX result into a :class:`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
|
from sglang.srt.layers.logits_processor import LogitsProcessorOutput
|
||||||
|
|
||||||
if mode == "idle":
|
decode = launch.decode
|
||||||
|
if launch.mode == "idle":
|
||||||
return GenerationBatchResult(
|
return GenerationBatchResult(
|
||||||
logits_output=LogitsProcessorOutput(next_token_logits=None),
|
logits_output=LogitsProcessorOutput(next_token_logits=None),
|
||||||
can_run_cuda_graph=False,
|
can_run_cuda_graph=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
if mode == "decode":
|
step_logprob_rows: dict[str, tuple] = {}
|
||||||
|
|
||||||
|
if launch.mode == "decode":
|
||||||
assert decode is not None
|
assert decode is not None
|
||||||
next_tokens_list = self._mlx_runner.decode_batch_finalize(decode)
|
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] = {}
|
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(
|
prefill_map[pending_p.req_id] = self._mlx_runner.prefill_finalize(
|
||||||
pending_p
|
pending_p
|
||||||
)
|
)
|
||||||
|
self._collect_step_logprobs(
|
||||||
|
step_logprob_rows, pending_p.lazy_logprobs, [pending_p.req_id]
|
||||||
|
)
|
||||||
|
|
||||||
extend_map: dict[str, int] = {}
|
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(
|
extend_map[pending_e.req_id] = self._mlx_runner.extend_finalize(
|
||||||
pending_e
|
pending_e
|
||||||
)
|
)
|
||||||
|
self._collect_step_logprobs(
|
||||||
|
step_logprob_rows, pending_e.lazy_logprobs, [pending_e.req_id]
|
||||||
|
)
|
||||||
|
|
||||||
decode_map: dict[str, int] = {}
|
decode_map: dict[str, int] = {}
|
||||||
if decode is not None:
|
if decode is not None:
|
||||||
@@ -509,6 +650,9 @@ class MlxTpModelWorker(TpModelWorker):
|
|||||||
decode_map = {
|
decode_map = {
|
||||||
rid: tok for rid, tok in zip(decode.req_ids, mixed_tokens)
|
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 = []
|
next_tokens_list = []
|
||||||
for req in reqs:
|
for req in reqs:
|
||||||
@@ -520,11 +664,16 @@ class MlxTpModelWorker(TpModelWorker):
|
|||||||
next_tokens_list.append(prefill_map[req.rid])
|
next_tokens_list.append(prefill_map[req.rid])
|
||||||
|
|
||||||
else:
|
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")
|
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(
|
return GenerationBatchResult(
|
||||||
logits_output=LogitsProcessorOutput(next_token_logits=None),
|
logits_output=logits_output,
|
||||||
next_token_ids=next_token_ids,
|
next_token_ids=next_token_ids,
|
||||||
can_run_cuda_graph=False,
|
can_run_cuda_graph=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.parser.reasoning_parser import ReasoningParser
|
||||||
from sglang.srt.platforms import current_platform
|
from sglang.srt.platforms import current_platform
|
||||||
from sglang.srt.plugins import load_plugins
|
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_batch_info import SamplingBatchInfo
|
||||||
from sglang.srt.sampling.sampling_params import TOP_K_ALL
|
from sglang.srt.sampling.sampling_params import TOP_K_ALL
|
||||||
from sglang.srt.server_args import PortArgs, ServerArgs
|
from sglang.srt.server_args import PortArgs, ServerArgs
|
||||||
@@ -2605,6 +2610,24 @@ class Scheduler(
|
|||||||
self._add_request_to_queue(req)
|
self._add_request_to_queue(req)
|
||||||
return
|
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:
|
if recv_req.return_routed_experts:
|
||||||
error_msg = None
|
error_msg = None
|
||||||
if recv_req.routed_experts_start_len < 0:
|
if recv_req.routed_experts_start_len < 0:
|
||||||
|
|||||||
@@ -327,7 +327,13 @@ class SchedulerLogprobResultProcessor:
|
|||||||
else:
|
else:
|
||||||
self._initialize_empty_logprob_containers(req)
|
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(
|
req.logprob.output_top_logprobs_val.append(
|
||||||
output.next_token_top_logprobs_val[i]
|
output.next_token_top_logprobs_val[i]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1198,6 +1198,25 @@ class ServerArgs:
|
|||||||
NS("device"),
|
NS("device"),
|
||||||
] = 1
|
] = 1
|
||||||
random_seed: A[Optional[int], "The random seed.", NS("device")] = None
|
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[
|
watchdog_timeout: A[
|
||||||
float,
|
float,
|
||||||
"Set watchdog timeout in seconds. If a forward batch takes longer than this, the server will crash to prevent hanging.",
|
"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"]:
|
elif model_arch in ["GptOssForCausalLM"]:
|
||||||
# Attention backend selection + XPU dtype validation moved to the
|
# Attention backend selection + XPU dtype validation moved to the
|
||||||
# override registry (arg_groups/overrides.py: _gpt_oss_overrides).
|
# override registry (arg_groups/overrides.py: _gpt_oss_overrides).
|
||||||
# None of these backends exist on MPS; attention_backend is still
|
# None of these backends exist on MPS, and under MLX attention
|
||||||
# unset there at this point (the torch_native default fills later).
|
# runs inside the MLX runner, so attention_backend is still unset
|
||||||
if not is_mps():
|
# 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 = [
|
supported_backends = [
|
||||||
"triton",
|
"triton",
|
||||||
"trtllm_mha",
|
"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:
|
sliding-window path end to end. Two guards:
|
||||||
|
|
||||||
1. ``TestGptOssMlxCorrectness`` — black-box serving smoke against a running
|
1. ``TestGptOssMlxCorrectness`` — black-box serving smoke against a running
|
||||||
server, including a >128-token prompt so the sliding window actually
|
server with the radix cache enabled (the default KV path), including a
|
||||||
engages.
|
>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
|
2. ``TestGptOssMlxReferenceCorrectness`` — token-for-token equivalence of
|
||||||
``MlxModelRunner`` greedy decoding against raw, unpatched mlx_lm greedy
|
``MlxModelRunner`` greedy decoding against raw, unpatched mlx_lm greedy
|
||||||
generation. SGLang keeps full KV and applies banded masks /
|
generation. SGLang keeps full KV and applies banded masks /
|
||||||
@@ -117,10 +118,14 @@ class TestGptOssMlxCorrectness(CustomTestCase):
|
|||||||
cls.base_url,
|
cls.base_url,
|
||||||
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
||||||
other_args=[
|
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",
|
"--trust-remote-code",
|
||||||
"--tp-size",
|
"--tp-size",
|
||||||
"1",
|
"1",
|
||||||
"--disable-radix-cache",
|
|
||||||
"--disable-cuda-graph",
|
"--disable-cuda-graph",
|
||||||
"--mem-fraction-static",
|
"--mem-fraction-static",
|
||||||
MEM_FRACTION_STATIC,
|
MEM_FRACTION_STATIC,
|
||||||
@@ -189,6 +194,24 @@ class TestGptOssMlxCorrectness(CustomTestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("BLUEBERRY", text.upper())
|
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)
|
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||||
class TestGptOssMlxReferenceCorrectness(CustomTestCase):
|
class TestGptOssMlxReferenceCorrectness(CustomTestCase):
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ if _HAS_MLX:
|
|||||||
MlxPendingJob,
|
MlxPendingJob,
|
||||||
SchedulerMlxOverlapMixin,
|
SchedulerMlxOverlapMixin,
|
||||||
)
|
)
|
||||||
|
from sglang.srt.hardware_backend.mlx.tp_worker import MlxLaunch
|
||||||
from sglang.srt.managers.scheduler_components import (
|
from sglang.srt.managers.scheduler_components import (
|
||||||
batch_result_processor as batch_result_processor_module,
|
batch_result_processor as batch_result_processor_module,
|
||||||
)
|
)
|
||||||
@@ -309,7 +310,7 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
|||||||
new_slot_ids=[4],
|
new_slot_ids=[4],
|
||||||
req_pool_idx=0,
|
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())
|
mx.eval(*runner._attention_kv_pool.all_buffers())
|
||||||
runner.prefill_finalize(pending)
|
runner.prefill_finalize(pending)
|
||||||
|
|
||||||
@@ -350,7 +351,8 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
|||||||
calls.append(
|
calls.append(
|
||||||
(len(caches), batched_input.tolist(), list(helper_req_ids))
|
(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):
|
def fail_native(*args, **kwargs):
|
||||||
raise AssertionError("dense decode should use batched attention")
|
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):
|
def fake_batched(caches, batched_input, helper_req_ids):
|
||||||
calls.append((len(caches), batched_input.tolist(), list(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):
|
def fail_native(*args, **kwargs):
|
||||||
raise AssertionError("dense chained decode should use batched attention")
|
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,
|
cache,
|
||||||
mx.array([[7]], dtype=mx.int32),
|
mx.array([[7]], dtype=mx.int32),
|
||||||
["r0"],
|
["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(lazy_tokens.tolist(), [0])
|
||||||
self.assertEqual(cache[0][0].offset, 1)
|
self.assertEqual(cache[0][0].offset, 1)
|
||||||
@@ -558,6 +562,9 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
|||||||
req_pool_idx={"r0": 0, "r1": 1},
|
req_pool_idx={"r0": 0, "r1": 1},
|
||||||
req_to_token_pool=req_to_token_pool,
|
req_to_token_pool=req_to_token_pool,
|
||||||
attention_layer_indices=[0],
|
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])
|
self.assertEqual(ctx.seq_lens, [1, 2])
|
||||||
@@ -578,7 +585,8 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
|||||||
|
|
||||||
def fake_hybrid(caches, batched_input, helper_req_ids):
|
def fake_hybrid(caches, batched_input, helper_req_ids):
|
||||||
calls.append((len(caches), batched_input.tolist(), list(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):
|
def fail_batched(*args, **kwargs):
|
||||||
raise AssertionError(
|
raise AssertionError(
|
||||||
@@ -713,7 +721,7 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
|||||||
new_slot_ids=[4],
|
new_slot_ids=[4],
|
||||||
req_pool_idx=req.req_pool_idx,
|
req_pool_idx=req.req_pool_idx,
|
||||||
)
|
)
|
||||||
MlxModelRunner._eval_with_cache(pending.lazy_token, pending.cache)
|
runner.eval_pending(pending)
|
||||||
runner.prefill_finalize(pending)
|
runner.prefill_finalize(pending)
|
||||||
|
|
||||||
self.assertEqual(runner.model.seen_inputs, [[[13]]])
|
self.assertEqual(runner.model.seen_inputs, [[[13]]])
|
||||||
@@ -770,7 +778,7 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
|||||||
req_pool_idx=req.req_pool_idx,
|
req_pool_idx=req.req_pool_idx,
|
||||||
req=req,
|
req=req,
|
||||||
)
|
)
|
||||||
MlxModelRunner._eval_with_cache(pending.lazy_token, pending.cache)
|
runner.eval_pending(pending)
|
||||||
runner.prefill_finalize(pending)
|
runner.prefill_finalize(pending)
|
||||||
tracked = [FakeNativeCache(), None]
|
tracked = [FakeNativeCache(), None]
|
||||||
runner._req_to_token_pool.auxiliary_state_pool.restore_cache(
|
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_pool_idx=req.req_pool_idx,
|
||||||
req=req,
|
req=req,
|
||||||
)
|
)
|
||||||
MlxModelRunner._eval_with_cache(pending.lazy_token, pending.cache)
|
runner.eval_pending(pending)
|
||||||
runner.prefill_finalize(pending)
|
runner.prefill_finalize(pending)
|
||||||
tracked = [FakeNativeCache(), None]
|
tracked = [FakeNativeCache(), None]
|
||||||
runner._req_to_token_pool.auxiliary_state_pool.restore_cache(
|
runner._req_to_token_pool.auxiliary_state_pool.restore_cache(
|
||||||
@@ -1094,11 +1102,13 @@ class TestMlxOverlapScheduler(unittest.TestCase):
|
|||||||
scheduler.last_batch = stale_batch
|
scheduler.last_batch = stale_batch
|
||||||
|
|
||||||
pending = MlxPendingJob(
|
pending = MlxPendingJob(
|
||||||
|
launch=MlxLaunch(
|
||||||
lazy_tokens=None,
|
lazy_tokens=None,
|
||||||
prefills=["prefill"],
|
prefills=["prefill"],
|
||||||
extends=[],
|
extends=[],
|
||||||
decode=None,
|
decode=None,
|
||||||
mode="extend",
|
mode="extend",
|
||||||
|
),
|
||||||
batch_copy=batch_copy,
|
batch_copy=batch_copy,
|
||||||
schedule_batch=schedule_batch,
|
schedule_batch=schedule_batch,
|
||||||
reqs=[SimpleNamespace(rid="r0")],
|
reqs=[SimpleNamespace(rid="r0")],
|
||||||
|
|||||||
@@ -203,8 +203,14 @@ class TestSchedulerProfilerManagerMPS(unittest.TestCase):
|
|||||||
mgr._init_profile(output_dir, None, None, None, None, None, False, "test")
|
mgr._init_profile(output_dir, None, None, None, None, None, False, "test")
|
||||||
return mgr
|
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):
|
def test_start_profile_failure_does_not_crash(self):
|
||||||
import mlx.core as mx
|
import torch
|
||||||
|
|
||||||
from sglang.srt.hardware_backend.mlx.profiler import (
|
from sglang.srt.hardware_backend.mlx.profiler import (
|
||||||
apply_metal_profiler_patches,
|
apply_metal_profiler_patches,
|
||||||
@@ -215,20 +221,22 @@ class TestSchedulerProfilerManagerMPS(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
mgr = self._make_manager(tmp)
|
mgr = self._make_manager(tmp)
|
||||||
with patch.object(
|
with patch.object(
|
||||||
mx.metal,
|
torch.mps.profiler,
|
||||||
"start_capture",
|
"metal_capture",
|
||||||
side_effect=RuntimeError("Capture layer is not inserted"),
|
side_effect=RuntimeError("Capture layer is not inserted"),
|
||||||
):
|
):
|
||||||
result = mgr._start_profile()
|
result = mgr._start_profile()
|
||||||
|
|
||||||
self.assertFalse(result.success)
|
self.assertFalse(result.success)
|
||||||
|
self.assertIn("Capture layer is not inserted", result.message)
|
||||||
self.assertFalse(mgr.profile_in_progress)
|
self.assertFalse(mgr.profile_in_progress)
|
||||||
self.assertIsNone(mgr.torch_profiler)
|
self.assertIsNone(mgr.torch_profiler)
|
||||||
|
|
||||||
def test_start_profile_success_with_mock_capture(self):
|
def test_start_profile_success_with_mock_capture(self):
|
||||||
|
from unittest.mock import MagicMock
|
||||||
from unittest.mock import patch as mock_patch
|
from unittest.mock import patch as mock_patch
|
||||||
|
|
||||||
import mlx.core as mx
|
import torch
|
||||||
|
|
||||||
from sglang.srt.hardware_backend.mlx.profiler import (
|
from sglang.srt.hardware_backend.mlx.profiler import (
|
||||||
apply_metal_profiler_patches,
|
apply_metal_profiler_patches,
|
||||||
@@ -238,14 +246,17 @@ class TestSchedulerProfilerManagerMPS(unittest.TestCase):
|
|||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
mgr = self._make_manager(tmp)
|
mgr = self._make_manager(tmp)
|
||||||
with mock_patch.object(mx.metal, "start_capture"), mock_patch.object(
|
capture_ctx = MagicMock()
|
||||||
mx.metal, "stop_capture"
|
with mock_patch.object(
|
||||||
|
torch.mps.profiler, "metal_capture", return_value=capture_ctx
|
||||||
), mock_patch("torch.distributed.barrier"):
|
), mock_patch("torch.distributed.barrier"):
|
||||||
result = mgr._start_profile()
|
result = mgr._start_profile()
|
||||||
self.assertTrue(result.success)
|
self.assertTrue(result.success, result.message)
|
||||||
self.assertTrue(mgr.profile_in_progress)
|
self.assertTrue(mgr.profile_in_progress)
|
||||||
|
capture_ctx.__enter__.assert_called_once()
|
||||||
mgr._stop_profile()
|
mgr._stop_profile()
|
||||||
self.assertFalse(mgr.profile_in_progress)
|
self.assertFalse(mgr.profile_in_progress)
|
||||||
|
capture_ctx.__exit__.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -202,6 +202,13 @@ class TestMlxReferenceCorrectness(CustomTestCase):
|
|||||||
self.runner.remove_request(rid)
|
self.runner.remove_request(rid)
|
||||||
return out
|
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):
|
def _diff_msg(self, prompt, ref, sgl):
|
||||||
horizon = min(len(ref), len(sgl))
|
horizon = min(len(ref), len(sgl))
|
||||||
first = next((j for j in range(horizon) if ref[j] != sgl[j]), horizon)
|
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:
|
for rid in rids:
|
||||||
self.runner.remove_request(rid)
|
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):
|
for i, (prompt, _, _) in enumerate(self.cases):
|
||||||
self.assertEqual(
|
want = self._truncate_at_eos(solo[i])
|
||||||
batched[i], solo[i], self._diff_msg(prompt, solo[i], batched[i])
|
got = self._truncate_at_eos(batched[i])
|
||||||
)
|
self.assertEqual(got, want, self._diff_msg(prompt, want, got))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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 (
|
from sglang.srt.hardware_backend.mlx.scheduler_mixin import (
|
||||||
SchedulerMlxOverlapMixin,
|
SchedulerMlxOverlapMixin,
|
||||||
)
|
)
|
||||||
|
from sglang.srt.hardware_backend.mlx.tp_worker import MlxLaunch
|
||||||
|
|
||||||
scheduler = self._make_scheduler(recv_side_effect=[[], _StopLoop()])
|
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 = (
|
scheduler.tp_worker.async_forward_batch_generation_mlx.side_effect = (
|
||||||
lambda _batch: (
|
lambda _batch: (
|
||||||
events.append("forward"),
|
events.append("forward"),
|
||||||
(None, [], [], None, "extend"),
|
MlxLaunch(
|
||||||
|
lazy_tokens=None,
|
||||||
|
prefills=[],
|
||||||
|
extends=[],
|
||||||
|
decode=None,
|
||||||
|
mode="extend",
|
||||||
|
),
|
||||||
)[1]
|
)[1]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -173,6 +180,7 @@ class TestOverlapLoopStampsLaunchTs(unittest.TestCase):
|
|||||||
from sglang.srt.hardware_backend.mlx.scheduler_mixin import (
|
from sglang.srt.hardware_backend.mlx.scheduler_mixin import (
|
||||||
SchedulerMlxOverlapMixin,
|
SchedulerMlxOverlapMixin,
|
||||||
)
|
)
|
||||||
|
from sglang.srt.hardware_backend.mlx.tp_worker import MlxLaunch
|
||||||
|
|
||||||
# Iteration 1: fresh decode launch. Iteration 2: chain a second
|
# Iteration 1: fresh decode launch. Iteration 2: chain a second
|
||||||
# decode on top of it. Iteration 3: stop.
|
# 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
|
scheduler.get_next_batch_to_run.return_value = plan
|
||||||
|
|
||||||
pending_decode = MagicMock()
|
pending_decode = MagicMock()
|
||||||
scheduler.tp_worker.async_forward_batch_generation_mlx.return_value = (
|
scheduler.tp_worker.async_forward_batch_generation_mlx.return_value = MlxLaunch(
|
||||||
MagicMock(),
|
lazy_tokens=MagicMock(),
|
||||||
[],
|
prefills=[],
|
||||||
[],
|
extends=[],
|
||||||
pending_decode,
|
decode=pending_decode,
|
||||||
"decode",
|
mode="decode",
|
||||||
)
|
)
|
||||||
scheduler.tp_worker.async_chained_decode_mlx.side_effect = lambda _decode: (
|
scheduler.tp_worker.async_chained_decode_mlx.side_effect = lambda _decode: (
|
||||||
events.append("chained_forward"),
|
events.append("chained_forward"),
|
||||||
(MagicMock(), [], [], MagicMock(), "decode"),
|
MlxLaunch(
|
||||||
|
lazy_tokens=MagicMock(),
|
||||||
|
prefills=[],
|
||||||
|
extends=[],
|
||||||
|
decode=MagicMock(),
|
||||||
|
mode="decode",
|
||||||
|
),
|
||||||
)[1]
|
)[1]
|
||||||
|
|
||||||
launch_times = iter((1.0, 2.0))
|
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
|
1. The attention contract accepts ``sm_scale`` and exposes per-layer window
|
||||||
sizes read from the mlx-lm container convention (``layer_types`` +
|
sizes read from the mlx-lm container convention (``layer_types`` +
|
||||||
``window_size``).
|
``window_size``).
|
||||||
2. The cache shims' ``make_mask`` mirrors mlx_lm's
|
2. The cache shims' ``make_mask`` matches mlx_lm's
|
||||||
``cache.create_attention_mask`` exactly — in particular ``window_size``
|
``cache.create_attention_mask`` semantically — in particular ``window_size``
|
||||||
must produce a banded mask (including for N == 1) instead of being
|
must produce a banded mask (including for N == 1) instead of being
|
||||||
silently dropped, or sliding-window layers degrade to full attention.
|
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
|
3. ``MLXAttentionWrapper._batched_decode`` applies the window by truncating
|
||||||
each request's KV to the trailing window, passes ``sinks`` through, and
|
each request's KV to the trailing window, passes ``sinks`` through, and
|
||||||
uses the contract scale helper.
|
uses the contract scale helper.
|
||||||
@@ -173,7 +176,22 @@ class TestGptOssAttentionContract(CustomTestCase):
|
|||||||
|
|
||||||
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||||
class TestShimMakeMask(CustomTestCase):
|
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):
|
def _shims(self, offset):
|
||||||
contig = ContiguousAttentionKVCache(
|
contig = ContiguousAttentionKVCache(
|
||||||
@@ -185,12 +203,11 @@ class TestShimMakeMask(CustomTestCase):
|
|||||||
)
|
)
|
||||||
return (AttentionOffsetCache(offset=offset), contig, pool_backed)
|
return (AttentionOffsetCache(offset=offset), contig, pool_backed)
|
||||||
|
|
||||||
def _assert_same_mask(self, got, ref, msg):
|
def _assert_same_mask(self, got, ref, msg, N, offset):
|
||||||
if ref is None or isinstance(ref, str):
|
|
||||||
self.assertEqual(got, ref, msg)
|
|
||||||
else:
|
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
isinstance(got, mx.array) and mx.array_equal(got, ref).item(),
|
mx.array_equal(
|
||||||
|
self._dense(got, N, offset), self._dense(ref, N, offset)
|
||||||
|
).item(),
|
||||||
msg,
|
msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,6 +230,32 @@ class TestShimMakeMask(CustomTestCase):
|
|||||||
ref,
|
ref,
|
||||||
f"{type(shim).__name__} mismatch for N={N} offset={offset} "
|
f"{type(shim).__name__} mismatch for N={N} offset={offset} "
|
||||||
f"window={window} return_array={return_array}",
|
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):
|
def test_windowed_mask_is_banded_including_self(self):
|
||||||
|
|||||||
@@ -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
|
The routing decision was duplicated across the sync and async paths (the bug
|
||||||
therefore existed in both). It now lives in the shared
|
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 helper decision directly;
|
||||||
* the sync wiring, by driving ``_forward_batch_generation_mlx``;
|
* the async wiring, by driving ``_async_extend_batch``;
|
||||||
* 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
|
They mock the MLX runner and load no model. Apple-Silicon-only because
|
||||||
``tp_worker`` imports ``mlx.core`` at module load.
|
``tp_worker`` imports ``mlx.core`` at module load.
|
||||||
@@ -35,7 +39,9 @@ from types import SimpleNamespace
|
|||||||
import torch
|
import torch
|
||||||
|
|
||||||
from sglang.srt.model_executor.forward_batch_info import ForwardMode
|
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.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
|
# 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
|
# 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:
|
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):
|
def __init__(self, known_rids):
|
||||||
self._known = set(known_rids)
|
self._known = set(known_rids)
|
||||||
self.calls: list[tuple[str, str]] = [] # (op, rid)
|
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._req_caches: dict[str, list] = {}
|
||||||
self._counter = 0
|
self._counter = 0
|
||||||
|
|
||||||
@@ -73,37 +83,27 @@ class _FakeRunner:
|
|||||||
|
|
||||||
return SimpleNamespace(state=[mx.array([0.0], dtype=mx.float32)])
|
return SimpleNamespace(state=[mx.array([0.0], dtype=mx.float32)])
|
||||||
|
|
||||||
# --- sync surface ---
|
# --- start/finalize surface (shared by the sync and async worker paths) ---
|
||||||
def extend(self, rid, new_token_ids, new_slot_ids):
|
def extend_start(
|
||||||
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(
|
|
||||||
self,
|
self,
|
||||||
req_id,
|
req_id,
|
||||||
new_token_ids,
|
new_token_ids,
|
||||||
full_token_ids,
|
|
||||||
prefix_slot_ids,
|
|
||||||
new_slot_ids,
|
new_slot_ids,
|
||||||
req_pool_idx,
|
needs_logits=True,
|
||||||
req=None,
|
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
|
import mlx.core as mx
|
||||||
|
|
||||||
self.calls.append(("extend_start", req_id))
|
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()]
|
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(
|
def prefill_start(
|
||||||
self,
|
self,
|
||||||
@@ -114,17 +114,24 @@ class _FakeRunner:
|
|||||||
new_slot_ids,
|
new_slot_ids,
|
||||||
req_pool_idx,
|
req_pool_idx,
|
||||||
req=None,
|
req=None,
|
||||||
|
needs_logits=True,
|
||||||
|
logit_edit_row=None,
|
||||||
|
logprob_spec=None,
|
||||||
):
|
):
|
||||||
import mlx.core as mx
|
import mlx.core as mx
|
||||||
|
|
||||||
self.calls.append(("prefill_start", req_id))
|
self.calls.append(("prefill_start", req_id))
|
||||||
|
self.logits_flags[("prefill_start", req_id)] = needs_logits
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
lazy_token=mx.array([0], dtype=mx.int32),
|
lazy_token=mx.array([0], dtype=mx.int32),
|
||||||
cache=[self._fake_cache_layer()],
|
cache=[self._fake_cache_layer()],
|
||||||
req_id=req_id,
|
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
|
import mlx.core as mx
|
||||||
|
|
||||||
for rid in rids:
|
for rid in rids:
|
||||||
@@ -133,8 +140,29 @@ class _FakeRunner:
|
|||||||
lazy_tokens=mx.array([0] * len(rids), dtype=mx.int32),
|
lazy_tokens=mx.array([0] * len(rids), dtype=mx.int32),
|
||||||
caches=[[self._fake_cache_layer()] for _ in rids],
|
caches=[[self._fake_cache_layer()] for _ in rids],
|
||||||
req_ids=list(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:
|
class _FakeReq:
|
||||||
def __init__(self, rid, req_pool_idx=0):
|
def __init__(self, rid, req_pool_idx=0):
|
||||||
@@ -142,6 +170,11 @@ class _FakeReq:
|
|||||||
self.prefix_indices = torch.empty(0, dtype=torch.long)
|
self.prefix_indices = torch.empty(0, dtype=torch.long)
|
||||||
self.fill_ids = [0]
|
self.fill_ids = [0]
|
||||||
self.req_pool_idx = req_pool_idx
|
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):
|
def get_fill_ids(self):
|
||||||
return self.fill_ids
|
return self.fill_ids
|
||||||
@@ -154,15 +187,26 @@ class _FakeBatch:
|
|||||||
self.reqs = reqs
|
self.reqs = reqs
|
||||||
self.extend_lens = list(extend_lens)
|
self.extend_lens = list(extend_lens)
|
||||||
self.decoding_reqs = decoding_reqs
|
self.decoding_reqs = decoding_reqs
|
||||||
|
self.sampling_info = None
|
||||||
|
self.return_logprob = False
|
||||||
# Arbitrary but correctly-sized token / slot arrays.
|
# Arbitrary but correctly-sized token / slot arrays.
|
||||||
self.input_ids = torch.arange(total, dtype=torch.long)
|
self.input_ids = torch.arange(total, dtype=torch.long)
|
||||||
self.out_cache_loc = 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)
|
@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."""
|
"""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
|
@staticmethod
|
||||||
def _worker(known_rids):
|
def _worker(known_rids):
|
||||||
from sglang.srt.hardware_backend.mlx.tp_worker import MlxTpModelWorker
|
from sglang.srt.hardware_backend.mlx.tp_worker import MlxTpModelWorker
|
||||||
@@ -170,6 +214,10 @@ class TestMlxExtendRouting(unittest.TestCase):
|
|||||||
worker = MlxTpModelWorker.__new__(MlxTpModelWorker)
|
worker = MlxTpModelWorker.__new__(MlxTpModelWorker)
|
||||||
worker._mlx_runner = _FakeRunner(known_rids)
|
worker._mlx_runner = _FakeRunner(known_rids)
|
||||||
worker._mlx_active_rids = set()
|
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
|
return worker
|
||||||
|
|
||||||
# ---------- the shared decision helper ----------
|
# ---------- the shared decision helper ----------
|
||||||
@@ -200,13 +248,28 @@ class TestMlxExtendRouting(unittest.TestCase):
|
|||||||
def test_sync_one_token_continuation_routes_to_extend(self):
|
def test_sync_one_token_continuation_routes_to_extend(self):
|
||||||
"""THE REGRESSION (sync): a 1-token continuation must extend, not decode."""
|
"""THE REGRESSION (sync): a 1-token continuation must extend, not decode."""
|
||||||
runner = self._run_sync([_FakeReq("r1")], [1], {"r1"}, None, ForwardMode.EXTEND)
|
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):
|
def test_sync_genuine_mixed_decode_routes_to_decode(self):
|
||||||
p, d = _FakeReq("p1"), _FakeReq("d1")
|
p, d = _FakeReq("p1"), _FakeReq("d1")
|
||||||
runner = self._run_sync([p, d], [4, 1], {"d1"}, [d], ForwardMode.MIXED)
|
runner = self._run_sync([p, d], [4, 1], {"d1"}, [d], ForwardMode.MIXED)
|
||||||
self.assertEqual(runner.ops_for("p1"), ["prefill"])
|
self.assertEqual(runner.ops_for("p1"), ["prefill_start"])
|
||||||
self.assertEqual(runner.ops_for("d1"), ["decode"])
|
self.assertEqual(runner.ops_for("d1"), ["decode_start"])
|
||||||
|
|
||||||
# ---------- async path: _async_extend_batch ----------
|
# ---------- async path: _async_extend_batch ----------
|
||||||
|
|
||||||
@@ -216,26 +279,34 @@ class TestMlxExtendRouting(unittest.TestCase):
|
|||||||
worker = MlxTpModelWorker.__new__(MlxTpModelWorker)
|
worker = MlxTpModelWorker.__new__(MlxTpModelWorker)
|
||||||
worker._mlx_runner = _FakeRunner(known_rids)
|
worker._mlx_runner = _FakeRunner(known_rids)
|
||||||
batch = _FakeBatch(forward_mode, reqs, extend_lens, decoding_reqs)
|
batch = _FakeBatch(forward_mode, reqs, extend_lens, decoding_reqs)
|
||||||
# returns (lazy_stacked, pending_prefills, pending_extends,
|
launch = worker._async_extend_batch(batch)
|
||||||
# pending_mixed_decode, mode)
|
return worker._mlx_runner, launch
|
||||||
result = worker._async_extend_batch(batch)
|
|
||||||
return worker._mlx_runner, result
|
|
||||||
|
|
||||||
def test_async_one_token_continuation_routes_to_extend(self):
|
def test_async_one_token_continuation_routes_to_extend(self):
|
||||||
"""THE REGRESSION (async): a 1-token continuation must extend, not decode."""
|
"""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
|
[_FakeReq("r1")], [1], {"r1"}, None, ForwardMode.EXTEND
|
||||||
)
|
)
|
||||||
self.assertEqual(runner.ops_for("r1"), ["extend_start"])
|
self.assertEqual(runner.ops_for("r1"), ["extend_start"])
|
||||||
self.assertEqual(len(result[2]), 1) # one pending extend
|
self.assertIs(runner.logits_flags[("extend_start", "r1")], True)
|
||||||
self.assertIsNone(result[3]) # no mixed decode
|
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):
|
def test_async_genuine_mixed_decode_routes_to_decode(self):
|
||||||
p, d = _FakeReq("p1"), _FakeReq("d1")
|
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("p1"), ["prefill_start"])
|
||||||
self.assertEqual(runner.ops_for("d1"), ["decode_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__":
|
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()
|
||||||
Reference in New Issue
Block a user