[mem_cache] Add free_full to release the full side of a tombstoned SWA node (#36637)

This commit is contained in:
Liangsheng Yin
2026-08-27 19:34:48 -07:00
committed by GitHub
parent b7686e17d6
commit daf6317196
12 changed files with 190 additions and 20 deletions
@@ -123,6 +123,14 @@ class BaseTokenToKVPoolAllocator(abc.ABC):
def free(self, free_index: torch.Tensor):
raise NotImplementedError()
def free_full(self, free_index: torch.Tensor):
"""Free slots whose SWA peers the caller already released.
A hybrid SWA allocator pairs each full-attention slot with an SWA slot
that can die first; this releases the full side alone. A single pool has
no peer, so it is a plain free()."""
self.free(free_index)
def free_segment(self, free_index: torch.Tensor, *, start_pos: int):
"""Free ``kv_row[start_pos : start_pos + n]`` of one request (or a
page-aligned copy); subclasses may use ``start_pos`` to skip the
@@ -316,6 +316,7 @@ class DeepSeekV4HiSparseTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
self.free_pages = None
self.release_pages = None
self.free_group = None
self.full_free_group = []
self.clear()
self.hisparse_kvcache.register_mapping(
@@ -364,6 +365,15 @@ class DeepSeekV4HiSparseTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
def free_swa(self, free_indices: torch.Tensor):
self.logical_attn_allocator.free_swa(free_indices)
def free_full(self, free_indices: torch.Tensor):
if free_indices.numel() == 0:
return
if self.free_group is None:
self.logical_attn_allocator.free_full(free_indices)
else:
self.full_free_group.append(self._copy_for_free_group(free_indices))
def available_size(self) -> int:
return min(
self.logical_attn_allocator.available_size(),
@@ -567,6 +577,7 @@ class DeepSeekV4HiSparseTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
self.full_to_hisparse_device_index_mapping[:-1].fill_(0)
self.free_group = None
self.full_free_group = []
def free(self, free_index: torch.Tensor):
if free_index.numel() == 0:
@@ -576,3 +587,14 @@ class DeepSeekV4HiSparseTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
self.logical_attn_allocator.free(free_index)
else:
self.free_group.append(self._copy_for_free_group(free_index))
def free_group_begin(self):
super().free_group_begin()
self.full_free_group = []
def free_group_end(self):
super().free_group_end()
if self.full_free_group:
full_free_group = self.full_free_group
self.full_free_group = []
self.free_full(torch.cat(full_free_group))
+28 -2
View File
@@ -95,6 +95,7 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
self.release_pages = None
self.free_group = None
self.swa_free_group = []
self.full_free_group = []
self._kvcache = kvcache
self.clear()
@@ -370,9 +371,24 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
self.swa_attn_allocator.free(swa_indices)
def free_full(self, free_index: torch.Tensor):
if free_index.numel() == 0:
return
if self.free_group is None:
# Full side only: a tombstoned range's mapping entries read as the
# padding slot, so `free` would push slot 0 into the SWA free list.
self.full_attn_allocator.free(free_index)
else:
self.full_free_group.append(self._copy_for_free_group(free_index))
assert (
self.full_attn_allocator.available_size() <= self.full_attn_allocator.size
)
def free_group_begin(self):
super().free_group_begin()
self.swa_free_group = []
self.full_free_group = []
def free_group_end(self):
super().free_group_end()
@@ -380,6 +396,10 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
swa_free_group = self.swa_free_group
self.swa_free_group = []
self.swa_attn_allocator.free(torch.cat(swa_free_group))
if self.full_free_group:
full_free_group = self.full_free_group
self.full_free_group = []
self.free_full(torch.cat(full_free_group))
def _expand_to_full_pages(self, indices: torch.Tensor) -> torch.Tensor:
pages = torch.unique(indices // self.page_size)
@@ -409,6 +429,7 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
self.full_to_swa_index_mapping[:-1].fill_(0)
self.free_group = None
self.swa_free_group = []
self.full_free_group = []
def get_cpu_copy(self, indices, mamba_indices=None):
return self._kvcache.get_cpu_copy(indices, mamba_indices=mamba_indices)
@@ -516,8 +537,13 @@ class PureSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
else:
self.free_group.append(self._copy_for_free_group(free_index))
# Not inherited: the SWA parent's hooks drive swa_free_group,
# which this pure-SWA variant does not have.
def free_full(self, free_index: torch.Tensor):
# All-SWA models have no full-attention pool, so there is nothing to
# release once the SWA side is gone.
return
# Not inherited: the SWA parent's hooks drive swa_free_group and
# full_free_group, which this pure-SWA variant does not have.
def free_group_begin(self):
self.free_group = []
@@ -2448,6 +2448,17 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
self.swa_attn_allocator.free(live)
self.swa_attn_allocator.clear_inverse_history()
def free_full(self, free_index: torch.Tensor) -> None:
"""Release the full-physical page and the virtual id, leaving the swa
side alone -- the caller already tombstoned it (`swa.v2p_page == -1`)."""
if free_index is None or free_index.numel() == 0:
return
if self.free_group is not None:
self.full_free_group.append(self._copy_for_free_group(free_index))
return
self.full_attn_allocator.free(free_index.detach().to(torch.int64))
self.full_attn_allocator.clear_inverse_history()
def set_full_to_swa_mapping(
self, full_indices: torch.Tensor, swa_indices: torch.Tensor
) -> None:
+24 -11
View File
@@ -591,6 +591,21 @@ class SWARadixCache(BasePrefixCache):
def total_size(self) -> Tuple[int, int]:
return self._total_size_helper()
def _free_node_value(
self, node: TreeNode, value: Optional[torch.Tensor] = None
) -> Tuple[int, int]:
if value is None:
value = node.value
num_tokens = len(value)
if node.swa_tombstone:
# SWA peers went back in `dec_swa_lock_only` or an SWA evict, so
# only the full side is still ours; `free` would hand the SWA pool
# mapping entries that read as the padding slot.
self.token_to_kv_pool_allocator.free_full(value)
return num_tokens, 0
self.token_to_kv_pool_allocator.free(value)
return num_tokens, num_tokens
def evict(self, params: EvictParams) -> EvictResult:
if self.disable:
return EvictResult()
@@ -611,11 +626,9 @@ class SWARadixCache(BasePrefixCache):
# 1. free node kv indices, evict full and swa tokens
self.kv_events.record_remove(x)
self.token_to_kv_pool_allocator.free(x.value)
full_num_evicted += len(x.value)
# Tombstoned leaves had their SWA freed earlier in `dec_swa_lock_only`
if not x.swa_tombstone:
swa_num_evicted += len(x.value)
node_full_evicted, node_swa_evicted = self._free_node_value(x)
full_num_evicted += node_full_evicted
swa_num_evicted += node_swa_evicted
# 2. get the next leaf, update the lru lists
x_next = self.full_lru_list.get_prev_leaf_no_lock(x)
@@ -676,9 +689,9 @@ class SWARadixCache(BasePrefixCache):
), f"leaf node with full lock must also have swa lock, {x.id=}"
# 1. a leaf node, free full and swa tokens
self.kv_events.record_remove(x)
self.token_to_kv_pool_allocator.free(x.value)
full_num_evicted += len(x.value)
swa_num_evicted += len(x.value)
node_full_evicted, node_swa_evicted = self._free_node_value(x)
full_num_evicted += node_full_evicted
swa_num_evicted += node_swa_evicted
# 2. get the next node, update the lru lists
x_next = self.swa_lru_list.get_prev_no_lock(x)
@@ -1302,7 +1315,7 @@ class SWARadixCache(BasePrefixCache):
swa_value = allocator.translate_loc_from_full_to_swa(incoming_full)
allocator.set_full_to_swa_mapping(node.value, swa_value)
allocator.clear_full_to_swa_mapping(incoming_full)
allocator.full_attn_allocator.free(incoming_full)
allocator.free_full(incoming_full)
node.swa_tombstone = False
self.swa_lru_list.insert_mru(node)
@@ -1346,8 +1359,8 @@ class SWARadixCache(BasePrefixCache):
), f"tombstone swa_lock_ref should always be 0, {node.parent.full_lock_ref=}, {node.parent.swa_lock_ref=}, {node.parent.id=}"
# delete tombstone node evicts full tokens
self.kv_events.record_remove(node.parent)
self.token_to_kv_pool_allocator.free(node.parent.value)
full_num_evicted += len(node.parent.value)
node_full_evicted, _ = self._free_node_value(node.parent)
full_num_evicted += node_full_evicted
self.full_lru_list.remove_node(node.parent)
self._delete_tombstone_leaf(node.parent)
node = node.parent
@@ -36,6 +36,13 @@ class FreeDeviceKV(msgspec.Struct, frozen=True):
indices: list[torch.Tensor]
class FreeDeviceKVFullOnly(msgspec.Struct, frozen=True):
"""Free the full side only, for a tombstoned node whose SWA peers are gone;
FreeDeviceKV would release the SWA side twice."""
indices: list[torch.Tensor]
class ComponentAction(msgspec.Struct, frozen=True):
"""Base for component-routed actions; the cache dispatches each one to
``component_type``'s class-level ``apply_component_action``; every subclass
@@ -100,4 +107,6 @@ class SWARebuild(ComponentAction, frozen=True):
# Cache-owned actions, applied by UnifiedRadixCache itself.
CacheAction = ReplaceWriteThroughOnNodeSplit | FreeDeviceKV | BackupKV
CacheAction = (
ReplaceWriteThroughOnNodeSplit | FreeDeviceKV | FreeDeviceKVFullOnly | BackupKV
)
@@ -22,7 +22,7 @@ from sglang.srt.mem_cache.hicache_storage import (
from sglang.srt.mem_cache.unified_cache.cache_action import (
FreeComponentDeviceSlot,
FreeComponentHostSlot,
FreeDeviceKV,
FreeDeviceKVFullOnly,
RebuildFullToSWAMapping,
RecoverSWAWithLockedFull,
SWARebuild,
@@ -295,7 +295,7 @@ class SWAComponent(TreeComponent):
)
return 0
full_cd.value = value_slice.clone()
cache_actions.append(FreeDeviceKV([old_full]))
cache_actions.append(FreeDeviceKVFullOnly([old_full]))
cache_actions.append(SWARebuild(node.id, value_slice))
return 0
elif swa_evicted_seqlen < total_prefix_len + prefix_len:
@@ -313,7 +313,7 @@ class SWAComponent(TreeComponent):
)
return start_idx
node.component_data[BASE_COMPONENT_TYPE].value = new_full.clone()
cache_actions.append(FreeDeviceKV([old_full]))
cache_actions.append(FreeDeviceKVFullOnly([old_full]))
cache_actions.append(SWARebuild(node.id, new_full))
return start_idx
else:
@@ -1156,7 +1156,7 @@ class SWAComponent(TreeComponent):
swa_value = self._translate_full_to_swa(action.incoming_full)
alloc.set_full_to_swa_mapping(action.kept_full, swa_value)
alloc.clear_full_to_swa_mapping(action.incoming_full)
alloc.full_attn_allocator.free(action.incoming_full)
alloc.free_full(action.incoming_full)
self.tree_core.set_component_device_value(
action.node_id, self.component_type, swa_value
)
@@ -48,6 +48,7 @@ from sglang.srt.mem_cache.unified_cache.cache_action import (
CacheAction,
ComponentAction,
FreeDeviceKV,
FreeDeviceKVFullOnly,
ReplaceWriteThroughOnNodeSplit,
)
from sglang.srt.mem_cache.unified_cache.components import (
@@ -939,7 +940,10 @@ class UnifiedTreeCore(UnifiedTreeCoreInterface):
@staticmethod
def _is_deferrable_action(action: CacheAction | ComponentAction) -> bool:
"""Fire-and-forget actions safe to batch until the next barrier."""
return isinstance(action, (FreeDeviceKV, ReplaceWriteThroughOnNodeSplit))
return isinstance(
action,
(FreeDeviceKV, FreeDeviceKVFullOnly, ReplaceWriteThroughOnNodeSplit),
)
def _insert_walk_step(self, state: _InsertWalkState) -> None:
"""Process one walked node, appending its barrier actions to the state."""
@@ -51,6 +51,7 @@ from sglang.srt.mem_cache.unified_cache.cache_action import (
ComponentAction,
FreeComponentDeviceSlot,
FreeDeviceKV,
FreeDeviceKVFullOnly,
ReplaceWriteThroughOnNodeSplit,
)
@@ -1052,6 +1053,9 @@ class UnifiedRadixCache(BasePrefixCache):
# tree values are page-aligned copies of a kv row: page-exact segments
for indices in action.indices:
self.token_to_kv_pool_allocator.free_segment(indices, start_pos=0)
elif isinstance(action, FreeDeviceKVFullOnly):
for indices in action.indices:
self.token_to_kv_pool_allocator.free_full(indices)
elif isinstance(action, BackupKV):
self._execute_and_commit_kv_backup(action)
else:
@@ -744,6 +744,33 @@ class TestUnifiedSWATokenToKVPoolAllocator(unittest.TestCase):
)
self.assertIn(tgt, free_full)
def test_swa_free_full_defers_inside_a_free_group(self):
"""The full-only release joins the barrier, like `free`."""
_, allocator, kvcache = self._build()
v = self._alloc(allocator, kvcache, 3)
target = v[1:2]
tgt = int(target.item())
# Tombstone the swa side, erasing each marker before its release
# (compaction runs inside both).
target_swa = allocator.swa_attn_allocator.virtual_to_physical[target]
kvcache.swa_kv_pool.buf[target_swa] = -1
allocator.free_swa(target)
full_phys = int(allocator.full_attn_allocator.virtual_to_physical[tgt].item())
kvcache.full_kv_pool.buf[full_phys] = -1
allocator.free_group_begin()
allocator.free_full(target)
deferred = set(
int(x) for x in allocator.full_attn_allocator.free_virtual_ids.tolist()
)
self.assertNotIn(tgt, deferred)
allocator.free_group_end()
drained = set(
int(x) for x in allocator.full_attn_allocator.free_virtual_ids.tolist()
)
self.assertIn(tgt, drained)
# 4. Compaction diverges between the two sub-pools (each runs its own).
def test_swa_compaction_diverges_physical_layout(self):
_, allocator, kvcache = self._build()
@@ -851,5 +851,48 @@ class TestSWASplitLeafOnInsert(CustomTestCase):
tree.sanity_check()
class TestFreeFullPartition(CustomTestCase):
"""`free_full` releases only the full side of a hybrid SWA allocator."""
def setUp(self):
_, self.allocator, _ = _build_swa_tree(is_eagle=False)
self.full_baseline = self.allocator.full_available_size()
self.swa_baseline = self.allocator.swa_available_size()
def _sizes(self):
return (
self.allocator.full_available_size(),
self.allocator.swa_available_size(),
)
def test_free_full_keeps_the_swa_peers_allocated(self):
indices = _swa_alloc(self.allocator, 4)
self.allocator.free_full(indices)
full_avail, swa_avail = self._sizes()
self.assertEqual(full_avail, self.full_baseline)
self.assertEqual(swa_avail, self.swa_baseline - 4)
def test_free_full_leaves_the_mapping_intact(self):
indices = _swa_alloc(self.allocator, 4)
before = self.allocator.full_to_swa_index_mapping[indices].clone()
self.allocator.free_full(indices)
self.assertTrue(bool((before > 0).all()))
self.assertTrue(
torch.equal(self.allocator.full_to_swa_index_mapping[indices], before)
)
def test_free_full_is_deferred_inside_a_free_group(self):
indices = _swa_alloc(self.allocator, 4)
self.allocator.free_group_begin()
self.allocator.free_full(indices)
self.assertEqual(self.allocator.full_available_size(), self.full_baseline - 4)
self.allocator.free_group_end()
self.assertEqual(self.allocator.full_available_size(), self.full_baseline)
if __name__ == "__main__":
unittest.main()
@@ -6733,6 +6733,7 @@ class TestUnifiedRadixCacheActionRouting(CustomTestCase):
# translate the source full to SWA and store it on the node (no free)
alloc.translate_loc_from_full_to_swa.assert_called_once_with(source_value)
alloc.free.assert_not_called()
alloc.free_full.assert_not_called()
cache.tree_core.set_component_device_value.assert_called_once_with(
5, ComponentType.SWA, swa_value
)
@@ -6767,7 +6768,9 @@ class TestUnifiedRadixCacheActionRouting(CustomTestCase):
alloc.set_full_to_swa_mapping.assert_called_once_with(kept_full, swa_value)
# the incoming full's stale mapping is cleared, then its slot freed (full-only)
alloc.clear_full_to_swa_mapping.assert_called_once_with(incoming_full)
alloc.full_attn_allocator.free.assert_called_once_with(incoming_full)
alloc.free_full.assert_called_once_with(incoming_full)
# not the inner allocator (skips the free-group defer) and not both halves
alloc.full_attn_allocator.free.assert_not_called()
alloc.free.assert_not_called()
cache.tree_core.set_component_device_value.assert_called_once_with(
5, ComponentType.SWA, swa_value