Prhub

#56794 [Bugfix][Kimi-K3] Keep transient checkpoints out of prefix-cache eviction

原始 PR 作者 Sy0307 合并时间 2026-09-15 14:18 文件变更 3 提交数 5 评论 7 代码增减 +94 / -2

执行摘要

修复 KDA 中段 checkpoint 挤占 prefix cache 淘汰热前缀

Kimi-K3 的混合 attention 路径中,FlashKDA 会为当前 prefill 创建内部 checkpoint,让本次 prefill 从某个循环状态边界继续,而无需额外调度步骤。PR body 指出:“A checkpoint in the middle of a prompt is useful to the current request, but it is not necessarily a reusable shared-prefix boundary.” 在改动之前,这类中段 checkpoint 会被重新打 key 并注册为普通 prefix-cache block,随后参与与热前缀相同的 GPU LRU 淘汰池,形成 “middle checkpoint -> hash -> prefix-cache LRU -> evicts hot prefix -> later local hit becomes a miss -> extra load or prefill work” 的退化链条。当 prefix_cache_retention_interval == 0 时问题更突出:中段 checkpoint 本就不该按 dense 语义被保留,但其临时 hash 仍让它成为淘汰候选。修复目标是让中段 checkpoint 只服务当前请求,同时保持 prompt 末尾与 shared-prefix 边界 checkpoint 的既有发布行为,并且不关闭 KDA checkpoint 计算、不改变 FlashKDA 写入的状态。

值得精读,但重点不在改动规模,而在三处设计取舍:

  1. 看 MambaManager._cache_partial_tail_block 的必填参数设计——把 retention 策略从隐式状态提升为强制显式契约,是这类“静默退化”问题值得复用的防回归模式;
  2. 看短路分支为何要在 return None 之前调用 _maybe_evict_cached_block——理解“slot 被 checkpoint state 覆盖前必须摘掉旧 hash”这一不变量,以及用私有 API 替代 evict_blocks 的取舍;
  3. 看 test_transient_checkpoint_evicts_retained_boundary_hash 如何把 checkpoint slot 与 shared-prefix 边界块强行重合,这是一个容易被漏掉的交叉场景。

审阅建议:如果所在团队使用 Mamba/KDA 混合模型并配置了 latest-only 保留,建议在本 PR 之后补一轮模型级 hybrid KDA 工作负载的前缀命中率与 ITL 回归;对 num_tokens 语义敏感的 chunked prefill / resume 路径,建议额外构造 sibling 复用用例确认例外条件覆盖充分。

讨论亮点

本 PR 的 inline review 评论为空(review_comments_count = 0),有价值的交锋集中在 Issue 评论与维护者直接推送的改动上:

  1. 维护者直接改设计(ZJY0516):“I directly pushed some changes:
    - Made retention_interval a required parameter of _cache_partial_tail_block so future callers can't silently fall back to dense behavior - Replaced the block_pool.evict_blocks(...) with a direct block_pool._maybe_evict_cached_block(checkpoint_block) call - Added regression tests”。两处调整分别针对“未来调用点静默退化”和“淘汰粒度过大”,并当场补齐回归测试。

  2. CI 失败归因(Sy0307):“Follow-up: updated the existing Mamba checkpoint regression test to pass the newly required retention_interval argument. The earlier CI failure was a stale direct test call; no production logic changed in this fix.” 说明必填参数确实把一个已存在的直调测试点暴露了出来。

  3. 未解决的验证缺口:PR body 自述 “Model-scale performance validation is still required on a representative hybrid KDA workload”,并声明 “This change was prepared with AI assistance; the author should review the complete diff and test results before merging.”,还特别强调 B200 上的 7.78% 提升是 workload-specific、并非隔离测量。

  4. 评审信号偏弱:claude[bot] 因 fork PR 关闭自动 review(“This pull request is from a fork — automated review is disabled.”),最终由 gcanlin(APPROVED,并 cc @ZJY0516 @ZeldaHuang)与 ZJY0516(APPROVED)两名维护者人工确认后合并。

实现拆解

  1. 入口透传保留策略:vllm/v1/core/single_type_kv_cache_manager.py 中 MambaManager.cache_blocks 在 mamba_cache_mode == "align" 分支调用 _cache_partial_tail_block 时新增 retention_interval=retention_interval。该值来自 coordinator 传入的 cache_blocks 参数,与 super().cache_blocks(...)、replay_boundaries 同源,使 checkpoint 发布逻辑首次具备当前保留策略的上下文。

  2. 核心分支:中段 checkpoint 降级为 request-local:_cache_partial_tail_block 的 retention_interval 由可选改为必填位置参数(int | None,无默认值),随后在已有的 checkpoint 分支内新增三条件短路:retention_interval == 0、num_tokens < request.num_prompt_tokens(仍在 prompt 内部)、checkpoint_position != request.shared_prefix_boundary(不是共享前缀边界)。三者同时成立时该 checkpoint 只服务当前请求,直接返回 None,因此既不会写入 cached_blocks_this_step,也不会进入普通 prefix cache 的 LRU 淘汰池。将参数设为必填是维护者刻意的防回归设计:未来新增调用点必须显式声明策略,不会静默退回 dense(None)发布行为。

  3. 清理同 slot 上的陈旧 hash:短路分支在 return None 之前先执行 self.block_pool._maybe_evict_cached_block(checkpoint_block)。原因是该 slot 可能已被本步 full-block 遍历打上 hash(例如某个可达边界,或与 shared_prefix_boundary 对齐的块),而 checkpoint state 随后会覆盖这个 block;若不摘除,缓存中会留下一个指向错误 state 的条目。这里用 BlockPool 的私有方法直接做单块失效,替代讨论中被换掉的 block_pool.evict_blocks(...) 批量接口,避免触发 connector 可见的批量驱逐语义。

  4. 例外保持与语义边界:prompt 末尾的 checkpoint(num_tokens >= request.num_prompt_tokens)与位于 request.shared_prefix_boundary 的 checkpoint 不受影响,仍走原来的 cache_partial_block(..., replace_existing_hashes=True) 路径,保证 sibling 恢复与 shared-prefix 复用不被削弱;dense 保留(retention_interval is None)路径完全不变。这一分支只改变“是否进入普通 prefix cache”,不改变 KDA checkpoint 本身的计算与 _pending_boundary_state_offloads 上报。

  5. 测试与配置配套:tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py 新增 71 行测试——test_internal_checkpoint_publication_respects_retention 以 (None, True) / (0, False) 参数化验证“中段 transient checkpoint 是否发布”与“prompt 末尾 checkpoint 始终发布”,test_transient_checkpoint_evicts_retained_boundary_hash 覆盖 checkpoint slot 与 shared-prefix 边界块重合、旧 boundary hash 必须被摘掉的场景;tests/v1/core/prefix_cache/test_mamba_eagle_resume_checkpoint.py 把直接调用私有方法的测试签名同步为显式传 retention_interval=manager.coordinator.retention_interval。无新增配置项、无 schema 或部署改动,复用 prefix_cache_retention_interval == 0 的既有语义;CI 由维护者触发两次 Buildkite(#88813、#88907)后通过。

文件 模块 状态 重要度
vllm/v1/core/single_type_kv_cache_manager.py 前缀缓存 modified 6.85
tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py 前缀缓存测试 modified 6.56
tests/v1/core/prefix_cache/test_mamba_eagle_resume_checkpoint.py 恢复点测试 modified 4.45

关键符号

MambaManager.cache_blocks MambaManager._cache_partial_tail_block test_internal_checkpoint_publication_respects_retention test_transient_checkpoint_evicts_retained_boundary_hash test_a_junction_past_the_prompt_falls_back_and_registers_nothing

关键源码片段

vllm/v1/core/single_type_kv_cache_manager.py core-logic

唯一的源码改动文件:`MambaManager.cache_blocks` 透传 retention_interval,`_cache_partial_tail_block` 新增“中段 checkpoint 不进入 prefix cache 并摘除陈旧 hash”的核心分支,是本次修复的全部生产逻辑。

# vllm/v1/core/single_type_kv_cache_manager.py —— MambaManager
​
def cache_blocks(
    self,
    request: Request,
    num_tokens: int,
    retention_interval: int | None = None,
    *,
    replay_boundaries: Sequence[int],
) -> None:
    num_cached_blocks_before = self.num_cached_block.get(request.request_id, 0)
    super().cache_blocks(
        request,
        num_tokens,
        retention_interval=retention_interval,
        replay_boundaries=replay_boundaries,
    )
    num_cached_blocks_after = self.num_cached_block.get(request.request_id, 0)
    if self.mamba_cache_mode == "align":
        # 关键改动:把 coordinator 传入的保留策略透传给 checkpoint 发布逻辑,
        # 使 partial tail / 内部 checkpoint 能感知 retention_interval == 0
        partial_hash = self._cache_partial_tail_block(
            request, num_tokens, retention_interval=retention_interval
        )
        # 只有真正发布的 hash 才记入本步,transient checkpoint 返回 None 后被自然跳过
        if partial_hash is not None:
            self.cached_blocks_this_step.add(partial_hash)
    # ... 后续 retained boundary 的 offload 上报逻辑保持不变
​
​
def _cache_partial_tail_block(
    self,
    request: Request,
    num_tokens: int,
    # 必填参数(无默认值):未来新增调用点必须显式声明保留策略,
    # 防止静默回退到 dense(None)的发布行为
    retention_interval: int | None,
) -> BlockHashWithGroupId | None:
    hash_block_size = self.block_pool.hash_block_size
    # 优先处理 FlashKDA 的内部 checkpoint:按导出边界重新给它打 key
    checkpoint = self._checkpoints.get(request.request_id)
    if checkpoint is not None:
        checkpoint_position, checkpoint_idx = checkpoint
        blocks = self.req_to_blocks[request.request_id]
        assert 0 <= checkpoint_idx < len(blocks)
        checkpoint_block = blocks[checkpoint_idx]
        if (
            # 仅 latest-only 稀疏保留下收紧;dense(None)路径行为不变
            retention_interval == 0
            # 只有 prompt 中段才视为 request-local,prompt 末尾仍按原逻辑发布
            and num_tokens < request.num_prompt_tokens
            # shared-prefix 边界是 sibling 的恢复点,必须继续发布
            and checkpoint_position != request.shared_prefix_boundary
        ):
            # 该 slot 可能已被本步 full-block pass 打上 hash(例如某个可达边界),
            # 而 checkpoint state 马上会覆盖这个 block;旧 hash 必须一并摘掉,
            # 否则缓存里会留下指向错误 state 的条目
            self.block_pool._maybe_evict_cached_block(checkpoint_block)
            # 返回 None:不写入 cached_blocks_this_step,也不进入普通 LRU 淘汰池
            return None
        if checkpoint_block.block_hash_num_tokens == checkpoint_position:
            return None
        return self.block_pool.cache_partial_block(
            request=request,
            block=checkpoint_block,
            num_tokens=checkpoint_position,
            kv_cache_group_id=self.kv_cache_group_id,
            block_size=self.block_size,
            replace_existing_hashes=True,
        )
    # ... 非 checkpoint 的 partial tail / hash 对齐分支保持原样
tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py test-coverage

新增 71 行回归测试,把“中段 checkpoint 不发布 / prompt 末尾与 shared-prefix 边界仍发布”以及“checkpoint slot 上的陈旧 boundary hash 必须被摘掉”两个不变量固化下来。

# tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py
​
@pytest.mark.parametrize(
    "retention_interval,transient_published",
    [(None, True), (0, False)],
)
def test_internal_checkpoint_publication_respects_retention(
    retention_interval, transient_published
):
    """retention_interval == 0 时,prompt 中段的内部 checkpoint 属于
    request-local,不得进入 prefix cache;prompt 末尾的 checkpoint
    保持原有发布行为;dense(None)路径不变。
    """
    hash_block_size = 16
    manager = make_full_mamba_manager(
        dcp_world_size=1,
        hash_block_size=hash_block_size,
        full_block_size=hash_block_size,
        mamba_block_size=32,
        num_prefill_checkpoint_blocks=1,
    )
    manager.coordinator.retention_interval = retention_interval
​
    request = make_request("producer", list(range(240)), hash_block_size, sha256)
    computed_blocks, num_computed, _ = manager.get_computed_blocks(request)
​
    # 中段 chunk:在 112 处导出一个 transient checkpoint
    assert manager.allocate_slots(request, 128, num_computed, computed_blocks)
    transient_hash = request.block_hashes[112 // hash_block_size - 1]
    transient_hit = manager.block_pool.get_cached_block(transient_hash, [1])
    # 两种策略的判定差异正好被这一行断言钉住
    assert (transient_hit is not None) == transient_published
​
    request.num_computed_tokens = 128
    manager.new_step_starts()
​
    # 最后一段:224 处的 prompt 末尾 checkpoint 在两种策略下都必须发布
    assert manager.allocate_slots(request, 112) is not None
    end_hash = request.block_hashes[224 // hash_block_size - 1]
    assert manager.block_pool.get_cached_block(end_hash, [1]) is not None
​
​
def test_transient_checkpoint_evicts_retained_boundary_hash():
    """retention_interval == 0 时,checkpoint slot 可能与本步 full-block
    遍历刚打上 hash 的可达边界块重合;checkpoint state 会覆盖该 slot,
    因此陈旧的 boundary hash 必须被摘除,而不是留着指向错误的 state。
    """
    hash_block_size = 16
    manager = make_full_mamba_manager(
        dcp_world_size=1,
        hash_block_size=hash_block_size,
        full_block_size=hash_block_size,
        mamba_block_size=32,
        num_prefill_checkpoint_blocks=1,
    )
    manager.coordinator.retention_interval = 0
​
    request = make_request("producer", list(range(240)), hash_block_size, sha256)
    # block 2 恰好结束于 shared-prefix 交界 96,会被 full-block pass 记为保留边界
    request.shared_prefix_boundary = 96
    computed_blocks, num_computed, _ = manager.get_computed_blocks(request)
    assert manager.allocate_slots(request, 128, num_computed, computed_blocks)
​
    # 同一个 slot 又是 state@112 的 transient checkpoint:
    # 边界 hash 与 checkpoint 自身都必须保持未发布
    boundary_hash = request.block_hashes[96 // hash_block_size - 1]
    assert manager.block_pool.get_cached_block(boundary_hash, [1]) is None
    checkpoint_hash = request.block_hashes[112 // hash_block_size - 1]
    assert manager.block_pool.get_cached_block(checkpoint_hash, [1]) is None
tests/v1/core/prefix_cache/test_mamba_eagle_resume_checkpoint.py test-coverage

为适配 `_cache_partial_tail_block` 的必填 `retention_interval` 参数而同步更新直接调用点;该文件也是必填参数设计暴露出的唯一 CI 失败来源。

# tests/v1/core/prefix_cache/test_mamba_eagle_resume_checkpoint.py
# 私有方法签名变化后,测试中的直接调用点必须显式传保留策略,
# 这也是把 retention_interval 设为必填参数后暴露出的唯一 CI 失败点。
_prefill(manager, stub, request)
mamba = manager.coordinator.single_type_managers[1]
running = request.num_computed_tokens
assert not mamba.req_to_blocks["r"][running // block_size].is_null
request.shared_prefix_boundary = running
assert (
    mamba._cache_partial_tail_block(
        request,
        running,
        # 从 coordinator 取真实策略,与生产调用路径保持一致
        retention_interval=manager.coordinator.retention_interval,
    )
    is None
)

评论区精华

把 retention_interval 设为 _cache_partial_tail_block 的必填参数 设计

维护者 ZJY0516 直接推送改动并说明:“Made retention_interval a required parameter of _cache_partial_tail_block so future callers can't silently fall back to dense behavior”。原作者在后续评论中确认该改动立刻暴露了一个陈旧直调测试:“updated the existing Mamba checkpoint regression test to pass the newly required retention_interval argument”。

结论:采纳:`_cache_partial_tail_block(request, num_tokens, retention_interval: int | None)` 取消默认值,调用方必须显式声明保留策略,避免未来新增调用点静默退回 dense 发布路径。 · 已采纳并合入

用 _maybe_evict_cached_block 替代面向 connector 的 evict_blocks 设计

ZJY0516:“Replaced the `block_pool.evict_blocks(...)` with a direct `block_pool._maybe_evict_cached_block(checkpoint_block)` call”。评论中未展开原因,从代码语义看是把淘汰范围从批量接口收窄为“只摘掉 checkpoint 这一个 slot 上的 hash”,避免误伤同批次其他块,也避免走 connector 可见的驱逐路径。

结论:采纳现有实现,但引入了对 BlockPool 私有方法的耦合:该分支假设“该 slot 上的 hash 属于本 KV cache group”,在多 group / DCP 场景下若 BlockPool 实现调整需要重新审视。 · 已采纳(存在长期耦合风险)

必填参数引发的 CI 失败与修复 测试

Sy0307:“The earlier CI failure was a stale direct test call; no production logic changed in this fix.” 随后触发 /ci run,Buildkite #88907(commit be1a1c94)通过;此前维护者已触发过 #88813(commit b1bca7b1)。

结论:已解决:更新 `test_mamba_eagle_resume_checkpoint.py` 中 `_cache_partial_tail_block(request, running)` 的调用,补传 `retention_interval=manager.coordinator.retention_interval`,生产逻辑未变。 · 已解决

模型级性能验证仍待补 性能

PR body:“Model-scale performance validation is still required on a representative hybrid KDA workload.” 同时给出 B200 TP8/PP2/DCP8 的对比(禁用 checkpoint 基线 1013.00 tokens/s,修复后 1091.80 tokens/s,+7.78%),但明确声明这是 workload-specific 数据,另一组 1108.89 tokens/s 仅作上下文参考。作者还声明 “This change was prepared with AI assistance”。

结论:未解决:合入时以单测与 CI 为主要验证手段,模型级混合 KDA 工作负载的性能回归尚待补做,性能数字不能作为该 15 行改动的隔离收益证据。 · 未解决

fork PR 自动评审关闭,依赖人工批准 other

claude[bot]:“This pull request is from a fork — automated review is disabled. A repository maintainer can comment `@claude review` to run a one-time review.” 随后 gcanlin APPROVED 并 “cc @ZJY0516 @ZeldaHuang”,ZJY0516 也给出 APPROVED,无 inline 评论。

结论:评审通过但缺少自动化深度审阅;实质设计调整来自维护者直接推送而非评审讨论,测试覆盖由维护者补充。 · 已解决

风险与影响

  1. 缓存淘汰组成变化带来的命中率回归:中段 checkpoint 不再进入 LRU 池,短期内会同时减少“挤占热前缀”和“多一个可命中的 hash”,若某些 sibling/resume 场景实际依赖中段 checkpoint 命中,就会从“命中”退化为重新 prefill 或 load。测试只覆盖了 shared_prefix_boundary 与 prompt-end 两个例外,未覆盖多层 MTP / 多 group 的 sibling 复用组合。

  2. 依赖 BlockPool 私有方法:新增分支直接调用 self.block_pool._maybe_evict_cached_block(checkpoint_block)。这是私有 API,绕过面向 connector 的 evict_blocks(...),一旦 BlockPool 内部 hash 归属(hybrid allocator、DCP/多 KV cache group)实现调整,此处“该 slot 上的 hash 属于本 group”的隐含假设可能失效。

  3. “中段”判定条件脆弱:num_tokens < request.num_prompt_tokens 依赖调用方传入的 num_tokens 语义。在 chunked prefill、resume/reprefill 场景中,coordinator 可能传入 num_computed - num_reprefillable 而非 chunk 末尾(test_mamba_eagle_resume_checkpoint.py 的 docstring 明确把这类差异描述为 “fail silently -- as lost reuse, not as an error”),边界误判不会报错,只会丢复用。

  4. 性能结论不可外推:PR body 的 1013.00 → 1091.80 tokens/s(+7.78%)是与“禁用 checkpoint 的诊断基线”对比,另一组禁用 GEMM-AR 的 1108.89 tokens/s 被作者标注为 “included only as context and is not an isolated measurement of this patch”,因此不能据此认为该 15 行改动独立带来该幅度收益。

  5. 测试与 CI 覆盖局限:验证集中在 tests/v1/core/prefix_cache/ 单测与 Buildkite CI;作者已声明模型级 hybrid KDA 工作负载的性能验证仍待补,属明确的遗留验证缺口。

对线上服务:Kimi-K3 等混合 attention(FlashKDA/Mamba + attention)模型、且把 prefix_cache_retention_interval 配为 0 并启用内部 checkpoint 的部署受益最大——中段 checkpoint 不再挤出热前缀,减少本地前缀 miss 与随之而来的额外 load/prefill;未启用该配置或使用 dense 保留的部署行为完全不变。

对系统模块:改动落在 prefix-cache 生命周期层,影响 Mamba/KDA block 的“是否可被共享复用”判定;_pending_boundary_state_offloads 的上报路径与 KDA checkpoint 计算本身不受影响,但可通过 offload 数据观察到发布边界的变化。KV connector 侧(MooncakeStore/NIXL 等)的边界配对逻辑不在此 PR 范围内。

对团队与后续开发:_cache_partial_tail_block 的签名变为必须显式传 retention_interval,任何新增调用点(包括测试直调)都需要同步适配,这已在本次 CI 中真实触发过一次失败;同时把“保留策略”从隐式全局状态改为显式参数,降低了后续重构时静默退化的概率。整体影响面为一个受三重条件保护的窄分支,回归风险可控但需要在模型级 workload 上补做验证。

核心缓存生命周期变更 依赖 BlockPool 私有方法 中段判定依赖 num_tokens 语义 缺少模型级性能回归 性能收益非隔离测量

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论