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
+19 -1
View File
@@ -16,7 +16,7 @@ limitations under the License.
from __future__ import annotations
import abc
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Protocol
import torch
@@ -24,6 +24,20 @@ if TYPE_CHECKING:
from sglang.srt.mem_cache.memory_pool import KVCache
class MambaFullCacheDonor(Protocol):
"""Allocator capability for reclaiming Full KV on Mamba byte pressure."""
def flush_deferred_full_frees(self) -> None: ...
def full_tokens_before_mamba_recheck(self, target_size: int) -> int:
"""Lower bound on new Full tokens before preparation can help."""
...
def prepare_mamba_allocation(self, target_size: int) -> None:
"""Expose layout-specific reclaim so Mamba capacity is queryable."""
...
class BaseTokenToKVPoolAllocator(abc.ABC):
@abc.abstractmethod
def __init__(
@@ -74,6 +88,10 @@ class BaseTokenToKVPoolAllocator(abc.ABC):
violation strings, empty when healthy. Static pools have no byte model."""
return []
def mamba_full_cache_donor(self) -> MambaFullCacheDonor | None:
"""Return the shared-pool donor capability, if this allocator has one."""
return None
def debug_print(self) -> str:
return ""
@@ -23,6 +23,7 @@ import torch
from torch.profiler import record_function
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.allocator.base import MambaFullCacheDonor
from sglang.srt.mem_cache.allocator.swa import SWATokenToKVPoolAllocator
from sglang.srt.mem_cache.allocator.unified_sub_pool import (
FloatMultiEndedAllocator,
@@ -30,6 +31,8 @@ from sglang.srt.mem_cache.allocator.unified_sub_pool import (
_chain_byte_accounting_violations,
_end_pair_chain,
_float_open_short_side,
_flush_deferred_free_group,
_full_tokens_before_mamba_recheck,
_relieve_for_alloc,
)
from sglang.srt.mem_cache.unified_memory_pool import UnifiedKVPool
@@ -845,6 +848,29 @@ class UnifiedMambaSWATokenToKVPoolAllocator(UnifiedSWATokenToKVPoolAllocator):
flt = b
_float_open_short_side(flt, demand)
def mamba_full_cache_donor(self) -> MambaFullCacheDonor:
return self
def flush_deferred_full_frees(self) -> None:
"""Expose grouped composite frees while preserving the group scope."""
_flush_deferred_free_group(
self,
(self.free_group, self.free_page_reps_group, self.full_free_group),
)
def full_tokens_before_mamba_recheck(self, target_size: int) -> int:
return _full_tokens_before_mamba_recheck(
self.full_attn_allocator, self.mamba_allocator, target_size
)
def prepare_mamba_allocation(self, target_size: int) -> None:
"""Expose Full reclaim, then move the SWA float away from Mamba."""
self.flush_deferred_full_frees()
if target_size <= self.mamba_allocator.available_size():
return
self.full_attn_allocator.flush_for_allocation()
_relieve_for_alloc(self.mamba_allocator, target_size)
def mamba_slot_full_token_cost(self) -> int:
"""Full-token-equivalents one mamba/conv slot removes from the shared buffer:
a tri-pool token costs e_f + e_s bytes, and the quotient is rounded UP."""
@@ -23,10 +23,14 @@ import torch
from torch.profiler import record_function
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.allocator.base import MambaFullCacheDonor
from sglang.srt.mem_cache.allocator.unified_sub_pool import (
MultiEndedAllocator,
_chain_byte_accounting_violations,
_end_pair_chain,
_flush_deferred_free_group,
_full_tokens_before_mamba_recheck,
_relieve_for_alloc,
)
from sglang.srt.mem_cache.unified_memory_pool import UnifiedKVPool
from sglang.srt.runtime_context import get_parallel
@@ -146,6 +150,26 @@ class UnifiedMambaTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
def full_available_size(self) -> int:
return self.full_attn_allocator.schedulable_available_size()
def mamba_full_cache_donor(self) -> MambaFullCacheDonor:
return self
def flush_deferred_full_frees(self) -> None:
"""Expose grouped Full frees without ending the caller's free group."""
_flush_deferred_free_group(self, (self.free_group, self.free_page_reps_group))
def full_tokens_before_mamba_recheck(self, target_size: int) -> int:
return _full_tokens_before_mamba_recheck(
self.full_attn_allocator, self.mamba_allocator, target_size
)
def prepare_mamba_allocation(self, target_size: int) -> None:
"""Make deferred Full reclaim visible to the Mamba capacity view."""
self.flush_deferred_full_frees()
if target_size > self.mamba_allocator.schedulable_available_size():
return
if target_size > self.mamba_allocator.available_size():
_relieve_for_alloc(self.mamba_allocator, target_size)
def mamba_slot_full_token_cost(self) -> int:
"""Full-token-equivalents of shared-gap bytes ONE mamba state consumes; the
prefill planner reserves this so admission stays inside the JOINT budget,
@@ -31,6 +31,7 @@ from typing import (
Generic,
List,
Optional,
Sequence,
Set,
Tuple,
TypeVar,
@@ -194,18 +195,50 @@ def _float_open_short_side(flt, demand) -> None:
def _relieve_for_alloc(short_pool, need_tokens: int) -> bool:
"""THE shortfall ladder: every allocation shortfall in the unified pool runs
exactly this, whether a single band's own alloc or a composite's coupled
multi-band alloc. `_flush` is called unconditionally -- an eager END no-ops
multi-band alloc. The urgent flush is called unconditionally -- an eager END no-ops
and a FLOAT always has boundary absorption to do -- so the ladder never
branches on lazy mode, member kind, or layout.
"""
for m in short_pool._flush_targets():
m._flush(urgent=True)
m.flush_for_allocation()
if need_tokens <= short_pool.available_size():
return True
short_pool._ask_float_for_room(need_tokens)
return need_tokens <= short_pool.available_size()
def _flush_deferred_free_group(
allocator: BaseTokenToKVPoolAllocator,
pending_groups: Sequence[Optional[Sequence[torch.Tensor]]],
) -> None:
"""Apply queued frees and reopen the caller's free-group scope."""
if allocator.free_group is None or not any(pending_groups):
return
allocator.free_group_end()
allocator.free_group_begin()
def _full_tokens_before_mamba_recheck(
full_allocator: MultiEndedAllocator,
mamba_allocator: MultiEndedAllocator,
target_size: int,
) -> int:
"""Conservative Full-token lower bound for the next Mamba capacity check.
The current Mamba slot count can hide at most one slot minus one byte of
residual room. Subtract that possible residue so this estimate only skips
checks that cannot succeed from Full bytes alone. Allocator capacity remains
the stop condition after the bound is crossed.
"""
missing_slots = max(0, target_size - mamba_allocator.schedulable_available_size())
if missing_slots == 0:
return 0
mamba_page_bytes = mamba_allocator.entry_bytes_per_page
minimum_missing_bytes = (missing_slots - 1) * mamba_page_bytes + 1
dcp_size = get_parallel().attn_dcp_size if full_allocator.shards_under_dcp else 1
return -(-minimum_missing_bytes * dcp_size // full_allocator.entry_bytes)
class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
"""Allocator for one sub-pool over a `UnifiedKVPool`."""
@@ -1919,6 +1952,11 @@ class MultiEndedAllocator(BaseTokenToKVPoolAllocator):
self._pending_reuse[latest_event] = (srcs_copy, src_pages_t)
self._pending_reuse_pages_cpu.update(srcs_copy)
def flush_for_allocation(self) -> int:
"""Public urgent flush used by peer allocation-pressure recovery."""
with record_function("MultiEndedAlloc.flush_for_allocation"):
return self._flush(urgent=True)
def flush_opportunistic(self) -> int:
"""Public, non-urgent flush at quiescent points; never blocks
`schedule_stream`. Fast-path the empty state: the scheduler triggers this
@@ -577,11 +577,62 @@ class UnifiedRadixCache(BasePrefixCache):
request_by_type = self._evict_request_by_type(params)
available_size_targets = {
ct: self._component_available_size(ct) + request_cnt
ct: (ct, self._component_available_size(ct) + request_cnt)
for ct, request_cnt in request_by_type.items()
if request_cnt > 0
}
return self._evict(params, available_size_targets)
allocator = self.token_to_kv_pool_allocator
mamba_full_donor = allocator.mamba_full_cache_donor()
mamba_target = available_size_targets.get(ComponentType.MAMBA)
initial_params = params
if mamba_target is not None and mamba_full_donor is not None:
# Full KV can supply bytes but cannot recycle Mamba virtual IDs.
mamba_id_shortfall = max(
0,
mamba_target[1]
- self.req_to_token_pool.mamba_allocator.available_size(),
)
initial_params = EvictParams(
num_tokens=params.num_tokens,
swa_num_tokens=params.swa_num_tokens,
mamba_num=mamba_id_shortfall,
)
result = self._evict(initial_params, available_size_targets)
if mamba_target is not None and mamba_full_donor is not None:
mamba_full_donor.prepare_mamba_allocation(mamba_target[1])
mamba_free_ids = self.req_to_token_pool.mamba_allocator.available_size()
mamba_capacity = self._component_available_size(ComponentType.MAMBA)
if mamba_free_ids >= mamba_target[1] and mamba_capacity < mamba_target[1]:
full_evictable = self.full_evictable_size()
if full_evictable > 0:
donor_result = self._evict(
EvictParams(num_tokens=full_evictable),
{ComponentType.FULL: mamba_target},
)
result.num_tokens_evicted += donor_result.num_tokens_evicted
result.swa_num_tokens_evicted += donor_result.swa_num_tokens_evicted
result.mamba_num_evicted += donor_result.mamba_num_evicted
# Preserve Mamba-victim recovery if Full cannot fund the target.
if (
self._component_available_size(ComponentType.MAMBA)
< mamba_target[1]
):
mamba_evictable = self.mamba_evictable_size()
if mamba_evictable > 0:
fallback_result = self._evict(
EvictParams(mamba_num=mamba_evictable),
{ComponentType.MAMBA: mamba_target},
)
result.num_tokens_evicted += fallback_result.num_tokens_evicted
result.swa_num_tokens_evicted += (
fallback_result.swa_num_tokens_evicted
)
result.mamba_num_evicted += fallback_result.mamba_num_evicted
return result
@staticmethod
def _evict_request_by_type(params: EvictParams) -> dict[ComponentType, int]:
@@ -611,7 +662,9 @@ class UnifiedRadixCache(BasePrefixCache):
def _evict(
self,
params: EvictParams,
available_size_targets: Optional[dict[ComponentType, int]] = None,
available_size_targets: Optional[
dict[ComponentType, tuple[ComponentType, int]]
] = None,
) -> EvictResult:
if self.disable:
return EvictResult()
@@ -712,22 +765,45 @@ class UnifiedRadixCache(BasePrefixCache):
self,
request_by_type: dict[ComponentType, int],
tracker: dict[ComponentType, int],
available_size_targets: Optional[dict[ComponentType, int]] = None,
available_size_targets: Optional[
dict[ComponentType, tuple[ComponentType, int]]
] = None,
) -> None:
# Buffer mode: eviction always wins over queued backup intents — a
# destroyed victim's intent is stale-swept and the content rewrites
# after its recompute.
last_mamba_donor_check = 0
mamba_donor_prepared = False
def target_reached(component_type: ComponentType) -> bool:
nonlocal last_mamba_donor_check, mamba_donor_prepared
if available_size_targets is None:
return False
target = available_size_targets.get(component_type)
# Do not compact on every eviction step. Shared allocators include
# drainable peer holes here and flush the peer once in alloc().
return (
target is not None
and self._component_available_size(component_type) >= target
)
if target is None:
return False
target_component, target_size = target
# A Full-leaf cascade can release Mamba or SWA state directly.
if self._component_available_size(target_component) >= target_size:
return True
if (
component_type == ComponentType.FULL
and target_component == ComponentType.MAMBA
):
donor = self.token_to_kv_pool_allocator.mamba_full_cache_donor()
assert donor is not None, "Mamba target requires a Full donor"
recheck_after = (
1
if mamba_donor_prepared
else donor.full_tokens_before_mamba_recheck(target_size)
)
if tracker[component_type] - last_mamba_donor_check < recheck_after:
return False
donor.prepare_mamba_allocation(target_size)
last_mamba_donor_check = tracker[component_type]
mamba_donor_prepared = True
# Schedulable capacity includes donor holes that allocation can compact.
return self._component_available_size(target_component) >= target_size
for ct in self.tree_components:
request_cnt = request_by_type[ct]
@@ -737,16 +813,14 @@ class UnifiedRadixCache(BasePrefixCache):
continue
self.tree_core.evict_device_start(ct, request_cnt)
try:
while not target_reached(ct):
while True:
node_id, made_progress = self._evict_device_next_node(ct, tracker)
if node_id is None:
if made_progress:
# Internal tombstone frees are now allocator-visible;
# recheck the allocation target before walking again.
continue
break
backup_kv = self._evict_device_leaf(node_id, tracker)
if backup_kv is not None:
if not made_progress:
break
else:
backup_kv = self._evict_device_leaf(node_id, tracker)
if node_id is not None and backup_kv is not None:
# Deferred demote: run the D->H backup, demote only on success.
written = self._execute_and_commit_kv_backup(
backup_kv, write_back=True
@@ -768,6 +842,8 @@ class UnifiedRadixCache(BasePrefixCache):
"until host space frees",
node_id,
)
if target_reached(ct):
break
finally:
self.tree_core.evict_device_end(ct)
@@ -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)