快捷方式

CUDAGraph 树

CUDAGraph 背景

有关 CUDAGraphs 的更详细背景,请阅读使用 CUDAGraphs 加速 PyTorch

CUDA Graphs 在 CUDA 10 中首次亮相,它允许将一系列 CUDA 内核定义并封装为单个单元,即操作图,而不是单独启动操作的序列。它提供了一种通过单个 CPU 操作启动多个 GPU 操作的机制,从而减少了启动开销。

CUDA Graphs 可以提供很大的加速效果,尤其适用于 CPU 开销高或计算量小的模型。由于需要使用相同的参数和依赖项以及内存地址运行相同的内核,因此存在一些限制。

  • 不支持控制流

  • 触发主机到设备同步的内核(如 .item())会导致错误

  • 内核的所有输入参数都固定为记录时的值

  • CUDA 内存地址是固定的,但是这些地址处内存的值可以更改

  • 没有必要的 CPU 操作或 CPU 副作用

PyTorch CUDAGraph 集成

PyTorch 提供了一个便利包装器,用于处理 CUDAGraphs,它处理了与 PyTorch 的缓存分配器之间的几个棘手的交互。

CachingAllocator 使用单独的内存池来处理所有新的分配。在 CUDAGraph 记录期间,内存会像在急切运行期间一样被精确地计算、分配和释放。在重播时,仅调用内核,分配器不会发生任何变化。在初始记录之后,分配器不知道用户程序中哪些内存正在被积极使用。

在急切分配和 cudagraph 分配之间使用单独的内存池可能会增加程序的内存,如果为两者分配了大量内存,则可能会增加程序的内存。

创建图形化可调用对象

创建图形化可调用对象 是一个 PyTorch 抽象,用于在多个可调用对象上共享单个内存池。图形化可调用对象利用了这样一个事实,即在 CUDA 图记录期间,缓存分配器会精确地计算内存,从而能够在单独的 CUDA 图记录之间安全地共享内存。在每次调用中,输出都会保留为活动内存,防止一个可调用对象覆盖另一个可调用对象的活动内存。图形化可调用对象只能以单一顺序调用;第一次运行的内存地址会写入第二次运行,以此类推。

TorchDynamo 以前的 CUDA Graphs 集成

使用 cudagraph_trees=False 运行不会在单独的图形捕获之间重用内存,这会导致巨大的内存回归。即使对于没有图形断点的模型,这也是一个问题。正向和反向是单独的图形捕获,因此正向和反向的内存池不共享。特别是,正向中保存的激活内存无法在反向中回收。

CUDAGraph 树集成

与图形化可调用对象类似,CUDA 图树使用单个内存池来处理所有图形捕获。但是,CUDA 图树不是要求单一序列的调用,而是创建了单独的 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 -> 4,或 1 -> 3 -> 4。

通过构建 CUDA 图记录的磁带,我们可以在单独的记录之间共享单个内存池中的所有内存,在本例中,是 1 -> 2 -> 4。我们添加了不变式以确保内存始终与记录时的位置相同,并且用户程序中不存在可能被覆盖的活动张量。

  • CUDA Graphs 的相同约束适用:必须使用相同的参数(静态大小、地址等)调用相同的内核

  • 记录和重播之间必须观察到相同的内存模式:如果一个图形的张量输出在记录期间在另一个图形之后消失,则它在重播期间也必须消失。

  • CUDA 池中的活动内存会在两个记录之间强制执行依赖关系

  • 这些记录只能以单一顺序 1 -> 2 -> 4 调用

所有内存都在单个内存池中共享,因此与急切模式相比没有额外的内存开销。现在,如果我们遇到新的路径并运行图形 3 会发生什么?

图形 1 会被重播,然后我们遇到图形 3,我们还没有记录它。在图形重播期间,私有内存池不会更新,因此 y 没有反映在分配器中。如果没有注意,我们就会覆盖它。为了支持在重播其他图形后重用同一个内存池,我们将内存池回滚到图形 1 结束时的状态。现在,我们的活动张量已反映在缓存分配器中,因此我们可以安全地运行新图形。

首先,我们会遇到已在图形 1 中记录的优化过的 CUDAGraph.replay() 路径。然后,我们会遇到图形 3。与之前一样,我们需要在记录之前预热一次该图形。在预热运行中,内存地址没有固定,因此图形 4 也会回退到归纳器,非 cudagraph 调用。

第二次命中图 3 时,我们已经预热并准备好了记录。我们记录图 3,然后再次记录图 4,因为输入内存地址已更改。这创建了一个 CUDA 图记录树,一个 CUDA 图树!

  1
 / \\
2   3
 \\   \\
  4   4

限制

由于 CUDA 图会固定内存地址,因此 CUDA 图在处理来自先前调用的实时张量方面没有很好的方法。

假设我们正在用以下代码对运行推理进行基准测试

import torch

@torch.compile(mode="reduce-overhead")
def my_model(x):
    y = torch.matmul(x, x)
    return y

x = torch.randn(10, 10)
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.

在单独的 CUDA 图实现中,第一次调用的输出将被第二次调用覆盖。在 CUDA 图树中,我们不想在迭代之间添加意外的依赖关系,这会导致我们无法命中热点路径,也不想过早地释放先前调用的内存。我们的启发式方法是在推理中,每次调用 torch.compile 时都开始一个新的迭代,而在训练中,只要没有未被调用的待处理反向传播,我们也会这样做。如果这些启发式方法错误,则可以使用 torch.compiler.mark_step_begin() 标记新迭代的开始,或者在开始下一次运行之前克隆先前迭代的张量(在 torch.compile 之外)。

比较

陷阱

单独的 CudaGraph

CUDAGraph 树

内存可能会增加

在每次图编译(新大小等)时

如果您还在运行非 cudagraph 内存

记录

在每次新的图调用时

将在您在程序中采取的任何新的、唯一的路径上重新记录

陷阱

一个图的调用将覆盖先前的调用

无法在模型的单独运行之间持久化内存 - 一个训练循环训练,或一次推理运行

文档

访问 PyTorch 的全面开发人员文档

查看文档

教程

获取面向初学者和高级开发人员的深入教程

查看教程

资源

查找开发资源并获得问题的解答

查看资源