Prhub

#54021 [Bugfix][KV Offload] Handle padded GPU cache storage

原始 PR 作者 ZJY0516 合并时间 2026-08-27 18:53 文件变更 2 提交数 2 评论 6 代码增减 +39 / -3

执行摘要

按逻辑大小截断 KV 卸载存储视图,修复对齐填充导致启动失败

PR body 明确说明目的是修复 #51718 引入的 nvidia-h100-lm-eval-kv-offload-large 启动回归,该失败可在 Build #85765 与 #85757 于 main 分支稳定复现。根因是 SimpleCPUOffloadWorker.register_kv_caches() 把 4 KiB 取整后的整个 backing storage reshape 为逻辑 cache regions:DeepSeek-V4 有 2,816 trailing alignment bytes,其 33,626,157,056 字节物理存储无法被配置的 33,626,154,240 字节逻辑分配整除,服务启动即失败。

值得精读。虽然改动量小,但 review 迭代过程极具教学价值:第一版用 quotient 处理 padding 的方案在 padding 小于 region 时成立,却在 padding 大于一个 region 时失效;最终以逻辑分配大小为视图边界,是更本质的解法。关注点:1)register_kv_caches() 中从物理存储到逻辑视图的边界处理模式;2)kv_cache_tensors[0].size 作为配置契约的用法;3)回归测试对"填充构成完整 region"这一边界场景的构造方式。

讨论亮点

review 中有一轮关键交锋,直接改变了修复方案:

chatgpt-codex-connector[bot](P1):当 region_bytes 不大于分配器的尾部 padding 时——例如小模型或低 --num-gpu-blocks-override——quotient 会把完整 padding 块计为额外 region。allocate_kv_cache() 把 backing tensor 按 4 KiB 取整,而 kv_cache_config.kv_cache_tensors[0].size 保留逻辑大小;虚假 region 会膨胀 total_bytes_per_block,导致 worker 分配的 CPU/disk 块槽少于调度器推导值,最终允许调度下发的块 ID 越界访问。建议用逻辑分配大小限定 raw 后再计算 region 数。

ZJY0516:Good catch。已在 d67ba7d1ab 中用 kv_cache_config.kv_cache_tensors[0].size 限定 raw storage 再 reshape。强化后的回归测试用 128 字节逻辑 cache 配 4 KiB 分配;此前的 quotient 修复会注册 32 个 region 而非 1 个,完整 worker 测试 11 项全部通过。

这段讨论体现了"取整除法"方案在存储对齐场景下的典型陷阱,以及"以逻辑大小而非物理大小为视图边界"的更根本解法。修复已被 reviewer zyongye 批准。

实现拆解

按三步拆解实现过程:

  1. 定位变更入口与根因:问题出在 vllm/v1/simple_kv_offload/worker.py 的 SimpleCPUOffloadWorker.register_kv_caches()。该方法通过 tensor.untyped_storage() 取回每层 KV cache 的底层物理存储后,直接执行 raw.view(-1, num_blocks, block_bytes),要求物理存储字节数能被 region 大小整除。而 GPU 侧存储经 allocate_kv_cache() 按 4 KiB 向上取整(见 vllm/v1/worker/utils.py),与逻辑配置之间存在尾部 padding,破坏了整除性。

  2. 第一版修复及被 review 发现的边界缺陷:首个 commit(2053466c)采用 raw.numel() // region_bytes 计算完整 region 数再切片,仅能解决尾部 padding 小于一个 region 的场景。Codex review 指出 P1 缺陷:当 padding 大于等于一个完整 region(小模型或低 --num-gpu-blocks-override 时),quotient 会把整个 padding 块计为虚假 region,膨胀 total_bytes_per_block,导致 CPU/disk 块槽少于调度器推导值,最终调度器下发的块 ID 越界。

  3. 最终修复方案:第二个 commit(d67ba7d1)改为先从 kv_cache_config.kv_cache_tensors[0].size 读取逻辑分配大小,先断言物理存储不小于逻辑大小,再执行 raw[:logical_storage_bytes].view(-1, num_blocks, block_bytes)。逻辑大小按 region 配置生成、可被整除,因此截断后 reshape 必然成功,且所有尾部填充(包括足以构成完整 region 的填充)被一并排除,彻底收敛问题。

  4. 测试配套:tests/v1/simple_kv_offload/test_worker.py 新增 test_register_kv_cache_storage_with_trailing_padding,用 128 字节逻辑 cache 覆盖 4 KiB 物理分配(3,968 字节尾部填充),断言注册出恰好 1 个 region 且形状为 (4, 32)——该用例能复现第一版修复注册 32 个虚假 region 的错误。同时为既有 test_register_shared_kv_cache_storage 与 test_register_separate_kv_head_groups 补充 kv_cache_tensors mock 配置,满足新增的读取逻辑。

  5. 验证:新增回归测试、完整 worker 测试文件(11 tests)、ruff check/format 以及 H100 DeepSeek-V4 评测均在 PR CI 中通过。

文件 模块 状态 重要度
vllm/v1/simple_kv_offload/worker.py 卸载器 modified 5.67
tests/v1/simple_kv_offload/test_worker.py 卸载器 modified 5.47

关键符号

register_kv_caches test_register_kv_cache_storage_with_trailing_padding

关键源码片段

vllm/v1/simple_kv_offload/worker.py core-logic

核心修复点:register_kv_caches() 从对整个物理存储 reshape 改为先按逻辑分配大小(kv_cache_tensors[0].size)截断再 reshape,彻底排除 4 KiB 对齐尾部填充,并新增物理存储不小于逻辑大小的防御断言。

def register_kv_caches(
    self,
    kv_caches: dict[str, torch.Tensor],
) -> None:
    """注册 GPU KV cache 并分配 pinned CPU/disk 后端存储。
​
    该函数会从 kv_caches 中推断底层 raw storage,
    并按 [num_blocks, block_bytes] 视图分割为 region 供 DMA 后端使用。
    """
    if not kv_caches:
        logger.warning("No KV caches to offload.")
        return
​
    self.device = next(iter(kv_caches.values())).device
​
    assert self.kv_cache_config is not None
    num_blocks = self.kv_cache_config.num_blocks
​
    # kv_cache_tensors[0].size 保留逻辑分配大小;GPU 侧物理存储
    # 会被 allocate_kv_cache() 按 4 KiB 向上取整对齐,
    # 因此物理大小可能大于逻辑大小且不整除(如 DeepSeek-V4 尾部多 2816 字节)。
    assert self.kv_cache_config.kv_cache_tensors
    logical_storage_bytes = self.kv_cache_config.kv_cache_tensors[0].size
​
    # DMA 后端以 base + block_id * stride(0) 整块拷贝,
    # 所以把每个唯一分配视作 [num_blocks, block_bytes]。
    unique_gpu_caches: dict[str, torch.Tensor] = {}
    seen: set[tuple[torch.device, int]] = set()
    for name, tensor in kv_caches.items():
        storage = tensor.untyped_storage()
        key = (tensor.device, storage.data_ptr())
        if key in seen:
            continue
        seen.add(key)
​
        physical_per_block, remainder = divmod(tensor.shape[0], num_blocks)
        assert remainder == 0, (
            f"KV cache {name!r} has {tensor.shape[0]} physical blocks, which "
            f"is not divisible by {num_blocks} scheduler blocks"
        )
        block_bytes = tensor.stride(0) * tensor.element_size() * physical_per_block
        raw = torch.empty(0, dtype=torch.int8, device=tensor.device).set_(storage)
        assert raw.numel() >= logical_storage_bytes, (
            f"KV cache {name!r} storage has {raw.numel()} bytes, smaller "
            f"than the configured {logical_storage_bytes}-byte allocation"
        )
        # 关键修复:先按逻辑分配大小截断物理存储,再 reshape。
        # 直接对完整物理存储 view 会因尾部填充导致:
        # 1) 物理大小不整除逻辑分配 -> reshape 抛 invalid shape 错误;
        # 2) 填充超过一个 region 大小时被计为虚假 region,
        # 膨胀 total_bytes_per_block,使块槽分配偏少、块 ID 最终越界。
        regions = raw[:logical_storage_bytes].view(-1, num_blocks, block_bytes)
        for idx, region in enumerate(regions):
            key_name = name if len(regions) == 1 else f"{name}.{idx}"
            unique_gpu_caches[key_name] = region
    ...
tests/v1/simple_kv_offload/test_worker.py test-coverage

新增针对尾部 padding 的回归测试,并强化既有测试的 kv_cache_tensors mock 配置;测试用例能复现第一版修复注册 32 个虚假 region 的错误,是防止回归的关键保障。

def test_register_kv_cache_storage_with_trailing_padding(monkeypatch):
    # 128 字节逻辑 cache 对应 4 KiB 物理分配(3,968 字节尾部填充),
    # 填充量远大于一个完整 region(num_blocks * block_bytes = 128 字节),
    # 专门覆盖 " 填充足以构成完整 region" 的边界场景:
    # 第一版 quotient 修复在此用例下会注册 32 个 region 而非 1 个。
    num_blocks = 4
    block_bytes = 32
    cache_bytes = num_blocks * block_bytes
    raw = torch.zeros(4096, dtype=torch.int8, device="cuda")
    cache = raw[:cache_bytes].view(num_blocks, block_bytes)
    worker = SimpleCPUOffloadWorker(
        vllm_config=None,
        kv_cache_config=MagicMock(
            num_blocks=num_blocks,
            kv_cache_tensors=[MagicMock(size=cache_bytes)],
        ),
        cpu_capacity_bytes=cache_bytes,
    )
    worker._backend = MagicMock()
    monkeypatch.setattr("vllm.v1.simple_kv_offload.worker.PIN_MEMORY", False)
​
    worker.register_kv_caches({"layer.0": cache})
​
    assert worker.gpu_kv_caches is not None
    assert list(worker.gpu_kv_caches) == ["layer.0"]
    assert worker.gpu_kv_caches["layer.0"].shape == (num_blocks, block_bytes)

评论区精华

尾部填充可能构成完整 region 的边界缺陷(P1) 正确性

Codex review 指出 quotient 修复的缺陷:当 region_bytes 不大于尾部 padding(小模型或低 --num-gpu-blocks-override)时,raw.numel() // region_bytes 会把完整 padding 块计为额外 region,膨胀 total_bytes_per_block,导致 CPU/disk 块槽少于调度器推导值,最终块 ID 越界。

结论:ZJY0516 采用 kv_cache_config.kv_cache_tensors[0].size 的逻辑分配大小截断 raw 后再 reshape,并强化回归测试(128 字节逻辑 cache 配 4 KiB 分配),彻底排除全部尾部填充。 · 已解决

回归测试对第一版修复的验证 测试

ZJY0516 确认修复后说明:强化后的测试使第一版 quotient 修复注册 32 个 region 而非 1 个;完整 worker 测试文件 11 项全部通过,ruff check/format 通过。

结论:测试用例能有效捕获两类错误:物理大小不整除导致的 reshape 失败,以及 padding 构成完整 region 导致的虚假 region。 · 已解决

风险与影响

风险点如下:

  1. 新增配置契约依赖:assert self.kv_cache_config.kv_cache_tensors 假定所有调用路径都已填充 kv_cache_tensors 字段。若未来出现绕过该配置的调用方或在 -O 模式下运行(断言被移除后 kv_cache_tensors[0] 为 None 会抛 TypeError),可能引入新的失败模式。当前 PR 已同步更新测试 mock,CI 全量通过。
  2. 整除性隐含假设:raw[:logical_storage_bytes].view(-1, num_blocks, block_bytes) 要求逻辑大小能被 num_blocks * block_bytes 整除。逻辑分配按 region 配置生成,通常成立;但若出现异常配置(逻辑大小非 region 整数倍),会以显式 reshape 错误暴露,而非优雅降级。
  3. 影响面收敛但仍需关注:修复只在 worker.py 单点生效,覆盖 SimpleCPUOffloadConnector 注册路径;OffloadingConnector 转移路径(#53607)和跨 worker 池对齐(#52921)不在本 PR 范围,相关历史缺陷仍在各自追踪中。

影响范围评估:

  • 用户/系统:修复 nvidia-h100-lm-eval-kv-offload-large 基准启动回归,DeepSeek-V4 等尾部对齐填充明显的模型在 SimpleCPUOffloadConnector(CPU/disk KV offload)下可正常启动;此前会直接抛 invalid shape 错误。
  • 代码范围:核心改动仅 vllm/v1/simple_kv_offload/worker.py 8 行(+7/-1),测试文件 +32/-2,属单点收敛型修复。
  • 团队层面:这是 #51718 KV cache layout 标准化序列(RFC #42082)之后的配套收敛修复,说明布局标准化引入了"物理存储对齐 vs 逻辑分配"的新语义,需要各消费方适配,后续同类问题值得在 KV 缓存管理器中统一抽象。
核心路径变更 依赖 kv_cache_tensors 配置契约 新增断言可能影响其他调用路径 逻辑大小整除性隐含假设

关联 Issue

#51718 [6/N][KV-Cache Layout Refactor] Standardize KV cache layout

完整报告

参与讨论