初始问题
代码中有一个 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 是独立的。
- GPU 块:request 在运行时 ref_cnt > 0,不会被驱逐
- CPU 块:store 完成后 ref_cnt 降到 0,可以被其他请求重新分配
探索历程
第一阶段:发现问题
初始方案(有 bug):
- Phase 1a:重新扫描
0..already_stored_g的旧 block - Phase 1b:扫描
already_stored_g..ready_blocks_g的新 block - 游标用
max()确保只前进不回退 - 添加
_had_recent_evictionflag 检测 LRU 活动
发现两个 bug:
-
标志永远为 False:
_had_recent_eviction在get_new_blocks()末尾被重置, 但_maybe_evict_cached_block()只在get_new_blocks()内部被调用。 结果:标志在同一个函数内被设置又清除,manager 检查时永远看到 False → Phase 1a 是死代码。 -
游标永久卡住:
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 修复:
- 从
get_new_blocks()移除标志重置 - 添加
consume_eviction_signal()方法 — 原子性地读取并重置标志 - Manager 在请求循环前一次性消费标志,快照供所有请求共享
Bug 2 修复:
advanced_per_group[g]初始化为already_stored_g(绝对位置)- Phase 1a 不修改它(重扫描不推进游标)
- Phase 1b 每处理一个块 +1
- 最终赋值用
=替代max()
第三阶段:验证
测试证明驱逐场景真实存在:
test_active_request_blocks_can_be_evicted:证明 active request 的 CPU 块确实会被驱逐test_phase1a_restore_enables_cache_hit:端到端证明 re-store 后新请求能命中缓存
之前的错误理解:
- 曾以为 “active request 的 block 不会被驱逐” → 这是 GPU 块的 ref_cnt,不是 CPU 块的
- CPU 块在 store 完成后就释放了,可以被其他请求重新分配
技术细节
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 逻辑)