Shortcuts

CUDA 语义

torch.cuda 用于设置和运行CUDA操作。它会跟踪当前选择的GPU,并且您分配的所有CUDA张量默认情况下都将在该设备上创建。可以通过 torch.cuda.device 上下文管理器来更改选定的设备。

然而,一旦张量被分配,你可以在其上进行操作,而不受所选设备的影响,并且结果将始终放置在与张量相同的设备上。

默认情况下,不允许跨GPU操作,但以下情况除外: copy_() 以及其他具有类似复制功能的方法, 例如 to()cuda()。 除非您启用点对点内存访问,否则任何尝试在分布在不同设备上的张量上启动操作的尝试都会引发错误。

下面你可以找到一个小例子来展示这一点:

cuda = torch.device('cuda')     # 默认的CUDA设备
cuda0 = torch.device('cuda:0')
cuda2 = torch.device('cuda:2')  # GPU 2 (这些是基于0索引的)

x = torch.tensor([1., 2.], device=cuda0)
# x.device 是 device(type='cuda', index=0)
y = torch.tensor([1., 2.]).cuda()
# y.device 是 device(type='cuda', index=0)

with torch.cuda.device(1):
    # 在GPU 1上分配一个张量
    a = torch.tensor([1., 2.], device=cuda)

    # 将张量从CPU传输到GPU 1
    b = torch.tensor([1., 2.]).cuda()
    # a.device 和 b.device 是 device(type='cuda', index=1)

    # 你也可以使用 ``Tensor.to`` 来传输张量:
    b2 = torch.tensor([1., 2.]).to(device=cuda)
    # b.device 和 b2.device 是 device(type='cuda', index=1)

    c = a + b
    # c.device 是 device(type='cuda', index=1)

    z = x + y
    # z.device 是 device(type='cuda', index=0)

    # 即使在上下文中,你也可以指定设备
    # (或者在 .cuda 调用中给出GPU索引)
    d = torch.randn(2, device=cuda2)
    e = torch.randn(2).to(cuda2)
    f = torch.randn(2).cuda(cuda2)
    # d.device, e.device, 和 f.device 都是 device(type='cuda', index=2)

TensorFloat-32 (TF32) 在 Ampere(及更高版本)设备上

从 PyTorch 1.7 开始,有一个名为 allow_tf32 的新标志。此标志在 PyTorch 1.7 到 PyTorch 1.11 中默认为 True,在 PyTorch 1.12 及更高版本中默认为 False。此标志控制 PyTorch 是否允许在内部使用自 Ampere 以来在 NVIDIA GPU 上可用的 TensorFloat32 (TF32) 张量核心来计算 matmul(矩阵乘法和批量矩阵乘法)和卷积。

TF32 tensor cores 旨在通过将输入数据舍入为具有10位尾数,并以FP32精度累积结果,保持FP32动态范围,从而在torch.float32张量上的matmul和卷积操作中实现更好的性能。

矩阵乘法和卷积是分别控制的,它们的对应标志可以在以下位置访问:

# 下面的标志控制是否允许在matmul上使用TF32。在PyTorch 1.12及更高版本中,此标志默认为False。
torch.backends.cuda.matmul.allow_tf32 = True

# 下面的标志控制是否允许在cuDNN上使用TF32。此标志默认为True。
torch.backends.cudnn.allow_tf32 = True

matmuls 的精度也可以更广泛地设置(不仅限于 CUDA),通过 set_float_32_matmul_precision()。 请注意,除了 matmuls 和卷积本身之外,内部使用 matmuls 或卷积的函数和 nn 模块也会受到影响。这些包括 nn.Linearnn.Conv*、cdist、tensordot、 仿射网格和网格采样、自适应对数 softmax、GRU 和 LSTM。

要了解精度和速度,请参见下面的示例代码和基准数据(在A100上):

a_full = torch.randn(10240, 10240, dtype=torch.double, device='cuda')
b_full = torch.randn(10240, 10240, dtype=torch.double, device='cuda')
ab_full = a_full @ b_full
mean = ab_full.abs().mean()  # 80.7277

a = a_full.float()
b = b_full.float()

# 在TF32模式下进行矩阵乘法。
torch.backends.cuda.matmul.allow_tf32 = True
ab_tf32 = a @ b  # 在GA100上耗时0.016秒
error = (ab_tf32 - ab_full).abs().max()  # 0.1747
relative_error = error / mean  # 0.0022

# 在禁用TF32的情况下进行矩阵乘法。
torch.backends.cuda.matmul.allow_tf32 = False
ab_fp32 = a @ b  # 在GA100上耗时0.11秒
error = (ab_fp32 - ab_full).abs().max()  # 0.0031
relative_error = error / mean  # 0.000039

从上面的例子中,我们可以看到,启用TF32后,在A100上的速度大约快了7倍,并且与双精度相比,相对误差大约大了2个数量级。需要注意的是,TF32与单精度速度的确切比率取决于硬件代次,因为诸如内存带宽与计算的比率以及TF32与FP32 matmul吞吐量的比率等属性可能会因代次或模型而异。 如果需要完整的FP32精度,用户可以通过以下方式禁用TF32:

torch.backends.cuda.matmul.allow_tf32 = False
torch.backends.cudnn.allow_tf32 = False

要在C++中关闭TF32标志,你可以执行

at::globalContext().setAllowTF32CuBLAS(false);
at::globalContext().setAllowTF32CuDNN(false);

有关TF32的更多信息,请参见:

FP16 GEMMs中的降低精度减少

fp16 GEMMs 可能会使用一些中间的降低精度的归约(例如,使用 fp16 而不是 fp32)。这些有选择的精度降低可以在某些工作负载(特别是那些具有较大 k 维度的工作负载)和 GPU 架构上实现更高的性能,但代价是数值精度和潜在的溢出风险。

一些关于V100的示例基准数据:

[--------------------------- bench_gemm_transformer --------------------------]
      [  m ,  k  ,  n  ]    |  allow_fp16_reduc=True  |  allow_fp16_reduc=False
1 线程: --------------------------------------------------------------------
      [4096, 4048, 4096]    |           1634.6        |           1639.8
      [4096, 4056, 4096]    |           1670.8        |           1661.9
      [4096, 4080, 4096]    |           1664.2        |           1658.3
      [4096, 4096, 4096]    |           1639.4        |           1651.0
      [4096, 4104, 4096]    |           1677.4        |           1674.9
      [4096, 4128, 4096]    |           1655.7        |           1646.0
      [4096, 4144, 4096]    |           1796.8        |           2519.6
      [4096, 5096, 4096]    |           2094.6        |           3190.0
      [4096, 5104, 4096]    |           2144.0        |           2663.5
      [4096, 5112, 4096]    |           2149.1        |           2766.9
      [4096, 5120, 4096]    |           2142.8        |           2631.0
      [4096, 9728, 4096]    |           3875.1        |           5779.8
      [4096, 16384, 4096]   |           6182.9        |           9656.5
(时间  微秒).

如果需要完全精度缩减,用户可以通过以下方式在fp16 GEMMs中禁用降低精度缩减:

torch.backends.cuda.matmul.allow_fp16_reduced_precision_reduction = False

要在C++中切换降低精度减少标志,可以执行以下操作

at::globalContext().setAllowFP16ReductionCuBLAS(false);

在BF16 GEMMs中的降低精度减少

类似的标志(如上所述)存在于BFloat16 GEMM中。 请注意,此开关在BF16中默认设置为True,如果您在工作中发现数值不稳定,您可能希望将其设置为False

如果不希望使用降低精度的缩减操作,用户可以通过以下方式在bf16 GEMMs中禁用降低精度的缩减操作:

torch.backends.cuda.matmul.allow_bf16_reduced_precision_reduction = False

要在C++中切换降低精度的减少标志,可以执行以下操作

at::globalContext().setAllowBF16ReductionCuBLAS(true);

异步执行

默认情况下,GPU操作是异步的。当你调用一个使用GPU的函数时,操作会被排队到特定的设备上,但不一定会在调用后立即执行。这使得我们能够并行执行更多的计算,包括在CPU或其他GPU上的操作。

一般来说,异步计算的效果对调用者是不可见的,因为(1)每个设备按队列顺序执行操作,并且(2)PyTorch在CPU和GPU之间或两个GPU之间复制数据时自动执行必要的同步。因此,计算将像每个操作都是同步执行一样进行。

您可以通过设置环境变量 CUDA_LAUNCH_BLOCKING=1 来强制同步计算。这在GPU上发生错误时非常方便。 (在异步执行的情况下,此类错误直到操作实际执行后才会报告,因此堆栈跟踪不会显示请求的位置。)

异步计算的一个结果是,没有同步的时间测量是不准确的。为了获得精确的测量结果,应该在测量之前调用torch.cuda.synchronize(),或者使用torch.cuda.Event来记录时间,如下所示:

start_event = torch.cuda.Event(enable_timing=True)
end_event = torch.cuda.Event(enable_timing=True)
start_event.record()

# 在这里运行一些东西

end_event.record()
torch.cuda.synchronize()  # 等待事件被记录!
elapsed_time_ms = start_event.elapsed_time(end_event)

作为一种例外,几个函数如 to()copy_() 允许显式的 non_blocking 参数, 这使得调用者可以在不需要时绕过同步。 另一个例外是CUDA流,如下所述。

CUDA 流

一个CUDA流是属于特定设备的线性执行序列。通常情况下,您不需要显式创建一个:默认情况下,每个设备使用其自己的“默认”流。

每个流中的操作按创建顺序进行序列化,但不同流的操作可以按任何相对顺序并发执行,除非使用显式同步函数(例如 synchronize()wait_stream())。例如,以下代码是不正确的:

cuda = torch.device('cuda')
s = torch.cuda.Stream()  # 创建一个新的流。
A = torch.empty((100, 100), device=cuda).normal_(0.0, 1.0)
with torch.cuda.stream(s):
    # sum() 可能在 normal_() 完成之前开始执行!
    B = torch.sum(A)

当“当前流”是默认流时,PyTorch会在数据移动时自动执行必要的同步,如上所述。然而,当使用非默认流时,确保适当的同步是用户的责任。这个例子的修正版本是:

cuda = torch.device('cuda')
s = torch.cuda.Stream()  # 创建一个新的流。
A = torch.empty((100, 100), device=cuda).normal_(0.0, 1.0)
s.wait_stream(torch.cuda.default_stream(cuda))  # 新!
with torch.cuda.stream(s):
    B = torch.sum(A)
A.record_stream(s)  # 新!

有两个新增加的内容。torch.cuda.Stream.wait_stream() 调用确保在开始在侧流上运行 sum(A) 之前,normal_() 的执行已经完成。torch.Tensor.record_stream()(详见)确保在 sum(A) 完成之前不会释放 A。你也可以在稍后的某个时间点手动等待流,使用 torch.cuda.default_stream(cuda).wait_stream(s)(注意,立即等待是没有意义的,因为这将阻止流执行与默认流上的其他工作并行运行。)有关何时使用其中一个的更多细节,请参阅 torch.Tensor.record_stream() 的文档。

请注意,即使在没有读取依赖性的情况下,这种同步也是必要的,例如,如本例所示:

cuda = torch.device('cuda')
s = torch.cuda.Stream()  # 创建一个新的流。
A = torch.empty((100, 100), device=cuda)
s.wait_stream(torch.cuda.default_stream(cuda))  # 仍然需要!
with torch.cuda.stream(s):
    A.normal_(0.0, 1.0)
    A.record_stream(s)

尽管在s上的计算没有读取A的内容,并且没有其他对A的使用,仍然需要进行同步,因为A可能对应于由CUDA缓存分配器重新分配的内存,并且旧的(已释放的)内存可能有未完成的操作。

反向传播的流语义

每个向后 CUDA 操作运行在与相应向前操作相同的流上。 如果您的向前传递在不同的流上并行运行独立的操作, 这有助于向后传递利用相同的并行性。

相对于周围操作的向后调用的流语义与其他调用相同。即使如前一段所述,向后操作在多个流上运行,向后传递也会插入内部同步以确保这一点。更具体地说,当调用 autograd.backwardautograd.gradtensor.backward, 并可选地提供CUDA张量作为初始梯度(例如, autograd.backward(..., grad_tensors=initial_grads)autograd.grad(..., grad_outputs=initial_grads)tensor.backward(..., gradient=initial_grad)), 这些操作的

  1. 可选地填充初始梯度(s),

  2. 调用反向传播,并且

  3. 使用梯度

与任何一组操作具有相同的流语义关系:

s = torch.cuda.Stream()

# 安全,梯度在反向传播的同一流上下文中使用
with torch.cuda.stream(s):
    loss.backward()
    use grads

# 不安全
with torch.cuda.stream(s):
    loss.backward()
use grads

# 安全,带有同步
with torch.cuda.stream(s):
    loss.backward()
torch.cuda.current_stream().wait_stream(s)
use grads

# 安全,填充初始梯度和调用反向传播在同一流上下文中
with torch.cuda.stream(s):
    loss.backward(gradient=torch.ones_like(loss))

# 不安全,填充初始梯度和调用反向传播在不同的流上下文中,没有同步
initial_grad = torch.ones_like(loss)
with torch.cuda.stream(s):
    loss.backward(gradient=initial_grad)

# 安全,带有同步
initial_grad = torch.ones_like(loss)
s.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(s):
    initial_grad.record_stream(s)
    loss.backward(gradient=initial_grad)

BC 注意:在默认流上使用梯度

在PyTorch的早期版本(1.9及更早版本)中,autograd引擎总是将默认流与所有反向操作同步,因此以下模式:

with torch.cuda.stream(s):
    loss.backward()
使用 梯度

只要在默认流上发生 use grads,就是安全的。 在当前的 PyTorch 中,这种模式不再安全。如果 backward()use grads 处于不同的流上下文中,你必须同步流:

with torch.cuda.stream(s):
    loss.backward()
torch.cuda.current_stream().wait_stream(s)
使用 grads

即使 use grads 在默认流上。

内存管理

PyTorch 使用一个缓存内存分配器来加速内存分配。这允许快速内存释放而无需设备同步。然而,分配器管理的未使用内存仍会显示为已使用在 nvidia-smi 中。您可以使用 memory_allocated()max_memory_allocated() 来监控张量占用的内存,并使用 memory_reserved()max_memory_reserved() 来监控缓存分配器管理的总内存量。调用 empty_cache() 释放 PyTorch 中所有 未使用 的缓存内存,以便其他 GPU 应用程序可以使用这些内存。然而,张量占用的 GPU 内存不会被释放,因此它不能增加 PyTorch 可用的 GPU 内存量。

为了更好地理解CUDA内存随时间的使用情况, 理解CUDA内存使用 描述了用于捕获和可视化内存使用痕迹的工具。

对于更高级的用户,我们通过 memory_stats()提供更全面的内存基准测试。我们还提供通过 memory_snapshot()捕获内存分配器状态的完整快照的能力,这可以帮助您理解代码生成的底层分配模式。

使用PYTORCH_CUDA_ALLOC_CONF优化内存使用

使用缓存分配器可能会干扰诸如 cuda-memcheck 之类的内存检查工具。要使用 cuda-memcheck 调试内存错误,请在您的环境中设置 PYTORCH_NO_CUDA_MEMORY_CACHING=1 以禁用缓存。

缓存分配器的行为可以通过环境变量 PYTORCH_CUDA_ALLOC_CONF 来控制。 格式为 PYTORCH_CUDA_ALLOC_CONF=<选项>:<值>,<选项2>:<值2>... 可用选项:

  • backend 允许选择底层分配器实现。 目前,有效选项包括 native,它使用 PyTorch 的原生实现,以及 cudaMallocAsync,它使用 CUDA 的内置异步分配器cudaMallocAsync 需要 CUDA 11.4 或更新版本。默认值为 nativebackend 适用于进程使用的所有设备,并且不能在每个设备的基础上指定。

  • max_split_size_mb 防止原生分配器 分割大于此大小(以MB为单位)的块。这可以减少 碎片化,并可能允许一些边缘工作负载在 不耗尽内存的情况下完成。性能成本可以从“零”到“显著” 不等,具体取决于分配模式。默认值是无限的,即所有块 都可以被分割。memory_stats()memory_summary() 方法对于调优很有用。此 选项应作为最后手段用于因“内存不足”而中止 并显示大量非活动分割块的工作负载。 max_split_size_mb 仅在使用 backend:native 时有意义。 使用 backend:cudaMallocAsync 时,max_split_size_mb 被忽略。

  • roundup_power2_divisions 有助于将请求的分配大小四舍五入到最近的2的幂次方分割,从而更好地利用块。在原生的CUDACachingAllocator中,大小以512的块大小为单位向上取整,因此这对于较小的尺寸效果很好。然而,这对于较大的临近分配可能效率不高,因为每个分配都会进入不同大小的块,从而最小化了这些块的重用。这可能会创建大量未使用的块,并浪费GPU内存容量。此选项启用将分配大小四舍五入到最近的2的幂次方分割。例如,如果我们需要将大小1200四舍五入,并且分割数为4,则大小1200介于1024和2048之间,如果我们在这两者之间进行4次分割,则值为1024、1280、1536和1792。因此,分配大小1200将被四舍五入到1280,作为最近的2的幂次方分割的上限。 指定一个单一值以应用于所有分配大小,或指定一组键值对以分别设置每个2的幂次方区间的分割。例如,要为所有小于256MB的分配设置1个分割,为256MB到512MB之间的分配设置2个分割,为512MB到1GB之间的分配设置4个分割,并为任何更大的分配设置8个分割,请将旋钮值设置为:[256:1,512:2,1024:4,>:8]。 roundup_power2_divisions 仅在使用 backend:native 时有意义。 在使用 backend:cudaMallocAsync 时,roundup_power2_divisions 将被忽略。

  • garbage_collection_threshold 有助于主动回收未使用的 GPU 内存,以避免触发昂贵的同步和回收所有操作(release_cached_blocks), 这对于延迟敏感的 GPU 应用程序(例如服务器)可能是不利的。 设置此阈值(例如,0.8)后,如果 GPU 内存使用量超过阈值(即, 分配给 GPU 应用程序的总内存的 80%),分配器将开始回收 GPU 内存块。该算法倾向于首先释放旧的和未使用的块,以避免释放正在积极重用的块。阈值值应大于 0.0 且小于 1.0。 garbage_collection_threshold 仅在使用 backend:native 时有意义。 使用 backend:cudaMallocAsync 时,garbage_collection_threshold 将被忽略。

  • expandable_segments(实验性,默认值:False)如果设置为True,此设置会指示分配器创建可以稍后扩展的CUDA分配,以更好地处理作业频繁更改分配大小的情况,例如批量大小变化的情况。通常对于大于2MB的分配,分配器会调用cudaMalloc来获取与用户请求大小相同的分配。将来,如果这些分配的一部分是空闲的,则可以将其重新用于其他请求。当程序对完全相同大小的请求或大小为该大小的倍数的请求进行多次请求时,这非常有效。许多深度学习模型遵循这种行为。然而,一个常见的例外是当批量大小从一个迭代到下一个迭代略有变化时,例如在批量推理中。当程序最初以批量大小N运行时,它将进行适合该大小的分配。如果在将来,它以大小N - 1运行,现有的分配仍然足够大。然而,如果它以大小N + 1运行,则必须进行稍微大一些的新分配。并非所有张量的大小都相同。有些可能是(N + 1)*A,而其他可能是(N + 1)*A*B,其中AB是模型中的一些非批量维度。由于分配器在现有分配足够大时会重用它们,因此一些(N + 1)*A分配实际上会适合已经存在的N*B*A段,尽管不是完美匹配。随着模型的运行,它将部分填充所有这些段,留下这些段末尾不可用的空闲内存切片。分配器在某个时候将需要cudaMalloc一个新的(N + 1)*A*B段。如果没有足够的内存,现在无法回收现有段末尾的空闲内存切片。对于50多层深的模型,这种模式可能会重复50多次,创建许多碎片。

    expandable_segments 允许分配器最初创建一个段,然后在需要更多内存时扩展其大小。与其为每次分配创建一个段,它尝试创建一个段(每个流),并根据需要增长。现在当 N + 1 情况运行时,分配将很好地平铺到一个大段中,直到它填满。然后请求更多内存并附加到段的末尾。这个过程不会创建那么多不可用的内存碎片,因此更有可能成功找到这块内存。

    pinned_use_cuda_host_register 选项是一个布尔标志,用于确定是否使用 CUDA API 的 cudaHostRegister 函数来分配固定内存,而不是默认的 cudaHostAlloc。当设置为 True 时,内存使用常规的 malloc 分配,然后在调用 cudaHostRegister 之前将页面映射到内存中。这种页面的预映射有助于减少在执行 cudaHostRegister 期间的锁定时间。

    pinned_num_register_threads 选项仅在 pinned_use_cuda_host_register 设置为 True 时有效。默认情况下,使用一个线程来映射页面。此选项允许使用更多线程并行化页面映射操作,以减少固定内存的总体分配时间。根据基准测试结果,此选项的良好值为 8。

注意

CUDA 内存管理 API 报告的一些统计数据是特定于 backend:native 的,并且在 backend:cudaMallocAsync 下没有意义。 详情请参阅每个函数的文档字符串。

使用自定义内存分配器进行CUDA

可以在 C/C++ 中将分配器定义为简单的函数,并将它们编译为共享库,下面的代码展示了一个仅跟踪所有内存操作的基本分配器。

#include 
#include 
#include 
// 使用 g++ 编译 alloc.cc -o alloc.so -I/usr/local/cuda/include -shared -fPIC
extern "C" {
void* my_malloc(ssize_t size, int device, cudaStream_t stream) {
   void *ptr;
   cudaMalloc(&ptr, size);
   std::cout<<"alloc "<<ptr<<size<<std::endl;
   return ptr;
}

void my_free(void* ptr, ssize_t size, int device, cudaStream_t stream) {
   std::cout<<"free "<<ptr<< " "<<stream<<std::endl;
   cudaFree(ptr);
}
}

这可以通过 torch.cuda.memory.CUDAPluggableAllocator 在 Python 中使用。 用户负责提供 .so 文件的路径以及与上述签名匹配的分配/释放函数的名称。

import torch

# 加载分配器
new_alloc = torch.cuda.memory.CUDAPluggableAllocator(
    'alloc.so', 'my_malloc', 'my_free')
# 交换当前分配器
torch.cuda.memory.change_current_allocator(new_alloc)
# 这将使用新的分配器在设备上分配内存
b = torch.zeros(10, device='cuda')
import torch

# 执行初始内存分配器
b = torch.zeros(10, device='cuda')
# 加载分配器
new_alloc = torch.cuda.memory.CUDAPluggableAllocator(
    'alloc.so', 'my_malloc', 'my_free')
# 这将出错,因为当前分配器已经被实例化
torch.cuda.memory.change_current_allocator(new_alloc)

cuBLAS 工作空间

对于每个cuBLAS句柄和CUDA流的组合,如果该句柄和流组合执行需要工作空间的cuBLAS内核,则会分配一个cuBLAS工作空间。为了避免重复分配工作空间,这些工作空间不会被释放,除非调用torch._C._cuda_clearCublasWorkspaces()。每个分配的工作空间大小可以通过环境变量CUBLAS_WORKSPACE_CONFIG指定,格式为:[SIZE]:[COUNT]。例如,每个分配的默认工作空间大小为CUBLAS_WORKSPACE_CONFIG=:4096:2:16:8,它指定了一个总大小为2 * 4096 + 8 * 16 KiB。要强制cuBLAS避免使用工作空间,请设置CUBLAS_WORKSPACE_CONFIG=:0:0

cuFFT 计划缓存

对于每个CUDA设备,使用一个cuFFT计划的LRU缓存来加速重复运行FFT方法(例如,torch.fft.fft())在具有相同几何形状和相同配置的CUDA张量上。由于某些cuFFT计划可能会分配GPU内存,这些缓存具有最大容量。

您可以使用以下API控制和查询当前设备的缓存属性:

  • torch.backends.cuda.cufft_plan_cache.max_size 给出了缓存的容量(在CUDA 10及更新版本上默认为4096,在旧版CUDA上默认为1023)。直接设置此值会修改缓存容量。

  • torch.backends.cuda.cufft_plan_cache.size 给出了当前缓存中驻留的计划数量。

  • torch.backends.cuda.cufft_plan_cache.clear() 清除缓存。

要控制和查询非默认设备的计划缓存,可以使用torch.device对象或设备索引对torch.backends.cuda.cufft_plan_cache对象进行索引,并访问上述属性之一。例如,要设置设备1的缓存容量,可以写成torch.backends.cuda.cufft_plan_cache[1].max_size = 10

即时编译

PyTorch 在 CUDA 张量上执行某些操作(如 torch.special.zeta)时,会进行即时编译。这种编译可能会耗费时间(根据您的硬件和软件,可能需要几秒钟),并且对于单个操作符可能会发生多次,因为许多 PyTorch 操作符实际上会从多种内核中选择,每个内核都必须根据其输入进行一次编译。这种编译在每个进程中发生一次,或者如果使用内核缓存,则只发生一次。

默认情况下,PyTorch会在$XDG_CACHE_HOME/torch/kernels中创建一个内核缓存,如果定义了XDG_CACHE_HOME,则会在$HOME/.cache/torch/kernels中创建(Windows除外,Windows目前尚不支持内核缓存)。可以通过两个环境变量直接控制缓存行为。如果将USE_PYTORCH_KERNEL_CACHE设置为0,则不会使用缓存,如果设置了PYTORCH_KERNEL_CACHE_PATH,则将使用该路径作为内核缓存,而不是默认位置。

最佳实践

设备无关代码

由于 PyTorch 的结构,您可能需要显式编写与设备无关(CPU 或 GPU)的代码;例如,创建一个新的张量作为循环神经网络的初始隐藏状态。

第一步是确定是否应该使用GPU。一个常见的模式是使用Python的argparse模块来读取用户参数,并有一个标志可以用来禁用CUDA,结合is_available()。在下面,args.device生成一个torch.device对象,可以用来将张量移动到CPU或CUDA。

import argparse
import torch

parser = argparse.ArgumentParser(description='PyTorch示例')
parser.add_argument('--disable-cuda', action='store_true',
                    help='禁用CUDA')
args = parser.parse_args()
args.device = None
if not args.disable_cuda and torch.cuda.is_available():
    args.device = torch.device('cuda')
else:
    args.device = torch.device('cpu')

注意

在评估给定环境中CUDA的可用性时(is_available()),PyTorch的默认行为是调用CUDA Runtime API方法cudaGetDeviceCount。因为此调用会反过来初始化CUDA Driver API(通过cuInit),如果它尚未初始化,则在运行了is_available()的进程的后续分叉将失败,并出现CUDA初始化错误。

可以在导入执行is_available()的PyTorch模块之前(或在直接执行它之前)在您的环境中设置PYTORCH_NVML_BASED_CUDA_CHECK=1,以便将is_available()引导为尝试基于NVML的评估(nvmlDeviceGetCount_v2)。如果基于NVML的评估成功(即NVML发现/初始化未失败),is_available()调用将不会毒化后续的分叉。

如果NVML发现/初始化失败,is_available()将回退到标准的CUDA Runtime API评估,并且上述的分叉约束将适用。

请注意,上述基于NVML的CUDA可用性评估提供的保证比默认的CUDA Runtime API方法(需要CUDA初始化成功)要弱。在某些情况下,基于NVML的检查可能会成功,但随后的CUDA初始化可能会失败。

现在我们已经有了 args.device,我们可以使用它来在所需的设备上创建一个张量。

x = torch.empty((8, 42), device=args.device)
net = Network().to(device=args.device)

这可以在多种情况下使用,以生成与设备无关的代码。下面是使用数据加载器时的示例:

cuda0 = torch.device('cuda:0')  # CUDA GPU 0
for i, x in enumerate(train_loader):
    x = x.to(cuda0)

当在系统上使用多个GPU时,您可以使用CUDA_VISIBLE_DEVICES环境标志来管理哪些GPU对PyTorch可用。如上所述,要手动控制张量创建在哪个GPU上,最佳实践是使用torch.cuda.device上下文管理器。

print("Outside device is 0")  # 在设备0上(大多数场景下的默认设置)
with torch.cuda.device(1):
    print("Inside device is 1")  # 在设备1上
print("Outside device is still 0")  # 在设备0上

如果你有一个张量,并且希望在同一设备上创建一个相同类型的新张量,那么你可以使用 torch.Tensor.new_* 方法 (参见 torch.Tensor)。 虽然前面提到的 torch.* 工厂函数 (创建操作)依赖于当前的 GPU 上下文和你传入的属性参数,但 torch.Tensor.new_* 方法保留了张量的设备和其他属性。

这是在创建模块时推荐的实践,其中在正向传递期间需要在内部创建新的张量。

cuda = torch.device('cuda')
x_cpu = torch.empty(2)
x_gpu = torch.empty(2, device=cuda)
x_cpu_long = torch.empty(2, dtype=torch.int64)

y_cpu = x_cpu.new_full([3, 2], fill_value=0.3)
print(y_cpu)

    tensor([[ 0.3000,  0.3000],
            [ 0.3000,  0.3000],
            [ 0.3000,  0.3000]])

y_gpu = x_gpu.new_full([3, 2], fill_value=-5)
print(y_gpu)

    tensor([[-5.0000, -5.0000],
            [-5.0000, -5.0000],
            [-5.0000, -5.0000]], device='cuda:0')

y_cpu_long = x_cpu_long.new_tensor([[1, 2, 3]])
print(y_cpu_long)

    tensor([[ 1,  2,  3]])

如果你想创建一个与另一个张量类型和大小相同,并填充为1或0的张量,ones_like()zeros_like() 提供了方便的辅助函数(这些函数还会保留张量的torch.devicetorch.dtype)。

x_cpu = torch.empty(2, 3)
x_gpu = torch.empty(2, 3)

y_cpu = torch.ones_like(x_cpu)
y_gpu = torch.zeros_like(x_gpu)

使用固定内存缓冲区

警告

这是一个高级技巧。如果你过度使用固定内存,当内存不足时可能会导致严重问题,你应该意识到固定操作通常是一个昂贵的操作。

从固定(页锁定)内存发起的主机到GPU的拷贝速度要快得多。CPU张量和存储暴露了一个pin_memory()方法,该方法返回一个对象的副本,并将数据放入固定区域。

此外,一旦你固定了一个张量或存储,你就可以使用异步GPU拷贝。只需在调用to()cuda()时传递一个额外的non_blocking=True参数。这可以用于在数据传输和计算之间重叠操作。

您可以通过在构造函数中传递 pin_memory=True 来使 DataLoader 返回放置在固定内存中的批次。

使用 nn.parallel.DistributedDataParallel 而不是 multiprocessing 或 nn.DataParallel

大多数涉及批量输入和多GPU的使用场景应默认使用DistributedDataParallel来利用多个GPU。

使用CUDA模型与 multiprocessing时存在重大注意事项;除非小心确保满足数据处理要求,否则您的程序可能会出现不正确或未定义的行为。

建议使用 DistributedDataParallel, 而不是 DataParallel 来进行多GPU训练,即使只有一个节点。

DistributedDataParallelDataParallel 的区别在于:DistributedDataParallel 使用多进程,每个GPU创建一个进程,而 DataParallel 使用多线程。通过使用多进程, 每个GPU都有其专用的进程,这避免了Python解释器的GIL导致的性能开销。

如果你使用 DistributedDataParallel,你可以使用 torch.distributed.launch 工具来启动你的程序,参见 第三方后端

CUDA 图

CUDA图是CUDA流及其依赖流执行的工作(主要是内核及其参数)的记录。 有关底层CUDA API的一般原则和详细信息,请参阅 CUDA图入门CUDA C编程指南中的图部分

PyTorch 支持使用 stream capture 构建 CUDA 图,这将使 CUDA 流进入 捕获模式。发送到捕获流的 CUDA 工作实际上不会在 GPU 上运行。相反,工作会被记录在一个图中。

捕获后,可以启动图形以根据需要多次运行GPU工作。 每次重放都会使用相同的参数运行相同的内核。对于指针参数,这意味着使用相同的内存地址。 通过在每次重放之前用新数据(例如,来自新批次)填充输入内存, 您可以在新数据上重新运行相同的工作。

为什么使用CUDA图?

重放一个图表会牺牲典型即时执行的动态灵活性,以换取大大减少的CPU开销。图表的参数和内核是固定的,因此图表重放会跳过所有参数设置和内核调度的层次,包括Python、C++和CUDA驱动程序的开销。在底层,重放通过一次调用cudaGraphLaunch将整个图表的工作提交给GPU。重放中的内核在GPU上执行得稍快,但消除CPU开销是主要的好处。

如果你的网络的全部或部分是图安全的(通常这意味着静态形状和静态控制流,但请参阅其他约束),并且你怀疑其运行时间至少在一定程度上受限于CPU,你应该尝试CUDA图。

PyTorch API

警告

此API处于测试阶段,可能会在未来的版本中进行更改。

PyTorch 通过一个原始的 torch.cuda.CUDAGraph 类 和两个便捷的包装器, torch.cuda.graphtorch.cuda.make_graphed_callables 来暴露图。

torch.cuda.graph 是一个简单、多功能的上下文管理器,用于在其上下文中捕获CUDA工作。 在捕获之前,通过运行几次急切迭代来预热要捕获的工作负载。预热必须在侧流上进行。 因为图在每次重放时都从相同的内存地址读取和写入,所以您必须在捕获期间维护对持有输入和输出数据的张量的长期引用。 要在新输入数据上运行图,请将新数据复制到捕获的输入张量中,重放图,然后从捕获的输出张量中读取新输出。 示例:

g = torch.cuda.CUDAGraph()

# 用于捕获的占位符输入
static_input = torch.empty((5,), device="cuda")

# 捕获前的预热
s = torch.cuda.Stream()
s.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(s):
    for _ in range(3):
        static_output = static_input * 2
torch.cuda.current_stream().wait_stream(s)

# 捕获图
# 为了允许捕获,自动将一个侧流设置为上下文中的当前流
with torch.cuda.graph(g):
    static_output = static_input * 2

# 用新数据填充图的输入内存以进行计算
static_input.copy_(torch.full((5,), 3, device="cuda"))
g.replay()
# static_output 保存结果
print(static_output)  # 充满 3 * 2 = 6

# 用更多数据填充图的输入内存以进行计算
static_input.copy_(torch.full((5,), 4, device="cuda"))
g.replay()
print(static_output)  # 充满 4 * 2 = 8

参见 全网络捕获, 与 torch.cuda.amp 一起使用, 以及 与多流一起使用 了解现实和高级模式。

make_graphed_callables 更加复杂。 make_graphed_callables 接受 Python 函数和 torch.nn.Module。对于每个传递的函数或模块, 它会创建单独的前向传递和后向传递工作图。请参阅 部分网络捕获

约束条件

一组操作如果满足以下所有约束条件,则称为可捕获

约束适用于所有工作在一个 torch.cuda.graph 上下文中,以及传递给 torch.cuda.make_graphed_callables() 的任何可调用对象的前向和反向传递中的所有工作。

违反以下任何一条都可能导致运行时错误:

  • 捕获必须在非默认流上进行。(这仅在你使用原始的 CUDAGraph.capture_beginCUDAGraph.capture_end 调用时才需要考虑。 graphmake_graphed_callables() 会为你设置一个侧流。)

  • 禁止使用那些同步CPU与GPU的操作(例如,.item() 调用)。

  • CUDA RNG操作是允许的,但必须使用默认生成器。例如,显式构造一个新的torch.Generator实例并将它作为generator参数传递给RNG函数是被禁止的。

违反其中任何一条都可能导致静默的数值错误或未定义行为:

  • 在一个进程中,同一时间只能进行一次捕获。

  • 在捕获进行期间,此进程(在任何线程上)不得运行任何未捕获的 CUDA 工作。

  • 未捕获CPU工作。如果捕获的操作包括CPU工作,则在重放期间该工作将被省略。

  • 每个回放都会从相同的(虚拟)内存地址读取和写入。

  • 动态控制流(基于CPU或GPU数据)是被禁止的。

  • 禁止动态形状。图表假设捕获的操作序列中的每个张量在每次重放中具有相同的大小和布局。

  • 在捕获中使用多个流是允许的,但存在限制

非约束条件

  • 一旦捕获,图形可以在任何流上重放。

全网络捕获

如果你的整个网络是可捕获的,你可以捕获并重放整个迭代:

N, D_in, H, D_out = 640, 4096, 2048, 1024
model = torch.nn.Sequential(torch.nn.Linear(D_in, H),
                            torch.nn.Dropout(p=0.2),
                            torch.nn.Linear(H, D_out),
                            torch.nn.Dropout(p=0.1)).cuda()
loss_fn = torch.nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

# 用于捕获的占位符
static_input = torch.randn(N, D_in, device='cuda')
static_target = torch.randn(N, D_out, device='cuda')

# 预热
# 这里为了方便使用了static_input和static_target,
# 但在实际环境中,因为预热包括了optimizer.step(),
# 所以你必须使用几批真实数据。
s = torch.cuda.Stream()
s.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(s):
    for i in range(3):
        optimizer.zero_grad(set_to_none=True)
        y_pred = model(static_input)
        loss = loss_fn(y_pred, static_target)
        loss.backward()
        optimizer.step()
torch.cuda.current_stream().wait_stream(s)

# 捕获
g = torch.cuda.CUDAGraph()
# 在捕获之前将梯度设置为None,以便backward()将创建
# 从图的私有池分配的.grad属性
optimizer.zero_grad(set_to_none=True)
with torch.cuda.graph(g):
    static_y_pred = model(static_input)
    static_loss = loss_fn(static_y_pred, static_target)
    static_loss.backward()
    optimizer.step()

real_inputs = [torch.rand_like(static_input) for _ in range(10)]
real_targets = [torch.rand_like(static_target) for _ in range(10)]

for data, target in zip(real_inputs, real_targets):
    # 用新数据填充图的输入内存以进行计算
    static_input.copy_(data)
    static_target.copy_(target)
    # replay()包括前向传播、反向传播和步骤。
    # 你甚至不需要在迭代之间调用optimizer.zero_grad()
    # 因为捕获的反向传播会在原地重新填充静态的.grad张量。
    g.replay()
    # 参数已更新。static_y_pred、static_loss和.grad
    # 属性保存了本次迭代数据的计算结果。

部分网络捕获

如果您的网络中有部分内容不安全进行捕获(例如,由于动态控制流、动态形状、CPU同步或必要的CPU端逻辑),您可以急切地运行这些不安全部分,并使用torch.cuda.make_graphed_callables()仅对捕获安全部分进行图化。

默认情况下,由 make_graphed_callables() 返回的可调用对象是自动微分感知的,并且可以直接在训练循环中用作您传递的函数或 nn.Module 的替代品。

make_graphed_callables() 在内部创建 CUDAGraph 对象,运行预热迭代,并根据需要维护 静态输入和输出。因此(与使用 torch.cuda.graph 不同)您不需要手动处理这些。

在以下示例中,数据依赖的动态控制流意味着网络无法端到端地捕获,但 make_graphed_callables() 允许我们捕获并运行图安全的部分,无论是否为图:

N, D_in, H, D_out = 640, 4096, 2048, 1024

module1 = torch.nn.Linear(D_in, H).cuda()
module2 = torch.nn.Linear(H, D_out).cuda()
module3 = torch.nn.Linear(H, D_out).cuda()

loss_fn = torch.nn.MSELoss()
optimizer = torch.optim.SGD(chain(module1.parameters(),
                                  module2.parameters(),
                                  module3.parameters()),
                            lr=0.1)

# 用于捕获的样本输入
# 样本输入的 requires_grad 状态必须与
# 每个可调用对象将看到的实际输入的 requires_grad 状态匹配。
x = torch.randn(N, D_in, device='cuda')
h = torch.randn(N, H, device='cuda', requires_grad=True)

module1 = torch.cuda.make_graphed_callables(module1, (x,))
module2 = torch.cuda.make_graphed_callables(module2, (h,))
module3 = torch.cuda.make_graphed_callables(module3, (h,))

real_inputs = [torch.rand_like(x) for _ in range(10)]
real_targets = [torch.randn(N, D_out, device="cuda") for _ in range(10)]

for data, target in zip(real_inputs, real_targets):
    optimizer.zero_grad(set_to_none=True)

    tmp = module1(data)  # 前向操作作为图运行

    if tmp.sum().item() > 0:
        tmp = module2(tmp)  # 前向操作作为图运行
    else:
        tmp = module3(tmp)  # 前向操作作为图运行

    loss = loss_fn(tmp, target)
    # module2 或 module3(无论选择哪个)的反向操作,
    # 以及 module1 的反向操作,作为图运行
    loss.backward()
    optimizer.step()

与 torch.cuda.amp 的使用

对于典型的优化器,GradScaler.step 会同步 CPU 与 GPU,这在捕获期间是被禁止的。为了避免错误,可以采用 部分网络捕获,或者(如果前向传播、损失计算和反向传播是捕获安全的)捕获前向传播、损失计算和反向传播,但不捕获优化器步骤:

# 预热
# 在实际环境中,使用几批真实数据。
s = torch.cuda.Stream()
s.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(s):
    for i in range(3):
        optimizer.zero_grad(set_to_none=True)
        with torch.cuda.amp.autocast():
            y_pred = model(static_input)
            loss = loss_fn(y_pred, static_target)
        scaler.scale(loss).backward()
        scaler.step(optimizer)
        scaler.update()
torch.cuda.current_stream().wait_stream(s)

# 捕获
g = torch.cuda.CUDAGraph()
optimizer.zero_grad(set_to_none=True)
with torch.cuda.graph(g):
    with torch.cuda.amp.autocast():
        static_y_pred = model(static_input)
        static_loss = loss_fn(static_y_pred, static_target)
    scaler.scale(static_loss).backward()
    # 不要捕获 scaler.step(optimizer) 或 scaler.update()

real_inputs = [torch.rand_like(static_input) for _ in range(10)]
real_targets = [torch.rand_like(static_target) for _ in range(10)]

for data, target in zip(real_inputs, real_targets):
    static_input.copy_(data)
    static_target.copy_(target)
    g.replay()
    # 急切地运行 scaler.step 和 scaler.update
    scaler.step(optimizer)
    scaler.update()

多流使用

捕获模式会自动传播到与捕获流同步的任何流。 在捕获过程中,您可以通过向不同的流发出调用来暴露并行性, 但整个流依赖关系DAG必须在捕获开始后从初始捕获流分支出来,并在捕获结束前重新加入初始流:

with torch.cuda.graph(g):
    # 在上下文管理器入口处,torch.cuda.current_stream()
    # 是初始捕获流

    # 不正确(没有从初始流分支或重新加入初始流)
    with torch.cuda.stream(s):
        cuda_work()

    # 正确:
    # 从初始流分支
    s.wait_stream(torch.cuda.current_stream())
    with torch.cuda.stream(s):
        cuda_work()
    # 在捕获结束前重新加入初始流
    torch.cuda.current_stream().wait_stream(s)

注意

为了避免高级用户在使用nsight systems或nvprof查看回放时产生混淆: 与即时执行不同,图在捕获时将非平凡的流DAG解释为提示,而不是命令。在回放期间,图可能会将独立的运算重新组织到不同的流上或以不同的顺序入队(同时尊重您原始DAG的整体依赖关系)。

与DistributedDataParallel一起使用

NCCL < 2.9.6

早于2.9.6版本的NCCL不允许捕获集体操作。 您必须使用部分网络捕获, 这将延迟所有归约操作在反向传播的图形部分之外进行。

在将网络与DDP包装之前,调用make_graphed_callables()在可图化的网络部分上。

NCCL >= 2.9.6

NCCL 版本 2.9.6 或更高版本允许在图中进行集体操作。 捕获 整个反向传播过程 的方法是一个可行的选择,但需要三个设置步骤。

  1. 禁用DDP的内部异步错误处理:

    os.environ["NCCL_ASYNC_ERROR_HANDLING"] = "0"
    torch.distributed.init_process_group(...)
    
  2. 在进行全反向捕获之前,DDP必须在侧流上下文中构建:

    with torch.cuda.stream(s):
        model = DistributedDataParallel(model)
    
  3. 您的预热必须在捕获之前至少运行11次启用DDP的急切迭代。

图内存管理

捕获的图每次重放时都会作用于相同的虚拟地址。 如果 PyTorch 释放了内存,后续的重放可能会导致非法内存访问。 如果 PyTorch 将内存重新分配给新的张量,重放可能会破坏这些张量所看到的值。 因此,图所使用的虚拟地址必须在重放过程中为图保留。PyTorch 的缓存分配器通过检测捕获正在进行,并从图的私有内存池中满足捕获的分配需求来实现这一点。私有池在它的 CUDAGraph 对象以及捕获期间创建的所有张量超出作用域之前一直保持活动状态。

私有池会自动维护。默认情况下,分配器会为每个捕获创建一个单独的私有池。如果你捕获多个图表,这种保守的方法确保图表重放不会相互破坏彼此的值,但有时会不必要地浪费内存。

跨捕获共享内存

为了节省存储在私有池中的内存,torch.cuda.graphtorch.cuda.make_graphed_callables() 可以选择允许不同的捕获共享相同的私有池。 如果你知道一组图总是按照它们被捕获的顺序重放, 并且永远不会并发重放,那么它们共享一个私有池是安全的。

torch.cuda.graphpool 参数是一个提示,用于使用特定的私有池,并且可以用于在图中共享内存,如下所示:

g1 = torch.cuda.CUDAGraph()
g2 = torch.cuda.CUDAGraph()

# (为 g1 和 g2 创建静态输入,运行它们的预热工作负载...)

# 捕获 g1
with torch.cuda.graph(g1):
    static_out_1 = g1_workload(static_in_1)

# 捕获 g2,提示 g2 可能与 g1 共享内存池
with torch.cuda.graph(g2, pool=g1.pool()):
    static_out_2 = g2_workload(static_in_2)

static_in_1.copy_(real_data_1)
static_in_2.copy_(real_data_2)
g1.replay()
g2.replay()

使用 torch.cuda.make_graphed_callables(),如果你想对几个可调用对象进行图化处理,并且你知道它们总是按相同的顺序运行(并且永远不会同时运行),将它们作为元组按它们在实际工作负载中运行的顺序传递,并且 make_graphed_callables() 将使用共享的私有池来捕获它们的图。

如果在实际工作负载中,您的可调用对象将以偶尔变化的顺序运行,或者它们将并发运行,则不允许将它们作为元组传递给make_graphed_callables()的单个调用。相反,您必须分别为每个可调用对象调用make_graphed_callables()