CPU overhead:GPU 为什么在等
沿着一条时间线,分清提交、排队、执行与同步
→ 逐步讲解 · ▶ 连续播放 · 拖动时间轴回看 · F 全屏
GPU 计算之前,CPU 先把工作组织好
一个请求到达,CPU 处理输入、挑选这一轮的请求,并准备索引、张量和算子参数。
框架和 CUDA runtime 把 GPU 工作提交到 stream。提交与执行完是两个不同时间点。
CPU 还要处理采样、输出或下一轮调度。哪些步骤落在 CPU,取决于推理引擎的实现。
CPU overhead 指为完成 GPU 工作付出的主机端组织与提交成本;tokenization 等阶段应分开计时。
先决定计时起点,才能讨论“快了多少”
| 指标 | 起止点 | 常见单位 |
|---|---|---|
| TTFT | 请求到达 → 第一个输出 token | ms / request |
| ITL | 相邻两个输出 token 到达的间隔 | ms / interval |
| TPOT | 每条请求的平均输出间隔(O>1) | ms / token |
| 吞吐 | 区间内完成的输出 token / 区间时长 | tokens/s |
| 提交耗时 | CPU 调用开始 → 异步 API 返回 | μs / call |
TTFT 含排队、tokenization、调度和 prefill。TPOT=(t最后−t首个)/(O−1),O 是输出 token 数;GPU 一个 kernel 的时间只解释其中一段。
一次卡顿,会被每请求平均值摊薄
ITL:[2, 8] ms TPOT=(10−0)/(3−1)=5 ms
用户先等了 2 ms,再等 8 ms;这条请求的平均输出间隔是 5 ms。评估流式输出的卡顿,需要保留每个相邻间隔。
请求 TPOT 的 p99
先为每条请求求一个均值,再对这些均值取 p99。每条请求贡献一个样本。
全部 ITL 的 p99
收集所有相邻输出间隔,再取 p99。长输出请求贡献更多样本。
两种统计量回答不同问题,不能混用。输出只有一个 token 时,TPOT 分母为 0,应单独统计而非当作 0 ms。
CPU 不必等一个 kernel 跑完再提交下一个
CPU 提交 K₁ 后返回,GPU 稍后开始执行。下面两条线分别表示主机 API 和设备执行。
GPU 跑 K₁ 的同时,CPU 已经提交 K₂、K₃。它们进入队列,按照同一 stream 的顺序执行。
此时 CPU 提交得足够快,GPU 可以连续工作。提交到开跑的间隔变长,也可能只是队列更满。
条块长度为教学示意,不代表实测延迟。不同 stream 可以有并发,但仍受依赖和硬件资源限制。
异步队列可以用三行递推算清楚
sᵢ = max(aᵢ, fᵢ₋₁)
fᵢ = sᵢ + gᵢ
hᵢ 是主机准备与提交时间;aᵢ 是入队时刻。GPU 必须等工作到达,也必须等同 stream 的上一项结束,所以取 max。gᵢ 是设备执行时间。
队列等待
qᵢ = sᵢ − aᵢ
工作已经提交,GPU 还在忙前一项。
设备空闲
idleᵢ = max(0, aᵢ − fᵢ₋₁)
前一项结束了,下一项还没到。
模型假设单 CPU 提交线程、单 stream,无拷贝与跨 stream 依赖;先用它练习读时间线。
提交越来越早,等待队列反而越来越长
入队时刻 [3,6,9,12];开跑时刻 [3,11,19,27];完成时刻 [11,19,27,35]。CPU 在 12 μs 已交完工作,GPU 到 35 μs 才完成。
主机慢一点,GPU 每轮都会出现空白
| kernel | 入队 aᵢ | 开始 sᵢ | 结束 fᵢ |
|---|---|---|---|
| 1 | 8 | 8 | 10 |
| 2 | 16 | 16 | 18 |
| 3 | 24 | 24 | 26 |
| 4 | 32 | 32 | 34 |
设备共执行 8 μs,总完成时间 34 μs;从 0 开始计的利用率 8/34≈23.5%。把 kernel 从 2 μs 优化到 1 μs,总时间只降到 33 μs:提交瓶颈还在。
小 kernel 跑得很快,下一份工作却还没到
GPU 算一个短 kernel 很快,CPU 还在准备下一个。队列耗尽以后,设备只能等。
这些空白是真正的 GPU idle gaps:空白处既没在算,也不是在等同一 stream 中的前序 kernel。
低 batch decode 会反复经过很多小算子。减轻每轮 CPU 工作、合并提交,可能比单独加快某个 kernel 更有效。
这里只示意提交不足造成的空闲;通信依赖、同步和数据拷贝也可能制造空白,需要用 trace 区分。
需要 GPU 的结果,CPU 才必须停下来等
CPU 如果马上要把 GPU 计算结果变成一个 Python 数字,例如对 CUDA tensor 调用 .item(),就要等这个结果准备好。
等待期间不能越过依赖继续做后续的主机工作;等数据回来后,还需要重新准备并提交 K₂。
能把读回移到迭代边界、合并读回,或把后续计算留在 GPU,就能减少这种停顿。
显式 synchronize 用于确实需要的边界和测量。不是每次 kernel launch 都会同步。
一次调度,推进多个请求
每个请求分别做很多重复的 Python 和框架调用,固定成本会付很多遍。
把多个活跃请求组织到同一轮 batch,CPU 的部分固定开销由更多 token 分摊;GPU 也能得到更大的工作块。
Continuous batching 在每轮边界移走已完成请求、接入新请求。调度本身仍要时间,还要兼顾等待延迟和显存。
批量大小与收益取决于负载;更大 batch 不等于每条请求延迟一定更低。
固定的一段执行,可以让 CUDA Graph 重放
每一轮重复同一组操作,CPU 仍逐个准备与提交,短 kernel 场景就容易受这些开销影响。
CUDA Graph 提前记录操作及依赖。以后用一次 replay 发起这一段工作。
GPU 仍运行这些 kernel。Graph 主要减少重复调度与提交;kernel fusion 则会把多个 kernel 合成更少的 kernel。
CUDA Graph 需要可捕获的计算与稳定的存储布局;动态请求通常通过 bucket、padding 或局部 graph 处理。
端到端 wall time:同步必须放在计时边界
import time, torch
for _ in range(20):
step() # warm up
torch.cuda.synchronize() # 清掉计时前的异步工作
t0 = time.perf_counter()
for _ in range(100):
step()
torch.cuda.synchronize() # 等最后一轮设备工作完成
ms_per_step = (time.perf_counter() - t0) * 1000 / 100它测整个 100 轮区间的平均完成时间,包含该区间内的主机提交与设备执行重叠。不要在每个 kernel 后插 synchronize;那会改变原来的流水行为。
CUDA event:量的是 stream 上两道标记之间
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
start.record()
for _ in range(100):
step()
end.record()
end.synchronize()
print(start.elapsed_time(end) / 100, "ms/step")记录到同一 stream 的两个 event 按顺序执行。若两者之间 GPU 在等主机提交,event 区间也包含这些空白;它不是“所有 kernel 纯计算时间之和”。
多 stream 的工作需要明确 join / event 依赖,否则终点标记可能早于其他 stream 的工作结束。
一个 .item() 会把后续提交绑到设备完成之后
# 每轮都依赖 GPU 标量 → CPU 控制流
if score.max().item() > threshold:
launch_branch_a()
else:
launch_branch_b()先看语义
CPU 确实需要决定分支时,等待是依赖的一部分。只删除 synchronize 无法消除这条数据依赖。
再改工作方式
能否在 GPU 内选择结果?是否能把多个状态合成一次读回?终止判断能否放到每轮边界统一处理?
不要为了避开一次同步而无条件计算两条昂贵分支;把新增计算也放进端到端比较。
Amdahl:先算这个优化最多值多少钱
f 是能被这次优化缩短的端到端关键路径比例,s 是该部分加速倍数。已被设备执行完全覆盖的 CPU 工作,不应重复算入 f。
例:总时间 10 ms
暴露的主机工作 4 ms,设备关键路径 6 ms。
主机工作快 4 倍 → 1 ms。
新总时间 7 ms
加速 10/7≈1.43×。
就算主机耗时归零,上限仍是 10/6≈1.67×。
缩短主机区段,剩下的设备区段决定上限
示意假设两段位于关键路径且无法再重叠。先把主机区段减半,再减到 1/4,观察总时间为何没有跟着减到 1/4。
平均变快以后,尾部请求可能仍在排队
教学样本:100 个请求,95 个耗时 10 ms,5 个耗时 100 ms。平均 = (95×10+5×100)/100 = 14.5 ms;p50=10 ms,p99=100 ms。
如果只看中位数,会漏掉那 5 个慢请求。比较配置时固定输入分布、并发和到达率,并同时报告 TTFT、TPOT 的分位数与输出吞吐。
分位数有多种插值定义,报告时写清工具和方法;小样本的 p99 很不稳定。
批量分摊固定成本,也会增加等待和单轮时长
教学配置 A:B=1
每轮 1 ms 固定提交 + 2 ms 计算
吞吐 = 1/0.003≈333 tokens/s
一次推进耗时 3 ms
教学配置 B:B=8
每轮 1 ms 固定提交 + 5 ms 计算
吞吐 = 8/0.006≈1333 tokens/s
一次推进耗时 6 ms
吞吐上升 4 倍,但每个请求的推进间隔更长;如果为了凑 batch 等待,还会加上排队时间。用延迟目标约束 batching,而不是只追最大 tokens/s。
抓一段稳定运行:让 CPU 与 GPU 出现在同一张图上
nsys profile --trace=cuda,nvtx,osrt \
--sample=none -o decode_trace python3 bench.py
nsys stats decode_trace.nsys-rep在 bench.py 中先 warm up,再用 NVTX 标记 scheduler、prepare inputs、decode 和 sampling;短时间采集有代表性的负载。
torch.cuda.nvtx.range_push("decode_step")
step()
torch.cuda.nvtx.range_pop()命令要求已安装 Nsight Systems 和可用 CUDA GPU。标记范围是 CPU 区段;借助关联的 CUDA API / kernel 判断它提交了哪些设备工作。
看到 40 μs 的 launch latency,先拆成两段
| 教学 trace 事件 | 时刻 | 含义 |
|---|---|---|
| CPU API 开始 / 返回 | 0 / 4 μs | API 用时 4 μs |
| 之前的 GPU kernel 结束 | 38 μs | 该 stream 一直有工作 |
| 新 kernel 开始 | 40 μs | 从 API 开始算延迟 40 μs |
这 40 μs 大部分是排队;不能直接说“CPU launch 花了 40 μs”。先看 API 本身,再看 stream 是否空闲,以及剩余 2 μs 的依赖或调度间隔。
同样长度的空白若发生在 GPU 已闲、CPU 还没调用下一次 launch 时,才更像供给不足。
优化前先下一个能被 trace 推翻的判断
| 教学观察 | 候选原因 | 下一步证据 |
|---|---|---|
| GPU 空 25 μs;CPU 正在调度 | scheduler / Python 供给不足 | NVTX 范围、CPU 栈、graph 对照 |
| GPU 空;CPU 卡在同步拷贝 | 读回依赖 | Memcpy DtoH、同步 API、.item() |
| 本卡空;其他卡仍在算 | 跨卡不平衡 / collective 等待 | 逐 rank trace、专家 token 数 |
| 每轮首次分配附近变慢 | 分配 / 初始化 / 缓存未热 | 固定 buffer 后重测、warm up 对照 |
每次只改一类因素,保持相同负载。CPU 整体占用高低不能单独证明瓶颈:一个提交线程满载也可能只占多核机器的一小部分。
把独立工作重叠起来,先确认生命周期
可重叠的准备
CPU 为下一轮整理元数据,GPU 执行当前轮。用双缓冲避免 CPU 改写设备还在读取的输入。
必须等的准备
依赖当前采样结果的 token、完成标记或动态路由,只有结果到达后才能决定。为这部分显式保留依赖。
这是吞吐模型,不是单个请求的延迟公式。传输、跨 stream event 与 buffer 复用时点也必须算进去。
一个有用的实验,必须能回答“为什么变快”
| 固定条件 | 采集 | 检验 |
|---|---|---|
| 模型、dtype、B、上下文分布 | 端到端 TTFT / TPOT / tokens/s | 输出正确性、相同请求数 |
| warm up、时钟状态、并发 | CPU API、GPU 时间线、通信 | 是否只是少算了工作 |
| 计时边界与同步方式 | p50 / p95 / p99、样本数 | 提升是否来自排队变化 |
微基准用于隔离算子;服务压测用于确认用户看到的收益。Profiler 本身也会增加开销,最后用不采集 trace 的运行确认端到端数字。
测量时,把三种时间分开
CPU wall time
只包住异步调用的 Python 计时,主要看到提交耗时;结尾同步后,才覆盖等待 GPU 完成的时间。
CUDA events
在 stream 上标记边界,测设备时间区间。读取耗时前,先确保结束 event 已完成。
系统 trace
Nsight Systems 或 PyTorch Profiler 同时显示 CPU API、GPU kernel、拷贝和同步。
把 GPU 空白对齐到 CPU:在调度、分配、读回,还是通信等待?
优化的下一步,由空白的位置决定
- GPU 连续忙:分析算子、显存读取和通信,CPU 未必是瓶颈。
- GPU 在等下一次提交:检查调度路径、Python 循环、batching 和 CUDA Graph。
- CPU 在等 GPU 回传:找到 .item()、同步拷贝、synchronize 和控制流依赖。
- 每轮都在分配:复用工作缓冲区,检查 allocator 与实际同步。
先 warm up,再用相同输入、并发和计时边界比较。既看吞吐,也看首 token 延迟与逐 token 延迟。
4 次提交:wall time 和设备时间为何不同?
展开参考解答
入队 [2,4,6,8] μs。开始 [2,7,12,17],完成 [7,12,17,22] μs。CPU 在 8 μs 返回,全部完成还需 14 μs。设备执行总量 20 μs,区间总长 22 μs;排队不是设备空闲。
先写出假设、单位与中间步骤,再展开核对。
把 30% 的关键路径加速 3 倍,值多少?
展开参考解答
CPU 为 6 ms,设备等其余区段为 14 ms。优化后 6/3+14=16 ms;加速 20/16=1.25×。CPU 耗时归零时,下限 14 ms、最大加速 20/14≈1.43×。
先写出假设、单位与中间步骤,再展开核对。
p50 相同,哪种配置更适合 50 ms 的延迟目标?
展开参考解答
A 的 p99 是第 99 个样本 =20 ms,但仍有 1 个 200 ms 违约;B 的 p99=45 ms,全部达标。分位数目标与“所有请求达标”不是同一件事,还要看违约率与最大值。
先写出假设、单位与中间步骤,再展开核对。
把提交队列写成模拟器,区分等待与空闲
python3 lab.py
# finish=35 us; host_return=12 us; remaining=23 us运行 --host 8 --gpu 2,预测设备空白。再只把 --gpu 改为 1,比较总完成时间;最后把 --host 改为 2,验证真正移除瓶颈后收益如何变化。
下载 CPU 实验代码 · lab.pyPython 3 标准库即可运行。模拟输出用于检验推导;GPU 性能需要在目标硬件上测量。
参考与延伸阅读
- NVIDIA · Overhead and Latency in Nsight Systems:区分 API 成本、launch latency、排队和同步。
- PyTorch · CUDA semantics:异步执行、CUDA events 与同步边界。
- PyTorch · Accelerating with CUDA Graphs:重放如何省掉 Python、C++ 与 driver 的重复提交成本。
- Nsight Systems · User Guide:CLI、CUDA trace 与 NVTX 标记。