大模型在线推理的成本压力,很大程度上来自一个被忽视的问题: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%)
根源很简单:批次的准备(调度新请求、更新 KV Cache 路由、构建输入张量)是纯 CPU 工作,而这些工作本可以在 GPU 计算上一批时同步进行。
解法一:CUDA Streams——让 GPU 操作并行
CUDA Stream 是 GPU 上的有序操作队列。同一个 Stream 内部是串行的,不同 Stream 之间可以并行。
异步批处理需要三个独立的 Stream:
H2D Stream(Host → Device 传输)
Compute Stream(前向推理计算)
D2H Stream(Device → Host 传输)
但实际上,由于 CPU 发射 kernel 本身有开销,并行效果并不如理想情况那样完美:
注意一个陷阱:CUDA 的默认 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 调用后立刻返回,继续干别的事情。
# 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 之后,依赖关系被正确维护,异步执行结果也正确了:
数据安全问题一:竞争条件与双缓冲
如果 Batch N 和 Batch N+1 共用同一块设备缓冲区:
- GPU 正在从缓冲区读数据(Batch N 的输入)
- CPU 同时在往缓冲区写数据(Batch N+1 的输入)
数据就损坏了。
解法是双缓冲(Double Buffering):准备两个缓冲槽,奇数批次用 Slot A,偶数批次用 Slot B,两者永远不会同时被访问:
代价是输入/输出张量的内存翻倍,但对整体 VRAM 影响通常可接受。
数据安全问题二:CUDA Graphs 与共享内存池
CUDA Graphs 会把一系列 GPU 操作录制成一张图,之后可以高效重放,大幅降低 kernel launch 延迟。但它有一个限制:图在录制时绑定了特定的内存地址,Slot A 录的图不能直接用于 Slot B 的缓冲区。
最直接的办法是为每个 Slot 录两张图,但这会让 VRAM 翻倍。更优雅的方案是共享内存池:
由于 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:
- 在准备 Batch N+1 输入时,把"来自 Batch N 输出"的位置先填
0(占位符) - 同时构建一个 Carry-Over Mask,记录每个 Batch N 输出 token 应该放到 Batch N+1 的哪个位置(
-1表示不需要搬运) - Batch N 计算结束后,执行一次向量加法:
input_ids += carry_over_tensor,把0位置替换成真正的 token
用加法而非赋值,是因为非 carry-over 位置本来就是合法 token id(不能清零),只有占位符位置是 0,加上 token id 正好等于 token id,其他位置加 0 不变。
这个 carry-over 操作被录进了 CUDA Graph 的第一步,额外开销几乎可以忽略。
完整的异步循环
把所有机制组合起来,执行流程如下:
Step 0(冷启动):CPU 准备 Batch 0 放入 Slot A,发送到 GPU 执行,阻塞等待 D2H 完成。
Step 1(异步开始):GPU 执行 Batch 0 的同时,CPU 空闲出来准备 Batch 1,并把 Batch 1 的所有 GPU 操作入队。
Step 2(双槽交替):Slot A 和 Slot B 开始交替,CPU 和 GPU 完全重叠运行。
完整时间线:
CPU 每批只阻塞一次(等 D2H 完成),其余时间全在准备下一批。
实测结果
| 指标 | 同步批处理 | 异步批处理 |
|---|---|---|
| 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 日。
关于
关注我获取更多资讯