feat(unified-memory): dense KV views for uniform-row MHA/SWA models (#34602)

Co-authored-by: Caihua Li <caihua.li@bytedance.com>
Co-authored-by: Cheng Wan <cheng.wan@radixark.ai>
This commit is contained in:
caihuali95
2026-08-30 15:10:12 -07:00
committed by GitHub
co-authored by Caihua Li Cheng Wan
parent 007ef5e23a
commit 4bea51d885
30 changed files with 1310 additions and 1819 deletions
@@ -1,10 +1,10 @@
"""
End-to-end accuracy test for the page-major KV layout on a hybrid-SWA MoE model.
End-to-end accuracy test for the unified memory pool on a hybrid-SWA MoE model.
Launches gpt-oss-20b with ``--enable-page-major-kv-layout`` on the Triton
attention backend and checks that GSM8K accuracy holds. This exercises the
SWA + full-attention KV pools under the page-granularity envelope layout
(SWAKVPool routes both sub-pools through PageMajorMHATokenToKVPool).
Launches gpt-oss-20b with ``--enable-unified-memory`` on the Triton attention
backend and checks that GSM8K accuracy holds. This exercises the SWA +
full-attention KV sub-pools stored as per-layer views in the unified
page-major envelope.
Registered to the label-gated ``run-ci-extra`` suite (opt-in, not per-commit).
@@ -22,9 +22,17 @@ from sglang.test.test_utils import DEFAULT_MODEL_NAME_FOR_TEST_MXFP4_WITH_MOE
register_cuda_ci(est_time=420, stage="extra-a", runner_config="1-gpu-large")
_UNIFIED_COMMON_ARGS = [
"--enable-unified-memory",
"--mem-fraction-static",
"0.70",
"--cuda-graph-backend-prefill=disabled",
]
class TestPageMajorGptOss(DefaultServerBase):
"""Page-major KV layout on gpt-oss-20b (hybrid-SWA MoE), Triton backend."""
class TestUnifiedGptOssTriton(DefaultServerBase):
"""Unified pool on gpt-oss-20b (hybrid-SWA MoE), Triton pinned: dense
MHA/SWA views through the reference backend."""
model = DEFAULT_MODEL_NAME_FOR_TEST_MXFP4_WITH_MOE
@@ -33,16 +41,7 @@ class TestPageMajorGptOss(DefaultServerBase):
num_shots = 5
parallel = 32
other_args = [
"--enable-page-major-kv-layout",
# The envelope's strided 4-D K/V views are only read by the Triton
# attention kernels (the layout's validator enforces this).
"--attention-backend",
"triton",
"--mem-fraction-static",
"0.70",
"--cuda-graph-backend-prefill=disabled",
]
other_args = _UNIFIED_COMMON_ARGS + ["--attention-backend", "triton"]
def test_gsm8k(self):
from sglang.test.few_shot_gsm8k import run_eval as run_few_shot_gsm8k
@@ -1,12 +1,12 @@
"""
End-to-end accuracy test for the page-major KV layout on a GDN-hybrid model.
End-to-end accuracy test for the unified memory pool on a GDN-hybrid model.
Launches Qwen3.5-4B (a gated-delta-net / linear-attention hybrid) with
``--enable-page-major-kv-layout`` on the Triton attention + linear-attn + Mamba
backends and checks that GSM8K accuracy holds. This exercises the page-major
path most prone to subtle bugs: the Mamba conv/SSM state stored as a strided
envelope view, plus the full-attention KV pool, both read/written by the GDN
prefill and decode kernels.
``--enable-unified-memory`` on the Triton attention + linear-attn + Mamba
backends and checks that GSM8K accuracy holds. This exercises the unified
envelope's most bug-prone path: the Mamba conv/SSM state stored as a strided
envelope view, plus the full-attention KV stored as per-layer views,
both read/written by the GDN prefill and decode kernels.
Registered to the label-gated ``run-ci-extra`` suite (opt-in, not per-commit).
@@ -24,35 +24,34 @@ from sglang.test.test_utils import DEFAULT_HYBRID_GDN_SMALL_MODEL_NAME_FOR_TEST
register_cuda_ci(est_time=300, stage="extra-a", runner_config="1-gpu-large")
_UNIFIED_COMMON_ARGS = [
"--trust-remote-code",
"--mem-fraction-static",
"0.85",
"--enable-unified-memory",
"--linear-attn-backend",
"triton",
"--mamba-backend",
"triton",
]
class TestPageMajorQwenHybrid(DefaultServerBase):
"""Page-major KV layout on Qwen3.5-4B (GDN-hybrid), Triton backends."""
class TestUnifiedQwenHybridTriton(DefaultServerBase):
"""Unified pool on Qwen3.5-4B (GDN-hybrid), Triton pinned: dense
full-attention views + strided conv/SSM state through the reference
backends."""
model = DEFAULT_HYBRID_GDN_SMALL_MODEL_NAME_FOR_TEST
# Measured in this harness: baseline (no page-major) and page-major both
# ~0.86; the 0.80 threshold leaves margin for run-to-run noise while still
# catching the prefill-state corruption that page-major hit before the
# gather/scatter fix in gdn_backend.forward_extend (which dropped it to ~0.61).
# 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.
gsm8k_threshold = 0.80
num_gsm8k_questions = 200
num_shots = 5
parallel = 32
other_args = [
"--trust-remote-code",
"--mem-fraction-static",
"0.85",
"--enable-page-major-kv-layout",
# Only the Triton attention / linear-attn / Mamba kernels read the
# strided envelope K/V and conv/SSM state (enforced by the validator).
"--attention-backend",
"triton",
"--linear-attn-backend",
"triton",
"--mamba-backend",
"triton",
]
other_args = _UNIFIED_COMMON_ARGS + ["--attention-backend", "triton"]
def test_gsm8k(self):
from sglang.test.few_shot_gsm8k import run_eval as run_few_shot_gsm8k
@@ -23,7 +23,7 @@ always PHYSICAL. Two routing contracts are pinned here:
`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` rail
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
@@ -127,7 +127,7 @@ class TestUnifiedSWARouting(unittest.TestCase):
self.assertEqual(len(pool.swa_kv_pool.calls), 1)
forwarded, kwargs = pool.swa_kv_pool.calls[0]
# SWA write rides the backend rail: forward the swa-physical loc directly.
# SWA write rides the backend slot: forward the swa-physical loc directly.
self.assertIs(forwarded, swa_phys)
self.assertNotIn("already_physical", kwargs)
# Full pool untouched for an SWA layer.
@@ -138,7 +138,7 @@ class TestUnifiedSWARouting(unittest.TestCase):
virtual_loc = torch.tensor([10, 11, 12], dtype=torch.int64)
layer = types.SimpleNamespace(layer_id=1) # SWA layer
# No swa_loc bundled -> the rail contract is violated; must assert
# No swa_loc bundled -> the write-loc contract is violated; must assert
# rather than silently writing wrong (un-translated) locations.
with self.assertRaises(AssertionError):
pool.set_kv_buffer(
@@ -149,6 +149,46 @@ class TestUnifiedSWARouting(unittest.TestCase):
)
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.
"""
def _make_bare_pool(self, page_size, v2p, multiplier=1):
from sglang.srt.mem_cache.multi_ended_allocator import MultiEndedAllocator
from sglang.srt.mem_cache.unified_memory_pool import UnifiedSWAKVPool
# A real sub-allocator (not a stand-in): the translation reads its v2p
# table, and the pool reaches it through the allocator's own method.
swa_allocator = object.__new__(MultiEndedAllocator)
swa_allocator.page_size = page_size
swa_allocator.virtual_to_physical = v2p
swa_allocator.kernel_page_multiplier = multiplier
pool = object.__new__(UnifiedSWAKVPool)
pool._swa_allocator = swa_allocator
return pool
def test_tombstoned_id_lands_on_sink(self):
for ps, mult in ((1, 1), (4, 1), (4, 6)):
v2p = torch.tensor([0, -1, 2], dtype=torch.int64)
pool = self._make_bare_pool(ps, v2p, multiplier=mult)
# Virtual ids covering the tombstoned page (index 1) and a live one.
kv_indices = torch.tensor([0, ps, 2 * ps], dtype=torch.int64)
out = pool.translate_loc_from_full_to_swa(kv_indices)
self.assertEqual(out.dtype, torch.int64)
self.assertTrue(
bool((out >= 0).all().item()),
f"tombstoned swa id stayed negative at page_size={ps}, "
f"multiplier={mult}: {out}",
)
self.assertEqual(int(out[1].item()), 0)
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
@@ -11,18 +11,18 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Unit tests for the page-major layer-major byte layout.
"""Unit tests for the page-major envelope byte layout.
The subject here is the ENVELOPE — the byte layout the unified pool stores its
KV in — pinned through ``MHASubPoolSpec``'s offset math. The dense 3-D 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. The new 4-D ``_build_mha_views`` output exposes correct byte addresses
for each (layer, page, tok_in_page, head, dim) — under both the
degenerate ``page_size=1`` case (byte-identical to the old per-token
envelope) and the new ``page_size>1`` layer-major case.
2. ``MHASubPoolSpec.layer_k_offset_in_page`` /
``layer_v_offset_in_page`` math matches the layout intent.
3. ``set_kv_buffer`` round-trips correctly for both page sizes.
4. Compaction (``move_kv_cache_native``) moves the right bytes for both
page sizes via the 4-D advanced indexing path.
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.
@@ -38,11 +38,7 @@ 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 (
MambaSubPoolSpec,
MHASubPoolSpec,
UnifiedKVPool,
)
from sglang.srt.mem_cache.unified_memory_pool import MHASubPoolSpec
_DEV = "cpu"
@@ -58,18 +54,6 @@ def _make_mha_spec(name, grow, layer_num=2, head_num=2, head_dim=4):
)
def _make_mamba_spec(name, grow, layer_num=2):
return MambaSubPoolSpec(
name=name,
layer_num=layer_num,
conv_state_shapes=((4, 3),),
conv_dtype=torch.float32,
temporal_state_shape=(2, 2, 2),
temporal_dtype=torch.float32,
grow_direction=grow,
)
class TestMHASpecLayerOffsets(unittest.TestCase):
"""Verify ``layer_k_offset_in_page`` / ``layer_v_offset_in_page`` math."""
@@ -113,175 +97,10 @@ class TestMHASpecLayerOffsets(unittest.TestCase):
self.assertEqual(spec.page_bytes(ps), ps * spec.entry_bytes())
class TestBuildMHAViews(unittest.TestCase):
"""Verify the 4-D view shape + strides at both page sizes."""
def _build(self, page_size, layer_num=3, head_num=2, head_dim=4, n_full_slots=64):
full = _make_mha_spec(
"full", "up", layer_num=layer_num, head_num=head_num, head_dim=head_dim
)
swa = _make_mha_spec(
"swa", "down", layer_num=2, head_num=head_num, head_dim=head_dim
)
# Pad to ensure max_slots % page_size == 0 in both sub-pools.
# entry_bytes is fixed per spec; size accordingly.
total = full.entry_bytes() * n_full_slots + swa.entry_bytes() * n_full_slots
pool = UnifiedKVPool(
total_bytes=total,
sub_pool_specs=[full, swa],
device=_DEV,
enable_memory_saver=False,
page_size=page_size,
)
return pool, full
def test_view_shape_is_4d(self):
for ps in [1, 8]:
pool, spec = self._build(page_size=ps)
k_views, v_views = pool.mha_views_for("full")
self.assertEqual(len(k_views), spec.layer_num)
max_slots = pool.max_slots("full")
for L in range(spec.layer_num):
self.assertEqual(k_views[L].ndim, 4)
self.assertEqual(
tuple(k_views[L].shape),
(max_slots // ps, ps, spec.head_num, spec.head_dim),
)
self.assertEqual(
tuple(v_views[L].shape),
(max_slots // ps, ps, spec.head_num, spec.v_head_dim),
)
def test_strides_at_page_size_1_match_envelope(self):
"""At ps=1, the 4-D view's stride[0] equals what today's 3-D view's
stride[0] would have been (= entry_bytes / itemsize)."""
pool, spec = self._build(page_size=1, layer_num=4, head_num=3, head_dim=8)
k_views, _ = pool.mha_views_for("full")
itemsize = spec.store_dtype.itemsize
for L in range(spec.layer_num):
# stride[0] = page_bytes/itemsize = entry_bytes/itemsize at ps=1
self.assertEqual(k_views[L].stride(0), spec.entry_bytes() // itemsize)
# stride[1] = k_row/itemsize (within-page token stride)
self.assertEqual(k_views[L].stride(1), spec.k_row_bytes() // itemsize)
# stride[2] = head_dim (head stride)
self.assertEqual(k_views[L].stride(2), spec.head_dim)
# stride[3] = 1 (innermost)
self.assertEqual(k_views[L].stride(3), 1)
def test_strides_at_page_size_gt_1(self):
pool, spec = self._build(page_size=8, layer_num=4, head_num=3, head_dim=8)
k_views, _ = pool.mha_views_for("full")
itemsize = spec.store_dtype.itemsize
for L in range(spec.layer_num):
# page_bytes = 8 * 4 * (k_row + v_row); stride[0] = that / itemsize
self.assertEqual(k_views[L].stride(0), spec.page_bytes(8) // itemsize)
# token stride within layer L's K block = k_row/itemsize
self.assertEqual(k_views[L].stride(1), spec.k_row_bytes() // itemsize)
self.assertEqual(k_views[L].stride(2), spec.head_dim)
self.assertEqual(k_views[L].stride(3), 1)
def test_distinct_layers_dont_alias_at_page_size_gt_1(self):
"""Writes to layer 0 must not affect layer 1's K/V values (under
layer-major within-page layout)."""
pool, spec = self._build(page_size=8, layer_num=3, head_num=2, head_dim=4)
k_views, v_views = pool.mha_views_for("full")
# Set page 0, token 3, layer 0 K to a distinct pattern.
target_val = 0.5
k_views[0][0, 3] = target_val
# Layer 1 K at the same (page, tok) should remain at default (0.0).
self.assertFalse(torch.all(k_views[1][0, 3] == target_val))
self.assertTrue(torch.all(k_views[1][0, 3] == 0.0))
# And layer 0 V at the same (page, tok) should remain at default.
self.assertFalse(torch.all(v_views[0][0, 3] == target_val))
self.assertTrue(torch.all(v_views[0][0, 3] == 0.0))
def test_distinct_pages_dont_alias_at_page_size_gt_1(self):
"""Writes to one page must not affect another page."""
pool, spec = self._build(page_size=8, layer_num=3, head_num=2, head_dim=4)
k_views, _ = pool.mha_views_for("full")
# Set page 0, token 3, layer 0 K to a distinct pattern.
k_views[0][0, 3] = 1.25
# Page 1, token 3, layer 0 K should remain at default.
self.assertTrue(torch.all(k_views[0][1, 3] == 0.0))
class TestMoveKVCacheNative4D(unittest.TestCase):
"""Verify ``move_kv_cache_native`` handles 4-D buffers at both
page_size=1 (degenerate envelope) and page_size>1 (layer-major)."""
def _build_buffer(
self, page_size, layer_num=2, head_num=2, head_dim=4, n_full_slots=64
):
full = _make_mha_spec(
"full", "up", layer_num=layer_num, head_num=head_num, head_dim=head_dim
)
swa = _make_mha_spec(
"swa", "down", layer_num=2, head_num=head_num, head_dim=head_dim
)
total = full.entry_bytes() * n_full_slots + swa.entry_bytes() * n_full_slots
pool = UnifiedKVPool(
total_bytes=total,
sub_pool_specs=[full, swa],
device=_DEV,
enable_memory_saver=False,
page_size=page_size,
)
return pool
def test_move_kv_cache_page_size_1(self):
pool = self._build_buffer(page_size=1, layer_num=2, head_num=2, head_dim=4)
k_views, v_views = pool.mha_views_for("full")
# Write distinct markers at source slots 5, 6.
for L in range(2):
k_views[L][5, 0] = float(L + 1)
v_views[L][5, 0] = -float(L + 1)
k_views[L][6, 0] = float(L + 10)
v_views[L][6, 0] = -float(L + 10)
# Move 5 -> 8 and 6 -> 9.
move_kv_cache_native(
k_views,
v_views,
tgt_loc=torch.tensor([8, 9], dtype=torch.int64),
src_loc=torch.tensor([5, 6], dtype=torch.int64),
page_size=1,
)
for L in range(2):
self.assertTrue(torch.all(k_views[L][8, 0] == float(L + 1)))
self.assertTrue(torch.all(v_views[L][8, 0] == -float(L + 1)))
self.assertTrue(torch.all(k_views[L][9, 0] == float(L + 10)))
self.assertTrue(torch.all(v_views[L][9, 0] == -float(L + 10)))
def test_move_kv_cache_page_size_gt_1(self):
ps = 8
pool = self._build_buffer(page_size=ps, layer_num=2, head_num=2, head_dim=4)
k_views, v_views = pool.mha_views_for("full")
# Write markers at token ids 5 and 14 (different pages).
for L in range(2):
# token 5 = (page 0, tok 5)
k_views[L][0, 5] = float(L + 1)
v_views[L][0, 5] = -float(L + 1)
# token 14 = (page 1, tok 6)
k_views[L][1, 6] = float(L + 10)
v_views[L][1, 6] = -float(L + 10)
# Move token 5 -> token 23 (page 2, tok 7) and 14 -> 31 (page 3, tok 7).
move_kv_cache_native(
k_views,
v_views,
tgt_loc=torch.tensor([23, 31], dtype=torch.int64),
src_loc=torch.tensor([5, 14], dtype=torch.int64),
page_size=ps,
)
for L in range(2):
# 23 = page 2, tok 7
self.assertTrue(torch.all(k_views[L][2, 7] == float(L + 1)))
self.assertTrue(torch.all(v_views[L][2, 7] == -float(L + 1)))
# 31 = page 3, tok 7
self.assertTrue(torch.all(k_views[L][3, 7] == float(L + 10)))
self.assertTrue(torch.all(v_views[L][3, 7] == -float(L + 10)))
def test_move_kv_cache_3d_legacy_path_unchanged(self):
"""move_kv_cache_native(3-D, page_size=1) must take the legacy
else-branch and be byte-identical to today."""
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."""
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):
@@ -292,64 +111,11 @@ class TestMoveKVCacheNative4D(unittest.TestCase):
v,
tgt_loc=torch.tensor([7], dtype=torch.int64),
src_loc=torch.tensor([5], dtype=torch.int64),
page_size=1,
)
for L in range(2):
self.assertTrue(torch.all(k[L][7] == float(L + 1)))
self.assertTrue(torch.all(v[L][7] == -float(L + 1)))
class TestByteIdentityAtPageSize1(unittest.TestCase):
"""Verify that at page_size=1 the new 4-D view describes the SAME
physical bytes as the old 3-D view would have. The view
semantics differ (4-D vs 3-D shape) but the underlying byte layout is
identical — confirmed by manually computing expected byte offsets and
matching them against the 4-D view's strides + storage_offset.
"""
def test_byte_addresses_match_envelope(self):
spec = _make_mha_spec("full", "up", layer_num=4, head_num=2, head_dim=4)
ps = 1
# Build pool.
total = spec.entry_bytes() * 64 + spec.entry_bytes() * 32
pool = UnifiedKVPool(
total_bytes=total,
sub_pool_specs=[
spec,
_make_mha_spec("swa", "down", layer_num=2),
],
device=_DEV,
enable_memory_saver=False,
page_size=ps,
)
k_views, v_views = pool.mha_views_for("full")
# For each (layer, slot), compute the expected byte address under
# the envelope layout and verify the 4-D view's data_ptr +
# advanced indexing agrees.
max_slots = pool.max_slots("full")
itemsize = spec.store_dtype.itemsize
base_addr = pool._raw.data_ptr()
for L in range(spec.layer_num):
for s in range(0, max_slots, max(1, max_slots // 4)):
# Envelope: bytes for slot s, layer L's K start at:
# s * entry_bytes + L * (k_row + v_row)
expected_k_byte_offset = s * spec.entry_bytes() + L * (
spec.k_row_bytes() + spec.v_row_bytes()
)
# 4-D view: k_views[L][page=s, tok=0, head=0, dim=0]
# storage_offset of the element [s, 0, 0, 0]:
view_offset_elems = (
k_views[L].storage_offset()
+ s * k_views[L].stride(0)
+ 0 * k_views[L].stride(1)
+ 0 * k_views[L].stride(2)
+ 0 * k_views[L].stride(3)
)
view_byte_offset = view_offset_elems * itemsize
# 4-D view sits over `_raw.view(spec.store_dtype)`, which
# has data_ptr == _raw.data_ptr() (same backing storage).
self.assertEqual(view_byte_offset, expected_k_byte_offset)
if __name__ == "__main__":
unittest.main()
@@ -438,8 +438,7 @@ class TestMultiEndedAllocator(unittest.TestCase):
def test_translate_kv_loc_dtype_assertion(self):
"""REGRESSION: wrong-dtype `out=` (int32 instead of int64) raises
AssertionError. Guards against the copy/paste hazard where someone
might allocate the full-physical buffer with the SWA int32 pattern."""
AssertionError -- `out=` must match the v2p dtype the gather writes."""
_, full_alloc, _, full_kv, _ = self._build_pair()
v = self._alloc(full_alloc, full_kv, 5)
wrong_dtype = torch.empty(v.shape, dtype=torch.int32, device=_DEV)
@@ -911,28 +910,28 @@ class TestUnifiedSWATokenToKVPoolAllocator(unittest.TestCase):
def test_swa_translate_loc_from_full_to_swa_with_out_writes_inplace(self):
"""REGRESSION: `translate_loc_from_full_to_swa(v, out=buf)`
must modify `buf` in place AND preserve `buf.data_ptr()`. `out=`
buffer MUST be int32 (matches SWA Triton kernel contract)."""
buffer is int64 — every id the allocator emits is."""
_, allocator, _ = self._build()
v = allocator.alloc(4)
self.assertIsNotNone(v)
buf = torch.empty(v.shape, dtype=torch.int32, device=_DEV)
buf = torch.empty(v.shape, dtype=torch.int64, device=_DEV)
ptr_before = buf.data_ptr()
ret = allocator.translate_loc_from_full_to_swa(v, out=buf)
self.assertIs(ret, buf)
self.assertEqual(buf.data_ptr(), ptr_before)
# Byte-identical to the no-out form:
no_out = allocator.translate_loc_from_full_to_swa(v)
self.assertEqual(no_out.dtype, torch.int32)
self.assertEqual(no_out.dtype, torch.int64)
self.assertTrue(bool((buf == no_out).all().item()))
def test_swa_translate_loc_from_full_to_swa_dtype_assertion(self):
"""REGRESSION: wrong-dtype `out=` (int64 instead of int32)
raises AssertionError. Guards against accidentally reusing the int64
full-physical buffer pattern for the SWA precompute."""
"""REGRESSION: wrong-dtype `out=` (int32 instead of int64) raises
AssertionError. Guards against reintroducing a narrowed SWA write loc: the
allocator emits int64 and consumers narrow at their own buffer."""
_, allocator, _ = self._build()
v = allocator.alloc(4)
self.assertIsNotNone(v)
wrong_dtype = torch.empty(v.shape, dtype=torch.int64, device=_DEV)
wrong_dtype = torch.empty(v.shape, dtype=torch.int32, device=_DEV)
with self.assertRaises(AssertionError):
allocator.translate_loc_from_full_to_swa(v, out=wrong_dtype)
@@ -947,21 +946,48 @@ class TestUnifiedSWATokenToKVPoolAllocator(unittest.TestCase):
# Inject a tombstone on the swa side at one of the live virtual ids.
v_tomb = int(v[1].item())
allocator.swa_attn_allocator.virtual_to_physical[v_tomb] = -1
# No-out form: result must be int32 AND every entry >= 0.
# No-out form: result must be int64 AND every entry >= 0.
out = allocator.translate_loc_from_full_to_swa(v)
self.assertEqual(out.dtype, torch.int32)
self.assertEqual(out.dtype, torch.int64)
self.assertTrue(
bool((out >= 0).all().item()),
"translate_loc_from_full_to_swa must clamp tombstoned to >=0",
)
self.assertEqual(int(out[1].item()), 0)
# out= form (int32 buffer) must also clamp.
buf = torch.empty(v.shape, dtype=torch.int32, device=_DEV)
# out= form must also clamp.
buf = torch.empty(v.shape, dtype=torch.int64, device=_DEV)
ret = allocator.translate_loc_from_full_to_swa(v, out=buf)
self.assertIs(ret, buf)
self.assertTrue(bool((buf >= 0).all().item()))
self.assertEqual(int(buf[1].item()), 0)
def test_swa_slot_zero_sink_invariant_survives_churn(self):
"""PINNED INVARIANT (swa side of the physical-loc contract): BOTH maps
send virtual 0 to physical 0 — `translate_kv_loc(zeros) == zeros` AND
`translate_loc_from_full_to_swa(zeros) == zeros` — after init and
after alloc/free/free_swa churn. Cuda-graph capture replaced the
capture-time translate with zero-fill/copy of the zero-filled static
buffers; that is only equivalent while slot 0 stays the sink in both
sub-pools."""
_, allocator, kvcache = self._build()
zeros64 = torch.zeros(4, dtype=torch.int64)
def check():
self.assertTrue(torch.equal(allocator.translate_kv_loc(zeros64), zeros64))
self.assertTrue(
torch.equal(allocator.translate_loc_from_full_to_swa(zeros64), zeros64)
)
check()
a = self._alloc(allocator, kvcache, 5)
b = self._alloc(allocator, kvcache, 5)
allocator.free_swa(a) # tombstone swa side only
self._free(allocator, kvcache, b) # full free (compaction on both)
self._free(allocator, kvcache, a)
c = self._alloc(allocator, kvcache, 3)
self._free(allocator, kvcache, c)
check()
# ---------------------------------------------------------------------------
# page_size > 1 — paged unit tests
@@ -1912,13 +1938,17 @@ class TestPagedMultiEndedAllocator(unittest.TestCase):
"v2p_page[virt_pages] * page_size + offsets.",
)
# And the composite allocator's translate method must produce the
# same token-granular result (same page math).
# The composite emits KERNEL-FACING ids, not the physical token ids
# this helper returns; they coincide only at multiplier 1, which no
# sub-pool uses.
swa_mult = allocator.swa_kernel_page_multiplier
self.assertEqual(swa_mult, 2 * swa_spec.layer_num)
composite_out = allocator.translate_loc_from_full_to_swa(v_tokens)
expected_dense = swa_phys_pages_direct * (PS * swa_mult) + offsets_in
self.assertTrue(
bool((swa_phys.long() == composite_out.long()).all().item()),
"REGRESSION: the UnifiedSWAKVPool helper and the composite "
"allocator's translate_loc_from_full_to_swa must agree.",
bool((composite_out.long() == expected_dense.long()).all().item()),
"REGRESSION: translate_loc_from_full_to_swa must emit the swa "
"sub-pool's kernel-facing ids (phys_page * ps * blocks_per_page + offset).",
)
@@ -2565,5 +2595,131 @@ class TestO3FusedAllocBind(unittest.TestCase):
self.assertEqual(int(sa.physical_to_virtual[p].item()), v)
class TestSWACompositeDenseSurface(unittest.TestCase):
"""The SWA composite's dense (kernel-facing) id surface.
Presence of `translate_kv_loc_for_kernel` / `full_v2p_page_table` is what flips
the attention backends' kernel-facing-first probes, and the `page_stride` scale in
`translate_loc_from_full_to_swa` is what carries the swa kernel-facing space.
Everything must collapse
byte-identically at multiplier 1 — the strided arm every existing SWA model
runs — and follow `kernel_id(t) = v2p[t//ps]*(ps*mult) + t%ps` otherwise.
"""
PS = 4
FULL_L = 4
SWA_L = 2
def _build(self):
full_spec = MHASubPoolSpec(
name="full",
layer_num=self.FULL_L,
head_num=2,
head_dim=4,
store_dtype=torch.float16,
grow_direction="up",
)
swa_spec = MHASubPoolSpec(
name="swa",
layer_num=self.SWA_L,
head_num=2,
head_dim=4,
store_dtype=torch.float16,
grow_direction="down",
)
n_full, n_swa = 64, 32 # tokens = 16 / 8 pages at PS=4
total = n_full * full_spec.entry_bytes() + n_swa * swa_spec.entry_bytes()
pool = UnifiedKVPool(
total_bytes=total,
sub_pool_specs=[full_spec, swa_spec],
device=_DEV,
enable_memory_saver=False,
page_size=self.PS,
)
kvcache = _FakeUnifiedSWAKVPool(pool)
return UnifiedSWATokenToKVPoolAllocator(
unified_buffer=pool,
kvcache=kvcache,
device=_DEV,
full_max_total_num_tokens=n_full,
swa_max_total_num_tokens=n_swa,
page_size=self.PS,
need_sort=False,
forward_stream=None,
)
def test_multipliers_come_from_the_specs(self):
"""Both sides scale by their OWN sub-pool's block count, and the
composite exposes the raw v2p tables unwrapped. Nothing injects the
scale: a spec whose views are dense cannot be paired with a
physical-id multiplier, which is the state that writes physical ids
into view rows."""
a = self._build()
self.assertEqual(a.kernel_page_multiplier, 2 * self.FULL_L)
self.assertEqual(a.swa_kernel_page_multiplier, 2 * self.SWA_L)
self.assertIs(a.full_v2p_page_table, a.full_attn_allocator.virtual_to_physical)
self.assertIs(a.swa_v2p_page_table, a.swa_attn_allocator.virtual_to_physical)
def test_full_dense_translate_matches_formula(self):
mult = 2 * self.FULL_L
a = self._build()
v = a.alloc(3 * self.PS)
self.assertIsNotNone(v)
v2p = a.full_attn_allocator.virtual_to_physical
expected = v2p[v // self.PS] * (self.PS * mult) + v % self.PS
self.assertTrue(torch.equal(a.translate_kv_loc_for_kernel(v), expected))
# The PHYSICAL translate must stay unscaled — compaction and the byte
# machinery depend on it staying in physical space.
phys = v2p[v // self.PS] * self.PS + v % self.PS
self.assertTrue(torch.equal(a.translate_kv_loc(v), phys))
def test_dense_translate_accepts_an_int32_page_table(self):
"""REGRESSION: fa3 translates its own page table, which is int32 and
2-D. A gather that requires an int64 index (`torch.take`) crashes the
scheduler there while every int64 caller stays green. Both page sizes:
at ps == 1 the index IS the caller's tensor, at ps > 1 it is derived."""
for ps in (1, 4):
with self.subTest(page_size=ps):
self.PS = ps
mult = 2 * self.FULL_L
a = self._build()
v = a.alloc(4 * ps)
self.assertIsNotNone(v)
v2p = a.full_attn_allocator.virtual_to_physical
expected = v2p[v // ps] * (ps * mult) + v % ps
page_table = v.to(torch.int32).view(2, -1)
got = a.translate_kv_loc_for_kernel(page_table)
self.assertEqual(got.shape, page_table.shape)
self.assertTrue(torch.equal(got.reshape(-1), expected))
# `out=` takes the same int32 index; the buffer stays int64.
dst = torch.empty(page_table.shape, dtype=torch.int64, device=_DEV)
a.translate_kv_loc_for_kernel(page_table, out=dst)
self.assertTrue(torch.equal(dst.reshape(-1), expected))
def test_swa_translate_scales_page_stride(self):
mult = 2 * self.SWA_L
a = self._build()
v = a.alloc(3 * self.PS)
self.assertIsNotNone(v)
v2p_swa = a.swa_attn_allocator.virtual_to_physical
expected = v2p_swa[v // self.PS] * (self.PS * mult) + v % self.PS
self.assertTrue(torch.equal(a.translate_loc_from_full_to_swa(v), expected))
def test_swa_dense_tombstone_still_lands_on_sink(self):
"""The scaled stride must not break the tombstone clamp: a tombstoned
page's ids (v2p == -1 -> -stride + offset, negative for every in-page
offset) still land on the sink, never negative."""
mult = 2 * self.SWA_L
a = self._build()
v = a.alloc(2 * self.PS)
self.assertIsNotNone(v)
tomb_page = int(v[0].item()) // self.PS
a.swa_attn_allocator.virtual_to_physical[tomb_page] = -1
got = a.translate_loc_from_full_to_swa(v)
self.assertTrue(bool((got >= 0).all().item()))
in_tomb = v // self.PS == tomb_page
self.assertTrue(bool((got[in_tomb] == 0).all().item()))
if __name__ == "__main__":
unittest.main()
@@ -1,12 +1,9 @@
"""CPU correctness tests for the page-major layer-major envelope layout.
"""CPU correctness tests for the page-major envelope Mamba state views.
Covers the standalone view builders (no allocator / shared pool):
- ``build_page_major_mha_views``: 4-D K/V views with correct addressing at
page_size 1 (token-granularity envelope) and > 1 (layer-major within a page),
and no aliasing across layers / slots.
- ``build_page_major_mamba_views``: conv / temporal state views.
- ``move_kv_cache_native`` 4-D branch: relocating token rows preserves data.
Covers the standalone ``build_page_major_mamba_views`` builder (no allocator /
shared pool): conv / temporal state views with correct shapes and no aliasing
across layers / slots. The unified pool stores its Mamba/KDA state through
these views.
Runs on CPU — pure-torch advanced indexing, no Triton.
@@ -23,107 +20,10 @@ import torch
from sglang.srt.mem_cache.layout.page_major import (
build_page_major_mamba_views,
build_page_major_mha_views,
mamba_entry_bytes,
mha_entry_bytes,
)
from sglang.srt.mem_cache.memory_pool import move_kv_cache_native
_DEV = "cpu"
_DT = torch.float32
def _make_mha_views(layer_num, head_num, head_dim, v_head_dim, page_size, num_pages):
entry = mha_entry_bytes(
layer_num=layer_num,
head_num=head_num,
head_dim=head_dim,
v_head_dim=v_head_dim,
itemsize=_DT.itemsize,
)
raw = torch.zeros(num_pages * page_size * entry, dtype=torch.uint8, device=_DEV)
k, v = build_page_major_mha_views(
raw,
layer_num=layer_num,
head_num=head_num,
head_dim=head_dim,
v_head_dim=v_head_dim,
store_dtype=_DT,
page_size=page_size,
num_pages=num_pages,
)
return raw, k, v
class TestPageMajorMHAViews(unittest.TestCase):
def test_view_shapes(self):
_, k, v = _make_mha_views(3, 2, 4, 4, page_size=2, num_pages=4)
self.assertEqual(len(k), 3)
for t in k:
self.assertEqual(tuple(t.shape), (4, 2, 2, 4))
for t in v:
self.assertEqual(tuple(t.shape), (4, 2, 2, 4))
def test_no_aliasing_ps1(self):
# Every (layer, slot) cell must be independently addressable.
layer_num, slots = 3, 5
_, k, v = _make_mha_views(layer_num, 2, 4, 4, page_size=1, num_pages=slots)
for L in range(layer_num):
for s in range(slots):
k[L][s, 0] = float(100 + L * 10 + s)
v[L][s, 0] = float(200 + L * 10 + s)
for L in range(layer_num):
for s in range(slots):
self.assertTrue(torch.all(k[L][s, 0] == float(100 + L * 10 + s)))
self.assertTrue(torch.all(v[L][s, 0] == float(200 + L * 10 + s)))
def test_page_slot_addressing_ps_gt1(self):
# token id t -> page t // ps, slot t % ps; no aliasing across tokens.
ps, pages = 2, 4
total = ps * pages
_, k, _ = _make_mha_views(2, 1, 2, 2, page_size=ps, num_pages=pages)
for L in range(2):
for t in range(total):
k[L][t // ps, t % ps, 0] = float(1000 + L * 100 + t)
for L in range(2):
for t in range(total):
self.assertEqual(
float(k[L][t // ps, t % ps, 0, 0].item()), 1000 + L * 100 + t
)
def test_asymmetric_v_head_dim(self):
_, k, v = _make_mha_views(2, 2, 6, 4, page_size=1, num_pages=3)
self.assertEqual(tuple(k[0].shape), (3, 1, 2, 6))
self.assertEqual(tuple(v[0].shape), (3, 1, 2, 4))
class TestPageMajorMove(unittest.TestCase):
def test_move_ps1(self):
slots = 6
_, k, v = _make_mha_views(2, 1, 4, 4, page_size=1, num_pages=slots)
for L in range(2):
for s in range(slots):
k[L][s, 0] = float(s + 1)
v[L][s, 0] = float(-(s + 1))
tgt = torch.tensor([0, 1], dtype=torch.int64)
src = torch.tensor([4, 5], dtype=torch.int64)
move_kv_cache_native(k, v, tgt, src, page_size=1)
for L in range(2):
self.assertTrue(torch.all(k[L][0, 0] == 5.0))
self.assertTrue(torch.all(k[L][1, 0] == 6.0))
self.assertTrue(torch.all(v[L][0, 0] == -5.0))
def test_move_ps_gt1(self):
ps, pages = 2, 4
total = ps * pages
_, k, v = _make_mha_views(1, 1, 2, 2, page_size=ps, num_pages=pages)
for t in range(total):
k[0][t // ps, t % ps, 0] = float(t + 1)
tgt = torch.tensor([0, 3], dtype=torch.int64) # page0 slot0, page1 slot1
src = torch.tensor([6, 7], dtype=torch.int64) # page3 slot0, page3 slot1
move_kv_cache_native(k, v, tgt, src, page_size=ps)
self.assertEqual(float(k[0][0, 0, 0, 0].item()), 7.0)
self.assertEqual(float(k[0][1, 1, 0, 0].item()), 8.0)
class TestMambaEnvelopeViews(unittest.TestCase):
@@ -18,7 +18,7 @@ import unittest
import torch
from sglang.srt.mem_cache.layout.page_major import (
build_dense_mla_views,
build_mla_views,
build_page_major_mamba_views,
mamba_entry_bytes,
mla_entry_bytes,
@@ -31,7 +31,7 @@ register_cpu_ci(est_time=60, suite="base-a-test-cpu")
class TestMLAEnvelopeTransferAddressing(CustomTestCase):
def test_page_envelope_matches_dense_views(self):
"""Every (page, layer, slot) row written through the dense MLA views
"""Every (page, layer, slot) row written through the MLA views
must land at raw_ptr + page * page_envelope_bytes + layer-block offset,
i.e. inside the page's transfer envelope."""
layer_num, page_size, kv_dim, num_pages = 3, 4, 8, 6
@@ -49,7 +49,7 @@ class TestMLAEnvelopeTransferAddressing(CustomTestCase):
)
# +1 page envelope of tail pad, as UnifiedKVPool allocates for MLA.
raw = torch.zeros((num_pages + 1) * page_bytes, dtype=torch.uint8)
views = build_dense_mla_views(
views = build_mla_views(
raw,
layer_num=layer_num,
kv_cache_dim=kv_dim,
@@ -1,412 +0,0 @@
"""Parity tests for the `store_cache_4d` Triton kernel.
The kernel writes K/V into the 4-D page-major envelope view. These tests prove
it produces byte-identical output to the legacy advanced-indexing path on
representative fixtures:
- ``page_size = 1`` (envelope-degenerate, the critical compatibility case)
- ``page_size > 1`` (layer-major within page)
- both int32 and int64 ``loc`` dtypes
- bf16 and fp8_e5m2 view dtypes
- asymmetric ``head_dim != v_head_dim``
- empty ``loc`` (no-op)
Skipped on CPU — Triton requires a GPU.
python -m pytest test/registered/unit/mem_cache/test_store_cache_4d.py -v
"""
import importlib.util
import unittest
import torch
from sglang.test.ci.ci_register import register_cuda_ci
_HAS_CUDA = torch.cuda.is_available()
# The set_kv_buffer integration test needs UnifiedMHATokenToKVPool, which only
# exists once the shared-KV-pool feature lands; skip it where absent.
_HAS_SHARED_POOL = (
importlib.util.find_spec("sglang.srt.mem_cache.unified_memory_pool") is not None
)
register_cuda_ci(est_time=30, stage="base-b", runner_config="1-gpu-small")
def _legacy_advanced_indexing_write(
k_view: torch.Tensor,
v_view: torch.Tensor,
cache_k: torch.Tensor,
cache_v: torch.Tensor,
loc: torch.Tensor,
page_size: int,
) -> None:
"""Reference implementation: the legacy bypass-super() advanced-indexing
path that the Triton kernel replaces. Used as the byte-identity oracle
for the parity tests below.
"""
if page_size == 1:
k_view[loc, 0] = cache_k
v_view[loc, 0] = cache_v
else:
page_id = loc // page_size
tok_in_p = loc % page_size
k_view[page_id, tok_in_p] = cache_k
v_view[page_id, tok_in_p] = cache_v
@unittest.skipUnless(_HAS_CUDA, "Triton kernels require CUDA")
class TestStoreCache4D(unittest.TestCase):
"""Byte-identity parity vs the legacy advanced-indexing write path."""
def _make_view_and_cache(
self,
num_pages: int,
page_size: int,
head_num: int,
head_dim: int,
v_head_dim: int,
N: int,
dtype: torch.dtype = torch.bfloat16,
loc_dtype: torch.dtype = torch.int64,
seed: int = 0xC0FFEE,
):
torch.manual_seed(seed)
# The unified memory pool's views are 4-D `(num_pages, page_size, head_num,
# head_dim)` with the trailing two dims contiguous. We allocate two
# independent contiguous buffers (one for the kernel-under-test,
# one as the legacy-path target) so we can compare them.
k_view = torch.zeros(
(num_pages, page_size, head_num, head_dim),
dtype=dtype,
device="cuda",
)
v_view = torch.zeros(
(num_pages, page_size, head_num, v_head_dim),
dtype=dtype,
device="cuda",
)
cache_k = torch.randn(
(N, head_num, head_dim), dtype=torch.float32, device="cuda"
).to(dtype)
cache_v = torch.randn(
(N, head_num, v_head_dim), dtype=torch.float32, device="cuda"
).to(dtype)
# Valid loc values in [0, num_pages * page_size); generate without
# duplicates so the comparison is unambiguous (advanced-indexing
# with duplicates is order-undefined for both paths).
total_slots = num_pages * page_size
assert N <= total_slots
loc = torch.randperm(total_slots, device="cuda")[:N].to(loc_dtype)
return k_view, v_view, cache_k, cache_v, loc
def _check_parity(
self,
num_pages: int,
page_size: int,
head_num: int,
head_dim: int,
v_head_dim: int,
N: int,
dtype: torch.dtype = torch.bfloat16,
loc_dtype: torch.dtype = torch.int64,
):
from sglang.kernels.ops.kvcache.cache_move import store_cache_4d
# Two independent target buffers — one for the kernel, one for the
# legacy reference path.
k_kernel, v_kernel, cache_k, cache_v, loc = self._make_view_and_cache(
num_pages,
page_size,
head_num,
head_dim,
v_head_dim,
N,
dtype=dtype,
loc_dtype=loc_dtype,
)
k_legacy = k_kernel.clone()
v_legacy = v_kernel.clone()
# Kernel-under-test
store_cache_4d(k_kernel, v_kernel, cache_k, cache_v, loc, page_size)
# Legacy reference
_legacy_advanced_indexing_write(
k_legacy, v_legacy, cache_k, cache_v, loc, page_size
)
# Byte-identical comparison — the kernel must reproduce the
# advanced-indexing path bit-for-bit, NOT just numerically close.
# For fp8 dtypes, torch.equal works on the integer bit pattern.
self.assertTrue(
torch.equal(k_kernel, k_legacy),
f"K view mismatch: ps={page_size}, dtype={dtype}, "
f"loc_dtype={loc_dtype}, N={N}",
)
self.assertTrue(
torch.equal(v_kernel, v_legacy),
f"V view mismatch: ps={page_size}, dtype={dtype}, "
f"loc_dtype={loc_dtype}, N={N}",
)
# ---- Test 1: ps=1 envelope-degenerate (the critical compat case) ----
def test_store_cache_4d_ps1_byte_identical(self):
"""At page_size=1 the kernel constexpr-folds to the slot-major
envelope view. Output must be byte-identical to advanced indexing.
This protects against byte-layout regression."""
self._check_parity(
num_pages=64,
page_size=1,
head_num=4,
head_dim=128,
v_head_dim=128,
N=16,
)
# ---- Test 2: ps>1 layer-major within page ----
def test_store_cache_4d_ps_gt1_byte_identical(self):
"""At page_size > 1 the kernel splits loc into (page_id, tok_in_p)
and writes via the 4-D stride. Output must match the equivalent
advanced-indexing write."""
self._check_parity(
num_pages=8,
page_size=64,
head_num=4,
head_dim=128,
v_head_dim=128,
N=128,
)
# ---- Test 3: int32 loc dtype ----
def test_store_cache_4d_int32_loc(self):
"""The SWA-side path passes int32 loc (matches the SWA Triton
kernel contract). PyTorch advanced indexing tolerates either
int32 or int64; the kernel must too."""
self._check_parity(
num_pages=32,
page_size=1,
head_num=4,
head_dim=64,
v_head_dim=64,
N=10,
loc_dtype=torch.int32,
)
# ---- Test 4: int64 loc dtype (already exercised, explicit) ----
# ---- Test 5: bf16 dtype (the production case) ----
# ---- Test 6: fp8_e5m2 dtype ----
def test_store_cache_4d_dtype_fp8_e5m2(self):
"""fp8_e5m2 is used for KV-cache quantization. Caller is responsible
for the cast; the kernel sees same-dtype source and destination."""
self._check_parity(
num_pages=16,
page_size=64,
head_num=4,
head_dim=128,
v_head_dim=128,
N=64,
dtype=torch.float8_e5m2,
)
# ---- Test 7: empty loc (no-op) ----
def test_store_cache_4d_empty_loc(self):
"""N=0 must be a no-op: no kernel launch, no exception, no buffer
mutation."""
from sglang.kernels.ops.kvcache.cache_move import store_cache_4d
k_view = torch.zeros((8, 4, 4, 64), dtype=torch.bfloat16, device="cuda")
v_view = torch.zeros((8, 4, 4, 64), dtype=torch.bfloat16, device="cuda")
k_before = k_view.clone()
v_before = v_view.clone()
cache_k = torch.empty((0, 4, 64), dtype=torch.bfloat16, device="cuda")
cache_v = torch.empty((0, 4, 64), dtype=torch.bfloat16, device="cuda")
loc = torch.empty((0,), dtype=torch.int64, device="cuda")
store_cache_4d(k_view, v_view, cache_k, cache_v, loc, page_size=4)
# Buffers must be unchanged.
self.assertTrue(torch.equal(k_view, k_before))
self.assertTrue(torch.equal(v_view, v_before))
# ---- Test 8: head_dim != v_head_dim (asymmetric, e.g. MLA-style) ----
def test_store_cache_4d_v_head_dim_differs(self):
"""When v_head_dim != head_dim, the kernel's K and V branches use
different per-token strides. Exercises the stride_k_tok ≠
stride_v_tok branch."""
self._check_parity(
num_pages=8,
page_size=16,
head_num=2,
head_dim=128,
v_head_dim=64,
N=16,
)
@unittest.skipUnless(_HAS_CUDA, "Triton kernels require CUDA")
class TestStoreCache4DAssertions(unittest.TestCase):
"""The wrapper's contract assertions must fire on bad inputs."""
def test_rejects_non_contiguous_view_trailing_dim(self):
"""Wrapper requires `stride[-1] == 1` and `stride[-2] == head_dim`
(the trailing two dims must be contiguous). A permutation that
breaks this should trigger AssertionError."""
from sglang.kernels.ops.kvcache.cache_move import store_cache_4d
# Build a 4-D view, then permute the last two dims → trailing
# contiguity violated.
k_view = torch.zeros(
(4, 4, 4, 64), dtype=torch.bfloat16, device="cuda"
).permute(
0, 1, 3, 2
) # now shape (4, 4, 64, 4); strides broken
v_view = torch.zeros((4, 4, 4, 64), dtype=torch.bfloat16, device="cuda")
cache_k = torch.zeros((2, 4, 64), dtype=torch.bfloat16, device="cuda")
cache_v = torch.zeros((2, 4, 64), dtype=torch.bfloat16, device="cuda")
loc = torch.arange(2, dtype=torch.int64, device="cuda")
with self.assertRaises(AssertionError):
store_cache_4d(k_view, v_view, cache_k, cache_v, loc, page_size=4)
def test_rejects_dtype_mismatch(self):
"""All four tensors must share a dtype; the caller is responsible
for any cast before the call."""
from sglang.kernels.ops.kvcache.cache_move import store_cache_4d
k_view = torch.zeros((4, 4, 4, 64), dtype=torch.bfloat16, device="cuda")
v_view = torch.zeros((4, 4, 4, 64), dtype=torch.bfloat16, device="cuda")
cache_k = torch.zeros((2, 4, 64), dtype=torch.float16, device="cuda")
cache_v = torch.zeros((2, 4, 64), dtype=torch.bfloat16, device="cuda")
loc = torch.arange(2, dtype=torch.int64, device="cuda")
with self.assertRaises(AssertionError):
store_cache_4d(k_view, v_view, cache_k, cache_v, loc, page_size=4)
@unittest.skipUnless(
_HAS_CUDA and _HAS_SHARED_POOL,
"Triton kernels require CUDA; UnifiedMHATokenToKVPool required",
)
class TestStoreCache4DThroughSetKVBuffer(unittest.TestCase):
"""Integration parity test — exercises the kernel through the FULL
``UnifiedMHATokenToKVPool.set_kv_buffer`` path (the direct PHYSICAL write +
the dtype cast; the pool no longer translates). Confirms it produces
bit-identical output to a PyTorch advanced-indexing reference write.
"""
def _build_pool(self, page_size: int):
"""Build a small UnifiedMHATokenToKVPool. The pool writes PHYSICAL locs
directly (no allocator / v2p translate), so `set_kv_buffer` receives the
already-physical write location."""
import torch as _t
from sglang.srt.mem_cache.unified_memory_pool import (
MHASubPoolSpec,
UnifiedKVPool,
UnifiedMHATokenToKVPool,
)
spec = MHASubPoolSpec(
name="full",
layer_num=2,
head_num=4,
head_dim=64,
store_dtype=_t.bfloat16,
grow_direction="up",
)
total = spec.entry_bytes() * 64
# Use a peer to satisfy the two-sub-pool contract.
peer = MHASubPoolSpec(
name="swa",
layer_num=1,
head_num=4,
head_dim=64,
store_dtype=_t.bfloat16,
grow_direction="down",
)
pool = UnifiedKVPool(
total_bytes=total + peer.entry_bytes() * 16,
sub_pool_specs=[spec, peer],
device="cuda",
enable_memory_saver=False,
page_size=page_size,
)
kv_pool = UnifiedMHATokenToKVPool(
unified_buffer=pool,
sub_pool_name="full",
page_size=page_size,
start_layer=0,
end_layer=2,
enable_alt_stream=False,
)
return kv_pool
def _run_set_kv_buffer_and_compare(self, page_size: int):
import torch as _t
kv_pool = self._build_pool(page_size)
# A fake `layer` object with the minimum interface
# `set_kv_buffer` reads: `.layer_id`.
class _FakeLayer:
layer_id = 0
layer = _FakeLayer()
head_num, head_dim = 4, 64
N = 16
# Generate valid loc in range [0, num_pages * page_size).
num_pages = kv_pool.k_buffer[0].shape[0]
total = num_pages * page_size
assert N <= total
loc = _t.randperm(total, device="cuda")[:N].to(_t.int64)
cache_k = _t.randn((N, head_num, head_dim), dtype=_t.bfloat16, device="cuda")
cache_v = _t.randn((N, head_num, head_dim), dtype=_t.bfloat16, device="cuda")
# Production path: the Triton `store_cache_4d` kernel via set_kv_buffer.
kv_pool.set_kv_buffer(layer, loc, cache_k.clone(), cache_v.clone())
k_kernel = kv_pool.k_buffer[0].clone()
v_kernel = kv_pool.v_buffer[0].clone()
# Reference: PyTorch advanced-indexing into a fresh view at the same
# (physical) loc, with no dtype cast (store_dtype == dtype) — the exact
# write the kernel performs.
kv_pool.k_buffer[0].zero_()
kv_pool.v_buffer[0].zero_()
k_view = kv_pool.k_buffer[0]
v_view = kv_pool.v_buffer[0]
if page_size == 1:
k_view[loc, 0] = cache_k
v_view[loc, 0] = cache_v
else:
page_id = loc // page_size
tok_in_p = loc % page_size
k_view[page_id, tok_in_p] = cache_k
v_view[page_id, tok_in_p] = cache_v
k_ref = kv_pool.k_buffer[0].clone()
v_ref = kv_pool.v_buffer[0].clone()
self.assertTrue(
_t.equal(k_kernel, k_ref),
f"K view mismatch through set_kv_buffer at ps={page_size}",
)
self.assertTrue(
_t.equal(v_kernel, v_ref),
f"V view mismatch through set_kv_buffer at ps={page_size}",
)
def test_integration_ps1(self):
self._run_set_kv_buffer_and_compare(page_size=1)
def test_integration_ps64(self):
self._run_set_kv_buffer_and_compare(page_size=64)
if __name__ == "__main__":
unittest.main()
@@ -1,190 +0,0 @@
"""Triton-kernel parity test for the page-aware decode / extend kernels.
Verifies that the modified decode / extend Triton kernels produce
bit-identical output when called against:
(a) the legacy 3-D ``[N, head, dim]`` KV view (PAGE_SIZE=1 default),
(b) the new 4-D ``[num_pages, page_size, head, dim]`` view with
``page_size=1`` (degenerate envelope — same physical bytes as (a)),
(c) the new 4-D view with ``page_size>1`` (layer-major), using the
same logical KV data but routed via page-aware address math.
Output for (a) vs (b) must be bit-identical at PAGE_SIZE=1 (the kernel
specializes to the legacy branch). Output for (c) must match a hand-
computed reference SDPA result (same logical attention; different byte
layout).
Skipped on CPU — Triton requires a GPU.
python -m pytest test/registered/unit/mem_cache/test_triton_kernel_layout.py -v
"""
import unittest
import torch
from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci
_HAS_CUDA = torch.cuda.is_available()
register_cuda_ci(est_time=30, stage="base-b", runner_config="1-gpu-small")
register_amd_ci(est_time=5, stage="stage-b", runner_config="1-gpu-small-amd")
@unittest.skipUnless(_HAS_CUDA, "Triton kernels require CUDA")
class TestTritonKernelLayoutParity(unittest.TestCase):
"""Decode + extend kernel parity across (3-D, 4-D ps=1, 4-D ps>1)."""
def _setup_decode_inputs(
self, bs=2, head_num=2, head_dim=8, num_slots=64, dtype=torch.float16
):
torch.manual_seed(0xC0FFEE)
# Logical KV: shape [num_slots, head_num, head_dim]
logical_kv_k = torch.randn(
num_slots, head_num, head_dim, dtype=dtype, device="cuda"
)
logical_kv_v = torch.randn(
num_slots, head_num, head_dim, dtype=dtype, device="cuda"
)
q = torch.randn(bs, head_num, head_dim, dtype=dtype, device="cuda")
# All requests use the first `seq_len` slots.
seq_len = 16
kv_indices_per_req = torch.arange(seq_len, dtype=torch.int64, device="cuda")
kv_indices = kv_indices_per_req.repeat(bs) # [bs * seq_len]
kv_indptr = torch.tensor(
[i * seq_len for i in range(bs + 1)], dtype=torch.int32, device="cuda"
)
return q, logical_kv_k, logical_kv_v, kv_indptr, kv_indices, seq_len
def _run_decode(self, q, k_buf, v_buf, kv_indptr, kv_indices, page_size):
from sglang.kernels.ops.attention.decode_attention import (
decode_attention_fwd,
)
bs, head_num, head_dim = q.shape
max_kv_splits = 4
attn_logits = torch.empty(
(bs, head_num, max_kv_splits, head_dim),
dtype=torch.float32,
device="cuda",
)
attn_lse = torch.empty(
(bs, head_num, max_kv_splits),
dtype=torch.float32,
device="cuda",
)
o = torch.empty_like(q)
num_kv_splits = torch.full(
(bs,), max_kv_splits, dtype=torch.int32, device="cuda"
)
decode_attention_fwd(
q,
k_buf,
v_buf,
o,
kv_indptr,
kv_indices,
attn_logits,
attn_lse,
num_kv_splits,
max_kv_splits,
sm_scale=1.0 / (head_dim**0.5),
k_scale=1.0,
v_scale=1.0,
logit_cap=0.0,
page_size=page_size,
)
return o
def test_decode_3d_vs_4d_ps1_byte_identical(self):
"""(a) vs (b): same physical bytes, different view shape.
Triton specializes PAGE_SIZE=1 to the legacy branch; output must
be bit-identical (modulo non-deterministic FP add ordering, which
we sidestep here since the kernels use deterministic reductions
for fixed input + grid)."""
q, k, v, kv_indptr, kv_indices, seq_len = self._setup_decode_inputs()
# (a) legacy 3-D view
o_3d = self._run_decode(q, k, v, kv_indptr, kv_indices, page_size=1)
# (b) 4-D view: reshape SAME physical bytes to (num_pages=N, 1, head, dim)
num_slots = k.shape[0]
k_4d = k.view(num_slots, 1, *k.shape[1:])
v_4d = v.view(num_slots, 1, *v.shape[1:])
o_4d_ps1 = self._run_decode(q, k_4d, v_4d, kv_indptr, kv_indices, page_size=1)
# bit-identical (same byte layout, same PAGE_SIZE specialization)
self.assertTrue(torch.equal(o_3d, o_4d_ps1))
def test_extend_3d_vs_4d_ps1_byte_identical(self):
"""Same parity check for extend kernel."""
from sglang.kernels.ops.attention.extend_attention import (
extend_attention_fwd,
)
torch.manual_seed(0xDEADBEEF)
# head_dim must be >= 16: the extend kernel's QK^T tl.dot requires the
# contraction dim K (= head_dim) >= 16 on modern GPU archs (Hopper+).
head_num, head_dim = 2, 32
num_slots = 32
dtype = torch.float16
bs = 2
prefix_len = 8
extend_len = 4
k_buffer = torch.randn(
num_slots, head_num, head_dim, dtype=dtype, device="cuda"
)
v_buffer = torch.randn(
num_slots, head_num, head_dim, dtype=dtype, device="cuda"
)
q_extend = torch.randn(
bs * extend_len, head_num, head_dim, dtype=dtype, device="cuda"
)
k_extend = torch.randn(
bs * extend_len, head_num, head_dim, dtype=dtype, device="cuda"
)
v_extend = torch.randn(
bs * extend_len, head_num, head_dim, dtype=dtype, device="cuda"
)
o = torch.empty_like(q_extend)
qo_indptr = torch.tensor(
[i * extend_len for i in range(bs + 1)], dtype=torch.int32, device="cuda"
)
kv_indptr = torch.tensor(
[i * prefix_len for i in range(bs + 1)], dtype=torch.int32, device="cuda"
)
kv_indices = torch.arange(prefix_len, dtype=torch.int64, device="cuda").repeat(
bs
)
def run(k_buf, v_buf, page_size):
o_out = torch.empty_like(q_extend)
extend_attention_fwd(
q_extend,
k_extend,
v_extend,
o_out,
k_buf,
v_buf,
qo_indptr,
kv_indptr,
kv_indices,
custom_mask=None,
is_causal=True,
mask_indptr=None,
max_len_extend=extend_len,
k_scale=1.0,
v_scale=1.0,
sm_scale=1.0 / (head_dim**0.5),
page_size=page_size,
)
return o_out
o_3d = run(k_buffer, v_buffer, page_size=1)
k_4d = k_buffer.view(num_slots, 1, *k_buffer.shape[1:])
v_4d = v_buffer.view(num_slots, 1, *v_buffer.shape[1:])
o_4d_ps1 = run(k_4d, v_4d, page_size=1)
self.assertTrue(torch.equal(o_3d, o_4d_ps1))
if __name__ == "__main__":
unittest.main()
@@ -47,7 +47,6 @@ def _build(device, page_size=1, kernel_page_multiplier=None):
device=device,
enable_memory_saver=False,
page_size=page_size,
view_tail_pad_bytes=page_size * full_spec.entry_bytes(),
)
kvcache = UnifiedMLATokenToKVPool(
unified_buffer=buf,
@@ -0,0 +1,531 @@
# Copyright 2023-2026 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Dense MHA K/V views for the unified memory pool (uniform-row hybrid models).
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):
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
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
register_cpu_ci(est_time=8, suite="base-a-test-cpu")
import unittest
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.unified_memory_pool import (
MHASubPoolSpec,
UnifiedKVPool,
UnifiedMHATokenToKVPool,
)
_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.
_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.
_L = 2
_H = 2
_D = 4
_ROW = _H * _D # row elements
_DTYPE = torch.bfloat16
_ITEM = _DTYPE.itemsize
_BLOCKS = 2 * _L
def _mha_spec(head_dim=_D, v_head_dim=None, layer_num=_L, grow="down"):
return MHASubPoolSpec(
name="full",
layer_num=layer_num,
head_num=_H,
head_dim=head_dim,
v_head_dim=v_head_dim,
store_dtype=_DTYPE,
grow_direction=grow,
)
def _kernel_id(t, ps):
return (t // ps) * (ps * _BLOCKS) + t % ps
def _make_raw(ps, num_pages, pad_pages=1):
page_bytes = ps * _BLOCKS * _ROW * _ITEM
raw = torch.zeros(
(num_pages + pad_pages) * page_bytes, dtype=torch.uint8, device=_DEV
)
return raw
def _build_views(raw, ps, num_pages, head_dim=_D, v_head_dim=_D, layer_num=_L):
return build_mha_views(
raw,
layer_num=layer_num,
head_num=_H,
head_dim=head_dim,
v_head_dim=v_head_dim,
store_dtype=_DTYPE,
page_size=ps,
num_pages=num_pages,
)
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.
"""
k_row_bytes = _ROW * _ITEM
v_row_bytes = _ROW * _ITEM
page_bytes = page_size * _L * (k_row_bytes + v_row_bytes)
as_dtype_view = raw.view(_DTYPE)
k_stride = (page_bytes // _ITEM, k_row_bytes // _ITEM, _D, 1)
v_stride = (page_bytes // _ITEM, v_row_bytes // _ITEM, _D, 1)
shape = (num_pages, page_size, _H, _D)
k_views, v_views = [], []
for layer in range(_L):
k_base = anchor_bytes + layer * page_size * (k_row_bytes + v_row_bytes)
v_base = k_base + page_size * k_row_bytes
k_views.append(
torch.as_strided(
as_dtype_view,
size=shape,
stride=k_stride,
storage_offset=k_base // _ITEM,
)
)
v_views.append(
torch.as_strided(
as_dtype_view,
size=shape,
stride=v_stride,
storage_offset=v_base // _ITEM,
)
)
return k_views, v_views
class TestMHADenseSpecSurface(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."""
spec = _mha_spec()
raw = torch.zeros(1 << 16, dtype=torch.uint8)
with self.assertRaises(AssertionError):
build_mha_views(
raw,
layer_num=spec.layer_num,
head_num=spec.head_num,
head_dim=6,
v_head_dim=4,
store_dtype=spec.store_dtype,
page_size=1,
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 TestDenseMHAViews(unittest.TestCase):
def test_view_shapes_are_stock_mha(self):
ps, num_pages = 4, 6
k_views, v_views = _build_views(_make_raw(ps, num_pages), ps, num_pages)
n_rows = num_pages * _BLOCKS * ps
self.assertEqual(len(k_views), _L)
self.assertEqual(len(v_views), _L)
for v in (*k_views, *v_views):
# The stock MHATokenToKVPool per-layer signature: 3-D, packed rows.
self.assertEqual(tuple(v.shape), (n_rows, _H, _D))
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."""
for ps in (1, 4):
num_pages = 5
raw = _make_raw(ps, num_pages)
sk, sv = _reference_strided_views(raw, page_size=ps, num_pages=num_pages)
dk, dv = _build_views(raw, ps, num_pages)
probes = [(0, 0, 0), (1, 1, ps - 1), (4, 0, ps // 2), (3, 1, 0)]
# strided-write -> view-read
for p, l, s in probes:
t = p * ps + s
d = _kernel_id(t, ps)
sk[l][p, s] = float(p * 100 + l * 10 + s + 1)
sv[l][p, s] = float(p * 100 + l * 10 + s + 2)
self.assertTrue(
torch.all(dk[l][d] == float(p * 100 + l * 10 + s + 1)),
f"K (p={p}, l={l}, s={s}, ps={ps}) view readback off-formula",
)
self.assertTrue(
torch.all(dv[l][d] == float(p * 100 + l * 10 + s + 2)),
f"V (p={p}, l={l}, s={s}, ps={ps}) view readback off-formula",
)
# view-write -> strided-read
for p, l, s in probes:
t = p * ps + s
d = _kernel_id(t, ps)
dk[l][d] = float(p * 100 + l * 10 + s + 3)
dv[l][d] = float(p * 100 + l * 10 + s + 4)
self.assertTrue(
torch.all(sk[l][p, s] == float(p * 100 + l * 10 + s + 3))
)
self.assertTrue(
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."""
ps, num_pages = 4, 4
dk, dv = _build_views(_make_raw(ps, num_pages), ps, num_pages)
t = 2 * ps + 1 # page 2, slot 1
d = _kernel_id(t, ps)
for l in range(_L):
dk[l][d] = float(2 * l + 1)
dv[l][d] = float(2 * l + 2)
for l in range(_L):
self.assertTrue(torch.all(dk[l][d] == float(2 * l + 1)))
self.assertTrue(torch.all(dv[l][d] == float(2 * l + 2)))
def test_missing_tail_pad_fails_loud(self):
ps, num_pages = 2, 4
raw = _make_raw(ps, num_pages, pad_pages=0)
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 ----
_N_FULL = 32 # full-attn token slots per pool in the fixtures below
_N_SWA = 16
def _swa_spec(grow="up", head_dim=_D, v_head_dim=None):
return MHASubPoolSpec(
name="swa",
layer_num=_L,
head_num=_H,
head_dim=head_dim,
v_head_dim=v_head_dim,
store_dtype=_DTYPE,
grow_direction=grow,
)
def _make_pool(ps=1, full_spec=None, device=_DEV):
full = full_spec if full_spec is not None else _mha_spec()
swa = _swa_spec()
total = full.entry_bytes() * _N_FULL + swa.entry_bytes() * _N_SWA
return UnifiedKVPool(
total_bytes=total,
sub_pool_specs=[full, swa],
device=device,
enable_memory_saver=False,
page_size=ps,
)
class TestUnifiedKVPoolDenseViews(unittest.TestCase):
def test_every_mha_sub_pool_is_per_layer_contiguous(self):
"""The unified pool has ONE MHA layout: both sub-pools come back as
stock 3-D per-layer views, whatever their page size."""
for ps in (1, 4):
pool = _make_pool(ps=ps)
for name in ("full", "swa"):
k, v = pool.mha_views_for(name)
self.assertEqual(k[0].dim(), 3, f"{name} K at ps={ps}")
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)
def _make_pool_and_kv(ps, device=_DEV):
kv = _make_pool(ps=ps, device=device)
return kv, UnifiedMHATokenToKVPool(
unified_buffer=kv,
sub_pool_name="full",
page_size=ps,
enable_alt_stream=False,
)
class TestUnifiedMHATokenToKVPool(unittest.TestCase):
def test_size_is_view_row_bound(self):
"""`size` drives BOTH the python OOB check and the store kernel's
device-side size_limit; it must be the view row bound, not slot count."""
for ps in (1, 4):
unified_kv, pool_under_test = _make_pool_and_kv(ps)
n_rows = (unified_kv.max_slots("full") // ps) * _BLOCKS * ps
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."""
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.
sk, sv = _reference_strided_views(
kv._raw,
page_size=ps,
num_pages=kv.max_slots("full") // ps,
anchor_bytes=kv.anchor_bytes("full"),
)
probes = [(1, 0), (2, ps - 1), (5, ps // 2)]
for l in range(_L):
toks = torch.tensor(
[p * ps + s for (p, s) in probes], device=_STORE_DEV
)
kernel_locs = (toks // ps) * (ps * _BLOCKS) + toks % ps
k = torch.full(
(len(probes), _H, _D),
float(l + 1),
dtype=_DTYPE,
device=_STORE_DEV,
)
v = torch.full(
(len(probes), _H, _D),
float(l + 101),
dtype=_DTYPE,
device=_STORE_DEV,
)
pool.set_kv_buffer(_layer(l), kernel_locs, k, v)
for p, s in probes:
self.assertTrue(
torch.all(sk[l][p, s] == float(l + 1)),
f"K (l={l}, p={p}, s={s}, ps={ps}) not at the envelope cell",
)
self.assertTrue(
torch.all(sv[l][p, s] == float(l + 101)),
f"V (l={l}, p={p}, s={s}, ps={ps}) not at the envelope cell",
)
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."""
ps = 4
kv, pool = _make_pool_and_kv(ps)
live = kv._raw.numel() - kv.view_tail_pad_bytes
seed = (torch.arange(live, dtype=torch.float32) % 251).to(torch.uint8)
kv._raw[:live] = seed
page_bytes = ps * _mha_spec().entry_bytes()
src_pages, tgt_pages = torch.tensor([5, 6]), torch.tensor([2, 3])
offs = torch.arange(ps)
run = lambda p: (p[:, None] * ps + offs).reshape(-1)
pool.move_kv_cache(run(tgt_pages), run(src_pages))
want = seed.clone()
for sp, tp in zip(src_pages.tolist(), tgt_pages.tolist()):
want[tp * page_bytes : (tp + 1) * page_bytes] = seed[
sp * page_bytes : (sp + 1) * page_bytes
]
self.assertTrue(
torch.equal(kv._raw[:live], want),
"envelope move did not relocate exactly the named pages",
)
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."""
_, pool = _make_pool_and_kv(1)
with self.assertRaises(NotImplementedError):
pool.get_contiguous_buf_infos()
with self.assertRaises(NotImplementedError):
pool.get_cpu_copy(torch.tensor([1]))
with self.assertRaises(NotImplementedError):
pool.load_cpu_copy(None, torch.tensor([1]))
with self.assertRaises(NotImplementedError):
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."""
with envs.SGLANG_USE_HND_KVCACHE.override(True):
_, pool = _make_pool_and_kv(1)
self.assertFalse(pool.use_hnd)
self.assertEqual(pool.kv_cache_layout, "page_major")
class TestFactoryDenseViews(unittest.TestCase):
"""The real SWA factory builds both sub-pools and wires the matching
kernel-facing multipliers into the composite allocator."""
# _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.
from sglang.srt.mem_cache.unified_memory_pool import init_unified_swa_pools
return init_unified_swa_pools(
device="cpu",
kv_cache_dtype=torch.float16,
head_num=2,
head_dim=8,
v_head_dim=8,
swa_head_num=2,
swa_head_dim=8,
swa_v_head_dim=8,
page_size=1,
start_layer=0,
end_layer=4,
swa_attention_layer_ids=[1, 3],
full_attention_layer_ids=[0, 2],
full_max_total_num_tokens=64,
swa_max_total_num_tokens=32,
enable_memory_saver=False,
need_sort=False,
)
def test_factory_wires_matching_multipliers(self):
b = self._bundle()
pool = b.unified_memory_pool
alloc = b.token_to_kv_pool_allocator
self.assertEqual(alloc.kernel_page_multiplier, self.FULL_MULT)
self.assertEqual(alloc.swa_kernel_page_multiplier, self.SWA_MULT)
# Sub-pools are the dense class exposing stock 3-D per-layer views.
self.assertEqual(b.token_to_kv_pool.full_kv_pool.k_buffer[0].dim(), 3)
self.assertEqual(b.token_to_kv_pool.swa_kv_pool.k_buffer[0].dim(), 3)
self.assertGreater(pool.view_tail_pad_bytes, 0)
if __name__ == "__main__":
unittest.main()
@@ -15,15 +15,15 @@
memory pool (Kimi-Linear).
`req_to_token` holds VIRTUAL token ids, while the per-layer MLA views are dense
(`build_dense_mla_views`). The paged MLA backends therefore need their page-level
block table filled with DENSE page ids:
(`build_mla_views`). The paged MLA backends therefore need their page-level
block table filled with kernel-facing page ids:
dense_page(virtual_page) = v2p[virtual_page] * layer_num
Three backend families reach that same formula by different routes:
- `create_flashmla_kv_indices_triton` in-kernel via `v2p_ptr` / `PAGE_MULT`
(trtllm_mla / cutedsl_mla / tokenspeed_mla);
- the flashinfer_mla updaters, post-gathering `translate_kv_loc_dense` over the
- the flashinfer_mla updaters, post-gathering `translate_kv_loc_for_kernel` over the
token-level kv_indices;
- `normal_decode_set_metadata` in-kernel, for fa3's captured-decode page table.
@@ -32,13 +32,13 @@ Covered here:
- kernel dense mapping against the python reference, for several page sizes,
ragged sequence lengths and a non-identity v2p permutation;
- padded block-table lanes never index the v2p table out of bounds;
- the token-level dense translate the flashinfer updaters apply agrees with the
- the token-level kernel-facing translate the flashinfer updaters apply agrees with the
page-level block table the trtllm path builds;
- fa3's fused metadata kernels agree with the same reference, on both the
page_size == 1 fast path (which is what Kimi-Linear takes: fa3 imposes no
page-size constraint) and the general path.
python -m pytest test/registered/unit/mem_cache/test_unified_mla_dense_block_table.py -v
python -m pytest test/registered/unit/mem_cache/test_unified_mla_block_table.py -v
"""
import unittest
@@ -145,7 +145,7 @@ class TestDenseBlockTable(unittest.TestCase):
def test_single_full_attention_layer_still_maps_v2p(self):
"""A config with exactly ONE full-attention layer (e.g. a PP rank owning a
single MLA layer) has `kernel_page_multiplier == 1`, but its req_to_token
still holds VIRTUAL ids. The dense id collapses onto the physical id, so
still holds VIRTUAL ids. The kernel-facing id collapses onto the physical id, so
the v2p gather alone IS the whole translation -- it must not be skipped.
Regression guard for detecting the unified pool via `multiplier > 1`:
@@ -180,7 +180,7 @@ class TestDenseBlockTable(unittest.TestCase):
def test_agrees_with_token_level_dense_translate(self):
"""The flashinfer updaters translate TOKEN ids with
`translate_kv_loc_dense`; the trtllm path builds PAGE ids in-kernel. Both
`translate_kv_loc_for_kernel`; the trtllm path builds PAGE ids in-kernel. Both
must address the same dense page block."""
page_size = 64
rt, rpi, sl, v2p = self._make_batch(page_size)
@@ -190,12 +190,12 @@ class TestDenseBlockTable(unittest.TestCase):
for r in range(rt.shape[0]):
n = int(sl[r].item())
virt_tokens = rt[r, :n].long()
# translate_kv_loc_dense's formula, applied to token ids.
# translate_kv_loc_for_kernel's formula, applied to token ids.
dense_tokens = (
v2p[virt_tokens // page_size] * (page_size * _LAYERS)
+ virt_tokens % page_size
)
# The block-table entry scaled by page_size must be the dense id of
# The block-table entry scaled by page_size must be the kernel-facing id of
# each page's first token.
first_of_page = dense_tokens[::page_size]
n_pages = (n + page_size - 1) // page_size
@@ -330,19 +330,19 @@ class TestUnifiedMLAHookDetection(unittest.TestCase):
hooks = self._probe()
self.assertFalse(hooks.enabled)
self.assertIsNone(hooks.v2p_page_table)
self.assertIsNone(hooks.translate_kv_loc_dense)
self.assertIsNone(hooks.translate_kv_loc_for_kernel)
self.assertEqual(hooks.kernel_page_multiplier, 1)
def test_multi_layer_unified_pool(self):
table = torch.arange(8)
hooks = self._probe(
full_v2p_page_table=table,
translate_kv_loc_dense=lambda x, **kw: x,
translate_kv_loc_for_kernel=lambda x, **kw: x,
kernel_page_multiplier=_LAYERS,
)
self.assertTrue(hooks.enabled)
self.assertIs(hooks.v2p_page_table, table)
self.assertIsNotNone(hooks.translate_kv_loc_dense)
self.assertIsNotNone(hooks.translate_kv_loc_for_kernel)
self.assertEqual(hooks.kernel_page_multiplier, _LAYERS)
def test_single_full_attention_layer_pool_is_still_unified(self):
@@ -356,13 +356,13 @@ class TestUnifiedMLAHookDetection(unittest.TestCase):
table = torch.arange(8)
hooks = self._probe(
full_v2p_page_table=table,
translate_kv_loc_dense=lambda x, **kw: x,
translate_kv_loc_for_kernel=lambda x, **kw: x,
kernel_page_multiplier=1,
)
self.assertTrue(hooks.enabled, "single-layer unified pool read as static")
self.assertIs(hooks.v2p_page_table, table)
self.assertIsNotNone(hooks.translate_kv_loc_dense)
# Multiplier stays 1: dense id == physical id, so the v2p gather alone is
self.assertIsNotNone(hooks.translate_kv_loc_for_kernel)
# Multiplier stays 1: kernel-facing id == physical id, so the v2p gather alone is
# the whole translation and PAGE_MULT must not scale it.
self.assertEqual(hooks.kernel_page_multiplier, 1)
@@ -409,7 +409,6 @@ class TestInPlaceKvIndicesTranslate(unittest.TestCase):
device=_DEV,
enable_memory_saver=False,
page_size=page_size,
view_tail_pad_bytes=page_size * full.entry_bytes(),
)
class _Stub:
@@ -438,7 +437,7 @@ class TestInPlaceKvIndicesTranslate(unittest.TestCase):
def test_int32_buffer_prefix_translated_tail_untouched(self):
"""Mirrors the updater: an int32 capture-stable buffer holding VIRTUAL
ids in [:n] gets the dense ids written back in place, narrowed to int32,
ids in [:n] gets the kernel-facing ids written back in place, narrowed to int32,
with the stale tail left alone (it must never index the v2p table)."""
alloc = self._allocator()
virt = alloc.alloc(64)
@@ -452,13 +451,13 @@ class TestInPlaceKvIndicesTranslate(unittest.TestCase):
tail_before = buf[n:].clone()
valid = buf[:n]
valid.copy_(alloc.translate_kv_loc_dense(valid))
valid.copy_(alloc.translate_kv_loc_for_kernel(valid))
expected = alloc.translate_kv_loc_dense(virt)
expected = alloc.translate_kv_loc_for_kernel(virt)
self.assertEqual(buf.dtype, torch.int32)
self.assertTrue(
torch.equal(buf[:n].long(), expected),
"in-place translate did not land dense ids in the stable buffer",
"in-place translate did not land kernel-facing ids in the stable buffer",
)
self.assertTrue(
torch.equal(buf[n:], tail_before),
@@ -471,8 +470,8 @@ class TestInPlaceKvIndicesTranslate(unittest.TestCase):
virt = alloc.alloc(64)
self.assertIsNotNone(virt)
self.assertFalse(
torch.equal(alloc.translate_kv_loc_dense(virt), virt),
"dense ids coincide with virtual ids; pick a different allocation",
torch.equal(alloc.translate_kv_loc_for_kernel(virt), virt),
"kernel-facing ids coincide with virtual ids; pick a different allocation",
)
@@ -11,16 +11,16 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""GPU parity of the dense-view `UnifiedMLATokenToKVPool` against the stock
"""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 DENSE locs (dense(t) = (t//ps)*(ps*L) + t%ps); the
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 dense views are
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;
@@ -48,7 +48,7 @@ _D = _LORA + _ROPE
_DTYPE = torch.bfloat16
def _dense(t: torch.Tensor, ps: int) -> torch.Tensor:
def _kernel_id(t: torch.Tensor, ps: int) -> torch.Tensor:
return (t // ps) * (ps * _L) + t % ps
@@ -85,7 +85,6 @@ def _make_pools(ps: int, n_tokens: int = 4096):
device=_DEV,
enable_memory_saver=False,
page_size=ps,
view_tail_pad_bytes=ps * full.entry_bytes(),
)
unified = UnifiedMLATokenToKVPool(
unified_buffer=pool,
@@ -118,7 +117,7 @@ def _rand_locs(max_tokens: int, ps: int, n: int) -> torch.Tensor:
class TestUnifiedMLAPoolGPUParity(unittest.TestCase):
def _assert_parity(self, unified, ref, locs, ps, layers=range(_L)):
for l in layers:
got = unified.get_key_buffer(l)[_dense(locs, ps)]
got = unified.get_key_buffer(l)[_kernel_id(locs, ps)]
want = ref.get_key_buffer(l)[locs]
torch.testing.assert_close(got, want, rtol=0, atol=0)
@@ -130,7 +129,7 @@ class TestUnifiedMLAPoolGPUParity(unittest.TestCase):
layer = types.SimpleNamespace(layer_id=l)
nope = torch.randn(n_loc, 1, _LORA, dtype=_DTYPE, device=_DEV)
rope = torch.randn(n_loc, 1, _ROPE, dtype=_DTYPE, device=_DEV)
unified.set_mla_kv_buffer(layer, _dense(locs, ps), nope, rope)
unified.set_mla_kv_buffer(layer, _kernel_id(locs, ps), nope, rope)
ref.set_mla_kv_buffer(layer, locs, nope, rope)
torch.cuda.synchronize()
self._assert_parity(unified, ref, locs, ps)
@@ -156,7 +155,7 @@ class TestUnifiedMLAPoolGPUParity(unittest.TestCase):
for l in (0, _L // 2, _L - 1):
layer = types.SimpleNamespace(layer_id=l)
k = torch.randn(n_loc, 1, _D, dtype=_DTYPE, device=_DEV)
unified.set_kv_buffer(layer, _dense(locs, ps), k, None)
unified.set_kv_buffer(layer, _kernel_id(locs, ps), k, None)
ref.set_kv_buffer(layer, locs, k, None)
torch.cuda.synchronize()
self._assert_parity(unified, ref, locs, ps, layers=(0, _L // 2, _L - 1))
@@ -170,8 +169,8 @@ class TestUnifiedMLAPoolGPUParity(unittest.TestCase):
layer = types.SimpleNamespace(layer_id=3)
nope = torch.randn(n_loc, 1, _LORA, dtype=_DTYPE, device=_DEV)
rope = torch.randn(n_loc, 1, _ROPE, dtype=_DTYPE, device=_DEV)
unified.set_mla_kv_buffer(layer, _dense(locs, ps), nope, rope)
got_nope, got_rope = unified.get_mla_kv_buffer(layer, _dense(locs, ps))
unified.set_mla_kv_buffer(layer, _kernel_id(locs, ps), nope, rope)
got_nope, got_rope = unified.get_mla_kv_buffer(layer, _kernel_id(locs, ps))
torch.cuda.synchronize()
torch.testing.assert_close(got_nope, nope, rtol=0, atol=0)
torch.testing.assert_close(got_rope, rope, rtol=0, atol=0)
@@ -188,14 +187,15 @@ class TestUnifiedMLAPoolGPUParity(unittest.TestCase):
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, _dense(src_t, ps), k, None)
unified.set_kv_buffer(layer, _kernel_id(src_t, ps), k, None)
before = [
unified.get_key_buffer(l)[_dense(src_t, ps)].clone() for l in range(_L)
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)[_dense(dst_t, ps)]
got = unified.get_key_buffer(l)[_kernel_id(dst_t, ps)]
torch.testing.assert_close(got, before[l], rtol=0, atol=0)
@@ -15,15 +15,15 @@
Covers, CPU-only (pure torch — no GPU / Triton kernels):
- `MLASubPoolSpec` byte math;
- `build_dense_mla_views` addressing: view_l[dense(t)] must land exactly at
- `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 dense ids, and 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_dense`: dense = v2p-page * (ps*L) +
- `MultiEndedAllocator.translate_kv_loc_for_kernel`: dense = v2p-page * (ps*L) +
offset, tombstone clamp to the sink, `out=` contract, multiplier-1
fallback, and correctness across eager compaction.
@@ -42,7 +42,7 @@ import unittest
import torch
from sglang.srt.mem_cache.layout.page_major import (
build_dense_mla_views,
build_mla_views,
mla_entry_bytes,
)
from sglang.srt.mem_cache.multi_ended_allocator import MultiEndedAllocator
@@ -98,12 +98,11 @@ def _make_unified(page_size=1, n_full_tokens=64, n_mamba_slots=8):
device=_DEV,
enable_memory_saver=False,
page_size=page_size,
view_tail_pad_bytes=page_size * full.entry_bytes(),
)
return pool, full, mamba
def _dense(t, ps, layer_num):
def _kernel_id(t, ps, layer_num):
return (t // ps) * (ps * layer_num) + t % ps
@@ -142,7 +141,7 @@ class TestDenseMLAViews(unittest.TestCase):
for ps in (1, 4):
num_pages = 6
raw, _ = self._make_raw(ps, num_pages)
views = build_dense_mla_views(
views = build_mla_views(
raw,
layer_num=_L,
kv_cache_dim=_D,
@@ -151,9 +150,9 @@ class TestDenseMLAViews(unittest.TestCase):
num_pages=num_pages,
)
self.assertEqual(len(views), _L)
n_dense = num_pages * _L * ps
n_rows = num_pages * _L * ps
for v in views:
self.assertEqual(tuple(v.shape), (n_dense, 1, _D))
self.assertEqual(tuple(v.shape), (n_rows, 1, _D))
# contiguous in the (row, dim) sense — .view(-1, ps, D) legality
self.assertEqual(v.stride(0), _D)
self.assertEqual(v.stride(2), 1)
@@ -161,7 +160,7 @@ class TestDenseMLAViews(unittest.TestCase):
for p, l, s in [(0, 0, 0), (1, 2, ps - 1), (4, 1, ps // 2), (5, 2, 0)]:
t = p * ps + s
marker = float(p * 100 + l * 10 + s + 1)
views[l][_dense(t, ps, _L)] = marker
views[l][_kernel_id(t, ps, _L)] = marker
# envelope formula, in elements
elem = p * (_L * ps * _D) + l * (ps * _D) + s * _D
self.assertTrue(
@@ -173,7 +172,7 @@ class TestDenseMLAViews(unittest.TestCase):
ps = 4
num_pages = 4
raw, _ = self._make_raw(ps, num_pages)
views = build_dense_mla_views(
views = build_mla_views(
raw,
layer_num=_L,
kv_cache_dim=_D,
@@ -182,7 +181,7 @@ class TestDenseMLAViews(unittest.TestCase):
num_pages=num_pages,
)
t = 2 * ps + 1 # page 2, slot 1
d = _dense(t, ps, _L)
d = _kernel_id(t, ps, _L)
for l in range(_L):
views[l][d] = float(l + 1)
for l in range(_L):
@@ -193,7 +192,7 @@ class TestDenseMLAViews(unittest.TestCase):
num_pages = 4
raw, _ = self._make_raw(ps, num_pages, pad_pages=0)
with self.assertRaises(AssertionError):
build_dense_mla_views(
build_mla_views(
raw,
layer_num=_L,
kv_cache_dim=_D,
@@ -279,7 +278,7 @@ class TestUnifiedMLATokenToKVPool(unittest.TestCase):
# 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][_dense(src_page * ps + s, ps, _L)] = float(
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)
@@ -289,7 +288,7 @@ class TestUnifiedMLATokenToKVPool(unittest.TestCase):
)
for l in range(_L):
for s in range(ps):
got = kv_pool.kv_buffer[l][_dense(dst_page * ps + s, ps, _L)]
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})"
)
@@ -331,7 +330,7 @@ class TestTranslateKvLocDense(unittest.TestCase):
v = alloc.alloc(8)
self.assertIsNotNone(v)
phys = alloc.translate_kv_loc(v)
dense = alloc.translate_kv_loc_dense(v)
dense = alloc.translate_kv_loc_for_kernel(v)
self.assertTrue(torch.all(dense == phys * _L))
def test_dense_matches_formula_paged(self):
@@ -340,15 +339,15 @@ class TestTranslateKvLocDense(unittest.TestCase):
v = alloc.alloc(3 * ps)
self.assertIsNotNone(v)
phys = alloc.translate_kv_loc(v)
dense = alloc.translate_kv_loc_dense(v)
dense = alloc.translate_kv_loc_for_kernel(v)
expected = (phys // ps) * (ps * _L) + phys % ps
self.assertTrue(torch.all(dense == expected))
def test_tombstone_clamps_to_sink(self):
alloc = self._build(ps=1)
# never-allocated virtual ids -> v2p == -1 -> dense id 0
# never-allocated virtual ids -> v2p == -1 -> kernel-facing id 0
virt = torch.tensor([alloc.min_slot_index + 1], dtype=torch.int64)
dense = alloc.translate_kv_loc_dense(virt)
dense = alloc.translate_kv_loc_for_kernel(virt)
self.assertTrue(torch.all(dense == 0))
def test_out_matches_and_aliases(self):
@@ -356,14 +355,14 @@ class TestTranslateKvLocDense(unittest.TestCase):
alloc = self._build(ps=ps)
v = alloc.alloc(2 * ps)
self.assertIsNotNone(v)
no_out = alloc.translate_kv_loc_dense(v)
no_out = alloc.translate_kv_loc_for_kernel(v)
out = torch.empty_like(v)
ret = alloc.translate_kv_loc_dense(v, out=out)
ret = alloc.translate_kv_loc_for_kernel(v, out=out)
self.assertIs(ret, out)
self.assertTrue(torch.all(out == no_out))
# canonical in-place aliasing: translate(x, out=x)
x = v.clone()
alloc.translate_kv_loc_dense(x, out=x)
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):
@@ -371,7 +370,7 @@ class TestTranslateKvLocDense(unittest.TestCase):
v = alloc.alloc(4)
self.assertIsNotNone(v)
self.assertTrue(
torch.all(alloc.translate_kv_loc_dense(v) == alloc.translate_kv_loc(v))
torch.all(alloc.translate_kv_loc_for_kernel(v) == alloc.translate_kv_loc(v))
)
def test_dense_follows_compaction(self):
@@ -383,8 +382,8 @@ class TestTranslateKvLocDense(unittest.TestCase):
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_dense(a) == phys_a * _L))
self.assertTrue(torch.all(alloc.translate_kv_loc_dense(c) == phys_c * _L))
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__":
@@ -15,9 +15,9 @@
The page-major envelope K/V views are strided, which only the Triton attention
kernels read. The one exception is the unified-memory MLA pool: it exposes each
layer as a DENSE contiguous view (`build_dense_mla_views`), so the paged MLA
layer as a contiguous view (`build_mla_views`), so the paged MLA
backends can read it directly once their kv_indices / block tables are remapped
to dense ids -- `fa3`, `flashinfer`'s MLA backend, and `trtllm_mla` with its
to kernel-facing ids -- `fa3`, `flashinfer`'s MLA backend, and `trtllm_mla` with its
`cutedsl_mla` / `tokenspeed_mla` subclasses.
Pinned here so the exception cannot silently widen to a backend that has no
@@ -47,6 +47,7 @@ def _accepts(
unified: bool = True,
linear_decode: str | None = None,
linear_prefill: str | None = None,
has_asymmetric_kv: bool = False,
) -> bool:
"""Run just `_handle_page_major_kv_layout` against a minimal stand-in.
@@ -72,7 +73,12 @@ def _accepts(
sa,
"_model_config",
SimpleNamespace(
attention_arch=AttentionArch.MLA if use_mla else AttentionArch.MHA
attention_arch=AttentionArch.MLA if use_mla else AttentionArch.MHA,
has_asymmetric_kv=has_asymmetric_kv,
head_dim=192 if has_asymmetric_kv else 128,
v_head_dim=128,
swa_head_dim=128,
swa_v_head_dim=128,
),
)
try:
@@ -106,7 +112,7 @@ class TestPageMajorBackendAllowlist(unittest.TestCase):
)
def test_dense_mla_backends_rejected_for_mha(self):
"""The dense-view exception is MLA-only -- MHA sub-pools stay strided."""
"""The per-layer-view exception is MLA-only -- MHA sub-pools stay strided."""
for backend in self.DENSE_MLA_BACKENDS:
self.assertFalse(
_accepts(backend, use_mla=False),
@@ -122,6 +128,40 @@ class TestPageMajorBackendAllowlist(unittest.TestCase):
f"{backend} must stay rejected without --enable-unified-memory",
)
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.DENSE_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."""
for backend in ("triton",) + self.DENSE_MLA_BACKENDS:
self.assertFalse(
_accepts(backend, use_mla=False, has_asymmetric_kv=True),
f"--enable-unified-memory + {backend} must be rejected for an "
"asymmetric-K/V model",
)
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."""
for backend in ("triton",) + self.DENSE_MLA_BACKENDS:
self.assertTrue(
_accepts(backend, use_mla=True, has_asymmetric_kv=True),
f"{backend} must stay allowed for an MLA model with asymmetric "
"K/V head dims",
)
def test_unwired_backends_always_rejected(self):
for backend in self.UNWIRED_BACKENDS:
for use_mla in (True, False):
@@ -131,15 +171,10 @@ class TestPageMajorBackendAllowlist(unittest.TestCase):
)
def test_helion_linear_attention_is_kda_only(self):
for unified in (True, False):
for phase in ("decode", "prefill"):
kwargs = {f"linear_{phase}": "helion"}
self.assertTrue(
_accepts("triton", use_mla=True, unified=unified, **kwargs)
)
self.assertFalse(
_accepts("triton", use_mla=False, unified=unified, **kwargs)
)
for phase in ("decode", "prefill"):
kwargs = {f"linear_{phase}": "helion"}
self.assertTrue(_accepts("triton", use_mla=True, **kwargs))
self.assertFalse(_accepts("triton", use_mla=False, **kwargs))
if __name__ == "__main__":