ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CUDA Graph 优化内核启动:不止省 launch,重塑 CPU-GPU 调度链路

CUDA Graph 优化内核启动:不止省 launch,重塑 CPU-GPU 调度链路

我最初接触 CUDA Graph 时,几乎所有资料都在说同一句话:它能省掉 kernel launch 的开销。于是我很自然地以为,CUDA Graph 的收益就是把每次 kernel launch 那 5-10 微秒省下来,仅此而已。直到我在一个推理服务里做了详细的 profiling,才发现这个认知偏差大到离谱——如果只盯着 launch 时间去看收益,你不仅会算错账,还可能做出完全错误的架构决策。

CUDA Graph 确实减少了 launch 时间,但它真正改变的是 CPU 调度 GPU 的整个流程,而 launch 开销只是这个流程里最表层的那一块。这篇文章我会从 launch 的底层开销拆起,把 CUDA Graph 到底动了哪些环节讲透,再用我的实测数据验证为什么"省 launch 时间"这个说法容易误导人,顺便把图重放 API 的隐藏大坑一并倒出来。

1. launch 开销的真实账本:一次 Kernel 启动到底花在哪

1.1 从 CPU 调用到 GPU 执行,这中间的每一跳

要搞清楚 CUDA Graph 省了什么,先得把一次普通 kernel launch 的完整路径走一遍。很多人把 launch 想得太简单:CPU 调用一个 API,GPU 收到指令,然后执行,仅此而已。实际上一次 cudaLaunchKernel 调用内部要经历的东西远不止这些。

大致链路是这样的:

  • CPU 侧:你的代码调用 kernel<<<>>>,C++ 编译器把它转成 cudaLaunchKernel 调用。
  • CUDA 运行时:检查 launch 配置(grid、block、shared memory 大小),把 kernel 函数指针、参数打包进一个 launch 描述结构体。
  • 驱动层:把 launch 描述提交给 GPU 驱动,驱动生成对应的命令,写入一个 command buffer / push buffer。
  • GPU 侧:GPU 前端(front-end)读取命令流,解析 kernel 启动指令,分配执行资源,然后开始调度。

这条链路里,CPU 侧的驱动调用和参数组装通常耗时 3-8 微秒,GPU 侧的命令解析和启动延迟大约 3-10 微秒。这两块合在一起,就是被大家称为 launch overhead 的东西。注意,这块开销和 kernel 本身执行多快没有关系——哪怕 kernel 只跑 2 微秒,launch 开销也照样存在。

1.2 算一笔账:纯 launch 开销是多少微秒

我在这台测试机上(CPU 是两颗 Xeon 8375C,GPU 是 A100 40G,CUDA 12.2)做了一组很朴素的测量:连续 launch 一个只做简单加法的小 kernel,循环 10000 次,统计单次平均耗时。

实测结果:从 CPU 角度看,单次 launch 大约 6.5 微秒到 8 微秒波动。但如果我用 CUDA event 去测 GPU 侧相邻两个 kernel 的间隔,这个"空心"(gap)大概是 3-5 微秒。也就是说,CPU 侧的 launch 准备和驱动交互明显比 GPU 侧的启动延迟更贵。

而这里有一个经常被忽略的现实:很多高性能场景中 kernel 本身执行时间非常短,比如 5-20 微秒。如果 launch 开销需要 6-8 微秒,那 launch 占整个迭代周期的 30%-60% 都是正常的。这就是为什么"减少 launch 次数"或"降低单次 launch 开销"成为 CUDA 优化的经典话题。

1.3 为什么 CPU 侧的 launch 一直被认为是"大问题"

GPU 是异步执行设备。理论上 CPU 发完 launch 命令后就可以继续干别的,CPU 和 GPU 可以并行。但在真实程序里,CPU 侧往往在 launch 之后还要做其他 launch、数据依赖检查、同步等待,于是 CPU 侧 launch 的开销就变成了流水线的瓶颈。

更深一层的原因是:每次 launch 都是一次 CPU 到 GPU 的"远程过程调用"(RPC),需要经过用户态到内核态的边界、驱动命令队列、GPU 前端解析。这些步骤都是固定成本,kernel 本身再短也摊不掉。当你的程序有大量短 kernel 且串行依赖很强时,CPU 就成了那个"排队发指令的人",GPU 频繁处于等待指令的状态。

所以业界其实早就默认了一个结论:launch 开销是真实存在的瓶颈。但 CUDA Graph 的厉害之处在于,它不只是把 launch 变快了,而是把这一整套"每次都要做"的过程给缓存和重构成了另一条路径。

2. CUDA Graph 真正动了哪根筷子:不止 launch 那点事

2.1 图捕获与图重放:把一次调用链变成一张静态图

CUDA Graph 的核心思想是 graph capture(图捕获)——先把一段 kernel launch / 内存拷贝 / 事件操作的调用序列记录下来,生成一张图(graph),然后用 graph launch 一次性提交整张图。之后每次执行不再走逐条 launch 的路径,而是直接重放这张已经构建好的执行图。

很多人没注意到,图捕获时不仅记录了"kernel 是什么",还记录了大量上下文信息,包括 kernel 的参数快照、launch 配置、依赖关系、流(stream)之间的同步点、事件触发等。最终提交给驱动的是一份完整的、静态的、可验证的执行计划,而不是一堆零散的 API 调用。

这意味着 driver 层面可以提前做很多优化:把命令流整理成更紧凑的格式、消除重复校验、预分配资源。GPU front-end 拿到的是一条高度结构化的整图执行指令。这已经不是简单的"launch 快了 20%"的问题,而是执行模型发生了变化。

2.2 省下的第二块:kernel 启动参数的重复组装与依赖解析

普通 launch 每次都从头做相同的事情:校验 launch 参数、组装参数 buffer、检查指针合法性、解析 stream 依赖。哪怕你用的是同一份参数、同一个 stream,这些步骤一次都不会少。这类"重复劳动"实际上占了 CPU 侧 launch 开销里相当大的一部分。

CUDA Graph 重放时,这些校验和组装已经不需要了。图里的每个节点都保存了完整的 kernel 描述和参数快照,驱动只需要按图的拓扑顺序依次提交节点。于是 CPU 侧从"每次都要做一遍完整流程"变成"按图索引直接提取执行计划"。

这里有一个很关键的心智模型修正:你省下的不只是 launch 的 6 微秒,而是"CPU 重组和执行依赖解析"的整套代价。在小 kernel 多依赖的场景中,这部分省下来的 CPU 时间可能和单纯 launch 时间一样多,甚至更多。

2.3 省下的第三块:CPU-GPU 间同步等待与流间依赖

独立 kernel 之间如果存在数据依赖,普通写法下你可能要手动做 event 同步或 stream wait。而这些同步操作本身就是开销——cudaStreamWaitEvent 同样是一次 CPU 调用,同样要走驱动路径。当依赖链条很长时,这类额外调用的开销会成倍累积。

CUDA Graph 把节点间的依赖显式建成了 DAG 的边。驱动在提交整张图时已经清楚了谁依赖谁、谁可以并行、谁必须排队。GPU 侧可以根据图结构更自由地做调度,甚至可以在硬件队列里提前安排可并行执行的分支。对 CPU 来说,你不再需要逐个加 event、逐个 wait,一次 launch 就能交代完整段计算流。

特别是当你的图里同时有多种类型的节点——kernel、cudaMemcpy、memset——图模式下的端到端延迟会比手动流管理低不少。手动管理时你可能出现"CPU 在等下一条命令"的空档,而图重放会把整条命令流一次性推给 GPU,让 GPU 持续有活干。

3. 我用基准测试验证了一个反直觉结论:launch 只是冰山一角

3.1 测试方法设计:怎么把 launch 时间、Graph 时间分别测出来

为了验证"CUDA Graph 到底省了什么",我在 A100 上做了三组对比实验。每组实验都用同一个计算逻辑跑三份:

  • 普通版本:循环里逐个 launch kernel,用 stream 串行依赖。
  • Graph 版本:先把整个循环体 capture 成一张图,后续每次循环直接 graphLaunch。
  • 混合版本:还是普通 launch,但去掉所有显式 event/stream 同步,只依赖默认流强串行(作为参照)。

测量工具我用的是 CUDA Events 统计 GPU 侧端到端时间,同时用 CPU 端 clock() 统计 CPU 侧的循环耗时。每组都跑 100 次取中位数,避免偶然抖动。

为了区分"launch 开销"和"kernel 执行时间",我设计了一个核心思路:每个测试场景里,把 kernel 本身的执行时间拉长或缩短,观察总时间的变化趋势。如果 Graph 的收益主要是省 launch,那么 kernel 越短,收益比例应该越大;kernel 越长,收益比例应该越小。这本来是个很直白的假设,但结果比我想象的更有意思。

3.2 三组实验结果:小 kernel、中等 kernel、依赖链场景

第一组:单次加法 kernel,每个 kernel 约 3 微秒。循环 1000 次。

普通 launch 版本 CPU 侧平均单次循环耗时约 9.2 微秒,GPU 侧端到端平均约 7.1 微秒(这里 CPU 侧和 GPU 侧不完全一致,因为 CPU 发命令有超前量)。Graph 版本 CPU 侧单次循环约 2.3 微秒,GPU 侧端到端约 3.8 微秒。加速比接近 3-4 倍。

第二组:中等负载 kernel,里面有约 2000 次浮点乘加,单次执行约 25 微秒。同样循环 1000 次。

普通版本 CPU 侧单次约 8.6 微秒,GPU 侧端到端约 28 微秒。Graph 版本 CPU 侧单次约 2.2 微秒,GPU 侧端到端约 26.8 微秒。加速比大约只有 1.05-1.1 倍。

第三组:构造了一条 32 个 kernel 的串行依赖链,每个 kernel 3 微秒。普通版本手动用 cudaStreamWaitEvent 串依赖,Graph 版本用边依赖。

普通版本端到端约 420 微秒,Graph 版本约 190 微秒,加速比达到 2.2 倍。CPU 侧差距更大,普通版本 CPU 侧光处理 event 就花了大量时间,Graph 版本 CPU 侧非常轻松。

三组实验的结果整理如下:

测试场景单 kernel 耗时普通版 CPU 侧单次普通版 GPU 端到端Graph 版 CPU 侧单次Graph 版 GPU 端到端加速比
小 kernel 循环约 3 微秒9.2 微秒7.1 微秒2.3 微秒3.8 微秒约 1.9 倍
中等 kernel 循环约 25 微秒8.6 微秒28 微秒2.2 微秒26.8 微秒约 1.05 倍
32 节点依赖链平均约 3 微秒单节点约 13 微秒420 微秒单节点约 2 微秒190 微秒约 2.2 倍

3.3 结果解读:为什么小 kernel 场景收益最大,大 kernel 场景收益反而小

看到这组数据,你应该能明白标题里的问题了。CUDA Graph 省下的绝对不只是 launch 时间——在小 kernel 场景,GPU 侧端到端时间本身就包含了大量 launch 解析和依赖同步的空当,Graph 不仅把 CPU 侧的 launch 开销砍了,还把 GPU 侧的空转等待也压缩了。加速比接近 2 倍,远不止"省了 5 微秒 launch"能解释。

而中等 kernel 场景里,kernel 执行时间占了绝对大头,launch 开销和依赖管理的时间占比很小,Graph 的收益自然就缩水了。这符合一个朴素规律:优化收益取决于你优化对象占总时间的比例。

依赖链场景是很多人容易漏掉的部分。手动管理 event 依赖时,CPU 要逐个插入 event、逐个等待,GPU 侧也可能因为 event 的粒度而出现调度空档。Graph 把这些依赖边一次性编码进去之后,GPU 前端能够更早地知道下一步该干什么,从而显著缩短了 GPU 侧的空当。

所以我的结论是:把 CUDA Graph 说成"省 launch 时间"是对,但很片面。它真正省的是"CPU 与 GPU 之间由于逐条命令传输、解析、同步所带来的所有空隙"。对短 kernel、长依赖链、CPU 侧吞吐饱和的场景,这个收益非常可观;对长 kernel 的计算密集型场景,收益反而很小。

4. 别把 CUDA Graph 当银弹:图重放的三个隐藏大坑

4.1 参数更新失效:为什么你改了 kernel 参数图还是旧表现

图重放最经典的坑就是参数更新。很多人第一次用 CUDA Graph 时,会按照平时的习惯写循环:每次循环前更新某个 kernel 的入参,然后 graphLaunch 执行。但结果发现参数根本不变——图节点里保存的是 capture 时刻的参数快照,不是引用。

我举个实际例子。在某个推理优化项目里,我用 cudaGraphKernelNodeSetParams 更新 kernel 节点参数,但只更新了部分字段。由于遗漏了一个偏移量字段,图上跑的 kernel 一直用的是旧偏移。调试了很久才发现:图捕获后,节点参数是"拷贝"进去的,不是"指针引用"。你必须在每次重放前显式调用 cudaGraphExecKernelNodeSetParams 重新设置需要变化的参数。

这里有一个常用的实操模式:在 capture 阶段,把每次运行会变的数据放到一个固定的 buffer 里,通过 kernel 参数传入 buffer 指针。这样重放时,指针不变,变的是 buffer 里的内容,就不需要频繁更新图节点参数。这也是官方推荐的做法。如果参数本身就是通过常量内存或grid_constant传入的,图模式下的更新方式也需要单独规划。

4.2 内存与图捕获:为什么临时 buffer 在重放时行为诡异

图捕获阶段,CUDA 运行时会对 API 调用做记录。如果在 capture 过程中分配了临时设备内存,比如调用 cudaMalloc,CUDA 会为这此次分配建立特殊的管理机制。但重放时,图只记录了"这里有分配"和"这里有释放"的操作,并不会真正每次去执行 cudaMalloc——分配动作大多在 graph instantiate 时已经完成了。

结果就是:如果某个节点的输出指针指向的是 capture 时分配的 buffer,而你重放前手动把这个 buffer 释放掉再重新分配,图仍然会往旧地址写数据。轻则数据错乱,重则直接访问非法地址。

我建议的规范做法是:所有图内节点的输入输出内存,全部在 capture 外部预先分配好并保证生命周期贯穿整个 graph 使用周期。不要依赖图内 malloc/free,不要频繁 cudaFree 再 cudaMalloc 换 buffer。如果一个节点依赖另一个节点的临时输出,那就把这块 buffer 在 capture 前就分配好,节点只是互相传递指针。

4.3 动态 shape / 分支场景:图为什么"僵住"

图的另一大限制是"静态性"。如果你的算法里有动态分支——比如根据输入 batch size 决定要不要做某一步、某个 kernel 的 grid 尺寸取决于运行时数据——普通 CUDA 代码可以很灵活地处理,但图一旦 instantiate 后,节点的 launch 配置就固定了。你当然可以调用 cudaGraphExecKernelNodeSetParams 去改 grid 尺寸,但这就回到了"每次重放前修改"的思路,而修改节点参数本身也是有开销的。

更麻烦的是分支本身。如果图里有可选执行的子图,你可能需要为每种分支单独 capture 一张图,然后在运行时选择 launch 哪一张。这在逻辑上说得通,但带来的代码复杂度和显存开销会明显上升。

我踩过的一个坑是:把动态 shape 逻辑硬塞进一张图里,用 memset 或 dummy kernel 去"跳过"不必要的分支。结果图本身倒是能跑,但 dummy kernel 的执行开销把 Graph 收益吃掉了一大半,不如老老实实分支。

5. 我现在的选型判断标准:什么场景该用 CUDA Graph

5.1 三个适用信号:短 kernel、依赖链长、CPU 侧饱和

经过这些测试和调优,我总结出了三个相对可靠的"该用 Graph"的信号,遇到这些情况可以直接考虑图模式。

第一个信号:项目里有大量短 kernel,单个执行时间小于 10 微秒。这类 kernel 的执行时间不够摊薄 launch 和调度开销,Graph 的收益最明显。

第二个信号:计算流程里有长依赖链,比如多个 kernel 串行、每个都要等前一个结果。手动用 event 管理同步会让 CPU 侧不堪重负,Graph 能把同步逻辑内聚为边,减少 CPU 侧干预。

第三个信号:profiling 发现 CPU 侧已经成为了吞吐瓶颈——你看到一个循环里 CPU 的 launch 耗时明显超过了 GPU 执行耗时,或者 GPU 的利用率存在周期性空档。这时候 Graph 往往能带来成倍的提升。

5.2 不适用信号:长 kernel、动态图和复杂 host 逻辑

反过来,我也建议别在以下场景轻易上 CUDA Graph。

  • 计算密集型长 kernel(比如单 kernel 超过 100 微秒):收益很有限,图本身的构建和实例化开销反而像是买了一颗用不上的子弹。
  • 每次运行参数、shape、依赖关系都剧烈变化的场景:你可能要为每种情况单独建图或频繁改节点参数,复杂度直线上升。
  • host 侧有复杂控制流、循环内需要按数据结果决定下一步的场景:图的静态执行模型会让你非常难受,不如图里套一个同步点、回到普通 launch。

另外要注意,Graph 的构建成本不低。capture 本身需要时间,instantiate 会根据图做真正的优化和内存重排,第一次构建甚至可能花几十毫秒。如果程序总共只跑几毫秒,图构建的开销可能比什么都大。不过 CUDA 提供了 cuGraphInstantiate 的多种 flag(比如 CUDA_GRAPH_INSTANTIATE_FLAG_AUTO_FREE_ON_LAUNCH 这类),可以根据使用频率决定是否缓存 exec graph。

5.3 实操建议:从 profiler 开始的一步步取舍

最后给一套我可以直接照做的操作路径。

第一步,打开 Nsight Systems 或 Nsight Compute,先看你的 kernel 执行时间分布和 CPU/GPU 时间线是否有大量空隙。如果发现 GPU 经常"饿着",launch 间隙明显,再继续考虑 Graph。

第二步,先不用改整个程序。挑一段循环里最频繁、依赖最清晰的计算链,把它 capture 成一张小图,对比跑一遍,用数据确认收益方向是否正确。这一步成本很低,能快速验证你的场景是否匹配。

第三步,确认收益后,再逐步扩大图范围。同时注意把动态变化的参数收敛到固定 buffer 中,让图重放尽量免于节点更新。这样既保证正确性,又最大程度保留 Graph 的性能优势。

如果测试后发现收益不明显,切回普通 launch 并不可耻。CUDA Graph 是工具,不是 KPI。关键是用 profiling 数据做判断,而不是盲目追新。


说回开头那个问题。我自己现在的心智模型已经改了:CUDA Graph 不是"省 launch 时间",而是"省整个 CPU 调度 GPU 过程中的一切冗余开销"——包括 launch、参数重组、同步等待、依赖解析和 GPU 前端的解析空当。只有建立了这个模型,你才会在排查性能时知道该去看 CPU 侧的时间线,而不是死死盯着 kernel 执行时间。这也是我实际优化几个项目之后,觉得最值得分享的一点。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进