CUDA编程实战09:CUDA Graph 实战------降低重复 Kernel 与流水线提交开销
适合读者:已经写过 Kernel、知道 Stream 与异步复制基本用法,想解决"小任务很多、每轮工作相似、CPU 提交可能拖后腿"问题的 CUDA 学习者。
本篇目标:用三个可独立运行的程序建立 CUDA Graph 的完整心智模型:先识别提交开销,再用 Stream Capture 将固定工作流录制为 Graph,最后用显式 API 更新不变拓扑中的 Kernel 参数。
配套源码 :
cuda-notes/blogs/code/09/,包含graph_benchmark.cu、graph_pipeline_capture.cu与graph_update.cu。预计实践时间:阅读约 70 分钟;动手运行与调整实验参数约 60~90 分钟。
系列定位:这是 CUDA 编程实战第 09 篇。前文已经讲过 Kernel、内存、Stream、Event 与流水线;本篇把这些基础组织成一条可重复提交、可度量、可演进的 GPU 工作流。

阅读提醒:不需要图论基础,也不必先掌握复杂性能分析工具。只要能够编译普通 CUDA 程序,并了解 Kernel、Stream 和异步复制,就可以跟着三个示例完成从"看懂"到"验证"的第一次 Graph 实践。
本文要解决的不是"怎样记住几个 Graph API",而是一个更实际的问题:当程序反复提交一串相同或相似的 GPU 操作时,CPU 为什么可能成为发令枪旁边的瓶颈?CUDA Graph 能减少什么、不能减少什么?怎样用可验证的实验判断它是否适合自己的业务?如果输入地址、Kernel 参数或流水线结构会变化,又应该怎样设计?
写在前面:Graph 不是新算法,而是把稳定工作流交给 GPU
很多人第一次听到 CUDA Graph,会自然联想到"图论""复杂调度"或"又一套难记的 API"。但在工程现场,它通常从一个很朴素的困扰开始:业务每次处理的数据不同,提交给 GPU 的工作步骤却几乎相同;Kernel 已经很短,GPU 也没有明显变慢,CPU 却反复准备参数、调用 Runtime、向队列塞入命令。
这篇文章不要求你背诵接口。你只需要带着一个问题阅读:我的程序是在等待 GPU 计算,还是在反复组织并提交同一套 GPU 工作? 如果答案偏向后者,CUDA Graph 就值得成为你的工具箱之一。读完后,你不仅能跑通 Demo,还能解释为什么某些场景收益明显、为什么另一些场景几乎没有变化,以及怎样避免为了"Graph 化"牺牲代码的正确性与可维护性。
一、先说结论:你学完这一篇能解决什么问题
如果你只想先拿到一张行动清单,可以记住下面五句话。
第一,CUDA Graph 的核心价值不是"把二十个 Kernel 合并成一个 Kernel",而是把一组已经确定的 GPU 工作及其依赖关系预先描述、实例化,然后用较低的重复提交成本反复重放。图内仍然有原来的 Kernel,GPU 仍然要完成原来的计算。
第二,Graph 最容易帮助"单次工作很短、节点很多、重复次数很多"的场景。深度学习推理中的固定算子链、迭代求解器中的固定步骤、小批量实时请求、每帧结构相同的处理流程,都是常见候选。如果一个 Kernel 本身要运行几百毫秒,Host 多花几微秒提交它通常不是主要矛盾,Graph 的比例收益往往不明显。
第三,Graph 与 Stream 不是竞争关系。Stream 负责排序、并发和外部依赖,Graph 负责预先表达一组节点及图内依赖。cudaGraphLaunch 本身也要提交到某个 Stream 中。前一篇学过的非阻塞 Stream、异步复制、固定页内存和 Event,在本篇仍然有用。
第四,Stream Capture 是迁移旧代码最省力的入口:在开始捕获与结束捕获之间,原来提交到 Stream 的操作不会立即执行,而会被记录为 Graph 节点。显式 Graph API 则更像手工搭建有向无环图,代码较多,但节点句柄、依赖和更新策略更清楚。
第五,不要看到一次加速就宣布成功。至少要同时检查正确性、Host 提交时间、端到端时间、设备侧时间、初始化与实例化成本、重复次数、同步边界以及指针生命周期。本文三个程序会把这些问题逐一落到可运行代码上。
读完后,你将能够:
- 判断当前程序是不是"提交开销占比高"的候选;
- 用 Stream Capture 把重复 Kernel 链变成可重放 Graph;
- 把
H2D → 多个 Kernel → D2H整条流水线捕获进 Graph; - 使用显式 Graph API 创建 Kernel 节点;
- 在不重建拓扑的情况下更新 Kernel 参数;
- 理解
cudaGraph_t与cudaGraphExec_t的区别; - 避开捕获期间同步、默认流依赖、地址失效、盲目串行化等常见陷阱;
- 设计一组公平基准,而不是只测到首次加载或错误的异步时间。
本文配套目录如下:
text
cuda-notes/blogs/
├─ CUDA编程实战09_CUDAGraph实战_降低重复Kernel与流水线提交开销.md
├─ assets/09/
│ ├─ 01-hero-cuda-graph.png
│ ├─ 02-ordinary-vs-graph-launch.svg
│ ├─ 03-graph-lifecycle.svg
│ ├─ 04-dag-node-types.svg
│ ├─ 05-stream-capture-mechanics.svg
│ ├─ 06-graph-update-strategies.svg
│ └─ 07-graph-break-even.svg
└─ code/09/
├─ CMakeLists.txt
├─ graph_benchmark.cu
├─ graph_pipeline_capture.cu
└─ graph_update.cu
三个程序都已经在 Windows 10、Visual Studio 2022、CUDA Toolkit 11.6、GeForce RTX 2060 上以 Release 配置重新构建并运行。代码刻意使用 CUDA 11.6 就具备的接口形式,方便仍在维护较老工具链的读者。更新版本 CUDA 可能提供更多节点类型、条件节点、设备端启动等高级能力,但不影响本文的基本模型。
二、先判断问题:为什么 GPU 很快,程序却可能卡在"发命令"
初学 CUDA 时,我们通常把性能问题理解成"Kernel 跑得不够快"。这个方向并没有错,但一个完整 CUDA 程序至少包含两条时间线:
- Host 线程构造参数、进入 Runtime、经过 Driver,向 GPU 工作队列提交命令;
- GPU 按依赖取得命令,调度线程块,访问显存并完成计算。
如果一个 Kernel 需要运行一秒,Host 提交它所用的几微秒几乎可以忽略。可是,当一个迭代只有几十个非常短的 Kernel,每个 Kernel 的设备执行时间也许只有数微秒,Host 对每个节点重复完成参数整理、状态检查和命令提交,开销就不再是背景噪声。
设一次业务迭代有 S S S 个短 Kernel,共重复 R R R 次。普通写法在逻辑上要进行 R × S R \times S R×S 次 Kernel 提交。即使 GPU 总计算量不变,Host 也必须一次次走提交路径。Graph 写法则先准备包含 S S S 个节点的可执行图,每次业务迭代只调用一次 cudaGraphLaunch。逻辑 Kernel 执行次数仍为 R × S R \times S R×S,但重复的 Host 提交粒度从"一个节点一次"变成了"一张图一次"。

用一个简化模型表示,普通路径总时间可以写为:
T o r d i n a r y ( R ) = R ( C s u b m i t + C d e v i c e ) T_{\mathrm{ordinary}}(R)=R(C_{\mathrm{submit}}+C_{\mathrm{device}}) Tordinary(R)=R(Csubmit+Cdevice)
Graph 路径总时间可以写为:
T g r a p h ( R ) = C p r e p a r e + R ( C r e p l a y + C d e v i c e ) T_{\mathrm{graph}}(R)=C_{\mathrm{prepare}}+R(C_{\mathrm{replay}}+C_{\mathrm{device}}) Tgraph(R)=Cprepare+R(Creplay+Cdevice)
这里的 C d e v i c e C_{\mathrm{device}} Cdevice 表示一次业务迭代真正需要的设备工作; C s u b m i t C_{\mathrm{submit}} Csubmit 表示普通方式逐项提交整组工作的成本; C r e p l a y C_{\mathrm{replay}} Creplay 表示一次 Graph 重放的提交成本; C p r e p a r e C_{\mathrm{prepare}} Cprepare 包含定义、捕获、验证和实例化等一次性准备成本。
这个模型传达了三个非常重要的事实。
其一,两个路径中的 C d e v i c e C_{\mathrm{device}} Cdevice 并没有因为换了 API 就自动消失。Graph 不是计算复杂度魔法。如果你的瓶颈是带宽、指令吞吐、分支发散或低占用率,还得使用前面几篇学过的访存合并、共享内存、分块、归约和线程组织方法。
其二,Graph 有起步成本。只执行一次的临时流程不一定值得定义和实例化。重复次数越多,准备成本被摊到每次 Replay 后越小。
其三,只有在 C s u b m i t C_{\mathrm{submit}} Csubmit 明显大于 C r e p l a y C_{\mathrm{replay}} Creplay 时,Graph 才有可观空间。不同驱动、操作系统、GPU、节点数量、节点类型和同步方式都会影响这两个量,因此不能照抄别人的倍数。
如果暂时忽略测量噪声,并假设两条路径的设备工作相同,那么 Graph 获得总时间优势的大致条件是:
R > C p r e p a r e C s u b m i t − C r e p l a y R>\frac{C_{\mathrm{prepare}}}{C_{\mathrm{submit}}-C_{\mathrm{replay}}} R>Csubmit−CreplayCprepare
这就是盈亏平衡点。分母越大,说明每次重放节省越多,需要的重复次数越少;准备成本越高,需要更多 Replay 才能回本。

图中曲线只是帮助建立直觉,不代表任何固定硬件比例。实际交点必须测量。甚至同一个程序,调大数据规模后,设备计算时间占比上升,Graph 的相对加速也可能下降;减少数据规模或增加短节点数量后,提交成本占比上升,相对收益又可能增加。
三、建立心智模型:CUDA Graph 是一张可重放的任务图
CUDA Graph 是由节点和依赖边组成的任务图。一个节点代表一项工作,例如 Kernel、内存复制、内存设置、Host 回调或空节点;一条从节点甲指向节点乙的依赖边表示:乙必须等甲完成以后才具备执行条件。
所谓"有向",是因为依赖有先后方向;所谓"无环",是因为不能形成"甲等乙、乙等丙、丙又等甲"的闭环。只要存在这种环,所有相关节点都会互相等待,任务就无法开始。循环迭代不是在图里画一条返回边,而是由 Host 多次 Replay 同一张 Graph,或使用新版本 CUDA 的特定高级机制。本文采用兼容性更好的 Host 重放方式。
最简单的 Graph 可以是一条直线:
text
H2D → Kernel A → Kernel B → D2H
但 Graph 并不等同于 Kernel 列表。它也可以分叉和汇合:
text
H2D → Kernel A ─┬→ Kernel B ─┐
└→ Kernel C ─┴→ Kernel D
Kernel B 和 Kernel C 都依赖 Kernel A,却彼此没有依赖。如果资源允许,它们可以并发。Kernel D 同时依赖 B 与 C,因此要等两个分支都完成。

这里要分清"图内依赖"和"图外顺序"。
图内依赖由节点之间的边决定。图外顺序则由 cudaGraphLaunch(graph_exec, stream) 所在的 Stream 决定:这次 Graph Launch 要遵守此前已经进入该 Stream 的工作,也会约束随后进入同一 Stream 的工作。Graph 被放进一个 Stream,并不意味着图内节点只能串行。只要拓扑和资源允许,运行时仍可安排独立分支并发。
因此,一个准确的心智模型是:
Stream 是这张图进入 GPU 调度体系的外部队列;Graph 是队列里一项包含内部结构的复合工作。
3.1 两个长得很像、职责完全不同的对象
学习 Graph API 时最常见的困惑,是为什么既有 cudaGraph_t,又有 cudaGraphExec_t。
cudaGraph_t 可以理解为"图的定义"或"蓝图"。它描述有哪些节点、每个节点的参数以及依赖关系。它可以来自显式 Graph API,也可以来自 Stream Capture。程序可以检查它的节点数量,修改蓝图中的参数,或者用它去创建可执行图。
cudaGraphExec_t 是"已经实例化的可执行图"。调用 cudaGraphInstantiate 时,CUDA 会验证拓扑并准备执行资源。以后真正提交的是 cudaGraphExec_t:
cpp
cudaGraph_t graph{};
cudaGraphExec_t graph_exec{};
CUDA_CHECK(cudaGraphInstantiate(
&graph_exec, graph, nullptr, nullptr, 0));
CUDA_CHECK(cudaGraphLaunch(graph_exec, stream));
一个容易踩坑的细节是:实例化可以理解为对 Graph 定义进行一次可执行快照。实例化之后再改 cudaGraph_t,不会神奇地自动同步到已经存在的 cudaGraphExec_t。如果要修改下一次执行,应调用针对可执行图的节点更新接口,或使用整图更新;如果拓扑变化超出更新限制,则重新实例化。
生命周期通常分为四段:
- 定义:显式添加节点,或从 Stream Capture 得到
cudaGraph_t; - 实例化:调用
cudaGraphInstantiate得到cudaGraphExec_t; - 反复执行:多次调用
cudaGraphLaunch; - 销毁:先销毁可执行图,再销毁图定义,最后释放其余资源。

准备阶段贵一些不一定是坏事。它把原本可能发生在每次迭代里的重复工作提前做了。真正的问题是:这个成本有没有被足够多次 Replay 摊薄。生命周期设计决定了摊销能否发生。如果你在热循环里每次都捕获、实例化、只执行一次再销毁,通常等于主动放弃 Graph 的主要价值。
比较合理的业务结构是:
cpp
// 初始化阶段
create_stream_and_buffers();
capture_or_build_graph();
instantiate_graph();
// 热路径
for (int request = 0; request < request_count; ++request) {
update_small_parameters_if_needed();
CUDA_CHECK(cudaGraphLaunch(graph_exec, stream));
}
// 退出阶段
CUDA_CHECK(cudaGraphExecDestroy(graph_exec));
CUDA_CHECK(cudaGraphDestroy(graph));
四、选择建图路线:显式 Graph API 与 Stream Capture
CUDA 提供两种主要建图方式。
第一种是显式 Graph API。你调用 cudaGraphCreate 创建空图,再用 cudaGraphAddKernelNode、内存复制节点接口等逐个添加节点,并显式给出依赖节点。优点是拓扑清楚、节点句柄天然可保存、便于精确更新和生成复杂分支。缺点是参数结构体较多,把一段原本能正常运行的 Stream 代码迁移过来时改动较大。
第二种是 Stream Capture。你对某个 Stream 调用 cudaStreamBeginCapture,然后照常提交受支持的异步操作,最后调用 cudaStreamEndCapture 得到 Graph。优点是容易复用现有流水线代码,也能让支持捕获的库调用进入同一张图。缺点是捕获期间有严格规则;隐蔽的同步 API、Legacy Stream 依赖、尚未支持捕获的第三方库,都可能让捕获失败。
选择原则可以很朴素:
- 已有一条稳定的 Stream 提交流水线,希望低改造成本接入 Graph:先用 Stream Capture;
- 需要清晰创建分支、精确保存节点、频繁更新少数已知节点:显式 API 更顺手;
- 一部分流程来自现有库,另一部分要手工添加依赖:可以先捕获子图,再用 Graph API 组合或调整;
- 拓扑每次都剧烈变化,而且几乎不重复:先别急着 Graph 化,重构稳定区域或保留普通 Stream 可能更合理。
本文第一个和第二个程序使用 Stream Capture;第三个程序使用显式 Graph API。这样学完后,你不是只会一种模板。
五、开始前准备:目录、工具链与构建命令
如果你已经跟着本系列前面的文章建立了 CUDA 工程,可以直接进入 code/09。如果单独阅读本文,先确认下面命令可用:
powershell
nvcc --version
cmake --version
Windows 下还需要装有与当前 CUDA Toolkit 兼容的 Visual Studio C++ 工具链。Linux 下通常使用 GCC 或 Clang。配套 CMakeLists.txt 如下:
cmake
cmake_minimum_required(VERSION 3.20)
project(cuda_blog_09 LANGUAGES CXX CUDA)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CUDA_STANDARD 17)
set(CMAKE_CUDA_STANDARD_REQUIRED ON)
if(NOT DEFINED CMAKE_CUDA_ARCHITECTURES)
set(CMAKE_CUDA_ARCHITECTURES 75)
endif()
add_executable(graph_benchmark graph_benchmark.cu)
add_executable(graph_pipeline_capture graph_pipeline_capture.cu)
add_executable(graph_update graph_update.cu)
foreach(target IN ITEMS
graph_benchmark
graph_pipeline_capture
graph_update)
target_compile_options(${target} PRIVATE
$<$<COMPILE_LANGUAGE:CUDA>:-O3>)
if(MSVC)
target_compile_options(${target} PRIVATE
$<$<COMPILE_LANGUAGE:CUDA>:-Xcompiler=/utf-8>
$<$<COMPILE_LANGUAGE:CXX>:/utf-8>)
endif()
endforeach()
示例默认把架构设置为 75,因为本文实测显卡是计算能力 7.5 的 RTX 2060。你应该根据自己的设备修改。比如 Ampere 的某些显卡使用 86,Ada 的某些显卡使用 89。如果不确定,可运行前面文章的设备信息程序,或根据显卡型号查询 NVIDIA 官方计算能力表。
Windows 使用 Visual Studio 生成器时,可以执行:
powershell
cd cuda-notes\blogs\code\09
cmake -S . -B build -DCMAKE_CUDA_ARCHITECTURES=75
cmake --build build --config Release
Linux 使用单配置生成器时,常见写法是:
bash
cd cuda-notes/blogs/code/09
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_CUDA_ARCHITECTURES=75
cmake --build build -j
为什么一定强调 Release?因为 Debug 配置、未优化 Host 代码、额外检查和调试信息都可能改变微小提交开销。本文研究的恰好是微秒级或毫秒级差异,所以编译配置必须记录清楚。调试正确性时可以用 Debug,比较性能时要切到 Release,并保持两条被比较路径使用同一套配置。
5.1 统一错误检查:异步 API 也不能"假装成功"
三个程序都使用同一个 CUDA_CHECK 宏:
cpp
void cuda_check_impl(cudaError_t error, const char* expression,
const char* file, int line) {
if (error != cudaSuccess) {
std::fprintf(stderr,
"CUDA error at %s:%d\n"
" expression: %s\n"
" reason: %s\n",
file, line, expression, cudaGetErrorString(error));
std::exit(EXIT_FAILURE);
}
}
#define CUDA_CHECK(call) \
cuda_check_impl((call), #call, __FILE__, __LINE__)
它的作用不是让代码显得专业,而是把"错误发生在哪个 API"直接打印出来。Graph 涉及捕获状态、实例化、更新与执行,如果省略检查,捕获早已失效而程序继续向下走,最后只看到一次模糊崩溃,会非常难排查。
Kernel 启动语法本身没有返回值,因此紧跟一组 Kernel 提交后调用:
cpp
CUDA_CHECK(cudaGetLastError());
这能检查启动配置等即时错误。真正的设备执行错误往往会在后续同步 API 暴露,所以教程又在合理边界调用 cudaStreamSynchronize 或等待 Event。性能代码不应在每个 Kernel 后无条件同步,因为那会破坏异步性;但必须在测量终点、结果读取前或对象销毁前建立明确完成边界。
六、Demo 1:将重复短 Kernel 链捕获为可重放 Graph
第一个程序 graph_benchmark.cu 不加入 H2D 与 D2H,而是让一个设备数组常驻显存,反复执行非常短的原地加法 Kernel。这样设计不是为了模拟全部业务,而是为了隔离一个问题:当设备工作很短、节点数量很多时,普通逐项提交与 Graph Replay 有多大差异?
Kernel 很简单:
cpp
__global__ void add_constant_kernel(std::uint32_t* data,
size_t count,
std::uint32_t value) {
const size_t index =
static_cast<size_t>(blockIdx.x) * blockDim.x + threadIdx.x;
if (index < count) {
data[index] += value;
}
}
边界判断 index < count 不能因为本文关注 Graph 就省略。默认数组长度是四千零九十六,恰好能被二百五十六整除,但程序允许从命令行传入任意合法长度。我们还会使用长度为一和非整块长度做边界测试。
每轮业务迭代执行二十个阶段,第一个阶段加一,第二个加二,一直到第二十个加二十。因此每个元素每轮的增量是:
Δ r o u n d = S ( S + 1 ) 2 \Delta_{\mathrm{round}}=\frac{S(S+1)}{2} Δround=2S(S+1)
总共重复 R R R 轮后,预期增量是:
Δ t o t a l = R S ( S + 1 ) 2 \Delta_{\mathrm{total}}=R\frac{S(S+1)}{2} Δtotal=R2S(S+1)
这条公式既用于解释,也真正进入 CPU 验证函数。只看程序"不崩溃"不是正确性验证;即便遗漏一个节点、参数错误或数组没有复位,程序仍可能正常退出。逐元素比对预期值,才能证明普通路径和 Graph 路径完成了相同逻辑工作。
默认参数为:
text
元素数量:4096
每轮阶段数:20
重复轮数:5000
逻辑 Kernel 执行次数:100000
这个规模故意让 Kernel 较短,让提交开销在总时间里占据足够比例。随后你可以把元素数量调大,观察结论怎样变化。
6.1 捕获之前先预热:别把首次初始化误算成 Graph 成本
CUDA Runtime 首次使用、上下文建立、模块加载、Kernel 首次触发、内存页映射等动作都可能只在第一次发生。如果普通路径先运行,Graph 路径后运行,却不预热,那么普通路径承担了所有首次成本,比较会偏向 Graph;反过来排列也会产生另一种偏差。
示例先完成显存分配、Stream 创建和一次普通复制,然后提交十次 Kernel 并同步:
cpp
CUDA_CHECK(cudaMemcpy(
d_data, input.data(), bytes,
cudaMemcpyHostToDevice));
for (int i = 0; i < 10; ++i) {
add_constant_kernel<<<
grid_size, block_size, 0, stream>>>(
d_data, count, 1u);
}
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaStreamSynchronize(stream));
捕获和实例化完成后,又重放十次 Graph 并同步。这不是生产程序必须照抄的固定次数,而是基准测试中的公平措施。更严谨的工程还会交替测量两条路径、重复多轮、报告中位数或分位数、锁定功耗状态,并记录系统是否有其他 GPU 任务。
为什么显存分配要在捕获之前?一方面,配套程序使用的是 CUDA 11.6,教程选择最稳妥的兼容路径;另一方面,分配行为通常属于资源生命周期管理,不应每轮重复。即使新版本支持更多捕获或内存节点能力,先把稳定缓冲区准备好,仍然是容易理解和验证的设计。
为什么 Kernel 要在捕获前至少启动一次?因为我们希望 Graph 记录业务节点,而不是把首次模块加载等偶发因素混进实验。捕获区越"纯",定位问题越容易。
6.2 Stream Capture 的五行骨架
第一个示例的核心捕获代码如下:
cpp
cudaGraph_t graph{};
cudaGraphExec_t graph_exec{};
CUDA_CHECK(cudaStreamBeginCapture(
stream, cudaStreamCaptureModeGlobal));
for (int stage = 0; stage < stages; ++stage) {
add_constant_kernel<<<
grid_size, block_size, 0, stream>>>(
d_data,
count,
static_cast<std::uint32_t>(stage + 1));
}
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaStreamEndCapture(stream, &graph));
CUDA_CHECK(cudaGraphInstantiate(
&graph_exec, graph, nullptr, nullptr, 0));
逐句理解这段代码。
cudaStreamBeginCapture 把指定 Stream 置于捕获状态。这里使用 cudaStreamCaptureModeGlobal,它会对当前进程中可能与捕获冲突的行为采用较严格的跨线程约束,适合作为入门默认选项。
接下来的 Kernel 启动看起来与普通代码完全一样,但捕获期间它们不是立即提交执行,而是转化为捕获图中的节点。因为都进入同一条 Stream,原有 Stream 顺序会转化成节点依赖:第二个 Kernel 依赖第一个,第三个依赖第二个,依此类推。
cudaStreamEndCapture 结束捕获并返回 cudaGraph_t。这一步之后,Stream 恢复普通状态。成功得到的只是图定义,尚不能直接用 cudaGraphLaunch 执行。
cudaGraphInstantiate 验证并实例化图,返回 cudaGraphExec_t。CUDA 11.6 使用本文展示的五参数形式。某些更新版本还提供带标志或更详细错误信息的新接口,阅读新版文档时不要把不同版本函数签名生搬硬套到旧工具链。

捕获期间"命令不执行"会带来一个反直觉后果:不能依赖捕获区中某个 Kernel 已经产生结果,再让 Host 读取这个结果决定下一步建什么节点。此时设备工作只是被记录。Graph 更适合拓扑预先可知的流程;需要 Host 根据中间结果分支时,应重新设计边界,使用多个图、图外决策,或评估新版本条件节点等高级功能。
6.3 Graph 中到底捕获了多少节点
捕获二十个 Kernel,理论上应该得到二十个节点。程序使用:
cpp
size_t node_count = 0;
CUDA_CHECK(cudaGraphGetNodes(
graph, nullptr, &node_count));
当节点数组参数为 nullptr 时,这个接口返回节点数量。输出节点数不是为了好看,而是一种廉价的结构检查。如果预期二十个节点却只得到十九个,至少说明捕获内容或理解有问题。
在更复杂的流水线中,节点数不一定等于源代码里肉眼可见的 API 数量。库实现可能生成多个内部节点,不同版本也可能对捕获图做不同处理。因此节点数适合检查本文这种明确简单的示例,生产工程则应结合节点类型查询、调试输出以及 Nsight Systems 时间线理解图结构,不要把某个固定数字写成脆弱断言。
若要枚举节点,可先查询数量,再准备 std::vector<cudaGraphNode_t>:
cpp
size_t node_count = 0;
CUDA_CHECK(cudaGraphGetNodes(graph, nullptr, &node_count));
std::vector<cudaGraphNode_t> nodes(node_count);
CUDA_CHECK(cudaGraphGetNodes(
graph, nodes.data(), &node_count));
随后可以查询每个节点类型,辅助寻找 Kernel 节点、Memcpy 节点或其他节点。本文参数更新示例直接使用显式 API,因此创建节点时就拿到了句柄,不需要依赖遍历顺序猜测目标节点。
6.4 普通路径与 Graph 路径如何保证"干的是同一件事"
普通路径每轮提交二十个 Kernel:
cpp
for (int iteration = 0;
iteration < iterations;
++iteration) {
for (int stage = 0;
stage < stages;
++stage) {
add_constant_kernel<<<
grid_size, block_size, 0, stream>>>(
d_data,
count,
static_cast<std::uint32_t>(stage + 1));
}
}
Graph 路径每轮提交一次可执行图:
cpp
for (int iteration = 0;
iteration < iterations;
++iteration) {
CUDA_CHECK(cudaGraphLaunch(
graph_exec, stream));
}
注意,Graph 路径的循环次数仍然是五千。每次 Graph 内部有二十个 Kernel,所以逻辑设备工作仍是一共十万个 Kernel。Graph 不是把五千轮也自动展开到图里。若把五千轮全部捕获进一张巨大图,虽然 Host 只需一次 Launch,但图准备、内存占用、更新灵活性和错误定位都会变差,也不再代表典型每请求重放模式。
在测量普通路径前,程序把初始数组重新复制到设备;普通路径完成并验证后,测量 Graph 路径前再次复位。两条路径都从完全相同的数据出发。缺少这一步会让第二条路径接着第一条路径的结果继续累加,即使时间有效,正确性对比也已经失去意义。
验证函数使用六十四位中间量计算增量,再按无符号三十二位行为得到期望值,避免阶段数和轮数相乘时在过窄中间表达式中意外溢出。参数解析也限制了元素、阶段和轮数范围。这些看似与 Graph 无关的细节,决定了示例是不是"换个参数仍能可信运行",而不是只对默认输入碰巧正确。
6.5 为什么同时测三种时间
示例输出三个时间:
enqueue_ms:Host 执行提交循环所花的墙钟时间;total_ms:从测量开始,到 Stream 中最后一项工作完成的端到端墙钟时间;event_ms:由同一 Stream 上的 CUDA Event 测得的设备时间线区间。
测量骨架是:
cpp
const auto total_start = std::chrono::steady_clock::now();
CUDA_CHECK(cudaEventRecord(start, stream));
const auto enqueue_start = std::chrono::steady_clock::now();
submit();
const auto enqueue_stop = std::chrono::steady_clock::now();
CUDA_CHECK(cudaEventRecord(stop, stream));
CUDA_CHECK(cudaEventSynchronize(stop));
const auto total_stop = std::chrono::steady_clock::now();
为什么不能只看 submit() 前后的 Host 墙钟时间?因为 CUDA 启动通常是异步的。Host 很快返回,只能说明命令进入了队列,不能说明 GPU 已完成。如果把这个时间叫"Kernel 执行时间",会得到严重误导的结果。
为什么不能只看 Event 时间?因为本文要研究的恰好包含 Host 提交开销。Event 处于设备时间线,适合观察 GPU 工作区间;端到端墙钟才体现 Host 提交、等待以及其他外部开销。两者接近时,说明这段测试中额外 Host 等待占比不大;两者差异较大时,要继续检查排队、CPU 调度、同步和平台影响。
为什么 Event 要记录在同一 Stream?因为它需要与被测工作建立明确顺序。开始 Event 位于被测工作之前,停止 Event 位于被测工作之后,等待停止 Event 才能保证区间完成。
还要注意,本文的 enqueue_ms 不是每个 API 固有成本的精密微基准。它包含 C++ 循环、函数封装、错误检查等 Host 工作,且系统调度会产生噪声。它的价值在于同一程序、同一构建、相同逻辑工作下比较两种提交方式。
6.6 示例一实测:为什么纯短 Kernel 链提升明显
在本文环境中运行:
powershell
.\build\Release\graph_benchmark.exe
得到一次实际结果:
text
GPU: NVIDIA GeForce RTX 2060, compute capability 7.5
Elements: 4096, stages/iteration: 20, iterations: 5000
Logical kernel executions: 100000, graph nodes: 20
method,enqueue_ms,total_ms,event_ms,verification
ordinary,406.215,411.555,411.481,PASSED
graph,117.875,125.554,125.489,PASSED
Graph total speedup: 3.28x
Host enqueue speedup: 3.45x
Verification: PASSED
这组数据首先说明,两条路径都通过了逐元素验证。其次,普通路径 Host 提交十万个短 Kernel,提交循环约四百零六毫秒;Graph 路径只提交五千次 Graph Launch,约一百一十八毫秒。端到端时间从约四百一十二毫秒下降到约一百二十六毫秒。
但不能把"Graph 固定提升三点二八倍"写进你的性能承诺。即使在同一台机器再次运行,频率、后台任务、驱动状态也会让数字变化。正确表述应该是:
在这台机器、这次 Release 构建、四千零九十六个元素、每轮二十个短 Kernel、重复五千轮的条件下,Graph 显著减少了重复提交成本。
这句话可复现,也知道边界。脱离条件只写倍数,就会把实验观察误装成普遍定律。
你可以主动改变参数:
powershell
# count=1048576,stages=20,iterations=1000
.\build\Release\graph_benchmark.exe 1048576 20 1000
# count=4096,stages=2,iterations=5000
.\build\Release\graph_benchmark.exe 4096 2 5000
# count=4096,stages=80,iterations=2000
.\build\Release\graph_benchmark.exe 4096 80 2000
通常,元素数量大幅增加会让设备计算占比上升,Graph 节省的提交时间在总时间中的比例可能下降;阶段数量增加会增加普通逐项提交次数,Graph 优势可能更明显;重复次数太少时,若把捕获与实例化也算进完整生命周期,准备成本可能尚未回本。这里使用"可能",是因为最终仍由实测决定。
6.7 Graph 不会自动优化 Kernel
这是整篇最需要反复强调的边界。
如果 Kernel 存在不合并访存,Graph 不会自动把它变成合并访问;如果矩阵乘法没有共享内存分块,Graph 不会替你引入分块;如果归约存在大量分支和同步,Graph 不会改变算法;如果一个 Kernel 启动的线程太少,Graph 不会凭空提高占用率。
Graph 可能改变提交和调度准备方式,但逻辑节点及数据依赖仍然存在。性能优化应先判断瓶颈属于哪一层:
| 现象 | 更可能的主要矛盾 | 首先检查 |
|---|---|---|
| 单个 Kernel 运行很久 | Kernel 算法或设备资源 | 访存、吞吐、占用率、分支 |
| GPU 时间线之间有明显空洞,CPU 忙于提交 | Host 提交 | Graph、批处理、Kernel 融合 |
| H2D 或 D2H 占大头 | 传输 | 固定页内存、批量传输、重叠 |
| 每轮都在分配释放 | 资源管理 | 缓冲区复用、内存池 |
| 每个操作都立即同步 | 同步边界 | 异步化、Event、流水线 |
| 节点短而多,拓扑稳定且重复 | 重放开销 | CUDA Graph |
Kernel 融合和 Graph 也不是互斥选择。把几个极小且数据相邻的 Kernel 融合,可能减少中间显存往返与启动次数;再把剩余稳定流程做成 Graph,可能进一步降低提交成本。不过融合会增加寄存器压力、代码复杂度并削弱模块化,Graph 则保留节点边界。应以数据和维护成本共同决定。
6.8 捕获模式:Global、ThreadLocal 与 Relaxed
cudaStreamBeginCapture 的第二个参数不是性能档位,而是对捕获期间潜在不安全 API 调用的约束模式。
cudaStreamCaptureModeGlobal 最严格地关注进程内其他线程可能造成的冲突。对于第一次写 Graph、线程模型简单的教程,它是稳妥默认值。
cudaStreamCaptureModeThreadLocal 主要把相关限制放在发起捕获的线程上。它适合你确实理解多线程提交关系,并确认其他线程行为不会破坏捕获的场景。
cudaStreamCaptureModeRelaxed 放宽部分检查,但"检查更少"绝不等于"不安全行为变安全"。如果代码在捕获期间执行了会与捕获语义冲突的同步、分配或 Legacy Stream 操作,放宽模式可能只是让错误更隐蔽。不要把 Relaxed 当作捕获失败后的万能开关。
对小白最实用的策略是:先用 Global;捕获区尽量短、纯粹;所有资源提前准备;把潜在库调用逐个确认是否支持 Capture;只有在明确理解线程与 API 约束后才调整模式。
6.9 捕获期间不能随意做什么
捕获状态不是普通执行状态。以下规则尤其重要。
第一,不要同步正在被捕获的 Stream,也不要查询它是否完成。因为捕获区中的工作尚未真正执行,要求它完成在语义上自相矛盾。
第二,避免会隐式依赖 Legacy 默认流的同步 API。本文创建的是带 cudaStreamNonBlocking 标志的显式 Stream,这能减少与 Legacy Stream 的隐式关系,但并不意味着任意同步 API 都可安全进入捕获。
第三,不要在不确认支持的情况下,把第三方库调用直接包进捕获区。支持情况可能随 CUDA、cuBLAS、cuDNN 和库版本变化。最稳妥的是查对应版本官方文档,再写一个最小捕获测试。
第四,捕获失败后也要正确结束捕获,让 Stream 离开捕获状态。某些违规调用会使捕获失效,随后 cudaStreamEndCapture 返回错误并可能给出空图。若错误路径直接跳过 End Capture,后续使用同一 Stream 会更加混乱。
第五,捕获区使用的指针、对象和参数必须满足生命周期要求。捕获记录的 Memcpy 源地址、目标地址、Kernel 参数中设备指针,并不会在以后自动追踪你新建的 C++ 容器。原内存释放后再重放,图里保存的地址就失效了。
一个推荐错误处理框架是:
cpp
cudaGraph_t graph = nullptr;
cudaError_t begin_status =
cudaStreamBeginCapture(
stream, cudaStreamCaptureModeGlobal);
if (begin_status == cudaSuccess) {
// 只提交已确认支持捕获的异步工作。
submit_capture_safe_work(stream);
cudaError_t end_status =
cudaStreamEndCapture(stream, &graph);
if (end_status != cudaSuccess) {
graph = nullptr;
// 记录错误,并回退到普通路径或终止初始化。
}
}
教程使用 CUDA_CHECK 遇错立即终止,适合让错误暴露。长期运行服务则应设计回退策略:Graph 初始化失败时使用普通 Stream 路径,同时输出完整诊断,不能带着半初始化的 graph_exec 继续运行。
6.10 多 Stream 捕获不是"每条流各自随便录"
复杂流水线可能在多个 Stream 上分叉。CUDA 允许通过 Event 把其他 Stream 的工作纳入同一次捕获,但必须建立明确依赖,并在结束捕获前重新汇合到原始捕获 Stream。
概念流程是:
- 在起始 Stream 开始捕获;
- 在起始 Stream 记录一个被捕获的 Event;
- 其他 Stream 等待这个 Event,从而加入同一捕获图;
- 多条 Stream 分别提交各自工作;
- 分支末尾记录 Event;
- 起始 Stream 等待所有分支 Event,完成重新汇合;
- 在起始 Stream 结束捕获。
如果分支没有重新汇合就 End Capture,捕获图可能不完整或结束失败。这里的 Event 不是用于 Host 忙等,而是把跨 Stream 依赖转成图中的边。
多 Stream 捕获很强,但也更难验证。第一次接入 Graph 时,建议先捕获一条已有的正确 Stream 链,确认收益和生命周期,再逐步引入分支。不要为了让图"看起来高级"而人为增加并发;只有真实独立工作才应该分支。
七、Demo 2:把复制与计算整条流水线捕获进 Graph
纯 Kernel 基准帮助我们看见提交开销,但真实应用往往还要搬数据。第二个程序 graph_pipeline_capture.cu 捕获:
text
异步 H2D → 十二个短 Kernel → 异步 D2H
默认每个方向复制二百六十二千一百四十四字节,每个请求有十二个 Kernel,共运行两千个请求。Host 输入和输出使用固定页内存:
cpp
std::uint32_t* h_input = nullptr;
std::uint32_t* h_output = nullptr;
CUDA_CHECK(cudaMallocHost(
reinterpret_cast<void**>(&h_input), bytes));
CUDA_CHECK(cudaMallocHost(
reinterpret_cast<void**>(&h_output), bytes));
固定页内存有两个作用。其一,cudaMemcpyAsync 才能获得可靠的异步传输语义;普通可分页内存可能触发内部暂存和额外同步。其二,Graph 每次 Replay 使用稳定的 Host 地址。程序初始化一次后,直到销毁图和完成全部执行才释放。
Device 缓冲区同样在捕获前分配,并在最后一次 Replay 完成后才释放。Graph 捕获的是地址,不是"名字叫 d_data 的变量"。若释放旧地址、重新 cudaMalloc 得到另一地址,Graph 不会因为 C++ 变量名相同就自动更新。
7.1 把普通流水线封装成一次提交
示例先把普通路径写成 Lambda:
cpp
auto submit_ordinary_once = [&]() {
CUDA_CHECK(cudaMemcpyAsync(
d_data, h_input, bytes,
cudaMemcpyHostToDevice, stream));
for (int stage = 0; stage < stages; ++stage) {
add_constant_kernel<<<
grid_size, block_size, 0, stream>>>(
d_data,
count,
static_cast<std::uint32_t>(stage + 1));
}
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaMemcpyAsync(
h_output, d_data, bytes,
cudaMemcpyDeviceToHost, stream));
};
这是一种值得学习的重构方式:把"提交一次业务工作"封装成不自行同步的函数。普通路径调用它,捕获路径也调用它,避免维护两份容易漂移的业务代码。
捕获过程非常短:
cpp
CUDA_CHECK(cudaStreamBeginCapture(
stream, cudaStreamCaptureModeGlobal));
submit_ordinary_once();
CUDA_CHECK(cudaStreamEndCapture(stream, &graph));
CUDA_CHECK(cudaGraphInstantiate(
&graph_exec, graph, nullptr, nullptr, 0));
因为 submit_ordinary_once 依次提交一次 H2D、十二个 Kernel、一次 D2H,所以捕获图应有十四个节点:
N n o d e s = 1 + S + 1 = S + 2 N_{\mathrm{nodes}}=1+S+1=S+2 Nnodes=1+S+1=S+2
默认 S = 12 S=12 S=12,因此程序输出十四个节点。边界测试把阶段数改为七时,输出九个节点。这个检查证明复制节点确实进入了图,而不是只捕获了 Kernel。
捕获区里没有同步。同步放在捕获之前的预热末尾,或真正 Replay 以后。这个边界是 Stream Capture 能否成功的关键。
7.2 为什么流水线示例每个请求之后都同步
示例的普通测量循环如下:
cpp
for (int i = 0; i < iterations; ++i) {
submit_ordinary_once();
CUDA_CHECK(cudaStreamSynchronize(stream));
}
Graph 测量循环如下:
cpp
for (int i = 0; i < iterations; ++i) {
CUDA_CHECK(cudaGraphLaunch(graph_exec, stream));
CUDA_CHECK(cudaStreamSynchronize(stream));
}
看到每轮同步,很多有经验的读者会立刻问:"前一篇不是说不要频繁同步吗?"这个问题问得非常好。这里不是把同步当成通用优化,而是明确建模一种请求---响应业务:
每个请求的 Host 消费者必须拿到 D2H 输出,才能决定或提交下一个请求。
两条路径都保留完全相同的请求完成边界,所以比较是公平的。Graph 能减少每个请求内部 H2D、十二个 Kernel 和 D2H 的逐项提交,但不能删除业务要求的"本请求结束后 Host 才继续"。
如果你的业务允许一次排队两千个完全独立请求,就不应该照抄这个同步边界。可以使用多缓冲 Slot,把请求分散到多条 Stream;也可以为每个 Slot 准备稳定地址和相应 GraphExec,让复制与计算流水化。性能实验必须模拟真实约束。为了得到漂亮数字而删除业务必需同步,或为了代码简单而加入业务不需要同步,都会测到错误问题。
因此程序把第一列命名为 request_loop_ms,而不是 enqueue_ms。因为这段 Host 循环包含每次请求完成等待,它不再只是异步入队耗时。给指标起准确名字,本身就是性能工程的一部分。
7.3 示例二实测:整条流水线为什么只快了一点
运行:
powershell
.\build\Release\graph_pipeline_capture.exe
本文环境的一次实测为:
text
Elements: 65536, bytes/direction: 262144, stages: 12, iterations: 2000
Captured nodes: 14 (expected H2D + 12 kernels + D2H = 14)
method,request_loop_ms,total_ms,event_ms,verification
ordinary_pipeline,466.491,466.552,466.484,PASSED
graph_pipeline,431.866,431.906,431.856,PASSED
Graph total speedup: 1.08x
Request-loop speedup: 1.08x
Verification: PASSED
这里 Graph 仍然更快,但提升只有约百分之八,远小于纯短 Kernel 示例的三点二八倍。原因不神秘:每个请求除了提交十二个 Kernel,还要完成双向复制和一次业务必需同步。这些工作没有因为 Graph 消失。Graph 节省的只是总成本中的一部分,所以比例收益较小。
这组结果比再展示一个夸张加速更有价值,因为它教会我们怎样解释"为什么只快了一点":
- 正确性通过,排除少做工作造成的假加速;
- 节点数符合预期,证明整条链被捕获;
- 两条路径同步边界相同,比较的是同一种请求模型;
- Event 与端到端时间接近,主要区间确实位于这条 Stream;
- 复制和每请求等待占据不可忽略比例,限制 Graph 的相对收益。
如果你的实测 Graph 路径慢一点,也不要立刻认定代码写错。准备成本是否纳入、重放次数是否太少、节点是否本来就很重、平台是否对特定 Graph 节点有额外成本、是否在每轮更新大量参数、是否错误串行化原本可并发的请求,都需要检查。优化的成熟表现不是保证每次都加速,而是能解释收益和退化。
7.4 一个容易被忽略的限制:同一个 GraphExec 不能与自己并发执行
根据当前 CUDA Graph 官方语义,同一个 cudaGraphExec_t 的多个启动不会让它与自身并发执行;这些执行会被排序。这个限制对吞吐流水线非常关键。
假设普通代码原来可以在多条 Stream 上同时处理四个 Slot。如果改造时只创建一个 GraphExec,然后从多条 Stream 同时启动它,不能想当然地认为四次同一 GraphExec 会并发。更可靠的设计是为每个可并发 Slot 准备独立的可执行图,或者使用符合当前版本语义的其他资源方案。
可以这样组织:
cpp
struct Slot {
cudaStream_t stream{};
cudaGraph_t graph{};
cudaGraphExec_t graph_exec{};
void* host_input{};
void* host_output{};
void* device_buffer{};
};
std::vector<Slot> slots(slot_count);
每个 Slot 有稳定缓冲地址、Stream 和 GraphExec。请求轮转进入空闲 Slot,Host 用 Event 判断何时可复用。这样既保留跨 Slot 并发,也让每个 GraphExec 的生命周期和地址关系清楚。
千万不要只把普通循环里的二千条流水线提交机械替换成对同一个 GraphExec 的二千次启动,然后把它叫作"并发吞吐优化"。普通 Stream 中相邻请求之间可能存在不同的排队与重叠机会,而同一 GraphExec 的自并发限制、图内固定依赖以及固定地址复用都可能改变行为。先画出真实资源图,再决定 Graph 粒度。
7.5 固定地址不是缺点,而是一项需要显式管理的契约
Graph 之所以能提前准备执行,需要很多信息保持稳定。对捕获的 Memcpy 来说,源地址、目标地址、复制大小与复制方向都是重要参数;对 Kernel 节点来说,函数、网格、线程块、动态共享内存以及参数共同决定执行。
"稳定"不等于数据内容不能变。h_input 指向同一块固定页内存,但每次 Replay 之前可以把新业务数据写入这块内存。Graph 中的 H2D 节点仍从同一个地址读取,却读到更新后的内容。
cpp
fill_next_request(h_input, count);
CUDA_CHECK(cudaGraphLaunch(graph_exec, stream));
CUDA_CHECK(cudaStreamSynchronize(stream));
consume_result(h_output, count);
这里地址不变,内容变化,是 Graph 很常见的使用方式。
如果每次请求大小变化,可选择:
- 按最大容量预分配缓冲区,更新实际元素数的 Kernel 参数;
- 为几个常见尺寸准备多个 GraphExec;
- 在允许范围内更新 Memcpy 和 Kernel 节点参数;
- 尺寸或拓扑变化过大时回退普通路径或重新实例化。
"最大容量加有效长度"通常最容易管理,但 Kernel 必须正确使用有效长度,复制大小也要根据需求设置。始终复制最大容量会浪费带宽;动态更新复制节点则增加管理复杂度。没有唯一答案,取决于尺寸分布、更新频率和带宽占比。
八、Demo 3:拓扑不变时,更新 Graph 中的 Kernel 参数
真实业务很少每次输入都完全相同。Graph 的价值并不要求所有标量参数永远固定。第三个程序 graph_update.cu 使用显式 Graph API 创建一个仿射变换节点:
cpp
__global__ void affine_kernel(std::uint32_t* data,
size_t count,
std::uint32_t multiplier,
std::uint32_t addend) {
const size_t index =
static_cast<size_t>(blockIdx.x) * blockDim.x + threadIdx.x;
if (index < count) {
data[index] =
data[index] * multiplier + addend;
}
}
程序只创建一个 Kernel 节点和一个 GraphExec,却连续启动三次。三次启动前分别把参数更新为:
text
第一次:乘二,加三
第二次:乘三,加五
第三次:乘一,加七
若初始元素为 x x x,最终结果为:
y = ( ( 2 x + 3 ) 3 + 5 ) 1 + 7 = 6 x + 21 y=((2x+3)3+5)1+7=6x+21 y=((2x+3)3+5)1+7=6x+21
所以输入前五个元素一、二、三、四、五,输出应为二十七、三十三、三十九、四十五、五十一。程序在 CPU 上按三步操作独立计算预期值并逐元素检查。
8.1 用显式 API 创建一个 Kernel 节点
先创建空图:
cpp
cudaGraph_t graph{};
cudaGraphNode_t kernel_node{};
cudaGraphExec_t graph_exec{};
CUDA_CHECK(cudaGraphCreate(&graph, 0));
再准备初始 Kernel 参数:
cpp
std::uint32_t initial_multiplier = 1u;
std::uint32_t initial_addend = 0u;
void* initial_args[] = {
&d_data,
&count,
&initial_multiplier,
&initial_addend
};
kernelParams 的形式与 Driver/Runtime 底层启动参数约定有关。数组里的每一项不是参数值直接转成 void*,而是指向该参数存储位置的指针。即使 d_data 自己已经是设备指针,这里仍然放 &d_data。
然后填充 cudaKernelNodeParams:
cpp
cudaKernelNodeParams params{};
params.func =
reinterpret_cast<void*>(affine_kernel);
params.gridDim = dim3(grid_size);
params.blockDim = dim3(block_size);
params.sharedMemBytes = 0;
params.kernelParams = initial_args;
params.extra = nullptr;
最后把节点加入 Graph:
cpp
CUDA_CHECK(cudaGraphAddKernelNode(
&kernel_node,
graph,
nullptr,
0,
¶ms));
第三和第四个实参表示依赖节点数组及依赖数量。这里图中只有一个根节点,所以依赖数组是 nullptr,数量是零。若节点乙依赖节点甲,可以把甲的句柄放入依赖数组,再创建乙。
实例化方式与捕获图相同:
cpp
CUDA_CHECK(cudaGraphInstantiate(
&graph_exec,
graph,
nullptr,
nullptr,
0));
显式 API 的代码量比捕获多,但我们直接保存了 kernel_node。更新时不必枚举节点,也不必假设"第一个节点一定是目标 Kernel"。
8.2 更新可执行图中的 Kernel 参数
每次启动前,程序准备新的标量:
cpp
std::uint32_t multiplier = multipliers[launch];
std::uint32_t addend = addends[launch];
void* updated_args[] = {
&d_data,
&count,
&multiplier,
&addend
};
params.kernelParams = updated_args;
随后调用:
cpp
CUDA_CHECK(cudaGraphExecKernelNodeSetParams(
graph_exec,
kernel_node,
¶ms));
CUDA_CHECK(cudaGraphLaunch(graph_exec, stream));
这里是 cudaGraphExecKernelNodeSetParams,名称中包含 Exec,它更新的是已经实例化的可执行图,影响后续 Launch。另一个容易混淆的接口 cudaGraphKernelNodeSetParams 更新的是 cudaGraph_t 蓝图中的节点参数,不会自动改写已有 GraphExec。
官方 Runtime API 说明,kernelParams 数组以及它指向的参数值会在 SetParams 调用期间被复制。因此本例中的局部 multiplier、addend 和 updated_args 在函数返回后即可离开作用域,不必一直存活到 GPU 执行结束。
参数更新只影响未来的 Launch,已经入队或正在执行的 Launch 不会被后来更新改写。同一个 GraphExec 的多次 Launch 又会被排序,所以本例可以连续执行"更新一、Launch 一、更新二、Launch 二、更新三、Launch 三",最后在读回结果前只同步一次:
cpp
CUDA_CHECK(cudaStreamSynchronize(stream));
这比每轮同步更准确地展示了 GraphExec 更新语义,也保留三次仿射变换的顺序。要是多个 Host 线程同时更新同一个对象,仍需遵守 Graph 对象非线程安全的限制,使用清晰的所有权或外部同步。
8.3 示例三实测输出与结果推导
运行:
powershell
.\build\Release\graph_update.exe
输出:
text
Launch 1: multiplier=2, addend=3
Launch 2: multiplier=3, addend=5
Launch 3: multiplier=1, addend=7
Elements: 1024
First outputs: 27 33 39 45 51
Verification: PASSED
手算第一个元素:
x 1 = 1 × 2 + 3 = 5 x_1=1\times2+3=5 x1=1×2+3=5
第二次:
x 2 = 5 × 3 + 5 = 20 x_2=5\times3+5=20 x2=5×3+5=20
第三次:
x 3 = 20 × 1 + 7 = 27 x_3=20\times1+7=27 x3=20×1+7=27
与输出第一个值一致。对第二个初始元素二,最终得到三十三。程序不是只检查前五个打印值,而是检查全部一千零二十四个元素。
还可以运行边界参数:
powershell
.\build\Release\graph_update.exe 1
.\build\Release\graph_update.exe 1003
长度一验证最小有效输入;一千零三不能被二百五十六整除,验证最后一个线程块中的越界保护。本文已经实测两者均输出 Verification: PASSED。
8.4 三种更新策略:改少量节点、更新整图、重新实例化
Graph 进入真实业务后,变化大致分三类。
第一类是少量已知节点的参数变化,例如标量、网格尺寸、线程块尺寸、复制地址或复制长度在接口允许范围内改变。此时节点级可执行图更新通常最直接。优点是不用比较整张图;前提是保存目标节点句柄,并满足当前 CUDA 版本对该节点类型的更新限制。
第二类是多个参数变化,或你通过再次捕获得到一张拓扑兼容的新 Graph。可以评估整图更新接口,例如 cudaGraphExecUpdate。它会尝试用新图更新已有 GraphExec。若兼容,就避免完整重新实例化;若失败,程序必须检查更新结果并准备回退。
第三类是拓扑或节点类型发生实质变化,例如节点数量变化、依赖分支改变、某个 Kernel 节点换成不同类型节点。此时最清楚的策略常常是销毁旧 GraphExec,使用新 Graph 重新实例化。它成本更高,但语义直接,不应为了避免一次初始化成本而写出难以证明正确的更新逻辑。

决策可以写成:
text
只改少量已知节点参数?
├─ 是:优先节点级 Exec SetParams
└─ 否:新旧图拓扑兼容?
├─ 是:尝试 Whole Graph Update,并检查返回结果
└─ 否:重新实例化
更新不是"调用成功就万事大吉"。还要验证输出,检查函数所属上下文、内存上下文、Memcpy 方向、节点类型、依赖拓扑以及版本限制。尤其在 CUDA 11.6 这样的较老工具链上,应直接阅读对应版本文档,不要只参考最新版示例。
8.5 更新能力的边界:不要把 GraphExec 当成任意可变脚本
CUDA Graph 的更新接口之所以比重新创建轻量,是因为它假设大量执行结构仍然可复用。这也意味着更新受约束。
Kernel 节点更新时,函数、参数、网格和线程块并非任何组合都在所有版本、所有上下文中合法。Memcpy 或 Memset 节点更新还涉及源和目标内存所属上下文、复制方向、内存类型等条件。整图更新通常要求拓扑映射兼容,节点类型和依赖关系不能随意变化。
工程上应采用"能力检测加回退"思路:
cpp
bool try_fast_update(...);
bool rebuild_graph(...);
if (!try_fast_update(...)) {
if (!rebuild_graph(...)) {
use_ordinary_stream_path(...);
}
}
快速更新失败不应导致服务直接使用旧参数继续计算。旧 GraphExec 可能仍然有效,但它表达的是旧任务。正确回退顺序应由业务决定:重新实例化、切回普通路径、拒绝该请求,或记录错误并停止。无论哪一种,都要避免"返回看似正常但数据其实属于旧参数"的静默错误。
对于长期运行程序,建议给每个 Graph 配置建立版本号或签名,至少包含:
- 拓扑类别;
- 节点数量和关键节点类型;
- 缓冲区容量;
- Kernel 版本;
- 数据类型;
- 设备与上下文;
- 可更新参数范围。
当请求超出当前签名能力时,主动选择另一 GraphExec 或重建,而不是冒险修改。
九、工程设计:Graph 与 Stream、内存和对象所有权如何协作
Graph 粒度过小,每次 Replay 只包含一个本来就很长的 Kernel,节省空间有限;粒度过大,把数千个几乎不变化但也难更新的请求全部展开,准备和管理成本又会膨胀。
一个实用边界通常是"单次业务迭代"或"单个固定 Slot 的完整处理流程"。它有几个好处:
- 输入与输出生命周期容易定义;
- 每次 Replay 对应一个可理解的业务单位;
- 参数更新集中在迭代边界;
- 错误和性能指标可以按请求统计;
- 多个 Slot 可以通过多 Stream 并发;
- Graph 重建不会牵涉无限长历史。
以四 Slot 图像处理为例,每个 Slot 可以有:
text
固定页输入缓冲 → H2D → 预处理 → 推理子流程 → 后处理 → D2H → 固定页输出缓冲
Host 使用四条 Stream 和四个完成 Event 轮转。每个 Slot 的 GraphExec 只使用自己的地址。这样 Graph 管理单请求内部提交,Stream 管理多个请求之间的并发。
若所有请求共享同一个 Device 临时缓冲区,即使创建四个 GraphExec,也会发生数据竞争。Graph 不会自动识别"这些指针实际指向同一块可写内存"并替你加业务依赖。资源隔离或显式依赖仍由程序员负责。
9.1 Graph Upload 与第一次 Launch
某些 CUDA 版本提供 cudaGraphUpload,可以在正式 Launch 前把可执行图需要的设备侧准备工作上传到指定 Stream。它的目的之一是减少第一次 Launch 可能承担的准备开销。
使用思路类似:
cpp
CUDA_CHECK(cudaGraphUpload(graph_exec, stream));
CUDA_CHECK(cudaStreamSynchronize(stream));
// 正式测量或处理请求
CUDA_CHECK(cudaGraphLaunch(graph_exec, stream));
是否值得显式 Upload,要以你的 CUDA 版本文档和测量为准。本文示例采用更通用的"实例化后预热十次"方法,也能避免把第一次重放的特殊成本直接当作稳态结果。
不要同时做了 Upload 和多次预热,却在报告中把这些准备成本完全隐藏,然后宣称一次性任务也获得相同收益。稳态吞吐与首请求延迟是两个指标:
- 稳态吞吐关心大量请求之后平均每次成本;
- 首请求延迟关心从进程或模型准备开始,到第一个有效结果返回;
- Graph 可以改善稳态提交,也可能把部分工作移到实例化或 Upload 阶段;
- 实时系统必须同时报告初始化时间和稳态时间。
9.2 线程安全与对象所有权
当前官方文档明确提醒,cudaGraph_t 不是线程安全对象。不要让多个 Host 线程在没有外部同步的情况下同时修改同一图定义。即使某个测试"跑了很多次都没崩",数据竞争仍然没有正确性保证。
建议为 Graph 设计明确所有权:
- 初始化线程负责创建和实例化;
- 每个工作 Slot 独占自己的 GraphExec,或由调度器串行访问;
- 更新和 Launch 使用同一套互斥或队列规则;
- 销毁前等待所有使用者退出,并确认相关 Stream 工作完成;
- 不把裸
cudaGraphNode_t句柄泄露给生命周期不受控的模块。
可以用一个 RAII 包装管理销毁:
cpp
struct GraphResources {
cudaGraph_t graph{};
cudaGraphExec_t exec{};
~GraphResources() {
if (exec != nullptr) {
cudaGraphExecDestroy(exec);
}
if (graph != nullptr) {
cudaGraphDestroy(graph);
}
}
};
正式工程还要处理析构中错误不能抛出、Stream 完成顺序、移动语义以及部分初始化失败。教程显式写销毁调用,是为了让生命周期更直观。
十、避坑清单:十五个常见错误及其修复方法
错误一:在热循环里每次重新实例化
症状是 Graph 代码比普通路径更慢。原因是最昂贵的准备成本没有摊销。修复方法是把捕获和实例化移到初始化或配置变化边界,热循环只做必要更新和 Launch。
错误二:把 Graph 当成 Kernel 融合
症状是期待显存访问量和逻辑 Kernel 数自动下降。修复方法是分别分析设备算法与 Host 提交。需要融合就明确重写 Kernel,需要降低提交开销才使用 Graph。
错误三:捕获期间调用同步 API
症状是 End Capture 报错或返回空图。修复方法是把分配、初始化、同步和 Host 决策移到捕获区外;捕获区只保留支持捕获的异步提交。
错误四:对 Legacy 默认流开始捕获
旧版本文档对 cudaStreamLegacy 捕获有明确限制。修复方法是创建显式非阻塞 Stream,并检查依赖的库是否真的使用你传入的 Stream。
错误五:捕获失败后不结束捕获
症状是后续同一 Stream 的 API 连续异常。修复方法是设计统一清理路径,让 End Capture 完成状态恢复,并丢弃无效图。
错误六:释放 Graph 中仍引用的内存
症状可能是非法地址、结果随机错误或稍后才暴露的异步异常。修复方法是让 Host 固定页缓冲、Device 缓冲和参数存储至少活到最后一次相关执行完成。
错误七:用可分页 std::vector 假设 D2H 一定真正异步
症状是复制无法按预期重叠,或捕获行为与性能不稳定。修复方法是对需要异步传输的 Host 缓冲使用 cudaMallocHost、cudaHostAlloc 或按规则注册固定页内存,并注意固定页内存是有限资源。
错误八:只测 Host 调用返回
症状是报告的"GPU 耗时"接近零。修复方法是使用 Event 测设备区间,使用墙钟加最终同步测端到端,同时单独记录 Host 提交时间。
错误九:没有预热
症状是第一次结果异常慢,换一下测试顺序倍数就大变。修复方法是让 Runtime、Kernel 和 Graph 路径都预热,再进行多轮测量。
错误十:比较的两条路径工作量不同
症状是 Graph 看起来极快,实际漏节点、少复制或没有同步。修复方法是从相同输入出发,保持业务边界一致,逐元素验证输出,并检查捕获节点数。
错误十一:更新了 Graph 蓝图,却启动旧 GraphExec
症状是新参数没有生效。修复方法是分清 cudaGraphKernelNodeSetParams 与 cudaGraphExecKernelNodeSetParams,或在修改蓝图后执行兼容的整图更新、重新实例化。
错误十二:无条件假设整图更新一定成功
症状是拓扑变化后继续启动旧图。修复方法是检查 cudaGraphExecUpdate 返回状态和更新结果,失败时重新实例化。
错误十三:从多条 Stream 启动同一个 GraphExec,期待自并发
症状是吞吐不升反降。修复方法是理解同一 GraphExec 的执行排序,为真正并发 Slot 准备独立可执行实例与缓冲区。
错误十四:以为参数更新会改写已经入队的 Launch
症状是错误地推断前一轮会使用后来更新的参数,因而加入大量无谓同步。修复方法是理解 Exec 节点更新只影响未来 Launch,已经入队或运行的 Launch 不受影响;同时记住同一 GraphExec 的多个 Launch 会被排序。
错误十五:忽略 CUDA 版本差异
症状是复制新版代码到 CUDA 11.6 后函数签名不匹配,或根据新版能力假设旧版支持。修复方法是同时查当前文档和部署版本的归档文档,以项目最低支持版本编译测试。
10.1 怎样判断自己的项目值得 Graph 化
先不要改代码,回答下面问题。
一、同一组 GPU 操作会重复多少次?如果只执行一次,除非还有其他确定收益,否则准备成本很难摊销。
二、每组有多少节点?节点越多、每个节点越短,逐项提交开销越可能突出。
三、GPU 时间线上是否有与 Host 提交相关的空洞?若 GPU 一直满载,主要瓶颈可能不在提交。
四、拓扑是否稳定?参数内容可以变化,但如果节点数量、类型和依赖每次都完全不同,Graph 管理成本会增大。
五、内存地址是否能稳定?若输入对象不断迁移,能否采用固定 Slot、最大容量缓冲或节点参数更新?
六、业务同步边界是什么?每请求必须返回,还是允许批量排队?Graph 设计必须保留真实语义。
七、是否需要同一流程并发多个实例?若需要,要规划多个 GraphExec、Stream 和互不冲突的缓冲区。
八、当前部署的 CUDA 最低版本是什么?接口和更新能力必须按最低版本设计。
若答案是"重复很多、节点短而多、拓扑稳定、地址可管理",Graph 很值得做原型。若答案是"单个 Kernel 很长、只运行几次、拓扑随机",先优化别处。
十一、从 Demo 迁移到项目:接入、验证与观测
把已有程序 Graph 化时,可以按以下顺序执行。
第一步,建立可靠普通路径。它必须有错误检查、结果验证和可信计时。没有正确基线,Graph 路径再快也无法证明做了同样工作。
第二步,用 Nsight Systems 或简单 Event、墙钟数据确定重复提交是否值得关注。记录节点数量、每个节点大致时长、同步点和 GPU 空洞。
第三步,选择最小稳定业务单元。通常是一轮迭代、一个请求或一个 Slot 的流水线。
第四步,把资源准备移到单元之外。分配固定缓冲区,创建非阻塞 Stream,加载 Kernel,完成预热。
第五步,先用 Stream Capture 包住原提交函数。捕获区不加新同步,不做 Host 决策,不调用未确认支持捕获的库。
第六步,实例化并预热 GraphExec。记录定义、实例化和首次执行时间,而不是只保留最好看的稳态结果。
第七步,从完全相同输入分别运行普通与 Graph 路径。至少输出 Host 循环、端到端、Event 时间和正确性。
第八步,改变重复次数、数据规模、节点数。寻找盈亏平衡点和收益区间,不只测一个默认参数。
第九步,规划更新。少量标量用节点级更新;多个兼容变化尝试整图更新;拓扑改变则重建;任何失败都有普通路径回退。
第十步,扩展到多 Slot。不要先追求复杂并发,先保证单 Slot 图正确,再用独立资源增加吞吐。
这套步骤的价值在于,每一步都有可验证产物。出现性能退化时,能定位是准备成本、执行成本、同步、更新还是资源竞争,而不是在一大段 Graph 代码里盲目试参数。
11.1 如何用 Nsight Systems 看 Graph
本文不要求安装分析器才能运行,但当项目复杂以后,时间线比单个数字更有解释力。
使用 Nsight Systems 时,重点观察:
- 普通路径 Host 线程是否连续调用大量 Kernel Launch;
- GPU 时间线上短 Kernel 之间是否有空洞;
- Graph 路径 Host API 是否变成较少的 Graph Launch;
- H2D、Kernel、D2H 的顺序是否符合捕获预期;
- 多 Stream 分支有没有实际重叠;
- 是否存在意外的设备同步;
- 第一次 Graph Launch 与后续稳态 Replay 是否不同;
- 多个请求是不是因为同一 GraphExec 或共享缓冲而被串行化。
不要只截一张"看起来很满"的时间线。先选取同样数量的业务请求,再分别放大 Host 和 GPU 区域,并把截图对应的参数、构建版本和硬件写在报告里。
如果 Graph 内部节点在某些分析器版本中以聚合形式展示,仍可通过 CUDA Graph 跟踪选项、NVTX 标记、节点命名能力或拆分实验理解。工具展示方式也随版本变化,应查对应 Nsight Systems 文档。
11.2 正确性与内存检查
三个示例都做 CPU 结果验证,但这不能完全替代内存检查。开发阶段可以在支持的平台运行 Compute Sanitizer:
powershell
compute-sanitizer --tool memcheck `
.\build\Release\graph_update.exe 1003
或在 Linux:
bash
compute-sanitizer --tool memcheck \
./build/graph_update 1003
工具能帮助发现越界、非法地址等问题。不过工具支持也受平台、驱动模式、CUDA 与 GPU 组合影响。例如部分 Windows WDDM 环境可能无法完成某些检查。若工具报告"不支持当前平台",它表示这次动态检查没有完成,不能被写成"程序已经无内存错误"。此时仍应保留边界输入、结果验证,并在支持的 TCC 或 Linux 环境、CI GPU 节点上补做 Sanitizer。
Graph 程序的异步错误可能在很晚才暴露。调试时可以暂时缩小规模、增加关键边界同步,找到第一个失败节点;定位后再移除不必要同步,恢复真实性能结构。不要把调试同步永久留在热路径而忘记重新测量。
11.3 配套程序的参数与测试矩阵
三个程序均支持命令行参数。
graph_benchmark:
text
graph_benchmark [count [stages [iterations]]]
graph_pipeline_capture:
text
graph_pipeline_capture [count [stages [iterations]]]
graph_update:
text
graph_update [count]
推荐测试矩阵:
| 目的 | 命令 | 关注点 |
|---|---|---|
| 默认短 Kernel | graph_benchmark |
提交和总时间加速 |
| 最小边界 | graph_benchmark 1 1 3 |
一个元素、一个节点 |
| 非整块流水线 | graph_pipeline_capture 1003 7 20 |
越界与九个节点 |
| 大数据 | graph_benchmark 1048576 20 1000 |
设备占比增大 |
| 少节点 | graph_benchmark 4096 2 5000 |
Graph 收益变化 |
| 多节点 | graph_benchmark 4096 80 2000 |
提交占比变化 |
| 更新最小边界 | graph_update 1 |
参数更新与单元素 |
| 更新非整块 | graph_update 1003 |
尾块越界保护 |
本文实际边界结果包括:
text
graph_benchmark 1 1 3
Verification: PASSED
graph_pipeline_capture 1003 7 20
Captured nodes: 9
Verification: PASSED
graph_update 1
Verification: PASSED
graph_update 1003
Verification: PASSED
性能倍数不应成为自动化测试的硬阈值。共享 CI 机器上噪声很大,驱动和硬件不同也会改变结果。自动化更适合断言退出码、节点结构和数值正确性;性能回归则使用受控机器、足够重复轮数和统计阈值。
11.4 一份面向生产环境的检查清单
在代码评审中,可以逐项确认:
- 普通路径仍然存在,能作为回退和正确性基线;
- Graph 粒度对应明确业务单元;
- 捕获和实例化不在每次热循环中;
- 捕获区没有非法同步或不支持的库调用;
- 使用显式非阻塞 Stream;
- Host 异步传输缓冲使用固定页内存;
- Graph 引用的所有地址生命周期足够长;
- 每个并发 Slot 有独立可执行实例或符合版本要求的设计;
- 更新接口返回值全部检查;
- 整图更新失败时能够重新实例化;
- 拓扑不兼容时不会继续使用旧图;
- Graph 定义的多线程访问有外部同步;
- 销毁前等待所有相关工作完成;
- 普通与 Graph 路径从同样输入出发;
- 输出做完整数值验证;
- 测量区分 Host 提交、设备区间与端到端;
- 初始化、实例化和首请求成本单独记录;
- 性能报告包含 GPU、驱动、CUDA、系统和构建配置;
- 改变规模、节点数与重复次数后仍知道收益边界;
- 在支持环境运行 Compute Sanitizer 和分析器。
只要其中任何一项答案含糊,就说明系统还有一段隐含契约没有写清楚。Graph 优化最怕隐含状态,因为它把许多执行信息预先固定下来。
十二、进阶取舍与动手练习
12.1 Graph、批处理与 Kernel 融合如何取舍
假设每个请求有二十个短 Kernel,有三条优化路线。
路线一是扩大批处理。一次处理更多数据,Kernel 运行更久,启动成本占比下降,吞吐通常提高。但请求要等待成批,延迟可能增加,内存也需要更大。
路线二是 Kernel 融合。把相邻操作写进一个 Kernel,减少中间读写和启动次数。它可能同时改善设备和 Host 两侧,但开发复杂度、寄存器压力、代码生成和可维护性都会增加。
路线三是 CUDA Graph。保持节点模块化,预先准备固定拓扑并低成本重放。它通常对业务代码侵入较小,但不能删除节点内部的数据移动,且要管理稳定地址和更新。
选择不必三选一。一个常见成熟方案是:先融合收益最大、语义紧密的相邻算子;以合理批量提高设备利用率;再把剩余稳定流水线 Graph 化。每一层都用独立基准证明收益。
如果业务最关心尾延迟,小批量加 Graph 可能比大批量更合适;如果只关心离线吞吐,大批量可能已经把启动成本摊得很小;如果显存带宽是主要瓶颈,融合减少中间流量可能比 Graph 更重要。优化从来不是 API 排名,而是约束下的选择。
12.2 练习一:寻找你机器上的盈亏平衡点
固定 count=4096、stages=20,逐步改变 iterations:
powershell
.\build\Release\graph_benchmark.exe 4096 20 1
.\build\Release\graph_benchmark.exe 4096 20 10
.\build\Release\graph_benchmark.exe 4096 20 100
.\build\Release\graph_benchmark.exe 4096 20 1000
.\build\Release\graph_benchmark.exe 4096 20 5000
把定义、实例化、预热也纳入一个新的完整生命周期计时。记录普通总时间和 Graph 总时间,找出 Graph 首次稳定领先的大致重复次数。
不要只运行一次。每个参数至少运行多轮,记录中位数。然后回答:
- 不计准备成本时,Graph 从几次开始领先?
- 计入准备成本时,盈亏平衡点移动到哪里?
- 首次 Graph Launch 是否显著慢于后续 Replay?
- Event 时间与墙钟时间的差异随重复次数如何变化?
完成这个练习,你就不再需要问"Graph 至少重复多少次才有用"这种没有硬件与任务条件的问题,因为你会自己测出答案。
12.3 练习二:为流水线增加双缓冲 Slot
复制第二个示例,创建两个 Slot。每个 Slot 包含:
- 一块固定页输入;
- 一块固定页输出;
- 一块 Device 缓冲;
- 一条非阻塞 Stream;
- 一张 Graph 和一个 GraphExec;
- 一个完成 Event。
第零个请求进入 Slot 零,第一个请求进入 Slot 一,第二个请求等待 Slot 零完成后复用。不要在每个请求后执行全设备同步,只等待即将复用的 Slot。
验证时给每个请求不同输入,输出中加入请求编号,防止两个 Slot 误用同一缓冲仍然"碰巧通过"。比较:
- 单 Slot 请求---响应;
- 双 Slot 普通 Stream;
- 双 Slot Graph。
观察 Graph 降低每 Slot 内部提交成本,双缓冲提供请求间并发,它们解决的是不同层问题。
12.4 练习三:验证参数更新的未来 Launch 语义
第三个示例已经连续完成三次"更新后 Launch",最后只同步一次。练习目标是证明后一次更新不会改写已经入队的前一次 Launch。
把三组参数扩展为十组,每次使用容易辨认的乘数与加数。每轮 SetParams 后立即 Launch,中间不做同步,最后一次性等待并读回。CPU 按同样顺序计算预期值。如果所有元素仍通过验证,就能直观看到每次 Launch 使用的是当时已经复制进可执行图的参数。
cpp
for (int launch = 0; launch < launch_count; ++launch) {
update_exec_params(graph_exec, kernel_node, launch);
CUDA_CHECK(cudaGraphLaunch(graph_exec, stream));
}
CUDA_CHECK(cudaStreamSynchronize(stream));
再做第二组实验:为两个 Slot 分别创建 GraphExec、Stream 和数据缓冲,给它们不同参数并同时提交。比较共享一个 GraphExec 与独立两个 GraphExec 的时间线。这样能同时理解"更新只作用于未来 Launch"和"同一 GraphExec 不与自身并发"这两条规则。
十三、本篇总结:你现在能用 Graph 解决什么问题
现在再回到开头的问题:为什么 GPU 很快,程序仍可能卡在发命令?
因为 Host 对大量短操作逐项提交,也会消耗真实时间。当每个设备节点很短、节点链稳定且重复次数多时,这些提交成本会成为可见比例。CUDA Graph 把节点、参数和依赖预先定义并实例化,热路径使用较少的 Graph Launch 重放整组工作,从而降低重复设置与提交开销。
但 Graph 不是"万能加速开关":
- 它不自动减少逻辑 Kernel 次数;
- 它不修复低效访存和算法;
- 它有定义、捕获和实例化成本;
- 它要求理解地址与参数生命周期;
- 它的捕获状态有严格 API 规则;
- 同一 GraphExec 不能与自身并发;
- 更新有拓扑和节点类型限制;
- 真实收益由任务、平台、同步和重复次数共同决定。
本文三个程序分别证明了三件事。
graph_benchmark 证明:短 Kernel 链中,普通逐项提交可能占据很大比例,Graph Replay 能显著降低 Host 与端到端时间。
graph_pipeline_capture 证明:复制和业务同步仍然存在时,Graph 收益可能只有一小部分;小幅提升并不失败,它准确反映了瓶颈构成。
graph_update 证明:拓扑不变时,可以保留同一个可执行图,在每次 Launch 前更新 Kernel 参数,而不必每轮重建。
真正值得带走的不是某个倍数,而是一套判断方法:
先建立正确基线,再识别提交瓶颈;先固定资源和业务边界,再捕获稳定流程;同时测 Host、设备和端到端;任何加速都用相同工作量和完整验证证明;变化少就轻量更新,变化大就重建,并始终保留回退路径。
掌握这套方法后,CUDA Graph 不再是一个神秘高级名词,而是你在特定瓶颈下能够有条件、有证据使用的工程工具。
十四、下一篇预告
Graph 实验已经多次使用 std::chrono 与 CUDA Event,也暴露了一个事实:如果计时边界不正确,任何优化结论都可能是假的。
下一篇将进入:
CUDA编程实战10:CUDA Event 与可信性能测量------从"跑得快"到"证据可靠"
我们会系统区分 Host 墙钟、设备时间线、异步入队时间、端到端延迟、吞吐、预热、统计噪声和分析器开销;写出可以直接运行的微基准框架;并用反例解释为什么少一次同步、换一个 Stream、调换测试顺序,就可能得到完全相反的"结论"。
如果你正在做 CUDA 优化,可信测量不是最后补的一张表,而是所有优化的地基。建议先保留本文三个程序的输出,下一篇会把它们升级成更系统的基准套件。
参考资料与源码入口
官方资料建议同时阅读当前版本与项目部署版本:
- NVIDIA CUDA Programming Guide:CUDA Graphs
- CUDA 11.6.2 C Programming Guide 归档版
- CUDA Runtime API:Graph Management
- CUDA 11.6 Runtime API:Graph Management 归档版
- NVIDIA CUDA Samples
配套源码:
code/09/graph_benchmark.cu:短 Kernel 普通提交与 Graph Replay 基准;code/09/graph_pipeline_capture.cu:捕获 H2D、Kernel 链和 D2H;code/09/graph_update.cu:显式 Kernel 节点与可执行图参数更新;code/09/CMakeLists.txt:三份程序统一构建配置。
最后提醒:Graph 的接口、节点类型和更新能力仍在演进。部署在 CUDA 11.6 时,应以 11.6 归档文档和实际编译为准;升级工具链时,再对照当前文档评估新能力。不要仅凭搜索结果中的一段新版代码修改生产工程。