执行摘要
- 一句话:插入时分割SWA叶子,锁定仅占一个滑动窗口
- 推荐动作:该 PR 值得仔细阅读,特别是
_maybe_split_leaf_for_swa_lock 的设计:通过插入时立即裁剪来防止锁定长叶子过度占用 SWA 池,是一种简洁有效的资源治理策略。对于关注 SWA 或统一缓存的开发者,理解此模式有助于在其他类似场景中复用。
功能与动机
SWA 只关注最后 sliding_window_size 个 token,但锁定一个缓存叶子时会固定整个叶子的 SWA slots。使用分块预填时单个叶子可能包含数千个 token,一个锁定请求会占用远超实际需要的 SWA 池,导致 SWA 池过早填满并开始撤回请求。该 PR 通过插入时立即裁剪叶子,使锁定仅保护一个窗口大小的 SWA 资源。
实现拆解
- 在
SWAComponent.commit_insert_component_data 末尾无条件调用 self._maybe_split_leaf_for_swa_lock(node),确保每个新叶子(已经在 SWA 窗口内)都经过长度裁剪。
- 新增
_maybe_split_leaf_for_swa_lock 方法:先计算覆盖滑动窗口所需的最小页面对齐大小 tail_size;若叶子长度大于 tail_size 且满足页面对齐条件,则通过 self.cache._split_node 将叶子分裂,前面部分成为可驱逐的普通前缀,后面部分保留 SWA 且长度为 tail_size。
- 补充单元测试
test_swa_leaf_capped_to_window_on_insert:分别构造长叶子(超过一个窗口)和短叶子(不超过一个窗口),验证长叶子分裂后 SWA 值大小为窗口大小且存在父节点,短叶子保持原样;验证锁定后 SWA 保护大小等于窗口大小而 full attention 保护整个序列。
- 修改
test_swa_lru_walk_down_does_not_refresh_ancestors_during_insert 测试中的节点数预期,因为 8 页叶子被分裂成两个节点,LRU 顺序节点数相应调整。
关键文件:
python/sglang/srt/mem_cache/unified_cache_components/swa_component.py(模块 SWA缓存;类别 source;类型 core-logic;符号 commit_insert_component_data, _maybe_split_leaf_for_swa_lock): 核心变更文件,新增 _maybe_split_leaf_for_swa_lock 方法并在 commit_insert_component_data 结尾调用,实现 SWA 叶子插入时按窗口长度裁剪。
test/registered/unit/mem_cache/test_unified_radix_cache_unittest.py(模块 单元测试;类别 test;类型 test-coverage;符号 test_swa_leaf_capped_to_window_on_insert, test_swa_lru_walk_down_does_not_refresh_ancestors_during_insert): 新增 test_swa_leaf_capped_to_window_on_insert 测试用例,并调整 test_swa_lru_walk_down_does_not_refresh_ancestors_during_insert 以适应新的分裂行为(节点数增加)。
关键符号:commit_insert_component_data, _maybe_split_leaf_for_swa_lock, test_swa_leaf_capped_to_window_on_insert
关键源码片段
python/sglang/srt/mem_cache/unified_cache_components/swa_component.py
核心变更文件,新增 _maybe_split_leaf_for_swa_lock 方法并在 commit_insert_component_data 结尾调用,实现 SWA 叶子插入时按窗口长度裁剪。
def commit_insert_component_data(
self,
node: UnifiedTreeNode,
is_new_leaf: bool,
params: InsertParams,
result: InsertResult,
) -> None:
# 仅在新建叶子时处理 SWA 元数据
if not is_new_leaf:
return
node_start = result.prefix_len
split_pos = params.swa_evicted_seqlen - node_start
if split_pos <= 0:
# 整个叶子在滑动窗口内,正常设置 SWA value
swa_value = self._translate_full_to_swa(
node.component_data[BASE_COMPONENT_TYPE].value
)
node.component_data[self.component_type].value = swa_value
self.cache.lru_lists[self.component_type].insert_mru(node)
self.cache.component_evictable_size_[self.component_type] += len(swa_value)
elif split_pos < len(node.key):
# 叶子跨越 SWA 驱逐边界:分裂出无 SWA 的父亲和有 SWA 的孩子
self.cache._split_node(node.key, node, split_pos)
swa_value = self._translate_full_to_swa(
node.component_data[BASE_COMPONENT_TYPE].value
)
node.component_data[self.component_type].value = swa_value
self.cache.lru_lists[self.component_type].insert_mru(node)
self.cache.component_evictable_size_[self.component_type] += len(swa_value)
else:
# 整个叶子在滑动窗口之外,作为 tombstone 不设置 SWA
return
# 对新插入的叶子执行窗口大小裁剪,防止锁定过多 SWA 池
self._maybe_split_leaf_for_swa_lock(node)
def _maybe_split_leaf_for_swa_lock(self, leaf: UnifiedTreeNode) -> None:
"""将新叶子限制为一个页面对齐的滑动窗口长度,确保锁定时仅占用 SWA 池的一个窗口"""
ct = self.component_type
cd = leaf.component_data[ct]
# 根节点、无 SWA value 或已被锁定的叶子不处理
if leaf is self.cache.root_node or cd.value is None or cd.lock_ref > 0:
return
page_size = self.cache.page_size
# 计算覆盖滑动窗口所需的最小页面对齐大小
tail_size = (self.sliding_window_size + page_size - 1) // page_size * page_size
leaf_len = len(leaf.key)
if leaf_len <= tail_size:
return # 叶子长度未超过一个窗口,无需分裂
split_at = leaf_len - tail_size
# 防御性检查:确保分裂点与叶子长度都是页面边界对齐的
if page_size > 1 and (split_at % page_size != 0 or leaf_len % page_size != 0):
return
self.cache._split_node(leaf.key, leaf, split_at)
test/registered/unit/mem_cache/test_unified_radix_cache_unittest.py
新增 test_swa_leaf_capped_to_window_on_insert 测试用例,并调整 test_swa_lru_walk_down_does_not_refresh_ancestors_during_insert 以适应新的分裂行为(节点数增加)。
def test_swa_leaf_capped_to_window_on_insert(self):
"""A long SWA leaf is split so locking it protects one window of SWA
while full attention still protects the whole sequence."""
if not self.cfg.has_swa:
self.skipTest("requires SWA component")
ps = self.cfg.page_size
window = self.cfg.sliding_window_size
tail_size = ((window + ps - 1) // ps) * ps
tail_pages = tail_size // ps
for case in ("long_splits", "short_keeps"):
with self.subTest(case=case):
tree, allocator, req_to_token_pool = build_fixture(self.cfg)
num_pages = tail_pages + 2 if case == "long_splits" else tail_pages
seq = self._make_seq(1, num_pages)
self._insert(tree, allocator, req_to_token_pool, seq)
tree.sanity_check()
leaf = tree.match_prefix(
MatchPrefixParams(key=RadixKey(array("q", seq)))
).last_device_node
swa_val = leaf.component_data[ComponentType.SWA].value
self.assertIsNotNone(swa_val)
if case == "long_splits":
# 被裁剪为一个页面对齐的窗口,且存在真实的前缀父节点
self.assertEqual(len(swa_val), tail_size)
self.assertIsNot(leaf.parent, tree.root_node)
else:
# 叶长不足一个窗口,保持原样
self.assertEqual(len(swa_val), len(seq))
self.assertIs(leaf.parent, tree.root_node)
lock_result = tree.inc_lock_ref(leaf)
# SWA 只保护一个窗口,而 full attention 保护整个序列
self.assertEqual(tree.swa_protected_size(), len(swa_val))
self.assertEqual(tree.full_protected_size(), len(seq))
tree.sanity_check()
tree.dec_lock_ref(
leaf,
DecLockRefParams(swa_uuid_for_lock=lock_result.swa_uuid_for_lock),
)
tree.sanity_check()
评论区精华
gemini-code-assist[bot] 在 _maybe_split_leaf_for_swa_lock 中指出:tail_size 和 leaf_len 均为 page_size 的倍数时,split_at 必定也是 page_size 的倍数,因此 split_at % page_size != 0 的检查是冗余的,建议简化为 if page_size > 1 and leaf_len % page_size != 0: return。但作者未采纳该建议,保留了原有的双重检查作为防御性验证。这是唯一讨论点。
- Check redundancy in page alignment validation (style): 作者未采纳该建议,保留了原有双重检查作为防御性验证。
风险与影响
关联脉络
- PR #26615 [sgl] Window-aware LRU refresh for SWA prefix cache in unified cache: 同一模块(统一缓存 SWA 组件)的改进,新增了 LRU 刷新策略,与本 PR 的叶子分割逻辑共同优化 SWA 池管理。
- PR #26870 Make unified tree SWA hicache tests faithful to write-through backup: 同一测试文件
test_unified_radix_cache_unittest.py 的增强,增加了 SWA 写入通备份的测试,与本 PR 的测试扩展同属一个持续改进方向。
参与讨论