Prhub

#49532 [XPU] Support EC connector KV Offloading on XPU

原始 PR 作者 chaojun-zhang 合并时间 2026-08-19 10:25 文件变更 5 提交数 7 评论 16 代码增减 +93 / -58

执行摘要

修复 XPU 指针 dtype 与溢出,EC 卸载支持 Intel GPU 并扩 CI

PR body 明确指出:"PR #47423 added ECCPUConnector for encoder-cache (EC) offloading to host memory, but the swap path assumed CUDA/ROCm-style raw pointers and was never exercised on XPU." 在 XPU 上,USM 指针可能设置最高位(值 >= 2**63),而 torch 对标量赋值的解包行为在 2.14 前后不一致:2.13 一律按有符号 long long 解包,2.14 起按 dtype 感知解包(pytorch#191458),导致原始地址与补码两种编码分别在某一侧失败,直接引发 EngineDeadError。RFC #50834 进一步指出这是贯穿 encoder-cache offload、Mamba 推测解码、Lamport workspace 三个模块的 convention-level 问题,本 PR 是其中首个 workaround。

值得精读。核心看点有三:一是 set_ptrs() 用 numpy 视图绕开 torch 标量赋值限制的取舍,二是 PR body 中 2.13/2.14 兼容性矩阵的分析方法(对任何跨版本库升级都有借鉴意义),三是与 RFC #50834 的体系化关联——它展示了一个看似局部的小修复如何为全仓 dtype 约定提供依据。建议结合 KV offload 的 gpu_worker.py 中同一模式的先例一起阅读。

讨论亮点

review 中最有价值的交锋集中在三点:

  • torch 限制的确认与统一方案诉求yma11 问 "So this is torch issue/limitation?",chaojun-zhang 确认并引用 kv_offload/cpu/gpu_worker.py#L158 的既有 numpy 先例;jikunshang 则提出 "we may need some unified solution for address overflow issue",最终由 RFC #50834 承接统一方向。

  • 补码方案在 torch 2.14 失效的自我纠错chaojun-zhang 先主张用 reinterpret_u64_as_i64 做补码改写,随后引用 pytorch#191458 与 c9f91bd 自我纠正,给出 2.13/2.14 兼容性矩阵:补码在 2.13 可用、2.14 崩溃;raw address 反之。结论是补码方案依赖上游 bug,必须放弃。

  • CI 内存档位的实证zxd1997066 最初建议 "maybe need use 16+ to have a try",提交后实测 16+ 上 Qwen2-VL 模型加载触发 No available memory for the cache blocks,确认需恢复 24+。

实现拆解

实现按 5 步拆解:

  1. 描述符指针 dtype 平台化vllm/distributed/ec_transfer/ec_connector/cpu/worker/descriptor_buffers.py)。新增模块级常量 _PTR_DTYPE = torch.uint64 if current_platform.is_xpu() else torch.int64DescriptorBufferPool.acquire() 分配 src/dst/sizes 三个张量时统一改用 _PTR_DTYPE。原因是 swap_blocks_batch 在 CUDA/ROCm 侧要求 int64 指针数组,而 XPU DMA 引擎要求 uint64;注释中已说明这是平台契约而非随意选择。

  2. numpy 视图绕行 torch 标量赋值(同文件)。DescriptorBuffers 扩展为包含 src_npdst_np 两个 numpy 字段(在 acquire() 中通过 src.numpy() 创建一次并缓存),新增 set_ptrs(idx, src, dst) 方法经 numpy 下标赋值写指针。原因:torch 的张量 setitem 在 2.14 前按有符号 long long 解包,拒绝 >= 263 的地址;2.14 起改为按 dtype 解包,又拒绝补码负数——两种编码各只在一侧成立,而 numpy 按数组 dtype 转换、不经过 torch 的标量解包,两个版本都正确。sizes 存字节数、值域远小于 263,保持普通 torch 赋值。

  3. 调用方改造vllm/distributed/ec_transfer/ec_connector/cpu/worker/__init__.py)。ECCPUWorker.save_caches()start_load_caches() 中原先直接 src_ptrs[idx] = ... 的赋值全部改为 bufs.set_ptrs(...)flush_saves() 解包方式同步调整为 bufs.src_ptrs, bufs.dst_ptrs, bufs.sizes。两路径共用唯一的写入入口,保证不会有遗漏的裸赋值残留。

  4. 测试平台化tests/v1/ec_connector/unit/cpu/worker/test_worker.pytests/v1/ec_connector/unit/test_ec_cpu_connector.py)。_requires_cuda 改为 _requires_accelerator(CUDA 或 XPU),device="cuda" 改为 device=DEVICE_TYPEcurrent_platform.device_type),断言 out.is_cuda 改为 out.device.type == DEVICE_TYPE,stream 类型断言从 torch.cuda.Stream 改为 current_platform.Stream;E2E 测试的 skipif 条件同步放宽到 CUDA/XPU。

  5. Intel CI 接入与资源调整.buildkite/intel_jobs/misc_intel.yaml)。V1 Core + KV + Metrics job 的 source_file_dependencies 增加 tests/v1/ec_connector/unit,命令追加 VLLM_BATCH_INVARIANT=1 pytest -v -s -m 'not cpu_test' v1/ec_connector/unit;内存档位从 16+ 提到 24+。提交历史显示作者曾一度降到 16+,经 zxd1997066 实测 No available memory for the cache blocks 后回滚。VLLM_BATCH_INVARIANT=1 则是为了消除精度测试中 batch-size-1 基线对比 batch-size-20 输出带来的贪婪解码非 batch 不变性抖动。

文件 模块 状态 重要度
vllm/distributed/ec_transfer/ec_connector/cpu/worker/descriptor_buffers.py 描述符池 modified 7.33
vllm/distributed/ec_transfer/ec_connector/cpu/worker/__init__.py 卸载工作器 modified 5.89
tests/v1/ec_connector/unit/cpu/worker/test_worker.py 单元测试 modified 5.53
tests/v1/ec_connector/unit/test_ec_cpu_connector.py 端到端测试 modified 4.47
.buildkite/intel_jobs/misc_intel.yaml CI 配置 modified 3.77

关键符号

set_ptrs DescriptorBufferPool.acquire DescriptorBufferPool.release ECCPUWorker.save_caches ECCPUWorker.flush_saves ECCPUWorker.start_load_caches

关键源码片段

vllm/distributed/ec_transfer/ec_connector/cpu/worker/descriptor_buffers.py core-logic

核心修复载体:按平台选择指针 dtype,并新增 set_ptrs() 用 numpy 视图写入高位指针,绕开 torch 2.13/2.14 互斥的溢出限制。

# vllm/distributed/ec_transfer/ec_connector/cpu/worker/descriptor_buffers.py# 平台相关的指针 dtype:
# - CUDA/ROCm 的 cache_kernels.cu 要求 int64 指针数组;
# - XPU 的 DMA 引擎要求 uint64(见 vllm._custom_ops.swap_blocks_batch)。
# XPU 的 USM 指针可能带最高位(值 >= 2**63),无法用 int64 表达。
_PTR_DTYPE = torch.uint64 if current_platform.is_xpu() else torch.int64
​
​
class DescriptorBuffers(NamedTuple):
    """swap_blocks_batch 的一组描述符:(src_ptrs, dst_ptrs, sizes)。    src_np / dst_np 是与指针张量共享内存的 numpy 视图,仅用于 set_ptrs() 写入。
    背景:torch 的标量赋值在 2.14 之前一律按有符号 long long 解包,
    遇到 >= 2**63 的 XPU USM 指针抛 "Overflow when unpacking long long";
    而 pytorch#191458(2.14 起)改为按 dtype 解包后,反而拒绝补码负数。
    numpy 按数组 dtype 转换,不经过 torch 的标量解包,两个版本都正确。
    """
    src_ptrs: torch.Tensor
    dst_ptrs: torch.Tensor
    sizes: torch.Tensor
    src_np: np.ndarray
    dst_np: np.ndarray
​
    def set_ptrs(self, idx: int, src: int, dst: int) -> None:
        """记录第 idx 个描述符的源地址与目标地址。        TODO(torch>=2.14): 最低 torch 版本升到 2.14 后可直接对张量赋值,
        删除这层 numpy 间接层。sizes 存的是字节数,值域远小于 2**63,
        不需要绕行,保持普通 torch 赋值(见 save_caches 中的 sizes[idx])。
        """
        self.src_np[idx] = src
        self.dst_np[idx] = dst
​
​
class DescriptorBufferPool:
    """描述符三元组缓冲池:跨 step 复用,避免每次 flush 重复分配。"""
​
    def __init__(self) -> None:
        self._pool: list[DescriptorBuffers] = [] # 空闲缓冲的 LIFO 栈
​
    def acquire(self, n: int) -> DescriptorBuffers:
        """取出容量 >= n 的缓冲;池中缓存不够大时新建一组。"""
        if self._pool:
            bufs = self._pool.pop()
            if bufs.src_ptrs.numel() >= n:
                return bufs
        src, dst, sizes = (torch.empty(n, dtype=_PTR_DTYPE) for _ in range(3))
        # 视图在分配时创建一次并缓存,后续 set_ptrs() 直接复用,
        # 避免每次写指针都创建 numpy 对象。
        return DescriptorBuffers(src, dst, sizes, src.numpy(), dst.numpy())
vllm/distributed/ec_transfer/ec_connector/cpu/worker/__init__.py core-logic

ECCPUWorker 的 save_caches/start_load_caches 两条路径全部改为经 set_ptrs() 写入指针,是 numpy 方案的调用方落地。

# vllm/distributed/ec_transfer/ec_connector/cpu/worker/__init__.py(ECCPUWorker 方法)
​
    def save_caches(self, encoder_cache, mm_hash, connector_metadata) -> None:
        """把 encoder 输出按 block 切分为描述符,攒批后一次性 flush。"""
        # ... 前置的 rank / block_ids 校验省略 ...
        if self._save_bufs is None:
            total = sum(len(v) for v in connector_metadata.saves.values())
            self._save_bufs = self._buf_pool.acquire(total)
​
        bufs = self._save_bufs
        src_base = src.view(-1).view(torch.uint8).data_ptr()
        dst_base = self._region.blocks.data_ptr()
        idx = self._save_count
​
        # 指针全部经 set_ptrs() 走 numpy 视图写入,避开 torch 标量赋值
        # 对 uint64 的溢出限制;sizes 只是普通字节数,直接 torch 赋值。
        for i, block_idx in enumerate(block_ids):
            start = i * block_size
            bufs.set_ptrs(idx, src_base + start, dst_base + block_idx * block_size)
            bufs.sizes[idx] = min(block_size, total_bytes - start)
            idx += 1
​
        self._save_count = idx
​
    def flush_saves(self) -> None:
        """把累积的所有 save 描述符合并为一次 swap_blocks_batch 调用。"""
        if self._save_count == 0:
            return
        bufs = self._save_bufs
        assert bufs is not None
        src_ptrs, dst_ptrs, sizes = bufs.src_ptrs, bufs.dst_ptrs, bufs.sizes
        n = self._save_count
        swap_blocks_batch(src_ptrs[:n], dst_ptrs[:n], sizes[:n])
        self._buf_pool.release(bufs)
        self._save_bufs = None
        self._save_count = 0

评论区精华

torch 标量赋值限制与 numpy 视图方案 正确性

yma11 在 save_caches 的 diff hunk 上提问 'So this is torch issue/limitation?',chaojun-zhang 确认并引用 kv_offload/cpu/gpu_worker.py#L158 的既有模式;yma11 进一步追问是否需要推动 torch 侧修复,jikunshang 表示 'we may need some unified solution for address overflow issue'。

结论:采用 numpy 视图写入指针,限制在 DescriptorBuffers.set_ptrs() 内,并预留 TODO(torch>=2.14) 清理点;统一方案由 RFC #50834 跟踪。 · 已解决

two's-complement 编码在 torch 2.14 失效的自我纠错 设计

chaojun-zhang 先主张用 reinterpret_u64_as_i64 补码改写绕过检查,随后在 review 中引用 pytorch#191458 与 c9f91bd 自我纠正:2.14 起 valueToTensor 对 kUInt64 改用 THPUtils_unpackUInt64,补码负数被拒、原始地址才被接受;两种编码各只在一侧成立,因此补码方案依赖上游 bug,不可行。

结论:放弃补码方案,改为 numpy 视图(即本 hunk 最初做法),保证 2.13 与 2.14 都正确。 · 已解决

测试 skip 条件是否应改用 cudalike question

jikunshang 对 _requires_accelerator = skipif(not (is_cuda() or is_xpu())) 提问 'should be cudalike?',chaojun-zhang 说明原 _requires_cuda 仅覆盖 CUDA,此处为显式扩展。

结论:保留 is_cuda() or is_xpu() 的显式判断,未改用 cudalike。 · 已解决

CI 实例内存 16+ 与 24+ 的取舍 question

jikunshang cc zxd1997066 确认内存档位;zxd1997066 最初建议 'maybe need use 16+ to have a try',提交 40e128bf 降到 16+;实测 16+ 出现 'No available memory for the cache blocks',随即回滚恢复 24+。

结论:E2E 测试需加载 Qwen2-VL-2B-Instruct 模型并跑生成,最终维持 24+ 内存要求。 · 已解决

风险与影响

  1. 平台分支 _PTR_DTYPE 的维护成本:这是临时约定,RFC #50834 计划全仓统一为 torch.uint64;未来迁移时需同步清理 set_ptrs 的 numpy 间接层(代码内已留 TODO(torch>=2.14)),若清理不彻底会出现新旧两套写法并存。
  2. numpy 视图与 torch 张量共享底层内存:视图绑定在 buffer 三元组内随池回收,生命周期一致;但后续若有人误在 release() 后仍持有视图引用,池化复用可能读到脏数据,风险需通过代码规范约束。
  3. 跨 torch 版本兼容性:修复依赖 numpy 的 dtype 转换语义,torch 2.14 正式升级后 TODO 移除本身是一次行为变更,需配合回归测试。
  4. CI 资源敏感:E2E 测试加载 Qwen2-VL-2B-Instruct 模型,16 GiB 实例实测失败,必须维持 24+ 内存档位;且测试依赖真实 CUDA/XPU 设备与 mmap,无加速器主机上只能跳过,覆盖率受硬件矩阵限制。

用户侧:XPU(Intel GPU)用户现在可启用 ec_connector=ECCPUConnector 做 encoder-cache 主机内存卸载,不再因指针溢出导致 EngineDeadError;系统侧:EC 卸载路径在 CUDA/ROCm/XPU 三平台行为一致;团队侧:为 RFC #50834 提供了经过双版本 torch 验证的 workaround 先例,Intel CI 开始覆盖 EC connector 的单元与 E2E 测试。改动仅 5 个文件、+93/-58 行,影响面收敛在 EC CPU connector 内部,不触碰其他 KV offload 路径。

XPU 平台分支 dtype 维护成本 跨 torch 版本兼容性风险 CI 内存资源敏感 (24+ GiB) numpy 视图与 tensor 共享内存 E2E 测试需真实 XPU/CUDA 设备

关联 Issue

#191458 uint64 scalar assignment overflow
#50834 [RFC]: Unify the Tensor Type for Device-Pointer Storage to `torch.uint64`

完整报告

参与讨论