资讯动态

CUDA Graph 的原理与实现

发布时间:2026/8/8 21:54:17 来源:尧图企业网站定制
一、要解决的问题CPU 启动开销与伪并行CUDA 的传统执行模型是流式启动stream launchCPU 逐个调用cudaMemcpyAsync、kernel launch把命令压入队列GPU 顺序消费。这个模型有两个结构性开销启动开销launch overhead每个 kernel launch 在 CPU 侧大约需要 3–10 μs驱动验证、参数打包、队列提交、门铃写入。当 kernel 本身只有几微秒时如小 batch 推理、HPC 中细粒度通信、GXF/Holoscan 这类高频 pipelineCPU 完全跟不上 GPU 的胃口——GPU 大量时间在等 CPU 做饭。依赖表达能力的浪费CPU 发射顺序是固定的即使逻辑上两个 kernel 没有依赖、可以并发只要它们在同一条流里顺序发射就天然串行。CPU 为了表达并行还得维护多条流 event 同步代码复杂、同步点又多。CUDA GraphCUDA 10 引入CUDA 11.3 后大幅完善的核心思想一句话概括把发射与执行解耦让 CPU 花一次代价把一整段工作含 kernel、memcpy、依赖拓扑描述成一个图实例化成 GPU 可直接重放的对象之后每次执行只需一次cudaGraphLaunch开销可低至 1–2 μs把整图作为一条命令交给 GPU。对 GPU 而言整个 graph 看起来就像一次启动CPU 完全退出热路径。这正是你之前问的 GXF/Holoscan 每帧调度、GPU-initiated networking 热循环里需要的东西。二、核心概念与数据模型2.1 三层对象cudaGraph_t 逻辑图节点 边的拓扑描述参数化的蓝图 │ cudaGraphInstantiate() ▼ cudaGraphExec_t 可执行图拓扑参数被固化、上传、优化后的 GPU 映像 │ cudaGraphLaunch() 可重复调用开销极低 ▼ GPU 硬件队列重放2.2 节点类型Node Types图的节点不只是 kernel覆盖面很广节点类型说明Kernel普通 kernelMemcpy / Memset设备内、D2H、H2D拷贝参数在实例化时固化Host Function图中嵌入 CPU 回调如日志、控制流判断Child Graph图嵌套图支持层级组合Conditional NodeCUDA 12.4条件分支/while 循环图内做控制流Event Record / Wait跨流的细粒度同步External Semaphore与 Vulkan/D3D 等外部 API 互操作Device Graph LaunchCUDA 12.0GPU 端 kernel 自己触发另一个 graphtail launch / fire-and-forget实现完全脱离 CPU 的图调度MemAlloc / MemFree图私有内存池的分配节点2.3 依赖边边表达的是执行顺序约束不是数据流。无依赖的节点 GPU 可以自动并发调度——调度器拿到的是整张 DAG而不是一串指令因此能做出流模型下 CPU 根本来不及做的并行决策。多个独立分支会映射到不同的硬件队列/SM 资源上同时跑。三、两条构建路径3.1 流捕获Stream Capture—— 主流方式不改算法代码用录制的方式把现有流操作录成图cudaGraph_t graph;cudaGraphExec_t graphExec;// 1. 先热身运行一次完成 cuBLAS/cuDNN 的算法选择、// workspace 分配、lazy module loading 等一次性副作用runMyPipeline(stream);// 普通执行cudaStreamSynchronize(stream);// 2. 开始捕获cudaStreamBeginCapture(stream,cudaStreamCaptureModeGlobal);runMyPipeline(stream);// 同样的代码这次不执行只被录下来// 3. 结束捕获得到逻辑图cudaStreamEndCapture(stream,graph);// 4. 实例化为可执行图只此一次的昂贵操作拓扑优化、参数固化、上传cudaGraphInstantiate(graphExec,graph,NULL,NULL,0);// 5. 热循环每次只是一次 launchfor(intiter0;iterN;iter){cudaGraphLaunch(graphExec,stream);// 微秒级}cudaStreamSynchronize(stream);捕获的约束是理解原理的关键捕获期间所有操作不真正执行只是被翻译成图节点。因此捕获路径上不能有cudaStreamSynchronize、同步版 memcpy、cudaMalloc同步分配等逼 CPU 和 GPU 对话的操作。指针参数被冻结捕获时 kernel 参数里的设备指针被原样固化为图参数。所以图重放要求输入输出缓冲区地址不变后面讲如何用内存池或cudaGraphExecKernelNodeSetParams变通。跨流捕获在捕获中对其他流做cudaEventRecord/Wait依赖关系会被如实录进图里形成分支拓扑——这是表达真实 pipeline 并行的方式。capture modeGlobal默认禁止任何线程的不安全API、ThreadLocal允许其他线程照常工作、Relaxed最宽松。推理框架常用 ThreadLocal 避免干扰其他线程。3.2 显式图 API完全手动搭拓扑适合动态生成图的场景调度器、DSL 编译器后端cudaGraphCreate(graph,0);cudaGraphNode_t k1,k2,k3;cudaKernelNodeParams params{/* func, gridDim, blockDim, args... */};cudaGraphAddKernelNode(k1,graph,NULL,0,params);cudaGraphAddKernelNode(k2,graph,k1,1,params);// k2 依赖 k1cudaGraphAddKernelNode(k3,graph,k1,1,params);// k3 依赖 k1// → k2、k3 无依赖重放时自动并发cudaGraphInstantiate(graphExec,graph,NULL,NULL,0);四、实例化Instantiate时到底发生了什么这是原理里最容易被略过、但决定性能的部分拓扑验证与优化驱动检查依赖合法性无环做拓扑排序把可并发的子图分发到不同硬件队列对 memcpy 节点选择最优拷贝引擎CE路径。参数固化与上传所有节点参数kernel 参数、grid/block 配置、memcpy 地址与长度被打包成 GPU 可直接消费的形式一次性写到设备内存。这正是重放时快的原因——重放时不再走CPU 逐个填命令包的路径GPU 前端按预上传的命令缓冲连续取指执行。图私有内存池cudaGraphInstantiateFlagDeviceLaunch/cudaDeviceGraphMemTrim相关机制下图可以申请自己专用的 device memory poolgraph 内部的临时分配MemAlloc 节点在重放间复用不进入全局分配器。重放开销一次cudaGraphLaunch≈ 一次 doorbell 指向命令缓冲的指针。实测端到端常把 CPU 侧每帧开销从几十微秒压到 1–2 μs。五、参数更新图不是铁板一块每帧输入指针/形状会变是常态推理框架、通信 pipeline 尤其如此不需要重新实例化5.1 原地修改 exec graph// 只改某个 kernel 节点的参数不重实例化cudaGraphExecKernelNodeSetParams(graphExec,node,newParams);或者整图级cudaGraphExecUpdate(graphExec,newGraph,errorNode,updateResult);// 驱动做拓扑 diff能复用就复用只有结构变化部分重新上传cudaGraphExecUpdate是 PyTorch/TensorRT 做 shape 变化处理的关键结构没变的部分零成本复用。5.2 内存池 固定地址约定更工程化的做法也是 GXF/Holoscan pipeline 的思路图内所有 buffer 从固定内存池分配指针永不变化每帧变化的数据拷贝进固定输入 buffer 再 launch graph。这样图永远不用更新重放成本恒为最低。六、高级机制6.1 Device Graph Launch设备端图启动kernel 内部直接cudaGraphLaunch()触发下一个图tail launch 保证后继图在当前图全部完成后启动。配合 conditional node可以在 GPU 上实现完整的推理→判断→再推理控制流CPU 在整个循环中完全不参与——对 MoE dispatch 这类路由结果决定下一步通信的场景这条路径与你关注的 GPU-initiated networking / GDAKI 思想同源决策留在设备端。6.2 Conditional NodeCUDA 12.4cudaGraphAddNode的cudaGraphNodeTypeConditional图内嵌if/while子图条件变量在 device memory 里由前面的 kernel 写入。while 节点可以表达直到收敛的迭代循环如解码器的迭代、PCG 求解器。6.3 多 GPU单图可以跨多个设备节点带 device 属性依赖边跨设备时驱动自动插入 P2P 拷贝/同步。但对 NCCL 集合通信主流做法仍是通信在图外、计算在图内或用 NCCL 自己的 graph 兼容注册NCCL 2.9 支持被捕获。七、典型收益与适用边界收益最大的场景每帧/每步由几十个到几千个小 kernel组成且 kernel 时长 ≲ 启动开销几微秒级推理 decode 阶段、Holoscan 传感器 pipeline每帧 10ms 内一串小算子、MD/HPC 短步长迭代。拓扑固定、运行次数极多实例化成本被摊薄到零。收益小甚至为负的场景kernel 本身毫秒级以上启动开销占比可忽略拓扑每步都变频繁 re-instantiate比直接发射还贵强依赖 CPU 中间决策除非用 conditional node/device launch 改造。量化参考PyTorch 官方 benchmark 中小模型推理启用 CUDA Graphs 后 CPU 开销下降一个数量级、端到端延迟改善 10–30% 是常态Holoscan Sensor Bridge 的 100G 数据路径正是靠固定图重放 固定内存池把每帧调度压到微秒级才能保证 GPUDirect RDMA 流水不断流。八、与你正在用的技术栈的关系一句话地图GXFGXF 的 scheduler 把 compute graphentity/component 图编译为执行计划其 GPU 执行后端正是 CUDA Graph——GXF 的图是算子依赖图CUDA Graph 是它在单个 GPU 上的物理落地形式。cuBLAS/cuDNN/TensorRT内部大量子图可直接被 stream capture 捕获前提是 warm-up 完成。NCCL / GPUNetIO通信部分要么留在图外要么通过 NCCL 的 capture 兼容模式被录进图GPUNetIO 的 device-side queue 则更进一步把通信本身也搬进设备端。九、最小完整示例#includecuda_runtime.h__global__voidscale(float*x,intn){intiblockIdx.x*blockDim.xthreadIdx.x;if(in)x[i]*2.0f;}intmain(){constintn120;float*d_x;cudaMalloc(d_x,n*sizeof(float));cudaStream_t s;cudaStreamCreate(s);cudaGraph_t g;cudaGraphExec_t ge;scale(n255)/256,256,0,s(d_x,n);// warm-upcudaStreamSynchronize(s);cudaStreamBeginCapture(s,cudaStreamCaptureModeGlobal);scale(n255)/256,256,0,s(d_x,n);// 录制cudaStreamEndCapture(s,g);cudaGraphInstantiate(ge,g,NULL,NULL,0);for(inti0;i10000;i)cudaGraphLaunch(ge,s);// 微秒级重放cudaStreamSynchronize(s);cudaGraphExecDestroy(ge);cudaGraphDestroy(g);cudaFree(d_x);return0;}一句话总结原理CUDA Graph 把 CPU 的发射循环预编译成 GPU 可自执行的重放对象。它不改变任何 kernel 的语义改变的是谁在什么时候做调度决策——从CPU 每步做决策变成CPU 一次性决策、GPU 自主重放代价是参数与拓扑的固化换来的是数量级的调度开销削减和拓扑感知的自动并行。如果你想深入某个方向——比如 conditional node 在 decode 循环里的具体用法、device graph launch 与 GDAKI 的结合、或者 PyTorchtorch.cuda.CUDAGraph的内存池实现细节——我可以单独展开。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价