fix(unified-memory): evict Full KV for Mamba byte shortfalls (#36713)

Co-authored-by: Yangmin Li <yangminl@nvidia.com>
Co-authored-by: YAMY <74099316+YAMY1234@users.noreply.github.com>
This commit is contained in:
SuperSong
2026-09-09 13:46:51 -07:00
committed by GitHub
co-authored by Yangmin Li YAMY
parent 6c1d0b1b29
commit bede776c2a
8 changed files with 969 additions and 22 deletions
@@ -25,6 +25,7 @@ register_cpu_ci(est_time=11, suite="base-a-test-cpu")
import contextlib
import random
import unittest
from unittest.mock import MagicMock
import torch
@@ -38,12 +39,16 @@ from sglang.srt.mem_cache.allocator.unified_sub_pool import (
FloatMultiEndedAllocator,
MultiEndedAllocator,
)
from sglang.srt.mem_cache.base_prefix_cache import EvictParams
from sglang.srt.mem_cache.unified_cache.components import ComponentType
from sglang.srt.mem_cache.unified_memory_pool import (
MambaSubPoolSpec,
MHASubPoolSpec,
MLASubPoolSpec,
UnifiedKVPool,
UnifiedMambaSlotAllocator,
)
from sglang.srt.mem_cache.unified_radix_cache import UnifiedRadixCache
from sglang.srt.runtime_context import get_parallel, publish, reset_context
from sglang.srt.server_args import ServerArgs
@@ -3040,7 +3045,7 @@ class TestDcpWidening(unittest.TestCase):
self.assertTrue(bool((written[~owned] == 0).all()))
self.assertTrue(bool((written[owned] > 0).all()))
def _build_composite(self, *, page_size):
def _build_composite(self, *, page_size, lazy_compaction=False):
from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator,
)
@@ -3070,8 +3075,51 @@ class TestDcpWidening(unittest.TestCase):
page_size=page_size,
need_sort=False,
forward_stream=None,
lazy_compaction=lazy_compaction,
)
def _build_donor_cache(self, allocator, mamba_slot_allocator, full_leaves):
cache = object.__new__(UnifiedRadixCache)
cache.disable = False
cache.tree_components = (ComponentType.FULL, ComponentType.MAMBA)
cache.is_swa_enabled = False
cache.cache_controller = None
cache.metrics_collector = None
cache.token_to_kv_pool_allocator = allocator
cache.req_to_token_pool = MagicMock(mamba_allocator=mamba_slot_allocator)
tree_core = MagicMock()
full_evictable = sum(int(indices.numel()) for indices in full_leaves)
tree_core.full_evictable_size.return_value = full_evictable
tree_core.mamba_evictable_size.return_value = 0
walk = {"request_cnt": 0, "freed_leaves": 0}
def start(component_type, request_cnt):
self.assertEqual(component_type, ComponentType.FULL)
walk["request_cnt"] = request_cnt
def next_node(component_type, tracker):
self.assertEqual(component_type, ComponentType.FULL)
if tracker[ComponentType.FULL] >= walk["request_cnt"] or walk[
"freed_leaves"
] >= len(full_leaves):
return None, False
return walk["freed_leaves"] + 1, True
def evict_leaf(node_id, tracker):
self.assertEqual(node_id, walk["freed_leaves"] + 1)
indices = full_leaves[-node_id]
tracker[ComponentType.FULL] += int(indices.numel())
allocator.free_segment(indices, start_pos=0)
walk["freed_leaves"] += 1
return None
tree_core.evict_device_start.side_effect = start
cache.tree_core = tree_core
cache._evict_device_next_node = MagicMock(side_effect=next_node)
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
return cache, walk
def test_mamba_slot_cost_is_in_the_same_units_as_available_size(self):
"""The planner charges `mamba_slot_full_token_cost()` against a budget
fed by `available_size()`. Both are bytes/entry_bytes conversions, so
@@ -3099,6 +3147,174 @@ class TestDcpWidening(unittest.TestCase):
# The un-scaled cost -- the bug -- would not have covered it.
self.assertLess(base_cost * full_entry, mamba_bytes * dcp_size)
def test_mamba_donor_recheck_bound_is_aggregate_and_dcp_widened(self):
missing_slots = 7
for dcp_size in (1, 2, 4):
with self.subTest(dcp_size=dcp_size), self._dcp(dcp_size):
allocator = self._build_composite(page_size=1)
allocator.mamba_allocator.schedulable_available_size = MagicMock(
return_value=3
)
bound = allocator.full_tokens_before_mamba_recheck(3 + missing_slots)
mamba_bytes = allocator.mamba_allocator.entry_bytes_per_page
full_bytes = allocator.full_attn_allocator.entry_bytes
minimum_missing_bytes = (missing_slots - 1) * mamba_bytes + 1
expected = -(-minimum_missing_bytes * dcp_size // full_bytes)
self.assertEqual(bound, expected)
self.assertLessEqual(
bound,
missing_slots * allocator.mamba_slot_full_token_cost(),
)
def test_allocation_flush_public_wrapper_is_urgent(self):
with self._dcp(1):
allocator = self._build_composite(page_size=1)
full = allocator.full_attn_allocator
full._flush = MagicMock(return_value=3)
self.assertEqual(full.flush_for_allocation(), 3)
full._flush.assert_called_once_with(urgent=True)
def test_full_donor_flushes_paged_free_group_without_closing_it(self):
with self._dcp(2):
allocator = self._build_composite(page_size=2)
full_indices = allocator.alloc(8)
self.assertIsNotNone(full_indices)
allocated_before = allocator.full_attn_allocator.allocated_count()
allocator.free_group_begin()
allocator.free_segment(full_indices, start_pos=0)
self.assertTrue(allocator.free_page_reps_group)
donor = allocator.mamba_full_cache_donor()
self.assertIsNotNone(donor)
donor.flush_deferred_full_frees()
self.assertEqual(allocator.free_group, [])
self.assertEqual(allocator.free_page_reps_group, [])
self.assertLess(
allocator.full_attn_allocator.allocated_count(), allocated_before
)
self.assertEqual(allocator.verify_byte_accounting(), [])
allocator.free_group_end()
def test_full_donor_reaches_real_capacity_across_supported_layouts(self):
cases = (
# Baseline eager layout.
(1, 1, False, False),
# Paged lazy layout with grouped frees.
(1, 4, True, True),
# DCP widens Full ids while Mamba remains one slot per request.
(2, 1, True, True),
(4, 1, False, False),
)
for dcp_size, page_size, lazy_compaction, grouped_free in cases:
with (
self.subTest(
dcp_size=dcp_size,
page_size=page_size,
lazy_compaction=lazy_compaction,
grouped_free=grouped_free,
),
self._dcp(dcp_size),
):
allocator = self._build_composite(
page_size=page_size,
lazy_compaction=lazy_compaction,
)
mamba_slots = UnifiedMambaSlotAllocator(
allocator.mamba_allocator,
max_size=allocator.size_mamba,
device=_DEV,
)
# Fill Full to its page-granular frontier, then consume any
# sub-Full-page byte residue with Mamba states. Mamba still
# owns unused virtual ids, but one more state has no backing
# bytes.
full_leaves = []
while True:
indices = allocator.alloc(allocator.page_size)
if indices is None:
break
full_leaves.append(indices)
self.assertTrue(full_leaves)
residual_mamba = mamba_slots.schedulable_available_size()
if residual_mamba:
self.assertIsNotNone(mamba_slots.alloc(residual_mamba))
self.assertGreater(mamba_slots.available_size(), 0)
self.assertEqual(mamba_slots.schedulable_available_size(), 0)
self.assertIsNone(mamba_slots.alloc(1))
gap_before = allocator.mamba_allocator._current_gap_bytes()
mamba_bytes = allocator.mamba_allocator.entry_bytes_per_page
full_page_bytes = allocator.full_attn_allocator.entry_bytes_per_page
expected_leaves = -(-(mamba_bytes - gap_before) // full_page_bytes)
self.assertGreater(expected_leaves, 0)
cache, walk = self._build_donor_cache(
allocator, mamba_slots, full_leaves
)
if grouped_free:
allocator.free_group_begin()
result = cache.evict_for_alloc(EvictParams(mamba_num=1))
self.assertEqual(walk["freed_leaves"], expected_leaves)
self.assertEqual(
result.num_tokens_evicted,
expected_leaves * allocator.page_size,
)
self.assertEqual(result.mamba_num_evicted, 0)
self.assertGreaterEqual(mamba_slots.schedulable_available_size(), 1)
allocated_mamba = mamba_slots.alloc(1)
self.assertIsNotNone(allocated_mamba)
self.assertEqual(allocator.verify_byte_accounting(), [])
if grouped_free:
self.assertEqual(allocator.free_group, [])
self.assertEqual(allocator.free_page_reps_group, [])
allocator.free_group_end()
def test_full_donor_batches_urgent_compaction_for_large_shortfall(self):
with self._dcp(1):
allocator = self._build_composite(
page_size=1,
lazy_compaction=True,
)
mamba_slots = UnifiedMambaSlotAllocator(
allocator.mamba_allocator,
max_size=allocator.size_mamba,
device=_DEV,
)
full_leaves = []
while True:
indices = allocator.alloc(1)
if indices is None:
break
full_leaves.append(indices)
residual_mamba = mamba_slots.schedulable_available_size()
if residual_mamba:
self.assertIsNotNone(mamba_slots.alloc(residual_mamba))
cache, walk = self._build_donor_cache(allocator, mamba_slots, full_leaves)
full = allocator.full_attn_allocator
original_flush = full.flush_for_allocation
full.flush_for_allocation = MagicMock(wraps=original_flush)
allocator.free_group_begin()
result = cache.evict_for_alloc(EvictParams(mamba_num=4))
self.assertGreater(walk["freed_leaves"], 1)
self.assertEqual(result.num_tokens_evicted, walk["freed_leaves"])
self.assertEqual(full.flush_for_allocation.call_count, 1)
self.assertGreaterEqual(mamba_slots.schedulable_available_size(), 4)
self.assertIsNotNone(mamba_slots.alloc(4))
self.assertEqual(allocator.verify_byte_accounting(), [])
allocator.free_group_end()
class TestFusedWriteLocTranslate(unittest.TestCase):
"""`write_loc_to_kernel_ids` must equal the arithmetic it stands for.
@@ -3,6 +3,14 @@
import unittest
from unittest.mock import MagicMock
import torch
from sglang.srt.mem_cache.allocator.unified_hybrid_swa import (
UnifiedMambaSWATokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.allocator.unified_mamba import (
UnifiedMambaTokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.base_prefix_cache import EvictParams
from sglang.srt.mem_cache.common import evict_from_tree_cache
from sglang.srt.mem_cache.unified_cache.components import ComponentType
@@ -27,6 +35,7 @@ class TestUnifiedRadixAllocationEviction(CustomTestCase):
capacity = {"available": 30}
allocator = MagicMock()
allocator.available_size.side_effect = lambda: capacity["available"]
allocator.mamba_full_cache_donor.return_value = None
cache.token_to_kv_pool_allocator = allocator
cache.req_to_token_pool = MagicMock()
@@ -50,6 +59,49 @@ class TestUnifiedRadixAllocationEviction(CustomTestCase):
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
return cache, capacity, leaf_count
@staticmethod
def _build_unified_mamba_donor_cache(
*, free_ids: int, byte_slots: int, tri_pool: bool = False
):
cache = object.__new__(UnifiedRadixCache)
cache.disable = False
cache.tree_components = (
(ComponentType.FULL, ComponentType.SWA, ComponentType.MAMBA)
if tri_pool
else (ComponentType.FULL, ComponentType.MAMBA)
)
cache.is_swa_enabled = tri_pool
cache.cache_controller = None
cache.metrics_collector = None
cache.tree_core = MagicMock()
cache.tree_core.full_evictable_size.return_value = 16
cache.tree_core.mamba_evictable_size.return_value = 8
capacity = {"free_ids": free_ids, "byte_slots": byte_slots}
allocator_cls = (
UnifiedMambaSWATokenToKVPoolAllocator
if tri_pool
else UnifiedMambaTokenToKVPoolAllocator
)
allocator = object.__new__(allocator_cls)
allocator.free_group = None
allocator.free_page_reps_group = None
if tri_pool:
allocator.full_free_group = []
allocator.full_attn_allocator = MagicMock()
allocator.full_attn_allocator.schedulable_available_size.return_value = 100
allocator.mamba_allocator = MagicMock()
allocator.mamba_allocator.available_size.side_effect = lambda: capacity[
"free_ids"
]
allocator.mamba_allocator.schedulable_available_size.side_effect = lambda: min(
capacity["free_ids"], capacity["byte_slots"]
)
allocator.full_tokens_before_mamba_recheck = MagicMock(return_value=1)
cache.token_to_kv_pool_allocator = allocator
cache.req_to_token_pool = MagicMock(mamba_allocator=allocator.mamba_allocator)
return cache, capacity, allocator
def test_allocation_eviction_stops_when_shared_capacity_is_sufficient(self):
cache, capacity, leaf_count = self._build_cache(collateral_capacity_gain=70)
@@ -91,6 +143,7 @@ class TestUnifiedRadixAllocationEviction(CustomTestCase):
cache.metrics_collector = None
cache.tree_core = MagicMock()
cache.token_to_kv_pool_allocator = MagicMock()
cache.token_to_kv_pool_allocator.mamba_full_cache_donor.return_value = None
capacity = {"available": 0}
mamba_allocator = MagicMock()
@@ -117,6 +170,387 @@ class TestUnifiedRadixAllocationEviction(CustomTestCase):
self.assertEqual(result.num_tokens_evicted, 20)
self.assertEqual(result.mamba_num_evicted, 1)
def test_mamba_allocation_uses_full_as_donor_for_byte_shortfall(self):
cache, capacity, _ = self._build_unified_mamba_donor_cache(
free_ids=2, byte_slots=1
)
def next_node(component_type, tracker):
if (
component_type == ComponentType.FULL
and tracker[ComponentType.FULL] < 16
):
return 1, True
return None, False
def evict_leaf(_node_id, tracker):
tracker[ComponentType.FULL] += 4
capacity["byte_slots"] += 1
return None
cache._evict_device_next_node = MagicMock(side_effect=next_node)
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
result = cache.evict_for_alloc(EvictParams(mamba_num=1))
self.assertEqual(capacity, {"free_ids": 2, "byte_slots": 2})
self.assertEqual(result.num_tokens_evicted, 4)
self.assertEqual(result.mamba_num_evicted, 0)
self.assertEqual(
cache.tree_core.evict_device_start.call_args_list,
[
unittest.mock.call(ComponentType.FULL, 16),
],
)
def test_mamba_allocation_recycles_mamba_for_id_shortfall(self):
cache, capacity, _ = self._build_unified_mamba_donor_cache(
free_ids=0, byte_slots=1
)
def next_node(component_type, tracker):
if (
component_type == ComponentType.MAMBA
and tracker[ComponentType.MAMBA] < 1
):
return 1, True
return None, False
def evict_leaf(_node_id, tracker):
tracker[ComponentType.MAMBA] += 1
capacity["free_ids"] += 1
capacity["byte_slots"] += 1
return None
cache._evict_device_next_node = MagicMock(side_effect=next_node)
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
result = cache.evict_for_alloc(EvictParams(mamba_num=1))
self.assertEqual(capacity, {"free_ids": 1, "byte_slots": 2})
self.assertEqual(result.num_tokens_evicted, 0)
self.assertEqual(result.mamba_num_evicted, 1)
cache.tree_core.evict_device_start.assert_called_once_with(
ComponentType.MAMBA, 1
)
def test_mamba_allocation_does_not_evict_full_while_id_bound(self):
cache, capacity, _ = self._build_unified_mamba_donor_cache(
free_ids=0, byte_slots=10
)
def next_node(component_type, tracker):
if component_type == ComponentType.FULL and tracker[ComponentType.FULL] < 4:
return 1, True
return None, False
def evict_leaf(_node_id, tracker):
tracker[ComponentType.FULL] += 4
capacity["byte_slots"] += 1
return None
cache._evict_device_next_node = MagicMock(side_effect=next_node)
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
result = cache.evict_for_alloc(EvictParams(mamba_num=1))
self.assertEqual(capacity, {"free_ids": 0, "byte_slots": 10})
self.assertEqual(result.num_tokens_evicted, 0)
self.assertEqual(result.mamba_num_evicted, 0)
cache.tree_core.evict_device_start.assert_called_once_with(
ComponentType.MAMBA, 1
)
def test_mamba_allocation_does_not_evict_full_after_partial_id_recovery(self):
cache, capacity, _ = self._build_unified_mamba_donor_cache(
free_ids=0, byte_slots=10
)
def next_node(component_type, tracker):
if (
component_type == ComponentType.MAMBA
and tracker[ComponentType.MAMBA] < 1
):
return 1, True
if component_type == ComponentType.FULL and tracker[ComponentType.FULL] < 4:
return 2, True
return None, False
def evict_leaf(node_id, tracker):
if node_id == 1:
tracker[ComponentType.MAMBA] += 1
capacity["free_ids"] += 1
capacity["byte_slots"] += 1
else:
tracker[ComponentType.FULL] += 4
capacity["byte_slots"] += 1
return None
cache._evict_device_next_node = MagicMock(side_effect=next_node)
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
result = cache.evict_for_alloc(EvictParams(mamba_num=2))
self.assertEqual(capacity, {"free_ids": 1, "byte_slots": 11})
self.assertEqual(result.num_tokens_evicted, 0)
self.assertEqual(result.mamba_num_evicted, 1)
cache.tree_core.evict_device_start.assert_called_once_with(
ComponentType.MAMBA, 2
)
def test_mamba_allocation_splits_mixed_id_and_byte_pressure(self):
cache, capacity, _ = self._build_unified_mamba_donor_cache(
free_ids=3, byte_slots=2
)
def next_node(component_type, tracker):
if (
component_type == ComponentType.MAMBA
and tracker[ComponentType.MAMBA] < 1
):
return 1, True
if component_type == ComponentType.FULL and tracker[ComponentType.FULL] < 4:
return 2, True
return None, False
def evict_leaf(node_id, tracker):
if node_id == 1:
tracker[ComponentType.MAMBA] += 1
capacity["free_ids"] += 1
capacity["byte_slots"] += 1
else:
tracker[ComponentType.FULL] += 4
capacity["byte_slots"] += 1
return None
cache._evict_device_next_node = MagicMock(side_effect=next_node)
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
result = cache.evict_for_alloc(EvictParams(mamba_num=2))
self.assertEqual(capacity, {"free_ids": 4, "byte_slots": 4})
self.assertEqual(result.num_tokens_evicted, 4)
self.assertEqual(result.mamba_num_evicted, 1)
self.assertEqual(
cache.tree_core.evict_device_start.call_args_list,
[
unittest.mock.call(ComponentType.MAMBA, 1),
unittest.mock.call(ComponentType.FULL, 16),
],
)
def test_full_donor_walk_continues_until_mamba_capacity_is_visible(self):
cache, capacity, _ = self._build_unified_mamba_donor_cache(
free_ids=2, byte_slots=1
)
leaf_count = 0
def next_node(component_type, tracker):
if (
component_type == ComponentType.FULL
and tracker[ComponentType.FULL] < 16
):
return tracker[ComponentType.FULL] // 4 + 1, True
return None, False
def evict_leaf(_node_id, tracker):
nonlocal leaf_count
leaf_count += 1
tracker[ComponentType.FULL] += 4
if leaf_count == 2:
capacity["byte_slots"] += 1
return None
cache._evict_device_next_node = MagicMock(side_effect=next_node)
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
result = cache.evict_for_alloc(EvictParams(mamba_num=1))
self.assertEqual(leaf_count, 2)
self.assertEqual(result.num_tokens_evicted, 8)
cache.tree_core.evict_device_start.assert_called_once_with(
ComponentType.FULL, 16
)
def test_full_donor_defers_preparation_until_safe_lower_bound(self):
cache, capacity, allocator = self._build_unified_mamba_donor_cache(
free_ids=2, byte_slots=1
)
leaf_count = 0
allocator.full_tokens_before_mamba_recheck.return_value = 8
def prepare(_target_size):
if leaf_count >= 2:
capacity["byte_slots"] = 2
allocator.prepare_mamba_allocation = MagicMock(side_effect=prepare)
cache._evict_device_next_node = MagicMock(return_value=(1, True))
def evict_leaf(_node_id, tracker):
nonlocal leaf_count
leaf_count += 1
tracker[ComponentType.FULL] += 4
return None
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
result = cache.evict_for_alloc(EvictParams(mamba_num=1))
self.assertEqual(leaf_count, 2)
self.assertEqual(result.num_tokens_evicted, 8)
# One initial layout preparation plus one check at the lower bound.
self.assertEqual(allocator.prepare_mamba_allocation.call_count, 2)
def test_full_donor_observes_cascade_before_lower_bound(self):
cache, capacity, allocator = self._build_unified_mamba_donor_cache(
free_ids=2, byte_slots=1
)
allocator.full_tokens_before_mamba_recheck.return_value = 100
allocator.prepare_mamba_allocation = MagicMock()
cache._evict_device_next_node = MagicMock(return_value=(1, True))
def evict_leaf(_node_id, tracker):
tracker[ComponentType.FULL] += 4
tracker[ComponentType.MAMBA] += 1
capacity["byte_slots"] = 2
return None
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
result = cache.evict_for_alloc(EvictParams(mamba_num=1))
self.assertEqual(result.num_tokens_evicted, 4)
self.assertEqual(result.mamba_num_evicted, 1)
# Only the pre-donor preparation runs; the cheap capacity check stops
# before the Full-byte lower bound after the Mamba cascade is visible.
allocator.prepare_mamba_allocation.assert_called_once()
def test_full_donor_rechecks_each_leaf_after_lower_bound(self):
cache, capacity, allocator = self._build_unified_mamba_donor_cache(
free_ids=2, byte_slots=1
)
leaf_count = 0
allocator.full_tokens_before_mamba_recheck.return_value = 8
def prepare(_target_size):
if leaf_count >= 3:
capacity["byte_slots"] = 2
allocator.prepare_mamba_allocation = MagicMock(side_effect=prepare)
cache._evict_device_next_node = MagicMock(return_value=(1, True))
def evict_leaf(_node_id, tracker):
nonlocal leaf_count
leaf_count += 1
tracker[ComponentType.FULL] += 4
return None
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
result = cache.evict_for_alloc(EvictParams(mamba_num=1))
self.assertEqual(leaf_count, 3)
self.assertEqual(result.num_tokens_evicted, 12)
# The failed lower-bound check falls back to leaf-granular checks.
self.assertEqual(allocator.prepare_mamba_allocation.call_count, 3)
def test_mamba_cache_is_last_resort_when_full_donor_is_exhausted(self):
cache, capacity, _ = self._build_unified_mamba_donor_cache(
free_ids=2, byte_slots=0
)
cache.tree_core.full_evictable_size.return_value = 4
def next_node(component_type, tracker):
if component_type == ComponentType.FULL and tracker[component_type] < 4:
return 1, True
if component_type == ComponentType.MAMBA and tracker[component_type] < 8:
return 2, True
return None, False
def evict_leaf(node_id, tracker):
if node_id == 1:
tracker[ComponentType.FULL] += 4
else:
tracker[ComponentType.MAMBA] += 1
capacity["free_ids"] += 1
capacity["byte_slots"] += 1
return None
cache._evict_device_next_node = MagicMock(side_effect=next_node)
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
result = cache.evict_for_alloc(EvictParams(mamba_num=1))
self.assertEqual(capacity, {"free_ids": 3, "byte_slots": 1})
self.assertEqual(result.num_tokens_evicted, 4)
self.assertEqual(result.mamba_num_evicted, 1)
self.assertEqual(
cache.tree_core.evict_device_start.call_args_list,
[
unittest.mock.call(ComponentType.FULL, 4),
unittest.mock.call(ComponentType.MAMBA, 8),
],
)
def test_full_donor_flushes_grouped_frees_before_capacity_recheck(self):
cache, capacity, allocator = self._build_unified_mamba_donor_cache(
free_ids=2, byte_slots=1
)
allocator.free_group_begin()
allocator.full_attn_allocator.free.side_effect = lambda _indices: (
capacity.update(byte_slots=capacity["byte_slots"] + 1)
)
allocator.mamba_allocator.alloc.side_effect = lambda need_size: (
torch.arange(need_size)
if min(capacity["free_ids"], capacity["byte_slots"]) >= need_size
else None
)
cache._evict_device_next_node = MagicMock(return_value=(1, True))
def evict_leaf(_node_id, tracker):
tracker[ComponentType.FULL] += 4
allocator.free(torch.tensor([1], dtype=torch.int64))
return None
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
result = cache.evict_for_alloc(EvictParams(mamba_num=1))
self.assertEqual(capacity["byte_slots"], 2)
self.assertEqual(result.num_tokens_evicted, 4)
self.assertEqual(allocator.free_group, [])
self.assertEqual(allocator.free_page_reps_group, [])
allocator.full_attn_allocator.free.assert_called_once()
self.assertIsNotNone(allocator.mamba_allocator.alloc(2))
allocator.free_group_end()
def test_tri_pool_uses_the_same_full_donor_capability(self):
cache, capacity, allocator = self._build_unified_mamba_donor_cache(
free_ids=2, byte_slots=1, tri_pool=True
)
cache._evict_device_next_node = MagicMock(return_value=(1, True))
def evict_leaf(_node_id, tracker):
tracker[ComponentType.FULL] += 4
capacity["byte_slots"] += 1
return None
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
result = cache.evict_for_alloc(EvictParams(mamba_num=1))
self.assertIs(allocator.mamba_full_cache_donor(), allocator)
self.assertEqual(capacity["byte_slots"], 2)
self.assertEqual(result.num_tokens_evicted, 4)
self.assertEqual(result.mamba_num_evicted, 0)
cache.tree_core.evict_device_start.assert_called_once_with(
ComponentType.FULL, 16
)
def test_common_helper_uses_allocation_aware_entry_point(self):
tree_cache = MagicMock()
tree_cache.is_chunk_cache.return_value = False
@@ -20,6 +20,7 @@ Pure CPU; fakes stand in for the KV pools (data markers verify moves).
import inspect
import unittest
from unittest.mock import MagicMock
import torch
@@ -30,6 +31,8 @@ from sglang.srt.mem_cache.allocator.unified_sub_pool import (
FloatMultiEndedAllocator,
MultiEndedAllocator,
)
from sglang.srt.mem_cache.base_prefix_cache import EvictParams
from sglang.srt.mem_cache.unified_cache.components import ComponentType
from sglang.srt.mem_cache.unified_memory_pool import (
MambaSubPoolSpec,
MHASubPoolSpec,
@@ -37,6 +40,7 @@ from sglang.srt.mem_cache.unified_memory_pool import (
UnifiedMambaSlotAllocator,
init_unified_mamba_swa_pools,
)
from sglang.srt.mem_cache.unified_radix_cache import UnifiedRadixCache
from sglang.test.ci.ci_register import register_cpu_ci
# Hermetic convention of this directory's pool tests: plain unittest.TestCase,
@@ -113,6 +117,7 @@ class TestUnifiedTriPool(unittest.TestCase):
n_full=32,
n_swa=16,
n_state=8,
page_size=1,
lazy_compaction=False,
):
full, swa, mamba = _tri_specs()
@@ -126,6 +131,7 @@ class TestUnifiedTriPool(unittest.TestCase):
sub_pool_specs=[full, swa, mamba],
device=_DEV,
enable_memory_saver=False,
page_size=page_size,
)
kvcache = _FakeUnifiedSWAKVPool(pool)
mamba_kv = _FakeKVCache(pool.max_slots("mamba"))
@@ -136,6 +142,7 @@ class TestUnifiedTriPool(unittest.TestCase):
device=_DEV,
full_max_total_num_tokens=n_full,
swa_max_total_num_tokens=n_swa,
page_size=page_size,
need_sort=False,
forward_stream=None,
lazy_compaction=lazy_compaction,
@@ -313,6 +320,90 @@ class TestUnifiedTriPool(unittest.TestCase):
_relieve_for_alloc(allocator, 1)
self.assertEqual(sa._hole_pages(), holes) # holes are assets, not backlog
def test_full_donor_stops_after_float_exposes_mamba_capacity(self):
for page_size, lazy_compaction in ((1, False), (4, True)):
with self.subTest(page_size=page_size, lazy_compaction=lazy_compaction):
_, allocator, _, _ = self._build(
page_size=page_size, lazy_compaction=lazy_compaction
)
mamba_slots = UnifiedMambaSlotAllocator(
allocator.mamba_allocator,
max_size=allocator.mamba_allocator.max_slots - 1,
device=_DEV,
)
full_leaves = []
while True:
indices = allocator.alloc(allocator.page_size)
if indices is None:
break
full_leaves.append(indices)
self.assertTrue(full_leaves)
residual_mamba = mamba_slots.schedulable_available_size()
if residual_mamba:
self.assertIsNotNone(mamba_slots.alloc(residual_mamba))
while mamba_slots.alloc(1) is not None:
pass
self.assertGreater(mamba_slots.available_size(), 0)
self.assertEqual(mamba_slots.schedulable_available_size(), 0)
cache = object.__new__(UnifiedRadixCache)
cache.disable = False
cache.tree_components = (
ComponentType.FULL,
ComponentType.SWA,
ComponentType.MAMBA,
)
cache.is_swa_enabled = True
cache.cache_controller = None
cache.metrics_collector = None
cache.token_to_kv_pool_allocator = allocator
cache.req_to_token_pool = MagicMock(mamba_allocator=mamba_slots)
tree_core = MagicMock()
tree_core.full_evictable_size.return_value = len(full_leaves)
tree_core.mamba_evictable_size.return_value = 0
walk = {"request_cnt": 0, "freed_leaves": 0}
def start(component_type, request_cnt):
self.assertEqual(component_type, ComponentType.FULL)
walk["request_cnt"] = request_cnt
def next_node(component_type, tracker):
self.assertEqual(component_type, ComponentType.FULL)
if tracker[ComponentType.FULL] >= walk["request_cnt"] or walk[
"freed_leaves"
] >= len(full_leaves):
return None, False
return walk["freed_leaves"] + 1, True
def evict_leaf(node_id, tracker):
self.assertEqual(node_id, walk["freed_leaves"] + 1)
indices = full_leaves[-node_id]
tracker[ComponentType.FULL] += int(indices.numel())
allocator.full_attn_allocator.free(indices)
walk["freed_leaves"] += 1
return None
tree_core.evict_device_start.side_effect = start
cache.tree_core = tree_core
cache._evict_device_next_node = MagicMock(side_effect=next_node)
cache._evict_device_leaf = MagicMock(side_effect=evict_leaf)
swa_live_before = allocator.swa_attn_allocator._live_pages()
result = cache.evict_for_alloc(EvictParams(mamba_num=1))
self.assertEqual(walk["freed_leaves"], 1)
self.assertEqual(result.num_tokens_evicted, page_size)
self.assertEqual(result.swa_num_tokens_evicted, 0)
self.assertEqual(result.mamba_num_evicted, 0)
self.assertEqual(
allocator.swa_attn_allocator._live_pages(), swa_live_before
)
self.assertGreaterEqual(mamba_slots.schedulable_available_size(), 1)
self.assertIsNotNone(mamba_slots.alloc(1))
self.assertEqual(allocator.verify_byte_accounting(), [])
class TestTriPagedFreeGroup(unittest.TestCase):
"""The tri composite at PAGE SIZE > 1, driven through the production free
@@ -370,6 +461,30 @@ class TestTriPagedFreeGroup(unittest.TestCase):
# Capacity fully recovered: the float parked, both ends rewound.
self.assertTrue(allocator.swa_attn_allocator._is_frontier_transparent())
def test_mamba_donor_flushes_full_only_group_without_closing_it(self):
_, allocator = self._build_paged(page_size=1)
full_indices = allocator.alloc(8)
self.assertIsNotNone(full_indices)
allocator.free_swa(full_indices)
allocated_before = allocator.full_attn_allocator.allocated_count()
allocator.free_group_begin()
allocator.free_full_segment(full_indices, start_pos=0)
self.assertTrue(allocator.full_free_group)
donor = allocator.mamba_full_cache_donor()
self.assertIsNotNone(donor)
donor.flush_deferred_full_frees()
self.assertEqual(allocator.free_group, [])
self.assertEqual(allocator.free_page_reps_group, [])
self.assertEqual(allocator.full_free_group, [])
self.assertLess(
allocator.full_attn_allocator.allocated_count(), allocated_before
)
self.assertEqual(allocator.verify_byte_accounting(), [])
allocator.free_group_end()
def test_ungrouped_segment_free_also_reaches_the_float(self):
pool, allocator = self._build_paged()
v = allocator.alloc(8)