CUDA Graph:把一轮计算录下来
固定操作和依赖,更新输入,然后重放
→ 逐步讲解 · ▶ 连续播放 · 拖动时间轴回看 · F 全屏
同一轮 decode,会反复走过同一串 kernel
归一化、投影、attention、MLP……下一轮又走相似的流程。逐个 launch 要反复经过框架和运行时。
短 kernel 的执行时间可能比组织和提交它们还短。CPU 没能及时提交时,GPU 时间线上就留下空白。
如果可以提前固定这一段操作及依赖,就能把重复的提交过程记录下来。
kernel 名称、数量和条块长度是教学示意;是否受提交限制应先查看实际 trace。
先热身,再录制,最后反复用
Warm up 先触发需要的初始化、编译与内存准备;为后面的捕获留下稳定的计算路径。
Capture 模式记录 CUDA 操作与依赖,构成 graph。PyTorch 的常见 stream capture 路径在这里录制 GPU 工作。
实例化后得到可执行 graph。创建成本付在前面,后续许多轮 replay 再把它分摊掉。
这里描述常规 PyTorch CUDAGraph 工作流;底层 CUDA 也支持显式构图、更新节点等能力。
一次 replay,GPU 按依赖执行整张图
Graph 保存操作与依赖。示意中 K₁、K₂ 读取输入,K₃ 等到它们的结果都就绪以后再运行。
CPU 发起一次 replay,图中的 kernel 按记录的依赖进入执行。相互独立的分支是否并发,还受资源和调度限制。
K₃ 写入输出 buffer。Graph 不会省去模型的数学运算;主要省去逐个操作的重复提交。
图中分支用于解释依赖,不对应某个具体 Transformer 层。
Graph 的契约:拓扑、参数与存储各管什么
| 对象 | 普通 PyTorch capture / replay | 可以怎样变化 |
|---|---|---|
| 操作拓扑 | 记录的 kernel 与依赖不自动重走 Python | 选另一张 graph,或把动态部分留在 eager |
| shape / stride / dtype | 捕获的算子参数固定 | 不同 bucket 单独捕获 |
| 张量存储地址 | replay 使用捕获时的指针 | 在原 buffer 内 copy_ 新数据 |
| 张量数值 | 每轮由设备实际读取 | 输入、KV 内容、设备索引可更新 |
这里讨论常规 CUDAGraph replay。CUDA 底层还有显式节点更新能力;Python 变量重新赋值不会替你更新捕获参数。
准备固定输入,在旁路 stream 上热身
import torch
static_x = torch.zeros(4, device="cuda")
def f(x):
return x.square() + 1
warm = torch.cuda.Stream()
warm.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(warm):
for _ in range(3):
f(static_x)
torch.cuda.current_stream().wait_stream(warm)zeros 先在当前 stream 上准备输入;wait_stream 建立先后关系。热身触发初始化与缓存,结束后主 stream 才进入捕获。示例要求 PyTorch + CUDA GPU。
捕获之后,把新值写进同一块存储
g = torch.cuda.CUDAGraph()
with torch.cuda.graph(g):
static_y = f(static_x)
static_x.fill_(3) # 原地址,内容变为 3
g.replay() # y = 3²+1 = 10
saved = static_y.clone() # 若要保留这一轮,先复制
static_x.fill_(4)
g.replay() # static_y 被覆盖为 17这段操作在同一 stream 按顺序进行。saved 独立保存 10,static_y 表示最近一次 replay 的输出。完整可下载示例还会与 eager 结果逐轮比较。
地址固定,不代表输入数据固定
常规 PyTorch graph replay 使用捕获时的 kernel 参数,包括张量的内存地址。输入、输出与必要缓冲区要持续有效。
下一轮把 x₂ copy 到原来的输入 buffer,再 replay;不要只把 Python 变量重新绑定到另一个 tensor。
输出 buffer 也会被下一次 replay 覆盖。若上一轮结果要长期保留,需要另存,或确保消费顺序正确。
KV cache 的内容和索引可以变化;被捕获的张量存储与操作布局仍需满足 graph 的要求。
保存 Python 引用,并没有保存旧一轮的数值
第一步更新 A 的内容,B 尚未变化;第二步 replay 覆盖 B,旧引用也看到 17。只有独立复制的 saved 仍是 10。
一个容易漏掉的 bug:变量换了,graph 的指针没换
static_x = torch.zeros(4, device="cuda")
# ...捕获引用 static_x 原存储的 graph...
original_x = static_x # 保留捕获用的存储
static_x = torch.full((4,), 9., device="cuda")
g.replay() # 仍读取 original_x 的 0
# 修复:original_x.copy_(static_x),再 replay变量名只是 Python 中的引用。Graph 记录的是原来那块存储的地址;释放原存储还可能引入更严重的生命周期错误。长期保留所有捕获用的输入与输出。
Python 分支在捕获时只走一次
# 不会在每次 replay 时重新运行这个 Python if
with torch.cuda.graph(g):
if use_branch_a:
static_y = branch_a(static_x)
else:
static_y = branch_b(static_x)以后把 use_branch_a 改成 False,不会重建 graph。分支由 CPU 决定时,在 replay 前选择对应 graph;需要设备数据参与选择时,用支持捕获的设备算子表达。
CPU 计数器、print、普通 Python 列表更新等副作用同样不会随 GPU replay 重复执行。
捕获失败,沿着“要等 CPU 的哪一步”去找
| 操作 / 行为 | 问题 | 处理方向 |
|---|---|---|
| CUDA tensor.item() | 要同步并返回主机标量 | 移到边界或设备内计算 |
| 依赖数据的动态输出 shape | 下一次存储 / kernel 参数不固定 | 固定容量 + 有效长度,或 eager |
| 首次编译 / 初始化 | 捕获中触发不兼容运行时操作 | 先用代表性形状 warm up |
| 自定义算子内部同步 | 可能违反 stream capture 约束 | 检查实现与后端文档 |
torch.cuda.graph 会使用适合捕获的 side stream。不要把不支持的操作包进 try/except 后静默跳过;先保证 eager 路径和捕获路径计算相同。
KV 可以增长:稳定的是大块存储,变化的是索引
预分配
KV blocks 与 block table 使用长期 buffer。每轮在原地址更新序列长度、slot mapping、有效 token 数。
设备读取元数据
kernel 读取本轮索引,写到对应 block / offset。这样“这一轮写哪里”可以变化,而参数指针不必变化。
这个能力取决于 attention kernel 与服务引擎:若实现根据长度在 CPU 改变 launch 形状,仍可能要切换 bucket 或走 eager。
无效 padding 行不能误写真实请求的 KV;使用安全 slot、mask 和对应后端约定,并测试短请求结束后的复用。
形状会变,就准备几个形状桶
在线服务每轮可能有不同数量的请求。为每一个可能的 batch 单独捕获,管理和显存成本都会增长。
例如只准备 1、2、4、8 的 graph。3 个请求使用 4 的 bucket,补齐一个无效位置,并正确屏蔽它的输出和副作用。
真实 token 数、序列长度和 KV block 索引可以放在稳定 buffer 中更新。特别动态的部分也可以保留 eager,使用局部 graph。
bucket 集合为教学示意;padding 的额外计算、attention 后端和动态元数据支持决定适用范围。
形状桶的代价,要按请求分布求平均
教学 batch 序列 [1,3,5,8];桶 [1,2,4,8] 对应 [1,4,8,8]。实际 token 17,计算槽位 21,padding 占 4/21≈19.0%。
如果新增 bucket=5,计算槽位降到 18,浪费率降到 1/18≈5.6%。代价是多捕获一张图、长期存储、管理和冷启动时间。
这里用每槽位相同成本估算;实际 attention 的长度、页表和 kernel 选择会让成本偏离线性。
Graph 省下的提交,要扣掉 padding 与拷贝
教学成本:eager 提交 6 μs + 有效计算 6 μs =12 μs;graph 提交 1 μs + bucket 计算 8 μs + copy/管理 1 μs =10 μs。净省 2 μs。
净收益只有 2 μs,捕获成本 600 μs 要多久回本?
沿用上一页:C=600 μs,te=12 μs,tg=10 μs。N=300 时正好打平;从 第 301 次 开始累计省时间。
若实际 B=2 却用了 bucket=8,额外计算可能让 tg≥te。这时重复再多也无法回本,应换小桶或保留 eager。
Graph 省调度,fusion 还可能省搬数据
Graph 把多个操作的提交打包;K₁、K₂、K₃ 仍是三个独立 kernel,原有的中间 HBM 读写通常还在。
Fusion 把相邻计算放进同一个 kernel,中间值有机会留在寄存器或 shared memory,减少启动次数与显存往返。
可以先融合适合的算子,再把剩余流程捕获成 graph。两者分别改善不同部分的成本。
融合可能增加寄存器压力或限制并行;是否更快仍要按实际形状测量。
重复得足够多,创建成本才回得来
捕获、实例化和 warm up 会花时间。把这些一次性成本记为 C,每轮端到端净节省记为 Δ。
只有 Δ > 0,且 N × Δ > C,整个任务才省时间。算 Δ 时,要包含输入拷贝、padding 和额外管理成本。
示例里 C = 500 μs、Δ = 5 μs,超过 100 轮才回本。稳定的高频 decode 更容易反复复用。
所有数字均为计算示例,不代表任何 GPU、模型或 CUDA 版本的实测结果。
Graph 还要占用持久的内存
GPU HBM 里已经有模型权重和 KV cache,还需要激活、通信与算子工作空间。
PyTorch 为捕获期间的分配维护 graph 私有内存池,让 replay 用到的地址持续有效;graph 与相关 tensor 存活期间,内存也需要保留。
更多 bucket 可能增加显存占用。满足重放顺序和非并发等约束时,某些 graph 可以共享池;共享不能破坏原有存储依赖。
内存布局为示意,不是固定比例。用服务的实际 KV 容量与峰值显存评估,避免只优化单轮微基准。
多一张 graph,可能少服务几条长请求
教学预算:剩余 8 GiB 可以给 KV / graph;每张 graph 独占 0.5 GiB,4 个 bucket 用 2 GiB,KV 剩 6 GiB。若每条请求 KV=0.25 GiB,可容纳 24 条。
增加到 8 个 bucket 后,graph 用 4 GiB,KV 只够 16 条。单轮更快不一定带来更高服务吞吐。
实际 pool 大小应测量;捕获的张量和 graph 生命周期会延长内存保留。不同图共享 pool 也有执行顺序与非并发约束。
换 stream 以后,“先写后读”需要显式依赖
# copy 与 replay 若不在同一个 stream,建立依赖
with torch.cuda.stream(copy_stream):
static_x.copy_(new_x)
ready.record()
with torch.cuda.stream(run_stream):
run_stream.wait_event(ready)
g.replay()
done.record()消费输出的 stream 同样要等待 done。下一轮不得提前覆盖 static_x / static_y;可以用 event 管理双缓冲或串行复用。跨 stream 使用的 tensor 还要保证分配器看到其使用寿命。
单 stream 顺序已经提供这些先后关系;增加 stream 必须有可重叠的工作才有收益。
把稳定的计算段捕获,动态调度留在边界
调度器决定活跃请求与 block 分配;计算段读取相同形状的 buffer;结果出来后再更新请求状态。这样每轮请求集合可以变化,而图中的算子布局保持稳定。
数据相关的动态 shape、跨进程操作或无法捕获的后端可以拆出去。拆成更多 graph 会增加 replay 与边界成本,必须重新测端到端收益。
检查 replay 是否真的覆盖了高频小算子:只捕获一个本来就很大的 GEMM,通常省不了多少主机关键路径。
打开 graph 后没有变快,用四组数据定位原因
| 教学观察 | 可能原因 | 比较什么 |
|---|---|---|
| GPU 原本始终忙 | 设备计算才是瓶颈 | GPU timeline 与 CPU 暴露区段 |
| API 时间下降,整轮不降 | copy / padding 抵消 | 相同真实 B 下完整时间 |
| 首次请求显著变慢 | capture / 编译进了请求路径 | 预捕获与冷启动指标 |
| 并发下降、KV 更早耗尽 | graph pool 挤占缓存 | 峰值显存、可用 KV blocks |
正确性比较要覆盖多轮不同输入、不同 bucket、padding 和请求结束后的复用。只测第一次 replay,无法发现旧输入与输出覆盖问题。
让重放进入服务的关键顺序
- 找出提交瓶颈:先用 trace 识别重复且可捕获的一段。
- 固定存储并 warm up:准备输入、输出与长期 buffer,完成必要初始化。
- 按 bucket 捕获:检查动态形状、CPU 同步和不支持捕获的路径。
- 每轮填数据再 replay:正确更新有效 token、KV 索引和 mask,遵守 stream 依赖。
- 量端到端收益:包含 padding、调度和显存代价,测试多轮输出与 eager 一致。
两次 replay 后,a 与 b 分别保存什么?
展开参考解答
第 1 轮 y=2²+1=5。a 指向捕获输出的同一存储;第 2 轮该存储变成 5²+1=26,所以 a=26。b 是独立拷贝,仍为 5。需要保留旧结果时,在覆盖前完成复制或消费。
先写出假设、单位与中间步骤,再展开核对。
少两个 bucket,节省了内存,增加了多少空算?
展开参考解答
实际 token 总数 18。A 使用 [2,4,8,8],总槽位 22,浪费 4/22=18.2%。B 使用 [4,4,8,8],总槽位 24,浪费 6/24=25%。B 少两张 graph,却多算 2 个槽位;还要按出现频率和 pool 大小判断取舍。
先写出假设、单位与中间步骤,再展开核对。
一个低频 bucket 是否值得提前捕获?
展开参考解答
eager=150×30=4.5 ms。graph=1.2+150×0.024=4.8 ms,反而慢 0.3 ms。每轮省 6 μs;1200/6=200 轮打平,要 201 轮 才有净收益。
先写出假设、单位与中间步骤,再展开核对。
先在 CPU 复现存储别名,再用 GPU 验证真实 replay
python3 lab.py
# alias=17; clone=10; slots=21; break_even=301CPU 实验模拟稳定 buffer、输出覆盖、形状桶与成本模型。下载同目录 gpu_example.py 后,在安装 PyTorch 与 CUDA 的机器运行 python3 gpu_example.py;逐轮断言 graph 与 eager 一致。
下载 CPU 实验代码 · lab.pyPython 3 标准库即可运行。模拟输出用于检验推导;GPU 性能需要在目标硬件上测量。
下载 CUDA 实验 · gpu_example.py参考与延伸阅读
- PyTorch · Accelerating with CUDA Graphs:capture、replay、相同地址与 CPU 开销。
- PyTorch · CUDA semantics:graph 限制、warm up、内存池与共享约束。
- NVIDIA · CUDA Graphs:构图、依赖、实例化、更新和执行。
- vLLM · CUDA Graphs:推理引擎中的 full / piecewise graph、运行形状与 padding。