Prhub

#51116 [Bugfix][KV Offload] Fall back when MADV_POPULATE_WRITE is unsupported

原始 PR 作者 Alex-ai-future 合并时间 2026-08-06 18:48 文件变更 2 提交数 7 评论 15 代码增减 +197 / -6

执行摘要

KV 卸载 mmap 预填充在旧内核上回退逐页写入

关联 Issue #44888 报告:vLLM v0.22.0 配置 TieringOffloadingSpec 后 worker 在 initialize_kv_cache 阶段崩溃,日志显示 mmap 创建成功后立刻触发 MADV_POPULATE_WRITE 相关异常。根因是 MADV_POPULATE_WRITE 为 Linux 5.14 新增(值 23),RHEL 8 的 4.18、Ubuntu 5.4 等旧内核返回 EINVAL,原实现未区分“内核不支持”与“真实 I/O 错误”,导致旧内核上任何触碰 KV offload 的启动直接中止。PR body 强调该变更只增加回退路径,不改变模型输出、精度与吞吐。

值得精读。虽然只有 2 个文件、改动不大,但包含三个值得借鉴的点:(1) 用模块级 helper 作为不可变内置类型的测试接缝,解决了 Python 3.12+ 无法子类化 mmap.mmap 的测试难题;(2) |= 0 这种“零副作用写”技巧——既触发缺页又不破坏数据,配合哨兵测试精确区分了 = 0 与 |= 0;(3) 错误分类处理——只有 EINVAL 走回退,其他 OSError 继续传播,避免掩盖真实 I/O 错误。此外,评审中 orozery 对“测试必须验证函数行为而非 OS 行为”的坚持(ftruncate 零页陷阱)也是很好的测试设计范例。

讨论亮点

评审核心交锋集中在三点:

  1. 写入方式要用 |= 0 而非 = 0:orozery 在 review 中直接要求 “Let's restore to your proposal of |= 0”,理由是共享 mmap 中页首字节可能已被 peer worker 写入 KV 数据,= 0 会静默覆盖;提交者随后切换并用哨兵测试锁定该语义。
  2. 断言不能验证 OS 行为:orozery 与 Claude 指出原测试中断言“页内其余字节为 0”之所以通过,是因为 ftruncate 零初始化了页面,而非 fallback 写了字节,属于“验证 OS 行为而非函数行为”;随后删除装饰性断言,改为直接驱动 _fallback_populate_write 并检查哨兵存活。
  3. 测试接缝的选择:mmap.mmap 在 Python 3.12+ 是不可变 C 类型,子类化与 patch 都会抛 TypeError(这是 #44913 卡在 pre-commit 的根因),本 PR 将填充逻辑抽为模块级 helper 作为 monkeypatch 接缝。

实现拆解

  1. 抽离模块级 helper 作为测试接缝:在 vllm/v1/kv_offload/cpu/shared_offload_region.py 新增 _madvise_populate_write 与 _fallback_populate_write 两个模块级函数,并把 _MADV_POPULATE_WRITE 常量提升到模块级。原因是 Python 3.12+ 的 mmap.mmap 是不可变 C 内置类型,无法子类化或 patch,只有把真实逻辑放进模块级函数,测试才能通过 monkeypatch 拦截。
  2. 新增探测与决策函数 _get_populate_write_fn:在 mmap 创建后对第一页执行一次 _madvise_populate_write 探测,捕获 OSError 并检查 errno:若为 EINVAL 则记录 warning 并返回回退函数,其他 OSError(如 EIO)直接重新抛出,避免掩盖真实故障。
  3. 改造 __init__ 两处调用点:ranked 场景(逐 block 循环、按 worker 列对齐)与 unranked 场景(整区一次调用)原先直接调用 self.mmap_obj.madvise(...),现统一改为先 populate_write_fn = _get_populate_write_fn(self.mmap_obj) 再调用 populate_write_fn(...),两个分支的偏移对齐逻辑保持不变。
  4. 测试配套:tests/v1/kv_offload/cpu/test_shared_offload_region.py 新增 5 个测试——成功路径 spy(1 次探测 + N 次填充、fallback 0 次)、ranked 与 unranked 两种 EINVAL 回退、哨兵字节保持 + mincore 页驻留断言(用于区分 = 0 与 |= 0)、非 EINVAL 异常传播;并新增 _page_residency 辅助函数通过 ctypes 调 mincore 检查页驻留。测试依赖 Linux 的 mincore/MADV_DONTNEED,非 Linux 平台自动 skip。
  5. 行为契约:只有 EINVAL 才触发回退,其他 OSError 继续传播;5.14+ 内核路径行为完全不变;MADV_POPULATE_WRITE 常量在 mmap 未暴露时回退到硬编码值 23。
文件 模块 状态 重要度
vllm/v1/kv_offload/cpu/shared_offload_region.py 卸载区 modified 7.55
tests/v1/kv_offload/cpu/test_shared_offload_region.py 卸载区 modified 7.08

关键符号

_get_populate_write_fn _madvise_populate_write _fallback_populate_write test_fallback_populate_write_preserves_bytes_and_faults_pages test_madvise_success_selects_madvise_population

关键源码片段

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

KV 卸载共享 mmap 的预填充逻辑所在文件;本 PR 在此新增 `MADV_POPULATE_WRITE` 探测与 `|= 0` 回退,修复 Linux < 5.14 上 TieringOffloadingSpec 启动即崩溃的问题。

# 模块级 helper 同时充当测试接缝:mmap.mmap 是 Python 3.12+ 的不可变 C 类型,
# 无法子类化或 patch,测试通过 monkeypatch 这两个函数来模拟内核行为。
​
def _madvise_populate_write(mmap_obj: mmap.mmap, offset: int, length: int) -> None:
    mmap_obj.madvise(_MADV_POPULATE_WRITE, offset, length)
​
​
def _fallback_populate_write(mmap_obj: mmap.mmap, offset: int, length: int) -> None:
    # 按 mmap.PAGESIZE 步长对每页首字节做读改写 |= 0:既触发该页缺页(物理页分配),
    # 又不会改动任何字节——共享 mmap 中 peer worker 可能已写入 KV 数据,= 0 会静默覆盖。
    arr = np.frombuffer(mmap_obj, dtype=np.uint8)
    arr[offset : offset + length : mmap.PAGESIZE] |= 0
​
​
def _get_populate_write_fn(mmap_obj: mmap.mmap) -> Callable[[mmap.mmap, int, int], None]:
    """选择 mmap 预填充方式:探测一次,遇到 EINVAL 才回退。"""
    try:
        # 只对第一页探测;Linux < 5.14(如 RHEL 8 的 4.18)会返回 EINVAL。
        _madvise_populate_write(mmap_obj, 0, mmap.PAGESIZE)
    except OSError as e:
        if e.errno != errno.EINVAL:
            raise # 其他 OSError(如 EIO)是真实故障,必须向外传播、不能掩盖。
        logger.warning(
            "MADV_POPULATE_WRITE is not supported; falling back to per-page "
            "writes for mmap pre-population. Startup may be slower."
        )
        return _fallback_populate_write
    return _madvise_populate_write
​
​
# __init__ 中:mmap 创建后先选定预填充函数,再按 rank 决定是分列填充还是整区填充
populate_write_fn = _get_populate_write_fn(self.mmap_obj)
if rank is not None:
    # 每个 worker 只填充自己负责的列(每个 block row 内一个槽位)
    worker_offset = rank * cpu_page_size
    for block in range(num_blocks):
        raw_offset = block * self._row_stride + worker_offset
        aligned_offset = (raw_offset // page_size) * page_size
        end = raw_offset + cpu_page_size
        aligned_length = end - aligned_offset
        populate_write_fn(self.mmap_obj, aligned_offset, aligned_length)
else:
    # 无 rank:一次调用填充整个共享区
    populate_write_fn(self.mmap_obj, 0, self.total_size_bytes)
tests/v1/kv_offload/cpu/test_shared_offload_region.py test-coverage

新增 5 个针对探测 / 回退路径的单元测试,覆盖成功路径、EINVAL 回退(ranked/unranked)、字节保持与异常传播,并引入 mincore 页驻留断言来区分 `= 0` 与 `|= 0`。

# sor 是测试文件顶部导入的模块别名:
# from vllm.v1.kv_offload.cpu import shared_offload_region as sor
​
def _page_residency(mmap_obj: mmap.mmap, length: int) -> list[bool]:
    """通过 libc.mincore 查询各页是否已驻留内存(用于断言 fallback 真的触发了缺页)。"""
    libc = ctypes.CDLL(None, use_errno=True)
    page_count = (length + PAGE_SIZE - 1) // PAGE_SIZE
    mincore = libc.mincore
    mincore.argtypes = [
        ctypes.c_void_p,
        ctypes.c_size_t,
        ctypes.POINTER(ctypes.c_ubyte),
    ]
    mincore.restype = ctypes.c_int
    vector = (ctypes.c_ubyte * page_count)()
    address = ctypes.addressof(ctypes.c_ubyte.from_buffer(mmap_obj))
    result = mincore(ctypes.c_void_p(address), length, vector)
    if result != 0:
        raise OSError(ctypes.get_errno())
    return [bool(value & 1) for value in vector]
​
​
def test_fallback_populate_write_preserves_bytes_and_faults_pages():
    """回退函数必须让每个目标页驻留,同时保留页内已有字节(区分 = 0 与 |= 0)。"""
    size = 3 * mmap.PAGESIZE
    mmap_obj = mmap.mmap(
        -1, size, flags=mmap.MAP_SHARED, prot=mmap.PROT_READ | mmap.PROT_WRITE
    )
    try:
        # 先丢弃全部页,制造干净的未驻留状态
        mmap_obj.madvise(mmap.MADV_DONTNEED, 0, size)
        if any(_page_residency(mmap_obj, size)):
            pytest.skip("kernel did not discard anonymous mmap pages")
​
        # 在页首埋入非零哨兵:若 fallback 是 = 0,哨兵会被覆盖导致断言失败
        mmap_obj[0] = 0xAB
        assert _page_residency(mmap_obj, size) == [True, False, False]
​
        sor._fallback_populate_write(mmap_obj, 0, size)
​
        # 三个页全部驻留,且哨兵字节存活
        assert _page_residency(mmap_obj, size) == [True, True, True]
        assert mmap_obj[0] == 0xAB
    finally:
        mmap_obj.close()

评论区精华

回退写入必须用 `|= 0` 而非 `= 0` 正确性

orozery 在 review 中要求恢复提交者最初的 `|= 0` 写法:共享 mmap 是跨 worker 的,页首字节可能已被 peer worker 写入 KV 数据,`= 0` 会静默覆盖。Claude 也补充说明相关测试断言基于 ftruncate 零页,是“验证 OS 行为而非 fallback 行为”。

结论:最终采用 `arr[offset:offset+length:PAGESIZE] |= 0`,并用“页首埋非零哨兵 + mincore 驻留断言”的测试来区分 `= 0` 与 `|= 0`。 · 已解决

测试断言验证了 OS 行为而非函数行为 测试

orozery 与 Claude 指出原 ranked/unranked 测试中的“页内其余字节必须为 0”断言无法证明 fallback 写过这些字节(它们来自 ftruncate 的零初始化),并建议改为哨兵存活断言 + 直接驱动 fallback 函数。

结论:按建议重写为 `test_fallback_populate_write_preserves_bytes_and_faults_pages`,并删除两个依赖 OS 零页行为的字节断言测试。 · 已解决

Python 3.12+ 测试接缝选择 测试

#44913 之前的尝试通过子类化 `mmap.mmap` 来注入 `EINVAL`,但 3.12+ 对不可变 C 类型子类化直接抛 `TypeError`,导致 mergify 反复报 pre-commit/CI 失败;本 PR 把四个测试改为 monkeypatch 模块级 `_madvise_populate_write` / `_fallback_populate_write`。

结论:以模块级 helper 作为测试接缝,绕开不可变 C 类型的限制。 · 已解决

PR 去留与 #44913 的关系 设计

orozery 认为 #44913 的方案更干净但需要修复;Etelis 建议协助那边;Alex-ai-future 拉取 guygir 分支合入本 PR 并接续修复,保留原作者 commits。

结论:本 PR 作为 #44913 的延续被合并;两个 PR 作者同属一个解决链。 · 已解决

风险与影响

回退路径性能风险:当 EINVAL 触发回退时,对 3.22 GB 的 mmap 按页(通常 4 KB)逐页 |= 0,每页一次 NumPy 步进写,启动耗时显著高于单次 madvise;PR 内已用 warning 提示“Startup may be slower”,但大 region 下需要关注是否超出进程启动超时。并发写窗口:arr[off:off+len:PAGESIZE] |= 0 是读改写,理论上与 peer worker 同时写同一页首字节存在竞争;不过值不变(与自身异或),且 KV 数据写入发生在预填充之后,实际风险极低。平台行为差异:探测依赖内核返回 EINVAL 来区分“不支持”;某些平台(如特定 libc 版本)可能返回不同 errno,此时按设计会直接抛错,行为与修复前一致,不会静默掩盖。测试中的 mincore 与 MADV_DONTNEED 依赖 Linux,非 Linux 平台会 skip,CI 覆盖有限。

用户侧:在 RHEL 8(4.18)、Ubuntu 5.4 等旧内核上启用 TieringOffloadingSpec 的部署不再启动崩溃,KV offload 从不可用变为可用(代价是启动变慢)。系统侧:每次 SharedOffloadRegion 构造多一次页粒度的探测 madvise 调用,开销可忽略;日志新增一条 warning 用于诊断。团队侧:确立了“不可变 C 类型通过模块级 helper 暴露测试接缝”的测试模式,后续类似的 mmap/C 扩展交互可复用;测试基建引入了 ctypes + mincore 页驻留断言,丰富了 mmap 行为验证手段。关联功能:KV offload 是 vLLM v1 的 KV 缓存分层卸载能力,此修复解除了其“仅限 5.14+ 内核”的硬依赖,扩大了 CPU offload 的部署面。

核心启动路径变更 回退路径性能风险 平台依赖行为 旧内核 CI 覆盖不足

关联 Issue

#44888 [Bug]: vllm start with TieringOffloadingSpec mmap_obj error

完整报告

参与讨论