Support swa HiCache for unified radix cache (#23391)
Co-authored-by: hzh0425 <hzh0425@apache.org>
This commit is contained in:
@@ -17,9 +17,11 @@ from sglang.srt.mem_cache.base_prefix_cache import (
|
||||
EvictResult,
|
||||
InsertParams,
|
||||
MatchPrefixParams,
|
||||
MatchResult,
|
||||
)
|
||||
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
|
||||
from sglang.srt.mem_cache.common import available_and_evictable_str
|
||||
from sglang.srt.mem_cache.hicache_storage import PoolName
|
||||
from sglang.srt.mem_cache.memory_pool import (
|
||||
HybridLinearKVPool,
|
||||
HybridReqToTokenPool,
|
||||
@@ -28,7 +30,10 @@ from sglang.srt.mem_cache.memory_pool import (
|
||||
)
|
||||
from sglang.srt.mem_cache.radix_cache import RadixKey
|
||||
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool, SWATokenToKVPoolAllocator
|
||||
from sglang.srt.mem_cache.unified_cache_components.tree_component import ComponentType
|
||||
from sglang.srt.mem_cache.unified_cache_components.tree_component import (
|
||||
CacheTransferPhase,
|
||||
ComponentType,
|
||||
)
|
||||
from sglang.srt.mem_cache.unified_radix_cache import (
|
||||
UnifiedRadixCache,
|
||||
UnifiedTreeNode,
|
||||
@@ -1201,6 +1206,24 @@ class UnifiedRadixCacheSuite:
|
||||
self.skipTest("HiCache tests do not run on SWA stacks")
|
||||
return False
|
||||
|
||||
def _simulate_backup(self, tree, node):
|
||||
"""Simulate D->H backup by setting host_value on each component."""
|
||||
for ct in (ComponentType.FULL, ComponentType.MAMBA, ComponentType.SWA):
|
||||
if ct not in self.cfg.components:
|
||||
continue
|
||||
cd = node.component_data[ct]
|
||||
if cd.value is not None and cd.host_value is None:
|
||||
cd.host_value = cd.value.clone()
|
||||
|
||||
def _simulate_backup_tree(self, tree):
|
||||
"""Backup all non-root nodes (simulates write-through)."""
|
||||
stack = [tree.root_node]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node is not tree.root_node:
|
||||
self._simulate_backup(tree, node)
|
||||
stack.extend(node.children.values())
|
||||
|
||||
def _init_hicache(self, tree):
|
||||
import sglang.srt.mem_cache.hybrid_cache.hybrid_pool_assembler as assembler
|
||||
|
||||
@@ -1516,33 +1539,275 @@ class UnifiedRadixCacheSuite:
|
||||
tree.sanity_check()
|
||||
|
||||
def test_hicache_evict_to_host_updates_aux_lru(self):
|
||||
"""Aux components move from device LRU to host LRU on device-to-host eviction."""
|
||||
if self._skip_unsupported_hicache_test():
|
||||
return
|
||||
if not self.cfg.has_mamba:
|
||||
self.skipTest("requires Mamba component")
|
||||
tree, allocator, req_to_token_pool = self._build_hicache_fixture()
|
||||
"""Aux components (MAMBA / SWA) move from device LRU to host LRU on D->H eviction."""
|
||||
aux_types = [
|
||||
ct
|
||||
for ct in (ComponentType.MAMBA, ComponentType.SWA)
|
||||
if ct in self.cfg.components
|
||||
]
|
||||
if not aux_types:
|
||||
self.skipTest("requires at least one aux component")
|
||||
|
||||
tree, allocator, req_to_token_pool = build_fixture(self.cfg)
|
||||
seq = self._make_seq(1, 2)
|
||||
self._insert(tree, allocator, req_to_token_pool, seq)
|
||||
|
||||
m = tree.match_prefix(MatchPrefixParams(key=RadixKey(seq)))
|
||||
node = m.last_device_node
|
||||
|
||||
# Check mamba is in device LRU
|
||||
mamba_lru = tree.lru_lists[ComponentType.MAMBA]
|
||||
host_mamba_lru = tree.host_lru_lists[ComponentType.MAMBA]
|
||||
self.assertTrue(mamba_lru.in_list(node))
|
||||
self.assertFalse(host_mamba_lru.in_list(node))
|
||||
for aux in aux_types:
|
||||
self.assertTrue(tree.lru_lists[aux].in_list(node))
|
||||
self.assertFalse(tree.host_lru_lists[aux].in_list(node))
|
||||
|
||||
self._backup_node(tree, node)
|
||||
self._simulate_backup(tree, node)
|
||||
tree.evict(EvictParams(num_tokens=len(seq)))
|
||||
|
||||
# Mamba should move to host LRU
|
||||
self.assertFalse(mamba_lru.in_list(node))
|
||||
if node.component_data[ComponentType.MAMBA].host_value is not None:
|
||||
self.assertTrue(host_mamba_lru.in_list(node))
|
||||
for aux in aux_types:
|
||||
self.assertFalse(tree.lru_lists[aux].in_list(node))
|
||||
if node.component_data[aux].host_value is not None:
|
||||
self.assertTrue(tree.host_lru_lists[aux].in_list(node))
|
||||
tree.sanity_check()
|
||||
|
||||
def _build_chain_pages(self, tree, allocator, req_to_token_pool, num_pages):
|
||||
"""Insert an incremental chain of single-page extensions.
|
||||
|
||||
Returns the chain root-to-leaf. Length may differ from num_pages
|
||||
when the radix tree merges or splits nodes.
|
||||
"""
|
||||
seq: list[int] = []
|
||||
for i in range(num_pages):
|
||||
seq = seq + self._make_seq(1000 * (i + 1), 1)
|
||||
self._insert(tree, allocator, req_to_token_pool, seq)
|
||||
m = tree.match_prefix(MatchPrefixParams(key=RadixKey(seq)))
|
||||
chain: list = []
|
||||
cur = m.last_device_node
|
||||
while cur is not tree.root_node:
|
||||
chain.append(cur)
|
||||
cur = cur.parent
|
||||
chain.reverse()
|
||||
return chain
|
||||
|
||||
def test_hicache_swa_load_back_min_suffix(self):
|
||||
"""LOAD_BACK collects only the suffix nodes needed to cover sliding_window_size."""
|
||||
if not self.cfg.has_swa:
|
||||
self.skipTest("requires SWA")
|
||||
if self.cfg.has_mamba:
|
||||
# Mamba's per-insert req allocation exhausts max_num_reqs on long chains.
|
||||
self.skipTest("SWA-only path keeps the chain construction simple")
|
||||
ps = self.cfg.page_size
|
||||
sw = self.cfg.sliding_window_size
|
||||
expected_pages = (sw + ps - 1) // ps
|
||||
chain_pages = expected_pages + 2
|
||||
if chain_pages * ps > self.cfg.kv_size // 2:
|
||||
self.skipTest("kv_size too small for the desired chain")
|
||||
|
||||
tree, allocator, req_to_token_pool = build_fixture(self.cfg)
|
||||
chain = self._build_chain_pages(tree, allocator, req_to_token_pool, chain_pages)
|
||||
if len(chain) <= expected_pages:
|
||||
self.skipTest("chain collapsed below the suffix length being tested")
|
||||
|
||||
self._simulate_backup_tree(tree)
|
||||
|
||||
# Tombstone every chain node on the device side without going through
|
||||
# the tree-wide eviction loop. This isolates build_hicache_transfers
|
||||
# from LRU and cascade ordering.
|
||||
for n in chain:
|
||||
n.component_data[ComponentType.FULL].value = None
|
||||
n.component_data[ComponentType.SWA].value = None
|
||||
|
||||
leaf = chain[-1]
|
||||
swa_comp = tree.components[ComponentType.SWA]
|
||||
transfers = swa_comp.build_hicache_transfers(leaf, CacheTransferPhase.LOAD_BACK)
|
||||
self.assertIsNotNone(transfers)
|
||||
self.assertEqual(len(transfers), 1)
|
||||
xfer = transfers[0]
|
||||
self.assertEqual(xfer.name, PoolName.SWA)
|
||||
self.assertEqual(len(xfer.nodes_to_load), expected_pages)
|
||||
# host_indices must cover exactly the expected suffix tokens (>= sw).
|
||||
self.assertEqual(int(xfer.host_indices.numel()), expected_pages * ps)
|
||||
self.assertGreaterEqual(int(xfer.host_indices.numel()), sw)
|
||||
self.assertEqual(xfer.nodes_to_load, chain[-expected_pages:])
|
||||
|
||||
def test_hicache_swa_host_independent_of_full(self):
|
||||
"""FULL host and SWA host are physically independent.
|
||||
Freeing one component's host_value must not touch the other.
|
||||
"""
|
||||
if not self.cfg.has_swa:
|
||||
self.skipTest("requires SWA")
|
||||
|
||||
tree, allocator, req_to_token_pool = build_fixture(self.cfg)
|
||||
seq = self._make_seq(1, 2)
|
||||
self._insert(tree, allocator, req_to_token_pool, seq)
|
||||
m = tree.match_prefix(MatchPrefixParams(key=RadixKey(seq)))
|
||||
node = m.last_device_node
|
||||
|
||||
self._simulate_backup(tree, node)
|
||||
tree.evict(EvictParams(num_tokens=len(seq)))
|
||||
|
||||
cd_full = node.component_data[ComponentType.FULL]
|
||||
cd_swa = node.component_data[ComponentType.SWA]
|
||||
self.assertIsNotNone(cd_full.host_value)
|
||||
self.assertIsNotNone(cd_swa.host_value)
|
||||
self.assertIn(node, tree.evictable_host_leaves)
|
||||
self.assertTrue(tree.host_lru_lists[ComponentType.SWA].in_list(node))
|
||||
|
||||
# Drop FULL host bookkeeping. SWA side must stay intact.
|
||||
tree.evictable_host_leaves.discard(node)
|
||||
cd_full.host_value = None
|
||||
self.assertIsNotNone(cd_swa.host_value)
|
||||
self.assertTrue(tree.host_lru_lists[ComponentType.SWA].in_list(node))
|
||||
self.assertNotIn(node, tree.evictable_host_leaves)
|
||||
|
||||
# Drop SWA host bookkeeping. FULL side (already cleared) stays cleared.
|
||||
tree.host_lru_lists[ComponentType.SWA].remove_node(node)
|
||||
cd_swa.host_value = None
|
||||
self.assertIsNone(cd_full.host_value)
|
||||
self.assertIsNone(cd_swa.host_value)
|
||||
self.assertFalse(tree.host_lru_lists[ComponentType.SWA].in_list(node))
|
||||
self.assertNotIn(node, tree.evictable_host_leaves)
|
||||
|
||||
def _swa_finalize_setup(self):
|
||||
"""Build a SWA chain long enough to fill at least the window
|
||||
plus one extra page, and host-back every node so we can flip
|
||||
SWA tombstones at will."""
|
||||
ps = self.cfg.page_size
|
||||
sw = self.cfg.sliding_window_size
|
||||
window_pages = (sw + ps - 1) // ps
|
||||
chain_pages = window_pages + 2
|
||||
if chain_pages * ps > self.cfg.kv_size // 2:
|
||||
self.skipTest("kv_size too small for the desired chain")
|
||||
|
||||
tree, allocator, req_to_token_pool = build_fixture(self.cfg)
|
||||
chain = self._build_chain_pages(tree, allocator, req_to_token_pool, chain_pages)
|
||||
if len(chain) <= window_pages:
|
||||
self.skipTest("chain collapsed below the window length")
|
||||
self._simulate_backup_tree(tree)
|
||||
return tree, allocator, req_to_token_pool, chain, window_pages
|
||||
|
||||
def test_hicache_swa_finalize_match_result(self):
|
||||
"""finalize_match_result bumps host_hit_length to 1 iff some SWA node
|
||||
within the trailing window is tombstoned (cd.value is None,
|
||||
cd.host_value is not None). Out-of-window tombstones and chains fully
|
||||
on device must leave host_hit_length untouched.
|
||||
|
||||
Sentinel only — never the real SWA token count, since SWA load-back
|
||||
does not grow req.prefix_indices and any non-zero value gets
|
||||
subtracted from extend_input_len in schedule_policy.
|
||||
"""
|
||||
if not self.cfg.has_swa:
|
||||
self.skipTest("requires SWA")
|
||||
if self.cfg.has_mamba:
|
||||
self.skipTest("SWA-only path keeps the chain construction simple")
|
||||
|
||||
tree, _, _, chain, window_pages = self._swa_finalize_setup()
|
||||
leaf = chain[-1]
|
||||
swa_comp = tree.components[ComponentType.SWA]
|
||||
|
||||
cases = [
|
||||
("all_on_device", None, 0),
|
||||
("tombstone_in_window", chain[-window_pages], 1),
|
||||
("tombstone_outside_window", chain[-(window_pages + 1)], 0),
|
||||
]
|
||||
for name, victim, expected in cases:
|
||||
with self.subTest(name):
|
||||
# Reset SWA state for each subcase.
|
||||
for n in chain:
|
||||
cd = n.component_data[ComponentType.SWA]
|
||||
if cd.value is None and cd.host_value is not None:
|
||||
cd.value = cd.host_value.clone()
|
||||
if victim is not None:
|
||||
victim.component_data[ComponentType.SWA].value = None
|
||||
|
||||
result = MatchResult(
|
||||
device_indices=torch.empty(
|
||||
(0,), dtype=torch.int64, device=tree.device
|
||||
),
|
||||
last_device_node=leaf,
|
||||
last_host_node=leaf,
|
||||
host_hit_length=0,
|
||||
)
|
||||
result = swa_comp.finalize_match_result(
|
||||
result=result,
|
||||
params=MatchPrefixParams(key=RadixKey(self._make_seq(1, 1))),
|
||||
value_chunks=[],
|
||||
best_value_len=0,
|
||||
)
|
||||
self.assertEqual(result.host_hit_length, expected)
|
||||
|
||||
def test_hicache_swa_commit_load_back_rebuilds_mapping(self):
|
||||
"""LOAD_BACK commit must:
|
||||
(1) restore SWA cd.value via _restore_device_value (host LRU -> device LRU),
|
||||
(2) rewrite full_to_swa_index_mapping[full_idx] = new_swa_idx for every
|
||||
loaded chunk so subsequent SWA reads via translate_loc_from_full_to_swa
|
||||
return the freshly allocated SWA slot."""
|
||||
if not self.cfg.has_swa:
|
||||
self.skipTest("requires SWA")
|
||||
if self.cfg.has_mamba:
|
||||
self.skipTest("SWA-only path keeps the chain construction simple")
|
||||
|
||||
tree, allocator, _, chain, window_pages = self._swa_finalize_setup()
|
||||
|
||||
# Tombstone every SWA node in the trailing window.
|
||||
loaded_nodes = chain[-window_pages:]
|
||||
for n in loaded_nodes:
|
||||
n.component_data[ComponentType.SWA].value = None
|
||||
# SWA LRU bookkeeping must reflect tombstone state for the
|
||||
# _restore_device_value path to exercise the host->device move.
|
||||
tree.lru_lists[ComponentType.SWA].remove_node(n)
|
||||
tree.host_lru_lists[ComponentType.SWA].insert_mru(n)
|
||||
|
||||
# Build the LOAD_BACK transfer the same way load_back() would.
|
||||
swa_comp = tree.components[ComponentType.SWA]
|
||||
transfers = swa_comp.build_hicache_transfers(
|
||||
chain[-1], CacheTransferPhase.LOAD_BACK
|
||||
)
|
||||
self.assertIsNotNone(transfers)
|
||||
xfer = transfers[0]
|
||||
self.assertEqual(xfer.nodes_to_load, loaded_nodes)
|
||||
|
||||
# Allocate SWA device slots from the inner allocator (mirrors how
|
||||
# _resolve_pool_transfers_allocation routes via device_alloc_fn ->
|
||||
# swa_attn_allocator.alloc on the load-back path).
|
||||
n_swa = int(xfer.host_indices.numel())
|
||||
new_swa = allocator.swa_attn_allocator.alloc(n_swa)
|
||||
self.assertIsNotNone(new_swa)
|
||||
xfer.device_indices = new_swa
|
||||
|
||||
# Snapshot pre-commit state for invariants checks.
|
||||
pre_evictable = tree.component_evictable_size_[ComponentType.SWA]
|
||||
|
||||
swa_comp.commit_hicache_transfer(
|
||||
chain[-1], CacheTransferPhase.LOAD_BACK, transfers=transfers
|
||||
)
|
||||
|
||||
# (1) cd.value restored, host LRU -> device LRU swap done.
|
||||
offset = 0
|
||||
for n in loaded_nodes:
|
||||
cd = n.component_data[ComponentType.SWA]
|
||||
self.assertIsNotNone(cd.value)
|
||||
chunk_len = int(cd.value.numel())
|
||||
self.assertEqual(
|
||||
cd.value.tolist(),
|
||||
new_swa[offset : offset + chunk_len].tolist(),
|
||||
)
|
||||
offset += chunk_len
|
||||
self.assertTrue(tree.lru_lists[ComponentType.SWA].in_list(n))
|
||||
self.assertFalse(tree.host_lru_lists[ComponentType.SWA].in_list(n))
|
||||
self.assertEqual(offset, n_swa)
|
||||
|
||||
# (2) full_to_swa_index_mapping rebuilt for every loaded chunk.
|
||||
for n in loaded_nodes:
|
||||
full_idx = n.component_data[ComponentType.FULL].value
|
||||
swa_idx = n.component_data[ComponentType.SWA].value
|
||||
translated = allocator.translate_loc_from_full_to_swa(full_idx)
|
||||
self.assertEqual(translated.tolist(), swa_idx.tolist())
|
||||
|
||||
# Evictable size moved up by the restored token count.
|
||||
self.assertEqual(
|
||||
tree.component_evictable_size_[ComponentType.SWA] - pre_evictable,
|
||||
n_swa,
|
||||
)
|
||||
|
||||
def test_hicache_mixed_backup_evict_insert(self):
|
||||
"""Complex scenario: backup some, evict, insert new, verify invariants."""
|
||||
if self._skip_unsupported_hicache_test():
|
||||
|
||||
Reference in New Issue
Block a user