Prhub

#55819 [Perf] Use UVA-backed contents for MRV2 apply_write

原始 PR 作者 bjf-frz 合并时间 2026-09-10 22:27 文件变更 2 提交数 5 评论 6 代码增减 +166 / -5

执行摘要

MRV2 apply_write 对 UVA 目标改用 UVA 暂存,省去 H2D 拷贝提速约 1.3~2×

PR body 指出:在 MRV2 中,StagedWriteTensor.apply_write() 无条件通过 H2D 将暂存内容拷贝到 GPU 内存,再让 Triton 内核写入目标,即使目标本身就是 UVA-backed(CPU 钉住内存的 GPU 可访问视图)。这意味着一次多余的临时 GPU 缓冲分配和显式 H2D 拷贝完全可以跳过,既降低延迟又节省显存。

值得精读。设计上"按目标选择暂存路径"与"直接复用现有池而非引入新抽象"清晰简洁,测试对增长、复用、in-flight 读者生命周期的覆盖扎实,性能数据完整且区分了稳态复用、首次增长与边界增长。可借鉴其基准方法(分段、增长、复用分类对比)用于其他内存/拷贝热点优化。

讨论亮点
  • @njhill 在首次 review 中建议:"I think we could just modify the existing UvaBufferPool rather than introducing growable one. I think it would be compatible. Size can just be initial size." 强调不要新增独立的可增长池类。
  • @bjf-frz 采纳并回复:已改为复用 UvaBufferPool,把 size 视为初始容量、按需增长第 0 维并保留尾部维度,测试结果已同步更新。
  • 作者额外披露了一个反面数据:逐调用重建 UVA 缓冲(不增长、不复用)比 H2D 基线慢约 15%,原因是每次调用的 UVA 视图创建与生命周期管理开销超过了省去 H2D 的收益,因此最终保留了可增长、可复用的实现。
  • @njhill 最终批准。

实现拆解

  1. 扩展 UvaBufferPool 支持按需增长:在 vllm/v1/worker/gpu/buffer_utils.py 的 UvaBufferPool.copy_to_uva() 中,当输入长度超过当前槽位第 0 维容量时,以 1 << (len(x) - 1).bit_length()(即不小于长度的最小 2 的幂)新建 UvaBuffer,保留尾部维度,并替换当前槽位。
  2. 为 UVA 目标新增 write_contents 池:StagedWriteTensor.__init__ 中新增 self.write_contents——仅当 uva_instead_of_gpu=True 时创建初始容量为 1 的 UvaBufferPool(dtype 与目标一致),否则为 None。
  3. apply_write 按目标分流:apply_write() 中若 write_contents is None 则走原有 async_tensor_h2d 路径;否则调用 write_contents.copy_to_uva(self._staged_write_contents),将写内容写入 UVA 缓冲后直接传给 Triton 内核,彻底避开 H2D 拷贝。
  4. 测试配套:tests/kernels/core/test_uva.py 新增 4 个测试,包括 monkeypatch 断言 UVA 目标不调用 H2D、池增长/缩短复用只暴露当前内容的前缀覆盖语义、copy_to_gpu 的 shape 与 out 参数兼容性、以及基于 CUDA 流和事件的 in-flight 读者生命周期验证(覆盖 3 种 dtype 与两种目标类型)。
  5. 性能验证:PR body 报告微基准稳态复用/增长场景提速 1.26×~1.96×(大尺寸约 2×),GSM8K 中位完成延迟从 73.698 μs 降至 52.508 μs(降约 29%),且三次运行中生长次数为 1/0/0,说明该工作负载主要受益于复用与 CPU 侧暂存。
文件 模块 状态 重要度
vllm/v1/worker/gpu/buffer_utils.py GPU 缓冲 modified 6.49
tests/kernels/core/test_uva.py UVA 测试 modified 7.27

关键符号

UvaBufferPool.copy_to_uva StagedWriteTensor.apply_write StagedWriteTensor.__init__ test_staged_write_uses_uva_contents_for_uva_target test_uva_pool_overwrites_exposed_prefix test_staged_write_inflight

关键源码片段

vllm/v1/worker/gpu/buffer_utils.py core-logic

核心变更文件:UvaBufferPool 增加按需增长逻辑,StagedWriteTensor 为 UVA 目标新增 write_contents 池并在 apply_write 中按目标分流,避免 H2D 拷贝。

class UvaBufferPool:
    """预分配每个槽位到 size,按需沿第 0 维增长。
​
    调用方必须在槽位复用(包括增长)前收回该槽的 GPU 读者,否则读者可能读到新写入的数据。
    """
​
    def copy_to_uva(self, x: torch.Tensor | np.ndarray | list) -> torch.Tensor:
        # 轮转到下一个槽位,保证并发写之间互不覆盖
        self._curr = (self._curr + 1) % self.max_concurrency
        buf = self._uva_bufs[self._curr]
        n = len(x)
        if n > buf.cpu.shape[0]:
            # 容量不足:按不小于 n 的最小 2 的幂扩张,保留尾部维度并整体替换槽位
            capacity = 1 << (n - 1).bit_length()
            buf = UvaBuffer((capacity, *buf.cpu.shape[1:]), self.dtype)
            self._uva_bufs[self._curr] = buf
        # 只写入当前内容前缀,避免旧内容对新读取造成干扰
        dst = buf.cpu if isinstance(x, torch.Tensor) else buf.np
        dst[:n] = x
        return buf.uva[:n]
class StagedWriteTensor:
    def __init__(self, size, dtype, device, max_concurrency=None, uva_instead_of_gpu=False):
        # ... 原有初始化 ...
        new_buffer = partial(UvaBufferPool, max_concurrency=max_concurrency)
        self.write_indices = new_buffer(self.num_rows, dtype=torch.int32)
        self.write_starts = new_buffer(self.num_rows, dtype=torch.int32)
        self.write_cu_lens = new_buffer(self.num_rows, dtype=torch.int32)
        # UVA 目标额外维护一个暂存内容池;GPU 目标保持 None 走 H2D 路径
        self.write_contents = new_buffer(1, dtype=dtype) if uva_instead_of_gpu else None
​
    def apply_write(self) -> None:
        n = len(self._staged_write_indices)
        if n == 0:
            return
​
        indices_uva = self.write_indices.copy_to_uva(self._staged_write_indices)
        starts_uva = self.write_starts.copy_to_uva(self._staged_write_starts)
        cu_lens_uva = self.write_cu_lens.copy_to_uva(self._staged_write_cu_lens)
​
        if self.write_contents is None:
            # GPU 目标:维持原有 H2D 暂存,Triton 内核读取 GPU 缓冲
            write_contents = async_tensor_h2d(
                self._staged_write_contents, device=self.device, dtype=self.dtype
            )
        else:
            # UVA 目标:直接把内容写入钉住内存的 UVA 缓冲,跳过 H2D 拷贝
            write_contents = self.write_contents.copy_to_uva(self._staged_write_contents)
​
        # 内核直接读取 UVA 视图(CPU 内存)写入目标张量
        _apply_write_kernel[(n,)](
            self.gpu,
            self.gpu.stride(0),
            indices_uva,
            starts_uva,
            write_contents,
            cu_lens_uva,
            None,
            BLOCK_SIZE=1024,
            MULTI_GROUP=False,
        )
        self.clear_staged_writes()
tests/kernels/core/test_uva.py test-coverage

新增 4 个测试,覆盖 UVA 内容路径不调用 H2D、池增长 / 缩短复用语义、copy_to_gpu 兼容性、以及 in-flight 读者生命周期,是正确性的主要保障。

@pytest.mark.skipif(not is_uva_available(), reason="UVA is not available.")
@pytest.mark.parametrize("uva_target", [False, True])
@pytest.mark.parametrize("dtype", [torch.int32, torch.int64, torch.float32])
def test_staged_write_inflight(uva_target, dtype):
    """保证每个 generation 在其消费者完成前不被槽位复用覆盖。"""
    device = torch.device("cuda:0")
    with torch.accelerator.device_index(device.index):
        state = StagedWriteTensor(
            (4, 4096), dtype, device, max_concurrency=2, uva_instead_of_gpu=uva_target
        )
        # 只有 UVA 目标才应创建 write_contents 池
        assert (state.write_contents is not None) == uva_target
        stream = torch.cuda.Stream()
        stream.wait_stream(torch.cuda.current_stream())
        pending: list[tuple[torch.cuda.Event, torch.Tensor, torch.Tensor]] = []
        expected = torch.zeros((4, 4096), dtype=dtype, device="cpu")
        for step in range(24):
            if len(pending) == 2:
                # 槽位即将被复用,先同步并校验上一代快照
                event, snapshot, reference = pending.pop(0)
                event.synchronize()
                torch.testing.assert_close(snapshot.cpu(), reference, rtol=0, atol=0)
            # 交替增长与收缩长度,覆盖 reallocation 和 reuse 两条路径
            length = [3, 17, 1500, 4090][step % 4]
            row = step % 3
            values = torch.arange(length, dtype=dtype, device="cpu") + step * 8192
            if dtype == torch.float32:
                values += 0.25
            expected[row, 2 : 2 + length] = values
            expected[3, 1:4] = step
            with torch.cuda.stream(stream):
                state.stage_write(row, 2, values.tolist())
                state.stage_write(3, 1, [step] * 3)
                state.apply_write()
                # GPU 消费者在下一轮更新前读取本代数据
                snapshot = state.gpu.clone()
                event = torch.cuda.Event()
                event.record(stream)
            pending.append((event, snapshot, expected.clone()))
        for event, snapshot, reference in pending:
            event.synchronize()
            torch.testing.assert_close(snapshot.cpu(), reference, rtol=0, atol=0)

评论区精华

扩展现有 UvaBufferPool 还是引入独立可增长池 设计

@njhill 建议直接修改现有 `UvaBufferPool` 而不是引入新的可增长类,认为把 size 视为初始大小即可兼容。作者采纳后更新实现与测试,并补充数据:逐调用新建 UVA 缓冲(无复用)比 H2D 基线慢约 15%,因此保留可增长、可复用设计。

结论:采纳 @njhill 建议,删除独立的 GrowableUvaBufferPool,直接在 `UvaBufferPool.copy_to_uva()` 中按 2 的幂增长第 0 维。 · 已解决

风险与影响

  • 公共池语义变更:UvaBufferPool 从固定容量变为可增长,影响所有调用方(UvaBackedTensor、FusedStagedWriter、StagedWriteTensor)。旧槽位在增长时被整体替换,若调用方存在未退休的异步 GPU reader 仍引用旧 UVA 视图,可能读到旧数据或被后续复用覆盖。PR 在 docstring 中明确要求调用方负责读者生命周期,测试也覆盖了 in-flight 场景,但该约束依赖调用方纪律。
  • 增长分配开销:新缓冲使用 torch.zeros 分配并零初始化,偶发分配成本可能暴露在尾部延迟中;微基准显示小规模首次使用(64 元素)略慢 1.9%,大尺寸下仍显著优于 H2D。
  • 平台依赖:UVA 路径受 is_uva_available() 保护,非 UVA 平台不可用,不影响默认行为;但测试用 skipif 跳过,潜在的未覆盖平台行为差异需要关注。
  • copy_to_gpu 行为变化:copy_to_uva() 现在可能返回比 self.size 更大的底层缓冲的切片,copy_to_gpu() 返回形状为当前内容长度而非池容量,测试已覆盖,但外部使用者可能对容量假设产生误解。
  • 影响 MRV2 路径中所有 uva_instead_of_gpu=True 的 StagedWriteTensor,主要作用于采样/调度相关张量的 staged write 热点,可显著降低单次 apply_write() 延迟。
  • GPU-backed 目标保持原 H2D 路径,默认行为不变,风险面可控。
  • 对团队而言,该 PR 提供了可复用的"按目标选择暂存位置"模式,并为 UvaBufferPool 增加了动态容量能力,后续其他 UVA 使用方也可受益。
  • 测试覆盖集中在 UVA 可用环境,CI 需要保证此类硬件测试的稳定性。
UVA 缓冲按需增长引入分配开销 异步读者生命周期依赖调用方保证 公共池语义变更影响多调用方 仅在 UVA 可用平台生效

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论