[Test] Prune redundant unified-memory allocator and pool tests (#38093)

This commit is contained in:
Liangsheng Yin
2026-09-04 20:47:01 -07:00
committed by GitHub
parent 92a4d8b5ee
commit d50e9a9756
21 changed files with 920 additions and 2487 deletions
@@ -1,28 +1,11 @@
"""Kimi-Linear (MLA full attention + KDA linear attention) served from the
unified memory pool.
Under `--enable-unified-memory` the MLA full side is exposed as per-layer
views and every loc the kernels see is a translated virtual id, so the whole
read/write path differs from the static pool. The unit tests pin that pool in
isolation; this is the end-to-end guard. `test_prefix_cache_branching` carries
most of the weight: a radix hit replays virtual locs whose physical pages may
have moved under compaction.
No `--attention-backend` is pinned on purpose -- the test runs whatever the host
resolves to (`fa3` on this suite's H100 runner, also the H200 default). Both
defects found in review on #32972 were reachable only under a resolved default,
which a pinned test hides by construction.
Reference GSM8K, all with `--enable-unified-memory`:
- 2x H200 TP2, resolved default (fa3): 0.917 @400, vs 0.915 static (1 sigma
~= 0.015). This file as written scores 0.920 @200.
- 2x H200 TP2, `--attention-backend triton`: 0.900 @400.
- 1x B300 TP1: 0.915 flashinfer, 0.900 trtllm_mla, @200.
Nightly-only: a second full 48B server launch is too much for per-PR CI on top
of the existing Kimi-Linear e2e coverage.
python -m pytest test/registered/models_e2e/test_kimi_linear_unified_memory.py -v
Under `--enable-unified-memory` the MLA full side is exposed as per-layer views
and every loc the kernels see is a translated virtual id, so the whole
read/write path differs from the static pool. `test_prefix_cache_branching`
carries most of the weight: a radix hit replays virtual locs whose physical
pages may have moved under compaction.
"""
import unittest
@@ -32,18 +15,23 @@ from sglang.test.kits.eval_accuracy_kit import GSM8KMixin
from sglang.test.kits.prefix_cache_branching_kit import PrefixCacheBranchingMixin
from sglang.test.server_fixtures.default_fixture import DefaultServerBase
register_cuda_ci(est_time=1200, stage="nightly", runner_config="4-gpu-h100")
register_cuda_ci(est_time=800, stage="nightly", runner_config="4-gpu-h100")
KIMI_LINEAR_MODEL = "moonshotai/Kimi-Linear-48B-A3B-Instruct"
class TestKimiLinearUnifiedMemory(
class TestKimiLinearUnifiedMemoryFlashMLA(
GSM8KMixin, PrefixCacheBranchingMixin, DefaultServerBase
):
"""flashmla at its ps=64 snap: the block-table route
(KVIndexTranslator.fill_read_table into flashmla's padded tables) plus the
ps=64 sub-pool sizing (64-token sink floor, per-layer-view tail pad).
Hopper-only, like the rest of this nightly suite."""
model = KIMI_LINEAR_MODEL
cache_chunk_size = 64
# Same bar as the static-pool Kimi-Linear e2e test: unified memory must not
# cost accuracy (measured 0.917 vs 0.915 static, see the module docstring).
# cost accuracy.
gsm8k_score_threshold = 0.88
other_args = [
"--trust-remote-code",
@@ -52,17 +40,6 @@ class TestKimiLinearUnifiedMemory(
"--chunked-prefill-size",
"2048",
"--enable-unified-memory",
]
class TestKimiLinearUnifiedMemoryFlashMLA(TestKimiLinearUnifiedMemory):
"""flashmla at its ps=64 snap: the canonical block-table route
(KVIndexTranslator.fill_read_table into flashmla's padded tables) plus the
ps=64 sub-pool sizing (64-token sink floor, per-layer-view tail pad) end to
end.
Hopper-only, like the rest of this nightly suite."""
other_args = TestKimiLinearUnifiedMemory.other_args + [
"--attention-backend",
"flashmla",
"--page-size",
@@ -73,13 +50,9 @@ class TestKimiLinearUnifiedMemoryFlashMLA(TestKimiLinearUnifiedMemory):
class TestKimiLinearUnifiedMemoryDCP(
GSM8KMixin, PrefixCacheBranchingMixin, DefaultServerBase
):
"""Unified memory + decode context parallelism.
`test_prefix_cache_branching` is the sharp one here: a radix hit replays
widened virtual locs whose pages may have moved under compaction, and each
rank must recover the same physical page from them while keeping a
different row inside it.
"""
"""Unified memory + decode context parallelism on flashinfer: on a radix hit
each rank must recover the same physical page from widened virtual locs
while keeping a different row inside it."""
model = KIMI_LINEAR_MODEL
cache_chunk_size = 64
@@ -2,11 +2,8 @@
gpt-oss-20b is uniform-row hybrid-SWA, so its MHA and SWA sub-pools are
per-layer views and the fa3 cell reads them through the translator's read
tables. The resolved-default cell pins the no-pin path, since a pinned
backend hides default-resolution breakage by construction. flashinfer is
absent on purpose: gpt-oss uses attention sinks, which it does not support.
Registered to the label-gated ``run-ci-extra`` suite (opt-in, not per-commit).
tables. flashinfer is absent on purpose: gpt-oss uses attention sinks, which
it does not support.
"""
import unittest
@@ -17,7 +14,7 @@ from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.server_fixtures.default_fixture import DefaultServerBase
from sglang.test.test_utils import DEFAULT_MODEL_NAME_FOR_TEST_MXFP4_WITH_MOE
register_cuda_ci(est_time=1500, stage="extra-a", runner_config="1-gpu-large")
register_cuda_ci(est_time=1000, stage="extra-a", runner_config="1-gpu-large")
_UNIFIED_COMMON_ARGS = [
"--enable-unified-memory",
@@ -68,12 +65,5 @@ class TestUnifiedGptOssFa3(TestUnifiedGptOssTriton):
other_args = _UNIFIED_COMMON_ARGS + ["--attention-backend", "fa3"]
class TestUnifiedGptOssResolvedDefault(TestUnifiedGptOssTriton):
"""No backend pin: whatever the host resolves must be in the allow-list,
or the server fails to boot under its own defaults."""
other_args = _UNIFIED_COMMON_ARGS
if __name__ == "__main__":
unittest.main()
@@ -4,11 +4,7 @@ Qwen3.5-4B is a gated-delta-net / linear-attention hybrid, which exercises the
path most prone to subtle bugs: the Mamba conv/SSM state stays a strided
envelope view (its kernels are stride-aware by design) while the
full-attention KV is per-layer views, which the fa3 / flashinfer cells read
through the translator's read tables. The resolved-default cell pins the
no-pin path, since a pinned backend hides default-resolution breakage by
construction.
Registered to the label-gated ``run-ci-extra`` suite (opt-in, not per-commit).
through the translator's read tables.
"""
import unittest
@@ -19,7 +15,7 @@ from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.server_fixtures.default_fixture import DefaultServerBase
from sglang.test.test_utils import DEFAULT_HYBRID_GDN_SMALL_MODEL_NAME_FOR_TEST
register_cuda_ci(est_time=1600, stage="extra-a", runner_config="1-gpu-large")
register_cuda_ci(est_time=1200, stage="extra-a", runner_config="1-gpu-large")
_UNIFIED_COMMON_ARGS = [
"--trust-remote-code",
@@ -40,9 +36,8 @@ class TestUnifiedQwenHybridTriton(DefaultServerBase):
model = DEFAULT_HYBRID_GDN_SMALL_MODEL_NAME_FOR_TEST
# Measured ~0.86 in this harness on both the static pools and the envelope
# layout; 0.80 leaves noise margin and still catches a corrupted prefill
# state, which reads ~0.61.
# Measured ~0.86 on both the static pools and the envelope layout; 0.80
# leaves noise margin and still catches a corrupted prefill state (~0.61).
gsm8k_threshold = 0.80
num_gsm8k_questions = 200
num_shots = 5
@@ -84,12 +79,5 @@ class TestUnifiedQwenHybridFlashinfer(TestUnifiedQwenHybridTriton):
other_args = _UNIFIED_COMMON_ARGS + ["--attention-backend", "flashinfer"]
class TestUnifiedQwenHybridResolvedDefault(TestUnifiedQwenHybridTriton):
"""No backend pin: whatever the host resolves must be in the allow-list,
or the server fails to boot under its own defaults."""
other_args = _UNIFIED_COMMON_ARGS
if __name__ == "__main__":
unittest.main()
@@ -1,20 +1,16 @@
"""Nothing under layers/attention may translate KV ids for itself.
Ownership is exactly two places: `KVIndexTranslator` for READS (indices are
born kernel-facing, backends consume its tables) and the ForwardBatch rebind
(`rebind_write_loc`) for WRITES. Virtual and physical ids share a value range,
so a backend that forgets a translate -- or does one twice -- reads the wrong
rows and nothing crashes. This scan makes both unrepresentable.
Ownership is exactly two places: `KVIndexTranslator` for READS and the
ForwardBatch rebind (`rebind_write_loc`) for WRITES. Virtual and physical ids
share a value range, so a backend that forgets a translate -- or does one
twice -- reads the wrong rows and nothing crashes.
Out of scope, deliberately: the allocator-internal implementations
(`allocator/unified_*` / `unified_memory_pool`), which ARE the mechanism the
Deliberately out of scope: the allocator-internal implementations
(`allocator/unified_*`, `unified_memory_pool`), which ARE the mechanism the
translator calls; the PD transfer plane's `translate_kv_indices_for_transfer`,
which stages for RDMA outside the forward path; and the STATIC SWA pool's
legacy full->swa slot map, a different mapping kind with no virtual/physical
ambiguity -- its call sites are count-pinned below so new ones are added
consciously.
python3 -m pytest test/registered/unit/layers/attention/test_kv_translate_ownership.py -v
ambiguity.
"""
import ast
@@ -60,30 +56,21 @@ def _iter_sources():
class TestUnifiedTranslateBanned(CustomTestCase):
def test_no_unified_translate_calls(self):
"""No backend calls the unified translate surfaces. A hit here means
a backend re-grew its own id-space transition -- the design whose two
failure modes (forgotten translate, duplicated translate) this scan
exists to prevent. Route reads through KVIndexTranslator views and
writes through the ForwardBatch rebind instead."""
"""No backend calls the unified translate surfaces, and none probes an
allocator for translate capability through getattr."""
banned = re.compile(r"\.translate_kv_loc(_kernel_id)?\(")
hits = [
f"{rel}: {m.group(0)}"
for rel, src in _iter_sources()
for m in banned.finditer(src)
]
self.assertEqual(hits, [])
def test_no_translate_capability_probing(self):
"""No backend probes an allocator for translate capability -- the
getattr-hook pattern is how per-backend translation grew the first
time."""
probing = re.compile(r"""getattr\([^)]*['"]translate_kv_loc""")
hits = [rel for rel, src in _iter_sources() if probing.search(src)]
self.assertEqual(hits, [])
calls, probes = [], []
for rel, src in _iter_sources():
calls += [f"{rel}: {m.group(0)}" for m in banned.finditer(src)]
if probing.search(src):
probes.append(rel)
self.assertEqual(calls, [])
self.assertEqual(probes, [])
def test_hooks_module_deleted_and_unimported(self):
"""The per-backend hooks module (the previous owner of backend-side
v2p knowledge) stays deleted, and nothing imports it."""
"""The per-backend v2p hooks module stays deleted, and nothing imports
it."""
self.assertFalse(
os.path.exists(os.path.join(_ATTN_DIR, "unified_mem_hooks.py"))
)
@@ -96,10 +83,9 @@ class TestUnifiedTranslateBanned(CustomTestCase):
def _derive_wrapper_names():
"""Wrapper classes, read off the source so a NEW one shows up the day it is
written: an AttentionBackend subclass whose own __init__ takes another
backend. Per class, not per file -- a file-wide scan passes as soon as any
one class in it forwards."""
"""Wrapper classes discovered from source, so a new one shows up the day it
is written: an AttentionBackend subclass whose own __init__ takes another
backend. Per class, not per file."""
backend = re.compile(r"Att(?:ention|n)Backend")
names = set()
for _rel, src in _iter_sources():
@@ -155,8 +141,8 @@ class _Runner:
def _build_wrappers(translator):
"""One live instance per wrapper. Only the inner that MUST supply the
translator carries it; every other inner carries None, so a wrapper that
copies from the linear / sparse / DSA side ends up with None and fails."""
translator carries it, so a wrapper copying off the linear / sparse / DSA
side ends up with None and fails."""
carrier = _Inner(translator)
# HybridAttnBackend reads the spec bag in __init__; the bag is unpublished
# outside a launched server.
@@ -176,10 +162,9 @@ def _build_wrappers(translator):
class TestWrapperBackendsForwardTranslator(CustomTestCase):
"""BUG REGRESSION. `AttentionBackend.kv_index_translator` defaults to None,
so a wrapper that does not re-expose its inner's copy reads as "needs no
translation" and producers that fetch it off `get_attn_backend()` skip the
virtual->kernel-facing translation instead of failing."""
"""Bug regression: `AttentionBackend.kv_index_translator` defaults to None,
so a wrapper that does not re-expose its inner's copy makes producers skip
the virtual->kernel-facing translation instead of failing."""
def test_every_wrapper_is_constructed_here(self):
self.assertEqual(
@@ -15,21 +15,9 @@
`HybridLinearKVPool`).
All write-location info travels in the attention metadata (`KVWriteLoc`); the
pools hold none and never translate — the write loc reaching `set_kv_buffer` is
always PHYSICAL. Two routing contracts are pinned here:
1. Full-attention. The full-physical loc is carried in `KVWriteLoc.full_loc`
(from `ForwardMetadata.out_cache_loc_full_physical`) and written directly.
`UnifiedSWAKVPool` asserts it's present (the unified memory pool always precomputes
it); `HybridLinearKVPool` falls back to `loc` for a static (non-shared) pool,
where `loc` is itself already physical.
2. SWA. The swa-physical loc rides the backend `swa_out_cache_loc` slot
(`KVWriteLoc.swa_loc`) and is written directly.
Pure dispatch tests: the inner sub-pools are recording stubs, so no GPU / real
buffers are needed (CPU CI).
python -m pytest test/registered/unit/mem_cache/test_full_loc_fast_path.py -v
pools hold none and never translate, so the loc reaching `set_kv_buffer` is
always PHYSICAL. Pure dispatch: the inner sub-pools are recording stubs, so no
GPU and no real buffers are needed.
"""
import types
@@ -61,9 +49,8 @@ class _RecordingPool:
class TestUnifiedSWARouting(unittest.TestCase):
"""`UnifiedSWAKVPool.set_kv_buffer` routing: full layers write `full_loc`
when present (triton's capture-stable buffer), else the rebound generic
`loc` -- the same id space once the loc is rebound; SWA layers write the swa-physical
`swa_loc`, which has no fallback (a different id space). The pool never
translates."""
`loc` -- the same id space; SWA layers write the swa-physical `swa_loc`,
which has no fallback (a different id space)."""
def _make_bare_pool(self):
from sglang.srt.mem_cache.unified_memory_pool import UnifiedSWAKVPool
@@ -93,18 +80,15 @@ class TestUnifiedSWARouting(unittest.TestCase):
self.assertEqual(len(pool.full_kv_pool.calls), 1)
forwarded, kwargs = pool.full_kv_pool.calls[0]
# Forward the full-physical tensor from the write metadata, NOT the
# virtual loc. No `already_physical` the pool only ever gets physical.
# virtual loc; no `already_physical`, the pool only ever gets physical.
self.assertIs(forwarded, full_phys)
self.assertIsNot(forwarded, virtual_loc)
self.assertNotIn("already_physical", kwargs)
def test_full_layer_falls_back_to_generic_loc(self):
"""Bug regression: fa3 x unified-SWA crashed at gpt-oss
cuda-graph capture because every backend except triton bundles the
2-arg KVWriteLoc(loc, swa) and the full-layer door demanded an explicit
full_loc. Once the loc is rebound the generic `loc` IS the full-side kernel-facing id
(rebind_write_loc runs at ForwardBatch construction),
so the door must fall back to it -- the pool still never translates."""
"""Bug regression: a 2-arg `KVWriteLoc(loc, swa)` with no explicit
`full_loc` must fall back to the rebound `loc` -- which IS the full-side
kernel-facing id -- instead of failing the full-layer door."""
pool = self._make_bare_pool()
rebound_loc = torch.tensor([10, 11, 12], dtype=torch.int64)
swa_phys = torch.tensor([1, 2, 0], dtype=torch.int64)
@@ -163,10 +147,9 @@ class TestUnifiedSWATombstoneClamp(unittest.TestCase):
"""`UnifiedSWAKVPool.translate_loc_from_full_to_swa` must clamp tombstoned
ids to the reserved padding sink (0).
A token whose swa page was freed carries -1 in `virtual_to_physical`. Before
the clamp, that produced a negative id, which a captured graph stores at a
negative offset from the buffer base. The composite allocator's method of
the same name already clamped; this path did not.
A token whose swa page was freed carries -1 in `virtual_to_physical`, and a
negative id makes a captured graph store at a negative offset from the
buffer base.
"""
def _make_bare_pool(self, page_size, v2p, multiplier=1):
@@ -203,83 +186,66 @@ class TestUnifiedSWATombstoneClamp(unittest.TestCase):
class TestHybridLinearFullLocRouting(unittest.TestCase):
"""`HybridLinearKVPool.set_kv_buffer` (non-MLA) writes the full-physical
`full_loc` from the write metadata when present (unified memory pool), else the
already-physical `loc` (static pool). No translate, no `already_physical`."""
"""`HybridLinearKVPool.set_kv_buffer` writes the full-physical `full_loc`
when present (unified memory pool), else the already-physical `loc` (static
pool) -- the same rule on the MHA and MLA branches."""
def _make_bare_pool(self):
def _make_bare_pool(self, use_mla):
from sglang.srt.mem_cache.memory_pool import HybridLinearKVPool
pool = object.__new__(HybridLinearKVPool)
pool.full_kv_pool = _RecordingPool()
pool.use_mla = False
pool.use_mla = use_mla
pool.full_attention_layer_id_mapping = {0: 0}
return pool
def test_writes_full_loc_from_write_loc(self):
pool = self._make_bare_pool()
virtual_loc = torch.tensor([7, 8, 9], dtype=torch.int64)
full_phys = torch.tensor([2, 3, 4], dtype=torch.int64)
for use_mla in (False, True):
for has_full_loc in (True, False):
with self.subTest(use_mla=use_mla, has_full_loc=has_full_loc):
pool = self._make_bare_pool(use_mla)
loc = torch.tensor([7, 8, 9], dtype=torch.int64)
full_phys = (
torch.tensor([2, 3, 4], dtype=torch.int64)
if has_full_loc
else None
)
layer = types.SimpleNamespace(layer_id=0)
pool.set_kv_buffer(
layer,
_loc_info(virtual_loc, full_phys=full_phys),
torch.zeros(3, 4, 8),
torch.zeros(3, 4, 8),
)
layer = types.SimpleNamespace(layer_id=0)
pool.set_kv_buffer(
layer,
_loc_info(loc, full_phys=full_phys),
torch.zeros(3, 4, 8),
None if use_mla else torch.zeros(3, 4, 8),
)
self.assertEqual(len(pool.full_kv_pool.calls), 1)
forwarded, kwargs = pool.full_kv_pool.calls[0]
self.assertIs(forwarded, full_phys)
self.assertIsNot(forwarded, virtual_loc)
self.assertNotIn("already_physical", kwargs)
def test_falls_back_to_loc_when_absent(self):
# Static (non-shared) pool: no full_loc bundled; `loc` is already
# physical, so write it directly.
pool = self._make_bare_pool()
phys_loc = torch.tensor([7, 8, 9], dtype=torch.int64)
layer = types.SimpleNamespace(layer_id=0)
pool.set_kv_buffer(
layer,
_loc_info(phys_loc),
torch.zeros(3, 4, 8),
torch.zeros(3, 4, 8),
)
self.assertEqual(len(pool.full_kv_pool.calls), 1)
forwarded, kwargs = pool.full_kv_pool.calls[0]
self.assertIs(forwarded, phys_loc)
self.assertNotIn("already_physical", kwargs)
self.assertEqual(len(pool.full_kv_pool.calls), 1)
forwarded, kwargs = pool.full_kv_pool.calls[0]
if has_full_loc:
self.assertIs(forwarded, full_phys)
self.assertIsNot(forwarded, loc)
else:
# Static (non-shared) pool: no full_loc bundled; `loc`
# is already physical, so write it directly.
self.assertIs(forwarded, loc)
self.assertNotIn("already_physical", kwargs)
class _RecordingMLAPool(_RecordingPool):
"""Also records the model-level MLA entry points."""
"""Also records the model-level MLA write entry point."""
def __init__(self):
super().__init__()
self.mla_set_calls = []
self.mla_get_calls = []
def set_mla_kv_buffer(self, layer, loc, cache_k_nope, cache_k_rope):
self.mla_set_calls.append(loc)
def get_mla_kv_buffer(self, layer, loc, dst_dtype=None):
self.mla_get_calls.append(loc)
return None, None
class TestHybridLinearMLARouting(unittest.TestCase):
"""MLA-side routing contracts of `HybridLinearKVPool`:
- `set_kv_buffer` (MLA branch) mirrors the MHA branch — write the
pre-translated `KVWriteLoc.full_loc` when present (unified pool, where it
carries the kernel-facing loc), else the raw `loc` (static pool, already physical).
- `set_mla_kv_buffer` / `get_mla_kv_buffer` forward `loc` untouched:
writes are kernel-facing since the ForwardBatch rebind, and read
indices are translated at their production sites."""
"""MLA-side door contract of `HybridLinearKVPool`: `set_mla_kv_buffer`
forwards `loc` untouched -- writes are kernel-facing since the ForwardBatch
rebind."""
def _make_bare_pool(self):
from sglang.srt.mem_cache.memory_pool import HybridLinearKVPool
@@ -290,46 +256,10 @@ class TestHybridLinearMLARouting(unittest.TestCase):
pool.full_attention_layer_id_mapping = {0: 0}
return pool
def test_mla_writes_full_loc_from_write_loc(self):
pool = self._make_bare_pool()
virtual_loc = torch.tensor([7, 8, 9], dtype=torch.int64)
kernel_phys = torch.tensor([21, 24, 27], dtype=torch.int64)
layer = types.SimpleNamespace(layer_id=0)
pool.set_kv_buffer(
layer,
_loc_info(virtual_loc, full_phys=kernel_phys),
torch.zeros(3, 1, 8),
None,
)
self.assertEqual(len(pool.full_kv_pool.calls), 1)
forwarded, _ = pool.full_kv_pool.calls[0]
self.assertIs(forwarded, kernel_phys)
self.assertIsNot(forwarded, virtual_loc)
def test_mla_falls_back_to_loc_when_absent(self):
pool = self._make_bare_pool()
phys_loc = torch.tensor([7, 8, 9], dtype=torch.int64)
layer = types.SimpleNamespace(layer_id=0)
pool.set_kv_buffer(
layer,
_loc_info(phys_loc),
torch.zeros(3, 1, 8),
None,
)
self.assertEqual(len(pool.full_kv_pool.calls), 1)
forwarded, _ = pool.full_kv_pool.calls[0]
self.assertIs(forwarded, phys_loc)
def test_set_mla_kv_buffer_door_never_translates(self):
"""Physical-loc contract: the write door forwards `loc` UNTOUCHED.
The translate happens exactly once at ForwardBatch construction
(rebind_write_loc, kernel-facing-first); a door that translated
again would double-translate every unified MLA write. Deleting the
forward (or re-adding a door translate) turns this red."""
"""The translate happens exactly once, at ForwardBatch construction
(`rebind_write_loc`); a door that translated again would
double-translate every unified MLA write."""
pool = self._make_bare_pool()
loc = torch.tensor([107, 108, 109], dtype=torch.int64)
layer = types.SimpleNamespace(layer_id=0)
@@ -339,21 +269,6 @@ class TestHybridLinearMLARouting(unittest.TestCase):
self.assertEqual(len(pool.full_kv_pool.mla_set_calls), 1)
self.assertIs(pool.full_kv_pool.mla_set_calls[0], loc)
def test_get_mla_kv_buffer_door_never_translates(self):
"""Kernel-facing contract, read side: `loc` is a read-index tensor
already translated at its production site
(fetch_mha_one_shot_kv_indices / prepare_chunked_kv_indices); the
door forwards it UNTOUCHED -- a re-added door translate would
double-translate every unified MLA prefix read."""
pool = self._make_bare_pool()
loc = torch.tensor([104, 105], dtype=torch.int64)
layer = types.SimpleNamespace(layer_id=0)
pool.get_mla_kv_buffer(layer, loc)
self.assertEqual(len(pool.full_kv_pool.mla_get_calls), 1)
self.assertIs(pool.full_kv_pool.mla_get_calls[0], loc)
class TestMlaWriteDoorsUnderDcp(unittest.TestCase):
"""Which MLA write door is DCP-aware, and which refuses.
@@ -362,10 +277,8 @@ class TestMlaWriteDoorsUnderDcp(unittest.TestCase):
the DCP write. `set_kv_buffer` (the combined latent+rope row) cannot: the
two backends that could reach it disagree on the loc space -- flashinfer's
`k_rope is None` branch passes a WIDENED loc, the Triton backend one it
already collapsed -- so there is no single correct translation. It used to
select `loc % dcp_size == dcp_rank` and then write WITHOUT dividing, i.e.
widened ids straight into a rank-local buffer. Refusing is the contract;
a re-added masked-but-undivided write is what this guards."""
already collapsed -- so there is no single correct translation and refusing
is the contract."""
def _bare_mla_pool(self):
from sglang.srt.mem_cache.memory_pool import MLATokenToKVPool
@@ -13,29 +13,8 @@
# ==============================================================================
"""KVIndexTranslator -- the read-path id translator.
Covers, CPU-only (the builder's pure-torch reference path; GPU parity of the
Triton kernel is a later CUDA CI pin):
- strict passthrough: a non-unified source returns the SAME req_to_token /
req_pool_indices objects -- zero tensor ops, no copies (the property that
makes backend re-pointing byte-identical for every non-unified server);
- static SWA pools keep their legacy full->swa mapping on the view;
- the read table matches the hand formula
entry[b, c] = clamp(v2p[req_to_token[req[b], c*ps] // ps] * mult, 0)
over the REAL SWA composite's tables (full AND swa, ps in {1, 4},
multiplier in {1, 2L}), with the swa table built from VIRTUAL ids;
- sink routing: dead lanes (seq_len 0), -1 req_to_token entries, and
tombstoned v2p pages all read entry 0;
- the capture contract: buffers are zero-filled and idempotent; a refresh
updates ONLY the live prefix (stale tails and rows beyond bs keep prior
contents); the returned table is the WHOLE buffer (pointer-stable);
- the eager-view memo: a single source-resident slot keyed by batch
identity (same batch shares one build; the next batch replaces it; a
dead batch never matches);
- the two-phase write contract: the rebind touches only the full side, and
the sliding-window write loc derives POINTWISE from the kernel-facing values
(pads, slices, and fresh copies included), for both pool families.
python -m pytest test/registered/unit/mem_cache/test_kv_index_translator.py -v
CPU-only: these exercise the builder's pure-torch reference path, not the
Triton kernel.
"""
from sglang.test.ci.ci_register import register_cpu_ci
@@ -137,9 +116,8 @@ def _reference_table(req_to_token, req_pool_indices, seq_lens, v2p, mult, ps, wi
class TestPassthrough(unittest.TestCase):
def test_non_unified_returns_same_objects(self):
"""The strict-passthrough property: no copy, no branch, the exact
tensors backends read today. A regression here (any tensor op on the
non-unified path) breaks byte-identity for every static-pool server."""
"""Strict passthrough: no copy, no branch. Any tensor op on the
non-unified path breaks byte-identity for every static-pool server."""
req_to_token = torch.arange(64, dtype=torch.int64).reshape(4, 16)
src = KVIndexTranslator(
req_to_token=req_to_token,
@@ -166,7 +144,7 @@ class TestPassthrough(unittest.TestCase):
def _alloc_and_fill(allocator, ps, lens):
"""Allocate per-request virtual runs and write them into a fake
req_to_token; returns (req_to_token, req_pool_indices, seq_lens)."""
req_to_token."""
width = 16 * ps
req_to_token = torch.full((len(lens), width), -1, dtype=torch.int64)
for r, n in enumerate(lens):
@@ -183,11 +161,10 @@ def _alloc_and_fill(allocator, ps, lens):
class TestReadTableBuild(unittest.TestCase):
def test_read_table_matches_reference_across_multipliers(self):
"""The load-bearing formula pin: full AND swa read tables equal
the independent per-element derivation, across page sizes and both
multiplier regimes (MLA=1, MHA=2L). The swa table agreeing with
a formula over VIRTUAL ids is also the never-chained-through-
full-physical proof."""
"""Both read tables must equal the independent per-element derivation,
across page sizes and both multiplier regimes (MLA=1, MHA=2L); the swa
table agreeing over VIRTUAL ids proves it is never chained through
full-physical."""
for ps in (1, 4):
for collapse in (True, False):
allocator = _build_composite(ps, collapse=collapse)
@@ -235,10 +212,8 @@ class TestReadTableBuild(unittest.TestCase):
)
def test_packed_stream_equals_the_rectangle_it_replaces(self):
"""The packed builder and the rectangle must agree element for element:
packed[indptr[b] + p] == ids[b, p // ps] * ps + p % ps. Consumers that
plan over the stream and consumers that read the page table have to see
the same KV, so a change to either builder alone turns this red."""
"""The two builders must agree element for element:
packed[indptr[b] + p] == ids[b, p // ps] * ps + p % ps."""
for ps in (1, 4):
allocator = _build_composite(ps)
req_to_token, rows, seq_lens = _alloc_and_fill(
@@ -269,9 +244,9 @@ class TestReadTableBuild(unittest.TestCase):
)
def test_sink_routing(self):
"""Dead lanes (seq_len 0), -1 slots inside the live prefix, and
tombstoned v2p pages must ALL read entry 0 -- one wild entry is a
captured-graph OOB read at replay."""
"""Dead lanes, -1 slots inside the live prefix, and tombstoned v2p
pages must ALL read entry 0; one wild entry is a captured-graph OOB
read at replay."""
ps = 4
allocator = _build_composite(ps)
req_to_token, rows, seq_lens = _alloc_and_fill(
@@ -300,12 +275,9 @@ class TestBuildInto(unittest.TestCase):
(their rows ARE the read table's rows)."""
def test_prefix_filled_tail_sentinel_preserved_width_capped(self):
"""Three contracts in one batch: entries equal the read-table formula,
lanes past each row's live pages keep the backend's -1 sentinel
(prefix-only -- a tail write scatters the trtllm sentinel contract),
and a table padded WIDER than the req_to_token page span (trtllm's
LCM alignment) is capped instead of tripping the builder's width
assert."""
"""The -1 tail sentinel belongs to the backend, and a table padded
WIDER than the req_to_token page span (trtllm's LCM alignment) must be
capped rather than trip the builder's width assert."""
ps = 4
allocator = _build_composite(ps)
full_mult = allocator.kernel_page_multiplier
@@ -339,8 +311,8 @@ class TestBuildInto(unittest.TestCase):
)
def test_passthrough_source_refuses(self):
"""Callers dispatch on `enabled`; a passthrough source has no v2p to
build from and must fail loud, not fill garbage."""
"""Callers dispatch on `reads_are_translated`; a passthrough source has
no v2p to build from and must fail loud, not fill garbage."""
src = KVIndexTranslator(
req_to_token=torch.zeros((2, 4), dtype=torch.int64),
token_to_kv_pool_allocator=SimpleNamespace(),
@@ -360,21 +332,18 @@ class TestPoolOwnership(unittest.TestCase):
"""A runner only gets the kernel-facing id space when the pool IT reads and
writes is the one the allocator's ids address.
Guarded shape: a runner handed a SHARED allocator (one slot index space,
one req_to_token) while owning a SEPARATE KV buffer sized to the
allocator's SLOT count. Probing the allocator alone reports "unified" for
that runner, so its indices would be mapped into the composite's
kernel-facing space (kernel-facing ids up to num_pages * multiplier) and then used
to address a buffer with only num_slots rows -- out of bounds on both the
read gather and the KV store.
Guarded shape: a runner handed a SHARED allocator while owning a SEPARATE
KV buffer sized to the allocator's SLOT count. Probing the allocator alone
reports "unified" for that runner, so its indices would be mapped into the
composite's kernel-facing space (ids up to num_pages * multiplier) and used
to address a buffer with only num_slots rows.
"""
def test_real_factory_bundle_satisfies_the_ownership_identity(self):
"""The guard rests on `allocator.get_kvcache() is token_to_kv_pool`
holding for a REAL target bundle. If a factory ever returned a pool
the allocator does not hold, the guard would silently disable the
unified path for EVERY model -- so pin it against the real factory
rather than against this file's own construction."""
"""The guard rests on `allocator.get_kvcache() is token_to_kv_pool`, so
a factory returning a pool the allocator does not hold would silently
disable the unified path for EVERY model. Pinned against the real
factory, not this file's own construction."""
from sglang.srt.mem_cache.unified_memory_pool import init_unified_swa_pools
bundle = init_unified_swa_pools(
@@ -424,9 +393,9 @@ class TestPoolOwnership(unittest.TestCase):
self.assertFalse(src.is_translating)
def test_disabled_source_is_strict_passthrough(self):
"""Consequence of the guard: such a runner must see RAW virtual ids on
the read table -- they index its own pool directly. A translate here is
the out-of-bounds bug the ownership identity exists to prevent."""
"""Such a runner must see RAW virtual ids: they index its own pool
directly, and a translate here is the out-of-bounds bug the ownership
identity exists to prevent."""
alloc = _build_composite(ps=1)
req_to_token = torch.arange(16, dtype=torch.int32, device=_DEV).view(2, 8)
src = KVIndexTranslator(
@@ -483,10 +452,9 @@ class TestCaptureContract(unittest.TestCase):
self.assertTrue(bool((cap[1:] == 7).all()), "rows beyond bs were touched")
def test_row_ids_not_reallocated_across_builds(self):
"""`row_ids` is a constant arange sized once from the request pool, so
builds at different batch sizes hand back slices of ONE buffer. A
per-build `torch.arange` would be correct but would spend an allocation
and a launch on every replay prep."""
"""`row_ids` is one arange sized from the request pool and sliced per
build; a per-build `torch.arange` would be correct but costs an
allocation and a launch on every replay prep."""
allocator = _build_composite(1)
req_to_token, rows, seq_lens = _alloc_and_fill(allocator, 1, lens=[4, 2, 3])
src = _make_source(allocator, req_to_token, 1)
@@ -531,10 +499,9 @@ class _FakeForwardBatch:
class TestViewMemo(unittest.TestCase):
"""The eager view is memoized ON THE SOURCE in a single slot keyed by
batch identity -- per-batch state stays out of the ForwardBatch (it does
not scale with the number of id spaces), and one metadata build's many
consumers still share one table build."""
"""The eager view is memoized ON THE SOURCE in a single slot keyed by batch
identity, so per-batch state stays out of the ForwardBatch while one
metadata build's many consumers still share one table build."""
def _fb(self, allocator, ps, lens):
req_to_token, rows, seq_lens = _alloc_and_fill(allocator, ps, lens=lens)
@@ -590,12 +557,10 @@ class TestViewMemo(unittest.TestCase):
class TestWriteLoc(unittest.TestCase):
"""The two-phase write contract: phase 1 (`rebind_write_loc`) rebinds the
full side once at ForwardBatch construction; phase 2 derives the
sliding-window write loc on demand, POINTWISE from the full-side
values. Value-based derivation is the property under test: pads, slices,
and fresh copies of the loc must all derive correctly with no handover
and no stored per-forward state."""
"""The two-phase write contract: `rebind_write_loc` rebinds the full side
once at ForwardBatch construction, and the sliding-window write loc derives
POINTWISE from the full-side values -- pads, slices, and fresh copies
included -- with no handover and no stored per-forward state."""
def _built(self, ps=1, n=4):
allocator = _build_composite(ps)
@@ -622,10 +587,8 @@ class TestWriteLoc(unittest.TestCase):
self.assertTrue(torch.equal(virt, keep))
def test_swa_write_loc_round_trips_from_full_side(self):
"""The derived property behind phase 2: for any virtual run t,
deriving from the kernel-facing full-side values must equal the direct
virtual->swa translate — `field(full(t)) == swa(t)` across page sizes
and multipliers."""
"""Derived property: `field(full(t)) == swa(t)` for any virtual run t,
across page sizes and multipliers."""
for ps in (1, 4, 64):
src, _, rows, seq_lens, _, want_full, want_swa = self._built(
ps=ps, n=3 * ps
@@ -634,30 +597,15 @@ class TestWriteLoc(unittest.TestCase):
self.assertTrue(torch.equal(got, want_swa))
def test_pad_lanes_derive_to_sink(self):
"""The DP pad appends zeros; kernel-facing 0 is the reserved padding slot in
every id space, so pad lanes must derive to swa slot 0 with no
`num_live` bookkeeping."""
"""The DP pad appends zeros, and kernel-facing 0 is the reserved
padding slot in every id space, so pad lanes derive to swa slot 0 with
no `num_live` bookkeeping."""
src, _, rows, seq_lens, _, want_full, want_swa = self._built(n=3)
padded = torch.cat([want_full, want_full.new_zeros(2)])
got = self._field(src, rows, seq_lens, padded)
self.assertTrue(torch.equal(got[:3], want_swa))
self.assertTrue(bool((got[3:] == 0).all()), "pad lanes must land on slot 0")
def test_slice_and_copy_derive_pointwise_without_handover(self):
"""REGRESSION (design): the retired identity-resolver refused any
tensor it had not been handed -- a TBO child's re-padded slice or a
registry's fresh copy raised. Value-based derivation must accept
both, pointwise, with no adopt/handover call."""
src, _, rows, seq_lens, _, want_full, want_swa = self._built(n=4)
padded = torch.cat([want_full, want_full.new_zeros(2)])
# TBO-child shape: a slice crossing the pad boundary.
got = self._field(src, rows, seq_lens, padded[2:6])
self.assertTrue(torch.equal(got[:2], want_swa[2:4]))
self.assertTrue(bool((got[2:] == 0).all()))
# Registry shape: a fresh equal-value copy.
got2 = self._field(src, rows, seq_lens, want_full.clone())
self.assertTrue(torch.equal(got2, want_swa))
def test_tombstoned_swa_page_clamps_to_sink(self):
src, allocator, rows, seq_lens, virt, want_full, _ = self._built(ps=1, n=2)
allocator.swa_v2p_page_table[int(virt[0])] = -1
@@ -11,22 +11,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Unit tests for the page-major envelope byte layout.
"""``move_kv_cache_native`` -- the stock per-layer 3-D move static-pool
compaction rides on -- must stay byte-exact. CPU-only.
The subject here is the ENVELOPE — the byte layout the unified pool stores its
KV in — pinned through ``MHASubPoolSpec``'s offset math. The 3-D per-layer views
the pool exposes over the same bytes are covered by
``test_unified_mha_views.py``, which also pins the view addressing
against the envelope formula byte for byte.
Verifies that:
1. ``MHASubPoolSpec.layer_k_offset_in_page`` / ``layer_v_offset_in_page`` math
matches the layout intent at ``page_size == 1`` and ``> 1``.
2. ``move_kv_cache_native`` (the stock per-layer 3-D move) stays byte-exact.
CPU-only — no GPU / Triton needed.
python -m pytest test/registered/unit/mem_cache/test_layout_compat.py -v
The page-major envelope layout and the per-layer views over it are covered by
``test_unified_mha_views.py``, which pins the view addressing against the
envelope formula byte for byte.
"""
from sglang.test.ci.ci_register import register_cpu_ci
@@ -38,69 +28,12 @@ import unittest
import torch
from sglang.srt.mem_cache.memory_pool import move_kv_cache_native
from sglang.srt.mem_cache.unified_memory_pool import MHASubPoolSpec
_DEV = "cpu"
def _make_mha_spec(name, grow, layer_num=2, head_num=2, head_dim=4):
return MHASubPoolSpec(
name=name,
layer_num=layer_num,
head_num=head_num,
head_dim=head_dim,
store_dtype=torch.float16,
grow_direction=grow,
)
class TestMHASpecLayerOffsets(unittest.TestCase):
"""Verify ``layer_k_offset_in_page`` / ``layer_v_offset_in_page`` math."""
def test_offsets_at_page_size_1_match_envelope(self):
spec = _make_mha_spec("full", "up", layer_num=3, head_num=2, head_dim=4)
# At ps=1, layer-major within a 1-token page IS envelope-per-token.
# Layer L's K offset = L * (k_row + v_row); V offset = +k_row.
k_row = spec.k_row_bytes()
v_row = spec.v_row_bytes()
for L in range(spec.layer_num):
self.assertEqual(
spec.layer_k_offset_in_page(L, page_size=1),
L * (k_row + v_row),
)
self.assertEqual(
spec.layer_v_offset_in_page(L, page_size=1),
L * (k_row + v_row) + k_row,
)
def test_offsets_at_page_size_gt_1(self):
spec = _make_mha_spec("full", "up", layer_num=3, head_num=2, head_dim=4)
ps = 8
k_row = spec.k_row_bytes()
v_row = spec.v_row_bytes()
# Layer L's K block within the page starts at L * ps * (k_row+v_row).
# V block starts at +ps * k_row.
for L in range(spec.layer_num):
self.assertEqual(
spec.layer_k_offset_in_page(L, page_size=ps),
L * ps * (k_row + v_row),
)
self.assertEqual(
spec.layer_v_offset_in_page(L, page_size=ps),
L * ps * (k_row + v_row) + ps * k_row,
)
def test_page_bytes(self):
spec = _make_mha_spec("full", "up", layer_num=3, head_num=2, head_dim=4)
# page_bytes = page_size * entry_bytes (preserved invariant)
for ps in [1, 8, 64, 256]:
self.assertEqual(spec.page_bytes(ps), ps * spec.entry_bytes())
class TestMoveKVCacheNative(unittest.TestCase):
def test_move_kv_cache_3d_path_unchanged(self):
"""The stock per-layer 3-D move must relocate exactly the named token
rows, byte-identically compaction on static pools rides on it."""
rows, byte-identically; compaction on static pools rides on it."""
k = [torch.zeros((32, 2, 4), dtype=torch.float16) for _ in range(2)]
v = [torch.zeros((32, 2, 4), dtype=torch.float16) for _ in range(2)]
for L in range(2):
File diff suppressed because it is too large Load Diff
@@ -11,31 +11,20 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Locked-full SWA tombstone-recovery under the unified pool (action handler).
"""Locked-full SWA tombstone recovery under the unified pool (action handler).
`RecoverSWAWithLockedFull` recovers a tombstoned SWA node whose full value is
LOCKED: the node cannot adopt the incoming request's ids wholesale, so the
static-pool recipe hands the node the INCOMING ids' swa pages, frees only their
FULL pages, and re-points the locked ids through `full_to_swa_index_mapping`.
`RecoverSWAWithLockedFull` hands a tombstoned SWA node the INCOMING request's
swa pages and frees only their FULL pages. The static recipe re-points the
locked ids through `full_to_swa_index_mapping`; the unified composite has no
such tensor -- the swa sub-pool's v2p IS the mapping -- so it expresses the
same move as a page-ownership rebind and then frees the incoming ids through
the composite, whose `swa_v2p_pages > 0` filter skips the tombstoned swa side.
The unified composite has no mapping tensor — the swa sub-pool's v2p IS the
mapping — and its `set_full_to_swa_mapping` is an explicit no-op stub. The
pre-fix handler therefore raised AttributeError on `full_to_swa_index_mapping`
(and, had that line been removed, would have silently skipped the rebind while
line 1 freed swa pages the kept ids still referenced). The fix expresses the
same move as a page-ownership REBIND: bind the node's virtual pages to the
incoming pages' physical pages, tombstone the incoming ones, then free the
incoming ids through the composite — whose `swa_v2p_pages > 0` filter skips the
tombstoned swa side, releasing ONLY the full side.
Why the recovery must succeed rather than decline (the v1 lesson, still true on
this branch): the TreeCore insert walk counts the node in `prefix_len`
regardless of component consumption, while the SWA match validator rejects a
`value is None` node — a declined recovery makes `insert` report a prefix the
follow-up `match_prefix` cannot honor, tripping
The recovery must succeed rather than decline: the TreeCore insert walk counts
the node in `prefix_len` regardless of component consumption, while the SWA
match validator rejects a `value is None` node, so a declined recovery reports
a prefix `match_prefix` cannot honor and trips
`new_prefix_len <= len(new_indices)` in `cache_unfinished_req`.
python -m pytest test/registered/unit/mem_cache/test_swa_locked_full_recover_unified.py -v
"""
import unittest
@@ -121,8 +110,8 @@ class _Probe(SWAComponent):
class _StaticAllocRecorder:
"""Stands in for the STATIC SWATokenToKVPoolAllocator: has the mapping
tensor and a real set_full_to_swa_mapping. The handler must keep routing
static pools through the original recipe."""
tensor and a real `set_full_to_swa_mapping`, so the handler must keep
routing static pools through the original recipe."""
def __init__(self, n=16):
self.full_to_swa_index_mapping = torch.arange(n, dtype=torch.int64)
@@ -133,9 +122,8 @@ class _StaticAllocRecorder:
self.full_attn_allocator = self
def set_full_to_swa_mapping(self, full, swa):
# Honour the write like the real static allocator: the handler routes
# every mapping write THROUGH the API (never by indexing the tensor),
# so the fake must apply it for the mapping asserts to observe it.
# The handler routes every mapping write THROUGH the API, so the fake
# must apply it for the mapping asserts to observe anything.
self.mapping_calls.append((full, swa))
self.full_to_swa_index_mapping[full.to(torch.int64)] = swa.to(torch.int64)
@@ -171,9 +159,9 @@ class _RecoverTestBase(unittest.TestCase):
class TestPagePairing(_RecoverTestBase):
def test_pairs_positionally_not_by_sorted_id(self):
"""Allocation hands out virtual ids in no particular order; deduping
with `torch.unique` (which sorts) would bind the node's page k to an
unrelated incoming page — silent wrong-KV."""
"""Allocation hands out virtual ids in no particular order, so a
`torch.unique` dedup (which sorts) would bind the node's page k to an
unrelated incoming page."""
probe, _ = self._probe()
kept = torch.tensor([9, 7, 5], dtype=torch.int64) # descending
incoming = torch.tensor([2, 4, 6], dtype=torch.int64) # ascending
@@ -225,15 +213,17 @@ class TestOwnershipTransfer(_RecoverTestBase):
class TestRecoverActionHandler(_RecoverTestBase):
def test_recovery_sets_a_live_device_value_and_frees_only_the_full_side(self):
"""End-to-end through apply_component_action — the pre-fix handler
raises AttributeError (`full_to_swa_index_mapping`) on this exact
call. Post-fix: the node gets a LIVE swa value, the HANDLER neither
allocates nor frees any swa page (ownership only moves), and the
incoming ids' FULL side returns to the pool."""
"""Bug regression: recovery must give the node a LIVE swa value and
return only the incoming ids' FULL side, allocating and freeing no swa
page of its own."""
probe, allocator = self._probe()
swa = allocator.swa_attn_allocator
kept, incoming = self._two_ranges(allocator)
allocator.free_swa(kept) # what eviction does when it tombstones
self.assertTrue(
bool((allocator.translate_loc_from_full_to_swa(kept) == 0).all()),
"precondition: a tombstoned range translates to the sink",
)
# Snapshot AFTER the setup traffic: the invariant under test is that
# the recovery handler itself moves ownership without moving capacity.
swa_live = swa.allocated_count()
@@ -260,21 +250,6 @@ class TestRecoverActionHandler(_RecoverTestBase):
full_avail + len(incoming),
"the incoming ids' FULL side must come back",
)
def test_recovered_ids_translate_to_live_pages_not_the_sink(self):
"""The tombstoned range translates to the clamped sink before the
recovery and to real pages after — recovering from the node's OWN
already-freed ids (instead of the donated ones) reintroduces the sink."""
probe, allocator = self._probe()
kept, incoming = self._two_ranges(allocator)
allocator.free_swa(kept)
self.assertTrue(
bool((allocator.translate_loc_from_full_to_swa(kept) == 0).all()),
"precondition: a tombstoned range translates to the sink",
)
probe.apply_component_action(
RecoverSWAWithLockedFull(node_id=1, kept_full=kept, incoming_full=incoming)
)
self.assertTrue(
bool((allocator.translate_loc_from_full_to_swa(kept) > 0).all()),
"after recovery the node's ids must address live swa pages",
@@ -283,8 +258,8 @@ class TestRecoverActionHandler(_RecoverTestBase):
class TestStaticPoolPathUnchanged(unittest.TestCase):
def test_static_allocator_keeps_the_mapping_recipe(self):
"""A static SWA allocator (has the mapping tensor) must keep the
original recipe — the unified branch must not hijack it."""
"""A static SWA allocator must keep the mapping recipe; the unified
branch must not hijack it."""
static = _StaticAllocRecorder()
probe = _Probe.__new__(_Probe)
probe.cache = _Cache(static)
@@ -296,11 +271,8 @@ class TestStaticPoolPathUnchanged(unittest.TestCase):
RecoverSWAWithLockedFull(node_id=3, kept_full=kept, incoming_full=incoming)
)
# Both mapping writes go through the allocator API -- the kept remap
# via set_full_to_swa_mapping, the incoming tombstone via
# clear_full_to_swa_mapping -- never by indexing
# `full_to_swa_index_mapping` (the tensor is absent on the unified
# composite by design).
# Both mapping writes go through the allocator API, never by indexing
# `full_to_swa_index_mapping` (absent on the unified composite).
self.assertEqual(len(static.mapping_calls), 1, "static recipe must run")
self.assertEqual(len(static.clear_calls), 1, "incoming must be tombstoned")
self.assertTrue(
@@ -11,55 +11,32 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""`SWAKVPool.get_v_head_dim()` the pool method a mambaish + hybrid-SWA
model reaches on boot.
"""`SWAKVPool.get_v_head_dim()` -- the pool method a mambaish + hybrid-SWA model
reaches on boot.
`TritonAttnBackend.__init__` picks its `v_head_dim` from one of three
branches, and the middle one asks the POOL:
if sliding_window_size is not None and swa_v_head_dim != v_head_dim:
... from model_config ... # asymmetric hybrid SWA
elif mambaish_config(model_config) is not None:
v_head_dim = token_to_kv_pool.get_v_head_dim() # <-- this one
else:
... from get_value_buffer(start_layer) ...
A model that is BOTH mambaish AND hybrid-SWA with MATCHING full/SWA value
head dims (Inkling-class) skips the first branch and lands in the second —
where its pool is an SWA-shaped pool, which had no `get_v_head_dim`. The
server died at backend construction with
AttributeError: 'SWAKVPool' object has no attribute 'get_v_head_dim'
on the STATIC pool and, identically, on `UnifiedSWAKVPool`. Neither the
mamba-hybrid pools (`HybridLinearKVPool` has the method) nor pure hybrid-SWA
models (not mambaish, so the branch is never taken) can reach it, which is
why it went unnoticed.
python -m pytest test/registered/unit/mem_cache/test_swa_pool_v_head_dim.py -v
`TritonAttnBackend.__init__` asks the POOL for `v_head_dim` when the model is
mambaish and its full/SWA value head dims MATCH (Inkling-class), so an
SWA-shaped pool without the method kills backend construction with
AttributeError.
"""
import inspect
import unittest
import torch
from sglang.srt.mem_cache.memory_pool import HybridLinearKVPool
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
from sglang.srt.mem_cache.unified_memory_pool import UnifiedSWAKVPool
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=15, suite="base-a-test-cpu")
_DEV = "cpu"
_FULL_V_HEAD_DIM = 8
_SWA_V_HEAD_DIM = 8 # MATCHING this is what routes Inkling into the branch
_SWA_V_HEAD_DIM = 8 # MATCHING: this is what routes Inkling into the branch
def _swa_pool():
"""A static SWAKVPool with the Inkling-class layer split: full and SWA
layers interleaved, layer 0 NOT a full-attention layer (which is exactly
why the backend asks the pool instead of indexing layer 0)."""
"""Inkling-class layer split: full and SWA layers interleaved, layer 0 NOT a
full-attention layer, which is why the backend asks the pool at all."""
return SWAKVPool(
size=32,
size_swa=16,
@@ -76,49 +53,9 @@ def _swa_pool():
class TestSWAPoolVHeadDim(unittest.TestCase):
def test_static_pool_reports_the_full_side_value_head_dim(self):
"""Red before the fix with AttributeError; the value must be the FULL
side's, since that is the geometry the caller means."""
pool = _swa_pool()
self.assertEqual(pool.get_v_head_dim(), _FULL_V_HEAD_DIM)
def test_answer_matches_the_full_pool_buffer_not_layer_zero(self):
"""Layer 0 is an SWA layer here, so a naive `get_value_buffer(0)`
would read the SWA side. Pin that the method routes through the FULL
sub-pool at its own start_layer — the property that makes it correct
under pipeline parallelism too."""
pool = _swa_pool()
want = pool.full_kv_pool.get_value_buffer(pool.full_kv_pool.start_layer).shape[
-1
]
self.assertEqual(pool.get_v_head_dim(), want)
# And layer 0 really is the SWA side in this fixture.
_, is_swa = pool.layers_mapping[0]
self.assertTrue(is_swa, "fixture must keep layer 0 on the SWA side")
def test_unified_swa_pool_inherits_it(self):
"""`UnifiedSWAKVPool` subclasses `SWAKVPool`, so the unified tri-pool
path (mambaish + hybrid SWA in one buffer) is covered by the same
method — no second implementation to drift."""
self.assertTrue(issubclass(UnifiedSWAKVPool, SWAKVPool))
self.assertIs(
UnifiedSWAKVPool.get_v_head_dim,
SWAKVPool.get_v_head_dim,
"the unified pool must inherit the method, not shadow it",
)
def test_signature_matches_the_hybrid_linear_precedent(self):
"""The backend calls this method on whichever pool it holds, so every
pool reachable from the mambaish branch must expose the SAME
zero-argument shape. `HybridLinearKVPool` is the precedent this one
mirrors; a future pool added to that branch has to match too."""
for cls in (SWAKVPool, HybridLinearKVPool):
sig = inspect.signature(cls.get_v_head_dim)
self.assertEqual(
[p for p in sig.parameters if p != "self"],
[],
f"{cls.__name__}.get_v_head_dim must take no arguments",
)
if __name__ == "__main__":
unittest.main()
@@ -11,28 +11,17 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Byte-conservation verifier for the unified 2-pool composites.
"""Byte-conservation verifier (`verify_byte_accounting`) for the unified
2-pool composites.
`verify_byte_accounting` is the idle-time tripwire the token-identity leak
check cannot provide: the unified pool's correctness rests on BYTE bookkeeping
(watermark spans, holes, pending compaction, frontier ordering inside one
shared buffer), and a drifted counter admits requests into memory that is not
actually free — silent corruption territory, not a crash.
The unified pool's correctness rests on BYTE bookkeeping -- watermark spans,
holes, pending compaction, frontier ordering inside one shared buffer -- and
a drifted counter admits requests into memory that is not actually free.
Nothing about that is visible to the token-identity leak check, so this
verifier is the only idle-time tripwire for it.
Derived properties pinned here:
* Conservation: on a lazy end pool the watermark span must equal
live + holes + pending pages at EVERY point of a healthy lifecycle
(alloc, partial free, group free, flush) — not just at rest.
* The check is not vacuous: drifting any single term (live count, watermark,
a leaked hole) reports loudly, naming the sub-pool.
* Chain order: one member's low frontier clearing the other's high frontier
is what "two pools share one buffer without overlap" MEANS; the pair check
must hold regardless of which member grows up.
* The strict escalation env defaults OFF: promoting the diagnostic to a
RuntimeError is a validation posture, not the production one.
python -m pytest test/registered/unit/mem_cache/test_unified_byte_accounting.py -v
The conservation identity: on a lazy end pool the watermark span equals
live + holes + pending pages at EVERY point of a lifecycle, not just at rest.
"""
import unittest
@@ -62,18 +51,6 @@ def _paged_pair(lazy: bool):
class TestHealthyLifecycleReportsClean(unittest.TestCase):
def test_swa_composite_clean_at_every_step(self):
inst, allocator, kvcache = _swa_composite()
self.assertEqual(allocator.verify_byte_accounting(), [])
v = inst._alloc(allocator, kvcache, 8)
self.assertEqual(allocator.verify_byte_accounting(), [])
allocator.free_swa(v[:4]) # tombstone half the swa side
self.assertEqual(allocator.verify_byte_accounting(), [])
inst._free(allocator, kvcache, v)
self.assertEqual(allocator.verify_byte_accounting(), [])
allocator.clear()
self.assertEqual(allocator.verify_byte_accounting(), [])
def test_lazy_end_pool_clean_through_free_and_flush(self):
full, _swa = _paged_pair(lazy=True)
self.assertEqual(full._byte_accounting_violations(), [])
@@ -86,10 +63,10 @@ class TestHealthyLifecycleReportsClean(unittest.TestCase):
class TestDriftReportsLoudly(unittest.TestCase):
"""Each mutation below models a distinct bookkeeping bug; the verifier
must name the drifted sub-pool. Without these, a regression in any single
counter passes every other test (the pool still 'works' it just lies
about capacity)."""
"""Each mutation models a distinct bookkeeping bug; the verifier must name
the drifted sub-pool. Without these, a regression in any single counter
passes every other test -- the pool still 'works', it just lies about
capacity."""
def _lazy_full(self):
full, _swa = _paged_pair(lazy=True)
@@ -98,20 +75,27 @@ class TestDriftReportsLoudly(unittest.TestCase):
self.assertEqual(full._byte_accounting_violations(), [])
return full
def test_drifted_live_count(self):
full = self._lazy_full()
full.live_page_count += 1
self.assertTrue(any("span" in s for s in full._byte_accounting_violations()))
def test_any_drifted_term_reports(self):
def drift_live_count(full):
full.live_page_count += 1
def test_leaked_hole(self):
full = self._lazy_full()
full._free_phys_pages = full._free_phys_pages[:-1] # hole vanished
self.assertTrue(any("span" in s for s in full._byte_accounting_violations()))
def leak_hole(full):
full._free_phys_pages = full._free_phys_pages[:-1] # hole vanished
def test_drifted_watermark(self):
full = self._lazy_full()
full.watermark_physical += 1
self.assertTrue(any("span" in s for s in full._byte_accounting_violations()))
def drift_watermark(full):
full.watermark_physical += 1
for term, mutate in (
("live_count", drift_live_count),
("leaked_hole", leak_hole),
("watermark", drift_watermark),
):
with self.subTest(term=term):
full = self._lazy_full()
mutate(full)
self.assertTrue(
any("span" in s for s in full._byte_accounting_violations())
)
def test_composite_report_names_the_sub_pool(self):
"""Frontier-bounds drift (checked in BOTH lazy and eager modes): push
@@ -129,11 +113,10 @@ class TestDriftReportsLoudly(unittest.TestCase):
class TestChainFrontierOrder(unittest.TestCase):
def test_overlapping_frontiers_report(self):
"""Both bands hold pages, then the up member's watermark is pushed past
the down member's LIVE low frontier: the two bands now claim the same
bytes of one buffer. (An empty down band cannot overlap — its low
frontier IS the buffer top — so both sides must be populated for the
scenario to be a real corruption.)"""
"""Both bands hold pages, then the up member's watermark is pushed
past the down member's LIVE low frontier. Both sides must be populated
for this to be real corruption: an empty down band cannot overlap,
its low frontier IS the buffer top."""
full, swa = _paged_pair(lazy=False)
chain = mea._end_pair_chain(full, swa)
up, down = chain
@@ -144,14 +127,6 @@ class TestChainFrontierOrder(unittest.TestCase):
out = mea._chain_byte_accounting_violations(chain)
self.assertTrue(any("overlap" in s for s in out), out)
def test_pair_order_is_direction_agnostic(self):
"""The factories and the unit fixtures orient the pair differently;
the check must order by grow direction, not by argument position."""
full, swa = _paged_pair(lazy=False)
a = mea._end_pair_chain(full, swa)
b = mea._end_pair_chain(swa, full)
self.assertEqual([x.sub_pool_name for x in a], [x.sub_pool_name for x in b])
if __name__ == "__main__":
unittest.main()
@@ -13,26 +13,17 @@
# ==============================================================================
"""Byte-budget buffer sizing for the unified 2-pool factories.
Derived properties pinned here:
With ``unified_total_bytes`` set the buffer is that many bytes exactly (the
mamba pair adds the state pool's bytes on top -- the budget is captured AFTER
the state carve-out); without it, sizing falls back to the token-count re-sum.
Sizing from the ratio-derived token counts instead would re-introduce the
configurator's rounding, which floors by the cell size and then page-aligns
EACH side, losing up to about a page of tokens per side.
* Budget honored EXACTLY: with ``unified_total_bytes`` set, the swa pair's
buffer is that many bytes (the mamba pair adds the state pool's bytes on
top — the budget is captured AFTER the state carve-out). Sizing from the
ratio-derived token counts instead re-introduces the configurator's
rounding: the swa split floors the budget by the cell size and then
page-aligns EACH side's token count, so the re-sum reconstructs less
than the profiled budget by up to about one page of tokens per side.
* Fallback: without the budget, sizing is the historical token-count re-sum,
bit-for-bit.
* bs=1 feasibility floor: a budget that cannot fit ONE worst-case request
(full KV at max context, plus one SWA window / the state slots a single
running request locks) raises at BOOT, before any pool construction —
under-sizing is a retract LIVELOCK at runtime, not a perf bug.
* The 4096-byte alignment exists because the factories ``.view()`` the whole
uint8 buffer as the KV dtype; an unaligned budget must be floored, never
rounded up (rounding up overcommits profiled memory).
python -m pytest test/registered/unit/mem_cache/test_unified_byte_budget_sizing.py -v
The bs=1 feasibility floor raises at BOOT, before any pool construction: a
budget that cannot fit ONE worst-case request (full KV at max context, plus
one SWA window / the state slots a running request locks) is a retract
LIVELOCK at runtime, not a perf bug.
"""
import unittest
@@ -92,37 +83,32 @@ def _entry_bytes():
class TestBudgetSizing(unittest.TestCase):
def test_swa_factory_honors_the_budget_exactly(self):
"""The buffer IS the budget, including values the token-count re-sum
cannot represent (not a whole-token multiple per side)."""
e = _entry_bytes()
budget = 96 * e + 512 # deliberately NOT a token-count multiple
bundle = _swa_factory(unified_total_bytes=budget)
self.assertEqual(bundle.unified_memory_pool.total_bytes, budget)
for budget in (
96 * e + 512, # deliberately NOT a token-count multiple
(64 + 32) * e + (e - 2), # almost one more entry
):
with self.subTest(budget=budget):
bundle = _swa_factory(unified_total_bytes=budget)
self.assertEqual(bundle.unified_memory_pool.total_bytes, budget)
def test_fallback_is_the_token_count_resum(self):
e = _entry_bytes()
bundle = _swa_factory()
self.assertEqual(bundle.unified_memory_pool.total_bytes, (64 + 32) * e)
def test_budget_beats_resum_on_rounding(self):
"""The property that motivates the whole phase: the re-sum cannot
represent a budget that is not a whole-token multiple per side, so it
strands bytes the buffer could have held."""
e = _entry_bytes()
budget = (64 + 32) * e + (e - 2) # almost one more entry
bundle = _swa_factory(unified_total_bytes=budget)
self.assertEqual(bundle.unified_memory_pool.total_bytes, budget)
self.assertGreater(budget, (64 + 32) * e)
class TestReservedFloorIsOneSourceOfTruth(unittest.TestCase):
"""The bs=1 floor charges the slot-0 sink, and MUST charge exactly what
`UnifiedKVPool` actually reserves.
Regression (GPU eval_434/436, Falcon-H1 boot): the floor hand-copied the
formula as `page_size * max(entry_bytes)`, applying the page multiplier to
the MAMBA spec. The pool deliberately excludes mamba (it is page_size=1),
so with page_size=256 and a ~139 MB state entry the floor over-charged the
sink by 256x — ~33 GiB of phantom requirement — and a healthy config
failed to boot with 25 GiB of real headroom.
Regression (Falcon-H1 boot): the floor hand-copied the formula as
`page_size * max(entry_bytes)`, applying the page multiplier to the MAMBA
spec, which the pool deliberately excludes because it is page_size=1. At
page_size=256 that over-charged the sink 256x and a healthy config failed
to boot with real headroom to spare.
"""
def _specs(self, page_size):
@@ -134,7 +120,7 @@ class TestReservedFloorIsOneSourceOfTruth(unittest.TestCase):
store_dtype=torch.float16,
grow_direction="down",
)
# A state entry vastly larger than a KV token entry the real ratio
# A state entry vastly larger than a KV token entry: the real ratio
# (~139 MB vs ~45 KB) is what made the over-charge fatal.
mamba = MambaSubPoolSpec(
name="mamba",
@@ -187,51 +173,22 @@ class TestBs1FeasibilityFloor(unittest.TestCase):
self.assertIn("bs=1 floor", str(ctx.exception))
self.assertIn("swa_window_kv", str(ctx.exception))
def test_context_longer_than_the_pool_is_not_rejected(self):
"""REGRESSION: the floor must NOT charge the full-attention token side.
`TpModelWorker.get_worker_info` clamps max_req_len to the pool, so a
context far larger than the buffer is refused at admission, not a
livelock -- and it is an ordinary way to serve a long-context model on
one GPU. Charging it here made such configs fail at boot."""
def test_feasible_floor_inputs_are_not_rejected(self):
"""Floor inputs that must NOT raise: charging the full-attention token
side, or a window the context never clamps, failed these at boot."""
e = _entry_bytes()
bundle = _swa_factory(
unified_total_bytes=200 * e,
model_context_len=1_000_000, # far beyond what the buffer holds
sliding_window_size=16,
)
self.assertEqual(bundle.unified_memory_pool.total_bytes, 200 * e)
def test_feasible_config_boots_with_floor_inputs_present(self):
e = _entry_bytes()
bundle = _swa_factory(
unified_total_bytes=200 * e,
model_context_len=64,
sliding_window_size=16,
)
self.assertEqual(bundle.unified_memory_pool.total_bytes, 200 * e)
def test_window_term_is_clamped_to_context(self):
"""A window larger than the context must charge at most the context —
otherwise short-context models over-raise."""
e = _entry_bytes()
bundle = _swa_factory(
unified_total_bytes=200 * e,
model_context_len=64,
sliding_window_size=10_000, # window >> context
)
self.assertIsNotNone(bundle)
def test_floor_message_itemizes_terms(self):
with self.assertRaises(RuntimeError) as ctx:
_check_bs1_feasibility_floor(
total_bytes=10,
floor_terms=[("a", 8), ("b", 8)],
factory="test",
)
msg = str(ctx.exception)
self.assertIn("a=8", msg)
self.assertIn("b=8", msg)
self.assertIn("16", msg)
for case, model_context_len, sliding_window_size in (
("context_longer_than_the_pool", 1_000_000, 16),
("feasible_config", 64, 16),
("window_larger_than_context", 64, 10_000),
):
with self.subTest(case=case):
bundle = _swa_factory(
unified_total_bytes=200 * e,
model_context_len=model_context_len,
sliding_window_size=sliding_window_size,
)
self.assertEqual(bundle.unified_memory_pool.total_bytes, 200 * e)
def test_exact_floor_passes(self):
"""Boundary: total == floor must NOT raise (>= is the contract)."""
@@ -11,29 +11,17 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Epoch-memoized capacity views on the allocator chain (2-pool subset).
"""Epoch-memoized capacity views on the allocator chain.
The capacity views (`available_size` / `schedulable_available_size` per band,
plus the composite joint view) are pure functions of a handful of
CPU-resident fields across the chain; schedulers read them O(queue) times
between mutations. `_CapacityField` descriptors bump `_capacity_epoch` on
every rebind, so the memos invalidate by construction.
The per-band and composite capacity views are pure functions of a handful of
CPU-resident fields that schedulers read O(queue) times between mutations;
`_CapacityField` descriptors bump `_capacity_epoch` on every rebind, so the
memos invalidate by construction.
The failure mode being guarded: a memo serving a STALE value after a mutation
the epoch machinery missed — either a new mutation site writing a field the
descriptors don't cover, or an in-place write that bypasses `__set__`. Stale
capacity is silent over-/under-admission, not a crash. Hence:
* every mutation kind is followed by memo == fresh-recompute assertions;
* a randomized op-sequence property test (seeded) catches interactions no
hand-written sequence covers;
* a deliberate descriptor-bypassing write must be caught by the IDLE check
(`verify_byte_accounting`) — readers cannot detect it, the battery must.
The tri-pool cases (float span fields, three-band joint view) join at the
tri phase; this file pins the 2-pool chain form they build on.
python -m pytest test/registered/unit/mem_cache/test_unified_capacity_memo.py -v
The guarded failure mode is a memo serving a STALE value after a mutation the
epoch machinery missed -- a new mutation site writing an uncovered field, or
an in-place write that bypasses `__set__`. Readers cannot detect either one;
stale capacity is silent over-/under-admission, not a crash.
"""
import random
@@ -114,10 +102,8 @@ class TestCapacityMemoCoherence(unittest.TestCase):
self._assert_memos_fresh(allocator)
def test_random_op_sequence_value_identity(self):
"""Property: after ANY mutation sequence, memoized views equal fresh
recomputes. Seeded (deterministic) — the sequences cover interleavings
(alloc / partial swa free / grouped free / flush / clear) that no
hand-written case enumerates."""
"""Property: after ANY mutation sequence the memoized views equal
fresh recomputes. Seeded, so the interleavings are deterministic."""
rng = random.Random(0xC0FFEE)
for lazy in (False, True):
with self.subTest(lazy_compaction=lazy):
@@ -162,29 +148,13 @@ class TestCapacityMemoCoherence(unittest.TestCase):
msg=f"bypassing write not caught: {violations}",
)
def test_joint_memo_invalidates_on_swa_only_mutation(self):
"""The joint view depends on the swa end's frontier through the chain
walk; an swa-side-only mutation (tombstoning) must invalidate the
composite memo even though the full side never moved."""
inst, allocator, kvcache = _build(lazy=True)
v = inst._alloc(allocator, kvcache, 8)
self.assertIsNotNone(v)
before = allocator.available_size()
allocator.free_swa(v[:4]) # swa band only
after = allocator.available_size()
self.assertEqual(after, allocator._compute_available_size())
self.assertGreaterEqual(after, before) # holes only ever add room
def test_float_only_span_move_invalidates_every_memo(self):
"""A hole-free float alloc rebinds NO free-list and has no watermark --
the span fields are its ONLY capacity state. If they are not
`_CapacityField` descriptors, the float's own memo AND both
neighbours' (the span flips transparency, walling off their gaps)
keep serving pre-move values.
Driven on a hand-wired end+float+end chain (the composite arrives
with the tri phase); the float is exercised alone so no end-pool
descriptor write can mask a missing span bump."""
"""A hole-free float alloc rebinds NO free-list and has no watermark:
the span fields are its ONLY capacity state, so unless they are
`_CapacityField` descriptors the float's own memo AND both neighbours'
(the span flips transparency, walling off their gaps) keep serving
pre-move values. Exercised on a hand-wired end+float+end chain so no
end-pool descriptor write can mask a missing span bump."""
from test_multi_ended_allocator import TestFloatMultiEndedAllocator
inst = TestFloatMultiEndedAllocator(
@@ -212,21 +182,12 @@ class TestCapacityMemoCoherence(unittest.TestCase):
self.assertLess(da.available_size(), high_end_cached)
self.assertLessEqual(fla.available_size(), float_cached)
def test_bind_rewiring_bumps_the_epoch(self):
"""Rewiring changes what the chain walks see; a memo primed before a
re-bind must not survive it."""
inst, allocator, kvcache = _build(lazy=False)
fa = allocator.full_attn_allocator
e0 = fa._chain_capacity_epoch()
fa.bind_peer(allocator.swa_attn_allocator) # re-bind (same peer)
self.assertGreater(fa._chain_capacity_epoch(), e0)
class TestTriCapacityMemoCoherence(unittest.TestCase):
"""Tri-composite twins of the 2-pool cases: the joint view walks THREE
bands (mamba end, swa float, full end), so a mutation on ANY of them must
invalidate the composite memo including the two mutations only the tri
has: a mamba-end state draw and a float span move behind the composite."""
invalidate the composite memo -- including the two only the tri has, a
mamba-end state draw and a float span move behind the composite."""
def _build_tri(self, lazy=False):
from test_unified_tri_pool import TestUnifiedTriPool
@@ -281,19 +242,6 @@ class TestTriCapacityMemoCoherence(unittest.TestCase):
ma.clear()
self._assert_memos_fresh(allocator)
def test_joint_memo_invalidates_on_mamba_only_mutation(self):
"""The joint view depends on the mamba end's frontier through the
chain walk; a mamba-only mutation must invalidate the composite memo
even though neither KV side moved."""
inst, allocator = self._build_tri()
ma = allocator.mamba_allocator
before = allocator.available_size()
slots = ma.alloc(4)
self.assertIsNotNone(slots)
after = allocator.available_size()
self.assertEqual(after, allocator._compute_available_size())
self.assertLessEqual(after, before)
if __name__ == "__main__":
unittest.main()
@@ -11,28 +11,17 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""The unified free path must not move anything across the PCIe bus.
"""The unified allocator free path must not sync the host.
Two independent host syncs lived in `MultiEndedAllocator`'s free path:
Two syncs are guarded. A tombstone written as ``t[idx] = -1`` makes torch
materialise the scalar as a CPU tensor and copy it H2D, which BLOCKS the host
until the stream drains. And `torch.unique`, recovering distinct PAGE ids from
freed TOKEN ids, has a data-dependent output shape and so must D2H the count;
`free_segment` instead takes stride slices off the caller's `start_pos`, a
page's tokens sitting consecutively in the kv row.
1. Tombstone scatters written as ``t[idx] = -1``. The scalar RHS makes torch
materialise ``-1`` as a CPU tensor and copy it H2D, and a pageable H2D
copy BLOCKS the host until the stream drains. Invisible on decode-shaped
work; ~16 ms per free behind an 8192-token prefill.
2. `torch.unique` recovering distinct PAGE ids from freed TOKEN ids. Its
output shape is data-dependent, so it must D2H the count
(``_unique2 -> item -> _local_scalar_dense -> cudaStreamSynchronize``).
`PagedTokenToKVPoolAllocator` already solved this with `free_segment`:
a page's tokens sit consecutively in the kv row, so given `start_pos` the
page representatives are stride slices. The unified allocators simply
never implemented it and so were permanently on the syncing path.
These tests mirror `test_paged_free_segment.py` -- the same sweep against the
`torch.unique` reference, the same free-group deferral -- because the unified
allocators now mirror that allocator's design rather than a parallel one.
python -m pytest test/registered/unit/mem_cache/test_unified_free_no_host_sync.py -v
Mirrors `test_paged_free_segment.py`, which pins the same properties for
`PagedTokenToKVPoolAllocator`.
"""
import ast
@@ -68,10 +57,9 @@ def _paged_allocator(lazy: bool):
_TABLES = {"virtual_to_physical", "physical_to_virtual"}
# Methods that MUST tombstone through index_fill_. Explicit, because "this
# method writes a tombstone" is a design fact per method, not something a scan
# can infer -- but `test_every_allocator_free_path_is_listed` below fails if a
# new allocator arrives with its own free path and is not added here.
# Methods that MUST tombstone through index_fill_; hand-listed because "writes
# a tombstone" is a per-method design fact a scan cannot infer. Completeness is
# guarded by `test_every_allocator_free_path_is_listed` below.
_TOMBSTONE_METHODS = [
(mea.MultiEndedAllocator, "_free_lazy"),
(mea.MultiEndedAllocator, "free"),
@@ -82,9 +70,8 @@ _TOMBSTONE_METHODS = [
]
# Ways to write `-1` into a table without the value crossing the bus:
# `index_fill_` takes the scalar as an argument torch keeps off the host, and
# the fused launchers store it from inside the kernel.
# Sanctioned no-sync tombstone forms: `index_fill_` takes the scalar as an
# argument torch keeps off the host; the fused launcher stores it in-kernel.
_NO_SYNC_TOMBSTONE_FORMS = ("index_fill_", "free_unbind_inplace")
@@ -109,9 +96,8 @@ def _allocators_in_module():
def _table_touching_methods():
"""Every own method of every allocator whose source names a page table.
DISCOVERY, not a list: a hardcoded list stops guarding the moment a new
allocator class arrives with its own free path -- which is what happened
when FloatMultiEndedAllocator was added and inherited no coverage.
Discovery rather than a hand list, so a new allocator class with its own
free path is covered the day it lands.
"""
out = []
for cls in _allocators_in_module():
@@ -130,15 +116,13 @@ def _table_touching_methods():
def _scalar_index_assignments(fn):
"""`self.<table>[<tensor idx>] = <scalar>` occurrences in fn's source.
Slice assignments (``t[a:b] = -1``) are excluded: a slice is a view, so the
fill needs no index tensor. Tensor-valued scatters are excluded too -- only
the scalar RHS materialises a CPU value tensor.
Slices (``t[a:b] = -1``) and tensor-valued scatters are excluded: only a
scalar RHS behind a tensor index materialises a CPU value tensor.
"""
def _is_scalar_literal(node):
# NOTE: `-1` parses as UnaryOp(USub, Constant(1)), NOT Constant. Testing
# only for Constant silently skips every negative literal -- i.e. every
# tombstone this scan exists to find.
# `-1` parses as UnaryOp(USub, Constant(1)), not Constant; matching
# only Constant silently skips every negative literal.
if isinstance(node, ast.Constant):
return True
return isinstance(node, ast.UnaryOp) and isinstance(node.operand, ast.Constant)
@@ -156,9 +140,8 @@ def _scalar_index_assignments(fn):
continue
if isinstance(tgt.slice, ast.Slice):
continue
# A CONSTANT integer index (`t[0] = 0`, `t[-1] = -1`) is a
# single-element sentinel write, not the tensor-index tombstone this
# guard exists to find, and the only such writes are in `clear()`.
# A constant index (`t[0] = 0`) is a single-element sentinel write,
# not a tensor-index tombstone; only `clear()` does it.
if _is_scalar_literal(tgt.slice):
continue
bad.append(ast.unparse(node))
@@ -167,6 +150,13 @@ def _scalar_index_assignments(fn):
class TestTombstonesDoNotCrossTheBus(unittest.TestCase):
def test_no_scalar_index_assignment(self):
# Self-check first: a scan whose AST matching has drifted reports
# clean on every method below.
def _offender(self):
self.virtual_to_physical[free_v_pages] = -1 # noqa: F821
self.assertEqual(len(_scalar_index_assignments(_offender)), 1)
discovered = _table_touching_methods()
self.assertGreaterEqual(
len(discovered), len(_TOMBSTONE_METHODS), "discovery scan went blind"
@@ -185,24 +175,10 @@ class TestTombstonesDoNotCrossTheBus(unittest.TestCase):
),
)
def test_the_scan_detects_the_scalar_form_it_guards(self):
"""Self-check. The scan is only as good as its AST matching, and it
silently missed every tombstone until `-1` was recognised as
UnaryOp(USub, Constant) rather than Constant. Pin that."""
def _offender(self):
self.virtual_to_physical[free_v_pages] = -1 # noqa: F821
self.assertEqual(len(_scalar_index_assignments(_offender)), 1)
def test_every_allocator_free_path_is_listed(self):
"""The positive list must name every allocator that owns a free path.
REGRESSION: the list used to hold three MultiEndedAllocator methods, so
adding FloatMultiEndedAllocator with its own `free` silently dropped that
free path out of coverage -- and it shipped a scalar tombstone. Fail here
instead, loudly, the next time an allocator arrives.
"""
"""Bug regression: an allocator that owns a free path but is missing
from `_TOMBSTONE_METHODS` must fail loudly here rather than drop out of
tombstone coverage."""
listed = {(cls.__name__, name) for cls, name in _TOMBSTONE_METHODS}
for cls in _allocators_in_module():
for name, fn in vars(cls).items():
@@ -212,9 +188,8 @@ class TestTombstonesDoNotCrossTheBus(unittest.TestCase):
src = inspect.getsource(fn)
except OSError:
continue
# WRITES a page table -- either correctly (index_fill_) or in the
# banned scalar form the scan below catches. A method that only
# READS a table has nothing to tombstone.
# Only a method that WRITES a page table needs a tombstone;
# one that merely READS has nothing to guard.
if not (
any(f"{t}.index_fill_" in src for t in _TABLES)
or _scalar_index_assignments(fn)
@@ -231,10 +206,8 @@ class TestTombstonesDoNotCrossTheBus(unittest.TestCase):
)
def test_free_paths_actually_write_a_tombstone(self):
"""Positive form, so deleting the scatter entirely cannot pass. The
mechanism is not the point -- keeping the tombstone value off the host
is -- so this lists the sanctioned ways to do that and a new one is
added here deliberately."""
"""Positive form, so deleting the scatter entirely cannot pass; a new
no-sync mechanism is added to `_NO_SYNC_TOMBSTONE_FORMS` deliberately."""
for cls, name in _TOMBSTONE_METHODS:
with self.subTest(method=f"{cls.__name__}.{name}"):
src = inspect.getsource(getattr(cls, name))
@@ -244,22 +217,6 @@ class TestTombstonesDoNotCrossTheBus(unittest.TestCase):
f"{_NO_SYNC_TOMBSTONE_FORMS}",
)
def test_index_fill_matches_scalar_assign_semantics(self):
"""Behaviour-preserving, including the edge cases the free path hands
it: empty index, duplicate pages, full table."""
for idx in (
torch.tensor([], dtype=torch.int64),
torch.tensor([1, 3, 5], dtype=torch.int64),
torch.tensor([2, 2, 3], dtype=torch.int64), # duplicates
torch.arange(6, dtype=torch.int64),
):
with self.subTest(n=int(idx.numel())):
a = torch.arange(6, dtype=torch.int64)
b = a.clone()
a[idx] = -1
b.index_fill_(0, idx, -1)
self.assertTrue(torch.equal(a, b))
# --------------------------------------------------------------------------
# 2. free_segment: stride page extraction instead of torch.unique
@@ -270,7 +227,6 @@ class TestFreeSegment(unittest.TestCase):
"""Mirrors `test_paged_free_segment.TestFreeSegment`."""
def test_matches_unique_over_tail_alignments(self):
"""Page-aligned starts against every tail alignment."""
for num_tokens in (1, PAGE_SIZE, PAGE_SIZE + 1, 3 * PAGE_SIZE - 1):
for start in range(0, num_tokens, PAGE_SIZE):
for end in (start + 1, num_tokens):
@@ -288,8 +244,6 @@ class TestFreeSegment(unittest.TestCase):
self.assertEqual(freed.numel(), expected.numel())
def test_never_calls_unique(self):
"""The decisive check -- make `torch.unique` explode. A textual guard
can be fooled; this cannot."""
for start in (0, PAGE_SIZE, 2 * PAGE_SIZE):
alloc = _paged_allocator(lazy=True)
row = alloc.alloc(3 * PAGE_SIZE)
@@ -299,12 +253,6 @@ class TestFreeSegment(unittest.TestCase):
):
alloc.free_segment(row[start : start + PAGE_SIZE], start_pos=start)
def test_unaligned_start_is_rejected(self):
alloc = _paged_allocator(lazy=True)
row = alloc.alloc(3 * PAGE_SIZE)
with self.assertRaises(AssertionError):
alloc.free_segment(row[1 : PAGE_SIZE + 1], start_pos=1)
def test_empty_segment_is_noop(self):
alloc = _paged_allocator(lazy=True)
before = alloc._free_phys_pages.numel()
@@ -312,8 +260,8 @@ class TestFreeSegment(unittest.TestCase):
self.assertEqual(alloc._free_phys_pages.numel(), before)
def test_page_size_one_takes_the_plain_path(self):
"""token == page: nothing to dedup, so `free_segment` must not invent
a stride slice that would drop tokens."""
"""token == page: a stride slice would drop tokens, so take the plain
path."""
alloc = _paged_allocator(lazy=True)
alloc.page_size = 1
v = alloc.alloc(PAGE_SIZE)
@@ -325,12 +273,9 @@ class TestFreeSegment(unittest.TestCase):
class TestFreeGroupKeepsPositions(unittest.TestCase):
"""Mirrors `test_paged_free_segment.test_group_defers_until_group_end`.
Bug regression: buffering RAW tokens and `torch.cat`-ing them at
`free_group_end` destroys each segment's shape, so the merged tensor has no
recoverable page structure and falls back to `torch.unique`. Measured as 71
of 77 `_free_lazy` calls still syncing on gpt-oss and Qwen3.5 ps=256
(eval_429), all attributed to `free_group_end` via the decode path. The fix
buffers page REPRESENTATIVES, so the merge concatenates page ids.
Bug regression: a free group must buffer page REPRESENTATIVES, not raw
tokens -- concatenating raw tokens loses the page structure and sends
`free_group_end` back to `torch.unique`.
"""
def test_group_defers_until_group_end(self):
@@ -346,8 +291,6 @@ class TestFreeGroupKeepsPositions(unittest.TestCase):
self.assertEqual(alloc._free_phys_pages.numel(), before + 2)
def test_group_end_does_not_sync(self):
"""The property the whole fix exists for: a grouped segment free must
complete with `torch.unique` disabled."""
alloc = _paged_allocator(lazy=True)
row = alloc.alloc(3 * PAGE_SIZE)
alloc.free_group_begin()
@@ -360,8 +303,8 @@ class TestFreeGroupKeepsPositions(unittest.TestCase):
self.assertGreater(alloc._free_phys_pages.numel(), 0)
def test_positionless_group_still_uses_the_unique_path(self):
"""Plain `free()` inside a group has no position to keep, so it must
still go through the (syncing) dedup -- correctness over speed."""
"""Plain `free()` carries no position to keep, so it must still take
the syncing dedup -- correctness over speed."""
alloc = _paged_allocator(lazy=True)
row = alloc.alloc(2 * PAGE_SIZE)
alloc.free_group_begin()
@@ -374,12 +317,9 @@ class TestFreeGroupKeepsPositions(unittest.TestCase):
class TestEveryUnifiedAllocatorOverridesFreeSegment(unittest.TestCase):
"""Completeness guard. The base `free_segment` DISCARDS `start_pos` and
"""Completeness guard: the base `free_segment` DISCARDS `start_pos` and
calls plain `free`, so an allocator that inherits it sends every segment
free into the syncing dedup -- silently, with no error and no wrong answer,
just a stalled scheduler thread. That is exactly what happened: the SWA
composite was overridden and the Mamba composite was not, and 77 of 77
`_free_lazy` calls on Qwen3.5 ps=256 still synced (eval_428).
free into the syncing dedup -- silently, with no error and no wrong answer.
"""
def test_all_overridden(self):
@@ -400,8 +340,8 @@ class TestEveryUnifiedAllocatorOverridesFreeSegment(unittest.TestCase):
)
def test_composites_buffer_reps_not_tokens_in_a_group(self):
"""The group buffer must exist on every allocator that can receive a
segment free, or `free_segment` raises inside a group."""
"""Every allocator that can receive a segment free needs the group
buffer, or `free_segment` raises inside a group."""
for cls in (
mea.MultiEndedAllocator,
unified_mamba.UnifiedMambaTokenToKVPoolAllocator,
@@ -432,13 +372,10 @@ class TestUnifiedSwaFullSideGroup(unittest.TestCase):
class TestFreeSwaWindowRatchetNoHostSync(unittest.TestCase):
"""The per-decode-step SWA window ratchet frees a CONTIGUOUS row slice
with host-int, page-aligned bounds — the same shape `free_segment` was
built for. `free_swa(..., start_pos=)` must therefore reach the swa side
with caller-derived page ids: no `torch.unique` (data-dependent shape =
host sync) and no stale-slot `.item()` on the per-step path.
Poisoning the ops is the decisive form (a textual guard can be fooled).
"""The per-decode-step SWA window ratchet frees a CONTIGUOUS row slice with
host-int, page-aligned bounds, so `free_swa(..., start_pos=)` must reach the
swa side with caller-derived page ids: no `torch.unique` and no stale-slot
`.item()` on the per-step path.
"""
PS = 4
@@ -480,8 +417,7 @@ class TestFreeSwaWindowRatchetNoHostSync(unittest.TestCase):
)
def test_ratchet_shape_free_swa_never_syncs(self):
"""Aligned bounds (the ratchet guarantees them at ps>1): no unique,
no item -- on the lazy production config."""
"""Aligned bounds, which the ratchet guarantees at ps > 1."""
alloc = self._swa_composite(lazy=True)
v = alloc.alloc(8 * self.PS)
self.assertIsNotNone(v)
@@ -497,8 +433,8 @@ class TestFreeSwaWindowRatchetNoHostSync(unittest.TestCase):
alloc.free_swa(v[4 * self.PS :], start_pos=4 * self.PS)
def test_full_only_segment_free_never_syncs(self):
"""The request-finish dead half (swa already tombstoned) frees the full
side by page reps: no unique from free_full's token dedup."""
"""Request-finish shape: the swa side is already tombstoned, so the
full side must free by page reps rather than `free_full`'s dedup."""
alloc = self._swa_composite(lazy=True)
v = alloc.alloc(8 * self.PS)
alloc.free_swa(v, start_pos=0)
@@ -523,8 +459,8 @@ class TestFreeSwaWindowRatchetNoHostSync(unittest.TestCase):
alloc.free_swa(v[1 : 5 * self.PS], start_pos=1)
def test_start_pos_path_matches_the_fallback_end_state(self):
"""Derived property: the stride-rep path and the dedup fallback must
leave IDENTICAL allocator state (v2p tombstones, capacity)."""
"""Derived property: the stride-rep path and the dedup fallback leave
identical v2p tombstones and capacity."""
for lazy in (True, False):
with self.subTest(lazy=lazy):
a1 = self._swa_composite(lazy=lazy)
@@ -559,10 +495,9 @@ class TestFreeSwaWindowRatchetNoHostSync(unittest.TestCase):
torch.cuda.is_available(), "the fused tombstone is a Triton kernel"
)
class TestFusedTombstoneWritesBothTables(unittest.TestCase):
"""The source scan above accepts `free_unbind_inplace` as a no-sync
mechanism; this is what makes that acceptance mean something. On CPU the
launcher takes its pure-torch reference path, so nothing else in the suite
ever runs the kernel that does the tombstoning.
"""The source scan accepts `free_unbind_inplace` as a no-sync mechanism; on
CPU the launcher takes its pure-torch reference path, so nothing else in
the suite ever runs the kernel that does the tombstoning.
"""
def test_matches_the_reference_over_randomized_bindings(self):
@@ -602,19 +537,6 @@ class TestFusedTombstoneWritesBothTables(unittest.TestCase):
f"a live binding was disturbed, trial {trial}",
)
def test_cuda_agrees_with_the_cpu_reference(self):
from sglang.kernels.ops.memory.virtual_slot import free_unbind_inplace
v = torch.tensor([3, 0, 5], dtype=torch.int64)
cpu_v2p = torch.tensor([1, 2, 3, 4, 5, 0], dtype=torch.int64)
cpu_p2v = torch.tensor([5, 0, 1, 2, 3, 4], dtype=torch.int64)
cu_v2p, cu_p2v = cpu_v2p.cuda(), cpu_p2v.cuda()
cpu_out = free_unbind_inplace(v, cpu_v2p, cpu_p2v)
cu_out = free_unbind_inplace(v.cuda(), cu_v2p, cu_p2v)
self.assertTrue(torch.equal(cpu_out, cu_out.cpu()))
self.assertTrue(torch.equal(cpu_v2p, cu_v2p.cpu()))
self.assertTrue(torch.equal(cpu_p2v, cu_p2v.cpu()))
def test_empty_free_is_a_noop(self):
from sglang.kernels.ops.memory.virtual_slot import free_unbind_inplace
@@ -11,49 +11,27 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Round-trip correctness of ``UnifiedKVPool._build_mamba_views`` the
"""Round-trip correctness of ``UnifiedKVPool._build_mamba_views`` -- the
envelope-strided conv/temporal (SSM) state views that back ``UnifiedMambaPool``.
This isolates the unified-memory-pool Mamba STATE layout from the full model. It guards
against a class of correctness defect where Falcon-H1 greedy decode is garbled
under the unified memory pool, isolated to the Mamba conv/temporal state path: a
stride/offset/alignment bug in the view construction (analogous to the fixed
`_extract_kv_strides` MHA bug).
Guards against garbled greedy decode from a stride/offset/alignment bug in the
state views. CPU-only.
Within one slot's envelope the bytes are
``[conv[0]·L0 | conv[0]·L1 | ... | conv[1]·L0 | ... | temporal·L0 | ...]`` and
across slots the layout is envelope (slot stride == entry_bytes). Each returned
view is ``(num_layers, max_slots, *inner_shape)``. The conv dtype (bf16, 2 B)
and temporal dtype (fp32, 4 B) DIFFER, so the temporal view's byte offset must
be a multiple of the temporal itemsize an alignment hazard that
``_build_mamba_views`` now asserts.
These tests prove the views:
- round-trip every (tensor, layer, slot) element with the Falcon-like
bf16-conv / fp32-temporal dtype mix (catches stride/offset/alignment bugs);
- do NOT alias each other (conv[i] vs conv[j] vs temporal) or across
layers/slots (catches envelope-overlap);
- match a contiguous ``(num_layers, max_slots, *inner)`` reference exactly
(the shape `MambaPool.State.conv[i]` / `.temporal` expose);
- reject a deliberately mis-aligned spec via the alignment assert.
The round-trip class is skipped on CPU — those views back GPU kernels and we
mirror the GPU path. ``TestKDAFlashInferEnvelopeStateContract`` is pure stride
arithmetic and runs everywhere.
python -m pytest test/registered/unit/mem_cache/test_shared_mamba_views.py -v
``[conv[0] L0 | conv[0] L1 | ... | conv[1] L0 | ... | temporal L0 | ...]``,
the slot stride is ``entry_bytes``, and each returned view is
``(num_layers, max_slots, *inner_shape)``. The conv dtype (bf16, 2 B) and
temporal dtype (fp32, 4 B) may DIFFER, so the temporal view's byte offset must
be a multiple of the temporal itemsize -- an alignment hazard that
``_build_mamba_views`` asserts.
"""
import unittest
import torch
from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci
from sglang.test.ci.ci_register import register_amd_ci, register_cpu_ci
_HAS_CUDA = torch.cuda.is_available()
_DEV = "cuda" if _HAS_CUDA else "cpu"
register_cuda_ci(est_time=30, stage="base-b", runner_config="1-gpu-small")
register_cpu_ci(est_time=30, suite="base-a-test-cpu")
register_amd_ci(est_time=30, stage="stage-b", runner_config="1-gpu-small-amd")
@@ -65,11 +43,10 @@ def _make_pool(
temporal_state_shape,
temporal_dtype,
want_slots=8,
device=_DEV,
device="cpu",
):
"""Build a minimal 2-sub-pool ``UnifiedKVPool`` (a small MHA grow-up peer
+ the Mamba grow-down pool under test) sized to hold >= ``want_slots`` Mamba
slots, and return ``(pool, mamba_spec)``."""
"""Minimal 2-sub-pool ``UnifiedKVPool`` sized to hold >= ``want_slots``
Mamba slots; returns ``(pool, mamba_spec)``."""
from sglang.srt.mem_cache.unified_memory_pool import (
MambaSubPoolSpec,
MHASubPoolSpec,
@@ -97,9 +74,8 @@ def _make_pool(
entry_mamba = mamba_spec.entry_bytes()
entry_full = full_spec.entry_bytes()
entry_max = max(entry_mamba, entry_full)
# Need max_slots("mamba") = total // entry_mamba >= want_slots, and total
# large enough that BOTH pools clear their min_slot_index. Add generous
# headroom, then round up to a multiple of 8 (covers bf16/fp32 .view()).
# Headroom so BOTH pools clear their min_slot_index, then round up to a
# multiple of 8 so bf16/fp32 ``.view()`` stays legal.
total_bytes = want_slots * entry_mamba + 8 * entry_max
total_bytes = ((total_bytes + 7) // 8) * 8
pool = UnifiedKVPool(
@@ -111,11 +87,8 @@ def _make_pool(
return pool, mamba_spec
@unittest.skipUnless(_HAS_CUDA, "shared Mamba views back GPU kernels")
class TestUnifiedMambaViews(unittest.TestCase):
# Falcon-H1-like dims: even conv_dim, bf16 conv, fp32 temporal, several
# layers. (Mamba2 conv state is (conv_dim, kernel-1); temporal/SSM state is
# (nheads, head_dim, ssm_state_size).)
# Falcon-H1-like dims: even conv_dim, bf16 conv, fp32 temporal.
FALCON_KW = dict(
mamba_layer_num=5, # odd, to stress the temporal-offset alignment
conv_state_shapes=[(48, 3)], # conv_dim=48, kernel-1=3
@@ -125,11 +98,9 @@ class TestUnifiedMambaViews(unittest.TestCase):
)
def _fill_and_roundtrip(self, pool, mamba_spec):
"""Write a distinct random tensor to each conv view + the temporal view
(in their own dtypes), then read all back and assert exact equality.
Writing ALL views first and reading ALL after means any envelope overlap
(conv[i]/conv[j]/temporal aliasing) corrupts an earlier write → mismatch.
"""
"""Write a distinct random tensor to every view, then read all of them
back: writing all first makes any envelope overlap corrupt an earlier
write."""
conv_views, temporal_view = pool.mamba_views_for("mamba")
torch.manual_seed(0)
refs = []
@@ -155,34 +126,48 @@ class TestUnifiedMambaViews(unittest.TestCase):
f"stride={temporal_view.stride()}",
)
def test_roundtrip_falcon_like(self):
pool, spec = _make_pool(**self.FALCON_KW)
self._fill_and_roundtrip(pool, spec)
def test_roundtrip_single_layer_single_slot_edges(self):
OTHER_GEOMETRIES = {
# 1 layer, multiple conv tensors, same-dtype conv/temporal.
pool, spec = _make_pool(
"single_layer_single_slot_edges": dict(
mamba_layer_num=1,
conv_state_shapes=[(16, 3), (8, 3)],
conv_dtype=torch.float32,
temporal_state_shape=(4, 8, 16),
temporal_dtype=torch.float32,
want_slots=4,
)
self._fill_and_roundtrip(pool, spec)
def test_roundtrip_multi_conv_tensors(self):
# Two conv tensors + bf16/fp32 mix — exercises the per-conv-tensor offset
),
# Two conv tensors + bf16/fp32 mix: exercises the per-conv-tensor offset
# accumulation in _build_mamba_views.
pool, spec = _make_pool(
"multi_conv_tensors": dict(
mamba_layer_num=3,
conv_state_shapes=[(32, 3), (16, 3)],
conv_dtype=torch.bfloat16,
temporal_state_shape=(8, 8, 16),
temporal_dtype=torch.float32,
want_slots=6,
)
self._fill_and_roundtrip(pool, spec)
),
# ALIGNED spec: conv region = 2 layers * 2*3*2 B = 24 B, a multiple of
# the fp32 temporal itemsize, so it must build and round-trip.
"alignment_ok": dict(
mamba_layer_num=2,
conv_state_shapes=[(2, 3)],
conv_dtype=torch.bfloat16,
temporal_state_shape=(2, 4, 4),
temporal_dtype=torch.float32,
want_slots=4,
),
}
def test_roundtrip_geometries(self):
"""Round-trip every (tensor, layer, slot) element across the geometry
shapes the view builder has to handle."""
for name, kwargs in (
("falcon_like", self.FALCON_KW),
*self.OTHER_GEOMETRIES.items(),
):
with self.subTest(geometry=name):
pool, spec = _make_pool(**kwargs)
self._fill_and_roundtrip(pool, spec)
def test_no_cross_region_overlap(self):
"""Zero buffer; write a sentinel to ONE view; every OTHER view must read
@@ -207,62 +192,10 @@ class TestUnifiedMambaViews(unittest.TestCase):
f"(envelope regions overlap)",
)
def test_per_layer_per_slot_addressing(self):
"""Distinct value per (layer, slot) on the temporal view; verify exact
addressing (no layer/slot aliasing). Uses small integers exactly
representable in the view dtype.
NB: ``temporal_view`` is a non-contiguous strided view, so we must NOT
``.reshape()`` it (that would COPY, breaking the alias) — we
broadcast-assign into the view in place and read back via basic
indexing (which keeps the view)."""
pool, spec = _make_pool(**self.FALCON_KW)
_, temporal_view = pool.mamba_views_for("mamba")
N, S = temporal_view.shape[0], temporal_view.shape[1]
inner_ndim = temporal_view.dim() - 2
# value = layer*S + slot (< N*S, small → exact in fp32)
base = (
torch.arange(N, device=temporal_view.device)[:, None] * S
+ torch.arange(S, device=temporal_view.device)[None, :]
).to(temporal_view.dtype)
# Broadcast (N, S) over the inner dims, in place into the strided view.
temporal_view[:] = base.view(N, S, *([1] * inner_ndim))
# Read back the first inner element of every (layer, slot) via basic
# indexing (stays a view).
readback = temporal_view[(slice(None), slice(None)) + (0,) * inner_ndim]
self.assertTrue(
torch.equal(readback, base),
"temporal (layer, slot) addressing wrong — layer/slot stride bug",
)
def test_matches_contiguous_reference(self):
"""The shared view must be a faithful relabeling of a contiguous
``(num_layers, max_slots, *inner)`` tensor: identical data written by the
same logical index reads back identically."""
pool, spec = _make_pool(**self.FALCON_KW)
conv_views, temporal_view = pool.mamba_views_for("mamba")
for v in conv_views + [temporal_view]:
ref = torch.randn(v.shape, device=v.device).to(v.dtype)
contig = ref.clone().contiguous()
v.copy_(ref)
self.assertEqual(tuple(v.shape), tuple(contig.shape))
self.assertTrue(
torch.equal(v.contiguous(), contig),
"shared view not equivalent to its contiguous counterpart",
)
def test_alignment_guard_fires_on_misaligned_spec(self):
"""A spec whose conv region (bf16) is an odd multiple of 2 B makes the
per-slot entry (= conv_region + N*temporal_row = 2 B + 4 B = 6 B) NOT a
multiple of the temporal itemsize (fp32, 4 B). The temporal/SSM-state
view's storage_offset is computed by integer-dividing a byte offset by
the temporal itemsize, so this would silently mis-offset the view.
``_build_mamba_views`` must reject it with a loud alignment assert.
NOTE: the ``entry_bytes % itemsize`` guard is what fires here, and it
subsumes the conv-region offset check (see the comment in
``_build_mamba_views``). We assert on the shared "misaligned" wording
rather than on which specific guard trips."""
"""A per-slot entry that is not a multiple of the temporal itemsize would
mis-offset the view and must trip the alignment assert; either guard can
fire first, so match on the shared "misaligned" wording."""
with self.assertRaises(AssertionError) as cm:
_make_pool(
mamba_layer_num=1, # entry = 2 B conv + 4 B temporal = 6 B, not %4
@@ -274,30 +207,12 @@ class TestUnifiedMambaViews(unittest.TestCase):
)
self.assertIn("misalign", str(cm.exception).lower())
def test_alignment_ok_for_aligned_spec(self):
"""An aligned spec (conv region a multiple of the temporal itemsize)
must build and round-trip cleanly."""
# conv region = N * conv_dim*(k-1) * 2 ; with conv_dim=2 -> per-layer 2*3*2=12,
# times N=2 = 24, divisible by 4. Aligned.
pool, spec = _make_pool(
mamba_layer_num=2,
conv_state_shapes=[(2, 3)],
conv_dtype=torch.bfloat16,
temporal_state_shape=(2, 4, 4),
temporal_dtype=torch.float32,
want_slots=4,
)
self._fill_and_roundtrip(pool, spec)
def _k3_kda_mamba_geometry(heads_per_rank: int) -> dict:
"""Kimi K3 KDA per-rank state geometry: 69 KDA layers, K = V = 128,
conv width 4 (=> 3 cached tokens), conv row ``(kernel-1, q+k+v dim)``
in the KimiLinear layout (``KimiLinearStateShape.create`` with
num_k_heads == num_heads, head_k_dim == head_dim — see
``models/kimi_linear.py``), temporal/SSM state ``(HV, V, K)``.
``heads_per_rank`` = 96 total KDA heads / attn_tp (12 at the TP8
deployment shape, cf. ``kernels/ops/attention/kda_fused_decode.py``)."""
"""Kimi K3 KDA per-rank state geometry: 69 KDA layers, K = V = 128, conv
width 4 (=> 3 cached tokens), conv row ``(kernel-1, q+k+v dim)`` per
``KimiLinearStateShape.create``, temporal/SSM state ``(HV, V, K)``, and
``heads_per_rank`` = 96 total KDA heads / attn_tp."""
h = heads_per_rank
return dict(
layer_num=69,
@@ -311,46 +226,35 @@ def _k3_kda_mamba_geometry(heads_per_rank: int) -> dict:
class TestKDAFlashInferEnvelopeStateContract(unittest.TestCase):
"""Derived property: the envelope-strided KDA temporal view (unified memory
/ page-major layout) must satisfy the state contract of FlashInfer
``recurrent_kda``, because the KDA flashinfer decode wrapper
(``linear/kernels/kda_flashinfer.py``) passes the committed per-layer pool
view straight into the kernel (in-place state update on the cu_seqlens path
— no gather/scatter copy around the call).
"""Derived property: the envelope-strided KDA temporal view must satisfy the
state contract of FlashInfer ``recurrent_kda``, because the KDA flashinfer
decode wrapper (``linear/kernels/kda_flashinfer.py``) passes the per-layer
pool view straight into the kernel, with no gather/scatter copy.
The kernel compiles its state argument as a CuTe fake tensor of shape
``[N, HV, V, K]`` with stride ``(sym_int64(divisibility=16), V*K, K, 1)``
and ``assumed_align=32`` (flashinfer ``kda_kernels/recurrent_kda.py``), so
a per-layer pool view is only readable by the kernel when:
and ``assumed_align=32``, so a per-layer pool view is readable only when:
* its inner strides are exactly compact ``(V*K, K, 1)``;
* its slot stride the per-slot envelope pitch, NOT ``HV*V*K`` is a
* its slot stride -- the per-slot envelope pitch, NOT ``HV*V*K`` -- is a
multiple of 16 elements (32 bytes at bf16);
* its base byte offset is 32-byte aligned (for every layer).
Any envelope-layout change that breaks one of these (per-slot padding that
is not a 32 B multiple, a conv-shape change misaligning the temporal
region, a transposed/padded temporal inner layout) would silently
mis-address every KDA state read/write on SM100 flashinfer decode; this
test turns such a diff red without a GPU.
"""
# 32 B: recurrent_kda's assumed_align AND its slot-stride divisibility
# (16 elements * 2 B bf16). External-source literal from flashinfer
# kda_kernels/recurrent_kda.py (S_batch = cute.sym_int64(divisibility=16),
# make_fake_tensor(..., assumed_align=32)).
# recurrent_kda's assumed_align AND its slot-stride divisibility (16
# elements * 2 B bf16), from flashinfer kda_kernels/recurrent_kda.py.
_KERNEL_ALIGN_BYTES = 32
@staticmethod
def _build_tp8_views():
"""Real TP8 K3 KDA envelope views on CPU (2 slots suffice — the
per-slot geometry is slot-count independent)."""
def _build_kda_views(heads_per_rank: int = 12):
"""Real K3 KDA envelope views on CPU for one attn-TP shard; 2 slots
suffice, the per-slot geometry is slot-count independent."""
from sglang.srt.mem_cache.layout.page_major import (
build_page_major_mamba_views,
mamba_entry_bytes,
)
geom = _k3_kda_mamba_geometry(12) # 96 heads / TP8
geom = _k3_kda_mamba_geometry(heads_per_rank)
entry_bytes = mamba_entry_bytes(**geom)
max_slots = 2
raw = torch.empty(max_slots * entry_bytes, dtype=torch.uint8, device="cpu")
@@ -360,76 +264,44 @@ class TestKDAFlashInferEnvelopeStateContract(unittest.TestCase):
return geom, entry_bytes, temporal_view
def test_k3_tp8_envelope_view_matches_recurrent_kda_contract(self):
"""Check every per-layer temporal view against the kernel contract."""
geom, entry_bytes, temporal_view = self._build_tp8_views()
itemsize = temporal_view.element_size()
_, v, k = geom["temporal_state_shape"]
for layer in (0, geom["layer_num"] - 1):
view = temporal_view[layer] # [slots, HV, V, K], what decode() gets
self.assertEqual(
view.stride()[1:],
(v * k, k, 1),
"temporal inner strides must stay compact (V*K, K, 1): "
"recurrent_kda compiles them as constants",
)
self.assertEqual(
view.stride(0),
entry_bytes // itemsize,
"slot stride must be the envelope pitch (entry_bytes)",
)
self.assertEqual(
view.stride(0) % (self._KERNEL_ALIGN_BYTES // itemsize),
0,
"slot stride must satisfy recurrent_kda's "
"sym_int64(divisibility=16) — 16 elements = 32 B at bf16",
)
self.assertEqual(
(view.storage_offset() * itemsize) % self._KERNEL_ALIGN_BYTES,
0,
f"layer {layer} temporal view base is not 32 B aligned "
"(recurrent_kda assumed_align=32)",
)
def test_k3_entry_and_temporal_offset_32B_multiples_across_tp(self):
"""The two byte quantities that feed the contract above — the per-slot
envelope pitch and the temporal region's offset inside the envelope
(= all-layers conv region, temporal comes last) — must be 32 B
multiples for every plausible attn-TP shard of K3's 96 KDA heads."""
import math
from sglang.srt.mem_cache.layout.page_major import mamba_entry_bytes
"""Check every per-layer temporal view against the kernel contract, for
every plausible attn-TP shard of K3's 96 KDA heads."""
for heads_per_rank in (96, 48, 24, 12): # attn_tp 1 / 2 / 4 / 8
geom = _k3_kda_mamba_geometry(heads_per_rank)
entry_bytes = mamba_entry_bytes(**geom)
conv_region_bytes = (
geom["layer_num"]
* math.prod(geom["conv_state_shapes"][0])
* geom["conv_dtype"].itemsize
)
self.assertEqual(
entry_bytes % self._KERNEL_ALIGN_BYTES,
0,
f"tp shard h={heads_per_rank}: envelope pitch {entry_bytes} B "
"breaks recurrent_kda's slot-stride divisibility",
)
self.assertEqual(
conv_region_bytes % self._KERNEL_ALIGN_BYTES,
0,
f"tp shard h={heads_per_rank}: temporal region offset "
f"{conv_region_bytes} B breaks assumed_align=32",
)
with self.subTest(heads_per_rank=heads_per_rank):
geom, entry_bytes, temporal_view = self._build_kda_views(heads_per_rank)
itemsize = temporal_view.element_size()
_, v, k = geom["temporal_state_shape"]
for layer in (0, geom["layer_num"] - 1):
# [slots, HV, V, K], what decode() gets
view = temporal_view[layer]
self.assertEqual(
view.stride()[1:],
(v * k, k, 1),
"temporal inner strides must stay compact (V*K, K, 1): "
"recurrent_kda compiles them as constants",
)
self.assertEqual(
view.stride(0),
entry_bytes // itemsize,
"slot stride must be the envelope pitch (entry_bytes)",
)
self.assertEqual(
view.stride(0) % (self._KERNEL_ALIGN_BYTES // itemsize),
0,
"slot stride must satisfy recurrent_kda's "
"sym_int64(divisibility=16) — 16 elements = 32 B at bf16",
)
self.assertEqual(
(view.storage_offset() * itemsize) % self._KERNEL_ALIGN_BYTES,
0,
f"layer {layer} temporal view base is not 32 B aligned "
"(recurrent_kda assumed_align=32)",
)
def test_wrapper_state_contract_check_matches_layout(self):
"""The KDA flashinfer decode wrapper enforces this same contract at
runtime (``FlashInferKDAKernel._check_state_stride_contract``, called
once per pool view before handing the pool to ``recurrent_kda``). A
regression in that check would only surface on SM100 hardware, so pin
its accept/reject behavior here: it must ACCEPT exactly what the
layouts produce — the envelope-strided per-layer view and a plain
contiguous pool — and REJECT views the kernel would silently
mis-address (wrong inner strides; a slot stride off the divisibility)."""
"""``FlashInferKDAKernel._check_state_stride_contract`` enforces the same
contract at runtime, and a regression there would only surface on SM100
hardware, so pin its accept/reject behavior on CPU."""
import types
from sglang.srt.layers.attention.linear.kernels.kda_flashinfer import (
@@ -442,7 +314,7 @@ class TestKDAFlashInferEnvelopeStateContract(unittest.TestCase):
# Fresh stub per call: the real kernel caches approvals by id().
check(types.SimpleNamespace(_state_contract_ok=set()), view)
_, _, temporal_view = self._build_tp8_views()
_, _, temporal_view = self._build_kda_views()
envelope = temporal_view[0] # what forward_decode hands to the kernel
run(envelope) # must not raise
@@ -11,26 +11,14 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""MHA K/V views for the unified memory pool (uniform-row hybrid models).
"""MHA K/V views for the unified memory pool (uniform-row hybrid models), CPU-only.
Covers, CPU-only (pure torch — no GPU / Triton kernels):
- `build_mha_views` refuses an asymmetric-KV spec: its addressing
assumes one uniform row width, so it is the boundary that checks;
- `build_mha_views` addressing: view_l[kernel_id(t)] must land exactly at
the page-major envelope byte offset the STRIDED builder assigns to the same
(page, slot, layer, K|V) cell — the two builders are views over one truth;
- K and V of one token share ONE kernel-facing id (per-layer origin shift does the
disambiguation), with no aliasing across the 2*L overlapping views;
- the missing-tail-pad and asymmetric-dims cases fail loud at construction.
Addressing law under test (the derived property everything else builds on):
Addressing law under test:
kernel_id(t) = (t // ps) * (ps * 2L) + t % ps
K of layer l at block 2l, V at block 2l+1, blocks are ps rows of
head_num*head_dim elements offsets identical to
K of layer l at block 2l, V at block 2l+1; blocks are ps rows of
head_num*head_dim elements, at offsets identical to
MHASubPoolSpec.layer_k/v_offset_in_page when rows are uniform.
python -m pytest test/registered/unit/mem_cache/test_unified_mha_views.py -v
"""
from sglang.test.ci.ci_register import register_cpu_ci
@@ -43,10 +31,7 @@ from types import SimpleNamespace
import torch
from sglang.srt.environ import envs
from sglang.srt.mem_cache.layout.page_major import (
build_mha_views,
mha_entry_bytes,
)
from sglang.srt.mem_cache.layout.page_major import build_mha_views
from sglang.srt.mem_cache.unified_memory_pool import (
MHASubPoolSpec,
UnifiedKVPool,
@@ -55,12 +40,10 @@ from sglang.srt.mem_cache.unified_memory_pool import (
_DEV = "cpu"
# `set_kv_buffer` dispatches on the PLATFORM (memory_pool._is_cuda, resolved at
# import), not on the tensors it is handed, so cases driving it must build on
# the platform's device. The rest of this file is byte arithmetic, so CPU.
# import), not on the tensors it is handed, so cases driving it build there.
_STORE_DEV = "cuda" if torch.cuda.is_available() else "cpu"
# Small-but-nontrivial MHA geometry: L=2 layers, H=2 heads, D=4, so every byte
# offset is hand-checkable. blocks = 2L = 4 per page.
# Geometry kept tiny so every byte offset is hand-checkable.
_L = 2
_H = 2
_D = 4
@@ -108,13 +91,8 @@ def _build_views(raw, ps, num_pages, head_dim=_D, v_head_dim=_D, layer_num=_L):
def _reference_strided_views(raw, *, page_size, num_pages, anchor_bytes=0):
"""Independent 4-D strided description of the page-major envelope.
This is the retired production strided builder, kept here as the oracle:
per-layer ``(num_pages, page_size, head_num, head_dim)`` views addressed by
``(page, slot)``, so the builder's addressing can be cross-checked
against a second, independently-derived description of the same bytes.
"""
"""Independent 4-D strided description of the same page-major envelope,
addressed by ``(page, slot)`` -- the oracle for the view builder."""
k_row_bytes = _ROW * _ITEM
v_row_bytes = _ROW * _ITEM
page_bytes = page_size * _L * (k_row_bytes + v_row_bytes)
@@ -147,11 +125,8 @@ def _reference_strided_views(raw, *, page_size, num_pages, anchor_bytes=0):
class TestMHASpecSurface(unittest.TestCase):
def test_asymmetric_rows_refused_by_the_view_builder(self):
"""The row-block array exists only for uniform rows, so the builder
whose addressing depends on it is the one that refuses (the MiMoV2
shape, scaled down). ServerArgs screens such models out of
--enable-unified-memory long before we get here; this is the check for
a caller that reaches the builder directly."""
"""ServerArgs screens asymmetric-KV models out of
--enable-unified-memory; this guards a caller reaching the builder."""
spec = _mha_spec()
raw = torch.zeros(1 << 16, dtype=torch.uint8)
with self.assertRaises(AssertionError):
@@ -166,28 +141,6 @@ class TestMHASpecSurface(unittest.TestCase):
num_pages=4,
)
def test_spec_offsets_equal_block_origins(self):
"""The spec's byte math and the view builder's origins are two
independent derivations of the envelope; under uniform rows they must
agree: layer_k_offset(l) == (2l)*ps*row, layer_v_offset(l) == (2l+1)*ps*row."""
spec = _mha_spec()
for ps in (1, 4):
row = spec.k_row_bytes()
for l in range(_L):
self.assertEqual(spec.layer_k_offset_in_page(l, ps), (2 * l) * ps * row)
self.assertEqual(
spec.layer_v_offset_in_page(l, ps), (2 * l + 1) * ps * row
)
def test_entry_bytes_matches_layout_helper(self):
spec = _mha_spec()
self.assertEqual(
spec.entry_bytes(),
mha_entry_bytes(
layer_num=_L, head_num=_H, head_dim=_D, v_head_dim=_D, itemsize=_ITEM
),
)
class TestMHAViews(unittest.TestCase):
def test_view_shapes_are_stock_mha(self):
@@ -202,11 +155,8 @@ class TestMHAViews(unittest.TestCase):
self.assertEqual(v.stride(), (_ROW, _D, 1))
def test_addressing_matches_strided_reference(self):
"""Cross-readback: bytes written through the reference STRIDED views at
(page, slot) must be read back through the views at kernel_id(t),
for both K and V of every layer — and vice versa. This pins that the
view builder and the independent strided description agree on the
same physical envelope."""
"""Cross-readback both ways: a write through the strided oracle at
(page, slot) must read back through the view at kernel_id(t)."""
for ps in (1, 4):
num_pages = 5
raw = _make_raw(ps, num_pages)
@@ -240,37 +190,6 @@ class TestMHAViews(unittest.TestCase):
torch.all(sv[l][p, s] == float(p * 100 + l * 10 + s + 4))
)
def test_byte_addresses_match_envelope_formula(self):
"""The per-layer view's byte address for token ``t``, layer ``L`` must equal
the hand-computed envelope formula: page origin + layer-block origin +
slot offset. Independent of any view builder — this is the raw layout
contract every envelope consumer (moves, sizing, transfer math) relies
on."""
k_row = _ROW * _ITEM
v_row = _ROW * _ITEM
for ps in (1, 4):
num_pages = 5
page_bytes = ps * _L * (k_row + v_row)
dk, dv = _build_views(_make_raw(ps, num_pages), ps, num_pages)
for t in (0, 1, ps, 3 * ps + (ps - 1), 4 * ps):
d = _kernel_id(t, ps)
for L in range(_L):
expected_k = (
(t // ps) * page_bytes
+ L * ps * (k_row + v_row)
+ (t % ps) * k_row
)
expected_v = (
(t // ps) * page_bytes
+ L * ps * (k_row + v_row)
+ ps * k_row
+ (t % ps) * v_row
)
got_k = (dk[L].storage_offset() + d * dk[L].stride(0)) * _ITEM
got_v = (dv[L].storage_offset() + d * dv[L].stride(0)) * _ITEM
self.assertEqual(got_k, expected_k, f"K t={t} L={L} ps={ps}")
self.assertEqual(got_v, expected_v, f"V t={t} L={L} ps={ps}")
def test_k_and_v_share_one_kernel_id_without_aliasing(self):
"""One kernel-facing id, 2L distinct cells (K and V of every layer): writes
through all 2L views at the SAME id must not clobber each other."""
@@ -291,12 +210,6 @@ class TestMHAViews(unittest.TestCase):
with self.assertRaises(AssertionError):
_build_views(raw, ps, num_pages)
def test_asymmetric_dims_rejected(self):
ps, num_pages = 2, 4
raw = _make_raw(ps, num_pages)
with self.assertRaises(AssertionError):
_build_views(raw, ps, num_pages, head_dim=6, v_head_dim=4)
# ---- pool level ----
@@ -341,26 +254,6 @@ class TestUnifiedKVPoolViews(unittest.TestCase):
self.assertEqual(v[0].dim(), 3, f"{name} V at ps={ps}")
self.assertTrue(k[0].is_contiguous())
def test_tail_pad_is_derived_from_the_specs(self):
"""The per-layer views hang past the last page envelope, so the pool
over-allocates one envelope of the widest sub-pool. Derived here, not
passed in, so no construction site can under-allocate it."""
for ps in (1, 4):
kv = _make_pool(ps)
full, swa = _mha_spec(), _swa_spec()
self.assertEqual(
kv.view_tail_pad_bytes,
ps * max(full.entry_bytes(), swa.entry_bytes()),
f"tail pad at ps={ps}",
)
self.assertEqual(
kv._raw.numel(),
full.entry_bytes() * _N_FULL
+ swa.entry_bytes() * _N_SWA
+ kv.view_tail_pad_bytes,
"the pad extends the allocation only",
)
def _layer(l):
return SimpleNamespace(layer_id=l)
@@ -386,12 +279,8 @@ class TestUnifiedMHATokenToKVPool(unittest.TestCase):
self.assertEqual(pool_under_test.size, n_rows - ps)
def test_stock_write_lands_on_envelope_truth(self):
"""Byte-identity: the pool's stock inherited `set_kv_buffer` at kernel-facing
locs must produce exactly the bytes that direct writes through STRIDED
views over the same envelope produce at the same (page, slot, layer)
cells. The strided views are built here purely as the independent
description of the envelope — pins the whole write path (loc -> view ->
raw bytes) end to end."""
"""Byte-identity: the inherited `set_kv_buffer` at kernel-facing locs must
produce the same bytes as writes through the strided oracle."""
for ps in (1, 4):
kv, pool = _make_pool_and_kv(ps, device=_STORE_DEV)
# An independent strided view of the SAME sub-pool region.
@@ -431,10 +320,8 @@ class TestUnifiedMHATokenToKVPool(unittest.TestCase):
)
def test_move_kv_cache_relocates_whole_envelopes(self):
"""Compaction hands PHYSICAL token runs, not kernel-facing ids. The override
must relocate exactly the page envelopes those runs name — red if it is
lost, since the inherited per-layer move would apply physical ids to
the row space."""
"""Compaction hands PHYSICAL token runs, not kernel-facing ids; the
override must relocate exactly the page envelopes those runs name."""
ps = 4
kv, pool = _make_pool_and_kv(ps)
live = kv._raw.numel() - kv.view_tail_pad_bytes
@@ -459,8 +346,7 @@ class TestUnifiedMHATokenToKVPool(unittest.TestCase):
def test_transfer_entry_points_fail_loud(self):
"""PD / CPU-copy entry points assume per-layer buffers indexed by TOKEN
id; against the row space they would silently mis-index (or hit a
missing-attr AttributeError). Every one of them must raise."""
id and would silently mis-index the row space, so each must raise."""
_, pool = _make_pool_and_kv(1)
with self.assertRaises(NotImplementedError):
pool.get_contiguous_buf_infos()
@@ -472,10 +358,8 @@ class TestUnifiedMHATokenToKVPool(unittest.TestCase):
pool.set_kv_buffer_prefix_valid()
def test_hnd_env_cannot_hijack_layout(self):
"""SGLANG_USE_HND_KVCACHE=1 used to flip the inherited env-driven
layout selector, putting the pool in a mode whose code paths do not
match its buffers (HND indexes 4-D; the per-layer views are 3-D). The
pinned label must win."""
"""SGLANG_USE_HND_KVCACHE must not flip this pool's layout: HND indexes
4-D while the per-layer views are 3-D, so the pinned label has to win."""
with envs.SGLANG_USE_HND_KVCACHE.override(True):
_, pool = _make_pool_and_kv(1)
self.assertFalse(pool.use_hnd)
@@ -483,17 +367,15 @@ class TestUnifiedMHATokenToKVPool(unittest.TestCase):
class TestFactoryViews(unittest.TestCase):
"""The real SWA factory builds the sub-pools and wires the matching
kernel-facing multipliers into the composite allocator. End-to-end over
that factory, the rebind must emit BOTH kernel-facing write locs."""
"""Over the real SWA factory: matching kernel-facing multipliers in the
composite allocator, and a rebind that emits both write locs."""
# _swa_factory geometry: L_full = L_swa = 2, uniform 8/8 dims, ps = 1.
FULL_MULT = 4 # 2 * L_full
SWA_MULT = 4 # 2 * L_swa
def _bundle(self):
# Self-contained tiny SWA-factory bundle (L_full = L_swa = 2, uniform
# 8/8 dims, ps = 1) — small enough that per-layer views build on CPU.
# Kept tiny so the per-layer views build on CPU.
from sglang.srt.mem_cache.unified_memory_pool import init_unified_swa_pools
return init_unified_swa_pools(
@@ -528,11 +410,8 @@ class TestFactoryViews(unittest.TestCase):
self.assertGreater(pool.view_tail_pad_bytes, 0)
def test_rebind_emits_kernel_facing_full_and_build_derives_swa(self):
"""End-to-end over the real factory: rebind_write_loc rebinds
out_cache_loc to FULL-kernel-facing ids (phase 1), and the per-batch build
derives the SWA write loc pointwise from those kernel-facing values
(phase 2) — both checked against the formulas over the VIRTUAL
ids."""
"""rebind_write_loc rebinds out_cache_loc to FULL-kernel-facing ids, and
the SWA write loc is derived pointwise from those kernel-facing values."""
from sglang.srt.mem_cache.kv_index_translator import KVIndexTranslator
b = self._bundle()
@@ -14,19 +14,11 @@
"""GPU parity of the per-layer-view `UnifiedMLATokenToKVPool` against the stock
`MLATokenToKVPool` on real K3 MLA geometry (L=24, D=512+64).
The unified pool receives kernel-facing locs (kernel_id(t) = (t//ps)*(ps*L) + t%ps); the
reference pool receives the raw token ids. Every (layer, token) cell must hold
identical bytes afterwards. Covers:
- `set_mla_kv_buffer` under BOTH kernel paths — the Triton fallback
(n_loc < 768) and the TMA JIT fast path (n_loc >= 768, which flattens the
buffer via `.view(shape[0], -1)`, only legal because per-layer views are
contiguous);
- `set_kv_buffer` (combined pre-concatenated write, the Triton-backend path);
- `get_mla_kv_buffer` roundtrip;
- page_size 1 and 64.
python -m pytest test/registered/unit/mem_cache/test_unified_mla_gpu_parity.py -v
The unified pool receives kernel-facing locs (kernel_id(t) = (t//ps)*(ps*L) +
t%ps) where the reference pool receives raw token ids; every (layer, token)
cell must hold identical bytes afterwards. The TMA JIT fast path (n_loc >= 768)
flattens the buffer via `.view(shape[0], -1)`, which is legal only because the
per-layer views are contiguous.
"""
import types
@@ -134,17 +126,12 @@ class TestUnifiedMLAPoolGPUParity(unittest.TestCase):
torch.cuda.synchronize()
self._assert_parity(unified, ref, locs, ps)
def test_set_mla_kv_buffer_triton_fallback_ps1(self):
self._run_set_mla(ps=1, n_loc=256) # < 768 -> Triton fallback kernel
def test_set_mla_kv_buffer_tma_jit_ps1(self):
self._run_set_mla(ps=1, n_loc=1024) # >= 768 -> TMA JIT fast path
def test_set_mla_kv_buffer_triton_fallback_ps64(self):
self._run_set_mla(ps=64, n_loc=256)
def test_set_mla_kv_buffer_tma_jit_ps64(self):
self._run_set_mla(ps=64, n_loc=1024)
def test_set_mla_kv_buffer_matches_stock_pool(self):
"""Both kernel paths at both page sizes: n_loc < 768 takes the Triton
fallback, n_loc >= 768 the TMA JIT fast path."""
for ps, n_loc in ((1, 256), (1, 1024), (64, 256), (64, 1024)):
with self.subTest(page_size=ps, n_loc=n_loc):
self._run_set_mla(ps=ps, n_loc=n_loc)
def test_set_kv_buffer_combined_write(self):
for ps in (1, 64):
@@ -175,29 +162,6 @@ class TestUnifiedMLAPoolGPUParity(unittest.TestCase):
torch.testing.assert_close(got_nope, nope, rtol=0, atol=0)
torch.testing.assert_close(got_rope, rope, rtol=0, atol=0)
def test_move_kv_cache_page_envelope_gpu(self):
for ps in (1, 64):
unified, ref, max_tokens = _make_pools(ps)
num_pages = max_tokens // ps
n_loc = ps # one full page of tokens
src_page, dst_page = num_pages - 2, 2
src_t = torch.arange(ps, device=_DEV, dtype=torch.int64) + src_page * ps
dst_t = torch.arange(ps, device=_DEV, dtype=torch.int64) + dst_page * ps
torch.manual_seed(17)
for l in range(_L):
layer = types.SimpleNamespace(layer_id=l)
k = torch.randn(n_loc, 1, _D, dtype=_DTYPE, device=_DEV)
unified.set_kv_buffer(layer, _kernel_id(src_t, ps), k, None)
before = [
unified.get_key_buffer(l)[_kernel_id(src_t, ps)].clone()
for l in range(_L)
]
unified.move_kv_cache(dst_t, src_t)
torch.cuda.synchronize()
for l in range(_L):
got = unified.get_key_buffer(l)[_kernel_id(dst_t, ps)]
torch.testing.assert_close(got, before[l], rtol=0, atol=0)
if __name__ == "__main__":
unittest.main()
@@ -11,26 +11,14 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""MLA views for the unified memory pool (MLA-hybrid-Mamba, Kimi K3).
"""MLA views for the unified memory pool (MLA-hybrid-Mamba, Kimi K3), CPU-only.
Covers, CPU-only (pure torch — no GPU / Triton kernels):
- `MLASubPoolSpec` byte math;
- `build_mla_views` addressing: view_l[kernel_id(t)] must land exactly at
the page-major envelope byte offset `p*(L*ps*D) + l*(ps*D) + s*D`, the
overlapping per-layer views must not alias at equal kernel-facing ids, and the
missing-tail-pad case must fail loud;
- `UnifiedKVPool` MLA plumbing: `view_tail_pad_bytes` extends the allocation
only, and the reserved sink floor covers the whole page-0 envelope;
- `UnifiedMLATokenToKVPool`: buffer wiring, V-as-prefix-slice, and the
page-envelope `move_kv_cache` (REAL physical token ids, page-major runs);
- `MultiEndedAllocator.translate_kv_loc_for_kernel`: kernel id = v2p-page * (ps*L) +
offset, tombstone clamp to the sink, `out=` contract, multiplier-1
fallback, and correctness across eager compaction.
Addressing law under test: the (page, layer, slot) cell sits at envelope byte
offset `p*(L*ps*D) + l*(ps*D) + s*D`, reached through the kernel-facing id
`(t // ps) * (ps * L) + t % ps`.
GPU parity of the actual read/write kernels (set_mla_kv_buffer TMA path etc.)
lives in the server-level tests, not here.
python -m pytest test/registered/unit/mem_cache/test_unified_mla_views.py -v
GPU parity of the read/write kernels (set_mla_kv_buffer TMA path etc.) lives in
`test_unified_mla_gpu_parity.py`.
"""
from sglang.test.ci.ci_register import register_cpu_ci
@@ -42,10 +30,7 @@ import unittest
import torch
from sglang.srt.mem_cache.allocator.unified_sub_pool import MultiEndedAllocator
from sglang.srt.mem_cache.layout.page_major import (
build_mla_views,
mla_entry_bytes,
)
from sglang.srt.mem_cache.layout.page_major import build_mla_views
from sglang.srt.mem_cache.unified_memory_pool import (
MambaSubPoolSpec,
MLASubPoolSpec,
@@ -55,8 +40,8 @@ from sglang.srt.mem_cache.unified_memory_pool import (
_DEV = "cpu"
# Small-but-nontrivial MLA geometry: L=3 layers, D=8 (=6+2), so every byte
# offset is hand-checkable. Real K3 is L=24, D=576 (=512+64).
# Geometry kept tiny so every byte offset is hand-checkable; real K3 is
# L=24, D=576 (=512+64).
_L = 3
_LORA = 6
_ROPE = 2
@@ -107,16 +92,6 @@ def _kernel_id(t, ps, layer_num):
class TestMLASubPoolSpec(unittest.TestCase):
def test_entry_bytes_and_dim(self):
spec = _mla_spec()
self.assertEqual(spec.kv_cache_dim, _D)
self.assertEqual(spec.entry_bytes(), _L * _D * _ITEM)
self.assertEqual(
spec.entry_bytes(),
mla_entry_bytes(layer_num=_L, kv_cache_dim=_D, itemsize=_ITEM),
)
self.assertEqual(spec.get_dtype(), _DTYPE)
def test_rejects_nonpositive_dims(self):
with self.assertRaises(AssertionError):
MLASubPoolSpec(
@@ -153,7 +128,7 @@ class TestMLAViews(unittest.TestCase):
n_rows = num_pages * _L * ps
for v in views:
self.assertEqual(tuple(v.shape), (n_rows, 1, _D))
# contiguous in the (row, dim) sense .view(-1, ps, D) legality
# contiguous in the (row, dim) sense: .view(-1, ps, D) legality
self.assertEqual(v.stride(0), _D)
self.assertEqual(v.stride(2), 1)
flat = raw.view(_DTYPE)
@@ -203,14 +178,6 @@ class TestMLAViews(unittest.TestCase):
class TestUnifiedKVPoolMLA(unittest.TestCase):
def test_max_slots_ignore_tail_pad(self):
pool, full, mamba = _make_unified(page_size=4)
total = full.entry_bytes() * 64 + mamba.entry_bytes() * 8
self.assertEqual(pool.max_slots("full"), total // full.entry_bytes())
self.assertEqual(pool.max_slots("mamba"), total // mamba.entry_bytes())
# allocation actually carries the pad
self.assertEqual(pool._raw.numel(), total + 4 * full.entry_bytes())
def test_reserved_floor_covers_page0_envelope(self):
ps = 4
pool, full, mamba = _make_unified(page_size=ps)
@@ -253,15 +220,24 @@ class TestUnifiedMLATokenToKVPool(unittest.TestCase):
self.assertTrue(torch.all(v[7] == 2.5))
def test_move_kv_cache_moves_page_envelopes(self):
"""Whole page envelopes relocate, in raw bytes and (at ps=4) as read
back through the per-layer views at the destination kernel ids."""
for ps in (1, 4):
pool, kv_pool = self._make(ps=ps)
num_pages = pool.max_slots("full") // ps
page_bytes = ps * _L * _D * _ITEM
env = pool._raw[: num_pages * page_bytes].view(num_pages, page_bytes)
src_pages = torch.tensor([num_pages - 2, num_pages - 4])
dst_pages = torch.tensor([2, 3])
src_pages = torch.tensor([num_pages - 2, num_pages - 4, num_pages - 3])
dst_pages = torch.tensor([2, 3, 5])
env[src_pages[0]] = 7
env[src_pages[1]] = 9
if ps == 4:
# write through the views at src, expect it at dst after the move
for l in range(_L):
for s in range(ps):
kv_pool.kv_buffer[l][
_kernel_id(int(src_pages[2]) * ps + s, ps, _L)
] = float(l * ps + s + 1)
# page-major token runs, exactly how compaction expands pages
offsets = torch.arange(ps, dtype=torch.int64)
src_t = (src_pages[:, None] * ps + offsets).reshape(-1)
@@ -269,29 +245,15 @@ class TestUnifiedMLATokenToKVPool(unittest.TestCase):
kv_pool.move_kv_cache(dst_t, src_t)
self.assertTrue(torch.all(env[dst_pages[0]] == 7), f"ps={ps}")
self.assertTrue(torch.all(env[dst_pages[1]] == 9), f"ps={ps}")
def test_move_then_readback(self):
ps = 4
pool, kv_pool = self._make(ps=ps)
num_pages = pool.max_slots("full") // ps
src_page, dst_page = num_pages - 3, 5
# write through the views at src, expect it at dst after the move
for l in range(_L):
for s in range(ps):
kv_pool.kv_buffer[l][_kernel_id(src_page * ps + s, ps, _L)] = float(
l * ps + s + 1
)
offsets = torch.arange(ps, dtype=torch.int64)
kv_pool.move_kv_cache(
(torch.tensor([dst_page])[:, None] * ps + offsets).reshape(-1),
(torch.tensor([src_page])[:, None] * ps + offsets).reshape(-1),
)
for l in range(_L):
for s in range(ps):
got = kv_pool.kv_buffer[l][_kernel_id(dst_page * ps + s, ps, _L)]
self.assertTrue(
torch.all(got == float(l * ps + s + 1)), f"(l={l}, s={s})"
)
if ps == 4:
for l in range(_L):
for s in range(ps):
got = kv_pool.kv_buffer[l][
_kernel_id(int(dst_pages[2]) * ps + s, ps, _L)
]
self.assertTrue(
torch.all(got == float(l * ps + s + 1)), f"(l={l}, s={s})"
)
class _FakeKVCache:
@@ -325,23 +287,29 @@ class TestTranslateKvLocForKernel(unittest.TestCase):
mamba_alloc.bind_peer(full_alloc)
return full_alloc
def test_kernel_id_matches_formula_ps1(self):
alloc = self._build(ps=1)
v = alloc.alloc(8)
self.assertIsNotNone(v)
phys = alloc.translate_kv_loc(v)
kernel = alloc.translate_kv_loc_for_kernel(v)
self.assertTrue(torch.all(kernel == phys * _L))
def test_kernel_id_matches_formula(self):
"""kernel id = (phys // ps) * (ps * multiplier) + phys % ps, across
page sizes, the multiplier-1 physical fallback, and eager compaction."""
for ps, multiplier in ((1, _L), (4, _L), (1, 1)):
with self.subTest(page_size=ps, multiplier=multiplier):
alloc = self._build(ps=ps, multiplier=multiplier)
a = alloc.alloc(4 * ps)
b = alloc.alloc(4 * ps)
c = alloc.alloc(4 * ps)
self.assertIsNotNone(c)
def test_kernel_id_matches_formula_paged(self):
ps = 4
alloc = self._build(ps=ps)
v = alloc.alloc(3 * ps)
self.assertIsNotNone(v)
phys = alloc.translate_kv_loc(v)
kernel = alloc.translate_kv_loc_for_kernel(v)
expected = (phys // ps) * (ps * _L) + phys % ps
self.assertTrue(torch.all(kernel == expected))
def check(virt):
phys = alloc.translate_kv_loc(virt)
expected = (phys // ps) * (ps * multiplier) + phys % ps
self.assertTrue(
torch.all(alloc.translate_kv_loc_for_kernel(virt) == expected)
)
for virt in (a, b, c):
check(virt)
alloc.free(b) # eager compaction relocates survivors
for virt in (a, c):
check(virt)
def test_tombstone_clamps_to_sink(self):
alloc = self._build(ps=1)
@@ -365,26 +333,6 @@ class TestTranslateKvLocForKernel(unittest.TestCase):
alloc.translate_kv_loc_for_kernel(x, out=x)
self.assertTrue(torch.all(x == no_out))
def test_multiplier_one_falls_back_to_physical(self):
alloc = self._build(ps=1, multiplier=1)
v = alloc.alloc(4)
self.assertIsNotNone(v)
self.assertTrue(
torch.all(alloc.translate_kv_loc_for_kernel(v) == alloc.translate_kv_loc(v))
)
def test_kernel_id_follows_compaction(self):
alloc = self._build(ps=1)
a = alloc.alloc(4)
b = alloc.alloc(4)
c = alloc.alloc(4)
self.assertIsNotNone(c)
alloc.free(b) # eager compaction relocates survivors
phys_a = alloc.translate_kv_loc(a)
phys_c = alloc.translate_kv_loc(c)
self.assertTrue(torch.all(alloc.translate_kv_loc_for_kernel(a) == phys_a * _L))
self.assertTrue(torch.all(alloc.translate_kv_loc_for_kernel(c) == phys_c * _L))
if __name__ == "__main__":
unittest.main()
@@ -13,25 +13,13 @@
# ==============================================================================
"""N-sub-pool construction sweep for ``UnifiedKVPool``.
The pool accepts N sub-pool specs: exactly one grow-up END, exactly one
grow-down END, and >= 0 "float" MIDDLE pools between their frontiers. These
tests pin the constructor contract the N-pool chain machinery builds on:
The pool accepts N sub-pool specs -- exactly one grow-up END, exactly one
grow-down END, and >= 0 "float" MIDDLE pools between their frontiers -- and
sorts them into the canonical chain order
``[up end, floats (input order), down end]``. Every sub-pool view spans the
whole buffer at anchor 0; keeping the bands disjoint is the allocators' job.
- canonical chain order ``[up end, floats (input order), down end]`` —
input list order is irrelevant (2-pool configs stay byte-identical);
- by-name geometry (``max_slots = total_bytes // entry_bytes``,
``min_slot_index`` past the shared reserved floor) independent of N;
- the reserved slot-0 sink covers EVERY sub-pool's page-0 dummy-write
envelope, floats included (mamba stays page_size=1);
- validation: unique names, exactly one up + one down, >= 2 specs, and
per-spec ``_allowed_grow_directions`` narrowing;
- float sub-pool views build and round-trip like end-pool views (all views
span the whole buffer at anchor 0; keeping the bands disjoint is the
allocators' job).
Pure CPU geometry — no allocator, no GPU.
python -m pytest test/registered/unit/mem_cache/test_unified_npool_sweep.py -v
Pure CPU geometry -- no allocator, no GPU.
"""
import unittest
@@ -46,10 +34,8 @@ from sglang.srt.mem_cache.unified_memory_pool import (
)
from sglang.test.ci.ci_register import register_cpu_ci
# Plain unittest.TestCase, importing only ci_register -- the deliberate
# hermetic convention of the pool-geometry tests in this directory (see
# test_multi_ended_allocator.py): no heavy sglang.test.test_utils import
# chain, so the suite runs in a lean torch-only environment.
# Hermetic convention of this directory's pool tests: plain unittest.TestCase,
# only ci_register imported (no heavy sglang.test.test_utils chain).
register_cpu_ci(est_time=30, suite="base-a-test-cpu")
_DEV = "cpu"
@@ -111,34 +97,46 @@ def _chain_names(pool: UnifiedKVPool):
class TestNPoolCanonicalOrder(unittest.TestCase):
def test_two_pool_input_order_irrelevant(self):
for specs in (
[_mha("full", "down"), _mamba("mamba", "up")],
[_mamba("mamba", "up"), _mha("full", "down")],
def test_chain_order_is_canonical(self):
"""Ends canonical (up first, down last) whatever the input order;
floats keep INPUT order between them, on every cache-spec kind."""
for specs, expect in (
([_mha("full", "down"), _mamba("mamba", "up")], ["mamba", "full"]),
([_mamba("mamba", "up"), _mha("full", "down")], ["mamba", "full"]),
(
[_mha("full", "down"), _mha("swa", "float"), _mamba("conv", "up")],
["conv", "swa", "full"],
),
(
[_mha("swa", "float"), _mamba("conv", "up"), _mha("full", "down")],
["conv", "swa", "full"],
),
(
[_mamba("conv", "up"), _mha("full", "down"), _mha("swa", "float")],
["conv", "swa", "full"],
),
(
[
_mha("full", "down"),
_mha("f1", "float"),
_mamba("state", "up"),
_mha("f0", "float", layer_num=1),
],
["state", "f1", "f0", "full"],
),
(
[
_mamba("state", "up"),
_mha("f_mha", "float"),
_mla("f_mla", "float", layer_num=1),
_mamba("f_mamba", "float", layer_num=1),
_mha("full", "down"),
],
["state", "f_mha", "f_mla", "f_mamba", "full"],
),
):
pool = _make_pool(specs)
self.assertEqual(_chain_names(pool), ["mamba", "full"])
def test_three_pool_float_in_the_middle(self):
for specs in (
[_mha("full", "down"), _mha("swa", "float"), _mamba("conv", "up")],
[_mha("swa", "float"), _mamba("conv", "up"), _mha("full", "down")],
[_mamba("conv", "up"), _mha("full", "down"), _mha("swa", "float")],
):
pool = _make_pool(specs)
self.assertEqual(_chain_names(pool), ["conv", "swa", "full"])
def test_four_pool_float_input_order_preserved(self):
pool = _make_pool(
[
_mha("full", "down"),
_mha("f1", "float"),
_mamba("state", "up"),
_mha("f0", "float", layer_num=1),
]
)
# Ends canonical; floats keep INPUT order between them.
self.assertEqual(_chain_names(pool), ["state", "f1", "f0", "full"])
with self.subTest(inputs=[s.name for s in specs]):
self.assertEqual(_chain_names(_make_pool(specs)), expect)
def test_by_name_geometry_independent_of_n(self):
two = _make_pool([_mha("full", "down"), _mamba("mamba", "up")])
@@ -151,9 +149,6 @@ class TestNPoolCanonicalOrder(unittest.TestCase):
two.total_bytes // two.spec(name).entry_bytes(),
)
self.assertEqual(two.max_slots(name), three.max_slots(name))
for pool in (two, three):
for s in pool.sub_pool_specs:
self.assertEqual(pool.anchor_bytes(s.name), 0)
class TestNPoolValidation(unittest.TestCase):
@@ -165,37 +160,24 @@ class TestNPoolValidation(unittest.TestCase):
with self.assertRaisesRegex(AssertionError, ">= 2 sub-pools"):
_make_pool([_mha("full", "down")])
def test_two_ups_rejected(self):
with self.assertRaisesRegex(AssertionError, "exactly one grow-up"):
_make_pool([_mha("a", "up"), _mamba("b", "up")])
def test_missing_down_end_rejected(self):
with self.assertRaisesRegex(AssertionError, "exactly one grow-up"):
_make_pool([_mha("a", "up"), _mha("b", "float")])
def test_missing_up_end_rejected(self):
with self.assertRaisesRegex(AssertionError, "exactly one grow-up"):
_make_pool([_mha("a", "down"), _mha("b", "float"), _mha("c", "float")])
def test_end_direction_counts_rejected(self):
"""Exactly one grow-up END and one grow-down END, no more, no less."""
for case, specs in (
("two_ups", [_mha("a", "up"), _mamba("b", "up")]),
("missing_down", [_mha("a", "up"), _mha("b", "float")]),
(
"missing_up",
[_mha("a", "down"), _mha("b", "float"), _mha("c", "float")],
),
):
with self.subTest(case=case):
with self.assertRaisesRegex(AssertionError, "exactly one grow-up"):
_make_pool(specs)
def test_bogus_direction_rejected_at_spec_level(self):
with self.assertRaisesRegex(AssertionError, "grow_direction"):
_mha("a", "sideways")
def test_float_accepted_on_all_cache_spec_kinds(self):
# Every cache-class spec kind may float (the chain decides placement).
pool = _make_pool(
[
_mamba("state", "up"),
_mha("f_mha", "float"),
_mla("f_mla", "float", layer_num=1),
_mamba("f_mamba", "float", layer_num=1),
_mha("full", "down"),
]
)
self.assertEqual(
_chain_names(pool), ["state", "f_mha", "f_mla", "f_mamba", "full"]
)
class TestReservedFloorWithFloats(unittest.TestCase):
def test_float_page_envelope_extends_the_sink(self):
@@ -217,8 +199,7 @@ class TestReservedFloorWithFloats(unittest.TestCase):
def test_too_small_buffer_fails_loud(self):
# 2048 B with page_size=16 and 128 B/entry MHA specs: the page-0 sink
# (16*128 = 2048 B) consumes the whole buffer -> min_slot_index ==
# max_slots for the MHA pools -> no allocatable slot -> loud error.
# (16*128 = 2048 B) consumes the whole buffer, leaving no slot.
with self.assertRaisesRegex(RuntimeError, "no room"):
_make_pool(
[_mamba("state", "up"), _mha("swa", "float"), _mha("full", "down")],
@@ -258,14 +239,6 @@ class TestFloatViews(unittest.TestCase):
k_views[1][row] = pattern
torch.testing.assert_close(k_views[1][row], pattern)
def test_float_mamba_views_zero_visible(self):
pool = _make_pool(
[_mamba("state", "up"), _mamba("fstate", "float"), _mha("full", "down")]
)
conv_views, temporal = pool.mamba_views_for("fstate")
self.assertTrue(all(v.eq(0).all() for v in conv_views))
self.assertTrue(temporal.eq(0).all())
if __name__ == "__main__":
unittest.main()
@@ -15,23 +15,7 @@
SWA KV + mamba/conv state in ONE unified byte buffer, chain
``[mamba (up END) | swa (FLOAT) | full (down END)]``.
Pinned contracts (each guards a distinct failure mode):
- chain wiring + the swa side being a `FloatMultiEndedAllocator`;
- the JOINT `available_size()` feasibility contract: `alloc(N)` for
N == available_size() must succeed (full extends into the high band
first, then the float extends a single side — the predicate models
exactly that order; over-promising here is the fail-loud
`alloc_with_virtual` assert, i.e. a crash in production);
- `free_swa` tombstones become float HOLES recycled IN PLACE by later
allocs (steady-state SWA churn == zero copies), while the full side
keeps the token;
- the per-request state surface (`UnifiedMambaSlotAllocator` over the
mamba END) and its independence from the token surface;
- urgent flushes drain the two ENDS but never touch the float's holes.
Pure CPU; fakes stand in for the KV pools (data markers verify moves).
python -m pytest test/registered/unit/mem_cache/test_unified_tri_pool.py -v
"""
import inspect
@@ -39,11 +23,13 @@ import unittest
import torch
import sglang.srt.mem_cache.allocator.unified_sub_pool as mea
from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedMambaSWATokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.allocator.unified_sub_pool import FloatMultiEndedAllocator
from sglang.srt.mem_cache.allocator.unified_sub_pool import (
FloatMultiEndedAllocator,
MultiEndedAllocator,
)
from sglang.srt.mem_cache.unified_memory_pool import (
MambaSubPoolSpec,
MHASubPoolSpec,
@@ -195,22 +181,6 @@ class TestUnifiedTriPool(unittest.TestCase):
# -- the joint availability contract --
def test_available_size_alloc_contract(self):
for lazy in (False, True):
_, allocator, kvcache, _ = self._build(lazy_compaction=lazy)
avail = allocator.available_size()
self.assertGreater(avail, 0)
v = allocator.alloc(avail)
self.assertIsNotNone(
v, f"alloc(available_size()={avail}) must succeed (lazy={lazy})"
)
self.assertEqual(int(v.numel()), avail)
# Both sides bound for every allocated virtual id.
fa = allocator.full_attn_allocator
sa = allocator.swa_attn_allocator
self.assertTrue(bool((fa.virtual_to_physical[v] >= 0).all()))
self.assertTrue(bool((sa.virtual_to_physical[v] >= 0).all()))
def test_available_shrinks_as_state_slots_grow(self):
_, allocator, _, _ = self._build()
before = allocator.available_size()
@@ -224,9 +194,8 @@ class TestUnifiedTriPool(unittest.TestCase):
# -- steady-state SWA churn: tombstones -> holes -> in-place reuse --
def _swa_interior_block(self, allocator, blocks):
"""The block whose SWA-physical pages touch neither float boundary --
`free_swa` on it must create interior holes (a boundary block would be
absorbed instead; both are zero-copy, different mechanisms)."""
"""The block whose SWA-physical pages touch neither float boundary:
`free_swa` on it holes; a boundary block would be absorbed instead."""
sa = allocator.swa_attn_allocator
for v in blocks:
pages = set(int(x) for x in sa.virtual_to_physical[v].tolist())
@@ -262,10 +231,9 @@ class TestUnifiedTriPool(unittest.TestCase):
self.assertEqual(len(sa._inverse_history), 0) # zero copies
def test_free_swa_boundary_block_absorbed_zero_copy(self):
# The OTHER zero-copy mechanism: a boundary block's tombstones shrink
# the span, handing bytes back to the neighbours. The shrink is
# DEFERRED out of the per-step free (it needs the hole set on the
# host); the per-step opportunistic flush is where it lands.
# A boundary block's tombstones shrink the span instead of holing it.
# The shrink needs the hole set on the host, so it is deferred out of
# the per-step free and lands in the opportunistic flush.
_, allocator, kvcache, _ = self._build()
blocks = [allocator.alloc(4) for _ in range(2)]
for v in blocks:
@@ -329,16 +297,7 @@ class TestUnifiedTriPool(unittest.TestCase):
(pool.max_slots("mamba") - 1) - 1,
)
# -- cost + flush semantics --
def test_mamba_slot_full_token_cost_formula(self):
_, allocator, _, _ = self._build()
e_tok = (
allocator.full_attn_allocator.entry_bytes
+ allocator.swa_attn_allocator.entry_bytes
)
m = allocator.mamba_allocator.entry_bytes_per_page
self.assertEqual(allocator.mamba_slot_full_token_cost(), -(-m // e_tok))
# -- flush semantics --
def test_urgent_flush_preserves_float_holes(self):
_, allocator, kvcache, _ = self._build(lazy_compaction=True)
@@ -359,18 +318,11 @@ class TestTriPagedFreeGroup(unittest.TestCase):
"""The tri composite at PAGE SIZE > 1, driven through the production free
path: free_group_begin -> free_segment -> free_group_end.
Regression (GPU eval_440, Inkling ps=128 boot crash): every other tri test
runs at page_size=1, where the page-REPRESENTATIVE machinery
(`free_page_reps_group` / `_release_page_reps`, added when the free path
was made host-sync-free) is entirely dead code — `free_segment` frees
directly. At ps>1 the composite releases reps by calling
`swa_attn_allocator.free(..., _pages=...)`, and the float allocator was
ported from a base that predates that keyword, so the first real decode
batch died with `TypeError: unexpected keyword argument '_pages'`.
`_pages` is not cosmetic: honouring it is what keeps the free path free of
the data-dependent `torch.unique` host sync, so this also pins that the
float takes the caller's page ids rather than re-deriving them.
Regression: every other tri test runs at page_size=1, where the
page-representative machinery is dead code. At ps>1 the composite releases
reps via `swa_attn_allocator.free(..., _pages=...)`, which the float must
accept or the first decode batch dies; honouring `_pages` is also what
keeps the free path off the data-dependent `torch.unique` host sync.
"""
def _build_paged(self, page_size=4, n_full=64, n_swa=32, n_state=8):
@@ -418,22 +370,7 @@ class TestTriPagedFreeGroup(unittest.TestCase):
# Capacity fully recovered: the float parked, both ends rewound.
self.assertTrue(allocator.swa_attn_allocator._is_frontier_transparent())
def test_float_free_honours_caller_supplied_pages(self):
"""`_pages` must be USED, not merely accepted -- re-deriving it is the
host sync the paged free path exists to avoid."""
pool, allocator = self._build_paged()
v = allocator.alloc(8)
self.assertIsNotNone(v)
sa = allocator.swa_attn_allocator
ps = allocator.page_size
pages = (v[::ps] // ps).clone()
live_before = sa._live_pages()
sa.free(v[::ps] * 0 + v[::ps], _pages=pages)
self.assertEqual(sa._live_pages(), live_before - pages.numel())
def test_ungrouped_segment_free_also_reaches_the_float(self):
"""`free_segment` outside a free group releases reps immediately --
the same float call, one frame shallower."""
pool, allocator = self._build_paged()
v = allocator.alloc(8)
self.assertIsNotNone(v)
@@ -442,13 +379,10 @@ class TestTriPagedFreeGroup(unittest.TestCase):
class TestTriFreeSwaNoHostSync(unittest.TestCase):
"""The tri's swa side is the FLOAT, and the float can never run the lazy
event pipeline — so unless the per-step frees carry caller-derived page
ids, the tri silently reintroduces the host syncs the sync-free free
path removed. Poison the ops to pin the property.
(Fixtures at page_size > 1 on purpose: ps==1 short-circuits the whole
page machinery and hides exactly this class of bug.)
"""The tri's swa side is the FLOAT, which can never run the lazy event
pipeline: unless the per-step frees carry caller-derived page ids, the tri
reintroduces the host syncs the sync-free free path removed. Fixtures run
at page_size > 1; ps==1 short-circuits the page machinery and hides this.
"""
PS = 4
@@ -476,24 +410,9 @@ class TestTriFreeSwaNoHostSync(unittest.TestCase):
alloc.free_swa(v[: 4 * self.PS], start_pos=0)
self.assertEqual(alloc.verify_byte_accounting(), [])
def test_float_free_has_no_stale_slot_item_sync(self):
"""The float's free must not `.item()`-assert per free (the lazy-path
contract: callers must not double-free; the idle span == p2v-bound +
holes conservation catches violations without a per-free sync)."""
alloc = self._tri()
v = alloc.alloc(4 * self.PS)
sa = alloc.swa_attn_allocator
from unittest import mock
with mock.patch.object(
torch.Tensor, "item", side_effect=AssertionError("item = host sync")
):
sa.free(v[:: self.PS], _pages=v[:: self.PS] // self.PS)
def test_fallback_free_swa_still_correct_for_radix_shapes(self):
"""Radix eviction hands arbitrary node values (no start_pos): the
dedup fallback must keep working and end in the same state as the
stride path."""
dedup fallback must end in the same state as the stride path."""
a1, a2 = self._tri(), self._tri()
v1, v2 = a1.alloc(6 * self.PS), a2.alloc(6 * self.PS)
self.assertTrue(torch.equal(v1, v2))
@@ -512,12 +431,9 @@ class TestTriFreeSwaNoHostSync(unittest.TestCase):
class TestGeneralizedRebalance(unittest.TestCase):
"""The float must yield to WHICHEVER end is short, with the direction
computed from the layout — not only to the token path's hard-coded side.
The mechanism (`make_room`) was always side-agnostic; these pin the
POLICY: any end pool's own-alloc shortfall reaches
`_ask_float_for_room`, which derives the side from the caller's
growth direction."""
derived from the caller's growth direction rather than hard-coded to the
token path's side. `make_room` was always side-agnostic; these pin the
POLICY in `_ask_float_for_room`."""
PS = 4
@@ -527,34 +443,10 @@ class TestGeneralizedRebalance(unittest.TestCase):
)
return inst._build_paged(page_size=self.PS)[1]
def test_state_end_shortfall_slides_the_float_low(self):
"""The previously-missing direction: mamba (grow-up END) starved while
free bytes idle ABOVE the float. The remedy must slide the float up
(open its LOW side) and let the state alloc succeed."""
alloc = self._tri()
v = alloc.alloc(4 * self.PS) # places the float mid-region
self.assertIsNotNone(v)
ma = alloc.mamba_allocator
sa = alloc.swa_attn_allocator
self.assertFalse(sa._is_frontier_transparent())
# Fill the LOW band exactly: as many state slots as fit below the
# float's low frontier.
e_m = ma.entry_bytes_per_page
fit = (sa._byte_low_frontier() - ma._byte_high_frontier()) // e_m
self.assertGreater(fit, 0)
got = ma.alloc(int(fit) * ma.page_size)
self.assertIsNotNone(got)
low_before = sa.low_wm_page
# One more slot does NOT fit below the float -- only a rebalance helps.
more = ma.alloc(ma.page_size)
self.assertIsNotNone(more, "state alloc must succeed via float rebalance")
self.assertGreater(sa.low_wm_page, low_before) # float slid UP
self.assertEqual(alloc.verify_byte_accounting(), [])
def test_direction_is_derived_from_growth_on_both_ends(self):
"""Raw end+float+end chain, BOTH orientations in one fixture: the
up-growing end opens the float's LOW side; the down-growing end opens
its HIGH side. No layout assumption survives."""
up-growing end opens the float's LOW side, the down-growing end its
HIGH side."""
from test_multi_ended_allocator import TestFloatMultiEndedAllocator
inst = TestFloatMultiEndedAllocator(
@@ -583,8 +475,6 @@ class TestGeneralizedRebalance(unittest.TestCase):
self.assertLess(fla.high_wm_page, high_before) # opened HIGH side
def test_two_pool_chain_rebalance_is_a_noop(self):
"""No float in the chain => the remedy must change nothing (the
2-pool composites keep their exact pre-existing behavior)."""
from test_multi_ended_allocator import (
TestPagedMultiEndedAllocator as _PagedFixture,
)
@@ -617,9 +507,8 @@ class TestGeneralizedRebalance(unittest.TestCase):
class TestComputedShortSide(unittest.TestCase):
"""`_ask_float_for_room` must open the side that MEASURES short -- never
"the side facing full". These pin the per-side computation, including
the coupled-ends-on-both-sides shape a DSV4-style composite
(C128 | swa-float | C4) will need.
"the side facing full" -- including the shape with coupled ends on BOTH
sides of the float.
"""
PS = 4
@@ -638,9 +527,9 @@ class TestComputedShortSide(unittest.TestCase):
def test_float_share_short_opens_the_state_side(self):
"""RED-LINE: full's demand fits its band, the float's own share fits
NEITHER band, and the state side has the larger surplus — the policy
must open the STATE side (the float slides toward full during a
TOKEN alloc), which the old "side facing full" policy could never do.
NEITHER band, and the state side has the larger surplus -- so the
policy must open the STATE side, i.e. slide the float toward full
during a TOKEN alloc.
"""
from unittest import mock
@@ -654,10 +543,9 @@ class TestComputedShortSide(unittest.TestCase):
# Position: slide the float LOW (setup uses the mechanism directly),
# so the low band is small and the geometry below is expressible.
b_low0, b_high0 = self._sides(alloc)
# Two positioning moves: pack the float low (leapfrog over-opens by
# design), then open the LOW side back to ~2 full-pages -- small
# enough that F outgrows it, wide enough that the integer need_n
# window below is non-empty.
# Pack the float low (leapfrog over-opens by design), then reopen LOW
# to ~2 full-pages: F must outgrow it, yet the need_n window below
# must stay non-empty.
sa.make_room(side="high", min_bytes=b_low0 + b_high0 - 2 * e_f)
sa.make_room(side="low", min_bytes=2 * e_f)
@@ -686,10 +574,8 @@ class TestComputedShortSide(unittest.TestCase):
self.assertEqual(calls[0]["side"], "low") # the STATE side
def test_full_side_short_target_matches_the_closed_form(self):
"""Equivalence: when the full side is the short one (today's only
reachable end-shortage), the ask must equal the documented formula
demand + max(0, F - far_surplus) + slack — i.e. the historical
behavior is the special case, preserved."""
"""When the full side is the short one, the ask must equal the
closed form demand + max(0, F - far_surplus) + slack."""
from unittest import mock
alloc = self._tri()
@@ -717,9 +603,9 @@ class TestComputedShortSide(unittest.TestCase):
self.assertEqual(calls[0]["min_bytes"], want)
def test_two_coupled_ends_lands_demand_on_both_sides(self):
"""DSV4 shape (C128 | float | C4): a coupled set with ends on BOTH
sides. One-side-short must open that side; BOTH-sides-short must not
move at all (relocation is zero-sum between the bands)."""
"""A coupled set with ends on BOTH sides of the float: one-side-short
must open that side; BOTH-sides-short must not move at all
(relocation is zero-sum between the bands)."""
from unittest import mock
alloc = self._tri()
@@ -730,8 +616,8 @@ class TestComputedShortSide(unittest.TestCase):
alloc.full_attn_allocator,
alloc.mamba_allocator,
)
# Synthetic coupling: the state end joins the demand vector, exactly
# the override a DSV4-style composite would ship.
# Synthetic coupling: the state end joins the demand vector, the
# override a composite with ends on both sides would ship.
need = lambda self, t: {
fa: -(-t // self.page_size),
sa: -(-t // self.page_size),
@@ -764,11 +650,14 @@ class TestComputedShortSide(unittest.TestCase):
self.assertEqual(calls[0]["side"], "high")
def test_nothing_short_means_no_relocation(self):
"""Everything fits -> the policy must not move a single page."""
"""Everything fits -> no page moves. The tri's token vector carries
{mamba: 0}: a zero entry must not move the float either."""
from unittest import mock
alloc = self._tri()
alloc.alloc(4 * self.PS)
demand = alloc._alloc_demand(2 * self.PS)
self.assertEqual(demand[alloc.mamba_allocator], 0)
sa = alloc.swa_attn_allocator
with mock.patch.object(
sa, "make_room", side_effect=AssertionError("needless move")
@@ -779,12 +668,9 @@ class TestComputedShortSide(unittest.TestCase):
class TestFloatPolicyTotalTarget(unittest.TestCase):
"""`make_room`'s min_bytes is a TARGET for the whole band, not a delta.
Regression: the band-level policy passed `deficit + one page` — with a
PARTIALLY free band that is below the current gap, so `make_room`
no-oped and the allocation failed even though the float had room to
slide. (Its own test missed this because it filled the band exactly,
making deficit ≈ the whole need.) The demand-vector policy computes the
total target, so a partial gap under-asks never.
Regression: an ask shaped as `deficit + one page` lands BELOW the current
gap when the band is only PARTIALLY free, so `make_room` no-ops and the
allocation fails though the float had room to slide.
"""
PS = 4
@@ -804,34 +690,17 @@ class TestFloatPolicyTotalTarget(unittest.TestCase):
gap_slots = int((sa._byte_low_frontier() - ma._byte_high_frontier()) // e_m)
self.assertGreater(gap_slots, 2)
low_before = sa.low_wm_page
# Need = partial-gap + 3: the old delta-ask was BELOW the current
# gap, so nothing moved and this returned None.
# partial-gap + 3: a delta-shaped ask lands below the current gap.
got = ma.alloc((gap_slots + 3) * ma.page_size)
self.assertIsNotNone(got, "partial-gap shortfall must relocate, not fail")
self.assertGreater(sa.low_wm_page, low_before)
self.assertEqual(alloc.verify_byte_accounting(), [])
def test_zero_demand_bands_are_inert(self):
"""The tri's token vector carries {mamba: 0}: a zero entry must
neither move the float for mamba's sake nor trip the index guard."""
from unittest import mock
alloc = self._tri()
alloc.alloc(4 * self.PS)
demand = alloc._alloc_demand(2 * self.PS)
self.assertEqual(demand[alloc.mamba_allocator], 0)
sa = alloc.swa_attn_allocator
with mock.patch.object(
sa, "make_room", side_effect=AssertionError("needless move")
):
alloc._ask_float_for_room(1) # nothing short -> no relocation
class TestTriDeferredAbsorption(unittest.TestCase):
"""Boundary absorption is deferred out of the per-step free and paid once
at a quiescent point — the base allocator's model (its lazy free does "no
boundary absorb" and `_flush` pays a single D2H). These pin WHERE it is
now paid, and that skipping it stays merely conservative."""
at a quiescent point. These pin WHERE it is paid, and that skipping it
stays merely conservative."""
PS = 4
@@ -855,9 +724,8 @@ class TestTriDeferredAbsorption(unittest.TestCase):
self.assertEqual(alloc.verify_byte_accounting(), [])
def test_shortfall_ladder_absorbs_before_the_deficit_math(self):
"""The zero-copy rung must run FIRST: a stale-wide span would inflate
the rebalance deficit and buy a `make_room` relocation the shrink
already covers."""
"""The zero-copy rung must run FIRST: a stale-wide span inflates the
rebalance deficit and buys a relocation the shrink already covers."""
alloc = self._tri()
v = alloc.alloc(8 * self.PS)
sa = alloc.swa_attn_allocator
@@ -884,9 +752,9 @@ class TestTriDeferredAbsorption(unittest.TestCase):
def test_clean_flush_skips_the_d2h_entirely(self):
"""Only `free` can put a hole ON a boundary (alloc DRAINS holes into
live pages; extension adds live pages), so with nothing freed since
the last absorb the walk provably finds nothing and must not pay
the D2H. Steady churn with only interior holes then costs no sync."""
live pages, extension adds live pages), so with nothing freed since
the last absorb the walk provably finds nothing and must not pay the
D2H."""
from unittest import mock
alloc = self._tri()
@@ -902,9 +770,8 @@ class TestTriDeferredAbsorption(unittest.TestCase):
self.assertEqual(sa._flush(urgent=False), 0)
def test_alloc_between_frees_cannot_hide_a_boundary_hole(self):
"""Soundness of the skip: an alloc drains holes and can change the
hole COUNT back to a previously-seen value, so the flag must be armed
by `free`, not inferred from `numel()`."""
"""An alloc drains holes and can restore a previously-seen hole
COUNT, so the flag must be armed by `free`, not read off `numel()`."""
alloc = self._tri()
v = alloc.alloc(8 * self.PS)
sa = alloc.swa_attn_allocator
@@ -1019,9 +886,9 @@ class TestTriFactorySizing(unittest.TestCase):
class TestTriPoolHardening(unittest.TestCase):
"""C1.7 pressure lanes: the planned-rebalance remedy in the alloc path
(a mis-positioned float must not fail an alloc that fits in total bytes),
retract-loop convergence through check_decode_capacity, and bounded copy
"""Pressure lanes: the planned-rebalance remedy in the alloc path (a
mis-positioned float must not fail an alloc that fits in total bytes),
retract-loop convergence through `check_decode_capacity`, and bounded copy
traffic under alternating end pressure.
"""
@@ -1029,9 +896,9 @@ class TestTriPoolHardening(unittest.TestCase):
return TestUnifiedTriPool._build(self, **kw)
def test_alloc_rebalances_a_blocking_float(self):
# Fill much of the high band so the float (midpoint-placed) walls off
# the low band's free bytes from `full`; the next alloc must succeed
# by SLIDING the float, not fail while total bytes suffice.
# Fill much of the high band so the midpoint-placed float walls the
# low band's free bytes off from `full`; the next alloc must SLIDE it
# rather than fail while total bytes suffice.
_, allocator, kvcache, _ = self._build(n_full=32, n_swa=24, n_state=8)
sa = allocator.swa_attn_allocator
v0 = allocator.alloc(4) # places the float at the region midpoint
@@ -1043,9 +910,8 @@ class TestTriPoolHardening(unittest.TestCase):
b_high_pages = fa._current_gap_bytes() // fa.entry_bytes_per_page
grab = fa.alloc(max(0, (b_high_pages - 2)))
self.assertIsNotNone(grab)
# The honest gate under-reports (no slide credit) -- asking BEYOND it
# is what fires the rebalance remedy; the ask still fits total free
# bytes because the LOW band holds them behind the float.
# The gate under-reports (no slide credit), so asking BEYOND it fires
# the remedy; the ask still fits the free bytes the LOW band holds.
avail = allocator.available_size()
need = avail + 4
live_before = sa._live_pages()
@@ -1063,9 +929,8 @@ class TestTriPoolHardening(unittest.TestCase):
self.assertEqual(allocator.verify_byte_accounting(), [])
def test_check_decode_capacity_retract_convergence(self):
# Simulated retract loop: requests' token blocks freed one at a time
# until the next-step allocation fits; must converge before bs=1 and
# never report capacity while the gate is short.
# Retract loop: token blocks freed one at a time until the next step
# fits; must converge before bs=1 and never report capacity early.
_, allocator, _, _ = self._build(n_full=32, n_swa=24, n_state=8)
reqs = []
while True:
@@ -1085,9 +950,8 @@ class TestTriPoolHardening(unittest.TestCase):
self.assertEqual(allocator.verify_byte_accounting(), [])
def test_alternating_pressure_copy_traffic_bounded(self):
# Alternating full-grow / swa-churn cycles: total float moves stay
# bounded (hole recycling + absorption do the steady-state work; the
# rebalance fires only on real positional deficits).
# Alternating full-grow / swa-churn: hole recycling and absorption do
# the steady-state work, so total float moves stay bounded.
_, allocator, kvcache, _ = self._build(n_full=48, n_swa=32, n_state=8)
sa = allocator.swa_attn_allocator
fa = allocator.full_attn_allocator
@@ -1124,13 +988,11 @@ class TestJointCapacityIsHonoured(unittest.TestCase):
"""`alloc(available_size())` must never fail.
REGRESSION: the joint predicate priced the swa float's extension in RAW
BYTES while `take_physical_pages` can only use whole pages on the float's
OWN grid -- `_region_bounds_pages` rounds the band's low edge UP. The
bounding frontier is a multiple of the NEIGHBOUR's entry size, which is
unrelated to the float's, so the byte budget credited a page the grid could
not yield and the very first alloc tripped `alloc_with_virtual`'s backstop
assert. Swept over geometries rather than pinned to one, so a symmetric
mistake on the FULL side would surface here too.
BYTES, but `take_physical_pages` yields only whole pages on the float's
OWN grid, whose low edge is rounded up to a multiple of the NEIGHBOUR's
entry size -- so the budget credited a page the grid could not yield.
Swept over geometries so a symmetric mistake on the FULL side surfaces
here too.
"""
def _build(self, *, page_size, n_full, n_swa, n_state, lazy, specs):
@@ -1162,8 +1024,7 @@ class TestJointCapacityIsHonoured(unittest.TestCase):
def test_fresh_boot_alloc_of_available_size_succeeds(self):
# Geometries chosen so the mamba end's frontier (a multiple of the
# STATE entry size) lands off the swa float's page grid -- the
# misalignment the byte budget used to ignore.
# STATE entry size) lands off the swa float's page grid.
for page_size in (1, 2, 4):
for fl, sl, ml in ((4, 3, 1), (4, 2, 2), (6, 3, 1), (3, 5, 2)):
for n_full, n_swa, n_state in ((24, 16, 4), (32, 16, 8), (20, 12, 6)):
@@ -1200,6 +1061,15 @@ class TestJointCapacityIsHonoured(unittest.TestCase):
f"alloc(available_size()={n}) returned None",
)
self.assertEqual(out.numel(), n)
# Both sides bound for every allocated virtual id.
fa = alloc.full_attn_allocator
sa = alloc.swa_attn_allocator
self.assertTrue(
bool((fa.virtual_to_physical[out] >= 0).all())
)
self.assertTrue(
bool((sa.virtual_to_physical[out] >= 0).all())
)
def test_available_size_never_exceeds_the_float_page_grid(self):
"""Direct form: the joint answer, converted to float pages, must fit
@@ -1235,13 +1105,11 @@ class TestJointCapacityIsHonoured(unittest.TestCase):
class TestFloatRelocationIsOrderedAgainstTheForward(unittest.TestCase):
"""Float relocation must settle the in-flight forward BEFORE its first copy.
REGRESSION: `make_room` / `compact_holes` issued `move_kv_cache` and rebound
`virtual_to_physical` with no ordering against the running forward, so the
copy could carry pre-write bytes and the rebind then pointed every later
reader at a destination that never received those writes -- silently wrong
KV, no crash. The END pools guard exactly this hazard in
`_flush(urgent=True)` via `_settle_inflight_forward`; the float had no
`forward_stream` / `wait_event` / settle call anywhere in its body.
REGRESSION: `make_room` / `compact_holes` issued `move_kv_cache` and
rebound `virtual_to_physical` with no ordering against the running
forward, so a copy could carry pre-write bytes and every later reader saw
a destination that never received those writes -- silently wrong KV, no
crash. The END pools guard the same hazard via `_settle_inflight_forward`.
"""
def _tri(self, lazy=True):
@@ -1318,11 +1186,9 @@ class TestFloatRelocationIsOrderedAgainstTheForward(unittest.TestCase):
self.assertEqual(order[0], "settle", f"first action was not a settle: {order}")
def test_the_settle_is_a_stream_wait_not_a_host_sync(self):
"""Pin the mechanism: `_settle_inflight_forward` must stream-wait, so
the fix costs no host sync on the shortfall path."""
src = inspect.getsource(
mea.MultiEndedAllocator._settle_inflight_forward # noqa: SLF001
)
"""The settle before a float move must be a stream wait: a host sync
there would land on the alloc-shortfall path every time the float moves."""
src = inspect.getsource(MultiEndedAllocator._settle_inflight_forward)
self.assertIn("wait_event", src)
self.assertNotIn(".item()", src)
self.assertNotIn("synchronize()", src)
@@ -1331,14 +1197,12 @@ class TestFloatRelocationIsOrderedAgainstTheForward(unittest.TestCase):
class TestFloatHoleCreditIsPerSide(unittest.TestCase):
"""A float's schedulable credit must follow the side the holes are on.
REGRESSION: the base `_peer_drainable_hole_bytes` asks
`_growth_side_neighbor()`, which reads `grow_direction`. A float's is
"float", so the base fell through to `low_peer` -- it never saw the HIGH
neighbour, and the single scalar it returned was then added to
`max(gap_low, gap_high)`, landing a LOW neighbour's holes on the HIGH gap.
Over-reporting `schedulable_available_size` makes the scheduler admit work
the shortfall ladder cannot satisfy, which the caller treats as a
memory-estimation bug.
REGRESSION: the base `_peer_drainable_hole_bytes` picks its neighbour from
`grow_direction`, which for a float is "float" -- it fell through to
`low_peer`, never saw the HIGH neighbour, and its single scalar was added
to `max(gap_low, gap_high)`, landing a LOW neighbour's holes on the HIGH
gap. Over-reporting `schedulable_available_size` admits work the shortfall
ladder cannot satisfy.
"""
def _float(self):
@@ -1365,15 +1229,6 @@ class TestFloatHoleCreditIsPerSide(unittest.TestCase):
)
return alloc, alloc.swa_attn_allocator
def test_credit_sees_both_neighbours(self):
_alloc, flt = self._float()
self.assertIsInstance(flt, FloatMultiEndedAllocator)
low = flt._side_drainable_hole_bytes("low")
high = flt._side_drainable_hole_bytes("high")
self.assertEqual(flt._peer_drainable_hole_bytes(), max(low, high))
# The base would have answered with the LOW side alone.
self.assertGreaterEqual(flt._peer_drainable_hole_bytes(), high)
def test_schedulable_never_exceeds_the_sum_of_the_two_sides(self):
"""Upper bound that the undirected scalar could violate: no side may be
credited with the other side's holes on top of its own gap."""
@@ -13,30 +13,19 @@
# ==============================================================================
"""`--enable-page-major-kv-layout` full-attention backend allowlist.
Two-way gate (see `_handle_page_major_kv_layout`), because the unified pool
exposes per-layer views and nothing else:
* unified-memory MLA models (`build_mla_views`) allow the whole wired
paged MLA family -- `fa3`, `flashinfer`'s MLA backend, `trtllm_mla` with
its `cutedsl_mla` / `tokenspeed_mla` subclasses, and `flashmla` (ps=64
snap);
* unified-memory MHA/SWA models (`build_mha_views`) allow `fa3` /
`fa4` / `flashinfer` / `trtllm_mha` alongside Triton;
* plain `--enable-page-major-kv-layout` without the unified pool keeps the
envelope-strided 4-D views only the stride-aware Triton kernels read.
`handle_page_major_kv_layout` gates two ways, because the per-layer views the
unified pool exposes are all the allowlisted backends can read: an MLA arm
(`build_mla_views`), an MHA/SWA arm (`build_mha_views`), and no page-major arm
at all without the unified pool. `fa3` is the resolved default on pre-Blackwell
hosts, so its absence from an arm makes `--enable-unified-memory` fail to boot
under its own default configuration.
The same handler also screens the pool itself: the MHA/SWA per-layer views need
The same handler screens the pool itself: the MHA/SWA per-layer views need
uniform K/V rows, so an asymmetric-K/V model (MiMoV2: head_dim 192 !=
v_head_dim 128) cannot run `--enable-unified-memory` at all and is rejected on
EVERY backend, Triton included. MLA models are exempt -- their sub-pool keeps
one latent row per layer, and several MLA configs (Kimi-Linear: head_dim 72,
v_head_dim 128) report asymmetric dims while running the unified pool today.
Pinned here so no arm silently widens to an unwired backend (`cutlass_mla`,
`aiter`) and no arm silently narrows: `fa3` is the resolved default on
pre-Blackwell hosts, so its absence from an arm makes `--enable-unified-memory`
fail to boot under its own default configuration.
python -m pytest test/registered/unit/server_args/test_page_major_backend_allowlist.py -v
v_head_dim 128) is rejected on EVERY backend, Triton included. MLA models are
exempt -- their sub-pool keeps one latent row per layer, and real MLA configs
(Kimi-Linear: head_dim 72, v_head_dim 128) report asymmetric dims while running
the unified pool today.
"""
import unittest
@@ -59,11 +48,8 @@ def _accepts(
linear_prefill: str | None = None,
has_asymmetric_kv: bool = False,
) -> bool:
"""Run just `_handle_page_major_kv_layout` against a minimal stand-in.
ServerArgs' real constructor pulls in a model config; this exercises the
single handler under test with the fields it reads.
"""
"""Run just `handle_page_major_kv_layout` against a minimal stand-in, since
ServerArgs' real constructor pulls in a model config."""
sa = ServerArgs.__new__(ServerArgs)
for name, value in {
"enable_unified_memory": unified,
@@ -145,21 +131,9 @@ class TestPageMajorBackendAllowlist(unittest.TestCase):
f"{backend} is an MLA kernel and must stay out of the MHA arm",
)
def test_plain_page_major_arm_is_gated_at_boot(self):
"""The strided views were removed: --enable-page-major-kv-layout
without --enable-unified-memory must be rejected up front for EVERY
backend, Triton included, until the per-layer-view reimplementation."""
for backend in ("triton",) + self.PER_LAYER_VIEW_MLA_BACKENDS:
for use_mla in (True, False):
self.assertFalse(
_accepts(backend, use_mla=use_mla, unified=False),
f"{backend} must be rejected on the static page-major arm",
)
def test_asymmetric_kv_mha_model_cannot_use_unified_memory(self):
"""head_dim != v_head_dim (MiMoV2): no uniform rows, so no per-layer views
and no unified pool. The rejection is the POOL's, not a backend's, so
it must fire on every backend -- Triton included."""
"""The rejection is the POOL's, not a backend's, so it must fire on
every backend -- Triton included."""
for backend in ("triton",) + self.PER_LAYER_VIEW_MHA_BACKENDS:
self.assertFalse(
_accepts(backend, use_mla=False, has_asymmetric_kv=True),
@@ -168,10 +142,8 @@ class TestPageMajorBackendAllowlist(unittest.TestCase):
)
def test_asymmetric_dims_do_not_screen_out_mla(self):
"""MLA stores one latent row per layer, so its K/V head dims never have
to agree -- and real MLA configs report them as unequal (Kimi-Linear:
head_dim 72, v_head_dim 128). Screening on `has_asymmetric_kv` alone
would lock every one of them out of the unified pool."""
"""Screening on `has_asymmetric_kv` alone would lock every real MLA
config out of the unified pool."""
for backend in ("triton",) + self.PER_LAYER_VIEW_MLA_BACKENDS:
self.assertTrue(
_accepts(backend, use_mla=True, has_asymmetric_kv=True),
@@ -180,9 +152,8 @@ class TestPageMajorBackendAllowlist(unittest.TestCase):
)
def test_page_major_rejected_without_unified_memory(self):
"""--enable-page-major-kv-layout without the unified pool is rejected
outright, Triton included: the static page-major arm went away with
the strided views and awaits its per-layer-view reimplementation."""
"""There is no static page-major arm today, so the flag alone is
rejected outright -- Triton included."""
for backend in ("triton",) + tuple(
set(self.PER_LAYER_VIEW_MLA_BACKENDS + self.PER_LAYER_VIEW_MHA_BACKENDS)
):