想象你是一位交响乐指挥家,面对的是一支技艺精湛的乐团(GPU)。传统的工作方式是:每演奏一个小节,你都要放下指挥棒、转过身、对着某位乐手喊出一个指令——“长笛,进!”“小提琴,强!”,等这位乐手回应后,你再转向下一位。一首曲子有上万个音符,你就得上万次地转身喊话,嗓子喊哑了,乐手也被反复打断,整首曲子的节奏支离破碎。 CUDA Graph 改变了这个荒唐的场面。它让你先在排练厅把整首曲子完完整整地走一遍、录成一张“总谱”(capture),然后正式演出时,你只需挥动一次指挥棒,所有乐手就照着总谱自己配合着把整首曲子一气呵成奏完——你不再需要逐小节指挥。这张“总谱”就是计算图(Graph),它把成千上万个 GPU 内核(kernel)的启动指令预先编排成一张依赖关系图,之后每次只需一次“开始”信号就能整体重放。这就是 CUDA Graph 的核心思想:把逐条发号的调度过程,变成一张可以反复使用的乐谱,从而消除 CPU 与 GPU 之间频繁通信带来的巨大开销。

岗位关联

职业价值:从事 CUDA 开发或推理引擎优化的工程师,掌握 CUDA Graph 能把 GPU kernel 调度开销降低一个数量级,是 LLM 推理、强化学习等高频小 kernel 场景必备的性能优化技能。

目录
  1. 可视化演示
  2. 启动开销:指挥家喊话的代价
  3. 捕获机制:把排练录成总谱
  4. 图优化器:编曲家的神来之笔
  5. 条件执行:乐谱里的跳跃记号
  6. 流对比分析:总谱与即兴演奏的取舍
  7. 实践要点:舞台监督的演出手册
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

通过捕获重放机制(多kernel录制为图→单次重放)、启动开销对比(传统逐个启动vs图启动)与图结构优化(节点依赖+条件分支+实例化缓存)三个模式,展示CUDA Graph如何消除kernel调度开销提升推理性能。

本节配有交互动效(进入视口后自动加载,约 3KB)。

CUDA Graph捕获与重放 录制阶段:逐个kernel启动被捕获为图节点构建完整计算图 重放阶段:整图作为单次提交执行消除逐kernel CPU开销 capture → graph → replay (1 launch) 一次录制多次重放是核心机制

启动开销:指挥家喊话的代价

想象指挥家每指挥一个音符都要转身向乐手喊话——即使乐手们动作已经很快,这“转身—喊话—等待回应”的过程依然需要时间。在 GPU 编程里,CPU 调用一次 cudaLaunchKernel 就好比喊一次话:CPU 要把 kernel 的参数打包、写入命令缓冲区、通知 GPU 驱动,这被称为启动开销(launch overhead)。一个典型 kernel 实际在 GPU 上执行只需要几微秒,而 CPU 端的启动开销却可能高达 5~10 微秒——相当于喊话时间比演奏时间还长。

传统编程用 流(Stream) 做异步调度,把多个 kernel 像流水线一样排在同一股“河道”里。流式异步让 CPU 不必等 GPU 执行完再发下一个命令,但每次下发仍然要单独走一遍驱动协议,就像指挥家虽然不用等乐手演完,但每喊一句话都得备一次嗓子。当你的程序里有上百个短小 kernel 时,这种“逐句发令”的累计成本就非常可观。

批量提交的思路由此而生:能不能把一百句话合并成一次喊话?这正是 CUDA Graph 的出发点。就像把乐手各自的碎片化音符先排练成一张总谱,而不是演出时逐个提醒。传统方式的总耗时可以写成一个简单的模型:

回到指挥家的比喻:每次喊话都是一次 T_launch,乐手演奏是 T_kernel。喊一百次话,嗓子费的力气不比演奏少。CUDA Graph 的使命,就是把这个“喊话次数”从 N 次压缩到接近 1 次,让指挥家只起手一次。

Tstream=N×(Tlaunch+Tkernel)T_{\text{stream}} = N \times (T_{\text{launch}} + T_{\text{kernel}})
总耗时 = 内核个数 ×(每次启动开销 + 内核执行时间)。当 T_launch 与 T_kernel 同量级时,启动开销几乎让 GPU 空转了一半时间,这正是优化空间所在。
  1. 内核个数N 代表程序中 kernel 的调用次数,比如一个神经网络推理循环里几十个算子。
    NN
  2. 单次启动开销T_{\text{launch}} 是 CPU 提交一次 kernel 所需的时间,包括参数解析、命令入队、驱动通信。
    TlaunchT_{\text{launch}}
  3. 内核执行时间T_{\text{kernel}} 是 kernel 在 GPU 上真正计算所需的时间。
    TkernelT_{\text{kernel}}
  4. 累加求和\times N 表示总共 N 次调用,每次都重复付出启动开销。
    ×N\times N

捕获机制:把排练录成总谱

CUDA Graph 最神奇的地方在于,它不需要你手动画一张图,而是通过流捕获(Stream Capture) 自动“录”出来。你可以把它想象成录音棚:指挥家指挥乐团完整地排练一遍,录音师把整个过程录下来。途中每一位乐手在何时开始、何时结束、与别人如何衔接,全都被记录在案。CUDA 的 cudaStreamBeginCapture 和 cudaStreamEndCapture 就是这两个“录制开始/结束”的按钮。

录制过程中,GPU 会为每一次 kernel 调用记录一个节点(node),包括 kernel 的参数、所在流、依赖的前后事件(event)。如果 kernel A 的结果被 kernel B 读取,B 的节点就会自动挂到 A 节点的后面,形成依赖边(dependency edge)。这就像录音师在乐谱上标注:“第二小节的定音鼓必须等第一小节的圆号吹完才能进入”——依赖关系是自动推导出来的,不需要你手动连线。

当录制完成,调用 cudaGraphInstantiate 时,CUDA 会把这个图结构“雕琢”成一个可执行的 executable graph,类似录音师把原始录音混音成正式母带。这张母带就是 cudaGraphExec_t,之后每次要执行整批 kernel,只需调用 cudaGraphLaunch 这一个 API,GPU 就会照着依赖边自动调度成千上万个节点。整体的时间模型变成了:

所以,指挥家的策略变成了:演出前花一次时间排练录谱(capture + instantiate 的成本),正式演出时只需一个起手动作,整首曲子自动演奏。只要这张谱子会被反复使用——比如推理服务每次请求都走同一批算子——前期的排练成本就能被摊薄到忽略不计。

Tgraph=Tcapture+Tinstantiate+N×(Tlaunchgraph+Tkernel)T_{\text{graph}} = T_{\text{capture}} + T_{\text{instantiate}} + N \times (T^{\text{graph}}_{\text{launch}} + T_{\text{kernel}})
图模式多了构建成本(录制+混音),但换来了每次启动开销 T_launch^graph 的大幅降低——因为 CPU 不再逐条解析参数,GPU 驱动可以直接按图索骥。
  1. 录制成本T_{\text{capture}} 是流捕获阶段的时间,相当于乐队完整排练一遍的投入。
    TcaptureT_{\text{capture}}
  2. 实例化成本T_{\text{instantiate}} 是把捕获的图“编译”成可执行图的时间,类似录音的后期混音。
    TinstantiateT_{\text{instantiate}}
  3. 单次图启动开销T^{\text{graph}}_{\text{launch}} 是 cudaGraphLaunch 一次的开销,比传统启动小一个数量级。
    TlaunchgraphT^{\text{graph}}_{\text{launch}}
  4. 重放执行N \times (...) 表示每次重放时 N 个 kernel 按图依次执行,但 CPU 只需付一次启动。
    N×(⋯ )N \times (\cdots)

图优化器:编曲家的神来之笔

总谱录好之后,CUDA 并不满足于“原样重放”。它内置了一个图优化器(Graph Optimizer),相当于一位编曲家,拿到原始总谱后开始大刀阔斧地修改。第一招是节点融合(fusion):如果相邻两个 kernel 之间没有其他依赖,且数据只在中间暂存,优化器会把它们合成一个 kernel,省掉一次中间数据的写入和读取。这就像编曲家把两个紧凑的乐句合并成一段连续演奏,中间的停顿直接剪掉。

第二招是依赖剪枝(dependency pruning)。原谱是录音师自动标注依赖的,难免有“过度谨慎”的边——比如两个 kernel 其实互不影响,却被标成了先后关系。编曲家仔细比对后发现它们其实可以同时演奏,于是删掉这条多余的依赖边。GPU 的调度器因此获得更大的并行空间,多个互不依赖的 kernel 可以真正并发执行。

第三招是内存复用(memory reuse)。传统流模式下,每个 kernel 都想自己独占一块中间缓冲区,内存需求像乐章里每个乐手都要求单独一间休息室。编曲家发现:第一乐章的乐手和第二乐章的乐手根本不会同时在场,于是让它们共用同一间休息室。图优化器会分析每个节点的生命周期,把不在交集内的中间缓冲重叠到同一块显存地址,显存峰值占用显著下降。这三招叠加之后,实际加速往往比单纯省掉启动开销还要可观:

编曲家的工作台就是 GPU 上的硬件调度器,它拿到优化后的图谱,能同时看到整首曲子的全局,而不是像传统方式那样“只见一个音符”。这正是图模式的核心优势:CPU 看到的是整张谱,GPU 调度器才能提前布局、穿插执行。

S=TlaunchstreamTlaunchgraphS = \frac{T^{\text{stream}}_{\text{launch}}}{T^{\text{graph}}_{\text{launch}}}
加速比定义为传统单次启动开销除以图模式单次启动开销。实际使用中 S 通常能达到 5~20 倍,因为图启动省掉了参数解析、事件等待、驱动协议栈等多层开销。
  1. 传统启动开销T^{\text{stream}}_{\text{launch}} 是普通 cudaLaunchKernel 的 CPU 端耗时。
    TlaunchstreamT^{\text{stream}}_{\text{launch}}
  2. 图启动开销T^{\text{graph}}_{\text{launch}} 是 cudaGraphLaunch 的 CPU 端耗时。
    TlaunchgraphT^{\text{graph}}_{\text{launch}}
  3. 求比值/ 表示两项相除,得到一个无量纲倍数,反映调度效率的提升幅度。
    前项后项\frac{\text{前项}}{\text{后项}}

条件执行:乐谱里的跳跃记号

传统乐谱总是一路演奏到底,但许多真实场景需要动态分支:比如推理时如果某个 batch 很短,可以跳过部分冗余计算。在传统流模式下,每次分支都需要 CPU 参与判断后再决定启动哪个 kernel——CPU 和 GPU 之间又多了一次往返。CUDA Graph 从 CUDA 11.0 开始支持条件节点(Conditional Node),相当于乐谱里的跳跃记号"DA CAPO AL FINE":乐手读到记号时自己判断是否跳回开头,不需要指挥家临时喊话。

cudaGraphConditionalHandle 可以让图的某个区域变成“可选段落”。当你启动这个图时,GPU 端会传入一个条件值——它可能来自前一个 kernel 的计算结果——条件节点自动选择执行 A 分支还是 B 分支。更妙的是循环结构也能入图:一个包含条件节点的子图可以构成循环体,让 GPU 在图上自动迭代,CPU 全程不必介入。这正是动态控制流的全部含义:让“决策”也从 CPU 下沉到 GPU,连 if/while 都变成音符的一部分。

用期望成本来理解条件执行的好处:假设分支 A 以概率 p 被选中,分支 B 以概率 1-p 被选中,传统方式每次都要 CPU 判断(一次启动开销),而图模式在 GPU 内部完成判断:

乐手们最头疼的就是指挥家反复打断:“注意!这里跳过去!”动态控制流入图之后,分段记号直接印在总谱上,所有跳转都是 GPU 对自己说“该跳了”,整个乐团的演奏行云流水,CPU 彻底解放出来去处理别的任务。

E[T]=p×TpathA+(1−p)×TpathB+TlaunchgraphE[T] = p \times T_{\text{pathA}} + (1-p) \times T_{\text{pathB}} + T^{\text{graph}}_{\text{launch}}
期望时间 = 各分支执行时间的加权平均 + 一次图启动。省掉的是“CPU 每轮判断 + 再启动”的反复开销,相当于乐手自己看着记号跳转,指挥家只起手一次。
  1. 分支概率p 表示运行时会走路径 A 的统计概率,来自真实数据分布。
    pp
  2. 路径 A 耗时T_{\text{pathA}} 是条件为真时子图执行的 GPU 耗时。
    TpathAT_{\text{pathA}}
  3. 路径 B 耗时T_{\text{pathB}} 是条件为假时另一条子图的耗时。
    TpathBT_{\text{pathB}}
  4. 加权期望p \times A + (1-p) \times B 是标准的期望值计算,反映多次运行的平均成本。
    p×TpathA+(1−p)×TpathBp \times T_{\text{pathA}} + (1-p) \times T_{\text{pathB}}

流对比分析:总谱与即兴演奏的取舍

既然 CUDA Graph 这么好,是不是所有场景都该弃用传统 Stream?答案藏在“排练成本”里。传统流异步(traditional Stream asynchrony) 本质上是“即兴演奏”的指挥方式,CPU 下发指令后不等结果立即返回,利用异步性让 CPU 和 GPU 并行工作。它的优点是灵活:每次调用都是独立的,参数随时可以改,启动零前置成本。缺点也明显:每个 kernel 启动都要走一遍完整驱动协议。

CUDA Graph 的优势在于“一次捕获、反复重放”,特别适合 kernel 序列固定且反复执行的场景,比如神经网络的推理阶段——每个请求都走完全相同的算子序列,只是数据不同。但如果你只跑一次少量 kernel,捕获和实例化的成本反而会盖过收益,就像花一整天排练一首只在婚礼上演奏一次的曲子,不划算。适用场景的判断标准是调用次数:图模式额外付出的构建成本,需要被足够多次的重放摊薄。

性能对比可以形式化为一个收益条件。令图构建总成本为 T_build,那么使用图模式的前提是节省的启动开销总和要大于构建成本:

回到总谱的比喻:录音棚不是随便进的,进门就要花排练费。但如果你的乐队每天要演同一首曲子演一百遍,这首排练费早就值回票价。传统 Stream 是即兴演奏,Graph 是录音重放——选哪种,先问自己同一首曲子要演多少遍。

Δ=N×(Tlaunchstream−Tlaunchgraph)−(Tcapture+Tinstantiate)>0\Delta = N \times (T^{\text{stream}}_{\text{launch}} - T^{\text{graph}}_{\text{launch}}) - (T_{\text{capture}} + T_{\text{instantiate}}) > 0
净收益 = 节省的启动开销 - 构建成本。当 Δ 大于 0 时用图模式才划算,N 越大、kernel 越短小,收益越明显。
  1. 单次节省T^{\text{stream}}_{\text{launch}} - T^{\text{graph}}_{\text{launch}} 是每个 kernel 从传统切换为图模式后省下的 CPU 时间。
    Tlaunchstream−TlaunchgraphT^{\text{stream}}_{\text{launch}} - T^{\text{graph}}_{\text{launch}}
  2. 总节省N \times (...) 乘以调用次数 N,得到整个程序节省的总时间。
    N×(⋯ )N \times (\cdots)
  3. 构建成本T_{\text{capture}} + T_{\text{instantiate}} 是录制排练和混音母带的投入。
    Tcapture+TinstantiateT_{\text{capture}} + T_{\text{instantiate}}
  4. 正收益条件\Delta > 0 表示节省大于投入,采用图模式才算明智的投资。
    Δ>0\Delta > 0

实践要点:舞台监督的演出手册

真正的演出团队不会只有一张谱子。一个 GPU 程序可能同时服务多个业务路径,就像乐团会准备多首曲目——推理引擎往往要维护多个图(multi-graph),按需切换。CUDA 支持多个 cudaGraphExec_t 实例并存,你可以为不同输入形状分别捕获一张图,然后根据运行时请求选择合适的图来重放。要注意的是,图切换本身也有开销,合理的做法是给高频路径长期驻留图,低频路径才临时构建。

内存规划是实践中最容易翻车的环节。图捕获时会把 kernel 用到的指针地址“固化”进图里,如果后续数据换了显存地址,重放就会崩溃或出错。解决办法是预先规划好输入输出缓冲的生命周期,用固定的内存池(比如 CUDA 的虚拟内存管理)保证每次重放时地址稳定。这就像总谱上写死了“第三圆号手用 2 号休息室”,演出前必须确认这位乐手真的在 2 号休息室。

Nsight Systems / Compute 是舞台监督的回放录像,用来验证优化效果。你可以在捕获图之前和重放图之后各打一个时间戳,对比两者在 Nsight 时间线上的调度间隙。如果发现 GPU 利用率仍然有空洞,说明图内仍有依赖可以剪枝,或者 kernel 本身太短小不值得入图。优秀的实践流程是:先用 Nsight 测量传统模式的启动开销占比 → 决定是否值得图化 → 捕获 → 实例化 → 重放 → 再用 Nsight 验证收益。多图的平均开销可以视为各图构建与重放成本之和:

舞台监督的智慧在于:排练花掉的每一分钟,都要从正式演出的顺畅里赚回来。CUDA Graph 的工程实践本质就是用一次有计划的“排练”,换取无数次高效率的“演出”——前提是你把总谱(内存)、曲目(多图)、回放记录(Nsight)都打理得井井有条。

Tmulti=∑i=1M(Tbuild(i)+Ri×Tlaunch(i))T_{\text{multi}} = \sum_{i=1}^{M} \left( T_{\text{build}}^{(i)} + R_i \times T_{\text{launch}}^{(i)} \right)
多图总时间 = 每张图的构建成本 + 该图被重放的次数 × 单次启动开销。工程上就是尽量让高频图占主体,减少低频图的构建浪费。
  1. 图的数量M 表示同时维护的 CUDA Graph 实例个数。
    MM
  2. 单图构建T_{\text{build}}^{(i)} 是第 i 张图的捕获与实例化总成本。
    Tbuild(i)T_{\text{build}}^{(i)}
  3. 重放次数R_i 表示第 i 张图在程序生命周期内被启动的次数。
    Ri×Tlaunch(i)R_i \times T_{\text{launch}}^{(i)}
  4. 求和汇总\sum 把 M 张图的成本加总,用于估算整体开销预算。
    ∑i=1M\sum_{i=1}^{M}

AI 应用场景

大模型推理引擎加速

阶段:推理部署与性能优化

解决的问题:LLM 推理的 prefill 阶段包含数十个不同形状的 GEMM 算子,传统方式逐个启动,CPU 调度开销在 batch 较小时占到端到端延迟的 30% 以上。CUDA Graph 将这些算子捕获成一张图,每次生成 token 只需一次图启动,推理吞吐显著提升。

具体案例:vLLM、TensorRT-LLM 等主流推理框架都内置了 CUDA Graph 支持。当一个请求到达时,框架先按当前 batch 形状捕获一张图,后续迭代直接重放同一张图,把 prefill 阶段耗时降低 40%~60%,token 生成过程获得稳定的低延迟。

强化学习批量 Rollout 并行推理

阶段:训练数据采集与仿真

解决的问题:强化学习训练中,agent 需要与环境交互采样,每一步都要跑同一个策略网络的前向推理。大量并行环境会启动成百上千个小 kernel,逐条调度的开销迅速成为瓶颈。CUDA Graph 对固定结构的策略网络进行图化,配合多图轮换支持不同 batch 大小,让采样吞吐翻倍。

具体案例:某机器人仿真平台在 PPO 训练中,使用 CUDA Graph 重放策略网络的多层 MLP 算子,将每轮 rollout 的采集时间从 15 ms 压缩到 6 ms,训练迭代速度提升 2.3 倍,且 CPU 占用率显著下降。

Python 演示

python34 行
# -*- coding: utf-8 -*-"""模拟传统 Stream 调度与 CUDA Graph 调度的性能差异通过一个简化的时间模型,对比逐次启动与图重放的累计开销。"""  class GpuScheduler:    def __init__(self, launch_us=10.0, kernel_us=100.0):        self.launch_us = launch_us        self.kernel_us = kernel_us        self.graph_build_us = 15.0        self.graph_launch_us = launch_us * 0.1     def stream_total(self, kernels, rounds):        """传统流式:每轮每个 kernel 都付一次完整启动开销"""        return rounds * kernels * (self.launch_us + self.kernel_us)     def graph_total(self, kernels, rounds):        """CUDA Graph:只构建一次,之后每轮重放付一次图启动"""        build_cost = self.graph_build_us + self.graph_launch_us        replay_cost = rounds * (self.graph_launch_us + kernels * self.kernel_us)        return build_cost + replay_cost     def report(self, kernels=20, rounds=100):        stream_time = self.stream_total(kernels, rounds)        graph_time = self.graph_total(kernels, rounds)        print(f"kernel 数: {kernels}, 重放轮数: {rounds}")        print(f"传统流式总耗时: {stream_time:.1f} us")        print(f"CUDA Graph 总耗时: {graph_time:.1f} us")        print(f"加速比: {stream_time / graph_time:.2f}x")  if __name__ == "__main__":    GpuScheduler().report()
目的:用时间模型量化展示 CUDA Graph 在“多 kernel、多轮重放”场景下的性能优势,帮助理解图构建成本与重放收益的关系。
  1. class GpuScheduler:定义一个模拟 GPU 调度器的类,封装两种调度方式的时间模型。
  2. self.graph_launch_us = launch_us * 0.1图调度的单次启动开销仅为传统方式的 10%,这是 CUDA Graph 收益的核心来源。
  3. def stream_total(...):传统模式下每轮每个 kernel 都独立计算 launch + kernel 耗时。
  4. build_cost = self.graph_build_us + self.graph_launch_us图模式额外付出一次构建成本(捕获+实例化+首次启动)。
  5. replay_cost = rounds * (self.graph_launch_us + kernels * self.kernel_us)之后每轮重放只需一次图启动,kernel 耗时保持不变。
  6. if __name__ == "__main__":保证模块被导入时不会自动执行,只有直接运行时才输出报告。
  7. GpuScheduler().report()用默认参数跑一次对比,展示加速比结果。

常见误区

误区“我明白了,CUDA Graph 就是把十个 kernel 打包成一个,少调用几次 `cudaLaunchKernel`,性能提升主要来自省掉那几次函数调用的时间。”

正解打包只是表象。真正的收益来自三层:一是 CPU 启动路径大幅缩短(驱动层免去了逐参数解析和命令入队);二是图优化器做节点融合和依赖剪枝,减少了 GPU 上的冗余执行和等待;三是调度器能看到全局依赖,让互不依赖的 kernel 真正并发执行。省掉的远不止“几次函数调用”,而是整个 CPU-GPU 同步协议的开销。就像总谱的价值不只是“不用逐句喊话”,更在于让整个乐团看到全局而自动协奏。

为何常见:因为初学者从 API 层面观察,只看到了“一次启动代替多次启动”的表象,看不到驱动内部协议和调度器全局视野的深层优化。

误区“Graph 捕获一次后就是一个万能模板,以后不管数据是什么、显存地址怎么变,直接 `cudaGraphLaunch` 就能跑,永远正确。”

正解图在捕获时会固化 kernel 参数中的指针值。如果下一次重放时输入张量的显存地址变了,图仍然会访问旧地址,轻则读脏数据,重则 segfault。安全做法是预先分配固定地址的内存池,或者每次重放前调用 cudaGraphExecKernelNodeSetParams 更新相关节点的参数。好比总谱上写死了“演奏者在 3 号站位”,换了站位必须先在总谱上改过来才能演出。

为何常见:因为官方文档强调“replay“(重放),字面意思容易让人误解为”一劳永逸”,忽略了图是参数化模板而非全自动适配器。

误区“CUDA Graph 是一种需要手动画的图结构,Stream Capture 是另一个选项,两者选一就行。”

正解Stream Capture 正是创建 CUDA Graph 的主要 API 途径——cudaStreamBeginCapture 开始录制流里的 kernel 调用,结束时得到一个 cudaGraph_t,这就是图本身。它们是“录制手段”与“产物”的关系,而非二选一的并列技术。当然你也可以用 cudaGraphAddKernelNode 手动建图,但 Stream Capture 是最主流、最不容易出错的构建方式。这就像“排练录音”和“总谱”的关系:录音是生成总谱的方式,而不是独立于总谱的另一件事。

为何常见:因为两者出现在不同 API 章节里,初学者把它们各自当成独立功能,没有意识到调用链上的包含关系。

误区“CUDA Graph 是官方优化技术,用了肯定快,我把所有 kernel 都塞进图里就对了。”

正解图模式有构建成本(capture + instantiate)。如果你的程序只跑一次少量 kernel,或者传统流调度本来就已经很高效(比如用 CUDA Graph 之前已经做了 kernel 融合),构建成本会超过节省的启动开销,表现为负优化。另外,图里的 kernel 地址被固化,若内存地址频繁变化需要反复更新参数,也会侵蚀收益。判断标准回到收益公式:只有当“减少的启动开销 × 重放次数”大于“构建成本”时才划算。就像录制一张专辑,如果只有一个人买,唱片公司肯定亏本。

为何常见:因为教程和案例都聚焦于收益惊人的场景,很少展示负优化的边界条件,让人产生了“银弹”错觉。

面试题

Q1. CUDA Graph 的核心思想是什么?

单选题 · easy

  • A.把多个 kernel 线程合并成一个线程
  • B.将 kernel 调度流程编译成图并整体重放← 正确答案
  • C.用 CPU 模拟 GPU 的计算过程
  • D.将显存中的数据压缩后传输

答案:B。CUDA Graph 把多次 kernel 启动捕获成一张依赖图,之后整体重放,从而消除逐次启动的 CPU 开销。A 是把线程合并,不是图思想;C、D 与 CUDA Graph 无关。就像用总谱代替逐句指挥,而不是改变乐手本身。

Q2. 使用哪个 API 可以启动一个已实例化的 CUDA Graph?

单选题 · easy

  • A.cudaLaunchKernel
  • B.cudaStreamSynchronize
  • C.cudaGraphLaunch← 正确答案
  • D.cudaMemcpyAsync

答案:C。cudaGraphLaunch 是图模式专用的启动入口,接收 cudaGraphExec_t 参数。cudaLaunchKernel 是传统逐 kernel 启动,cudaStreamSynchronize 是同步等待,cudaMemcpyAsync 是异步拷贝,都与图启动无关。

Q3. 关于 Stream Capture 与 CUDA Graph 的关系,正确的是?

单选题 · mid

  • A.两者是并列的两种独立优化技术
  • B.Stream Capture 是 CUDA Graph 的调试工具
  • C.Stream Capture 是构建 CUDA Graph 的录制手段← 正确答案
  • D.CUDA Graph 是 Stream Capture 的硬件加速器

答案:C。cudaStreamBeginCapture 录制流中的 kernel 调用,结束时返回一个 cudaGraph_t,所以 Stream Capture 是构建图的主要途径,是“手段”与“产物”的关系,而非并列技术。

Q4. 为什么短小的 kernel 序列不适合用 CUDA Graph?

单选题 · mid

  • A.短小 kernel 的启动开销反而更小
  • B.捕获和实例化的构建成本会超过节省的开销← 正确答案
  • C.短小 kernel 无法被捕获进图
  • D.图模式只支持单个 kernel 执行

答案:B。图模式需要先付 capture + instantiate 的成本,如果 kernel 序列短且只执行一次,构建成本会大于节省的启动开销,出现负收益。短小 kernel 同样可入图,C、D 是错误说法。这与“排练成本大于演出收益”的道理一致。

Q5. 图条件节点(Conditional Node)的主要作用是什么?

单选题 · mid

  • A.在内核中执行 if-else 分支计算
  • B.让图内控制流决策在 GPU 端自动完成← 正确答案
  • C.控制 GPU 的冷却风扇转速
  • D.在 CPU 和 GPU 间同步共享内存

答案:B。条件节点将分支/循环决策下沉到 GPU 执行,重放图时 GPU 根据条件自动选择路径,CPU 无需中途介入。A 是普通 kernel 内的分支,与图级控制流不同;C、D 完全不相关。

Q6. 关于图优化器的“内存复用”,下列说法正确的是?

单选题 · hard

  • A.用更小的数据类型替换 kernel 参数← 正确答案
  • B.将互不重叠生命周期的中间缓冲映射到同一块显存
  • C.把显存数据统一搬到 CPU 内存以减少显存占用
  • D.在每个 kernel 之间自动插入内存拷贝指令
  • E.390
  • F.440
  • G.450
  • H.500

答案:A。净收益 = 传统流总开销 - (图构建成本 + 图启动总开销) = 500 - (60 + 50) = 390 微秒。计算时注意 60 和 50 都要算进图模式的成本,很多同学只减了构建成本导致误选 B。

Q7. 哪些场景适合使用 CUDA Graph?(多选)

多选题 · easy

  • A.推理服务反复执行固定算子序列← 正确答案
  • B.强化学习批量环境策略网络推理← 正确答案
  • C.只执行一次的少量 kernel 启动
  • D.训练循环中每步重放同一组算子← 正确答案

答案:ABD。A、B、D 都是“同一批 kernel 被反复重放”的场景,构建成本能被摊薄。C 是一次性场景,构建成本盖过收益,不适合。判断标准就是看“同一首曲子要演多少遍”。

Q8. 图优化器可能对图执行哪些优化操作?(多选)

多选题 · mid

  • A.将相邻无依赖的 kernel 节点合并← 正确答案
  • B.删除多余的依赖边← 正确答案
  • C.把条件节点改成无条件节点
  • D.复用生命周期不重叠的中间缓冲← 正确答案

答案:ABD。图优化器做节点融合、依赖剪枝、内存复用三类典型优化。C 会破坏语义,优化器不可能删除用户显式声明的条件分支。

Q9. 关于 CUDA Graph 的内存地址固化问题,正确的处理方式有?(多选)

多选题 · mid

  • A.每次重放前更新节点参数← 正确答案
  • B.使用固定地址的内存池← 正确答案
  • C.捕获一次后永远不修改指针
  • D.每次重放前重新捕获整个图

答案:AB。图捕获会固化指针地址,若数据地址变化,需要用 cudaGraphExecKernelNodeSetParams 更新节点参数(A),或预先规划固定地址池(B)。C 不现实,数据总会变;D 若多次重建图则失去重放收益。就像总谱写好站位后,换人必须改谱或约定固定站位。