Prhub

#45757 [CPUOffloading] Guard CPU eviction check

原始 PR 作者 varun-sundar-rabindranath 合并时间 2026-06-18 10:35 文件变更 2 提交数 1 评论 6 代码增减 +114 / -0

执行摘要

跟踪 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.

值得精读的性能优化案例:使用轻量计数器避免昂贵遍历的“快速失败”模式。建议在类似缓存管理场景(如显存管理)中复用该设计思路。

讨论亮点

主要讨论三个点:

  • 命名:reviewer orozery 建议将 _num_idle_cache_blocks 改为 _num_evictable_blocks,作者最终采用 _num_evictable_cache_blocks,认为保留 cache 能更好与 free list 区分。
  • 检查顺序:orozery 建议在 prepare_load 中先检查 ref_cnt == 0 再递增,而非递增后再检查 == 1,以提高可读性,作者同意并修改。
  • 断言简化:orozery 提议移除断言中的冗余错误消息,作者采纳。

实现拆解

  1. 初始化计数器:在 CPUOffloadingManager.__init__ 中新增 self._num_evictable_cache_blocks = 0,表示当前缓存中引用计数为 0、可被逐出的块数量。
  2. 加载/卸载路径更新:在 prepare_load 中,当某块的 ref_cnt 从 0 变为 1 时(即首次被加载)递减计数器;在 complete_load 中,当 ref_cnt 从 1 变为 0 时递增加计数器。
  3. 存储路径更新:在 complete_store(成功)时,块变为就绪且引用为 0,递增加计数器;存储失败时不增加。
  4. 逐出早停守卫:在 prepare_store 中,计算需要逐出的块数 num_blocks_to_evict,若该值大于 _num_evictable_cache_blocks 则判定逐出必定失败,直接返回 None,跳过 policy.evict。若逐出成功,按实际逐出块数递减计数器。
  5. 重置清零reset_cache 中同步将计数器置零。
    配套改动:新增 tests/v1/kv_offload/cpu/test_manager.py 中的 test_evictable_cache_block_count,覆盖完整生命周期、并发加载、逐出后失败、reset 及 spy 验证早停路径。
文件 模块 状态 重要度
vllm/v1/kv_offload/cpu/manager.py 卸载管理 modified 6.15
tests/v1/kv_offload/cpu/test_manager.py 测试 modified 6.04

关键符号

CPUOffloadingManager.__init__ CPUOffloadingManager.prepare_store CPUOffloadingManager.prepare_load CPUOffloadingManager.complete_load CPUOffloadingManager.complete_store CPUOffloadingManager.reset_cache

关键源码片段

vllm/v1/kv_offload/cpu/manager.py core-logic

核心变更文件,实现逐出早停守卫和计数器维护

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 test-coverage

新增全面测试,覆盖计数器在各种生命周期和边界条件下的正确性

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

评论区精华

计数器命名建议 style

reviewer orozery 建议将 `_num_idle_cache_blocks` 改为 `_num_evictable_blocks`。作者回复使用 `_num_evictable_cache_blocks`,认为保留 'cache' 能更好与 free list 区分。

结论:最终采用 `_num_evictable_cache_blocks` · 已解决

prepare_load 中检查顺序 设计

orozery 建议在 `block.ref_cnt` 递增之前检查 `== 0` 而非递增后检查 `== 1`,以更清晰表达。作者同意。

结论:改为先检查 ref_cnt == 0 再递增 · 已解决

断言冗余消息移除 style

orozery 认为断言消息 `f"{self._num_evictable_cache_blocks=} can't be negative."` 冗余,建议移除。

结论:移除消息,仅保留简单 assert · 已解决

风险与影响

  1. 计数器一致性:所有修改 ref_cnt 的路径(prepare_loadcomplete_loadcomplete_storeevictreset_cache)均已更新计数器,但若未来新增操作(如 touch)或直接修改 ref_cnt,可能导致计数偏差。
  2. 并发安全:当前 offloading 模型为单线程调度,故未加锁;若后续引入多线程,需确保计数器操作的原子性。
  3. 依赖 policy.evict 行为:计数器递减基于 evict 返回的实际逐出块数,若 policy.evict 因保护集等原因返回少于请求的块数,计数器仍正确;若 evict 返回的块中有非空闲块(违反预期),计数器可能错误。

对用户:大缓存场景下显著降低逐出开销,吞吐量从无法完成到可正常运行(配合 #45765)。
对系统:减少无意义的 policy.evict 调用,降低延迟波动。
对团队:引入轻量计数器,逻辑清晰,易于维护和扩展。

计数器路径覆盖 并发安全假设 policy 行为依赖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论