动画图解 LLM 系统 · 2026-10-20

CPU overhead:GPU 为什么在等

沿着一条时间线,分清提交、排队、执行与同步

→ 逐步讲解 · ▶ 连续播放 · 拖动时间轴回看 · F 全屏

先找到 CPU 在哪里

GPU 计算之前,CPU 先把工作组织好

请求 / tokenCPU 调度组 batch · 准备参数CUDA 提交入队 kernelGPU执行计算CPU 工作:tokenization / scheduler / Python / dispatcher / driver

一个请求到达,CPU 处理输入、挑选这一轮的请求,并准备索引、张量和算子参数。

框架和 CUDA runtime 把 GPU 工作提交到 stream。提交与执行完是两个不同时间点。

CPU 还要处理采样、输出或下一轮调度。哪些步骤落在 CPU,取决于推理引擎的实现。

CPU overhead 指为完成 GPU 工作付出的主机端组织与提交成本;tokenization 等阶段应分开计时。

工程推演

先决定计时起点,才能讨论“快了多少”

指标起止点常见单位
TTFT请求到达 → 第一个输出 tokenms / 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 的时间只解释其中一段。

服务指标 · 一个小算例

一次卡顿,会被每请求平均值摊薄

输出到达时刻:[0, 2, 10] ms
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 跑完再提交下一个

时间 →CPUGPU提交 K₁提交 K₂提交 K₃执行 K₁执行 K₂执行 K₃K₂ 可以先提交,在同一 stream 中等待 K₁

CPU 提交 K₁ 后返回,GPU 稍后开始执行。下面两条线分别表示主机 API 和设备执行。

GPU 跑 K₁ 的同时,CPU 已经提交 K₂、K₃。它们进入队列,按照同一 stream 的顺序执行。

此时 CPU 提交得足够快,GPU 可以连续工作。提交到开跑的间隔变长,也可能只是队列更满。

条块长度为教学示意,不代表实测延迟。不同 stream 可以有并发,但仍受依赖和硬件资源限制。

工程推演

异步队列可以用三行递推算清楚

aᵢ = aᵢ₋₁ + hᵢ
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 依赖;先用它练习读时间线。

动画计算 · 教学时间线

提交越来越早,等待队列反而越来越长

CPU 入队时刻GPU 执行1K12K23K34K4刻度:10 px = 1 μs;h=3 μs,g=8 μs尚未执行队列等待 0 μs

入队时刻 [3,6,9,12];开跑时刻 [3,11,19,27];完成时刻 [11,19,27,35]。CPU 在 12 μs 已交完工作,GPU 到 35 μs 才完成。

工程推演

主机慢一点,GPU 每轮都会出现空白

h=8 μs,g=2 μs,N=4
kernel入队 aᵢ开始 sᵢ结束 fᵢ
18810
2161618
3242426
4323234

设备共执行 8 μs,总完成时间 34 μs;从 0 开始计的利用率 8/34≈23.5%。把 kernel 从 2 μs 优化到 1 μs,总时间只降到 33 μs:提交瓶颈还在。

提交跟不上计算

小 kernel 跑得很快,下一份工作却还没到

时间 →CPUGPU准备 + 提交 K₁准备 + 提交 K₂准备 + 提交 K₃K₁K₂K₃GPU 空闲GPU 空闲kernel 越短,每次提交的成本越显眼

GPU 算一个短 kernel 很快,CPU 还在准备下一个。队列耗尽以后,设备只能等。

这些空白是真正的 GPU idle gaps:空白处既没在算,也不是在等同一 stream 中的前序 kernel。

低 batch decode 会反复经过很多小算子。减轻每轮 CPU 工作、合并提交,可能比单独加快某个 kernel 更有效。

这里只示意提交不足造成的空闲;通信依赖、同步和数据拷贝也可能制造空白,需要用 trace 区分。

会打断异步流水的操作

需要 GPU 的结果,CPU 才必须停下来等

时间 →CPUGPU提交 K₁.item() 等结果提交 K₂执行 K₁执行 K₂等待下一次提交读回结果 / 显式 synchronize → CPU 等 GPU

CPU 如果马上要把 GPU 计算结果变成一个 Python 数字,例如对 CUDA tensor 调用 .item(),就要等这个结果准备好。

等待期间不能越过依赖继续做后续的主机工作;等数据回来后,还需要重新准备并提交 K₂。

能把读回移到迭代边界、合并读回,或把后续计算留在 GPU,就能减少这种停顿。

显式 synchronize 用于确实需要的边界和测量。不是每次 kernel launch 都会同步。

把固定成本分摊出去

一次调度,推进多个请求

逐请求提交一起调度CPU → 请求 ACPU → 请求 BCPU → 请求 CCPU → 请求 DCPU:组织 A + B + C + D → 一次批量算子A 的 tokenB 的 tokenC 的 tokenD 的 token

每个请求分别做很多重复的 Python 和框架调用,固定成本会付很多遍。

把多个活跃请求组织到同一轮 batch,CPU 的部分固定开销由更多 token 分摊;GPU 也能得到更大的工作块。

Continuous batching 在每轮边界移走已完成请求、接入新请求。调度本身仍要时间,还要兼顾等待延迟和显存。

批量大小与收益取决于负载;更大 batch 不等于每条请求延迟一定更低。

减少重复提交

固定的一段执行,可以让 CUDA Graph 重放

时间 →逐个提交Graphlaunch 1launch 2launch 3launch 4launch 5一次 replayGPU:K₁ → K₂ → K₃ → K₄ → K₅计算仍要做;重复的主机端逐项提交被省掉

每一轮重复同一组操作,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:先算这个优化最多值多少钱

speedup = 1 / [(1−f) + f/s]

f 是能被这次优化缩短的端到端关键路径比例,s 是该部分加速倍数。已被设备执行完全覆盖的 CPU 工作,不应重复算入 f。

例:总时间 10 ms
暴露的主机工作 4 ms,设备关键路径 6 ms。
主机工作快 4 倍 → 1 ms。

新总时间 7 ms
加速 10/7≈1.43×。
就算主机耗时归零,上限仍是 10/6≈1.67×。

动画计算 · 关键路径

缩短主机区段,剩下的设备区段决定上限

原来 10 msCPU 4GPU 6优化后总计 10.00 ms加速 1.00×

示意假设两段位于关键路径且无法再重叠。先把主机区段减半,再减到 1/4,观察总时间为何没有跟着减到 1/4。

工程推演

平均变快以后,尾部请求可能仍在排队

pₓ = 排序后的第 ceil(x·n) 个样本(nearest-rank)

教学样本: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 很不稳定。

工程推演

批量分摊固定成本,也会增加等待和单轮时长

每 token 固定开销 ≈ hfixed / B

教学配置 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 μsAPI 用时 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、完成标记或动态路由,只有结果到达后才能决定。为这部分显式保留依赖。

稳态每轮下界 ≳ max(tCPU, tGPU) + 无法覆盖的依赖

这是吞吐模型,不是单个请求的延迟公式。传输、跨 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 延迟。

练习 1

4 次提交:wall time 和设备时间为何不同?

单 stream,h=2 μs,g=5 μs,4 个 kernel;初始 CPU / GPU 时刻都为 0。求入队时刻、完成时刻和 CPU 全部提交后的剩余等待。
展开参考解答

入队 [2,4,6,8] μs。开始 [2,7,12,17],完成 [7,12,17,22] μs。CPU 在 8 μs 返回,全部完成还需 14 μs。设备执行总量 20 μs,区间总长 22 μs;排队不是设备空闲。

先写出假设、单位与中间步骤,再展开核对。

练习 2

把 30% 的关键路径加速 3 倍,值多少?

总时间 20 ms,其中暴露的 CPU 工作占 30%。把这部分加速 3 倍,其余保持不变。求新时间、加速比和理论上限。
展开参考解答

CPU 为 6 ms,设备等其余区段为 14 ms。优化后 6/3+14=16 ms;加速 20/16=1.25×。CPU 耗时归零时,下限 14 ms、最大加速 20/14≈1.43×。

先写出假设、单位与中间步骤,再展开核对。

练习 3

p50 相同,哪种配置更适合 50 ms 的延迟目标?

A:100 个请求中 99 个 20 ms、1 个 200 ms;B:95 个 20 ms、5 个 45 ms。按 nearest-rank 算 p99,并比较所有请求都小于 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.py

Python 3 标准库即可运行。模拟输出用于检验推导;GPU 性能需要在目标硬件上测量。

继续深入

参考与延伸阅读

接着看:CUDA Graph · Continuous batching · RMSNorm 算子优化