初始问题

代码中有一个 FIXME(manager.py):

# FIXME: handle CPU cache eviction, where
# num_stored_blocks can be stale and omit evicted blocks in
# the middle of the request.

问题场景:CPU 块在 store 完成后被释放(ref_cnt=0),后续被 get_new_blocks() 重新分配时, _maybe_evict_cached_block() 会移除缓存条目。这意味着原请求的 GPU 块仍在运行(ref_cnt > 0), 但它的 CPU 缓存条目已经丢失。后续相同前缀的请求查缓存 → miss → 需要重新 prefill。

关键洞察:GPU ref_cnt 和 CPU ref_cnt 是独立的

探索历程

第一阶段:发现问题

初始方案(有 bug):

发现两个 bug

  1. 标志永远为 False_had_recent_evictionget_new_blocks() 末尾被重置, 但 _maybe_evict_cached_block() 只在 get_new_blocks() 内部被调用。 结果:标志在同一个函数内被设置又清除,manager 检查时永远看到 False → Phase 1a 是死代码。

  2. 游标永久卡住max(old_cursor, advanced_per_group[g]) 中, 当 Phase 1a 不执行时,advanced_per_group[g] 只是 Phase 1b 的增量(delta), 但 max() 把它当作绝对位置。当 delta < already_stored_g 时,游标永远不前进 → O(n²) 重复扫描。

第二阶段:修复

Bug 1 修复

Bug 2 修复

第三阶段:验证

测试证明驱逐场景真实存在

之前的错误理解

技术细节

CPU vs GPU ref_cnt 独立性

时刻1: req_a 存储 2 块到 CPU 块 10, 11
       → store 完成后: CPU 块 10, 11 ref_cnt = 0(释放到空闲队列)
       → req_a 仍在运行: GPU 块 ref_cnt > 0

时刻2: req_b 需要 4 块 → get_new_blocks(4)
       → 取走 CPU 块 10, 11
       → _maybe_evict_cached_block() 移除缓存条目
       → req_a 的缓存条目没了!(虽然 req_a 还在跑)

时刻3: req_c(和 req_a 相同前缀)来查缓存
       → miss → 需要重新 prefill ← 这就是 FIXME 描述的问题

Phase 1a 修复后

时刻3: req_a 继续运行 → Phase 1a 检测到驱逐
       → 重扫描已存储块 → 发现缓存条目丢失
       → 重新分配到新的 CPU 块 → 恢复缓存条目

时刻4: req_c 来查缓存
       → hit → 直接 load,不需要重新 prefill ✓

游标修复对比

场景 旧 max() 新 =
Phase 1a 执行,Phase 1b 扫 2 块 max(4, 6) = 6 6
Phase 1a 不执行,Phase 1b 扫 2 块 max(4, 2) = 4 ✗ 卡住 4 + 2 = 6
Phase 1a 不执行,Phase 1b 扫 0 块 max(4, 0) = 4 4 + 0 = 4

最终 PR

提交: PR #47235 (vllm-project/vllm) 改动: 3 个文件,+521/-40 行 测试: 4 个新测试全部通过 备选方案: PR #47234(只文档化驱逐行为,不加 re-store 逻辑)