[mem_cache] Route hybrid SWA full-side kv-row frees through free_segment (#37876)

Co-authored-by: weireweire <20922698+weireweire@users.noreply.github.com>
Co-authored-by: Sam Shleifer <sshleifer@gmail.com>
This commit is contained in:
Liangsheng Yin
2026-09-04 01:48:33 -07:00
committed by GitHub
co-authored by weireweire Sam Shleifer
parent dae126d510
commit 67248e04b4
11 changed files with 178 additions and 61 deletions
+18 -1
View File
@@ -192,6 +192,23 @@ class BaseTokenToKVPoolAllocator(abc.ABC):
Starts sit on page boundaries, ends may fall mid-page, and the page
ranges of consecutive segments do not overlap -- so in page units the
segments are aligned and disjoint, and every page is released once."""
for free_index, start_pos in self._page_disjoint(segments):
self.free_segment(free_index, start_pos=start_pos)
def free_full_segment(self, free_index: torch.Tensor, *, start_pos: int):
"""free_full() for a kv-row segment; same start-alignment contract as
free_segment(). Default: plain free_full()."""
assert start_pos % self.page_size == 0, (
f"segment start {start_pos} is not page-aligned"
)
self.free_full(free_index)
def free_full_segments(self, segments):
"""free_segments() for the full side alone; see free_full()."""
for free_index, start_pos in self._page_disjoint(segments):
self.free_full_segment(free_index, start_pos=start_pos)
def _page_disjoint(self, segments):
ps = self.page_size
prev_end = None
for free_index, start_pos in segments:
@@ -202,4 +219,4 @@ class BaseTokenToKVPoolAllocator(abc.ABC):
f"segment at {start_pos} shares a page with the one ending at {prev_end}"
)
prev_end = start_pos + n
self.free_segment(free_index, start_pos=start_pos)
yield free_index, start_pos
+32 -13
View File
@@ -103,7 +103,6 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
self.release_pages = None
self.free_group = None
self.swa_free_group = []
self.full_free_group = []
self._kvcache = kvcache
self.clear()
@@ -402,18 +401,34 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
self.full_to_swa_index_mapping[free_index] == 0,
msg="caller wants free",
)
if self.free_group is None:
self.full_attn_allocator.free(free_index)
else:
self.full_free_group.append(self._copy_for_free_group(free_index))
self.full_attn_allocator.free(free_index)
assert (
self.full_attn_allocator.available_size() <= self.full_attn_allocator.size
)
def free_segment(self, free_index: torch.Tensor, *, start_pos: int):
if free_index.numel() == 0:
return
# SWA first, as in free(): it reads the mapping that a later cache
# action in this group may re-point.
self.free_swa(free_index)
self.full_attn_allocator.free_segment(free_index, start_pos=start_pos)
def free_full_segment(self, free_index: torch.Tensor, *, start_pos: int):
if free_index.numel() == 0:
return
expect(
_SWA_PEER_RELEASED,
self.full_to_swa_index_mapping[free_index] == 0,
msg="caller wants free_segment",
)
self.full_attn_allocator.free_segment(free_index, start_pos=start_pos)
def free_group_begin(self):
super().free_group_begin()
self.swa_free_group = []
self.full_free_group = []
# No full-side pile here: the full allocator's own group defers those.
self.full_attn_allocator.free_group_begin()
def free_group_end(self):
super().free_group_end()
@@ -421,10 +436,7 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
swa_free_group = self.swa_free_group
self.swa_free_group = []
self._release_swa(torch.cat(swa_free_group))
if self.full_free_group:
full_free_group = self.full_free_group
self.full_free_group = []
self.full_attn_allocator.free(torch.cat(full_free_group))
self.full_attn_allocator.free_group_end()
assert (
self.full_attn_allocator.available_size() <= self.full_attn_allocator.size
)
@@ -468,7 +480,6 @@ 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)
@@ -594,8 +605,16 @@ class PureSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
# 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_segment(self, free_index: torch.Tensor, *, start_pos: int):
# Single pool: the parent's split into an SWA and a full half would
# release the same slots twice.
self.free(free_index)
def free_full_segment(self, free_index: torch.Tensor, *, start_pos: int):
return
# Not inherited: the SWA parent's hooks drive swa_free_group and the full
# allocator's group, which this pure-SWA variant does not have.
def free_group_begin(self):
BaseTokenToKVPoolAllocator.free_group_begin(self)
+6 -10
View File
@@ -127,7 +127,7 @@ def free_kv_row_segments(
) -> None:
"""Free ascending disjoint ``(kv_indices, start_pos)`` segments of one
request's kv row, split at the SWA eviction floor."""
swa_dead: list[torch.Tensor] = []
swa_dead: list[tuple[torch.Tensor, int]] = []
swa_alive: list[tuple[torch.Tensor, int]] = []
for kv_indices, start_pos in segments:
num_indices = kv_indices.numel()
@@ -137,23 +137,19 @@ def free_kv_row_segments(
# the deliberately unmapped prefix of a PD decode SWA-tail prealloc.
num_dead = min(max(swa_evicted_seqlen - start_pos, 0), num_indices)
if num_dead > 0:
swa_dead.append(kv_indices[:num_dead])
swa_dead.append((kv_indices[:num_dead], start_pos))
if num_dead < num_indices:
swa_alive.append((kv_indices[num_dead:], start_pos + num_dead))
if swa_dead and swa_alive:
# A mid-page floor would send a page shared by the dead and alive
# sides back twice.
# The two sides are separate calls, so neither one's page-disjointness
# check sees a floor that splits a page between them.
assert swa_evicted_seqlen % allocator.page_size == 0, (
f"SWA eviction floor {swa_evicted_seqlen} splits a page "
f"(page_size {allocator.page_size})"
)
if len(swa_dead) == 1:
allocator.free_full(swa_dead[0])
elif swa_dead:
# Two dead pieces can share a boundary page, and only free_full's own
# page dedup covers that -- free_segments trims the alive side alone.
allocator.free_full(torch.cat(swa_dead))
if swa_dead:
allocator.free_full_segments(swa_dead)
if swa_alive:
allocator.free_segments(swa_alive)
@@ -3227,6 +3227,7 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
self.free_group = None
self.free_page_reps_group: Optional[List[torch.Tensor]] = None
self.full_free_group: List[torch.Tensor] = []
# Empty (not None) for the leak checker.
self.free_pages = torch.empty(0, dtype=torch.int64, device=device)
self.release_pages = torch.empty(0, dtype=torch.int64, device=device)
@@ -3661,6 +3662,17 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
self.full_attn_allocator.free(free_index.detach().to(torch.int64))
self.full_attn_allocator.clear_inverse_history()
def free_full_segment(self, free_index: torch.Tensor, *, start_pos: int) -> None:
if free_index is None or free_index.numel() == 0:
return
if self.page_size == 1:
# token == page: free_full already frees by exact ids, no dedup.
self.free_full(free_index)
return
# The swa v2p is the mapping, so a tombstoned swa page drops out of the
# two-sided segment path by itself; full-only is the same call.
self.free_segment(free_index, start_pos=start_pos)
def set_full_to_swa_mapping(
self, full_indices: torch.Tensor, swa_indices: torch.Tensor
) -> None:
@@ -3676,13 +3688,20 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
# -- free-group --
# Not the SWA parent's hooks: those open the parent's paged full allocator
# as a free group, and this composite's sub-pools defer on their own.
def free_group_begin(self) -> None:
super().free_group_begin()
BaseTokenToKVPoolAllocator.free_group_begin(self)
self.free_page_reps_group = []
self.full_free_group = []
def free_group_end(self) -> None:
pending, self.free_page_reps_group = self.free_page_reps_group, None
super().free_group_end()
full_free_group, self.full_free_group = self.full_free_group, []
BaseTokenToKVPoolAllocator.free_group_end(self)
if full_free_group:
self.full_attn_allocator.free(torch.cat(full_free_group))
self.full_attn_allocator.clear_inverse_history()
if pending:
self._release_page_reps(pending)
@@ -3746,6 +3765,7 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
self.swa_attn_allocator.clear()
self.free_group = None
self.free_page_reps_group = None
self.full_free_group = []
# -- Lazy compaction hooks --
@@ -503,10 +503,11 @@ class FullComponent(TreeComponent):
if isinstance(action, FreeComponentDeviceSlot):
alloc = self.cache.token_to_kv_pool_allocator
for indices in action.indices:
# tree values are page-aligned copies of a kv row: page-exact segments
if self.cache.is_swa_enabled:
alloc.full_attn_allocator.free(indices)
alloc.full_attn_allocator.free_segment(indices, start_pos=0)
else:
alloc.free(indices)
alloc.free_segment(indices, start_pos=0)
return
raise AssertionError(
f"FullComponent: unhandled ComponentAction {type(action).__name__}"
@@ -1371,7 +1371,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.free_full(action.incoming_full)
alloc.free_full_segment(action.incoming_full, start_pos=0)
self.tree_core.set_component_device_value(
action.node_id, self.component_type, swa_value
)
@@ -1097,7 +1097,7 @@ class UnifiedRadixCache(BasePrefixCache):
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)
self.token_to_kv_pool_allocator.free_full_segment(indices, start_pos=0)
elif isinstance(action, BackupKV):
if self.linker is not None:
self.linker.offload_nodes(action.node_ids)
@@ -141,7 +141,7 @@ class _StaticAllocRecorder:
self.clear_calls.append(full)
self.full_to_swa_index_mapping[full.to(torch.int64)] = 0
def free_full(self, indices):
def free_full_segment(self, indices, *, start_pos):
self.freed_full.append(indices)
def free(self, indices):
@@ -307,8 +307,8 @@ class TestStaticPoolPathUnchanged(unittest.TestCase):
),
"incoming ids' mapping entries must be zeroed (static recipe)",
)
# Through free_full, not the inner allocator: the latter skips the
# free-group defer.
# Through free_full_segment, not the inner allocator: the latter skips
# the free-group defer.
self.assertEqual(len(static.freed_full), 1)
self.assertEqual(static.freed_via_inner, [])
((node_id, ct, _),) = probe.tree_core.set_calls
@@ -2,6 +2,7 @@ import unittest
from array import array
from types import SimpleNamespace
from unittest import mock
from unittest.mock import patch
import torch
@@ -830,13 +831,13 @@ class TestSWA(unittest.TestCase):
req2.prefix_indices = torch.tensor([21, 22, 23, 24, 25], device=tree.device)
freed_lens = []
original_free = allocator.free
original_free_segment = allocator.free_segment
def wrapped_free(indices):
def wrapped_free_segment(indices, *, start_pos):
freed_lens.append(int(indices.numel()))
return original_free(indices)
return original_free_segment(indices, start_pos=start_pos)
allocator.free = wrapped_free
allocator.free_segment = wrapped_free_segment
tree.cache_finished_req(
req2, is_insert=False, kv_len_to_handle=req2._kv_committed_len
)
@@ -1070,21 +1071,45 @@ class TestFreeKvRow(CustomTestCase):
)
self.assertEqual(self._sizes(), (self.full_baseline, self.swa_baseline))
def test_adjacent_below_floor_pieces_release_their_shared_page_once(self):
def test_below_floor_pieces_go_back_through_the_full_side(self):
_, allocator, _ = _build_swa_tree(is_eagle=False, page_size=4)
indices = _swa_alloc(allocator, 8)
allocator.free_swa(indices)
after_alloc = allocator.full_available_size()
# Rows [0, 6) and [6, 8) both sit below the floor and share page 1.
free_kv_row_segments(
allocator,
[(indices[:6], 0), (indices[6:], 6)],
swa_evicted_seqlen=8,
)
# Both rows [0, 4) and [4, 8) sit below the floor: full side only.
with patch.object(
allocator.full_attn_allocator,
"free",
side_effect=AssertionError("full side took the unique path"),
):
free_kv_row_segments(
allocator,
[(indices[:4], 0), (indices[4:], 4)],
swa_evicted_seqlen=8,
)
self.assertEqual(allocator.full_available_size(), after_alloc + 8)
def test_grouped_full_side_frees_defer_and_skip_the_unique_path(self):
_, allocator, _ = _build_swa_tree(is_eagle=False, page_size=4)
indices = _swa_alloc(allocator, 12)
allocator.free_swa(indices[:8])
after_alloc = allocator.full_available_size()
with patch.object(
allocator.full_attn_allocator,
"free",
side_effect=AssertionError("full side took the unique path"),
):
allocator.free_group_begin()
# dead rows [0, 8) and the alive row [8, 12) from one request
free_kv_row_segments(allocator, [(indices, 0)], swa_evicted_seqlen=8)
self.assertEqual(allocator.full_available_size(), after_alloc)
allocator.free_group_end()
self.assertEqual(allocator.full_available_size(), after_alloc + 12)
def test_free_kv_row_reads_the_record_row_and_its_floor(self):
indices = _swa_alloc(self.allocator, 8)
cache = _RowCache(self.allocator, indices)
@@ -406,6 +406,26 @@ class TestEveryUnifiedAllocatorOverridesFreeSegment(unittest.TestCase):
self.assertIn("free_page_reps_group", inspect.getsource(cls))
class TestUnifiedSwaFullSideGroup(unittest.TestCase):
"""`free_full` inside a free group must defer and land at `free_group_end`;
the composite carries that pile itself, not through the SWA parent's hooks."""
def test_full_only_frees_defer_until_group_end(self):
from test_unified_swa_shared_virtual_ids import _build
alloc = _build(64, 32, 2, 4)
idx = alloc.alloc(8)
alloc.free_swa(idx)
before = alloc.full_available_size()
alloc.free_group_begin()
alloc.free_full(idx[:4])
alloc.free_full_segment(idx[4:], start_pos=4)
self.assertEqual(alloc.full_available_size(), before)
alloc.free_group_end()
self.assertEqual(alloc.full_available_size(), before + 8)
class TestFreeSwaWindowRatchetNoHostSync(unittest.TestCase):
"""The per-decode-step SWA window ratchet frees a CONTIGUOUS row slice
with host-int, page-aligned bounds — the same shape `free_segment` was
@@ -471,6 +491,25 @@ class TestFreeSwaWindowRatchetNoHostSync(unittest.TestCase):
alloc.free_swa(v[: 4 * self.PS], start_pos=0)
alloc.free_swa(v[4 * self.PS :], start_pos=4 * self.PS)
def test_full_only_segment_free_never_syncs(self):
"""The request-finish dead half (swa already tombstoned) frees the full
side by page reps: no unique from free_full's token dedup."""
alloc = self._swa_composite(lazy=True)
v = alloc.alloc(8 * self.PS)
alloc.free_swa(v, start_pos=0)
before = alloc.full_available_size()
with (
mock.patch.object(
torch, "unique", side_effect=AssertionError("unique = host sync")
),
mock.patch.object(
torch.Tensor, "item", side_effect=AssertionError("item = host sync")
),
):
alloc.free_full_segment(v[: 4 * self.PS], start_pos=0)
alloc.free_full_segment(v[4 * self.PS :], start_pos=4 * self.PS)
self.assertEqual(alloc.full_available_size(), before + 8 * self.PS)
def test_unaligned_start_pos_is_rejected(self):
"""A mid-page start must fail loudly, not release the head page whole."""
alloc = self._swa_composite(lazy=True)
@@ -516,15 +516,18 @@ def build_fixture(
full_attention_layer_ids=cfg.full_attention_layer_ids,
device=device,
)
allocator = SWATokenToKVPoolAllocator(
size=cfg.kv_size,
size_swa=cfg.kv_size,
page_size=cfg.page_size,
dtype=cfg.dtype,
device=device,
kvcache=kv_pool,
need_sort=False,
)
# Tree values reach the allocator as start_pos=0 segments, so only the
# debug cross-check against torch.unique catches a mis-aligned value.
with envs.SGLANG_DEBUG_MEMORY_POOL.override(True):
allocator = SWATokenToKVPoolAllocator(
size=cfg.kv_size,
size_swa=cfg.kv_size,
page_size=cfg.page_size,
dtype=cfg.dtype,
device=device,
kvcache=kv_pool,
need_sort=False,
)
elif cfg.has_mamba:
kv_pool = HybridLinearKVPool(
size=cfg.kv_size,
@@ -4070,11 +4073,8 @@ class UnifiedRadixCacheSuite:
# for an un-charged gate to accept.
extend_need = 2 * ps + 1
max_new = 8
self.assertIsNotNone(
cons_alloc.swa_attn_allocator.alloc(
cons_alloc.swa_available_size() - (window + extend_need - 1)
)
)
hold = cons_alloc.swa_available_size() - (window + extend_need - 1)
self.assertIsNotNone(cons_alloc.swa_attn_allocator.alloc(hold // ps * ps))
# The adder's SWA gate for this request (_swa_budget_for_req).
surfaced_swa_hit = cons.staged_prefetch_swa_tokens(req_id)
@@ -7559,8 +7559,8 @@ class TestUnifiedRadixCacheActionRouting(CustomTestCase):
_component_with_cache(ComponentType.FULL, cache).apply_component_action(
FreeComponentDeviceSlot([indices], component_type=ComponentType.FULL)
)
cache.token_to_kv_pool_allocator.full_attn_allocator.free.assert_called_once_with(
indices
cache.token_to_kv_pool_allocator.full_attn_allocator.free_segment.assert_called_once_with(
indices, start_pos=0
)
def test_apply_component_action_device_kv_swa_uses_free_swa(self):
@@ -7681,7 +7681,7 @@ 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.free_full.assert_called_once_with(incoming_full)
alloc.free_full_segment.assert_called_once_with(incoming_full, start_pos=0)
# Never by indexing the tensor: the unified composite has no
# `full_to_swa_index_mapping` to index into.
alloc.full_to_swa_index_mapping.__setitem__.assert_not_called()