feat(mem_cache): unified memory pool for hybrid Mamba / SWA models (#29678)
Co-authored-by: lch1475369 <lch1475369@gmail.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
# 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.
|
||||
# ==============================================================================
|
||||
"""Routing tests for the composite write paths (`UnifiedSWAKVPool`,
|
||||
`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 `ForwardBatch.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` rail
|
||||
(`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
|
||||
"""
|
||||
|
||||
import types
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
def _loc_info(virtual_loc, swa_phys=None, full_phys=None):
|
||||
from sglang.srt.mem_cache.memory_pool import KVWriteLoc
|
||||
|
||||
return KVWriteLoc(virtual_loc, swa_phys, full_phys)
|
||||
|
||||
|
||||
class _RecordingPool:
|
||||
"""Stub sub-pool that records the `loc` and kwargs passed to `set_kv_buffer`."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def set_kv_buffer(self, layer, loc, cache_k, cache_v, *args, **kwargs):
|
||||
self.calls.append((loc, kwargs))
|
||||
|
||||
|
||||
class TestUnifiedSWARouting(unittest.TestCase):
|
||||
"""`UnifiedSWAKVPool.set_kv_buffer` routing: full layers write the full-physical
|
||||
`full_loc`; SWA layers write the swa-physical `swa_loc`. Both come from the
|
||||
write metadata; the pool never translates."""
|
||||
|
||||
def _make_bare_pool(self):
|
||||
from sglang.srt.mem_cache.unified_memory_pool import UnifiedSWAKVPool
|
||||
|
||||
# Bypass the heavy __init__; set only the attributes set_kv_buffer reads.
|
||||
pool = object.__new__(UnifiedSWAKVPool)
|
||||
pool.full_kv_pool = _RecordingPool()
|
||||
pool.swa_kv_pool = _RecordingPool()
|
||||
# layer 0 -> full attention; layer 1 -> SWA. (pool_layer_id, is_swa)
|
||||
pool.layers_mapping = {0: (0, False), 1: (0, True)}
|
||||
return pool
|
||||
|
||||
def test_full_layer_writes_full_loc(self):
|
||||
pool = self._make_bare_pool()
|
||||
virtual_loc = torch.tensor([10, 11, 12], dtype=torch.int64)
|
||||
swa_phys = torch.tensor([1, 2, 0], dtype=torch.int64)
|
||||
full_phys = torch.tensor([3, 4, 5], dtype=torch.int64)
|
||||
|
||||
layer = types.SimpleNamespace(layer_id=0) # full layer
|
||||
pool.set_kv_buffer(
|
||||
layer,
|
||||
_loc_info(virtual_loc, swa_phys, full_phys),
|
||||
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]
|
||||
# Forward the full-physical tensor from the write metadata, NOT the
|
||||
# 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_requires_full_loc(self):
|
||||
pool = self._make_bare_pool()
|
||||
virtual_loc = torch.tensor([10, 11, 12], dtype=torch.int64)
|
||||
swa_phys = torch.tensor([1, 2, 0], dtype=torch.int64)
|
||||
|
||||
layer = types.SimpleNamespace(layer_id=0)
|
||||
# No full_loc precomputed -> fail loud (the unified memory pool must precompute
|
||||
# out_cache_loc_full_physical) rather than write a virtual loc as physical.
|
||||
with self.assertRaises(AssertionError):
|
||||
pool.set_kv_buffer(
|
||||
layer,
|
||||
_loc_info(virtual_loc, swa_phys),
|
||||
torch.zeros(3, 4, 8),
|
||||
torch.zeros(3, 4, 8),
|
||||
)
|
||||
|
||||
def test_swa_layer_writes_swa_loc(self):
|
||||
pool = self._make_bare_pool()
|
||||
virtual_loc = torch.tensor([10, 11, 12], dtype=torch.int64)
|
||||
swa_phys = torch.tensor([1, 2, 0], dtype=torch.int64)
|
||||
|
||||
layer = types.SimpleNamespace(layer_id=1) # SWA layer
|
||||
pool.set_kv_buffer(
|
||||
layer,
|
||||
_loc_info(virtual_loc, swa_phys),
|
||||
torch.zeros(3, 4, 8),
|
||||
torch.zeros(3, 4, 8),
|
||||
)
|
||||
|
||||
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.
|
||||
self.assertIs(forwarded, swa_phys)
|
||||
self.assertNotIn("already_physical", kwargs)
|
||||
# Full pool untouched for an SWA layer.
|
||||
self.assertEqual(len(pool.full_kv_pool.calls), 0)
|
||||
|
||||
def test_swa_layer_requires_swa_loc(self):
|
||||
pool = self._make_bare_pool()
|
||||
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
|
||||
# rather than silently writing wrong (un-translated) locations.
|
||||
with self.assertRaises(AssertionError):
|
||||
pool.set_kv_buffer(
|
||||
layer,
|
||||
_loc_info(virtual_loc, None),
|
||||
torch.zeros(3, 4, 8),
|
||||
torch.zeros(3, 4, 8),
|
||||
)
|
||||
|
||||
|
||||
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`."""
|
||||
|
||||
def _make_bare_pool(self):
|
||||
from sglang.srt.mem_cache.memory_pool import HybridLinearKVPool
|
||||
|
||||
pool = object.__new__(HybridLinearKVPool)
|
||||
pool.full_kv_pool = _RecordingPool()
|
||||
pool.use_mla = False
|
||||
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)
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,355 @@
|
||||
# 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.
|
||||
# ==============================================================================
|
||||
"""Unit tests for the page-major layer-major byte layout.
|
||||
|
||||
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.
|
||||
|
||||
CPU-only — no GPU / Triton needed.
|
||||
|
||||
python -m pytest test/registered/unit/mem_cache/test_layout_compat.py -v
|
||||
"""
|
||||
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=6, suite="base-a-test-cpu")
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
_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,
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
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 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."""
|
||||
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):
|
||||
k[L][5] = float(L + 1)
|
||||
v[L][5] = -float(L + 1)
|
||||
move_kv_cache_native(
|
||||
k,
|
||||
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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,10 +24,10 @@ 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 SharedMHATokenToKVPool, which only
|
||||
# exists once the shared-memory-pool feature lands; skip it where absent.
|
||||
# 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.shared_memory_pool") is not None
|
||||
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")
|
||||
@@ -72,7 +72,7 @@ class TestStoreCache4D(unittest.TestCase):
|
||||
seed: int = 0xC0FFEE,
|
||||
):
|
||||
torch.manual_seed(seed)
|
||||
# The shared pool's views are 4-D `(num_pages, page_size, head_num,
|
||||
# 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.
|
||||
@@ -154,7 +154,7 @@ class TestStoreCache4D(unittest.TestCase):
|
||||
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 the Stage 1/2/3 green eval matrix from regression."""
|
||||
This protects against byte-layout regression."""
|
||||
self._check_parity(
|
||||
num_pages=64,
|
||||
page_size=1,
|
||||
@@ -228,8 +228,7 @@ class TestStoreCache4D(unittest.TestCase):
|
||||
|
||||
def test_store_cache_4d_dtype_fp8_e5m2(self):
|
||||
"""fp8_e5m2 is used for KV-cache quantization. Caller is responsible
|
||||
for the cast (Phase 1); the kernel sees same-dtype source and
|
||||
destination."""
|
||||
for the cast; the kernel sees same-dtype source and destination."""
|
||||
self._check_parity(
|
||||
num_pages=16,
|
||||
page_size=64,
|
||||
@@ -317,31 +316,25 @@ class TestStoreCache4DAssertions(unittest.TestCase):
|
||||
|
||||
@unittest.skipUnless(
|
||||
_HAS_CUDA and _HAS_SHARED_POOL,
|
||||
"Triton kernels require CUDA; SharedMHATokenToKVPool required",
|
||||
"Triton kernels require CUDA; UnifiedMHATokenToKVPool required",
|
||||
)
|
||||
class TestStoreCache4DThroughSetKVBuffer(unittest.TestCase):
|
||||
"""Integration parity test — exercises the kernel through the FULL
|
||||
``SharedMHATokenToKVPool.set_kv_buffer`` path, including the
|
||||
``_external_allocator`` v2p translation and the dtype cast. Confirms the
|
||||
production code path produces bit-identical output to a PyTorch
|
||||
advanced-indexing reference write.
|
||||
``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_and_stub_alloc(self, page_size: int, v2p=None):
|
||||
"""Build a small SharedMHATokenToKVPool wired to a stub allocator.
|
||||
|
||||
By default `virtual_to_physical` is identity (the kernel-vs-legacy
|
||||
parity tests don't exercise virtual-id semantics). Pass an explicit
|
||||
`v2p` tensor (sized `max_slots + 1`) to exercise a NON-identity
|
||||
translation — used by the `set_full_loc` fast-path parity test, which
|
||||
needs virtual != physical so the precomputed-physical fast path is
|
||||
meaningfully different from the per-call gather."""
|
||||
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.shared_memory_pool import (
|
||||
from sglang.srt.mem_cache.unified_memory_pool import (
|
||||
MHASubPoolSpec,
|
||||
SharedMemoryPool,
|
||||
SharedMHATokenToKVPool,
|
||||
UnifiedKVPool,
|
||||
UnifiedMHATokenToKVPool,
|
||||
)
|
||||
|
||||
spec = MHASubPoolSpec(
|
||||
@@ -362,15 +355,15 @@ class TestStoreCache4DThroughSetKVBuffer(unittest.TestCase):
|
||||
store_dtype=_t.bfloat16,
|
||||
grow_direction="down",
|
||||
)
|
||||
pool = SharedMemoryPool(
|
||||
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 = SharedMHATokenToKVPool(
|
||||
shared_buffer=pool,
|
||||
kv_pool = UnifiedMHATokenToKVPool(
|
||||
unified_buffer=pool,
|
||||
sub_pool_name="full",
|
||||
page_size=page_size,
|
||||
start_layer=0,
|
||||
@@ -378,21 +371,12 @@ class TestStoreCache4DThroughSetKVBuffer(unittest.TestCase):
|
||||
enable_alt_stream=False,
|
||||
)
|
||||
|
||||
# Stub allocator with an identity (default) or caller-supplied v2p.
|
||||
max_slots = pool.max_slots("full")
|
||||
if v2p is None:
|
||||
v2p = _t.arange(max_slots + 1, dtype=_t.int64, device="cuda")
|
||||
|
||||
class _StubAllocator:
|
||||
virtual_to_physical = v2p
|
||||
|
||||
kv_pool.attach_allocator(_StubAllocator())
|
||||
return kv_pool
|
||||
|
||||
def _run_set_kv_buffer_and_compare(self, page_size: int):
|
||||
import torch as _t
|
||||
|
||||
kv_pool = self._build_pool_and_stub_alloc(page_size)
|
||||
kv_pool = self._build_pool(page_size)
|
||||
|
||||
# A fake `layer` object with the minimum interface
|
||||
# `set_kv_buffer` reads: `.layer_id`.
|
||||
@@ -415,9 +399,8 @@ class TestStoreCache4DThroughSetKVBuffer(unittest.TestCase):
|
||||
k_kernel = kv_pool.k_buffer[0].clone()
|
||||
v_kernel = kv_pool.v_buffer[0].clone()
|
||||
|
||||
# Reference: PyTorch advanced-indexing into a fresh view. The stub
|
||||
# allocator's v2p is identity, so physical loc == virtual loc and no
|
||||
# dtype cast happens (store_dtype == dtype), making this the exact
|
||||
# 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_()
|
||||
@@ -449,81 +432,6 @@ class TestStoreCache4DThroughSetKVBuffer(unittest.TestCase):
|
||||
def test_integration_ps64(self):
|
||||
self._run_set_kv_buffer_and_compare(page_size=64)
|
||||
|
||||
def _run_full_loc_fast_path_parity(self, page_size: int):
|
||||
"""Stage 3.5 fast-path byte-identity: writing through the precomputed
|
||||
full-physical loc (`set_loc` fast path) must produce a byte-identical
|
||||
KV buffer to writing the virtual loc and letting `set_kv_buffer`
|
||||
translate per call. Uses a NON-identity v2p so the two paths are
|
||||
genuinely different code (fast path skips the gather)."""
|
||||
import torch as _t
|
||||
|
||||
# Non-identity v2p: reverse-map the physical slot space so virtual i
|
||||
# lands on a different physical slot. Keep slot 0 -> 0 (padding sink).
|
||||
# Build the pool once to learn max_slots, then rebuild with the v2p.
|
||||
probe = self._build_pool_and_stub_alloc(page_size)
|
||||
max_slots = probe.k_buffer[0].shape[0] * page_size
|
||||
v2p = _t.arange(max_slots + 1, dtype=_t.int64, device="cuda")
|
||||
# Shuffle the interior [1, max_slots) so virtual != physical, leave
|
||||
# 0 (sink) and the trailing sentinel (max_slots -> itself) alone.
|
||||
interior = _t.randperm(max_slots - 1, device="cuda") + 1
|
||||
v2p[1:max_slots] = interior
|
||||
|
||||
kv_pool = self._build_pool_and_stub_alloc(page_size, v2p=v2p)
|
||||
|
||||
class _FakeLayer:
|
||||
layer_id = 0
|
||||
|
||||
layer = _FakeLayer()
|
||||
head_num, head_dim = 4, 64
|
||||
N = 16
|
||||
num_pages = kv_pool.k_buffer[0].shape[0]
|
||||
total = num_pages * page_size
|
||||
# Draw virtual ids from [1, total) (avoid the padding sink at 0).
|
||||
loc = (_t.randperm(total - 1, device="cuda")[:N] + 1).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")
|
||||
|
||||
# SLOW path: no precompute pinned -> per-call v2p gather inside
|
||||
# set_kv_buffer translates virtual -> physical.
|
||||
kv_pool.set_loc(None)
|
||||
kv_pool.set_kv_buffer(layer, loc, cache_k.clone(), cache_v.clone())
|
||||
k_slow = kv_pool.k_buffer[0].clone()
|
||||
v_slow = kv_pool.v_buffer[0].clone()
|
||||
|
||||
# FAST path: precompute the full-physical loc exactly as
|
||||
# `set_kv_buffer`'s page math would, pin it via set_loc, and pass
|
||||
# it as `loc` so the data-ptr fast path fires (no gather).
|
||||
if page_size == 1:
|
||||
phys = _t.clamp_min(v2p[loc], 0)
|
||||
else:
|
||||
virt_pages = loc // page_size
|
||||
offsets = loc % page_size
|
||||
phys = _t.clamp_min(v2p[virt_pages] * page_size + offsets, 0)
|
||||
kv_pool.k_buffer[0].zero_()
|
||||
kv_pool.v_buffer[0].zero_()
|
||||
kv_pool.set_loc(phys)
|
||||
try:
|
||||
kv_pool.set_kv_buffer(layer, phys, cache_k.clone(), cache_v.clone())
|
||||
k_fast = kv_pool.k_buffer[0].clone()
|
||||
v_fast = kv_pool.v_buffer[0].clone()
|
||||
finally:
|
||||
kv_pool.set_loc(None)
|
||||
|
||||
self.assertTrue(
|
||||
_t.equal(k_fast, k_slow),
|
||||
f"K mismatch: full_loc fast path != per-call translate at ps={page_size}",
|
||||
)
|
||||
self.assertTrue(
|
||||
_t.equal(v_fast, v_slow),
|
||||
f"V mismatch: full_loc fast path != per-call translate at ps={page_size}",
|
||||
)
|
||||
|
||||
def test_full_loc_fast_path_parity_ps1(self):
|
||||
self._run_full_loc_fast_path_parity(page_size=1)
|
||||
|
||||
def test_full_loc_fast_path_parity_ps64(self):
|
||||
self._run_full_loc_fast_path_parity(page_size=64)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
# 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.
|
||||
# ==============================================================================
|
||||
"""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).
|
||||
|
||||
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.
|
||||
|
||||
Skipped on CPU — these views back GPU kernels and we mirror the GPU path.
|
||||
|
||||
python -m pytest test/registered/unit/mem_cache/test_shared_mamba_views.py -v
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.test.ci.ci_register import register_cuda_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")
|
||||
|
||||
|
||||
def _make_pool(
|
||||
*,
|
||||
mamba_layer_num,
|
||||
conv_state_shapes,
|
||||
conv_dtype,
|
||||
temporal_state_shape,
|
||||
temporal_dtype,
|
||||
want_slots=8,
|
||||
device=_DEV,
|
||||
):
|
||||
"""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)``."""
|
||||
from sglang.srt.mem_cache.unified_memory_pool import (
|
||||
MambaSubPoolSpec,
|
||||
MHASubPoolSpec,
|
||||
UnifiedKVPool,
|
||||
)
|
||||
|
||||
mamba_spec = MambaSubPoolSpec(
|
||||
name="mamba",
|
||||
layer_num=mamba_layer_num,
|
||||
grow_direction="down",
|
||||
conv_state_shapes=tuple(tuple(s) for s in conv_state_shapes),
|
||||
conv_dtype=conv_dtype,
|
||||
temporal_state_shape=tuple(temporal_state_shape),
|
||||
temporal_dtype=temporal_dtype,
|
||||
)
|
||||
# Tiny full-attention peer (required: exactly one grow-up + one grow-down).
|
||||
full_spec = MHASubPoolSpec(
|
||||
name="full",
|
||||
layer_num=1,
|
||||
head_num=1,
|
||||
head_dim=8,
|
||||
store_dtype=torch.bfloat16,
|
||||
grow_direction="up",
|
||||
)
|
||||
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()).
|
||||
total_bytes = want_slots * entry_mamba + 8 * entry_max
|
||||
total_bytes = ((total_bytes + 7) // 8) * 8
|
||||
pool = UnifiedKVPool(
|
||||
total_bytes=total_bytes,
|
||||
sub_pool_specs=[full_spec, mamba_spec],
|
||||
device=device,
|
||||
enable_memory_saver=False,
|
||||
)
|
||||
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_KW = dict(
|
||||
mamba_layer_num=5, # odd, to stress the temporal-offset alignment
|
||||
conv_state_shapes=[(48, 3)], # conv_dim=48, kernel-1=3
|
||||
conv_dtype=torch.bfloat16,
|
||||
temporal_state_shape=(6, 8, 16), # nheads, head_dim, ssm_state
|
||||
temporal_dtype=torch.float32,
|
||||
)
|
||||
|
||||
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.
|
||||
"""
|
||||
conv_views, temporal_view = pool.mamba_views_for("mamba")
|
||||
torch.manual_seed(0)
|
||||
refs = []
|
||||
for v in conv_views:
|
||||
r = torch.randn(v.shape, device=v.device).to(v.dtype)
|
||||
v.copy_(r)
|
||||
refs.append(r)
|
||||
rt = torch.randn(temporal_view.shape, device=temporal_view.device).to(
|
||||
temporal_view.dtype
|
||||
)
|
||||
temporal_view.copy_(rt)
|
||||
refs.append(rt)
|
||||
# Read back AFTER all writes.
|
||||
for i, v in enumerate(conv_views):
|
||||
self.assertTrue(
|
||||
torch.equal(v, refs[i]),
|
||||
f"conv view[{i}] round-trip mismatch (stride/offset/overlap "
|
||||
f"bug); shape={tuple(v.shape)} stride={v.stride()}",
|
||||
)
|
||||
self.assertTrue(
|
||||
torch.equal(temporal_view, refs[-1]),
|
||||
f"temporal view round-trip mismatch; shape={tuple(temporal_view.shape)} "
|
||||
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):
|
||||
# 1 layer, multiple conv tensors, same-dtype conv/temporal.
|
||||
pool, spec = _make_pool(
|
||||
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
|
||||
# accumulation in _build_mamba_views.
|
||||
pool, spec = _make_pool(
|
||||
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)
|
||||
|
||||
def test_no_cross_region_overlap(self):
|
||||
"""Zero buffer; write a sentinel to ONE view; every OTHER view must read
|
||||
all-zero. Pinpoints conv[i]/conv[j]/temporal aliasing if present."""
|
||||
pool, spec = _make_pool(**self.FALCON_KW)
|
||||
conv_views, temporal_view = pool.mamba_views_for("mamba")
|
||||
views = list(conv_views) + [temporal_view]
|
||||
names = [f"conv[{i}]" for i in range(len(conv_views))] + ["temporal"]
|
||||
for target in range(len(views)):
|
||||
pool._raw.zero_()
|
||||
views[target].fill_(7.0)
|
||||
for other in range(len(views)):
|
||||
if other == target:
|
||||
self.assertTrue(
|
||||
bool((views[other] == 7.0).all().item()),
|
||||
f"write to {names[target]} did not fully land",
|
||||
)
|
||||
continue
|
||||
self.assertTrue(
|
||||
bool((views[other] == 0).all().item()),
|
||||
f"writing {names[target]} CORRUPTED {names[other]} "
|
||||
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."""
|
||||
with self.assertRaises(AssertionError) as cm:
|
||||
_make_pool(
|
||||
mamba_layer_num=1, # entry = 2 B conv + 4 B temporal = 6 B, not %4
|
||||
conv_state_shapes=[(1, 1)],
|
||||
conv_dtype=torch.bfloat16,
|
||||
temporal_state_shape=(1,),
|
||||
temporal_dtype=torch.float32,
|
||||
want_slots=4,
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user