LLM 推理提速 22%:用异步 Continuous Batching 让 GPU 不再空等 的文章头图

LLM 推理提速 22%:用异步 Continuous Batching 让 GPU 不再空等

HuggingFace 详解异步 Continuous Batching:通过 CUDA Streams、CUDA Events、双缓冲和 Carry-Over Mask,让 CPU 批次准备与 GPU 计算完全并行,将 GPU 空闲率从 24% 降至 0.6%,实测提速 22%。

阅读时长: 5 分钟
共 2232字
作者: eimoon.com

大模型在线推理的成本压力,很大程度上来自一个被忽视的问题:CPU 和 GPU 在交替等对方。这篇文章来自 HuggingFace 的推理优化系列(第二篇),讲清楚了异步 Continuous Batching 的完整实现原理,以及如何把 GPU 空闲率从 24% 压到 0.6%,换来 22% 的吞吐提升。


问题:同步批处理在浪费什么

标准的同步 Continuous Batching 流程是这样的:

CPU 准备批次 → GPU 计算 → CPU 等待 → CPU 更新状态 → CPU 准备下一批...

同步批处理示意图

CPU 和 GPU 在轮流工作,谁在跑,另一个就在等。实测数据(8B 模型,batch size 32,8K tokens,H200):

  • 总耗时:300.6 秒
  • GPU 空闲占比:24%
  • 理论提升空间:节省到 228 秒(快 24%)

同步批处理时间线:绿色为 GPU 执行,红色为 CPU 准备阶段,可以看到 24% 的 GPU 空闲

根源很简单:批次的准备(调度新请求、更新 KV Cache 路由、构建输入张量)是纯 CPU 工作,而这些工作本可以在 GPU 计算上一批时同步进行。


解法一:CUDA Streams——让 GPU 操作并行

CUDA Stream 是 GPU 上的有序操作队列。同一个 Stream 内部是串行的,不同 Stream 之间可以并行

CUDA Streams 并行示意:不同 Stream 上的操作可以同时执行

异步批处理需要三个独立的 Stream:

H2D Stream(Host → Device 传输)
Compute Stream(前向推理计算)
D2H Stream(Device → Host 传输)

但实际上,由于 CPU 发射 kernel 本身有开销,并行效果并不如理想情况那样完美:

实际的 CUDA Streams 并行:CPU 的 kernel launch 开销导致启动时间错开

注意一个陷阱:CUDA 的默认 Stream 是"同步 Stream",它会等待所有其他 Stream 清空后才执行,操作期间也会阻塞其他 Stream 启动。

默认 Stream 的阻塞行为对比:使用默认 Stream 会强制串行执行

因此必须显式创建非默认 Stream,并手动管理同步关系。如果只是简单地把操作扔进不同 Stream 而不处理依赖,结果会是错的:

未同步的异步批处理:Stream 之间没有协调会导致计算错误


解法二:CUDA Events——精确控制依赖

光有多个 Stream 还不够,操作之间有数据依赖(先传输完才能计算,先计算完才能回传),需要 CUDA Events 来标记进度:

  • stream.record(event):在 Stream 当前位置插入一个事件标记
  • stream.wait(event):让本 Stream 等待,直到某个 Event 完成

关键:stream.wait() 阻塞的是 Stream,不是 CPU。CPU 调用后立刻返回,继续干别的事情。

CUDA Events 同步机制:Stream 2 等待 Stream 1 的 Event 完成后才开始

# Batch N 的执行编排
h2d_stream.enqueue_transfer(batch_N_inputs)
h2d_stream.record(h2d_done)

compute_stream.wait(h2d_done)      # Compute Stream 等传输完成
compute_stream.forward_pass(batch_N)
compute_stream.record(compute_done)

d2h_stream.wait(compute_done)      # D2H Stream 等计算完成
d2h_stream.enqueue_transfer(batch_N_outputs)
d2h_stream.record(d2h_done)

# CPU 只在这里阻塞一次
d2h_done_event.synchronize()

加上 Events 之后,依赖关系被正确维护,异步执行结果也正确了:

正确的异步批处理:通过 Events 保证依赖顺序,同时保持 Stream 间的并行


数据安全问题一:竞争条件与双缓冲

如果 Batch N 和 Batch N+1 共用同一块设备缓冲区:

  • GPU 正在从缓冲区读数据(Batch N 的输入)
  • CPU 同时在往缓冲区写数据(Batch N+1 的输入)

数据就损坏了。

解法是双缓冲(Double Buffering):准备两个缓冲槽,奇数批次用 Slot A,偶数批次用 Slot B,两者永远不会同时被访问:

双缓冲:Slot A 和 Slot B 交替使用,避免 CPU 写入和 GPU 读取同一块内存

代价是输入/输出张量的内存翻倍,但对整体 VRAM 影响通常可接受。


数据安全问题二:CUDA Graphs 与共享内存池

CUDA Graphs 会把一系列 GPU 操作录制成一张图,之后可以高效重放,大幅降低 kernel launch 延迟。但它有一个限制:图在录制时绑定了特定的内存地址,Slot A 录的图不能直接用于 Slot B 的缓冲区。

最直接的办法是为每个 Slot 录两张图,但这会让 VRAM 翻倍。更优雅的方案是共享内存池

共享内存池:两张 CUDA Graph 共享同一块内存池,总占用约等于较大那张图

由于 Batch N 计算完成后 Batch N+1 才开始,两张图永远不会并行运行,因此可以共享同一块内存池。总 VRAM ≈ max(Graph1, Graph2),而不是两者之和。


数据安全问题三:Token Carry-Over

这是最微妙的一个问题。Batch N 生成的新 token 需要作为 Batch N+1 的输入——但 Batch N+1 的输入是在 Batch N 还在跑的时候就开始准备的,新 token 根本还不存在。

解法是占位符 + Carry-Over Mask

Carry-Over 原理:Batch N 输出的 token 通过 Mask 被搬运到 Batch N+1 的输入中

  1. 在准备 Batch N+1 输入时,把"来自 Batch N 输出"的位置先填 0(占位符)
  2. 同时构建一个 Carry-Over Mask,记录每个 Batch N 输出 token 应该放到 Batch N+1 的哪个位置(-1 表示不需要搬运)
  3. Batch N 计算结束后,执行一次向量加法:input_ids += carry_over_tensor,把 0 位置替换成真正的 token

Carry-Over Mask 张量:记录每个 token 的目标位置,-1 表示不搬运

用加法而非赋值,是因为非 carry-over 位置本来就是合法 token id(不能清零),只有占位符位置是 0,加上 token id 正好等于 token id,其他位置加 0 不变。

这个 carry-over 操作被录进了 CUDA Graph 的第一步,额外开销几乎可以忽略。


完整的异步循环

把所有机制组合起来,执行流程如下:

Step 0(冷启动):CPU 准备 Batch 0 放入 Slot A,发送到 GPU 执行,阻塞等待 D2H 完成。

异步批处理 Step 0:冷启动,发送第一个批次

Step 1(异步开始):GPU 执行 Batch 0 的同时,CPU 空闲出来准备 Batch 1,并把 Batch 1 的所有 GPU 操作入队。

异步批处理 Step 1:GPU 执行 Batch 0,CPU 同步准备 Batch 1

Step 2(双槽交替):Slot A 和 Slot B 开始交替,CPU 和 GPU 完全重叠运行。

异步批处理 Step 2:Slot A 和 Slot B 开始交替,CPU/GPU 完全并行

完整时间线

完整的异步批处理时间线:每个槽的 H2D、Carry-Over、Compute、D2H 操作全部标注

CPU 每批只阻塞一次(等 D2H 完成),其余时间全在准备下一批。


实测结果

异步批处理时间线:几乎全绿,GPU 利用率 99.4%,空闲降至 0.6%

指标 同步批处理 异步批处理
GPU 利用率 76% 99.4%
GPU 空闲率 24% 0.6%
总耗时(8K tokens,batch 32) 300.6 秒 234.5 秒
提速幅度 基准 +22%
CPU 阻塞次数 每步多次 每批一次
额外内存开销 输入缓冲区 ~2x

测试配置:8B 模型,batch size 32,8K tokens,H200。


什么情况值得开启

适合:

  • GPU compute-bound 场景(大模型、大 batch)
  • 长序列生成(16K+ tokens 效果更明显)
  • 追求高吞吐的在线推理服务

不适合:

  • CPU bound 场景(批次准备本身就是瓶颈,GPU 不是)
  • 极小 batch size(调度开销比计算时间还长)

代码在哪里

完整实现在 transformers 库:

  • 入口:continuous_batching.py
  • 异步实现:input_outputs.py 中的 ContinuousBatchingAsyncIOs

同步批处理是最容易理解的写法,但在大模型推理里,CPU 准备批次的时间和 GPU 计算的时间几乎是同一个量级——这意味着每一步都有将近一半的硬件在等待。异步化不需要改模型、不需要量化、不需要更多硬件,只是把已经花出去的时间重叠起来。22% 不是极限,是一个不该放弃的起点。


原文:Unlocking asynchronicity in continuous batching,作者 Rémi Ouazan Reboul、Pedro Cuenca、Aritra Roy Gosthipaty,发布于 HuggingFace Blog,2026 年 5 月 14 日。

关于

关注我获取更多资讯

月球基地博客公众号二维码,扫码关注获取更多 AI 与编程资讯
📢 公众号
月球基地博客作者个人微信二维码,扫码交流 AI 与编程话题
💬 个人号
使用 Hugo 构建
主题 StackJimmy 设计