先说结论:CUDA Graph 省下的,严格来说不是“launch 时间”这一个东西,而是把 CPU 侧反复执行 launch 的相关成本、命令提交的开销、以及 GPU 等待指令时的空窗,一起打包处理了。你实测时看到的端到端时延下降,往往是这几项叠加的结果。
我是在优化一个实时推理服务时对这种体感特别深的。那时候用 Nsight Systems 抓 timeline,发现 GPU 占用率低得很“诡异”,不是 kernel 跑不动,而是每个 kernel 执行完以后,GPU 要等 CPU 花几微秒把下一个 kernel 的 launch 命令送进来。整个流水线就这么被 launch 节奏卡住了。后来引入 CUDA Graph,同样一批 kernel,GPU 占用率明显上去了,端到端时间也降了 30% 以上。
这篇内容就把我排查和实测的过程完整写一遍,重点聊清楚:CUDA Graph 到底把省下的时间花在哪、它能不能真的替代普通 launch、以及工程里有哪些坑不值得再踩。
1. 先搞明白“launch 时间”到底花在哪
1.1 一次普通 kernel launch 的完整路径
很多人以为 kernel launch 就是把一个函数“丢”给 GPU 去跑,很快,没什么成本。实际上一次普通 launch 在 CPU 侧做的事情相当多:
- 检查当前 CUDA context、stream、kernel 参数是否合法;
- 根据 launch 配置计算 grid、block 映射关系;
- 在驱动层把 kernel 对应的模块、函数句柄解析出来;
- 分配或复用一块命令内存,把 launch 描述信息写进去;
- 通过系统调用把命令提交给 GPU 前端;
- 如果启用了 cudaMemcpyAsync、事件、或者依赖上一次结果,还会产生隐式同步。
这一整套流程,在主流 GPU 和驱动上通常要 5~10 微秒。注意这个数字不是固定不变的,它在 CPU 频率、驱动版本、进程上下文切换、甚至系统负载影响下会波动。
打个比方,普通 launch 像是你每次都走到工人面前,单独告诉他“下一步做 A”“下一步做 B”“下一步做 C”。哪怕每句话只有几毫秒,架不住指令数量多。CUDA Graph 做的事,相当于你在开工前把所有步骤写成一张规范操作卡,GPU 拿到卡片后按顺序执行完毕,CPU 不需要一条条下达。
1.2 GPU 是真的“忙”还是在“等命令”
对很多 short kernel 来说,GPU 执行时间可能只有 2~3 微秒,而 launch 开销却要 5 微秒左右。这时你会发现一个反直觉的现象:GPU 计算能力没有被用完,时间都花在“等 CPU 喂下一条指令”上了。
如果你还开了多个 stream、加了事件同步,情况会变得更糟。事件同步本身会让 CPU 阻塞等待 GPU 完成某个点,GPU 又要反过来等待 CPU 把其他 stream 的 kernel 发进来。一来一回,流水线的气泡只会增加。
所以准确地说,CUDA Graph 省下的不是 GPU 的 kernel 执行时间,而是 CPU 反复“下达指令”的时间,以及 GPU 因为等待这些指令而空转的时间。这个区分很重要,它能解释为什么在某些场景下你上了 CUDA Graph 却几乎看不到收益——因为你的 kernel 本身足够长,GPU 早就忙不过来了,launch 开销被完全隐藏,优化它自然没有意义。
2. CUDA Graph 的底层思路:把 launch 提前做完
2.1 capture 阶段到底 capture 了什么
CUDA Graph 的核心是“先捕获,后实例化,再执行”。捕获时,API 会记录下你在 stream 上发起的各种操作,包括 kernel launch、cudaMemcpyAsync、cudaMemsetAsync、事件等,以及它们之间的依赖关系。
这里有一个常见误解:很多人以为捕获阶段会把 kernel 真的执行一遍。不是的。捕获时只会记录命令流,kernel 不会运行。正因为这样,捕获操作本身非常快,和你平时往 stream 上丢一个 launch 命令差不多,但 kernel 没有真正计算。
默认用法是这样的:
cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal); kernel_a<<<grid, block, 0, stream>>>(...); kernel_b<<<grid, block, 0, stream>>>(...); cudaStreamEndCapture(stream, &graph);这两次 kernel launch 在捕获模式下会被记录为两个图节点,且默认存在顺序依赖。之后你可以拿到一个cudaGraph_t对象。
在 capture 里还可以放cudaMemcpyAsync、cudaMemsetAsync,甚至条件节点。这样整条执行流水线的内存搬运和计算步骤都能被图结构统一管理。对推理场景来说,前处理、推理、后处理经常就是一条固定链,恰好适合这种模式。
2.2 实例化与执行:图如何变成真正可运行的对象
拿到图对象以后,还不能直接用,需要实例化:
cudaGraphExec_t graphExec; cudaGraphInstantiate(&graphExec, graph, 0); cudaGraphDestroy(graph);实例化的过程会生成一个可执行计划,包括设备端调度信息、依赖关系、内核参数修正等。这一步可能比较慢,因为它要做很多准备工作,但好处是它只需要做一次,之后可以反复 launch。真正执行时只需一句:
cudaGraphLaunch(graphExec, stream);和普通 loop 相比,相当于把原先 N 次 launch 压缩成了 1 次 launch。
还有个容易忽略的细节:cudaGraphDestroy销毁的是图结构,graphExec仍然可以继续执行。如果你不再需要这个执行对象,记得单独调用cudaGraphExecDestroy。否则内存会一直占着。
2.3 省下的准确描述:CPU 提交开销 + 隐式同步开销
回到标题的问题:CUDA Graph 省下的真的是 launch 时间吗?我的答案是:它省下的是“launch 时间”这个整体,但不是 GPU 端 launch 之后的执行时间,而是 CPU 端为每次 launch 付出的命令编码、提交和同步阻塞时间。
普通 launch 的循环里,即使没有显式同步,CPU 也需要把命令逐个写入 GPU 命令队列。如果每次 kernel 之间没有数据依赖,驱动可以用异步流水让 launch 和 GPU 执行重叠,但一旦存在依赖,或者使用了事件、多 stream,CPU 与 GPU 之间就会互相等待。
CUDA Graph 实例化以后,GPU 侧的调度器可以在硬件层面按图遍历节点,几乎不需要 CPU 干预。这消掉的不只是 launch 系统调用,还包括:
- 每次 launch 时的参数校验;
- 命令缓冲区的重新分配;
- stream 与事件引起的同步检查;
- 多次图遍历时的依赖解析开销。
所以更准确的表述是:CUDA Graph 省下的是 CPU 侧的 launch overhead,以及由它引发的 GPU 空转。如果你测的是 GPU kernel 时间,不会变短;如果你测的是端到端延迟,会发现明显下降。
3. 实测:一个 dummy kernel 的差距
3.1 基准程序怎么写
为了验证上面的理论,我自己写了一个最简单的向量加法 kernel,然后做两种测试:一种是普通 for 循环 launch,另一种是把这个循环整体 capture 成一张图,再执行一次。循环次数我设成 1000 次,这样 launch 开销很容易被放大。
核心代码大致是这样:
__global__ void addKernel(float *a, float *b, float *c, int n) { int i = blockIdx.x * blockDim.x + threadIdx.x; if (i < n) c[i] = a[i] + b[i]; } // 普通循环 launch auto t0 = std::chrono::high_resolution_clock::now(); for (int i = 0; i < ITERS; i++) { addKernel<<<grid, block, 0, stream>>>(a, b, c, N); } cudaStreamSynchronize(stream); auto t1 = std::chrono::high_resolution_clock::now(); // CUDA Graph:整个循环捕获为一张图 cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal); for (int i = 0; i < ITERS; i++) { addKernel<<<grid, block, 0, stream>>>(a, b, c, N); } cudaStreamEndCapture(stream, &graph); cudaGraphInstantiate(&graphExec, graph, 0); cudaGraphDestroy(graph); auto t2 = std::chrono::high_resolution_clock::now(); cudaGraphLaunch(graphExec, stream); cudaStreamSynchronize(stream); auto t3 = std::chrono::high_resolution_clock::now();注意这里普通循环里我没有在每次 launch 之间加同步,而是最后一次性同步。这样测的是“纯异步 launch 循环”的吞吐。如果有显示同步存在,普通 launch 的差距会更大。
另一个关键点是:图里的 1000 个 kernel 是串行依赖的,因为它们在同一个 stream 上捕获。你如果希望不同节点并行,就需要在 capture 时把它们放到不同 stream,再通过事件建立依赖,这是另一种更复杂的用法。
3.2 数字解析:为什么 total time 能低 30%~50%
在我这台机器上(NVIDIA A10,驱动 535,CUDA 12.2),kernel 本身只有大概 2 微秒,1000 次普通 launch 的墙钟时间大概 6 毫秒左右,而 CUDA Graph 执行同样的 1000 个 kernel,只要 3 毫秒左右。
看单个 kernel 的执行时间,两者几乎一样,都能到 2 微秒左右。差距全在端到端时间上。
具体拆解下来大概是这样的:
| 指标 | 普通循环 launch | CUDA Graph 执行 |
|---|---|---|
| GPU kernel 总执行时间 | 约 2.0 ms | 约 2.0 ms |
| CPU launch 总耗时 | 约 4.5 ms | 约 0.01 ms |
| 端到端墙钟时间 | 约 6.0 ms | 约 3.1 ms |
| 额外同步/调度开销 | 明显 | 几乎可忽略 |
你会发现 GPU kernel 时间没有变化,省下来的都是 CPU 侧的命令提交和调度时间。当 kernel 更短、迭代次数更多时,这个差距会被进一步放大;反过来,如果每个 kernel 都被设计得足够大,GPU 已经满负荷,CUDA Graph 的收益就会缩水。
3.3 测量 launch 开销的工具方法
很多人在测 launch 时间时喜欢用cudaEvent,但这个用法容易踩坑。
cudaEventRecord+cudaEventElapsedTime测的是 GPU 时间轴上的耗时,它并不直接反映 CPU 端 launch 所花的时间。如果你在 launch 循环前后记录事件,最后同步,测到的是“从第一个 kernel 在 GPU 上开始执行,到最后一个 kernel 在 GPU 上执行完成”这一段,其实已经包含了 GPU 等待 launch 的空窗,但无法区分 CPU submit 时间和 GPU idle 时间。
想要看清 launch 开销,建议配合 Nsight Systems 使用。在 timeline 视图下,你可以清楚地看到 CPU 上每一行 launch 命令之间的空隙,以及 GPU 上 kernel 节点之间是否有气泡。如果 CPU 端每两个 launch 命令之间的间隔明显大于 GPU kernel 执行时间,说明 launch 开销已经成了瓶颈。
Nsight Compute 则更适合分析 kernel 内部的计算瓶颈,比如访存、指令吞吐、共享内存 bank conflict 等。它直接采样 kernel 执行期间的硬件计数器,一般不反映 launch 调度开销。
4. 实战中最容易踩的坑
4.1 捕获阶段不能有动态控制流
CUDA Graph 捕获的是一张固定的、可重复执行的依赖图。它适合“结构不变、参数变”的场景,不适合在 host 端根据运行期结果临时决定分支。
比如你在 capture 循环里写了这样的代码,几乎一定会出问题:
cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal); for (int i = 0; i < N; i++) { if (condition_from_host) { kernel_a<<<...>>>(...); } else { kernel_b<<<...>>>(...); } } cudaStreamEndCapture(stream, &graph);如果condition_from_host的值在 capture 的瞬间是确定的,那图里确实只会包含其中一条分支,之后无论 condition 怎么变,图都不会自动改变。如果你的分支依赖的是 device 端计算结果,捕获阶段甚至可能直接报cudaErrorStreamCaptureUnsupported。
解决思路有两种:
- 把动态分支放到图外部,图只覆盖两个相对固定的路径;
- 用 CUDA 12.0 之后开始推广的
cudaGraphConditionalHandle/ conditional nodes,把条件判断放到 GPU 端,让设备端自己决定走哪个子图。
第二种方式更强大,但使用门槛更高,调试也更麻烦。我个人的建议是:如果你的动态性主要来自业务逻辑而不是计算结构,优先把逻辑剥离到图外,不要强行塞进图里。
4.2 graphExec 的参数更新与重建
很多场景下,kernel 本身不变,但参数会变,比如每次调用时数组地址、偏移量不同。如果你每次重新cudaGraphInstantiate,成本会非常高,可能比普通 launch 还慢。
正确的做法是复用graphExec,并用专门的接口更新节点参数:
cudaGraphExecKernelNodeSetParams(graphExec, node, &nodeParams);这一步比重新实例化快很多,适合动态 shape 地址变化的场景。但要注意,节点类型、kernel 函数指针、grid/block 维度的结构性信息不能通过这个接口随意改。如果结构本身变了,比如从 2D grid 改成 3D grid,或者新增/删除了某个 kernel,你需要重新捕获图并重新实例化。
所以实际项目中,我通常会把整个图封装成一个类:
- 第一次调用时 capture + instantiate;
- 后续调用只走参数更新 +
cudaGraphLaunch; - 如果检测到结构变化,才触发重新 instantiate。
4.3 graph launch 与 stream 之间的同步关系
cudaGraphLaunch本身也是在一个 stream 上发起的。图内部的节点依赖关系和 stream 没有直接关系,图一旦开始执行,节点会按图结构调度。
但外部 stream 同步需要特别注意。假设你先在stream1上执行了一个普通 kernel,再cudaGraphLaunch(exec, stream1),这没问题,因为图会在同一个 stream 上排在普通 kernel 后面。可如果你在stream2上启动图,而图里前一个节点依赖stream1的结果,却没有建立跨 stream 依赖,就可能产生数据竞争。
捕获时编辑器会自动记录 stream 内的依赖,但跨 stream 依赖需要通过cudaEventRecord/cudaStreamWaitEvent显式建立。忘了这一步,图执行时可能跳过等待,直接读旧数据,而且表现得时好时坏,极难排查。
我遇到过最隐蔽的一次 bug,就是普通 launch 和 graph launch 混用在同一个 stream 上,图里明明复制了一个 buffer,但源 buffer 在另一个 stream 上更新,图却不知道要等它。解决起来不难,在 capture 之前把事件依赖加进去,或者把相关操作也放进捕获范围,问题立刻消失。
5. 常见 launch 相关报错与排查速查
5.1 CUDA_ERROR_LAUNCH_TIMEOUT 其实不是 launch 本身的问题
很多人看到CUDA_ERROR_LAUNCH_TIMEOUT,下意识以为是 kernel launch 失败了。实际上这个错误多半出现在 Windows TDR(Timeout Detection and Recovery)机制下,当 GPU 长时间被一个 kernel 占用、看门狗认为系统已无响应时,驱动会强制终止执行。
从 CUDA Graph 的角度看,如果你的图里包含一个超长的 kernel,或者图内节点因为依赖关系几乎串行持续了很长时间,同样会触发 TDR。排查时不要只看 launch 那一条 API,而是要看 GPU 上是否真的有一个 kernel 长时间运行。
Linux 下的对应现象往往是程序直接报CUDA error: an illegal memory access was encountered或者进程卡死,原因可能是真正越界访问导致后续 kernel 启动失败。遇到这类问题,先跑compute-sanitizer,别盲目优化 launch。
5.2 cudaGraphLaunch 返回值错误排查
cudaGraphLaunch返回错误时,常见的有:
| 错误码 | 可能原因 | 排查方向 |
|---|---|---|
cudaErrorInvalidResourceHandle | graphExec 已被销毁,或句柄不合法 | 检查 exec 是否重复 destroy,是否还在使用期 |
cudaErrorInvalidValue | stream 不合法,或 launch 参数与图节点不匹配 | 检查 stream 是否被错误释放,节点参数是否更新 |
cudaErrorStreamCaptureUnsupported | 捕获时使用了不支持的 API | 检查是否调用了cudaSetDevice等不支持的函数 |
cudaErrorGraphNodeUpdateFailed | cudaGraphExecKernelNodeSetParams更新失败 | 确认 kernel function 签名、参数结构是否一致 |
cudaErrorLaunchFailure | kernel 启动失败,或设备端异常 | 用cudaGetLastError/compute-sanitizer进一步定位 |
遇到cudaGraphLaunch失败,第一件事不是去翻图 API,而是打印cudaGetErrorString,然后看上一次同步之前有没有隐藏错误。很多时候是前面的 kernel 越界了,图 launch 只是那个错误的“接盘侠”。
5.3 别把应用层 launch 和 CUDA kernel launch 混在一起
在工程里,“launch”这个词很容易被过度滥用。比如我们在分布式训练脚本里常见的accelerate launch,它其实是一个进程启动器,用来管理多进程训练环境,和 CUDA kernel launch 完全不是一个层面的东西。
如果看到cannot launch这类异常,先分清报错来源:是操作系统在启动某个软件进程时失败,还是 CUDA runtime 在提交 kernel 时失败。两者排查路径完全不一样。前者要看环境变量、路径、权限;后者要查 CUDA context、驱动版本、显存状态。
这个区分听起来简单,但我在社区里见过不少把 PyTorch 的accelerate launch报错当成 CUDA 问题来查的同学,折腾半天结果发现是虚拟环境里一个依赖没装好。
6. 我对 CUDA Graph 的理解与使用体会
踩过不少坑之后,我反而觉得 CUDA Graph 最值得肯定的地方,不是那个“节省 launch 时间”的口号,而是它逼着你把计算图的结构彻底梳理一遍。
在一个实时推理服务里,输入 batch size 固定、模型结构固定、前后处理固定,图形就是一张稳定重复执行的 DAG。CUDA Graph 能让 CPU 几乎不参与中间调度,GPU 按既定路线一口气跑完,端到端延迟自然低很多。
但如果你面对的是动态 shape、动态分支、甚至设备端会产生新工作量的场景,CUDA Graph 的收益就会打折扣。强行上图的后果往往是捕获失败、频繁重建图、或者得到一张过时图导致计算错误。
我的实操建议很简单:先别管 CUDA Graph 省下了什么,先用 Nsight Systems 抓一次 timeline,看看 CPU 的 launch 调用之间有没有明显间隙,GPU 是不是一直在等命令。有间隙,图值得上;没间隙,说明 launch 不是瓶颈,与其折腾图,不如优化 kernel 本身。
我自己的习惯是:对每条推理链先写一个普通版本,再写一个 graph 版本做 A/B 对比,用同一个数据、同样的迭代次数,测三遍取中位数。如果端到端收益不到 20%,我会直接放弃图,避免维护复杂度。如果收益明显,再花精力把参数更新和内存池一起管理好。
CUDA Graph 不是一个银弹,但对固定结构、短 kernel、高频率调用的场景,它确实是我目前见过的,最接近“免费午餐”的优化手段。关键在于你得先确认瓶颈是不是真的在 launch 这一侧。