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

CUDA Graph:把一轮计算录下来

固定操作和依赖,更新输入,然后重放

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

从重复操作开始

同一轮 decode,会反复走过同一串 kernel

时间 →CPUGPUlaunch 1launch 2launch 3launch 4launch 5K1K2K3K4K5每轮执行同一段流程,主机却重复做了一遍提交

归一化、投影、attention、MLP……下一轮又走相似的流程。逐个 launch 要反复经过框架和运行时。

短 kernel 的执行时间可能比组织和提交它们还短。CPU 没能及时提交时,GPU 时间线上就留下空白。

如果可以提前固定这一段操作及依赖,就能把重复的提交过程记录下来。

kernel 名称、数量和条块长度是教学示意;是否受提交限制应先查看实际 trace。

建立 graph

先热身,再录制,最后反复用

Warm up真正执行几轮Capture记录操作与依赖Instantiate准备可执行 graphReplay × N反复执行捕获记录 CUDA 工作;录制这一步本身不执行这些 GPU 操作

Warm up 先触发需要的初始化、编译与内存准备;为后面的捕获留下稳定的计算路径。

Capture 模式记录 CUDA 操作与依赖,构成 graph。PyTorch 的常见 stream capture 路径在这里录制 GPU 工作。

实例化后得到可执行 graph。创建成本付在前面,后续许多轮 replay 再把它分摊掉。

这里描述常规 PyTorch CUDAGraph 工作流;底层 CUDA 也支持显式构图、更新节点等能力。

依赖图

一次 replay,GPU 按依赖执行整张图

输入 buffer A每轮填入新数据K₁:投影K₂:另一分支K₃:合并输出 BCPU:一次 cudaGraphLaunch

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 结果逐轮比较。

存储与数据

地址固定,不代表输入数据固定

固定存储:buffer A这一轮:x₁下一轮:x₂地址不变 · 内容更新相同 graph相同指针参数固定存储:buffer B输出 y₁输出 y₂

常规 PyTorch graph replay 使用捕获时的 kernel 参数,包括张量的内存地址。输入、输出与必要缓冲区要持续有效。

下一轮把 x₂ copy 到原来的输入 buffer,再 replay;不要只把 Python 变量重新绑定到另一个 tensor。

输出 buffer 也会被下一次 replay 覆盖。若上一轮结果要长期保留,需要另存,或确保消费顺序正确。

KV cache 的内容和索引可以变化;被捕获的张量存储与操作布局仍需满足 graph 的要求。

动画推演 · 存储与别名

保存 Python 引用,并没有保存旧一轮的数值

固定输入存储 Ax = 3Graph:y = x² + 1y = 10独立的 clonesaved = 10旧引用 = static_y;它仍指向输出存储 B。旧引用读到 10

第一步更新 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 = 3ABC选择 bucket = 4ABCpadGraph · 1Graph · 2Graph · 4Graph · 8

在线服务每轮可能有不同数量的请求。为每一个可能的 batch 单独捕获,管理和显存成本都会增长。

例如只准备 1、2、4、8 的 graph。3 个请求使用 4 的 bucket,补齐一个无效位置,并正确屏蔽它的输出和副作用。

真实 token 数、序列长度和 KV block 索引可以放在稳定 buffer 中更新。特别动态的部分也可以保留 eager,使用局部 graph。

bucket 集合为教学示意;padding 的额外计算、attention 后端和动态元数据支持决定适用范围。

工程推演

形状桶的代价,要按请求分布求平均

padding 浪费率 = (Σ bucket(Bᵢ) − Σ Bᵢ) / Σ bucket(Bᵢ)

教学 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 与拷贝

实际 B = 3;每槽计算 2 μstoken 1token 2token 3padding有效计算:6 μseager 总时间:12 μs

教学成本:eager 提交 6 μs + 有效计算 6 μs =12 μs;graph 提交 1 μs + bucket 计算 8 μs + copy/管理 1 μs =10 μs。净省 2 μs。

工程推演

净收益只有 2 μs,捕获成本 600 μs 要多久回本?

Teager = N·te  Tgraph = C + N·tg
Tgraph < Teager ⇒ N > C/(te−tg)

沿用上一页:C=600 μs,te=12 μs,tg=10 μs。N=300 时正好打平;从 第 301 次 开始累计省时间。

若实际 B=2 却用了 bucket=8,额外计算可能让 tg≥te。这时重复再多也无法回本,应换小桶或保留 eager。

两个优化方向

Graph 省调度,fusion 还可能省搬数据

CUDA Graph:减少提交Kernel fusion:减少 kernel / 搬运一次 replayK1K2K3K₁ → HBM → K₂ → HBM → K₃一个融合 kernel两种办法可以一起用:graph 中的节点也可以是融合 kernel

Graph 把多个操作的提交打包;K₁、K₂、K₃ 仍是三个独立 kernel,原有的中间 HBM 读写通常还在。

Fusion 把相邻计算放进同一个 kernel,中间值有机会留在寄存器或 shared memory,减少启动次数与显存往返。

可以先融合适合的算子,再把剩余流程捕获成 graph。两者分别改善不同部分的成本。

融合可能增加寄存器压力或限制并行;是否更快仍要按实际形状测量。

摊销

重复得足够多,创建成本才回得来

创建成本 C = 500 μs每轮净节省 Δ = 5 μsreplay 次数 N累计节省CN > C / Δ = 100

捕获、实例化和 warm up 会花时间。把这些一次性成本记为 C,每轮端到端净节省记为 Δ。

只有 Δ > 0,且 N × Δ > C,整个任务才省时间。算 Δ 时,要包含输入拷贝、padding 和额外管理成本。

示例里 C = 500 μs、Δ = 5 μs,超过 100 轮才回本。稳定的高频 decode 更容易反复复用。

所有数字均为计算示例,不代表任何 GPU、模型或 CUDA 版本的实测结果。

别只看 launch 时间

Graph 还要占用持久的内存

GPU HBM 总预算模型权重KV cacheGraph ApoolGraph Bpool捕获更多形状,需要关注 graph 内存与 KV 可用容量

GPU HBM 里已经有模型权重和 KV cache,还需要激活、通信与算子工作空间。

PyTorch 为捕获期间的分配维护 graph 私有内存池,让 replay 用到的地址持续有效;graph 与相关 tensor 存活期间,内存也需要保留。

更多 bucket 可能增加显存占用。满足重放顺序和非并发等约束时,某些 graph 可以共享池;共享不能破坏原有存储依赖。

内存布局为示意,不是固定比例。用服务的实际 KV 容量与峰值显存评估,避免只优化单轮微基准。

工程推演

多一张 graph,可能少服务几条长请求

可用于 KV 的余量 = HBM − 权重 − 工作区 − 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 必须有可重叠的工作才有收益。

工程推演

把稳定的计算段捕获,动态调度留在边界

CPU scheduler → 填入固定元数据 → graph replay → 消费结果

调度器决定活跃请求与 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,无法发现旧输入与输出覆盖问题。

落地

让重放进入服务的关键顺序

  1. 找出提交瓶颈:先用 trace 识别重复且可捕获的一段。
  2. 固定存储并 warm up:准备输入、输出与长期 buffer,完成必要初始化。
  3. 按 bucket 捕获:检查动态形状、CPU 同步和不支持捕获的路径。
  4. 每轮填数据再 replay:正确更新有效 token、KV 索引和 mask,遵守 stream 依赖。
  5. 量端到端收益:包含 padding、调度和显存代价,测试多轮输出与 eager 一致。
练习 1

两次 replay 后,a 与 b 分别保存什么?

计算 y=x²+1。第 1 轮 x=2,令 a=static_y、b=static_y.clone();第 2 轮原地写 x=5 并 replay。完成后 a、b 分别是多少?
展开参考解答

第 1 轮 y=2²+1=5。a 指向捕获输出的同一存储;第 2 轮该存储变成 5²+1=26,所以 a=26。b 是独立拷贝,仍为 5。需要保留旧结果时,在覆盖前完成复制或消费。

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

练习 2

少两个 bucket,节省了内存,增加了多少空算?

真实 batch [2,3,6,7]。方案 A 的桶为 [1,2,4,8];方案 B 为 [4,8]。分别求计算槽位和 padding 浪费率。
展开参考解答

实际 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 大小判断取舍。

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

练习 3

一个低频 bucket 是否值得提前捕获?

eager 每轮 30 μs;replay 含 copy / padding 后 24 μs;捕获成本 1.2 ms。预计只运行 150 轮,求两种总时间,并给出回本次数。
展开参考解答

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=301

CPU 实验模拟稳定 buffer、输出覆盖、形状桶与成本模型。下载同目录 gpu_example.py 后,在安装 PyTorch 与 CUDA 的机器运行 python3 gpu_example.py;逐轮断言 graph 与 eager 一致。

下载 CPU 实验代码 · lab.py

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

下载 CUDA 实验 · gpu_example.py
继续深入

参考与延伸阅读

接着看:CPU overhead · Continuous batching · Kernel fusion 实例