Reset only the used mamba state on radix cache hit (#31643)

This commit is contained in:
Ke Bao
2026-07-19 00:40:02 +08:00
committed by GitHub
parent ece02ffc9c
commit 5609f8e509
2 changed files with 98 additions and 30 deletions
@@ -19,7 +19,6 @@ limitations under the License.
The radix tree data structure for managing the hybrid (full and Mamba) KV cache. The radix tree data structure for managing the hybrid (full and Mamba) KV cache.
""" """
import heapq
from array import array from array import array
from collections import defaultdict from collections import defaultdict
from typing import TYPE_CHECKING, List, Optional, Tuple from typing import TYPE_CHECKING, List, Optional, Tuple
@@ -83,7 +82,11 @@ class TreeNode:
self.full_lock_ref = 0 self.full_lock_ref = 0
self.mamba_lock_ref = 0 self.mamba_lock_ref = 0
# last access time is only used for sanity check. LRU is maintained by the lru list. # last access time is only used for sanity check. LRU is maintained by the lru list.
# `last_access_time` tracks the full LRU (whole matched path is reused as prefix);
# `mamba_last_access_time` tracks the mamba LRU, which only touches the single state
# actually consumed per access, so the two orders diverge and need separate stamps.
self.last_access_time = get_last_access_time() self.last_access_time = get_last_access_time()
self.mamba_last_access_time = self.last_access_time
self.hit_count = 0 self.hit_count = 0
self.host_ref_counter = 0 self.host_ref_counter = 0
@@ -172,10 +175,12 @@ class LRUList:
self.prv = "mamba_prev" self.prv = "mamba_prev"
self.nxt = "mamba_next" self.nxt = "mamba_next"
self.lock_ref = "mamba_lock_ref" self.lock_ref = "mamba_lock_ref"
self.time_attr = "mamba_last_access_time"
else: else:
self.prv = "prev" self.prv = "prev"
self.nxt = "next" self.nxt = "next"
self.lock_ref = "full_lock_ref" self.lock_ref = "full_lock_ref"
self.time_attr = "last_access_time"
# Initialize dummy head and tail nodes # Initialize dummy head and tail nodes
self.head = TreeNode() # Most recently used side self.head = TreeNode() # Most recently used side
self.tail = TreeNode() # Least recently used side self.tail = TreeNode() # Least recently used side
@@ -226,6 +231,8 @@ class LRUList:
assert ( assert (
not self.mamba or node.mamba_value is not None not self.mamba or node.mamba_value is not None
), f"Resetting mamba tombstone node in mamba lru list: {node.id=}" ), f"Resetting mamba tombstone node in mamba lru list: {node.id=}"
if self.mamba:
node.mamba_last_access_time = get_last_access_time()
self._remove_node(node) self._remove_node(node)
self._add_node(node) self._add_node(node)
@@ -255,6 +262,8 @@ class LRUList:
assert ( assert (
node.id not in self.cache node.id not in self.cache
), f"Inserting node {node.id=} already in lru list, existing node: {self.cache[node.id].id=}" ), f"Inserting node {node.id=} already in lru list, existing node: {self.cache[node.id].id=}"
if self.mamba:
node.mamba_last_access_time = get_last_access_time()
self.cache[node.id] = node self.cache[node.id] = node
self._add_node(node) self._add_node(node)
@@ -330,21 +339,20 @@ class LRUList:
msg = f"{self.mamba=} LRU list: " msg = f"{self.mamba=} LRU list: "
x_lru = self._get_lru() x_lru = self._get_lru()
while x_lru is not None and x_lru.id in self.cache: while x_lru is not None and x_lru.id in self.cache:
msg += f"[{x_lru.id}] {x_lru.last_access_time:f} -> " msg += f"[{x_lru.id}] {getattr(x_lru, self.time_attr):f} -> "
x_lru = getattr(x_lru, self.prv) x_lru = getattr(x_lru, self.prv)
print(msg) print(msg)
if not tree_cache: if not tree_cache:
return return
msg = f"{self.mamba=} Nodes (sorted by last_access_time): " msg = f"{self.mamba=} Nodes (sorted by {self.time_attr}): "
if self.mamba: if self.mamba:
nodes = tree_cache._collect_nontombstone_nodes() nodes = tree_cache._collect_nontombstone_nodes()
else: else:
nodes = tree_cache._collect_all_nodes() nodes = tree_cache._collect_all_nodes()
heapq.heapify(nodes) nodes.sort(key=lambda n: getattr(n, self.time_attr))
while len(nodes): for x in nodes:
x = heapq.heappop(nodes) msg += f"[{x.id}] {getattr(x, self.time_attr):f} -> "
msg += f"[{x.id}] {x.last_access_time:f} -> "
print(msg) print(msg)
# Note: this is expensive, only use for debug # Note: this is expensive, only use for debug
@@ -364,8 +372,8 @@ class LRUList:
# Note: this is expensive, only use for debug or idle check # Note: this is expensive, only use for debug or idle check
def sanity_check(self, tree_cache: MambaRadixCache): def sanity_check(self, tree_cache: MambaRadixCache):
""" """
Check if the lru list is valid by rebuilding the lru list from the tree, heapifying it, and Check the lru list is valid by rebuilding it from the tree, sorting by this list's
checking if the lru list is valid. access-time stamp, and checking the order matches the linked list.
""" """
try: try:
if self.mamba: if self.mamba:
@@ -374,16 +382,16 @@ class LRUList:
nodes = tree_cache._collect_all_nodes() nodes = tree_cache._collect_all_nodes()
total_nodes = len(nodes) total_nodes = len(nodes)
total_lru = len(self.cache) total_lru = len(self.cache)
# heapify based on last_access_time # rebuild expected order from this list's own access-time stamp (full and mamba
heapq.heapify(nodes) # lists have independent recency, so they use different stamps)
nodes.sort(key=lambda n: getattr(n, self.time_attr))
# the root node is not in the lru list # the root node is not in the lru list
assert len(nodes) == ( assert len(nodes) == (
total_lru + (0 if self.mamba else 1) total_lru + (0 if self.mamba else 1)
), f"len(nodes): {len(nodes)}, total_lru: {total_lru}" ), f"len(nodes): {len(nodes)}, total_lru: {total_lru}"
x_lru = self._get_lru() x_lru = self._get_lru()
while len(nodes): for x in nodes:
x = heapq.heappop(nodes)
if x == tree_cache.root_node: if x == tree_cache.root_node:
# root node is not in the lru list # root node is not in the lru list
continue continue
@@ -393,7 +401,7 @@ class LRUList:
assert ( assert (
x == x_lru x == x_lru
), f"Incorrect LRU list, {self.mamba=}, x: {x.id=} != x_lru: {x_lru.id=}, {x.last_access_time=}, {x_lru.last_access_time=}" ), f"Incorrect LRU list, {self.mamba=}, x: {x.id=} != x_lru: {x_lru.id=}, {getattr(x, self.time_attr)=}, {getattr(x_lru, self.time_attr)=}"
assert ( assert (
x_lru.full_lock_ref == 0 x_lru.full_lock_ref == 0
), f"x_lru should not be locked when idle, {x_lru.full_lock_ref=}, {x_lru.id=}" ), f"x_lru should not be locked when idle, {x_lru.full_lock_ref=}, {x_lru.id=}"
@@ -1104,11 +1112,16 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
cow_mamba = params.cow_mamba cow_mamba = params.cow_mamba
req = params.req req = params.req
# update time for matched nodes, and make nodes closer to root to be least recently used # Full KV of the whole matched path is reused as prefix, so refresh the entire
# this allows mamba to evict nodes closer to root first # chain (nodes closer to root end up least recently used, evicted first).
node_update = last_node node_update = last_node
self.full_lru_list.reset_node_and_parents_mru(node_update, self.root_node) self.full_lru_list.reset_node_and_parents_mru(node_update, self.root_node)
self.mamba_lru_list.reset_node_and_parents_mru(node_update, self.root_node) # Mamba only consumes last_node's state (cf. inc_lock_ref, which locks just this
# node's mamba_value). Refreshing ancestors would keep a whole session's states
# adjacent in the mamba LRU and evict cold sessions wholesale; touch only the used
# state so older leaves survive.
if last_node is not self.root_node and last_node.mamba_value is not None:
self.mamba_lru_list.reset_node_mru(last_node)
# This last_access_time is for sanity check, can be deleted after validation in production # This last_access_time is for sanity check, can be deleted after validation in production
cur_time = get_last_access_time() cur_time = get_last_access_time()
@@ -1170,12 +1183,13 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
new_node.key = child.key[:split_len] new_node.key = child.key[:split_len]
new_node.value = child.value[:split_len].clone() new_node.value = child.value[:split_len].clone()
# child time should be later than parent's time for mamba tombstone # child time should be later than the new parent's time in the full LRU
child.last_access_time = get_last_access_time() child.last_access_time = get_last_access_time()
# A split does not change the set of live mamba states (child keeps its value,
# new_node is a mamba tombstone), so the mamba LRU is left untouched — only the
# full LRU reorders around the new intermediate node.
self.full_lru_list.remove_node(child) self.full_lru_list.remove_node(child)
if child.mamba_value is not None:
self.mamba_lru_list.remove_node(child)
child.parent = new_node child.parent = new_node
child.key = child.key[split_len:] child.key = child.key[split_len:]
child.value = child.value[split_len:].clone() child.value = child.value[split_len:].clone()
@@ -1184,12 +1198,10 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
child.hash_value, split_len, self.page_size child.hash_value, split_len, self.page_size
) )
# insert the new node and child into the lru lists, insert # insert the new node and child into the full lru list, insert
# parent first so that parent is after child in the lru list # parent first so that parent is after child in the lru list
self.full_lru_list.insert_mru(new_node) self.full_lru_list.insert_mru(new_node)
self.full_lru_list.insert_mru(child) self.full_lru_list.insert_mru(child)
if child.mamba_value is not None:
self.mamba_lru_list.insert_mru(child)
return new_node return new_node
def _insert_helper( def _insert_helper(
@@ -1201,14 +1213,14 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
chunked: bool = False, chunked: bool = False,
prev_prefix_len: int = 0, prev_prefix_len: int = 0,
) -> Tuple[int, bool]: ) -> Tuple[int, bool]:
# Update the last access time from root to leaf, so that # Refresh the full LRU from root to leaf (the whole path is reused as prefix).
# mamba will tombstone the node closer to root first # The mamba states of these existing nodes were not recomputed this insert, so
# the mamba LRU is left untouched here; only genuinely new mamba states (the new
# leaf / a revived tombstone below) are inserted.
assert mamba_value is not None, "Mamba value should not be None here." assert mamba_value is not None, "Mamba value should not be None here."
node.last_access_time = get_last_access_time() node.last_access_time = get_last_access_time()
if node != self.root_node: if node != self.root_node:
self.full_lru_list.reset_node_mru(node) self.full_lru_list.reset_node_mru(node)
if node.mamba_value is not None:
self.mamba_lru_list.reset_node_mru(node)
if len(key) == 0: if len(key) == 0:
return 0, True return 0, True
@@ -1219,8 +1231,6 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
node = node.children[child_key] node = node.children[child_key]
node.last_access_time = get_last_access_time() node.last_access_time = get_last_access_time()
self.full_lru_list.reset_node_mru(node) self.full_lru_list.reset_node_mru(node)
if node.mamba_value is not None:
self.mamba_lru_list.reset_node_mru(node)
prefix_len = node.key.match(key, page_size=self.page_size) prefix_len = node.key.match(key, page_size=self.page_size)
if prev_prefix_len < total_prefix_length + prefix_len: if prev_prefix_len < total_prefix_length + prefix_len:
@@ -1260,7 +1270,6 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
else: # mamba value already exists else: # mamba value already exists
mamba_value_exist = True mamba_value_exist = True
self.full_lru_list.reset_node_mru(node) self.full_lru_list.reset_node_mru(node)
self.mamba_lru_list.reset_node_mru(node)
node.last_access_time = get_last_access_time() node.last_access_time = get_last_access_time()
return total_prefix_length, mamba_value_exist return total_prefix_length, mamba_value_exist
@@ -387,6 +387,65 @@ class TestMamba(unittest.TestCase):
print(available_and_evictable_str(tree)) print(available_and_evictable_str(tree))
tree.sanity_check() tree.sanity_check()
def test_mamba_lru_match_refreshes_only_used_node(self):
"""A prefix-cache hit must refresh only the matched leaf's mamba state in
the mamba LRU, not its ancestors. Whole-chain refresh clustered a session's
states adjacently, so under mamba-pool pressure eviction dropped whole cold
sessions instead of the intermediate states reuse never needs. Guards against
reverting the mamba list to reset_node_and_parents_mru.
"""
tree, allocator, req_to_token_pool, make_dummy_req = (
self._setup_tree_and_allocator()
)
def insert(token_ids):
req = make_dummy_req()
kv = allocator.alloc(len(token_ids))
tree.insert(
InsertParams(
key=RadixKey(array("q", token_ids)),
value=kv,
mamba_value=req.mamba_pool_idx.unsqueeze(0),
)
)
def match_leaf(token_ids):
return tree.match_prefix(
MatchPrefixParams(key=RadixKey(array("q", token_ids)))
).last_device_node
def mamba_lru_mru_to_lru():
lst = tree.mamba_lru_list
order, x = [], getattr(lst.head, lst.nxt)
while x is not None and x is not lst.tail and x.id in lst.cache:
order.append(x)
x = getattr(x, lst.nxt)
return order
# Two independent sessions, each a 2-node mamba chain:
# root -> a1 -> b1 and root -> a2 -> b2
insert([1, 2, 3])
insert([1, 2, 3, 4, 5, 6])
insert([7, 8, 9])
insert([7, 8, 9, 10, 11, 12])
b1 = match_leaf([1, 2, 3, 4, 5, 6])
a1 = b1.parent
b2 = match_leaf([7, 8, 9, 10, 11, 12])
a2 = b2.parent
# Session 2 was matched last, so session 1's ancestor a1 is older than a2.
order = mamba_lru_mru_to_lru()
self.assertGreater(order.index(a1), order.index(a2))
# Re-access session 1. Only its consumed leaf (b1) moves to MRU; its ancestor
# a1 must stay put -- whole-chain reset would bump a1 right behind b1, making
# it newer than a2.
self.assertIs(match_leaf([1, 2, 3, 4, 5, 6]), b1)
order = mamba_lru_mru_to_lru()
self.assertIs(order[0], b1)
self.assertGreater(order.index(a1), order.index(a2))
tree.sanity_check()
def test_mamba_radix_cache_kv_events(self): def test_mamba_radix_cache_kv_events(self):
tree, allocator, _, make_dummy_req = self._setup_tree_and_allocator( tree, allocator, _, make_dummy_req = self._setup_tree_and_allocator(
enable_kv_cache_events=True enable_kv_cache_events=True