PyTorch CUDAGraph Trees 深度解析:基于 TorchInductor 的 CUDA 图内存池共享与动态图树机制
2026/9/24 13:53:35 网站建设 项目流程

PyTorch CUDAGraph Trees 深度解析:基于 TorchInductor 的 CUDA 图内存池共享与动态图树机制

【免费下载链接】pytorchTensors and Dynamic neural networks in Python with strong GPU acceleration项目地址: https://gitcode.com/GitHub_Trending/py/pytorch

导读

CUDAGraph Trees 是 PyTorch 编译栈(TorchDynamo + TorchInductor)在torch.compile(mode="reduce-overhead")模式下用于削减 GPU kernel 启动开销的核心优化。它通过在单个内存池上构建多分支 CUDA 图树,解决了传统 CUDA Graph 在任意控制流、跨图内存复用与输入变更场景下的三大痛点:内存无法复用、录制顺序被固定、以及图间依赖难以追踪。读完本文,你将掌握 CUDAGraph Trees 的底层录制/回放/重新录制流程、输入突变与动态形状支持策略、以及 NCCL 与图分区等进阶配置的完整用法。

背景:为什么需要 CUDAGraph Trees

CUDA Graph 的基本原理与限制

CUDA Graphs 自 CUDA 10 引入,其核心思想是把一串 CUDA kernel 捕获为单一操作单元(操作图),从而通过一次 CPU 操作启动多个 GPU 操作,显著降低逐个 kernel 启动带来的 CPU 开销。对于 CPU 开销占比高或单 kernel 计算量小的模型,CUDA Graphs 能带来可观的加速。

但这种"冻结录制现场"的机制也带来一系列硬性限制:

  • 任意控制流不可用:但通过torch.cond()表达的控制流可以被捕获进 CUDA Graph(参见 Data Dependent Control Flow);
  • 触发 host↔device 同步的 kernel 会报错(如.item());
  • 所有 kernel 输入参数被固定为录制时的值
  • CUDA 内存地址被固定(但地址上的数值可以变化);
  • 不允许关键 CPU 算子或 CPU 副作用

PyTorch 的 CUDAGraph 封装与缓存分配器

PyTorch 提供了torch.cuda.CUDAGraph便捷封装,处理与 PyTorch 缓存分配器(CachingAllocator)交互的棘手问题。录制期间,缓存分配器使用独立的内存池记录所有新分配:分配与释放行为与 eager 执行完全一致;回放时只调用 kernel,不触碰分配器。但这也意味着录制结束后,分配器并不知道用户程序中哪些内存仍在活跃使用。如果 eager 与 cudagraph 各自持有大量内存,独立内存池会增加程序整体内存占用

make_graphed_callables 与 TorchDynamo 旧集成的缺陷

make_graphed_callables通过"输出保持活跃"的机制在一系列 callable 之间安全共享单个内存池,但它要求固定的单一调用顺序(第一次运行的内存地址被"烧录"进后续录制)。

而在cudagraph_trees=False的旧 TorchDynamo 集成中,不同图捕获之间不复用内存,即使模型完全没有图中断也会带来严重的内存回退:前向与反向是两次独立的图捕获,内存池不共享,前向中保存的激活内存无法在反向中回收。

CUDAGraph Trees 集成:单内存池上的多分支图树

与 Graph Callables 类似,CUDAGraph Trees 也在所有图捕获间共享单个内存池,但不再要求单一调用序列,而是为不同的执行路径分别建立 CUDA 图录制树。看下面的示例:

@torch.compile(mode="reduce-overhead") def foo(x): # GRAPH 1 y = x * x * x # graph break triggered here if y.sum() > 0: # GRAPH 2 z = y ** y else: # GRAPH 3 z = (y.abs() ** y.abs()) torch._dynamo.graph_break() # GRAPH 4 return z * torch.rand_like(z) # the first run warms up each graph, which does things like CuBlas or Triton benchmarking foo(torch.arange(0, 10, device="cuda")) # The second run does a CUDA Graph recording, and replays it foo(torch.arange(0, 10, device="cuda")) # Finally we hit the optimized, CUDA Graph replay path foo(torch.arange(0, 10, device="cuda"))

该函数存在两条执行路径:1 -> 2 -> 41 -> 3 -> 4。CUDAGraph Trees 通过构建一条 CUDA 图录制"磁带"(这里是1 -> 2 -> 4)在单内存池内共享全部内存,并附加不变式(invariants)保证:

  • 内存始终位于录制时的同一位置,用户程序中不存在可能被覆盖的活跃张量;
  • CUDA Graph 的既有约束依然适用:同一 kernel 必须以相同参数(静态尺寸、地址等)调用;
  • 录制与回放必须观察相同的内存模式:若某张量在录制期间先于另一图死亡,回放时也必须如此;
  • 内存池中的活跃内存会在两次录制之间强制建立依赖关系
  • 这些录制只能按单一顺序调用:1 -> 2 -> 4

由于所有内存共享一个池,相对 eager 没有额外内存开销。

命中新路径时的内存池 checkpoint 机制

如果程序切换到新路径并运行 Graph 3 会发生什么?Graph 1 先被回放,随后遇到尚未录制的 Graph 3。图回放不会更新私有内存池,因此分配器中并不反映y的存在,直接录制会覆盖它。CUDAGraph Trees 的做法是:将内存池状态 checkpoint 回 Graph 1 结束时的状态,让活跃张量重新反映到缓存分配器中,然后安全地录制新图。

具体流程是:

  1. 先命中已录制的 Graph 1 的CUDAGraph.replay()快速路径;
  2. 遇到 Graph 3,需要先 warmup 一次(warmup 时内存地址未固定,Graph 4 也会退回 Inductor 非 cudagraph 调用);
  3. 第二次命中 Graph 3 时完成录制,由于输入内存地址已变化,Graph 4 也需要重新录制

于是录制集合生长为一棵真正的"CUDA 图树":

1 / \ 2 3 \ \ 4 4

从源码实现看,这一整套逻辑由 torch/_inductor/cudagraph_trees.py 中的CUDAGraphNode(单个图录制节点,含_record/run/_copy_inputs_and_remove_from_src等方法)与CUDAGraphTreeManager(负责"将独立的图录制或执行分组为录制树,并检查必要的不变式、管理图 warmup")协作完成。模块 docstring 明确指出:CUDAGraph Trees 支持A, BA, B'两种路径的高效执行,内存开销仅为max(mem(A, B), mem(A, B'))——这正是"树"而非"序列"的核心价值。而"从回放切回录制"依赖checkpoint pool state:回放只重放 CUDA 操作、不重建 CPU 侧分配器簿记,因此录制新子图前必须恢复这份簿记(见 cudagraph_trees.py 顶部注释与CUDAGraphTreeManagerdocstring)。

与 Graph Callables 的本质区别

对比维度make_graphed_callablesCUDAGraph Trees
内存池单一池共享单一池共享
调用顺序只能固定单一序列支持任意树形路径(A,BA,B'并存)
跨图存活性需手工保证输出存活自动追踪图 A 输出是否在运行图 B 时存活,据此决定是否新增 B 的录制
主要用途少量 callable 手工封装Dynamo 跨图中断场景(模块主要使用场景)

输入突变支持(Input Mutation Support)

输入突变指函数对输入张量进行原地写操作,例如:

def foo(x, y): # mutates input x x.add_(1) return x + y

这类函数对 CUDAGraph Trees 是挑战:由于 CUDA Graph 要求静态内存地址,对于每个输入张量x,CUDAGraph Trees 可能分配静态地址x',执行时先把x拷贝到x'再回放录制图。若函数原地修改了x',由于xx'地址不同,修改不会反映到用户的x上。

源码中的 check_for_mutation 是这一判断的实现:对 cudagraph trees 模式,它只把mutated_input_idxs既不是静态输入(参数/缓冲区)、也不是 CUDA 图录制张量输出的下标视为真正的突变输入,其余突变(如参数上的原地操作)被允许。

CUDAGraph Trees 对三类输入做了区分:

  • 来自 eager 的输入:假设每次执行的张量地址都可能变化,必须在录制与执行前拷贝到静态地址张量;
  • 参数与缓冲区(Parameters and buffers):假设(并在运行时检查)每次执行地址一致,无需拷贝内容;
  • CUDAGraph Trees 之前的输出张量:由于图输出地址固定,前一个图输出作为后一个图输入时地址也固定,同参数/缓冲区一样无需拷贝;运行时若发现地址不稳定,会触发重新录制(re-record)

因此,CUDAGraph Trees支持对参数/缓冲区、以及前序 CUDAGraph Trees 输出张量的突变。若对 eager 输入做突变,则会跳过 CUDAGraph 并输出skipping due to mutated inputs日志。下面是官方示例——第一个循环展示了"前序图输出作为突变目标"仍可走 CUDAGraph,最后一段则演示tmp.clone()破坏该链后回退:

import torch @torch.compile(mode="reduce-overhead") def foo(x): return x + 1 @torch.compile(mode="reduce-overhead") def mut(x): return x.add_(2) # Enable input mutation support torch._inductor.config.triton.cudagraph_support_input_mutation = True for i in range(3): torch.compiler.cudagraph_mark_step_begin() inp = torch.rand([4], device="cuda") # CUDAGraph is applied since `foo` does not mutate `inp` tmp = foo(inp) # Although `mut` mutates `tmp`, which is an output of a CUDAGraph # managed function. So CUDAGraph is still applied. mut(tmp) torch.compiler.cudagraph_mark_step_begin() inp = torch.rand([4], device="cuda") tmp = foo(inp) # While `tmp` is a CUDAGraph Tree managed function's output, `tmp.clone()` # is not. So CUDAGraph is not applied to `mut` and there is a log # `skipping cudagraphs due to mutated inputs` mut(tmp.clone())

若要为"突变 eager 输入"的函数启用 CUDAGraph Trees,需要改写函数以避免输入突变。

Note在 "reduce-overhead" 模式下,通过设置torch._inductor.config.triton.cudagraph_support_input_mutation = True启用输入突变支持(该配置项在 torch/_inductor/config.py 中定义,默认值not is_fbcode(),即非 fbcode 环境默认开启)。

动态形状支持(Dynamic Shape Support)

动态形状指输入张量在不同调用间形状不同。由于 CUDA Graph 要求固定张量地址,CUDAGraph Trees 会为每种唯一输入形状重新录制一次 CUDA Graph,从而让单个 Inductor 图对应多个 CUDAGraph。

  • 当形状种类有限(如推理场景的少量 batch size)时,重新录制是划算的;
  • 若形状频繁变化甚至每次调用都不同,重新录制可能得不偿失。文档还指出:CUDA 12.4 与 Driver 550+ 之前,每次 kernel 启动占用约 64 KB 设备内存,大量重新录制会显著放大该开销。

对应地,CUDAGraph Trees 也内建了防御机制:源码中cudagraph_dynamic_shape_warn_limit = 8(默认值,见 config.py)会在动态形状导致的图数量超过阈值时发出警告。

建议的规避策略:

  1. 将输入张量 padding 到少量固定形状,继续享受 CUDAGraph 收益;
  2. 设置torch._inductor.config.triton.cudagraph_skip_dynamic_graphs = True(定义于 config.py,默认False),跳过动态形状函数的 cudagraph,只对静态形状函数启用。

NCCL 支持

CUDAGraph Trees 支持包含 NCCL 算子的函数。CUDAGraph Trees 本身按每设备录制 CUDA Graph,而 NCCL 支持让跨设备通信得以在图内进行。示例:

@torch.compile(mode="reduce-overhead") def func(x): y = x * x y = torch.distributed.all_reduce(y, op=torch.distributed.ReduceOp.SUM) x = torch.nn.functional.silu(x) return x * y

跳过 CUDAGraph 的常见原因

由于 CUDA Graph 要求静态输入地址且不支持 CPU 算子,CUDAGraph Trees 会检查函数是否满足要求,必要时跳过并给出原因。常见原因如下:

  • 输入突变(Input mutation):原地修改 eager 输入的函数会被跳过;修改参数/缓冲区或 CUDAGraph Trees 管理函数输出仍被支持(见上文 Input Mutation Support 一节);
  • CPU 算子:包含 CPU 算子的函数被跳过。建议把函数拆分为多个函数,仅对纯 GPU 算子的函数应用 CUDAGraph Trees;
  • 多设备算子:包含跨多设备算子的函数被跳过(目前按设备粒度应用)。请使用 NCCL 等受支持的库做跨设备通信(见 NCCL Support 一节);
  • 自由 unbacked 符号(Free unbacked symbols):通常出现在动态形状场景中;目前 CUDAGraph Trees 为每种唯一输入形状录制一张图(见 Dynamic Shape Support 一节);
  • CUDAGraph 不安全的自定义算子:可能包含 cudagraph 不安全算子导致跳过(见下一节);
  • 不兼容算子(Incompatible operators):包含不兼容算子的函数会被跳过,需替换为受支持算子。

不兼容算子的完整清单如下(这些算子从根本上与 CUDA 图捕获不兼容——如 CPU 同步、动态内存分配等,见 torch/_inductor/utils.py 中FORBIDDEN_CUDAGRAPH_OPS的定义,且与文档、测试用例中出现的清单一致):

aten._fused_moving_avg_obs_fq_helper.default aten._fused_moving_avg_obs_fq_helper_functional.default aten.multinomial.default fbgemm.dense_to_jagged.default fbgemm.jagged_to_padded_dense.default run_and_save_rng_state run_with_rng_state aten._local_scalar_dense aten._assert_scalar

torch.are_deterministic_algorithms_enabled()时,以下算子同样不兼容:

aten._fused_moving_avg_obs_fq_helper.default aten._fused_moving_avg_obs_fq_helper_functional.default aten.multinomial.default fbgemm.dense_to_jagged.default fbgemm.jagged_to_padded_dense.default run_and_save_rng_state run_with_rng_state aten._local_scalar_dense aten._assert_scalar

此外,源码还额外将aten._linalg_eigh.default列入禁止清单(注释说明 cuSOLVER 支撑的 linalg.eigh 无法被 CUDA 图捕获),并在 ROCm 环境下追加aten.topk.default作为 workaround(见 utils.py)。

CUDAGraph 不安全的自定义算子(CUDAGraph Unsafe Custom Ops)

自定义算子默认被假定对 CUDAGraph 安全。但某些自定义算子可能包含 CPU 算子等不支持的操作。由于编译器把自定义算子视为黑盒,用户必须显式地通过torch._C.Tag.cudagraph_unsafe标签标记其为不安全,如下例所示。当函数包含 cudagraph 不安全自定义算子时,除非启用CUDAGraph partition,否则整个函数会被 CUDAGraph 跳过。

@torch.library.custom_op( "mylib::modify", mutates_args=(), tags=(torch._C.Tag.cudagraph_unsafe,), ) def modify(pic: torch.Tensor) -> torch.Tensor: pic1 = pic + 1 pic1_cpu = (pic1.cpu() + 1) * 2 return pic1_cpu.cuda() + pic @modify.register_fake def _(pic): return torch.empty_like(pic)

CUDAGraph 分区(CUDAGraph Partition)

如前所述,CUDA Graph 不支持 CPU 算子等操作,限制了其应用面。CUDAGraph partition是一种编译器解决方案:自动把不支持的算子切分出去,重排算子以减少分区数量,并对每个分区单独应用 CUDAGraph。启用方式为设置torch._inductor.config.graph_partition = True(该配置在 torch/_inductor/config.py 中定义,默认取环境变量TORCHINDUCTOR_GRAPH_PARTITION,非 fbcode 环境默认开启)。

考虑下面的例子:xy是 GPU 输入,而y_cpu是 CPU 张量。不启用分区时,整个函数因 CPU 算子必须被跳过;启用分区后,CPU 算子被切出,剩余 GPU 算子被 cudagraph 化,产生两张独立的 CUDA Graph:

def f(x, y): x1 = x + 1 y1 = y + 1 y_cpu = y1.cpu() + 1 z = x @ y return x1 + y1 + z + y_cpu.cuda()

当前 CUDAGraph partition 支持切出以下类型的算子:

  • 非 GPU 算子(Non-GPU Ops):典型如 CPU 张量上的计算;
  • 设备拷贝算子(Device Copy Ops):设备间数据传输,如示例中的y1.cpu()
  • 控制流算子(Control Flow Ops):控制流算子尚未被 CUDA Graph 支持,因此被切出;
  • CUDAGraph 不安全的自定义算子:带torch._C.Tag.cudagraph_unsafe标签的自定义算子被切出(见上文);
  • Unbacked Symints:参见 Dynamic Shape Support 一节。

源码侧还提供了与分区相关的配套调优参数,例如cudagraph_partition_memory_budget(默认1.1,见 config.py)、cudagraph_min_partition_size(分区节点数下限,低于阈值不 cudagraph 化,避免小分区录制/回放开销大于收益,见 config.py)以及reorder_for_reducing_graph_partitions(见 config.py)。

已知限制:跨迭代存活性与手动标记

由于 CUDA Graph 固定内存地址,它难以优雅地处理上一次调用遗留的存活张量。考虑如下推理基准代码:

import torch @torch.compile(mode="reduce-overhead") def my_model(x): y = torch.matmul(x, x) return y x = torch.randn(10, 10, device="cuda") y1 = my_model(x) y2 = my_model(x) print(y1) # RuntimeError: Error: accessing tensor output of CUDAGraphs that has been overwritten by a subsequent run.

在旧的 Separate CUDA Graph 实现中,第一次调用的输出会被第二次调用静默覆盖。CUDAGraph Trees 则既不想在迭代间引入导致错过热路径的额外依赖,也不想提前释放前一次调用的内存。其启发式规则是:

  • 推理:每次torch.compile调用都开启新迭代;
  • 训练:只要没有尚未执行的反向(pending backward),同样开启新迭代。

如果启发式判断错误,可以:

  1. torch.compiler.cudagraph_mark_step_begin()显式标记新一轮迭代的开始(其实现见 torch/compiler/init.py,内部转调 cudagraph_trees.py 中的mark_step_begin);或
  2. 在开始下一次运行前,于torch.compile之外手动 clone 上一轮迭代的张量。

如果需要用户可见输出跨迭代存活,可设置torch._inductor.config.triton.cudagraph_trees_generation_cloning = "user_visible"(定义于 config.py)。启用该 opt-in 行为后,CUDAGraph Trees 会在开启新 generation 前,把存活的用户可见输出 storage从 CUDAGraph 内存池中 clone 出来;这不适用于梯度、保存的激活或其他内部张量。该模式可能引入额外开销(跨 generation 存活的输出被显式拷贝),若开销敏感,可在不设置该选项的情况下手动 clone/copy 这些输出。

与 Separate CUDAGraph 的对比总结

FootgunsSeparate CudaGraphCUDAGraph Trees
内存可能增加每次图编译(新尺寸等)时仅当同时运行非 cudagraph 内存时
录制时机图的任何新调用都会重新录制仅当程序经过新的唯一路径时重新录制
Footguns某张图的调用会覆盖上一次调用无法在一次训练循环或一次推理运行之间跨运行持久化内存

结语与实操建议

CUDAGraph Trees 是torch.compile(mode="reduce-overhead")默认启用(config.triton.cudagraph_trees = True,见 config.py)的图内存池方案,其全部核心逻辑集中在 torch/_inductor/cudagraph_trees.py(约 3294 行,包含CUDAGraphNodeCUDAGraphTreeManagerCUDAWarmupNodeTreeManagerContainer等关键类),并在 torch/_inductor/compile_fx.py 的cudagraphify入口处根据该配置在"Trees"与旧实现之间切换。实际落地时可以按以下清单自查:

  1. 内存开销:确认没有同时存在大量 eager 分配,否则单池共享收益会被稀释;
  2. 输入突变:避免对 eager 输入做原地操作;参数/缓冲区与前序图输出的突变已被支持;
  3. 动态形状:形状种类有限时直接享受按形状重新录制;形状频繁变化时优先 padding 固定形状,或开启cudagraph_skip_dynamic_graphs
  4. 跨迭代存活输出:推理/训练循环外保留旧输出会触发RuntimeError,按需使用cudagraph_mark_step_begin()或 clone,必要时开启cudagraph_trees_generation_cloning
  5. 不安全算子:为自定义算子显式打上cudagraph_unsafe标签,或开启graph_partition让编译器自动切分。

结合上述配置与源码路径,读者可以在自己的模型上快速定位"为何没走 CUDAGraph 热路径",并据此做出正确的性能与内存取舍。

【免费下载链接】pytorchTensors and Dynamic neural networks in Python with strong GPU acceleration项目地址: https://gitcode.com/GitHub_Trending/py/pytorch

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询