Prhub

#38941 [Fix] Merge adjacent KV-row frees so a mid-page split under DCP cannot double-free

原始 PR 作者 klshuster 合并时间 2026-09-14 21:44 文件变更 3 提交数 2 评论 1 代码增减 +107 / -2

执行摘要

合并相邻 KV 行释放区间,修 DCP 下页内接缝双重释放崩溃

PR body 给出了明确的线上故障动机:radix cache 服务在混合 Mamba + MLA 模型(Kimi-K3、Kimi-Linear)配 --dcp-size 8 时,在普通负载下崩溃,报 mem_cache/allocator/base.py, free_segments 的 AssertionError: segment at 576 shares a page with the one ending at 576,作者描述为“Every scheduler dies at once”,在 MMLU 式批量(大量前缀共享的 logprob 请求同时结束)下一分钟内必现,agentic 流量下数小时内也会出现。作者还指出断言并非仅“过于严格”:Without the assertion it would be worse: the second piece would free a page the first piece already released. 即若无断言就退化为真正的 double free。修复后作者用 Kimi-K3(--attention-backend cutedsl_mla --dcp-size 8,radix cache 与 hierarchical cache 开启、draft-model 投机解码开启)跑出 210 个带 radix 命中的 prefill batch 无断言,而同样配置未修复的三台服务在同一负载下一分钟内全部死亡。

值得精读,尤其推荐给维护 KV cache / radix cache / DCP 的工程师:这是一个“小而准”的生产崩溃修复,11 行源码解决整机级故障,且修复点的选择有讲究。阅读时重点看三处设计决策:一是为什么在 free_kv_row 这一层合并,而不是让 allocator 放宽断言(后者会掩盖真实 double free);二是 coalesce_ranges 只合并严格相邻区间的边界处理,以及它隐含的升序前置条件是否需要显式断言;三是测试如何把 512/576 的线上几何按 64 倍缩放到 page 8 / 位置 9,并用第二个用例锁住“有空隙的共享页仍然报错”这条安全线。建议顺手确认其他 free_kv_row_segments 调用点是否都已覆盖,并评估为“保留前缀结束在页内”的接缝情形补一个用例。

讨论亮点

评审非常轻:唯一的 review 是 ispobock 的空正文 APPROVED,issue 下唯一评论是 ispobock: cc @hnyls2002,没有任何针对实现细节的追问;实质性分析全部由作者写在 PR body 里,包括根因链和“去掉断言会更糟”的判断。合并由 ispobock 执行,PR 带 bypass-fastfail、run-ci-extra 标签。PR body 的 CI 状态里 “Latest PR Test (AMD ROCm 10)” 为红(Run #34733362396),讨论中没有解释或跟进。

实现拆解

  1. 入口与根因定位:BasePrefixCache.free_kv_row(python/sglang/srt/mem_cache/base_prefix_cache.py)是所有前缀缓存共用的 KV 行归还入口。 它把行位置区间切成 (row[start:end], start) 段交给 free_kv_row_segments,再由 allocator 的 free_full_segments 归还页。DCP 下 allocator 页宽为 page_size * dcp_size(默认 64 × 8 = 512),radix 树按该宽度分页,但 cache-length cap(如 Mamba 组件的 track boundary 576)仍落在未加宽的 64 token 网格,cache_finished_req 于是把请求尾部拆成「页内未对齐尾段」和「延迟截断尾段」两段相接区间,接缝落在 512 token 页内部,free_segments 因两段共享同一页而断言失败。

  2. 新增纯函数 coalesce_ranges(python/sglang/srt/mem_cache/common.py)。 单次线性扫描,仅当 start == merged[-1][1](严格相接)时把区间合并,遇到空隙则原样保留;放在 free_kv_row_segments 等释放工具同侧,保持释放语义内聚。这样 allocator 只看到一段页对齐区间,每个页只释放一次。

  3. 释放入口接线:free_kv_row 内改为 from ... import coalesce_ranges, free_kv_row_segments,在构造段列表前先做 coalesce_ranges(ranges)。 为什么把合并放在调用方而不是 allocator:allocator 的共享页断言是全局安全网,放松它会掩盖真实 double free;而在 free_kv_row 这一层,调用方刚好知道这些区间属于同一行、语义上是连续的尾部,是唯一具备“可安全合并”信息的层级。合并后每个区间仍携带自己的 start,free_kv_row_segments 按 swa_evicted_seqlen 切死/活两段的逻辑不受影响,所有继承 BasePrefixCache 的缓存(unified radix cache、HiCache 变体、chunk cache 等)自动受益。

  4. 刻意保留的安全边界。 allocator 侧断言一行未改:两段相邻区间之外的“有空隙但共享页”仍会被拒绝,说明本 PR 只消化“可证明等价”的合并,而不吞掉异常信号。

  5. 测试与 CI 配套(新增 test/registered/unit/mem_cache/test_free_kv_row_coalesce.py,92 行)。 用 PagedTokenToKVPoolAllocator(size=16*8, page_size=8) 把线上几何按 64 倍缩放(512 → 8,576 → 位置 9),_make_cache_with_row 只搭最小 allocator + 伪造 req_to_token 行,_RowCache 继承 BasePrefixCache 后除 free_kv_row 外全部抛 NotImplementedError,避免拉起完整调度栈;用例一验证 free_kv_row(kv, [(8, 9), (9, 24)]) 后每个页恰好释放一次,用例二验证有空隙的 [(8, 9), (12, 24)] 仍抛 AssertionError。测试通过 register_cpu_ci(est_time=10, suite="base-a-test-cpu") 进入 CPU CI。性能上只是请求完成时对少量区间做一遍线性遍历,作者明确标注“Not applicable”。

文件 模块 状态 重要度
python/sglang/srt/mem_cache/base_prefix_cache.py 前缀缓存 modified 6.16
python/sglang/srt/mem_cache/common.py 缓存公共工具 modified 6.62
test/registered/unit/mem_cache/test_free_kv_row_coalesce.py 内存缓存测试 added 7.13

关键符号

coalesce_ranges free_kv_row free_kv_row_segments _make_cache_with_row test_adjacent_ranges_split_mid_page_free_every_page_once test_non_adjacent_ranges_sharing_a_page_still_rejected

关键源码片段

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

修复的实际接线点:`BasePrefixCache.free_kv_row` 是先合并区间再构造释放段的地方,也是所有前缀缓存共享的 KV 归还入口,决定了修复的覆盖面与不变量边界。

# python/sglang/srt/mem_cache/base_prefix_cache.py(BasePrefixCache)
def free_kv_row(self, kv: Any, ranges: list[tuple[int, int]]) -> None:
    """Give back ascending, disjoint, half-open row-position ranges
    of the ``kv`` record's row; one call keeps a shared page freed once.
    """
    # 函数内 import,避免 base_prefix_cache 在模块加载期依赖 mem_cache.common 的运行时符号
    from sglang.srt.mem_cache.common import coalesce_ranges, free_kv_row_segments
​
    row = self.req_to_token_pool.req_to_token[kv.req_pool_idx]
    # 相邻片段如果在同一个(被 dcp_size 加宽过的)页内交接,这一页会被释放两次;
    # allocator 会直接断言失败,所以先合并成一个区间再交给释放逻辑。
    free_kv_row_segments(
        self.token_to_kv_pool_allocator,
        # 合并后的区间用自己的 start 重新切片,仍携带起始位置,
        # 供 free_kv_row_segments 按 swa_evicted_seqlen 把每段切成“已死 / 存活”两半。
        [(row[start:end], start) for start, end in coalesce_ranges(ranges)],
        swa_evicted_seqlen=kv.swa_evicted_seqlen,
    )
python/sglang/srt/mem_cache/common.py core-logic

新增核心工具函数 `coalesce_ranges`,是修复的语义核心:只合并严格相邻的半开区间,空档保留,从而在修掉崩溃的同时不削弱 allocator 的 double-free 断言;该文件同时定义了 `free_kv_row_segments` 的 SWA 水位线切分与页对齐断言。

# python/sglang/srt/mem_cache/common.py
def coalesce_ranges(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
    """Merge adjacent half-open ranges so a split that falls mid-page frees that page once."""
    merged: list[tuple[int, int]] = []
    # 单次线性扫描;依赖调用方保证 ranges 升序且互不相交(见 free_kv_row 的 docstring)。
    for start, end in ranges:
        # 只有「严格相接」才合并:start 恰等于上一段的 end,说明两段之间的接缝
        # 落在页内部,分开释放会把同一个(DCP 加宽后的)页释放两次。
        if merged and start == merged[-1][1]:
            merged[-1] = (merged[-1][0], end)
        else:
            # 中间存在空隙时保持原样:此时共享页确实可能被释放两次,
            # 交给 allocator 的页不相交断言继续拦截,而不是在这里静默吞掉。
            merged.append((start, end))
    return merged
test/registered/unit/mem_cache/test_free_kv_row_coalesce.py test-coverage

新增的 CPU 单测,把线上 DCP 几何按 64 倍缩放(512/576 → 8/9)复现崩溃,并同时锁住“合并生效”和“有空隙仍拒绝”两个方向;`_RowCache` 用最小实现隔离 `free_kv_row`,避免拉起完整调度栈。

# test/registered/unit/mem_cache/test_free_kv_row_coalesce.py
# 线上几何:64 token 页被 dcp_size 8 加宽成 512;这里按 64 倍缩小 —— page 8,
# Mamba track boundary 576 落到位置 9。
PAGE_SIZE = 8
NUM_PAGES = 16
​
​
def _make_cache_with_row(num_tokens):
    # 只搭最小的分页 allocator + 伪造的 req_to_token 行,不拉起完整调度栈
    alloc = PagedTokenToKVPoolAllocator(
        size=NUM_PAGES * PAGE_SIZE,
        page_size=PAGE_SIZE,
        dtype=torch.float16,
        device="cpu",
        kvcache=None,
        need_sort=False,
    )
    row = alloc.alloc(num_tokens)
    req_to_token = torch.zeros((1, num_tokens), dtype=row.dtype)
    req_to_token[0] = row
    kv = SimpleNamespace(req_pool_idx=0, swa_evicted_seqlen=0)
    return _RowCache(alloc, req_to_token), alloc, row, kv
​
​
class TestFreeKvRowCoalesce(unittest.TestCase):
    def test_adjacent_ranges_split_mid_page_free_every_page_once(self):
        cache, alloc, row, kv = _make_cache_with_row(3 * PAGE_SIZE)
        before = len(alloc.free_pages)
        # 保留前缀 [0, 8);页内未对齐尾段 [8, 9) 与延迟截断尾段 [9, 24) 在 9 处相接,
        # 9 落在 page 1 内部 —— 修复前这里会抛共享页断言。
        cache.free_kv_row(kv, [(8, 9), (9, 3 * PAGE_SIZE)])
        freed = alloc.free_pages[: len(alloc.free_pages) - before]
        # 每个页必须且只能被释放一次:[8, 24) 恰好整页覆盖 page 1 与 page 2
        reference = torch.unique(row[8:] // PAGE_SIZE)
        self.assertTrue(torch.equal(torch.sort(freed)[0], reference))
​
    def test_non_adjacent_ranges_sharing_a_page_still_rejected(self):
        cache, _, _, kv = _make_cache_with_row(3 * PAGE_SIZE)
        # 两段之间留有缺口([8, 9) 与 [12, 24)),共享页是真的会被释放两次,
        # 断言必须保留 —— 这是本 PR 刻意不放松的安全网。
        with self.assertRaises(AssertionError):
            cache.free_kv_row(kv, [(8, 9), (12, 3 * PAGE_SIZE)])

评论区精华

DCP 加宽页与未加宽 cap 网格的接缝导致 double free 正确性

作者在 PR body 中给出完整根因链:DCP 下 allocator 页为 `page_size * dcp_size`(默认 512),radix 树也按该宽度分页,但 Mamba 组件把已完成请求的可缓存长度 cap 在其最后一个 track boundary(576),于是 `cache_finished_req` 交回两段相接区间——页内未对齐尾段与延迟截断尾段——接缝落在同一个 512 token 页内,`free_segments` 抛 `AssertionError: segment at 576 shares a page with the one ending at 576`;作者同时指出若无断言则退化为真 double free。

结论:在 `free_kv_row` 中用 `coalesce_ranges` 先把相邻区间并成一段再交给 allocator,每个页只释放一次,崩溃消失;作者以 Kimi-K3 + `--dcp-size 8` 的 210 个 prefill batch 实测无断言作为验证。 · 已解决

合并相邻区间是否削弱 allocator 的 double-free 保护 设计

关键设计取舍是“在调用方合并”而非“让 allocator 容忍共享页”:`coalesce_ranges` 只合并严格相接(`start == merged[-1][1]`)的区间,两段之间有空隙却共享一页的情况原样保留,继续由 allocator 断言拦截。作者用第二个用例 `test_non_adjacent_ranges_sharing_a_page_still_rejected` 把这条边界固化为回归测试,避免“修崩溃”演变成“掩盖 double free”。

结论:安全网保留,非相邻共页仍然报错;合并范围被限制在可证明等价(语义连续、页覆盖不变)的情形。 · 已解决

评审强度与 CI 覆盖是否足够 测试

实现之外的复核非常薄:唯一 review 是 ispobock 的空正文 `APPROVED`,issue 下唯一评论是 `ispobock: cc @hnyls2002`,没有对合并前置条件、页覆盖等价性等边界提出追问;PR body 的 CI 状态显示 “Latest PR Test (AMD ROCm 10)” 为红(Run #34733362396)且无后续说明;PR 带 `bypass-fastfail` 标签。

结论:合入依据主要是作者自述的 Kimi-K3 实测与新增 CPU 单测,第三方复核有限;AMD ROCm 10 失败原因未在讨论中澄清,属于遗留的可见性缺口。 · 待处理

风险与影响

  1. 覆盖范围仅在 BasePrefixCache.free_kv_row:若存在其它直接调用 free_kv_row_segments 的路径,或子类重写 free_kv_row 后自行构造段列表,传入相邻共页区间仍会命中同一断言。材料未提供完整调用图,建议以 grep 确认所有调用点是否都经过合并。
  2. coalesce_ranges 的前置条件未做校验:它只按 start == merged[-1][1] 合并,隐含依赖输入升序且互不相交(该保证只写在 free_kv_row 的 docstring 里)。若未来调用方传入乱序区间,函数会静默退化为不合并(回到崩溃行为);传入重叠区间则会原样追加,可能产生重叠段。建议补断言或排序兜底。
  3. 合并在页覆盖上的等价性边界:用例覆盖的是“保留前缀正好在页边界结束([0, 8) 之后接 [8, 9) + [9, 24))”的情形,合并后的并集恰好整页覆盖。若接缝两侧合起来并不覆盖整页(例如保留前缀结束在页内),释放语义是否仍与两次独立释放等价,缺少测试覆盖,需结合 allocator free_full_segments 的页粒度语义确认。
  4. 根因仍在,只是被绕开:DCP 下“页按 512 对齐、cache-length cap 按 64 网格”的不一致没有消除,free_kv_row_segments 中 swa_evicted_seqlen % allocator.page_size == 0 这类基于页宽的断言在相近配置(SWA 水位线落在页内)下仍可能被触发。
  5. 回归面较小:非 DCP 或只有单个区间的场景下 coalesce_ranges 是恒等变换,行为变化只发生在“相邻且接缝在页内”的情形,而该情形修复前是必崩,因此引入新回归的风险低。
  6. CI 信号:AMD ROCm 10 一项为红且讨论无结论;PR 依赖 bypass-fastfail 由维护者人工放行合入。

用户侧:使用 Kimi-K3 / Kimi-Linear 等 hybrid Mamba + MLA 模型并开启 --dcp-size 8(叠加 radix cache、HiCache、投机解码)的部署,从“正常负载下一分钟内整机崩溃、所有 scheduler 同时断言”恢复为可稳定服务,属于线上可用性级别的修复。系统侧:改动落在所有前缀缓存共享的 KV 归还入口 BasePrefixCache.free_kv_row,处于内存释放核心路径,但只在区间相邻时改变行为,非 DCP 场景等价于无变化,页级释放的并发与锁语义未触及。团队侧:新增的 CPU 单测给出了一套把线上 DCP 页宽问题按 64 倍缩放到纯 CPU 复现的模板,后续同类“加宽页 vs 未加宽网格”问题可直接复用;同时暴露出 DCP 页几何与其它仍按 64 网格计算的组件之间可能存在系统性不一致,值得作为专项排查。

核心释放路径变更 仅覆盖 free_kv_row 入口 DCP 页宽根因未消除 coalesce_ranges 依赖升序前置条件 缺少端到端 DCP 回归测试 AMD ROCm 10 CI 红

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论