# PR #45757 完整报告

- 仓库：`vllm-project/vllm`
- 标题：[CPUOffloading] Guard CPU eviction check
- 合并时间：2026-06-18 10:35
- 原文链接：http://prhub.com.cn/vllm-project/vllm/pull/45757

---

# 执行摘要

- 一句话：跟踪 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.

# 实现拆解

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`（模块 卸载管理；类别 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`

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

```python
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`

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

```python
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

```

# 评论区精华

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

- 计数器命名建议 (style): 最终采用 `_num_evictable_cache_blocks`
- prepare_load 中检查顺序 (design): 改为先检查 ref_cnt == 0 再递增
- 断言冗余消息移除 (style): 移除消息，仅保留简单 assert

# 风险与影响

- 风险：
 1. **计数器一致性**：所有修改 `ref_cnt` 的路径（`prepare_load`、`complete_load`、`complete_store`、`evict`、`reset_cache`）均已更新计数器，但若未来新增操作（如 `touch`）或直接修改 `ref_cnt`，可能导致计数偏差。
 2. **并发安全**：当前 offloading 模型为单线程调度，故未加锁；若后续引入多线程，需确保计数器操作的原子性。
 3. **依赖 `policy.evict` 行为**：计数器递减基于 `evict` 返回的实际逐出块数，若 `policy.evict` 因保护集等原因返回少于请求的块数，计数器仍正确；若 `evict` 返回的块中有非空闲块（违反预期），计数器可能错误。
 - 影响：**对用户**：大缓存场景下显著降低逐出开销，吞吐量从无法完成到可正常运行（配合 #45765）。
**对系统**：减少无意义的 `policy.evict` 调用，降低延迟波动。
**对团队**：引入轻量计数器，逻辑清晰，易于维护和扩展。

- 风险标记：计数器路径覆盖 , 并发安全假设 , policy 行为依赖

# 关联脉络

- PR #39726 [SimpleCPUOffloadConnector]: Add support for reset_cache(): 该 PR 为 reset_cache 提供了基础支持，本 PR 在 reset_cache 中同步计数器清零。