[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
@@ -5,8 +5,9 @@ uses per-head attention sinks, so it exercises the MLX backend's
|
||||
sliding-window path end to end. Two guards:
|
||||
|
||||
1. ``TestGptOssMlxCorrectness`` — black-box serving smoke against a running
|
||||
server, including a >128-token prompt so the sliding window actually
|
||||
engages.
|
||||
server with the radix cache enabled (the default KV path), including a
|
||||
>128-token prompt so the sliding window actually engages and a repeated
|
||||
prompt so a radix prefix hit must reproduce the cold greedy output.
|
||||
2. ``TestGptOssMlxReferenceCorrectness`` — token-for-token equivalence of
|
||||
``MlxModelRunner`` greedy decoding against raw, unpatched mlx_lm greedy
|
||||
generation. SGLang keeps full KV and applies banded masks /
|
||||
@@ -117,10 +118,14 @@ class TestGptOssMlxCorrectness(CustomTestCase):
|
||||
cls.base_url,
|
||||
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
||||
other_args=[
|
||||
# Radix cache stays enabled (the default): sliding-window
|
||||
# layers keep windowed per-request KV, the shared pool holds
|
||||
# full-attention layers, and prefix hits recompute the
|
||||
# prefix, so serving must stay correct without
|
||||
# --disable-radix-cache.
|
||||
"--trust-remote-code",
|
||||
"--tp-size",
|
||||
"1",
|
||||
"--disable-radix-cache",
|
||||
"--disable-cuda-graph",
|
||||
"--mem-fraction-static",
|
||||
MEM_FRACTION_STATIC,
|
||||
@@ -189,6 +194,24 @@ class TestGptOssMlxCorrectness(CustomTestCase):
|
||||
)
|
||||
self.assertIn("BLUEBERRY", text.upper())
|
||||
|
||||
def test_radix_prefix_hit_reproduces_greedy_output(self):
|
||||
# The server runs with the radix cache enabled. Sending the same
|
||||
# >128-token prompt twice makes the second request hit the cached
|
||||
# prefix; on sliding-window models the runner recomputes the prefix
|
||||
# (windowed KV keeps no pool history), and greedy output must be
|
||||
# identical to the cold request.
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a concise assistant."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": _NUMBER_LIST
|
||||
+ ". Which number comes right after 41? Answer briefly.",
|
||||
},
|
||||
]
|
||||
cold = self._chat(messages, max_tokens=48)
|
||||
hit = self._chat(messages, max_tokens=48)
|
||||
self.assertEqual(cold, hit)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||
class TestGptOssMlxReferenceCorrectness(CustomTestCase):
|
||||
|
||||
@@ -46,6 +46,7 @@ if _HAS_MLX:
|
||||
MlxPendingJob,
|
||||
SchedulerMlxOverlapMixin,
|
||||
)
|
||||
from sglang.srt.hardware_backend.mlx.tp_worker import MlxLaunch
|
||||
from sglang.srt.managers.scheduler_components import (
|
||||
batch_result_processor as batch_result_processor_module,
|
||||
)
|
||||
@@ -309,7 +310,7 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
||||
new_slot_ids=[4],
|
||||
req_pool_idx=0,
|
||||
)
|
||||
MlxModelRunner._eval_with_cache(pending.lazy_token, pending.cache)
|
||||
runner.eval_pending(pending)
|
||||
mx.eval(*runner._attention_kv_pool.all_buffers())
|
||||
runner.prefill_finalize(pending)
|
||||
|
||||
@@ -350,7 +351,8 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
||||
calls.append(
|
||||
(len(caches), batched_input.tolist(), list(helper_req_ids))
|
||||
)
|
||||
return mx.array(list(range(len(caches))), dtype=mx.int32)
|
||||
# Last-token logits whose argmax is the row index.
|
||||
return mx.eye(len(caches), 8, dtype=mx.float32)
|
||||
|
||||
def fail_native(*args, **kwargs):
|
||||
raise AssertionError("dense decode should use batched attention")
|
||||
@@ -386,7 +388,8 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
||||
|
||||
def fake_batched(caches, batched_input, helper_req_ids):
|
||||
calls.append((len(caches), batched_input.tolist(), list(helper_req_ids)))
|
||||
return mx.array([8], dtype=mx.int32)
|
||||
# Last-token logits whose argmax is token 8.
|
||||
return mx.arange(9, dtype=mx.float32)[None, :]
|
||||
|
||||
def fail_native(*args, **kwargs):
|
||||
raise AssertionError("dense chained decode should use batched attention")
|
||||
@@ -502,12 +505,13 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
||||
]
|
||||
]
|
||||
|
||||
lazy_tokens = runner._decode_with_batched_attention(
|
||||
lazy_logits = runner._decode_with_batched_attention(
|
||||
cache,
|
||||
mx.array([[7]], dtype=mx.int32),
|
||||
["r0"],
|
||||
)
|
||||
mx.eval(lazy_tokens, *MlxModelRunner._cache_state_arrays(cache))
|
||||
lazy_tokens = mx.argmax(lazy_logits, axis=-1)
|
||||
mx.eval(lazy_tokens, *MlxModelRunner.cache_state_arrays(cache))
|
||||
|
||||
self.assertEqual(lazy_tokens.tolist(), [0])
|
||||
self.assertEqual(cache[0][0].offset, 1)
|
||||
@@ -558,6 +562,9 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
||||
req_pool_idx={"r0": 0, "r1": 1},
|
||||
req_to_token_pool=req_to_token_pool,
|
||||
attention_layer_indices=[0],
|
||||
# The fused scatter addresses pool buffers by full-attention index,
|
||||
# so the context requires the map whenever the RoPE kernel is live.
|
||||
full_kv_pool_index_by_layer={0: 0},
|
||||
)
|
||||
|
||||
self.assertEqual(ctx.seq_lens, [1, 2])
|
||||
@@ -578,7 +585,8 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
||||
|
||||
def fake_hybrid(caches, batched_input, helper_req_ids):
|
||||
calls.append((len(caches), batched_input.tolist(), list(helper_req_ids)))
|
||||
return mx.array([4, 5], dtype=mx.int32)
|
||||
# Last-token logits whose argmax is 4 for row 0, 5 for row 1.
|
||||
return mx.eye(8, dtype=mx.float32)[4:6]
|
||||
|
||||
def fail_batched(*args, **kwargs):
|
||||
raise AssertionError(
|
||||
@@ -713,7 +721,7 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
||||
new_slot_ids=[4],
|
||||
req_pool_idx=req.req_pool_idx,
|
||||
)
|
||||
MlxModelRunner._eval_with_cache(pending.lazy_token, pending.cache)
|
||||
runner.eval_pending(pending)
|
||||
runner.prefill_finalize(pending)
|
||||
|
||||
self.assertEqual(runner.model.seen_inputs, [[[13]]])
|
||||
@@ -770,7 +778,7 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
||||
req_pool_idx=req.req_pool_idx,
|
||||
req=req,
|
||||
)
|
||||
MlxModelRunner._eval_with_cache(pending.lazy_token, pending.cache)
|
||||
runner.eval_pending(pending)
|
||||
runner.prefill_finalize(pending)
|
||||
tracked = [FakeNativeCache(), None]
|
||||
runner._req_to_token_pool.auxiliary_state_pool.restore_cache(
|
||||
@@ -832,7 +840,7 @@ class TestMlxAuxiliaryStateRunnerCache(unittest.TestCase):
|
||||
req_pool_idx=req.req_pool_idx,
|
||||
req=req,
|
||||
)
|
||||
MlxModelRunner._eval_with_cache(pending.lazy_token, pending.cache)
|
||||
runner.eval_pending(pending)
|
||||
runner.prefill_finalize(pending)
|
||||
tracked = [FakeNativeCache(), None]
|
||||
runner._req_to_token_pool.auxiliary_state_pool.restore_cache(
|
||||
@@ -1094,11 +1102,13 @@ class TestMlxOverlapScheduler(unittest.TestCase):
|
||||
scheduler.last_batch = stale_batch
|
||||
|
||||
pending = MlxPendingJob(
|
||||
lazy_tokens=None,
|
||||
prefills=["prefill"],
|
||||
extends=[],
|
||||
decode=None,
|
||||
mode="extend",
|
||||
launch=MlxLaunch(
|
||||
lazy_tokens=None,
|
||||
prefills=["prefill"],
|
||||
extends=[],
|
||||
decode=None,
|
||||
mode="extend",
|
||||
),
|
||||
batch_copy=batch_copy,
|
||||
schedule_batch=schedule_batch,
|
||||
reqs=[SimpleNamespace(rid="r0")],
|
||||
|
||||
@@ -203,8 +203,14 @@ class TestSchedulerProfilerManagerMPS(unittest.TestCase):
|
||||
mgr._init_profile(output_dir, None, None, None, None, None, False, "test")
|
||||
return mgr
|
||||
|
||||
# MetalCaptureProfiler has two strategies: start_mlx drives
|
||||
# mx.metal.start_capture, start_mps drives torch.mps.profiler.metal_capture.
|
||||
# This manager takes the MPS one, so that is the symbol to stand in for --
|
||||
# patching mx.metal here leaves the real Metal capture running, which fails
|
||||
# with "Capture layer is not inserted" unless MTL_CAPTURE_ENABLED=1 is set
|
||||
# in the environment.
|
||||
def test_start_profile_failure_does_not_crash(self):
|
||||
import mlx.core as mx
|
||||
import torch
|
||||
|
||||
from sglang.srt.hardware_backend.mlx.profiler import (
|
||||
apply_metal_profiler_patches,
|
||||
@@ -215,20 +221,22 @@ class TestSchedulerProfilerManagerMPS(unittest.TestCase):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mgr = self._make_manager(tmp)
|
||||
with patch.object(
|
||||
mx.metal,
|
||||
"start_capture",
|
||||
torch.mps.profiler,
|
||||
"metal_capture",
|
||||
side_effect=RuntimeError("Capture layer is not inserted"),
|
||||
):
|
||||
result = mgr._start_profile()
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertIn("Capture layer is not inserted", result.message)
|
||||
self.assertFalse(mgr.profile_in_progress)
|
||||
self.assertIsNone(mgr.torch_profiler)
|
||||
|
||||
def test_start_profile_success_with_mock_capture(self):
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
import mlx.core as mx
|
||||
import torch
|
||||
|
||||
from sglang.srt.hardware_backend.mlx.profiler import (
|
||||
apply_metal_profiler_patches,
|
||||
@@ -238,14 +246,17 @@ class TestSchedulerProfilerManagerMPS(unittest.TestCase):
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mgr = self._make_manager(tmp)
|
||||
with mock_patch.object(mx.metal, "start_capture"), mock_patch.object(
|
||||
mx.metal, "stop_capture"
|
||||
capture_ctx = MagicMock()
|
||||
with mock_patch.object(
|
||||
torch.mps.profiler, "metal_capture", return_value=capture_ctx
|
||||
), mock_patch("torch.distributed.barrier"):
|
||||
result = mgr._start_profile()
|
||||
self.assertTrue(result.success)
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertTrue(mgr.profile_in_progress)
|
||||
capture_ctx.__enter__.assert_called_once()
|
||||
mgr._stop_profile()
|
||||
self.assertFalse(mgr.profile_in_progress)
|
||||
capture_ctx.__exit__.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -202,6 +202,13 @@ class TestMlxReferenceCorrectness(CustomTestCase):
|
||||
self.runner.remove_request(rid)
|
||||
return out
|
||||
|
||||
def _truncate_at_eos(self, seq):
|
||||
"""``seq`` up to and including its first EOS (whole seq if none)."""
|
||||
for i, tok in enumerate(seq):
|
||||
if tok in self.eos_ids:
|
||||
return seq[: i + 1]
|
||||
return list(seq)
|
||||
|
||||
def _diff_msg(self, prompt, ref, sgl):
|
||||
horizon = min(len(ref), len(sgl))
|
||||
first = next((j for j in range(horizon) if ref[j] != sgl[j]), horizon)
|
||||
@@ -246,10 +253,18 @@ class TestMlxReferenceCorrectness(CustomTestCase):
|
||||
for rid in rids:
|
||||
self.runner.remove_request(rid)
|
||||
|
||||
# Compare up to and including the first EOS. The horizon is fixed so
|
||||
# the batch composition never changes mid-run, which walks past EOS on
|
||||
# short answers -- and there the distribution is near-degenerate, so
|
||||
# batched and solo argmax can pick different tokens from a numerical
|
||||
# tie. That is float reduction order (a padded batched SDPA vs an
|
||||
# unpadded solo one), not state bleed: any cache crossover would show
|
||||
# up while the model still has an opinion. Measured on this fixture,
|
||||
# case 1 reaches EOS at index 2 and first differs at index 6.
|
||||
for i, (prompt, _, _) in enumerate(self.cases):
|
||||
self.assertEqual(
|
||||
batched[i], solo[i], self._diff_msg(prompt, solo[i], batched[i])
|
||||
)
|
||||
want = self._truncate_at_eos(solo[i])
|
||||
got = self._truncate_at_eos(batched[i])
|
||||
self.assertEqual(got, want, self._diff_msg(prompt, want, got))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
"""Unit tests for MLX in-graph sampling (hardware_backend/mlx/sampling.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import unittest
|
||||
from collections import Counter
|
||||
|
||||
from sglang.test.ci.ci_register import register_cpu_ci, register_mlx_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=2, suite="base-a-test-cpu")
|
||||
register_mlx_ci(est_time=20, suite="stage-a-unit-test-mlx")
|
||||
|
||||
_HAS_MLX = importlib.util.find_spec("mlx") is not None
|
||||
_SKIP_REASON = "requires mlx"
|
||||
|
||||
if _HAS_MLX:
|
||||
import mlx.core as mx
|
||||
|
||||
from sglang.srt.hardware_backend.mlx.sampling import (
|
||||
DEFAULT_SAMPLING_SEED,
|
||||
GREEDY_PARAMS,
|
||||
MAX_BOUNDED_TOP_K,
|
||||
MlxLogprobSpec,
|
||||
MlxSamplingParams,
|
||||
_candidate_width,
|
||||
_gumbel_noise,
|
||||
_murmur_hash32,
|
||||
all_greedy,
|
||||
compute_logprobs,
|
||||
sample_tokens,
|
||||
sanitize_logits,
|
||||
)
|
||||
|
||||
|
||||
def _reference_murmur3(seed: int, pos: int, col: int) -> int:
|
||||
"""Pure-Python MurmurHash3 mirroring the Triton kernel in
|
||||
sglang/kernels/ops/sampling/murmur_hash.py: blocks seed_low,
|
||||
seed_high, position, column; length-16 finalization; fmix32."""
|
||||
|
||||
def mix(h: int, k: int) -> int:
|
||||
k = (k * 0xCC9E2D51) & 0xFFFFFFFF
|
||||
k = ((k << 15) | (k >> 17)) & 0xFFFFFFFF
|
||||
k = (k * 0x1B873593) & 0xFFFFFFFF
|
||||
h ^= k
|
||||
h = ((h << 13) | (h >> 19)) & 0xFFFFFFFF
|
||||
return (h * 5 + 0xE6546B64) & 0xFFFFFFFF
|
||||
|
||||
seed &= 0xFFFFFFFFFFFFFFFF
|
||||
h = mix(0, seed & 0xFFFFFFFF)
|
||||
h = mix(h, (seed >> 32) & 0xFFFFFFFF)
|
||||
h = mix(h, pos & 0xFFFFFFFF)
|
||||
h = mix(h, col & 0xFFFFFFFF)
|
||||
h ^= 16
|
||||
h ^= h >> 16
|
||||
h = (h * 0x85EBCA6B) & 0xFFFFFFFF
|
||||
h ^= h >> 13
|
||||
h = (h * 0xC2B2AE35) & 0xFFFFFFFF
|
||||
h ^= h >> 16
|
||||
return h
|
||||
|
||||
|
||||
def _params(temperature=1.0, top_k=1 << 30, top_p=1.0, min_p=0.0, seed=None):
|
||||
return MlxSamplingParams(
|
||||
temperature=temperature, top_k=top_k, top_p=top_p, min_p=min_p, seed=seed
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||
class TestMurmurHashPort(CustomTestCase):
|
||||
def test_matches_pure_python_reference(self):
|
||||
"""Guards the mx uint32 port of the CUDA murmur kernel: any drift in
|
||||
wraparound/shift/block-order semantics changes seeded sampling."""
|
||||
seeds = [0, 1, 42, 2**31, 2**63 + 12345]
|
||||
positions = [0, 7, 1023, 2**31 - 1, 5]
|
||||
vocab = 64
|
||||
hashed = _murmur_hash32(seeds=seeds, positions=positions, vocab_size=vocab)
|
||||
mx.eval(hashed)
|
||||
for row, (seed, pos) in enumerate(zip(seeds, positions)):
|
||||
for col in (0, 1, vocab // 2, vocab - 1):
|
||||
self.assertEqual(
|
||||
int(hashed[row, col].item()),
|
||||
_reference_murmur3(seed, pos, col),
|
||||
msg=f"mismatch at seed={seed} pos={pos} col={col}",
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||
class TestSampleTokens(CustomTestCase):
|
||||
VOCAB = 32
|
||||
|
||||
def _logits(self, batch_size: int, key_int: int = 0) -> mx.array:
|
||||
return (
|
||||
mx.random.normal(shape=(batch_size, self.VOCAB), key=mx.random.key(key_int))
|
||||
* 3.0
|
||||
)
|
||||
|
||||
def _draw(self, logits, params, positions=None, n=200, key_start=100):
|
||||
"""Sample n times with distinct keys, return per-row token Counters."""
|
||||
batch_size = logits.shape[0]
|
||||
positions = positions if positions is not None else [5] * batch_size
|
||||
counters = [Counter() for _ in range(batch_size)]
|
||||
for i in range(n):
|
||||
toks = sample_tokens(
|
||||
last_logits=logits,
|
||||
params=params,
|
||||
positions=positions,
|
||||
key=mx.random.key(key_start + i),
|
||||
)
|
||||
mx.eval(toks)
|
||||
for row, t in enumerate(toks.tolist()):
|
||||
counters[row][int(t)] += 1
|
||||
return counters
|
||||
|
||||
@staticmethod
|
||||
def _reference_support(probs, top_k, top_p, min_p):
|
||||
"""Independent replica of the mask, in pure Python, on sorted probs."""
|
||||
order = sorted(range(len(probs)), key=lambda i: (-probs[i], i))
|
||||
keep, cum = [], 0.0
|
||||
for rank, idx in enumerate(order):
|
||||
p = probs[idx]
|
||||
masked = (
|
||||
rank >= min(top_k, len(probs))
|
||||
or cum > top_p
|
||||
or p < probs[order[0]] * min_p
|
||||
)
|
||||
cum += p
|
||||
if not masked:
|
||||
keep.append(idx)
|
||||
return set(keep)
|
||||
|
||||
def _probs(self, logits):
|
||||
probs = mx.softmax(logits.astype(mx.float32), axis=-1)
|
||||
mx.eval(probs)
|
||||
return probs[0].tolist()
|
||||
|
||||
def test_greedy_rows_match_argmax_in_mixed_batch(self):
|
||||
"""A greedy row must return exactly argmax even when other rows in
|
||||
the batch sample — guards the where() row-select and the sglang
|
||||
greedy convention (top_k == 1)."""
|
||||
logits = self._logits(3)
|
||||
expected = mx.argmax(logits, axis=-1).tolist()
|
||||
params = [_params(top_k=1), _params(temperature=0.7), _params(top_k=1)]
|
||||
counters = self._draw(logits, params, n=25)
|
||||
self.assertEqual(set(counters[0]), {expected[0]})
|
||||
self.assertEqual(set(counters[2]), {expected[2]})
|
||||
|
||||
def test_filter_supports_match_reference(self):
|
||||
"""The sampled support must stay inside the independently computed
|
||||
mask for each filter and for their combination — guards the rank
|
||||
mask, the nucleus exclusion, the min_p threshold, and the
|
||||
sorted->vocab index map."""
|
||||
cases = [
|
||||
("top_k=2", dict(top_k=2)),
|
||||
("top_p=0.6", dict(top_p=0.6)),
|
||||
("min_p=0.3", dict(min_p=0.3)),
|
||||
("top_k=8,top_p=0.7,min_p=0.05", dict(top_k=8, top_p=0.7, min_p=0.05)),
|
||||
]
|
||||
for label, kwargs in cases:
|
||||
with self.subTest(label):
|
||||
logits = self._logits(1, key_int=3)
|
||||
support = self._reference_support(
|
||||
self._probs(logits),
|
||||
kwargs.get("top_k", 1 << 30),
|
||||
kwargs.get("top_p", 1.0),
|
||||
kwargs.get("min_p", 0.0),
|
||||
)
|
||||
drawn = set(self._draw(logits, [_params(**kwargs)], n=400)[0])
|
||||
self.assertTrue(drawn <= support, f"{label}: extra {drawn - support}")
|
||||
self.assertTrue(drawn, f"{label}: nothing sampled")
|
||||
|
||||
def test_seeded_row_is_deterministic_and_key_independent(self):
|
||||
"""A row with sampling_seed must produce the same token regardless
|
||||
of the RNG key or batch composition — the murmur-gumbel path only
|
||||
depends on (seed, position, logits)."""
|
||||
logits = self._logits(2, key_int=5)
|
||||
seeded = _params(temperature=1.0, seed=1234)
|
||||
tok_solo = sample_tokens(
|
||||
last_logits=logits[:1],
|
||||
params=[seeded],
|
||||
positions=[9],
|
||||
key=mx.random.key(0),
|
||||
)
|
||||
tok_other_key = sample_tokens(
|
||||
last_logits=logits[:1],
|
||||
params=[seeded],
|
||||
positions=[9],
|
||||
key=mx.random.key(999),
|
||||
)
|
||||
tok_in_batch = sample_tokens(
|
||||
last_logits=logits,
|
||||
params=[seeded, _params(temperature=0.8)],
|
||||
positions=[9, 3],
|
||||
key=mx.random.key(7),
|
||||
)
|
||||
mx.eval(tok_solo, tok_other_key, tok_in_batch)
|
||||
self.assertEqual(tok_solo.tolist(), tok_other_key.tolist())
|
||||
self.assertEqual(int(tok_in_batch[0].item()), int(tok_solo[0].item()))
|
||||
|
||||
def test_seeded_row_unaffected_by_batchmate_filtering(self):
|
||||
"""A seeded row's token must not change when a batchmate triggers
|
||||
the top-k/top-p sort path — guards the vocab-id-space noise
|
||||
contract (regression: noise was applied in sorted-rank space when
|
||||
any row filtered, so batch composition changed seeded tokens)."""
|
||||
# Near-uniform seeded row: the Gumbel noise decides the token, so
|
||||
# a change of noise index space is guaranteed to show up.
|
||||
seeded_logits = (
|
||||
mx.random.normal(shape=(1, self.VOCAB), key=mx.random.key(8)) * 0.05
|
||||
)
|
||||
mate_logits = (
|
||||
mx.random.normal(shape=(1, self.VOCAB), key=mx.random.key(9)) * 3.0
|
||||
)
|
||||
logits = mx.concatenate([seeded_logits, mate_logits], axis=0)
|
||||
seeded = _params(seed=4321)
|
||||
solo = sample_tokens(
|
||||
last_logits=logits[:1],
|
||||
params=[seeded],
|
||||
positions=[6],
|
||||
key=mx.random.key(0),
|
||||
)
|
||||
with_filtering_mate = sample_tokens(
|
||||
last_logits=logits,
|
||||
params=[seeded, _params(temperature=1.2, top_k=2)],
|
||||
positions=[6, 11],
|
||||
key=mx.random.key(55),
|
||||
)
|
||||
mx.eval(solo, with_filtering_mate)
|
||||
self.assertEqual(int(with_filtering_mate[0].item()), int(solo[0].item()))
|
||||
|
||||
def test_bounded_top_k_picks_the_same_token_as_the_full_vocab_chain(self):
|
||||
"""The bounded top-K chain is an optimization, not a policy change:
|
||||
a seeded row must pick the same token whether or not the batch is
|
||||
eligible for it. A batchmate without a finite top_k pushes the
|
||||
whole batch back onto the full-vocab chain, so the same seeded row
|
||||
is sampled both ways here."""
|
||||
logits = self._logits(2, key_int=11)
|
||||
seeded = _params(top_k=4, seed=2024)
|
||||
bounded = sample_tokens(
|
||||
last_logits=logits[:1],
|
||||
params=[seeded],
|
||||
positions=[6],
|
||||
key=mx.random.key(0),
|
||||
)
|
||||
# min_p alone leaves top_k at TOP_K_ALL, so this batch falls back.
|
||||
full_vocab = sample_tokens(
|
||||
last_logits=logits,
|
||||
params=[seeded, _params(min_p=0.1)],
|
||||
positions=[6, 2],
|
||||
key=mx.random.key(3),
|
||||
)
|
||||
mx.eval(bounded, full_vocab)
|
||||
self.assertEqual(int(full_vocab[0].item()), int(bounded[0].item()))
|
||||
|
||||
def test_candidate_width_gates_the_bounded_chain(self):
|
||||
"""Only a batch whose widest top_k fits inside both the bound and
|
||||
the vocabulary may shrink the chain; anything else must return the
|
||||
full vocab size (which selects the scatter-back path)."""
|
||||
vocab = 4096
|
||||
for label, params, expected in [
|
||||
("under the bound", [_params(top_k=64)], 64),
|
||||
("at the bound", [_params(top_k=MAX_BOUNDED_TOP_K)], MAX_BOUNDED_TOP_K),
|
||||
("past the bound", [_params(top_k=MAX_BOUNDED_TOP_K + 1)], vocab),
|
||||
("no top_k (TOP_K_ALL)", [_params()], vocab),
|
||||
("top_k == vocab", [_params(top_k=vocab)], vocab),
|
||||
("widest row wins", [_params(top_k=4), _params(top_k=64)], 64),
|
||||
("one unbounded row", [_params(top_k=4), _params(top_p=0.9)], vocab),
|
||||
]:
|
||||
with self.subTest(label):
|
||||
self.assertEqual(_candidate_width(params, vocab), expected)
|
||||
|
||||
def test_seeded_row_varies_with_position(self):
|
||||
"""Positions feed the hash, so a fixed seed must not freeze the
|
||||
distribution across steps: over many positions the sampled tokens
|
||||
must not all collapse to one value (vocab of near-uniform probs)."""
|
||||
logits = mx.zeros((1, self.VOCAB)) # uniform distribution
|
||||
seeded = [_params(seed=77)]
|
||||
toks = set()
|
||||
for pos in range(40):
|
||||
t = sample_tokens(
|
||||
last_logits=logits,
|
||||
params=seeded,
|
||||
positions=[pos],
|
||||
key=mx.random.key(0),
|
||||
)
|
||||
mx.eval(t)
|
||||
toks.add(int(t[0].item()))
|
||||
self.assertGreater(len(toks), 5, toks)
|
||||
|
||||
def test_seeded_noise_is_finite(self):
|
||||
"""The uniform draw is clamped to [2**-32, 1 - 2**-24] before the
|
||||
double log: uint32(0xFFFFFFFF) rounds UP to 2**32 in float32, so an
|
||||
unclamped u can exceed 1 and make log(-log u) NaN — and u == 1 gives
|
||||
+inf, which would deterministically force that token."""
|
||||
# Sanity-check the hazard the clamp exists for.
|
||||
u_max = mx.array([0xFFFFFFFF], dtype=mx.uint32).astype(mx.float32) / float(
|
||||
0xFFFFFFFF
|
||||
)
|
||||
mx.eval(u_max)
|
||||
self.assertGreaterEqual(float(u_max.item()), 1.0)
|
||||
self.assertFalse(bool(mx.isfinite(-mx.log(-mx.log(u_max))).item()))
|
||||
|
||||
noise = _gumbel_noise(
|
||||
params=[_params(seed=1), _params(seed=2**63 - 1)],
|
||||
positions=[0, 4096],
|
||||
shape=(2, 1 << 16),
|
||||
key=mx.random.key(0),
|
||||
)
|
||||
mx.eval(noise)
|
||||
self.assertTrue(bool(mx.all(mx.isfinite(noise)).item()))
|
||||
|
||||
def test_seed_with_min_p_is_supported(self):
|
||||
"""seed + min_p is well defined here (the pytorch backend asserts on
|
||||
the combination): Gumbel-max over unnormalized masked weights is
|
||||
invariant to the missing renormalization, which is exactly the TODO
|
||||
at layers/sampler.py's multinomial_with_seed path."""
|
||||
logits = self._logits(1, key_int=4)
|
||||
support = self._reference_support(self._probs(logits), 1 << 30, 1.0, 0.3)
|
||||
tok = sample_tokens(
|
||||
last_logits=logits,
|
||||
params=[_params(seed=1234, min_p=0.3)],
|
||||
positions=[9],
|
||||
key=mx.random.key(0),
|
||||
)
|
||||
mx.eval(tok)
|
||||
self.assertIn(int(tok[0].item()), support)
|
||||
|
||||
def test_temperature_sharpens_distribution(self):
|
||||
"""Lower temperature must concentrate mass on the argmax token —
|
||||
guards the per-row temperature division (e.g. broadcasting bugs
|
||||
that apply one row's temperature to all rows)."""
|
||||
logits = self._logits(2, key_int=6)
|
||||
expected0 = int(mx.argmax(logits[0]).item())
|
||||
params = [_params(temperature=0.05), _params(temperature=5.0)]
|
||||
counters = self._draw(logits, params, n=200)
|
||||
self.assertGreater(counters[0][expected0] / 200.0, 0.95)
|
||||
self.assertGreater(len(counters[1]), 5, "high temp should spread mass")
|
||||
|
||||
def test_greedy_helpers(self):
|
||||
self.assertTrue(all_greedy([GREEDY_PARAMS, _params(top_k=1)]))
|
||||
self.assertFalse(all_greedy([GREEDY_PARAMS, _params(temperature=0.9)]))
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||
class TestSanitizeAndLogprobs(CustomTestCase):
|
||||
VOCAB = 16
|
||||
|
||||
def test_sanitize_matches_nan_to_num_semantics(self):
|
||||
"""Guards the port of sanitize_nan_logits' exact replacement values
|
||||
(+-1e30, not dtype extremes — temperature division would overflow
|
||||
dtype extremes back to inf and softmax them to NaN)."""
|
||||
import struct
|
||||
|
||||
def f32(v):
|
||||
return struct.unpack("f", struct.pack("f", v))[0]
|
||||
|
||||
x = mx.array([[1.0, float("nan"), float("inf"), -float("inf")]])
|
||||
out = sanitize_logits(x)
|
||||
mx.eval(out)
|
||||
self.assertEqual(out.tolist(), [[1.0, f32(-1e30), f32(1e30), f32(-1e30)]])
|
||||
|
||||
def test_logprobs_match_reference_and_row_shapes(self):
|
||||
"""compute_logprobs must equal log_softmax(logits/temp) per row and
|
||||
cut top-k / token-ids to each row's requested shape — guards the
|
||||
per-row temperature broadcast and the spec row alignment."""
|
||||
import math
|
||||
|
||||
logits = mx.random.normal(shape=(2, self.VOCAB), key=mx.random.key(11))
|
||||
params = [_params(temperature=0.5), _params(temperature=2.0)]
|
||||
tokens = mx.array([3, 7], dtype=mx.uint32)
|
||||
spec = MlxLogprobSpec(top_ks=(2, 0), token_ids=(None, (1, 4)))
|
||||
lp = compute_logprobs(logits, params, tokens, spec)
|
||||
mx.eval(*[a for a in [lp.chosen, lp.top_val, lp.top_idx] if a is not None])
|
||||
|
||||
raw = logits.tolist()
|
||||
for row, temp in ((0, 0.5), (1, 2.0)):
|
||||
scaled = [v / temp for v in raw[row]]
|
||||
m = max(scaled)
|
||||
lse = m + math.log(sum(math.exp(v - m) for v in scaled))
|
||||
ref = [v - lse for v in scaled]
|
||||
chosen_token = int(tokens[row].item())
|
||||
self.assertAlmostEqual(
|
||||
float(lp.chosen[row].item()), ref[chosen_token], places=4
|
||||
)
|
||||
if row == 0:
|
||||
expect_top = sorted(ref, reverse=True)[:2]
|
||||
got = lp.top_val[row].tolist()[:2]
|
||||
for a, b in zip(got, expect_top):
|
||||
self.assertAlmostEqual(a, b, places=4)
|
||||
if row == 1:
|
||||
got = lp.token_ids_val[1].tolist()
|
||||
self.assertAlmostEqual(got[0], ref[1], places=4)
|
||||
self.assertAlmostEqual(got[1], ref[4], places=4)
|
||||
self.assertIsNone(lp.token_ids_val[0])
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||
class TestRunnerSelectTokens(CustomTestCase):
|
||||
"""_select_tokens_with_logprobs lifecycle on a bare runner (object.__new__)."""
|
||||
|
||||
class _FakeCache:
|
||||
def __init__(self, offset):
|
||||
self.offset = offset
|
||||
|
||||
class _FakeLayout:
|
||||
has_auxiliary_state = False
|
||||
first_attention_layer_index = 0
|
||||
|
||||
def _runner(self, enable_sampling):
|
||||
from sglang.srt.hardware_backend.mlx.model_runner import MlxModelRunner
|
||||
|
||||
runner = object.__new__(MlxModelRunner)
|
||||
runner._enable_sampling = enable_sampling
|
||||
runner._cache_layout = self._FakeLayout()
|
||||
runner._req_sampling = {}
|
||||
runner._rng_key = mx.random.key(0) if enable_sampling else None
|
||||
return runner
|
||||
|
||||
def test_disabled_and_greedy_paths_consume_no_rng(self):
|
||||
"""Flag-off and all-greedy batches must return exact argmax and
|
||||
leave the RNG key untouched — guards the byte-exact greedy
|
||||
contract that the e2e temp=0 test relies on."""
|
||||
logits = mx.random.normal(shape=(2, 16), key=mx.random.key(1))
|
||||
expected = mx.argmax(logits, axis=-1).tolist()
|
||||
caches = [[self._FakeCache(4)], [self._FakeCache(9)]]
|
||||
|
||||
disabled = self._runner(enable_sampling=False)
|
||||
toks = disabled._select_tokens_with_logprobs(logits, ["a", "b"], caches)[0]
|
||||
self.assertEqual(toks.tolist(), expected)
|
||||
|
||||
enabled = self._runner(enable_sampling=True)
|
||||
enabled._req_sampling = {"a": GREEDY_PARAMS, "b": _params(top_k=1)}
|
||||
key_before = enabled._rng_key
|
||||
toks = enabled._select_tokens_with_logprobs(logits, ["a", "b"], caches)[0]
|
||||
self.assertEqual(toks.tolist(), expected)
|
||||
self.assertIs(enabled._rng_key, key_before)
|
||||
|
||||
def test_discarded_chunk_without_trunk_stays_greedy_and_consumes_no_rng(self):
|
||||
"""A needs_logits=False chunk on a model without a headless trunk
|
||||
must not sample: consuming RNG for a discarded token would make
|
||||
the final output depend on prefill chunking."""
|
||||
|
||||
def full_model_only(input_ids, cache=None):
|
||||
return mx.zeros((1, input_ids.shape[1], 16))
|
||||
|
||||
runner = self._runner(enable_sampling=True)
|
||||
runner.model = full_model_only # no .model attr -> no trunk
|
||||
runner._req_sampling = {"a": _params(temperature=1.0)}
|
||||
key_before = runner._rng_key
|
||||
tok, lazy_logprobs = runner._forward_lazy_token(
|
||||
mx.array([[3, 4]], dtype=mx.int32),
|
||||
[self._FakeCache(2)],
|
||||
needs_logits=False,
|
||||
req_id="a",
|
||||
)
|
||||
self.assertIsNone(lazy_logprobs)
|
||||
mx.eval(tok)
|
||||
self.assertEqual(tok.tolist(), [0]) # argmax of zeros
|
||||
self.assertIs(runner._rng_key, key_before)
|
||||
|
||||
def test_logit_edits_gate_greedy_and_sampled_and_logprobs(self):
|
||||
"""An additive -inf edit row must exclude a token from greedy argmax,
|
||||
from sampling, AND from the reported logprob distribution — guards
|
||||
the edits-before-selection ordering (a regression that samples raw
|
||||
logits would pass every other test on near-uniform inputs)."""
|
||||
runner = self._runner(enable_sampling=True)
|
||||
runner._req_sampling = {"g": GREEDY_PARAMS, "s": _params(temperature=1.0)}
|
||||
caches = [[self._FakeCache(4)], [self._FakeCache(4)]]
|
||||
logits = mx.zeros((2, 8))
|
||||
logits = mx.put_along_axis(
|
||||
logits,
|
||||
mx.array([[7], [7]], dtype=mx.uint32),
|
||||
mx.array([[5.0], [5.0]]),
|
||||
axis=-1,
|
||||
) # token 7 dominates both rows
|
||||
edits = mx.zeros((2, 8))
|
||||
edits = mx.put_along_axis(
|
||||
edits,
|
||||
mx.array([[7], [7]], dtype=mx.uint32),
|
||||
mx.array([[-float("inf")], [-float("inf")]]),
|
||||
axis=-1,
|
||||
) # ...but is masked out for both
|
||||
spec = MlxLogprobSpec(top_ks=(1, 1), token_ids=(None, None))
|
||||
for _ in range(10):
|
||||
tokens, lp = runner._select_tokens_with_logprobs(
|
||||
logits, ["g", "s"], caches, edits, spec
|
||||
)
|
||||
mx.eval(tokens, lp.chosen, lp.top_val)
|
||||
self.assertNotIn(7, tokens.tolist())
|
||||
self.assertNotIn(7, [row[0] for row in lp.top_idx.tolist()])
|
||||
|
||||
def test_seed_is_gated_on_deterministic_inference(self):
|
||||
"""Upstream seed contract: SamplingBatchInfo only populates
|
||||
sampling_seed under --enable-deterministic-inference, and then seeds
|
||||
every row (default 42). A per-request seed outside that flag is
|
||||
ignored by every other backend, so it is ignored here too."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
def make_req(sampling_seed):
|
||||
return SimpleNamespace(
|
||||
sampling_params=SimpleNamespace(
|
||||
temperature=0.8,
|
||||
top_k=1 << 30,
|
||||
top_p=1.0,
|
||||
min_p=0.0,
|
||||
sampling_seed=sampling_seed,
|
||||
frequency_penalty=0.0,
|
||||
presence_penalty=0.0,
|
||||
repetition_penalty=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIsNone(MlxSamplingParams.from_req(make_req(7)).seed)
|
||||
self.assertIsNone(
|
||||
MlxSamplingParams.from_req(make_req(None), deterministic_seeding=False).seed
|
||||
)
|
||||
self.assertEqual(
|
||||
MlxSamplingParams.from_req(make_req(7), deterministic_seeding=True).seed, 7
|
||||
)
|
||||
self.assertEqual(
|
||||
MlxSamplingParams.from_req(make_req(None), deterministic_seeding=True).seed,
|
||||
DEFAULT_SAMPLING_SEED,
|
||||
)
|
||||
|
||||
def test_chained_decode_keeps_logit_bias(self):
|
||||
"""A chained decode step must keep applying the batch's static
|
||||
logit_bias rows — regression: the chained path passed edits=None,
|
||||
silently dropping the bias after the first (fresh) step."""
|
||||
runner = self._runner(enable_sampling=True)
|
||||
runner._req_sampling = {"a": GREEDY_PARAMS}
|
||||
runner._req_caches = {"a": [self._FakeCache(3)]}
|
||||
runner._req_token_ids = {"a": [1]}
|
||||
# token 2 dominates; the edit row bans it -> argmax must fall to 1
|
||||
logits = mx.array([[0.0, 3.0, 5.0, 0.0]])
|
||||
runner._decode_with_batched_attention = lambda caches, x, rids: logits
|
||||
edits = mx.array([[0.0, 0.0, -float("inf"), 0.0]])
|
||||
|
||||
fresh = runner.decode_batch_start(["a"], edit_rows=edits)
|
||||
chained = runner.decode_batch_start_chained(fresh)
|
||||
mx.eval(fresh.lazy_tokens, chained.lazy_tokens)
|
||||
self.assertEqual(fresh.lazy_tokens.tolist(), [1])
|
||||
self.assertEqual(chained.lazy_tokens.tolist(), [1])
|
||||
|
||||
def test_logits_hook_bridge_roundtrip(self):
|
||||
"""The custom-logit-processor hook must see materialized float32
|
||||
logits and its in-place edits must re-enter the graph — guards the
|
||||
mx->numpy->mx bridge (a copy-semantics change would drop edits)."""
|
||||
runner = self._runner(enable_sampling=True)
|
||||
logits = mx.zeros((1, 8), dtype=mx.bfloat16)
|
||||
|
||||
def hook(arr):
|
||||
assert arr.dtype.name == "float32"
|
||||
arr[0, 5] = 99.0
|
||||
return arr
|
||||
|
||||
edited = runner._run_logits_hook(logits, hook)
|
||||
mx.eval(edited)
|
||||
self.assertEqual(int(mx.argmax(edited, axis=-1)[0].item()), 5)
|
||||
|
||||
def test_sampling_path_advances_rng_key(self):
|
||||
"""Consecutive sampling builds must consume distinct keys, or every
|
||||
chained decode step would draw identical noise."""
|
||||
logits = mx.zeros((1, 16))
|
||||
runner = self._runner(enable_sampling=True)
|
||||
runner._req_sampling = {"a": _params(temperature=1.0)}
|
||||
caches = [[self._FakeCache(3)]]
|
||||
toks = set()
|
||||
for _ in range(20):
|
||||
t = runner._select_tokens_with_logprobs(logits, ["a"], caches)[0]
|
||||
mx.eval(t)
|
||||
toks.add(int(t[0].item()))
|
||||
self.assertGreater(len(toks), 3, toks)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||
class TestWorkerSamplingExtras(CustomTestCase):
|
||||
"""Worker-side builders: logit-edit rows, logprob specs, output assembly."""
|
||||
|
||||
VOCAB = 8
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
from sglang.srt.runtime_context import get_context
|
||||
|
||||
# The worker reads --mlx-enable-sampling off the device config bag,
|
||||
# which fails closed before a publish.
|
||||
cls._config = get_context().override_server_args(mlx_enable_sampling=True)
|
||||
cls._config.install()
|
||||
cls.addClassCleanup(cls._config.restore)
|
||||
|
||||
@staticmethod
|
||||
def _worker():
|
||||
from sglang.srt.hardware_backend.mlx.tp_worker import MlxTpModelWorker
|
||||
|
||||
return MlxTpModelWorker.__new__(MlxTpModelWorker)
|
||||
|
||||
def _batch(self, sinfo, n=2, return_logprob=False, has_grammar=False):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(
|
||||
reqs=[
|
||||
SimpleNamespace(
|
||||
rid=f"r{i}", return_logprob=return_logprob, grammar=None
|
||||
)
|
||||
for i in range(n)
|
||||
],
|
||||
sampling_info=sinfo,
|
||||
return_logprob=return_logprob,
|
||||
top_logprobs_nums=None,
|
||||
token_ids_logprobs=None,
|
||||
has_grammar=has_grammar,
|
||||
)
|
||||
|
||||
def test_edit_rows_combine_grammar_mask_and_bias(self):
|
||||
"""The grammar mask must be applied through the backend's own
|
||||
apply_vocab_mask on a zeros base and summed with logit_bias —
|
||||
guards the backend-agnostic zeros-trick, the combine order, and the
|
||||
ForwardBatch.init_new grammars-population mirror (regression: the
|
||||
MLX paths never build a ForwardBatch, so sinfo.grammars stayed None
|
||||
and live grammar objects produced no mask at all)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
class FakeGrammar:
|
||||
def apply_vocab_mask(self, logits, vocab_mask):
|
||||
logits[0, 3] = -float("inf") # row 0 forbids token 3
|
||||
|
||||
sinfo = SimpleNamespace(
|
||||
grammars=None, # not yet populated, as on the real MLX path
|
||||
logit_bias=torch.zeros(2, self.VOCAB).index_put_(
|
||||
(torch.tensor([1]), torch.tensor([5])), torch.tensor([2.5])
|
||||
),
|
||||
vocab_size=self.VOCAB,
|
||||
grammar_mask=None,
|
||||
)
|
||||
|
||||
def update_mask():
|
||||
sinfo.grammar_mask = SimpleNamespace(
|
||||
grammar=FakeGrammar(), vocab_mask=torch.zeros(2, 1)
|
||||
)
|
||||
|
||||
sinfo.update_regex_vocab_mask = update_mask
|
||||
|
||||
batch = self._batch(sinfo, has_grammar=True)
|
||||
batch.reqs[0].grammar = object()
|
||||
rows = self._worker()._build_logit_edit_rows(batch)
|
||||
self.assertEqual(
|
||||
[g is not None for g in sinfo.grammars],
|
||||
[True, False],
|
||||
"worker must mirror ForwardBatch.init_new's grammars population",
|
||||
)
|
||||
mx.eval(rows["r0"], rows["r1"])
|
||||
self.assertEqual(rows["r0"].tolist()[3], -float("inf"))
|
||||
self.assertEqual(rows["r1"].tolist()[5], 2.5)
|
||||
self.assertIsNone(sinfo.grammar_mask, "mask must be released after use")
|
||||
|
||||
def test_edit_rows_none_when_nothing_to_edit(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
sinfo = SimpleNamespace(grammars=None, logit_bias=None, vocab_size=self.VOCAB)
|
||||
self.assertIsNone(self._worker()._build_logit_edit_rows(self._batch(sinfo)))
|
||||
|
||||
def test_logprob_spec_subset_alignment(self):
|
||||
"""Spec rows must align to the rid subset order, not batch order —
|
||||
guards mixed-batch decode sub-batches."""
|
||||
from sglang.srt.hardware_backend.mlx.tp_worker import MlxTpModelWorker
|
||||
|
||||
rows = {"a": (3, None), "c": (0, (7, 9))}
|
||||
spec = MlxTpModelWorker._logprob_spec_for(rows, ["c", "b", "a"])
|
||||
self.assertEqual(spec.top_ks, (0, 0, 3))
|
||||
self.assertEqual(spec.token_ids, ((7, 9), None, None))
|
||||
self.assertIsNone(MlxTpModelWorker._logprob_spec_for(rows, ["x"]))
|
||||
|
||||
@unittest.skipUnless(
|
||||
importlib.util.find_spec("xgrammar") is not None, "requires xgrammar"
|
||||
)
|
||||
def test_xgrammar_wrapper_supports_cpu_logits(self):
|
||||
"""The MLX edit-row builder feeds CPU logits to the grammar
|
||||
backend's apply_vocab_mask — regression: the xgrammar wrapper
|
||||
raised 'Unsupported device: cpu' (its dispatch stopped at
|
||||
cuda/xpu/musa/npu), so every grammar request crashed the worker."""
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from sglang.srt.constrained.xgrammar_backend import XGrammarGrammar
|
||||
|
||||
logits = torch.zeros(1, 40)
|
||||
blocks = math.ceil(40 / 32)
|
||||
bitmask = torch.full((1, blocks), -1, dtype=torch.int32)
|
||||
bitmask[0, 0] = int(np.int32(np.uint32(0xFFFFFFFF & ~(1 << 7))))
|
||||
XGrammarGrammar.apply_vocab_mask(None, logits, bitmask)
|
||||
self.assertEqual(logits[0, 7].item(), -float("inf"))
|
||||
self.assertEqual(logits[0, 6].item(), 0.0)
|
||||
|
||||
def test_assemble_logprob_output_matches_scheduler_contract(self):
|
||||
"""Field shapes must survive the scheduler's move_logprobs_to_cpu
|
||||
(`.tolist()` on the batch tensor and on every per-row val/idx entry)
|
||||
and add_logprob_return_values indexing — guards the external
|
||||
LogitsProcessorOutput consumption contract, including rows without
|
||||
logprob requests getting empty-but-tolistable fills."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sglang.srt.hardware_backend.mlx.tp_worker import MlxTpModelWorker
|
||||
|
||||
step_rows = {"a": (-1.5, [-0.1, -0.2], [4, 2], [-3.0], [9])}
|
||||
reqs = [SimpleNamespace(rid="a"), SimpleNamespace(rid="b")]
|
||||
out = MlxTpModelWorker._assemble_logprob_output(step_rows, reqs)
|
||||
|
||||
self.assertEqual(out.next_token_logprobs.tolist(), [-1.5, 0.0])
|
||||
self.assertEqual(
|
||||
[v.tolist() for v in out.next_token_top_logprobs_val],
|
||||
[[-0.10000000149011612, -0.20000000298023224], []],
|
||||
)
|
||||
self.assertEqual(
|
||||
[v.tolist() for v in out.next_token_top_logprobs_idx], [[4, 2], []]
|
||||
)
|
||||
self.assertEqual(
|
||||
[v.tolist() for v in out.next_token_token_ids_logprobs_val],
|
||||
[[-3.0], []],
|
||||
)
|
||||
self.assertEqual(out.next_token_token_ids_logprobs_idx, [[9], []])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -129,6 +129,7 @@ class TestOverlapLoopStampsLaunchTs(unittest.TestCase):
|
||||
from sglang.srt.hardware_backend.mlx.scheduler_mixin import (
|
||||
SchedulerMlxOverlapMixin,
|
||||
)
|
||||
from sglang.srt.hardware_backend.mlx.tp_worker import MlxLaunch
|
||||
|
||||
scheduler = self._make_scheduler(recv_side_effect=[[], _StopLoop()])
|
||||
|
||||
@@ -148,7 +149,13 @@ class TestOverlapLoopStampsLaunchTs(unittest.TestCase):
|
||||
scheduler.tp_worker.async_forward_batch_generation_mlx.side_effect = (
|
||||
lambda _batch: (
|
||||
events.append("forward"),
|
||||
(None, [], [], None, "extend"),
|
||||
MlxLaunch(
|
||||
lazy_tokens=None,
|
||||
prefills=[],
|
||||
extends=[],
|
||||
decode=None,
|
||||
mode="extend",
|
||||
),
|
||||
)[1]
|
||||
)
|
||||
|
||||
@@ -173,6 +180,7 @@ class TestOverlapLoopStampsLaunchTs(unittest.TestCase):
|
||||
from sglang.srt.hardware_backend.mlx.scheduler_mixin import (
|
||||
SchedulerMlxOverlapMixin,
|
||||
)
|
||||
from sglang.srt.hardware_backend.mlx.tp_worker import MlxLaunch
|
||||
|
||||
# Iteration 1: fresh decode launch. Iteration 2: chain a second
|
||||
# decode on top of it. Iteration 3: stop.
|
||||
@@ -193,16 +201,22 @@ class TestOverlapLoopStampsLaunchTs(unittest.TestCase):
|
||||
scheduler.get_next_batch_to_run.return_value = plan
|
||||
|
||||
pending_decode = MagicMock()
|
||||
scheduler.tp_worker.async_forward_batch_generation_mlx.return_value = (
|
||||
MagicMock(),
|
||||
[],
|
||||
[],
|
||||
pending_decode,
|
||||
"decode",
|
||||
scheduler.tp_worker.async_forward_batch_generation_mlx.return_value = MlxLaunch(
|
||||
lazy_tokens=MagicMock(),
|
||||
prefills=[],
|
||||
extends=[],
|
||||
decode=pending_decode,
|
||||
mode="decode",
|
||||
)
|
||||
scheduler.tp_worker.async_chained_decode_mlx.side_effect = lambda _decode: (
|
||||
events.append("chained_forward"),
|
||||
(MagicMock(), [], [], MagicMock(), "decode"),
|
||||
MlxLaunch(
|
||||
lazy_tokens=MagicMock(),
|
||||
prefills=[],
|
||||
extends=[],
|
||||
decode=MagicMock(),
|
||||
mode="decode",
|
||||
),
|
||||
)[1]
|
||||
|
||||
launch_times = iter((1.0, 2.0))
|
||||
|
||||
@@ -7,10 +7,13 @@ pin the three seams that make such models work on the MLX backend:
|
||||
1. The attention contract accepts ``sm_scale`` and exposes per-layer window
|
||||
sizes read from the mlx-lm container convention (``layer_types`` +
|
||||
``window_size``).
|
||||
2. The cache shims' ``make_mask`` mirrors mlx_lm's
|
||||
``cache.create_attention_mask`` exactly — in particular ``window_size``
|
||||
2. The cache shims' ``make_mask`` matches mlx_lm's
|
||||
``cache.create_attention_mask`` semantically — in particular ``window_size``
|
||||
must produce a banded mask (including for N == 1) instead of being
|
||||
silently dropped, or sliding-window layers degrade to full attention.
|
||||
Where the window provably cannot bind (``offset + N <= window_size``) the
|
||||
band equals plain causal, and the shims return the cheap form instead, as
|
||||
mlx_lm's own ``RotatingKVCache.make_mask`` does.
|
||||
3. ``MLXAttentionWrapper._batched_decode`` applies the window by truncating
|
||||
each request's KV to the trailing window, passes ``sinks`` through, and
|
||||
uses the contract scale helper.
|
||||
@@ -173,7 +176,22 @@ class TestGptOssAttentionContract(CustomTestCase):
|
||||
|
||||
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||
class TestShimMakeMask(CustomTestCase):
|
||||
"""The shims must return exactly what mlx_lm's own KVCache.make_mask returns."""
|
||||
"""The shims must be semantically equal to mlx_lm's own KVCache.make_mask.
|
||||
|
||||
Equal *content*, not equal representation: where the window provably
|
||||
cannot bind the shims return the cheap ``"causal"`` / ``None`` form that
|
||||
mlx_lm's RotatingKVCache.make_mask also returns, so the comparison
|
||||
densifies both sides first.
|
||||
"""
|
||||
|
||||
def _dense(self, mask, N, offset):
|
||||
"""Dense boolean form of any of the three mask representations."""
|
||||
if mask is None:
|
||||
return mx.ones((N, offset + N), dtype=mx.bool_)
|
||||
if isinstance(mask, str):
|
||||
self.assertEqual(mask, "causal")
|
||||
return create_causal_mask(N, offset)
|
||||
return mask
|
||||
|
||||
def _shims(self, offset):
|
||||
contig = ContiguousAttentionKVCache(
|
||||
@@ -185,14 +203,13 @@ class TestShimMakeMask(CustomTestCase):
|
||||
)
|
||||
return (AttentionOffsetCache(offset=offset), contig, pool_backed)
|
||||
|
||||
def _assert_same_mask(self, got, ref, msg):
|
||||
if ref is None or isinstance(ref, str):
|
||||
self.assertEqual(got, ref, msg)
|
||||
else:
|
||||
self.assertTrue(
|
||||
isinstance(got, mx.array) and mx.array_equal(got, ref).item(),
|
||||
msg,
|
||||
)
|
||||
def _assert_same_mask(self, got, ref, msg, N, offset):
|
||||
self.assertTrue(
|
||||
mx.array_equal(
|
||||
self._dense(got, N, offset), self._dense(ref, N, offset)
|
||||
).item(),
|
||||
msg,
|
||||
)
|
||||
|
||||
def test_shims_match_mlx_lm_reference(self):
|
||||
cases = [
|
||||
@@ -213,8 +230,34 @@ class TestShimMakeMask(CustomTestCase):
|
||||
ref,
|
||||
f"{type(shim).__name__} mismatch for N={N} offset={offset} "
|
||||
f"window={window} return_array={return_array}",
|
||||
N,
|
||||
offset,
|
||||
)
|
||||
|
||||
def test_non_binding_window_returns_the_cheap_mask(self):
|
||||
# offset + N <= window: no query can reach past the window, so the
|
||||
# band equals plain causal and materialising it only costs time (a
|
||||
# mask array forces sdpa off its fused causal path, ~2x per layer).
|
||||
self.assertIsNone(make_attention_mask(1, 0, window_size=4))
|
||||
self.assertIsNone(make_attention_mask(1, 3, window_size=4))
|
||||
self.assertEqual(make_attention_mask(4, 0, window_size=4), "causal")
|
||||
# ...and one position past the boundary the band is required again.
|
||||
self.assertIsInstance(make_attention_mask(4, 1, window_size=4), mx.array)
|
||||
self.assertIsInstance(make_attention_mask(1, 4, window_size=4), mx.array)
|
||||
|
||||
def test_non_binding_window_matches_the_band_it_replaces(self):
|
||||
# The shortcut is only legal because the two forms are elementwise
|
||||
# identical; pin that against mlx_lm's own band builder.
|
||||
for N, offset, window in ((1, 0, 4), (1, 3, 4), (4, 0, 4), (8, 0, 16)):
|
||||
band = create_causal_mask(N, offset, window_size=window)
|
||||
cheap = self._dense(
|
||||
make_attention_mask(N, offset, window_size=window), N, offset
|
||||
)
|
||||
self.assertTrue(
|
||||
mx.array_equal(band, cheap).item(),
|
||||
f"N={N} offset={offset} window={window}",
|
||||
)
|
||||
|
||||
def test_windowed_mask_is_banded_including_self(self):
|
||||
# Query at absolute position 6 with W=4 may attend to keys 3..6
|
||||
# (j in [i - W + 1, i], the window includes the query itself).
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Unit tests for sliding-window layers on the MLX radix/pool KV path.
|
||||
|
||||
The shared ``MlxAttentionKVPool`` stores full-attention layers only,
|
||||
sliding-window layers keep window-bounded per-request storage, and a
|
||||
radix prefix hit on an SWA model recomputes the whole prefix. Scheduler
|
||||
bookkeeping stays in the unclamped coordinates, so these tests drive
|
||||
``MlxModelRunner`` directly with hand-built slot ids, mirroring how the
|
||||
tp_worker calls it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import unittest
|
||||
|
||||
from sglang.test.ci.ci_register import register_cpu_ci, register_mlx_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=10, suite="base-a-test-cpu")
|
||||
register_mlx_ci(est_time=10, suite="stage-a-unit-test-mlx")
|
||||
|
||||
_HAS_MLX = (
|
||||
importlib.util.find_spec("mlx") is not None
|
||||
and importlib.util.find_spec("mlx_lm") is not None
|
||||
)
|
||||
_SKIP_REASON = "requires mlx + mlx_lm"
|
||||
|
||||
if _HAS_MLX:
|
||||
import mlx.core as mx
|
||||
from mlx_lm.models import gpt_oss
|
||||
|
||||
from sglang.srt.hardware_backend.mlx.aot import (
|
||||
MlxAOTKernelContext,
|
||||
MlxAOTKernelSet,
|
||||
MlxAOTRoPEContext,
|
||||
MlxAOTRoPEKernel,
|
||||
)
|
||||
from sglang.srt.hardware_backend.mlx.kv_cache import (
|
||||
BatchedDecodeContext,
|
||||
ContiguousAttentionKVCache,
|
||||
MlxAttentionKVPool,
|
||||
MLXAttentionWrapper,
|
||||
WindowedAttentionKVCache,
|
||||
find_attention_layers,
|
||||
get_layer_window_sizes,
|
||||
patch_model_attention,
|
||||
)
|
||||
from sglang.srt.hardware_backend.mlx.kv_cache.layout import MlxModelCacheLayout
|
||||
from sglang.srt.hardware_backend.mlx.model_runner import MlxModelRunner
|
||||
|
||||
TINY_WINDOW = 8
|
||||
|
||||
|
||||
def _tiny_gpt_oss_model():
|
||||
"""Randomly initialized 4-layer gpt_oss with alternating sliding/full layers.
|
||||
|
||||
Mirrors test_windowed_kv_cache.py's builder (kept local: the registered
|
||||
unit-test directory is not an importable package).
|
||||
"""
|
||||
args = gpt_oss.ModelArgs(
|
||||
num_hidden_layers=4,
|
||||
num_local_experts=8,
|
||||
num_experts_per_tok=2,
|
||||
vocab_size=128,
|
||||
hidden_size=64,
|
||||
intermediate_size=64,
|
||||
head_dim=16,
|
||||
num_attention_heads=4,
|
||||
num_key_value_heads=2,
|
||||
sliding_window=TINY_WINDOW,
|
||||
rope_theta=150000,
|
||||
rope_scaling={
|
||||
"rope_type": "yarn",
|
||||
"factor": 32.0,
|
||||
"beta_fast": 32.0,
|
||||
"beta_slow": 1.0,
|
||||
"original_max_position_embeddings": 4096,
|
||||
"truncate": False,
|
||||
},
|
||||
)
|
||||
return gpt_oss.Model(args)
|
||||
|
||||
|
||||
def _stub_runner(model, disable_radix_cache, pool_size=64):
|
||||
"""Surgically build a runner around an already-loaded tiny model."""
|
||||
layers, attrs = find_attention_layers(model)
|
||||
runner = MlxModelRunner.__new__(MlxModelRunner)
|
||||
runner.model = model
|
||||
runner.disable_radix_cache = disable_radix_cache
|
||||
runner._cache_layout = MlxModelCacheLayout.from_attention_discovery(
|
||||
layers, attrs, layer_window_sizes=get_layer_window_sizes(model)
|
||||
)
|
||||
runner._max_seq_len = 64
|
||||
runner._cache_pool = []
|
||||
runner._req_caches = {}
|
||||
runner._req_token_ids = {}
|
||||
runner._req_sampling = {}
|
||||
runner._req_pool_idx = {}
|
||||
runner._req_synced_offset = {}
|
||||
runner._req_to_token_pool = None
|
||||
runner._attention_kv_pool = None
|
||||
runner._decode_step_ct = 0
|
||||
runner._clear_steps = 0
|
||||
runner._aot_kernels = MlxAOTKernelSet()
|
||||
runner._pool_size = pool_size
|
||||
if not disable_radix_cache:
|
||||
runner.init_cache_pools(None)
|
||||
return runner
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||
class TestSwaLayoutAndPoolContract(CustomTestCase):
|
||||
def _layout(self, with_windows=True):
|
||||
model = _tiny_gpt_oss_model()
|
||||
layers, attrs = find_attention_layers(model)
|
||||
return MlxModelCacheLayout.from_attention_discovery(
|
||||
layers, attrs, get_layer_window_sizes(model) if with_windows else None
|
||||
)
|
||||
|
||||
def test_partition_and_dense_full_pool_index(self):
|
||||
layout = self._layout()
|
||||
self.assertEqual(layout.attention_layer_indices, (0, 1, 2, 3))
|
||||
self.assertEqual(layout.swa_attention_layer_indices, (0, 2))
|
||||
self.assertEqual(layout.full_attention_layer_indices, (1, 3))
|
||||
# Dense over full layers only, so it differs from the cache index.
|
||||
self.assertEqual(layout.full_kv_pool_index_by_layer, {1: 0, 3: 1})
|
||||
self.assertEqual(layout.attention_pool_index_by_layer, {0: 0, 1: 1, 2: 2, 3: 3})
|
||||
with self.assertRaises(KeyError):
|
||||
layout.full_kv_pool_index(0)
|
||||
|
||||
# Without a window map the two indices coincide (pre-SWA behavior).
|
||||
plain = self._layout(with_windows=False)
|
||||
self.assertFalse(plain.has_sliding_window_layers)
|
||||
self.assertEqual(
|
||||
plain.full_kv_pool_index_by_layer, plain.attention_pool_index_by_layer
|
||||
)
|
||||
|
||||
def test_sliding_window_model_gets_no_pool(self):
|
||||
# An SWA prefix hit recomputes the prefix instead of gathering it, so
|
||||
# the shared pool would have no reader. Allocating it would burn the
|
||||
# whole auto-sized KV budget on a write-only buffer.
|
||||
runner = _stub_runner(_tiny_gpt_oss_model(), disable_radix_cache=False)
|
||||
self.assertTrue(runner._cache_layout.has_sliding_window_layers)
|
||||
self.assertIsNone(runner._attention_kv_pool)
|
||||
# The layer-type split still resolves -- it is the seam the shared
|
||||
# window-aware SWA pool will build on.
|
||||
self.assertEqual(runner._cache_layout.full_kv_pool_index_by_layer, {1: 0, 3: 1})
|
||||
|
||||
def test_pool_covers_every_layer_without_sliding_windows(self):
|
||||
model = _tiny_gpt_oss_model()
|
||||
layers, attrs = find_attention_layers(model)
|
||||
runner = _stub_runner(model, disable_radix_cache=True)
|
||||
runner._cache_layout = MlxModelCacheLayout.from_attention_discovery(
|
||||
layers, attrs
|
||||
)
|
||||
runner.disable_radix_cache = False
|
||||
runner.init_cache_pools(None)
|
||||
self.assertEqual(runner._attention_kv_pool.num_layers, 4)
|
||||
self.assertEqual(runner._attention_kv_pool.pool_size, 65)
|
||||
|
||||
def test_all_sliding_window_model_gets_no_pool(self):
|
||||
# An all-SWA model has nothing to pool. Pool construction must skip
|
||||
# out, and pool sizing must still land on a finite slot count rather
|
||||
# than dividing by zero bytes per slot.
|
||||
runner = _stub_runner(_tiny_gpt_oss_model(), disable_radix_cache=False)
|
||||
layers, attrs = find_attention_layers(runner.model)
|
||||
runner._cache_layout = MlxModelCacheLayout.from_attention_discovery(
|
||||
layers, attrs, {idx: TINY_WINDOW for idx in range(4)}
|
||||
)
|
||||
self.assertEqual(runner._cache_layout.full_attention_layer_indices, ())
|
||||
self.assertEqual(runner._cache_layout.full_kv_pool_index_by_layer, {})
|
||||
|
||||
runner._attention_kv_pool = None
|
||||
runner.init_cache_pools(None)
|
||||
self.assertIsNone(runner._attention_kv_pool)
|
||||
|
||||
runner._mem_fraction_static = 0.5
|
||||
self.assertGreater(runner._compute_pool_size(None), 0)
|
||||
|
||||
def test_sliding_flag_without_window_map_still_rejected(self):
|
||||
model = _tiny_gpt_oss_model()
|
||||
patch_model_attention(model)
|
||||
model.model.layers[0].self_attn._inner.is_sliding = True
|
||||
runner = _stub_runner(model, disable_radix_cache=True)
|
||||
# With the container window map the flagged layer is bounded: fine.
|
||||
runner._get_attn_config()
|
||||
# Without a resolvable window the layer cannot be bounded: reject.
|
||||
layers, attrs = find_attention_layers(model)
|
||||
runner._cache_layout = MlxModelCacheLayout.from_attention_discovery(
|
||||
layers, attrs
|
||||
)
|
||||
with self.assertRaises(NotImplementedError):
|
||||
runner._get_attn_config()
|
||||
|
||||
def test_sync_writes_full_layers_only(self):
|
||||
runner = _stub_runner(_tiny_gpt_oss_model(), disable_radix_cache=False)
|
||||
# init_cache_pools skips the pool on an SWA model (see
|
||||
# test_sliding_window_model_gets_no_pool), so attach one by hand: the
|
||||
# layer-type filtering in _sync_new_kv_to_pool is what the shared
|
||||
# window-aware SWA pool will rely on, and it must stay correct.
|
||||
self.assertIsNone(runner._attention_kv_pool)
|
||||
runner._attention_kv_pool = MlxAttentionKVPool(
|
||||
pool_size=runner._pool_size + 1,
|
||||
num_layers=runner._cache_layout.num_full_attention_layers,
|
||||
n_kv_heads=2,
|
||||
head_dim=16,
|
||||
dtype=mx.float32,
|
||||
)
|
||||
cache = runner._new_native_cache()
|
||||
per_layer_k = {}
|
||||
for layer_idx in range(4):
|
||||
k = mx.full((1, 2, 5, 16), float(layer_idx + 1))
|
||||
cache[layer_idx].update_and_fetch(k, -k)
|
||||
per_layer_k[layer_idx] = k
|
||||
slot_ids = [7, 9, 11]
|
||||
runner._sync_new_kv_to_pool(cache, cache_start=2, slot_ids=slot_ids)
|
||||
for layer_idx, pool_idx in ((1, 0), (3, 1)):
|
||||
got_k, got_v = runner._attention_kv_pool.get_kv(
|
||||
pool_idx, mx.array(slot_ids, dtype=mx.int32)
|
||||
)
|
||||
want = per_layer_k[layer_idx][0, :, 2:5, :].transpose(1, 0, 2)
|
||||
self.assertTrue(mx.array_equal(got_k, want).item())
|
||||
self.assertTrue(mx.array_equal(got_v, -want).item())
|
||||
# Untouched pool slots stay zero (nothing wrote outside the slots).
|
||||
rest_k, _ = runner._attention_kv_pool.get_kv(
|
||||
0, mx.array([1, 2, 3], dtype=mx.int32)
|
||||
)
|
||||
self.assertEqual(mx.abs(rest_k).max().item(), 0.0)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||
class TestSwaRadixPath(CustomTestCase):
|
||||
"""Radix-path prefill/decode is token-identical to the per-request path.
|
||||
|
||||
Both runners share one tiny gpt-oss (same random weights). The
|
||||
reference runner uses the ``disable_radix_cache`` per-request path
|
||||
pinned by test_windowed_kv_cache.py; the radix runner replays the
|
||||
same requests through pool sync, prefix hits, and prefix recomputes.
|
||||
"""
|
||||
|
||||
DECODE_STEPS = 6
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
mx.random.seed(7)
|
||||
cls.model = _tiny_gpt_oss_model()
|
||||
patch_model_attention(cls.model)
|
||||
|
||||
def setUp(self):
|
||||
self.reference = _stub_runner(self.model, disable_radix_cache=True)
|
||||
self.radix = _stub_runner(self.model, disable_radix_cache=False)
|
||||
|
||||
def _greedy(self, runner, rid, full_ids, new_ids, prefix_slots, new_slots):
|
||||
tokens = [
|
||||
runner.prefill(
|
||||
req_id=rid,
|
||||
new_token_ids=list(new_ids),
|
||||
full_token_ids=list(full_ids),
|
||||
prefix_slot_ids=list(prefix_slots),
|
||||
new_slot_ids=list(new_slots),
|
||||
req_pool_idx=0,
|
||||
)
|
||||
]
|
||||
for _ in range(self.DECODE_STEPS):
|
||||
tokens.extend(runner.decode_batch([rid]))
|
||||
return tokens
|
||||
|
||||
def _reference_stream(self, prompt):
|
||||
tokens = self._greedy(
|
||||
self.reference, "ref", prompt, prompt, prefix_slots=(), new_slots=()
|
||||
)
|
||||
self.reference.remove_request("ref")
|
||||
return tokens
|
||||
|
||||
def _seed(self, prompt, slots):
|
||||
self._greedy(self.radix, "seed", prompt, prompt, (), slots)
|
||||
self.radix.remove_request("seed")
|
||||
|
||||
def _assert_windowed_bounded(self, rid):
|
||||
cache = self.radix._req_caches[rid]
|
||||
for layer_idx in self.radix._cache_layout.swa_attention_layer_indices:
|
||||
entry = cache[layer_idx]
|
||||
self.assertIsInstance(entry, WindowedAttentionKVCache)
|
||||
self.assertLessEqual(entry.get_kv()[0].shape[2], 2 * TINY_WINDOW)
|
||||
|
||||
def test_cold_prefill_matches_reference(self):
|
||||
prompt = [(i * 7 + 3) % 128 for i in range(20)]
|
||||
want = self._reference_stream(prompt)
|
||||
got = self._greedy(
|
||||
self.radix, "cold", prompt, prompt, (), range(1, len(prompt) + 1)
|
||||
)
|
||||
self.assertEqual(got, want)
|
||||
self._assert_windowed_bounded("cold")
|
||||
|
||||
def test_partial_prefix_hit_recomputes_exactly(self):
|
||||
# Prefix (20) is well past the window (8): the hit must recompute the
|
||||
# whole prefix rather than gather it, a chunked extend continues on
|
||||
# top, and the stream must match one cold reference over the same
|
||||
# tokens with every cache left at the unclamped absolute position.
|
||||
prefix = [(i * 7 + 3) % 128 for i in range(20)]
|
||||
chunk_a, chunk_b = [9, 42, 77, 5], [11, 13, 17]
|
||||
prefix_slots = list(range(1, len(prefix) + 1))
|
||||
self._seed(prefix, prefix_slots)
|
||||
want = self._reference_stream(prefix + chunk_a + chunk_b)
|
||||
|
||||
gathers = []
|
||||
self.radix._cache_with_pool_backed_attention = lambda slots, n: gathers.append(
|
||||
n
|
||||
)
|
||||
self.radix.prefill(
|
||||
req_id="hit",
|
||||
new_token_ids=chunk_a,
|
||||
full_token_ids=prefix + chunk_a,
|
||||
prefix_slot_ids=prefix_slots,
|
||||
new_slot_ids=list(range(30, 34)),
|
||||
req_pool_idx=0,
|
||||
)
|
||||
self.assertEqual(gathers, [], "SWA prefix hits must recompute, not gather")
|
||||
|
||||
got = [self.radix.extend("hit", chunk_b, list(range(34, 37)))]
|
||||
for _ in range(self.DECODE_STEPS):
|
||||
got.extend(self.radix.decode_batch(["hit"]))
|
||||
self.assertEqual(got, want)
|
||||
self._assert_windowed_bounded("hit")
|
||||
expected = len(prefix + chunk_a + chunk_b) + self.DECODE_STEPS
|
||||
for layer_idx in range(4):
|
||||
self.assertEqual(self.radix._req_caches["hit"][layer_idx].offset, expected)
|
||||
|
||||
def test_full_prefix_hit_without_new_tokens(self):
|
||||
# An exact hit leaves no extend tokens: the prefix rebuild supplies
|
||||
# run tokens ending on the last prefix token, whose logits predict
|
||||
# the next token.
|
||||
prompt = [(i * 5 + 11) % 128 for i in range(20)]
|
||||
prefix_slots = list(range(1, len(prompt) + 1))
|
||||
self._seed(prompt, prefix_slots)
|
||||
want = self._reference_stream(prompt)
|
||||
got = self._greedy(self.radix, "exact", prompt, [], prefix_slots, ())
|
||||
self.assertEqual(got, want)
|
||||
|
||||
def test_fused_aot_kernel_serves_full_layers_by_full_pool_index(self):
|
||||
# The fused RoPE+pool-scatter kernel must skip sliding-window layers
|
||||
# and address the pool by the full-attention index, not the cache one.
|
||||
sliding_wrapper = self.model.model.layers[0].self_attn
|
||||
full_wrapper = self.model.model.layers[1].self_attn
|
||||
|
||||
recorded = []
|
||||
original = MLXAttentionWrapper._rope_custom_aot
|
||||
|
||||
def _recording_rope(queries, keys, values, positions, pool_idx, rope_ctx):
|
||||
recorded.append(pool_idx)
|
||||
return queries, keys
|
||||
|
||||
MLXAttentionWrapper._rope_custom_aot = staticmethod(_recording_rope)
|
||||
try:
|
||||
win = WindowedAttentionKVCache(TINY_WINDOW)
|
||||
contig = ContiguousAttentionKVCache(
|
||||
n_kv_heads=2, head_dim=16, max_seq_len=32, dtype=mx.float32
|
||||
)
|
||||
ctx = BatchedDecodeContext(
|
||||
batch_size=1,
|
||||
seq_lens=[0],
|
||||
attention_layer_caches=[[win], [contig]],
|
||||
attention_pool_index_by_layer={0: 0, 1: 1},
|
||||
full_kv_pool_index_by_layer={1: 0},
|
||||
aot=MlxAOTKernelContext(
|
||||
rope=MlxAOTRoPEContext(kernel=MlxAOTRoPEKernel(), kv_pool=None)
|
||||
),
|
||||
)
|
||||
x = mx.random.normal((1, 1, 64))
|
||||
mx.eval(sliding_wrapper._batched_decode(x, ctx))
|
||||
self.assertEqual(recorded, [], "SWA layer must not hit the fused kernel")
|
||||
mx.eval(full_wrapper._batched_decode(x, ctx))
|
||||
# Cache index for layer 1 is 1; its full-pool index is 0.
|
||||
self.assertEqual(recorded, [0], "full layer needs the full-pool index")
|
||||
finally:
|
||||
MLXAttentionWrapper._rope_custom_aot = original
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -15,11 +15,15 @@ correct discriminator is ``batch.decoding_reqs``, not the chunk length.
|
||||
|
||||
The routing decision was duplicated across the sync and async paths (the bug
|
||||
therefore existed in both). It now lives in the shared
|
||||
``MlxTpModelWorker._route_extend_request`` helper. These tests cover:
|
||||
``MlxTpModelWorker._route_extend_request`` helper, and the sync entry point
|
||||
launches through the async one rather than re-implementing it. These tests
|
||||
cover:
|
||||
|
||||
* the helper decision directly (both paths delegate to it);
|
||||
* the sync wiring, by driving ``_forward_batch_generation_mlx``;
|
||||
* the async wiring, by driving ``_async_extend_batch``.
|
||||
* the helper decision directly;
|
||||
* the async wiring, by driving ``_async_extend_batch``;
|
||||
* the sync entry point, by driving ``_forward_batch_generation_mlx`` --
|
||||
which also guards the delegation, since a divergence there would show up
|
||||
as a routing or token-ordering difference between the two.
|
||||
|
||||
They mock the MLX runner and load no model. Apple-Silicon-only because
|
||||
``tp_worker`` imports ``mlx.core`` at module load.
|
||||
@@ -35,7 +39,9 @@ from types import SimpleNamespace
|
||||
import torch
|
||||
|
||||
from sglang.srt.model_executor.forward_batch_info import ForwardMode
|
||||
from sglang.srt.runtime_context import get_context
|
||||
from sglang.test.ci.ci_register import register_cpu_ci, register_mlx_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
# CPU marker is AST-parsed "this test exists"; actual CPU-side execution is
|
||||
# gated by the @skipUnless guard below. MLX marker runs for real on the MLX
|
||||
@@ -49,11 +55,15 @@ _SKIP_REASON = "Apple-Silicon-only (tp_worker imports mlx.core at module load)"
|
||||
|
||||
|
||||
class _FakeRunner:
|
||||
"""Records which routing path each request took (sync + async surfaces)."""
|
||||
"""Records which routing path each request took (both worker paths
|
||||
drive the runner through the same start/finalize surface)."""
|
||||
|
||||
def __init__(self, known_rids):
|
||||
self._known = set(known_rids)
|
||||
self.calls: list[tuple[str, str]] = [] # (op, rid)
|
||||
# (op, rid) -> needs_logits as received; guards the worker's
|
||||
# chunk-finality derivation reaching the runner intact.
|
||||
self.logits_flags: dict[tuple[str, str], bool] = {}
|
||||
self._req_caches: dict[str, list] = {}
|
||||
self._counter = 0
|
||||
|
||||
@@ -73,37 +83,27 @@ class _FakeRunner:
|
||||
|
||||
return SimpleNamespace(state=[mx.array([0.0], dtype=mx.float32)])
|
||||
|
||||
# --- sync surface ---
|
||||
def extend(self, rid, new_token_ids, new_slot_ids):
|
||||
self.calls.append(("extend", rid))
|
||||
self._counter += 1
|
||||
return 1000 + self._counter
|
||||
|
||||
def decode_batch(self, rids):
|
||||
for rid in rids:
|
||||
self.calls.append(("decode", rid))
|
||||
return [2000 + i for i in range(len(rids))]
|
||||
|
||||
def prefill(
|
||||
# --- start/finalize surface (shared by the sync and async worker paths) ---
|
||||
def extend_start(
|
||||
self,
|
||||
req_id,
|
||||
new_token_ids,
|
||||
full_token_ids,
|
||||
prefix_slot_ids,
|
||||
new_slot_ids,
|
||||
req_pool_idx,
|
||||
req=None,
|
||||
needs_logits=True,
|
||||
logit_edit_row=None,
|
||||
logprob_spec=None,
|
||||
):
|
||||
self.calls.append(("prefill", req_id))
|
||||
return 3000
|
||||
|
||||
# --- async surface ---
|
||||
def extend_start(self, req_id, new_token_ids, new_slot_ids):
|
||||
import mlx.core as mx
|
||||
|
||||
self.calls.append(("extend_start", req_id))
|
||||
self.logits_flags[("extend_start", req_id)] = needs_logits
|
||||
self._req_caches[req_id] = [self._fake_cache_layer()]
|
||||
return SimpleNamespace(lazy_token=mx.array([0], dtype=mx.int32), req_id=req_id)
|
||||
return SimpleNamespace(
|
||||
lazy_token=mx.array([0], dtype=mx.int32),
|
||||
cache=self._req_caches[req_id],
|
||||
req_id=req_id,
|
||||
lazy_logprobs=None,
|
||||
)
|
||||
|
||||
def prefill_start(
|
||||
self,
|
||||
@@ -114,17 +114,24 @@ class _FakeRunner:
|
||||
new_slot_ids,
|
||||
req_pool_idx,
|
||||
req=None,
|
||||
needs_logits=True,
|
||||
logit_edit_row=None,
|
||||
logprob_spec=None,
|
||||
):
|
||||
import mlx.core as mx
|
||||
|
||||
self.calls.append(("prefill_start", req_id))
|
||||
self.logits_flags[("prefill_start", req_id)] = needs_logits
|
||||
return SimpleNamespace(
|
||||
lazy_token=mx.array([0], dtype=mx.int32),
|
||||
cache=[self._fake_cache_layer()],
|
||||
req_id=req_id,
|
||||
lazy_logprobs=None,
|
||||
)
|
||||
|
||||
def decode_batch_start(self, rids):
|
||||
def decode_batch_start(
|
||||
self, rids, edit_rows=None, logprob_spec=None, logits_hook=None
|
||||
):
|
||||
import mlx.core as mx
|
||||
|
||||
for rid in rids:
|
||||
@@ -133,8 +140,29 @@ class _FakeRunner:
|
||||
lazy_tokens=mx.array([0] * len(rids), dtype=mx.int32),
|
||||
caches=[[self._fake_cache_layer()] for _ in rids],
|
||||
req_ids=list(rids),
|
||||
lazy_logprobs=None,
|
||||
)
|
||||
|
||||
def prefill_finalize(self, pending):
|
||||
return 3000
|
||||
|
||||
def extend_finalize(self, pending):
|
||||
self._counter += 1
|
||||
return 1000 + self._counter
|
||||
|
||||
def decode_batch_finalize(self, pending):
|
||||
return [2000 + i for i in range(len(pending.req_ids))]
|
||||
|
||||
def collect_logprobs(self, lazy_logprobs):
|
||||
return None
|
||||
|
||||
def eval_pending(self, pending):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def cache_state_arrays(caches):
|
||||
return [s for cache_list in caches for c in cache_list for s in c.state]
|
||||
|
||||
|
||||
class _FakeReq:
|
||||
def __init__(self, rid, req_pool_idx=0):
|
||||
@@ -142,6 +170,11 @@ class _FakeReq:
|
||||
self.prefix_indices = torch.empty(0, dtype=torch.long)
|
||||
self.fill_ids = [0]
|
||||
self.req_pool_idx = req_pool_idx
|
||||
# Mirrors Req's chunk-finality contract read by
|
||||
# MlxTpModelWorker._chunk_needs_logits: extend_range=None means
|
||||
# "not truncated" (final chunk / plain prefill).
|
||||
self.extend_range = None
|
||||
self.full_untruncated_fill_ids = self.fill_ids
|
||||
|
||||
def get_fill_ids(self):
|
||||
return self.fill_ids
|
||||
@@ -154,15 +187,26 @@ class _FakeBatch:
|
||||
self.reqs = reqs
|
||||
self.extend_lens = list(extend_lens)
|
||||
self.decoding_reqs = decoding_reqs
|
||||
self.sampling_info = None
|
||||
self.return_logprob = False
|
||||
# Arbitrary but correctly-sized token / slot arrays.
|
||||
self.input_ids = torch.arange(total, dtype=torch.long)
|
||||
self.out_cache_loc = torch.arange(total, dtype=torch.long)
|
||||
|
||||
|
||||
@unittest.skipUnless(_IS_APPLE_SILICON and _HAS_MLX, _SKIP_REASON)
|
||||
class TestMlxExtendRouting(unittest.TestCase):
|
||||
class TestMlxExtendRouting(CustomTestCase):
|
||||
"""Routing contract for MlxTpModelWorker: shared helper + sync + async."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# The worker reads --mlx-enable-sampling off the device config bag,
|
||||
# which fails closed before a publish. Routing itself is orthogonal
|
||||
# to sampling, so pin it off for the whole case.
|
||||
cls._config = get_context().override_server_args(mlx_enable_sampling=False)
|
||||
cls._config.install()
|
||||
cls.addClassCleanup(cls._config.restore)
|
||||
|
||||
@staticmethod
|
||||
def _worker(known_rids):
|
||||
from sglang.srt.hardware_backend.mlx.tp_worker import MlxTpModelWorker
|
||||
@@ -170,6 +214,10 @@ class TestMlxExtendRouting(unittest.TestCase):
|
||||
worker = MlxTpModelWorker.__new__(MlxTpModelWorker)
|
||||
worker._mlx_runner = _FakeRunner(known_rids)
|
||||
worker._mlx_active_rids = set()
|
||||
# The sync entry point delegates to the async launch, which guards
|
||||
# pool creation behind this flag; forward_batch_generation has
|
||||
# already run it for real by the time either path is reached.
|
||||
worker._mlx_pool_initialized = True
|
||||
return worker
|
||||
|
||||
# ---------- the shared decision helper ----------
|
||||
@@ -200,13 +248,28 @@ class TestMlxExtendRouting(unittest.TestCase):
|
||||
def test_sync_one_token_continuation_routes_to_extend(self):
|
||||
"""THE REGRESSION (sync): a 1-token continuation must extend, not decode."""
|
||||
runner = self._run_sync([_FakeReq("r1")], [1], {"r1"}, None, ForwardMode.EXTEND)
|
||||
self.assertEqual(runner.ops_for("r1"), ["extend"])
|
||||
self.assertEqual(runner.ops_for("r1"), ["extend_start"])
|
||||
# Untruncated (extend_range None) => final chunk => logits required.
|
||||
self.assertIs(runner.logits_flags[("extend_start", "r1")], True)
|
||||
|
||||
def test_sync_non_final_chunk_skips_logits(self):
|
||||
"""Head-skip derivation: a scheduler-truncated chunk (extend_range.end
|
||||
below the request's full untruncated length) reaches the runner with
|
||||
needs_logits=False; its next-token output is popped as the stale
|
||||
intermediate token, so computing the vocab head for it is pure waste.
|
||||
Everything else about routing is unchanged."""
|
||||
req = _FakeReq("r1")
|
||||
req.full_untruncated_fill_ids = list(range(8))
|
||||
req.extend_range = SimpleNamespace(start=0, end=4) # 4 < 8: non-final
|
||||
runner = self._run_sync([req], [4], {"r1"}, None, ForwardMode.EXTEND)
|
||||
self.assertEqual(runner.ops_for("r1"), ["extend_start"])
|
||||
self.assertIs(runner.logits_flags[("extend_start", "r1")], False)
|
||||
|
||||
def test_sync_genuine_mixed_decode_routes_to_decode(self):
|
||||
p, d = _FakeReq("p1"), _FakeReq("d1")
|
||||
runner = self._run_sync([p, d], [4, 1], {"d1"}, [d], ForwardMode.MIXED)
|
||||
self.assertEqual(runner.ops_for("p1"), ["prefill"])
|
||||
self.assertEqual(runner.ops_for("d1"), ["decode"])
|
||||
self.assertEqual(runner.ops_for("p1"), ["prefill_start"])
|
||||
self.assertEqual(runner.ops_for("d1"), ["decode_start"])
|
||||
|
||||
# ---------- async path: _async_extend_batch ----------
|
||||
|
||||
@@ -216,26 +279,34 @@ class TestMlxExtendRouting(unittest.TestCase):
|
||||
worker = MlxTpModelWorker.__new__(MlxTpModelWorker)
|
||||
worker._mlx_runner = _FakeRunner(known_rids)
|
||||
batch = _FakeBatch(forward_mode, reqs, extend_lens, decoding_reqs)
|
||||
# returns (lazy_stacked, pending_prefills, pending_extends,
|
||||
# pending_mixed_decode, mode)
|
||||
result = worker._async_extend_batch(batch)
|
||||
return worker._mlx_runner, result
|
||||
launch = worker._async_extend_batch(batch)
|
||||
return worker._mlx_runner, launch
|
||||
|
||||
def test_async_one_token_continuation_routes_to_extend(self):
|
||||
"""THE REGRESSION (async): a 1-token continuation must extend, not decode."""
|
||||
runner, result = self._run_async(
|
||||
runner, launch = self._run_async(
|
||||
[_FakeReq("r1")], [1], {"r1"}, None, ForwardMode.EXTEND
|
||||
)
|
||||
self.assertEqual(runner.ops_for("r1"), ["extend_start"])
|
||||
self.assertEqual(len(result[2]), 1) # one pending extend
|
||||
self.assertIsNone(result[3]) # no mixed decode
|
||||
self.assertIs(runner.logits_flags[("extend_start", "r1")], True)
|
||||
self.assertEqual(len(launch.extends), 1) # one pending extend
|
||||
self.assertIsNone(launch.decode) # no mixed decode
|
||||
|
||||
def test_async_non_final_chunk_skips_logits(self):
|
||||
"""Async twin of the head-skip derivation guard."""
|
||||
req = _FakeReq("r1")
|
||||
req.full_untruncated_fill_ids = list(range(8))
|
||||
req.extend_range = SimpleNamespace(start=0, end=4)
|
||||
runner, _ = self._run_async([req], [4], {"r1"}, None, ForwardMode.EXTEND)
|
||||
self.assertEqual(runner.ops_for("r1"), ["extend_start"])
|
||||
self.assertIs(runner.logits_flags[("extend_start", "r1")], False)
|
||||
|
||||
def test_async_genuine_mixed_decode_routes_to_decode(self):
|
||||
p, d = _FakeReq("p1"), _FakeReq("d1")
|
||||
runner, result = self._run_async([p, d], [4, 1], {"d1"}, [d], ForwardMode.MIXED)
|
||||
runner, launch = self._run_async([p, d], [4, 1], {"d1"}, [d], ForwardMode.MIXED)
|
||||
self.assertEqual(runner.ops_for("p1"), ["prefill_start"])
|
||||
self.assertEqual(runner.ops_for("d1"), ["decode_start"])
|
||||
self.assertIsNotNone(result[3]) # pending mixed decode present
|
||||
self.assertIsNotNone(launch.decode) # pending mixed decode present
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Unit tests for the MLX windowed per-request attention KV cache.
|
||||
|
||||
``WindowedAttentionKVCache`` keeps only the trailing ``window`` tokens of a
|
||||
sliding-window layer. Every level is pinned against the full-history path it
|
||||
replaces: the cache arrays against a ``ContiguousAttentionKVCache`` trailing
|
||||
slice, the container forward against full-history caches, and
|
||||
``MLXAttentionWrapper`` batched decode against the same wrapper driven by
|
||||
full-history caches.
|
||||
|
||||
Sliding-window layers use this storage on both KV paths; how it composes
|
||||
with the shared pool and radix prefix hits is pinned in
|
||||
test_swa_radix_pool.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import unittest
|
||||
|
||||
from sglang.test.ci.ci_register import register_cpu_ci, register_mlx_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=10, suite="base-a-test-cpu")
|
||||
register_mlx_ci(est_time=10, suite="stage-a-unit-test-mlx")
|
||||
|
||||
_HAS_MLX = (
|
||||
importlib.util.find_spec("mlx") is not None
|
||||
and importlib.util.find_spec("mlx_lm") is not None
|
||||
)
|
||||
_SKIP_REASON = "requires mlx + mlx_lm"
|
||||
|
||||
if _HAS_MLX:
|
||||
import mlx.core as mx
|
||||
from mlx_lm.models import gpt_oss
|
||||
from mlx_lm.models.base import create_causal_mask
|
||||
|
||||
from sglang.srt.hardware_backend.mlx.kv_cache import (
|
||||
BatchedDecodeContext,
|
||||
ContiguousAttentionKVCache,
|
||||
MLXAttentionWrapper,
|
||||
WindowedAttentionKVCache,
|
||||
find_attention_layers,
|
||||
get_layer_window_sizes,
|
||||
make_attention_mask,
|
||||
)
|
||||
from sglang.srt.hardware_backend.mlx.kv_cache.layout import MlxModelCacheLayout
|
||||
|
||||
WINDOW = 8
|
||||
HIDDEN, N_KV_HEADS, HEAD_DIM = 64, 2, 16
|
||||
|
||||
|
||||
def _dense_mask(mask, n_queries: int, offset: int):
|
||||
"""Densify the cheap ``"causal"`` / ``None`` mask forms.
|
||||
|
||||
``make_attention_mask`` returns those instead of a materialised band
|
||||
whenever the window cannot bind, so their width lives in the key tensor
|
||||
rather than in the mask. Densifying keeps width and content checkable
|
||||
for both forms.
|
||||
"""
|
||||
if mask is None or isinstance(mask, str):
|
||||
return create_causal_mask(n_queries, offset)
|
||||
return mask
|
||||
|
||||
|
||||
def _tiny_gpt_oss_model():
|
||||
"""Random-weight 4-layer gpt_oss, alternating sliding/full layers."""
|
||||
return gpt_oss.Model(
|
||||
gpt_oss.ModelArgs(
|
||||
num_hidden_layers=4,
|
||||
num_local_experts=8,
|
||||
num_experts_per_tok=2,
|
||||
vocab_size=128,
|
||||
hidden_size=HIDDEN,
|
||||
intermediate_size=64,
|
||||
head_dim=HEAD_DIM,
|
||||
num_attention_heads=4,
|
||||
num_key_value_heads=N_KV_HEADS,
|
||||
sliding_window=WINDOW,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||
class TestWindowedCacheEquivalence(CustomTestCase):
|
||||
"""Storage equivalence with the full-history trailing slice.
|
||||
|
||||
Both caches receive identical K/V and only copy it, so the comparisons
|
||||
are exact (``mx.array_equal``), not tolerance-based.
|
||||
"""
|
||||
|
||||
W, H, D = 4, 2, 8
|
||||
|
||||
def _kv(self, S):
|
||||
return mx.random.normal((1, self.H, S, self.D))
|
||||
|
||||
def test_chunk_patterns_match_full_trailing_slice(self):
|
||||
"""Windowed storage == trailing slice of full history, everywhere.
|
||||
|
||||
Also pins what the forward pass depends on: the mask built before
|
||||
``update_and_fetch`` is exactly as wide as the keys it then returns,
|
||||
the kept prefix still covers a full window, ``offset`` stays
|
||||
absolute, and the decode buffer stays bounded by ``2 * window``.
|
||||
"""
|
||||
mx.random.seed(0)
|
||||
for chunks in [
|
||||
(3,), # stays inside the window
|
||||
(4,), # lands exactly on the window
|
||||
(5,), # first chunk already crosses the window
|
||||
(6, 3), # second chunk forces prefix normalisation
|
||||
(2, 2, 2, 2), # repeated small chunks
|
||||
(1, 1, 1), # degenerate single-token chunks
|
||||
(10, 1, 7), # chunk larger than 2*window, then mixed
|
||||
]:
|
||||
full = ContiguousAttentionKVCache(max_seq_len=128)
|
||||
win = WindowedAttentionKVCache(self.W)
|
||||
for S in chunks:
|
||||
k, v = self._kv(S), self._kv(S)
|
||||
mask = win.make_mask(S, window_size=self.W) # runs before update
|
||||
fk, fv = full.update_and_fetch(k, v)
|
||||
wk, wv = win.update_and_fetch(k, v)
|
||||
at = f"chunk S={S} of {chunks}"
|
||||
self.assertTrue(mx.array_equal(wk, fk[:, :, -wk.shape[2] :, :]), at)
|
||||
self.assertTrue(mx.array_equal(wv, fv[:, :, -wv.shape[2] :, :]), at)
|
||||
# When the window cannot bind, make_mask returns the cheap
|
||||
# "causal"/None form whose width is implicit in the key
|
||||
# tensor; densify so the invariant stays checkable either way.
|
||||
dense = _dense_mask(mask, S, wk.shape[2] - S)
|
||||
self.assertEqual(dense.shape[-1], wk.shape[2], f"mask width, {at}")
|
||||
self.assertGreaterEqual(
|
||||
wk.shape[2] - S, min(win.offset - S, self.W), f"prefix, {at}"
|
||||
)
|
||||
self.assertEqual(win.offset, full.offset, at)
|
||||
|
||||
for step in range(4 * self.W):
|
||||
k, v = self._kv(1), self._kv(1)
|
||||
full.write_token(k, v)
|
||||
win.write_token(k, v)
|
||||
fk, _ = full.get_kv()
|
||||
wk, _ = win.get_kv()
|
||||
t = min(win.offset, self.W)
|
||||
at = f"decode step {step} after {chunks}"
|
||||
self.assertTrue(mx.array_equal(wk[:, :, -t:, :], fk[:, :, -t:, :]), at)
|
||||
self.assertEqual(win.offset, full.offset, at)
|
||||
self.assertLessEqual(win.keys.shape[2], 2 * self.W, at)
|
||||
|
||||
def test_decode_reallocates_amortised_not_per_token(self):
|
||||
"""Compaction must stay amortised O(1) on both write paths."""
|
||||
for write in ("write_token", "update_and_fetch"):
|
||||
win = WindowedAttentionKVCache(self.W)
|
||||
big = self._kv(5 * self.W)
|
||||
win.update_and_fetch(big, big)
|
||||
buf, reallocs = win.keys, 0
|
||||
for _ in range(10 * self.W):
|
||||
getattr(win, write)(self._kv(1), self._kv(1))
|
||||
if win.keys is not buf:
|
||||
buf, reallocs = win.keys, reallocs + 1
|
||||
self.assertLessEqual(reallocs, 12, f"{write} reallocated {reallocs}x")
|
||||
|
||||
def test_full_context_mask_raises_once_history_is_unservable(self):
|
||||
win = WindowedAttentionKVCache(self.W)
|
||||
win.update_and_fetch(self._kv(3), self._kv(3))
|
||||
self.assertEqual(win.make_mask(2), "causal") # nothing dropped yet
|
||||
# One oversized chunk is enough: the next update normalises the
|
||||
# prefix to the window, so full context is already unservable.
|
||||
win.update_and_fetch(self._kv(10), self._kv(10))
|
||||
with self.assertRaises(RuntimeError):
|
||||
win.make_mask(2)
|
||||
|
||||
def test_reset_keeps_buffers_and_replays(self):
|
||||
win = WindowedAttentionKVCache(self.W)
|
||||
win.update_and_fetch(self._kv(6), self._kv(6))
|
||||
win.reset()
|
||||
self.assertEqual(win.offset, 0)
|
||||
self.assertIsNotNone(win.keys) # buffer kept for reuse
|
||||
k = self._kv(2)
|
||||
out, _ = win.update_and_fetch(k, k)
|
||||
self.assertEqual(out.shape[2], 2) # no stale prefix survived
|
||||
self.assertTrue(mx.array_equal(out, k))
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||
class TestWindowedModelForward(CustomTestCase):
|
||||
"""Container path: chunked prefill + greedy decode on a tiny gpt-oss."""
|
||||
|
||||
def test_chunked_prefill_and_greedy_decode_match_full_history(self):
|
||||
mx.random.seed(0)
|
||||
model = _tiny_gpt_oss_model()
|
||||
windows = get_layer_window_sizes(model)
|
||||
self.assertEqual([windows[i] for i in range(4)], [WINDOW, None, WINDOW, None])
|
||||
ids = (mx.arange(20) * 7 + 3) % 128 # 2.5x window
|
||||
split = 12 # second chunk starts beyond the window
|
||||
|
||||
tokens = {}
|
||||
for name in ("windowed", "full"):
|
||||
cache = [
|
||||
(
|
||||
WindowedAttentionKVCache(windows[i])
|
||||
if name == "windowed" and windows[i] is not None
|
||||
else ContiguousAttentionKVCache(max_seq_len=64)
|
||||
)
|
||||
for i in range(4)
|
||||
]
|
||||
model(ids[None, :split], cache=cache)
|
||||
out = model(ids[None, split:], cache=cache)
|
||||
seq = []
|
||||
for _ in range(2 * WINDOW): # crosses the compaction boundary
|
||||
token = mx.argmax(out[:, -1, :], axis=-1)
|
||||
seq.append(token.item())
|
||||
out = model(token[None], cache=cache)
|
||||
tokens[name] = seq
|
||||
|
||||
self.assertEqual(
|
||||
tokens["windowed"],
|
||||
tokens["full"],
|
||||
"windowed caches diverge from full history",
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||
class TestWindowedBatchedDecode(CustomTestCase):
|
||||
"""The production decode path (``MLXAttentionWrapper._batched_decode``).
|
||||
|
||||
Windowed and full-history caches are driven through the *same* wrapper
|
||||
with the same inputs. The wrapper slices the trailing window off
|
||||
whatever ``get_kv`` returns, so both runs must build byte-identical SDPA
|
||||
inputs and the outputs must be bit-equal, not merely close.
|
||||
"""
|
||||
|
||||
def test_chained_decode_across_compaction_boundary(self):
|
||||
"""Decode steps built in still-lazy pairs, riding a compaction.
|
||||
|
||||
Pairs mirror ``decode_batch_start_chained``: step N+1's graph is
|
||||
built before step N materialises. Compaction allocates a fresh
|
||||
buffer instead of mutating in place, so step N's returned views must
|
||||
stay valid. Prefill lengths 15 and 6 put the oversized-chunk shrink
|
||||
in the first pair and a steady-state rebuild in a later one.
|
||||
"""
|
||||
mx.random.seed(1)
|
||||
attn = _tiny_gpt_oss_model().model.layers[0].self_attn
|
||||
wrapper = MLXAttentionWrapper(attn, layer_idx=0, window_size=WINDOW)
|
||||
wins, fulls = [], []
|
||||
for length in (15, 6):
|
||||
x = mx.random.normal((1, length, HIDDEN))
|
||||
win = WindowedAttentionKVCache(WINDOW)
|
||||
full = ContiguousAttentionKVCache(max_seq_len=64)
|
||||
for cache in (win, full):
|
||||
attn(x, make_attention_mask(length, 0, window_size=WINDOW), cache=cache)
|
||||
wins.append(win)
|
||||
fulls.append(full)
|
||||
|
||||
def build_step(x_step, caches):
|
||||
ctx = BatchedDecodeContext(
|
||||
batch_size=len(caches),
|
||||
seq_lens=[c.offset for c in caches],
|
||||
attention_layer_caches=[caches],
|
||||
)
|
||||
return wrapper._batched_decode(x_step, ctx)
|
||||
|
||||
for pair in range(6):
|
||||
steps = [mx.random.normal((len(wins), 1, HIDDEN)) for _ in range(2)]
|
||||
# Both graphs are built before either materialises.
|
||||
got = [(build_step(x, wins), build_step(x, fulls)) for x in steps]
|
||||
mx.eval(got)
|
||||
for tag, (windowed, full) in zip("ab", got):
|
||||
self.assertTrue(
|
||||
mx.array_equal(windowed, full), f"pair {pair}{tag} diverges"
|
||||
)
|
||||
for win, full in zip(wins, fulls):
|
||||
self.assertEqual(win.offset, full.offset)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MLX, _SKIP_REASON)
|
||||
class TestModelRunnerCacheWiring(CustomTestCase):
|
||||
"""``_new_native_cache``/``_acquire_cache`` wiring without loading weights."""
|
||||
|
||||
def _stub_runner(self, window_map):
|
||||
from sglang.srt.hardware_backend.mlx.model_runner import MlxModelRunner
|
||||
|
||||
layers, attrs = find_attention_layers(_tiny_gpt_oss_model())
|
||||
runner = MlxModelRunner.__new__(MlxModelRunner)
|
||||
runner._cache_layout = MlxModelCacheLayout.from_attention_discovery(
|
||||
layers, attrs, layer_window_sizes=window_map
|
||||
)
|
||||
runner._max_seq_len = 4096
|
||||
runner._cache_pool = []
|
||||
return runner
|
||||
|
||||
def test_windowed_only_for_sliding_layers_and_reset_on_reuse(self):
|
||||
runner = self._stub_runner(get_layer_window_sizes(_tiny_gpt_oss_model()))
|
||||
cache = runner._new_native_cache()
|
||||
self.assertEqual(
|
||||
[type(c) for c in cache],
|
||||
[
|
||||
WindowedAttentionKVCache,
|
||||
ContiguousAttentionKVCache,
|
||||
WindowedAttentionKVCache,
|
||||
ContiguousAttentionKVCache,
|
||||
],
|
||||
)
|
||||
self.assertEqual(cache[0].window, WINDOW)
|
||||
|
||||
# Models without container windows have an empty map, so every
|
||||
# attention layer keeps a contiguous full-history cache.
|
||||
for c in self._stub_runner({})._new_native_cache():
|
||||
self.assertIsInstance(c, ContiguousAttentionKVCache)
|
||||
|
||||
k = mx.random.normal((1, N_KV_HEADS, 10, HEAD_DIM))
|
||||
cache[0].update_and_fetch(k, k)
|
||||
cache[1].update_and_fetch(k, k)
|
||||
runner._release_cache(cache)
|
||||
reused = runner._acquire_cache()
|
||||
self.assertIs(reused, cache)
|
||||
for c in reused:
|
||||
self.assertEqual(c.offset, 0)
|
||||
# A stale local buffer would prepend the previous request's KV.
|
||||
out, _ = reused[0].update_and_fetch(k, k)
|
||||
self.assertEqual(out.shape[2], 10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user