解锁连续批处理中的异步性
同步批处理导致 GPU 有四分之一时间处于空闲
在同步连续批处理中,CPU 和 GPU 交替工作:当 GPU 计算前向传播时 CPU 等待,而 CPU 准备下一个批次时 GPU 等待。对批量大小为 32 的 8B 模型生成 8K tokens 进行分析显示总时间为 300.6 秒,其中 24.0% 的时间用于 GPU 等待 CPU。这个空闲间隔代表了仅靠连续批处理无法消除的吞吐量损失。
创建并发需要非默认 CUDA 流
为了让 CPU 和 GPU 工作重叠,我们必须在不阻塞 CPU 的情况下启动 GPU 操作。在 PyTorch 中,未显式指定流的操作会在默认流上运行,该流会与所有其他流同步,并迫使 CPU 等待 GPU 完成。使用非默认流可以让 CPU 入队工作并立即恢复控制。我们需要三个独立的流:一个用于 host‑to‑device(H2D)传输,一个用于 GPU 计算,一个用于 device‑to‑host(D2H)传输,因为这些操作相互独立,并且当放置在不同流中时可以并发运行。
在流之间强制执行顺序需要 CUDA 事件
独立启动三个流会导致竞争条件:计算可能在 H2D 传输完成之前开始,而 D2H 可能在计算结束之前传输结果。CUDA 事件会在一个流中记录一个标记;另一个流可以在继续之前等待该标记。通过在每次 H2D 传输后记录事件并在计算流上等待它,同样在计算后记录事件并在 D2H 流上等待它,我们创建了一个流水线,其中 GPU 强制执行正确的顺序,而 CPU 在入队所有工作后保持空闲。
避免数据损坏和处理 carry‑over 需要双缓冲和掩码
在批次 N 计算时准备批次 N+1 可能会覆盖 GPU 仍在读取的输入缓冲区。解决方案是保持两套主机侧和设备侧张量(槽 A 和 B),并在它们之间交替使用,这会增加内存使用量但可以防止竞争条件。由于同时出现在两个批次中的请求需要其新生成的 token 作为下一个批次的输入,我们在批次 N+1 的输入缓冲区中插入一个占位符 token(值 0),随后用批次 N 的输出中的实际 token 替换它。这种替换称为 carry‑over,由指定目标位置的 carry‑over 掩码执行;该掩码在 CUDA 图中以可忽略的成本应用。
完整的异步循环重叠 CPU 批次准备与 GPU 计算
步骤 0(冷启动)使用同步路径调度批次 0。从步骤 1 开始,CPU 在 GPU 使用槽 A 计算批次 N 时,在槽 B 中准备批次 N+1:驱逐已完成的请求,接收新请求,更新 KV 缓存,并构建 carry‑over 掩码。输入准备好后,CPU 在 H2D 流上入队 H2D 传输,记录事件,在计算流上等待该事件,启动前向传播,记录另一个事件,并在 D2H 流上等待该事件后再启动输出传输。然后 CPU 仅在最终同步事件上阻塞以将结果复制回去,之后它立即开始准备下一个批次。此模式会重复,只要批次 N+1 的输入在批次 N 完成时已准备好,GPU 就会保持忙碌。
实验结果显示 22% 的加速
使用异步流水线运行与同步基线相同的工作负载(8K tokens,批量大小 32,8B 模型)会得到一个时间线,其中 CPU 和 GPU 几乎持续重叠。GPU 在总运行时间的 99.4% 处于活动状态,而同步情况下为 76.0%。总生成时间从 300.6 秒降至 234.5 秒,加速约 22%。剩余的差距对应于 CPU 等待 D2H 传输完成的不可避免的同步点;不需要新的内核或模型更改。
结论
通过使用 CUDA 流和事件将基于调度的依赖替换为基于数据的依赖,并通过双缓冲和 carry‑over 掩码解决竞争条件,我们在连续批处理中解耦了 CPU 和 GPU 工作。这使得 GPU 能够保持饱和,在保持模型准确性的同时为 LLM 推理提供显著的吞吐量提升。
Sources
相关
- Dispatch
- Dispatch
- Dispatch
- Dispatch
- Dispatch