Prhub

#34729 Retain SWA down to the last state checkpoint

原始 PR 作者 ispobock 合并时间 2026-08-15 01:12 文件变更 6 提交数 3 评论 6 代码增减 +165 / -0

执行摘要

保留 SWA 至最后 checkpoint,混合模型 decode 前缀复用翻倍

PR body 开篇即指出问题:'A hybrid SWA + mamba model throws away most of its decode-region prefix reuse at the default --mamba-track-interval'。作者用 32 个 prompt 的实测数据定位:page_size=128、track_interval=256 时仅 16/32 请求在第二轮命中 decode 区域,track_interval=512 时仅 5/32。根因是 SWA 驱逐相对 tail 计算(evict_threshold = pre_len - max(window, page)),而前缀匹配落在位于 tail 之前的 state checkpoint 上,匹配需要 checkpoint 之下有一整窗口存活 SWA,但部分窗口已被释放,且 swa_evicted_seqlen 只前进不回退。此前唯一的绕法是让 track_interval 等于 page_size,代价是 checkpoint 密度与 mamba 池 footprint 翻倍;本 PR 将两者解耦。

值得精读。核心设计决策有三个:一是“驱逐基准(tail)与匹配落点(state checkpoint)不一致”的根因分析,二是让 helper 保持对 mamba 无知、由知道两方组件的缓存层统一计算 floor 的模块化边界,三是用 KL bitexact 测试作为正确性仪表来验证“多保留的状态是否真的正确”。注意作者标注的两个 TODO(prefill 覆盖、SWA 池 sizing),合并该 PR 后应持续跟踪。

讨论亮点

本 PR 没有 review 评论(review_comments_count = 0),核心讨论来自 PR body 的作者自查:

1) prefill 区域尚未覆盖——req.mamba_last_track_seqlencache_unfinished_req 末尾被清空,首个 decode 步 floor 读到 None,需要把 floor 计算迁移到树侧;
2) SWA 池占用成本未直接测量,作者给出解析上界(checkpoint 间距)与探针数据(每请求额外保留 127-255 token,窗口 511,占比 <1%),并明确表态 'Worth a reviewer's judgement rather than my assertion';
3) 后续计划把 SWA 池 sizing floor 从 window 提升为 window + interval,防止池压力下额外保留反噬。Issue 评论中主要是 CI 重跑:第一次在 2-gpu-h100 上 test_unified_radix_cache_kl_swa.pytest_unified_radix_cache_kl_full.py 失败,重跑后全部通过。

实现拆解

变更入口是驱逐工具函数与缓存层接口的成对改造,共 4 个步骤:

  1. 驱逐工具增加可选下限python/sglang/srt/mem_cache/common.pyfree_swa_out_of_window_slots 新增 retain_floor: int | None = None 关键字参数;在 radix cache 分支算出 evict_threshold = pre_len - max(window, page) 后追加 min(evict_threshold, retain_floor)None 时行为与旧版完全一致;is_chunk_cache=True 时显式跳过 floor(chunk cache 不建树,checkpoint 不可能被匹配,保留纯属成本)。frontier 单调性由外层 max(req.kv.swa_evicted_seqlen, ...) 保证,floor 永远不会导致“反释放”。

  2. floor 计算集中到缓存层BasePrefixCache 新增默认方法 swa_retain_floor(req) -> int | None,直接返回 None——没有第二状态流的缓存不受影响;UnifiedRadixCache 覆写该方法,仅当 is_mamba_enabledreq.mamba_last_track_seqlen 存在时返回 checkpoint - sliding_window_size,保证 checkpoint 之下保留完整窗口供前缀匹配读取。

  3. 两个调用点统一走缓存接口ScheduleBatch._evict_swa(decode 侧驱逐)和 SWAComponent.free_out_of_window_slots(chunked-prefill 插入路径)分别传入 retain_floor=self.tree_cache.swa_retain_floor(req)retain_floor=self.cache.swa_retain_floor(req),floor 推导只在一处完成,helper 对 mamba 保持无知。

  4. 测试与精度验证test/registered/unit/mem_cache/test_swa_eviction_boundary.py 新增 5 个用例(钳制、chunk cache 忽略、None 兼容旧行为、floor 高于阈值时惰性、frontier 只前进不反释放);复用 test_unified_radix_cache_kl_hybrid_bitexact(KL 下限 1e-9)在 H200/B200 验证:floor 开启后新增命中的请求 KL 全部为 0,证明保留的窗口内容正确而非仅存在。

文件 模块 状态 重要度
python/sglang/srt/mem_cache/common.py 驱逐逻辑 modified 6.09
python/sglang/srt/mem_cache/unified_radix_cache.py 统一缓存 modified 6.52
python/sglang/srt/mem_cache/base_prefix_cache.py 缓存基类 modified 5.63
python/sglang/srt/managers/schedule_batch.py 调度器 modified 4.35
python/sglang/srt/mem_cache/unified_cache/components/swa_component.py SWA 组件 modified 4.35
test/registered/unit/mem_cache/test_swa_eviction_boundary.py 缓存驱逐 modified 7.04

关键符号

free_swa_out_of_window_slots BasePrefixCache.swa_retain_floor UnifiedRadixCache.swa_retain_floor ScheduleBatch._evict_swa SWAComponent.free_out_of_window_slots

关键源码片段

python/sglang/srt/mem_cache/common.py core-logic

驱逐逻辑行为落点。新增 retain_floor 可选参数并用 min() 钳制 evict_threshold,None 时与旧行为逐字节一致,chunk cache 分支显式忽略 floor,是整个变更的核心机制。

def free_swa_out_of_window_slots(
    req: Req,
    pre_len: int,
    *,
    sliding_window_size: int,
    page_size: int,
    req_to_token_pool: ReqToTokenPool,
    token_to_kv_pool_allocator: BaseTokenToKVPoolAllocator,
    is_chunk_cache: bool = False,
    retain_floor: int | None = None,
) -> None:
    if req.kv is None:
        return
​
    # SWA radix cache 需要驱逐不在树缓存、也不在滑动窗口内的 token。
    # cache_protected_len 必须按 page 对齐,避免驱逐侵入受保护区域。
    assert (
        req.cache_protected_len % page_size == 0
    ), "cache_protected_len must be page aligned"
    evict_floor = max(req.cache_protected_len, getattr(req, "swa_evict_floor", 0))
    if page_size > 1 and evict_floor > req.cache_protected_len:
        evict_floor = -(-evict_floor // page_size) * page_size
    req.kv.swa_evicted_seqlen = max(req.kv.swa_evicted_seqlen, evict_floor)
​
    if is_chunk_cache:
        # Chunk cache 不构建 radix tree,无 tombstone-leaf 顾虑;
        # 直接驱逐到窗口边界,尾部 queue 保证按 page 对齐。
        evict_threshold = pre_len - sliding_window_size
    else:
        # Radix cache:至少保留 max(window, page)。尾部按 page 对齐,
        # 且多减一页保证 frontier 低于插入边界 page_floor(seq_len),
        # 避免最后一个叶子变成全 tombstone。
        evict_threshold = pre_len - max(sliding_window_size, page_size)
​
    if retain_floor is not None and not is_chunk_cache:
        # 调用方决定 floor 的位置(见 BasePrefixCache.swa_retain_floor);
        # 这里只承诺不释放越过 floor 的 slot。
        # Chunk cache 没有树,保留的 checkpoint 永远不会被匹配,纯属成本。
        evict_threshold = min(evict_threshold, retain_floor)
​
    new_swa_evicted_seqlen = max(
        req.kv.swa_evicted_seqlen,
        evict_threshold,
    )
​
    if page_size > 1:
        new_swa_evicted_seqlen = (new_swa_evicted_seqlen // page_size) * page_size
​
    if new_swa_evicted_seqlen > req.kv.swa_evicted_seqlen:
        free_slots = req_to_token_pool.req_to_token[
            req.req_pool_idx, req.kv.swa_evicted_seqlen : new_swa_evicted_seqlen
        ]
        token_to_kv_pool_allocator.free_swa(free_slots)
        maybe_evict_dsv4_state_on_swa(
            token_to_kv_pool_allocator, req_to_token_pool, req, new_swa_evicted_seqlen
        )
        req.kv.swa_evicted_seqlen = new_swa_evicted_seqlen
python/sglang/srt/mem_cache/unified_radix_cache.py core-logic

新增 UnifiedRadixCache.swa_retain_floor 覆写,把 floor 计算集中到同时了解 SWA 与 mamba 两种组件的缓存层:仅 mamba 启用且 checkpoint 存在时返回 checkpoint - sliding_window_size。

def swa_retain_floor(self, req) -> int | None:
    # 只有 SWA 与 mamba 混合缓存需要保留到最后一个 state checkpoint:
    # 前缀匹配落在 checkpoint 上,而 checkpoint 位于 tail 之后,
    # 若按 tail - window 驱逐会毁掉本可复用的 decode 区域。
    if not self.is_mamba_enabled or self._sliding_window_size is None:
        return None
    checkpoint = req.mamba_last_track_seqlen
    if checkpoint is None:
        # 首个 decode 步之前 checkpoint 尚未写入树,暂无 floor。
        return None
    # 保留 checkpoint 之前一整个窗口的 SWA slot,供前缀匹配读取。
    return checkpoint - self._sliding_window_size
test/registered/unit/mem_cache/test_swa_eviction_boundary.py test-coverage

5 个新用例完整刻画 retain_floor 行为契约:钳制、chunk cache 忽略、None 兼容旧行为、惰性、不反释放;配合 KL bitexact 精度测试作为正确性仪表。

def test_retain_floor_clamps_eviction(self):
    """混合缓存把 SWA 保留到最后一个 state checkpoint,而不是 tail 之后的
    窗口,因为前缀匹配落在 checkpoint 上。即使 tail 已远远前移,
    floor 也必须钳制驱逐前沿。"""
    page_size, window = 8, 16
    tree, allocator, pool = _build_swa_tree(
        page_size=page_size, sliding_window_size=window
    )
    seq_len = 200
    checkpoint = 96
    kv = _swa_alloc(allocator, seq_len)
    pool.write((0, slice(0, seq_len)), kv)
    req = _make_req(0, list(range(seq_len)), 0, tree)
    batch = _make_batch(tree, allocator, pool)
​
    free_swa_out_of_window_slots(
        req,
        seq_len - 1,
        sliding_window_size=window,
        page_size=page_size,
        req_to_token_pool=batch.req_to_token_pool,
        token_to_kv_pool_allocator=batch.token_to_kv_pool_allocator,
        retain_floor=checkpoint - window,
    )
​
    # 无 floor 时会推进到 page_floor(199 - 16) = 176,
    # 有 floor 时驱逐前沿必须被钳制在 checkpoint - window 之内。
    self.assertLessEqual(req.kv.swa_evicted_seqlen, checkpoint - window)
    self.assertEqual(req.kv.swa_evicted_seqlen % page_size, 0)
​
​
def test_retain_floor_does_not_unfree(self):
    """frontier 只前进。floor 在 slot 已释放后到达时,不能把 slot 认领回来,
    否则下一轮会双重释放。"""
    page_size, window = 8, 16
    tree, allocator, pool = _build_swa_tree(
        page_size=page_size, sliding_window_size=window
    )
    seq_len = 200
    kv = _swa_alloc(allocator, seq_len)
    pool.write((0, slice(0, seq_len)), kv)
    req = _make_req(0, list(range(seq_len)), 0, tree)
    batch = _make_batch(tree, allocator, pool)
    common_kwargs = dict(
        sliding_window_size=window,
        page_size=page_size,
        req_to_token_pool=batch.req_to_token_pool,
        token_to_kv_pool_allocator=batch.token_to_kv_pool_allocator,
    )
​
    # 第一次无 floor 驱逐,frontier 前移。
    free_swa_out_of_window_slots(req, seq_len - 1, **common_kwargs)
    advanced = req.kv.swa_evicted_seqlen
    self.assertGreater(advanced, 0)
​
    # 第二次带 floor=0 调用,必须保持原 frontier 不变。
    free_swa_out_of_window_slots(req, seq_len - 1, retain_floor=0, **common_kwargs)
    self.assertEqual(req.kv.swa_evicted_seqlen, advanced)

评论区精华

prefill 区域未被保留覆盖(作者 TODO) 设计

PR body 的 TODO 中作者自述:req.mamba_last_track_seqlen 在 cache_unfinished_req 末尾(checkpoint 交给树后)被清空,因此首个 decode 步 floor 读到 None,prefill 区域不会获得额外保留;要覆盖需让 floor 来自树而非请求。

结论:本 PR 明确不覆盖 prefill 区域,留待后续将 floor 计算迁移到树侧后处理。 · 待处理

SWA 池占用成本未测量 性能

作者在 PR body 中说明峰值设备内存不变(池按 mem-fraction-static 预分配),真正代价是 SWA 池占用增加且未直接测量;探针测得每请求额外保留 127-255 token(窗口 511),估算低于 SWA 池的 1%,并明确表示 'Worth a reviewer's judgement rather than my assertion'。

结论:作者给出解析界限(checkpoint 间距)与实测探针数据,并计划后续把池 sizing floor 从 window 改为 window + interval,防止池压力下反噬。 · 待处理

风险与影响

1) SWA 池占用上升(未量化):floor 让每个请求多保留至多一个 checkpoint 间距的 slot,作者估算占比低于 1% 但未直接测量;池按 mem-fraction-static 预分配,常规配置不改变设备峰值,但池压力场景可能影响批处理并发,作者已计划把池 sizing floor 改为 window + interval
2) prefill 区域未覆盖:req.mamba_last_track_seqlen 清空时机导致首个 decode 步无 floor,prefill 阶段仍可能丢失复用,属作者明示 TODO。
3) 正确性防护:frontier 只前进不后退,floor 不会导致已释放 slot 被重新认领(双重释放风险已被 test_retain_floor_does_not_unfree 覆盖);chunk cache 显式忽略;非 mamba 缓存走 None 默认路径行为不变。
4) CI 偶发:KL 相关测试在 2-gpu-h100 首轮失败、重跑通过,未完全排除环境因素。

对用户:混合 SWA + mamba 模型(如带 mamba 状态的 Qwen3.x 系列)第二轮请求 decode 区域缓存命中率从约 50%(16/32)甚至 16%(5/32)提升到 100%(32/32),复用前缀 token +12.9%、重算 token -77.1%,在多轮对话与多请求共享前缀场景下收益显著。对系统:改动集中在 mem_cache 驱逐路径,非 mamba 缓存与 chunk cache 显式不受影响;SWA 池占用小幅上升需在池压力配置下观察。对团队:确立了“匹配落在 checkpoint、驱逐相对 tail”这一缓存语义认知,为后续把 floor 迁移到树侧(覆盖 prefill)以及 SWA 池 sizing 调整铺路。

SWA 池占用上升未量化 prefill 区域复用未覆盖 核心缓存驱逐路径变更

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论