执行摘要
- 一句话:跟踪 evictable 块数,提前跳过无效 eviction 检查
- 推荐动作:值得精读的性能优化案例:使用轻量计数器避免昂贵遍历的“快速失败”模式。建议在类似缓存管理场景(如显存管理)中复用该设计思路。
功能与动机
Eviction can be expensive when the CPU cache is big and full. The eviction logic walks all the blocks to check if there is a candidate. The PR adds logic to track the idle cache blocks based on the load and store requests and escapes the check if the eviction is guaranteed to fail.
实现拆解
- 初始化计数器:在
CPUOffloadingManager.__init__ 中新增 self._num_evictable_cache_blocks = 0,表示当前缓存中引用计数为 0、可被逐出的块数量。
- 加载/卸载路径更新:在
prepare_load 中,当某块的 ref_cnt 从 0 变为 1 时(即首次被加载)递减计数器;在 complete_load 中,当 ref_cnt 从 1 变为 0 时递增加计数器。
- 存储路径更新:在
complete_store(成功)时,块变为就绪且引用为 0,递增加计数器;存储失败时不增加。
- 逐出早停守卫:在
prepare_store 中,计算需要逐出的块数 num_blocks_to_evict,若该值大于 _num_evictable_cache_blocks 则判定逐出必定失败,直接返回 None,跳过 policy.evict。若逐出成功,按实际逐出块数递减计数器。
- 重置清零:
reset_cache 中同步将计数器置零。
配套改动:新增 tests/v1/kv_offload/cpu/test_manager.py 中的 test_evictable_cache_block_count,覆盖完整生命周期、并发加载、逐出后失败、reset 及 spy 验证早停路径。
关键文件:
vllm/v1/kv_offload/cpu/manager.py(模块 卸载管理;类别 source;类型 core-logic;符号 init, prepare_load, complete_load, prepare_store): 核心变更文件,实现逐出早停守卫和计数器维护
tests/v1/kv_offload/cpu/test_manager.py(模块 测试;类别 test;类型 test-coverage;符号 test_evictable_cache_block_count, spy_evict): 新增全面测试,覆盖计数器在各种生命周期和边界条件下的正确性
关键符号:CPUOffloadingManager.init, CPUOffloadingManager.prepare_store, CPUOffloadingManager.prepare_load, CPUOffloadingManager.complete_load, CPUOffloadingManager.complete_store, CPUOffloadingManager.reset_cache
关键源码片段
vllm/v1/kv_offload/cpu/manager.py
核心变更文件,实现逐出早停守卫和计数器维护
class CPUOffloadingManager(OffloadingManager):
def __init__(self, ...):
# ... 其他初始化
# 新增计数器:跟踪当前缓存中引用计数为 0 的块数,即可以逐出的块数
self._num_evictable_cache_blocks: int = 0
# ...
@override
def prepare_store(self, keys, req_context) -> PrepareStoreOutput | None:
# ... filter keys_to_store ...
if not keys_to_store:
return PrepareStoreOutput(keys_to_store=[], store_spec=...)
num_blocks_to_evict = len(keys_to_store) - self._get_num_free_blocks()
to_evict = []
if num_blocks_to_evict > 0:
# 【关键早停】如果需要的逐出量超过可逐出块数,逐出必定失败,直接返回 None
if num_blocks_to_evict > self._num_evictable_cache_blocks:
return None
protected = set(keys)
evicted = self._policy.evict(num_blocks_to_evict, protected)
if evicted is None:
return None
# 逐出成功后,按实际逐出块数递减计数器
self._num_evictable_cache_blocks -= len(evicted)
for key, block in evicted:
self._free_block(block)
to_evict.append(key)
# ... 继续分配和返回
@override
def prepare_load(self, keys, req_context) -> LoadStoreSpec:
blocks = []
for key in keys:
block = self._policy.get(key)
# ... 断言
# 在增加引用之前检查是否为 0(首次加载),如果是则递减空闲块计数
if block.ref_cnt == 0:
self._num_evictable_cache_blocks -= 1
block.ref_cnt += 1
blocks.append(block)
return self._get_load_store_spec(keys, blocks)
@override
def complete_load(self, keys, req_context) -> None:
for key in keys:
block = self._policy.get(key)
block.ref_cnt -= 1
if block.ref_cnt == 0:
self._num_evictable_cache_blocks += 1
tests/v1/kv_offload/cpu/test_manager.py
新增全面测试,覆盖计数器在各种生命周期和边界条件下的正确性
def test_evictable_cache_block_count():
"""
验证 _num_evictable_cache_blocks 在完整 store/load 生命周期、
逐出、失败 store、并发加载、reset_cache 以及 prepare_store 早停路径中的正确性。
"""
manager = make_cpu_manager(num_blocks=4, cache_policy="lru")
assert manager._num_evictable_cache_blocks == 0
# 模拟 3 个块被占用(prepare_store → complete_store 后变为 idle)
manager.prepare_store(to_keys([1, 2, 3]), _EMPTY_REQ_CTX)
assert manager._num_evictable_cache_blocks == 0
manager.complete_store(to_keys([1, 2, 3]), _EMPTY_REQ_CTX)
assert manager._num_evictable_cache_blocks == 3
# 加载块 1 两次:第一次从 0→1 计数减 1,第二次不额外减
manager.prepare_load(to_keys([1]), _EMPTY_REQ_CTX)
assert manager._num_evictable_cache_blocks == 2
manager.prepare_load(to_keys([1]), _EMPTY_REQ_CTX) # 第二次并发加载
assert manager._num_evictable_cache_blocks == 2 # 不重复递减
# 第一次 complete_load 引用未归零,计数不变;第二次归零,加 1
manager.complete_load(to_keys([1]), _EMPTY_REQ_CTX)
assert manager._num_evictable_cache_blocks == 2
manager.complete_load(to_keys([1]), _EMPTY_REQ_CTX)
assert manager._num_evictable_cache_blocks == 3
# 逐出后计数减少
manager.prepare_store(to_keys([4, 5, 6]), _EMPTY_REQ_CTX)
assert manager._num_evictable_cache_blocks == 1
# 失败 store 不恢复计数(块被丢弃)
manager.complete_store(to_keys([4, 5, 6]), _EMPTY_REQ_CTX, success=False)
assert manager._num_evictable_cache_blocks == 1
# reset_cache 清零
manager.reset_cache()
assert manager._num_evictable_cache_blocks == 0
# ** spy 验证早停路径:当空闲块为 0 时 prepare_store 不调用 policy.evict **
manager.prepare_store(to_keys([10, 11, 12]), _EMPTY_REQ_CTX)
manager.complete_store(to_keys([10, 11, 12]), _EMPTY_REQ_CTX)
manager.prepare_load(to_keys([10, 11, 12]), _EMPTY_REQ_CTX)
assert manager._num_evictable_cache_blocks == 0
evict_called = False
original_evict = manager._policy.evict
def spy_evict(*args, **kwargs):
nonlocal evict_called
evict_called = True
return original_evict(*args, **kwargs)
manager._policy.evict = spy_evict
# 此时所有块被加载,空闲块为 0,prepare_store 需要 1 个新块,必须逐出 1 个
# 但 _num_evictable_cache_blocks == 0,所以应当直接返回 None
result = manager.prepare_store(to_keys([13]), _EMPTY_REQ_CTX)
assert result is None
assert not evict_called # 验证没有调用 policy.evict
评论区精华
主要讨论三个点:
风险与影响
关联脉络
- PR #39726 [SimpleCPUOffloadConnector]: Add support for reset_cache(): 该 PR 为 reset_cache 提供了基础支持,本 PR 在 reset_cache 中同步计数器清零。
参与讨论