CUDA 语义

3.1 设备分配

torch.cuda 用于设置和运行 CUDA 操作。它会跟踪当前选定的 GPU,并且您分配的所有 CUDA 张量将默认在该设备上创建。所选设备可以使用 torch.cuda.device 环境管理器进行更改。

一旦分配了张量,您就可以对其执行操作而必在意所选的设备如何,并且结果将总是与张量一起放置在相同的设备上。

默认的情况下不允许进行交叉 GPU 操作,除了 copy_() 和其他具有类似复制功能的方法(如 to()cuda() )之外。除非启用端到端的存储器访问,否则任何尝试将张量分配到不同设备上的操作都会引发错误。

下面可以用一个小例子来展示:

  1. cuda = torch.device("cuda") # 默认为 CUDA 设备
  2. cuda0 = torch.device("cuda:0")
  3. cuda2 = torch.device("cuda:2") # GPU 2
  4. # x, y 的设备是 device(type='cuda', index=0)
  5. x = torch.tensor([1., 2.], device=cuda0)
  6. y = torch.tensor([1., 2.], cuda())
  7. # 在 GPU 1 上分配张量
  8. with torch.cuda.device(1):
  9. # GPU 1
  10. a = torch.tensor([1., 2.], device=cuda)
  11. # 从 CPU 传递到 GPU 1
  12. # a、b 所在的设备都是 device(type='cuda', index=1)
  13. b = torch.tensor([1., 2.]).cuda()
  14. # 也可以用 Tensor.to 来传递张量
  15. # b2 的设备与 a 同
  16. b2 = torch.tensor([1., 2.]).to(device=cuda)
  17. # 即使是在环境中,你也可以指定设备
  18. d = torch.randn(2, device=cuda2)
  19. e = torch.randn(2).to(cuda2)

3.2 异步执行

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

一般情况下,异步计算的效果对调用者是不可见的,因为(1)每个设备按照它们排队的顺序执行操作,(2)在 CPU 和 GPU 之间或两个 GPU 之间复制数据时,PyTorch 自动执行必要的同步。因此,计算将按每个操作同步执行的方式进行。

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

作为一个例外,copy_() 等几个函数允许一个显式的异步参数 async,它允许调用者在不必要时绕过同步。另一个例外是 CUDA 流,解释如下:

3.2.1 CUDA 流

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

除非显式的使用同步函数(例如 synchronize()wait_stream() ),否则每个流内的操作都按照它们创建的顺序进行序列化,但是来自不同流的操作可以以任意相对顺序并发执行。例如,下面的代码是不正确的:

  1. cuda = torch.device("cuda")
  2. s = torch.cuda.stream() # 在当前流中创建一个新的流
  3. A = torch.empty((100,100), device = cuda).normal_(0.0, 1.0)
  4. with torch.cuda.stream(s):
  5. # sum()可能在 normal_()执行完成前就开始执行
  6. B = torch.sum(A)

当“当前流”是默认流时,如上所述,PyTorch 在数据移动时自动执行必要的同步。但是,使用非默认流时,用户有责任确保正确的同步。

3.3 内存管理

PyTorch使用缓存内存分配器来加速内存分配。这允许在没有设备同步的情况下快速释放内存。但是,由分配器管理的未使用的内存仍将显示为在 nvidia-smi 中使用。您可以使用 memory_allocated()max_memory_allocated() 来监视张量占用的内存,并使用 memory_cached()max_memory_cached() 来监视由缓存分配器管理的内存。调用 empty_cache() 可以从 PyTorch 中释放所有未使用的缓存内存,以便其他 GPU 应用程序可以使用这些内存。但是,被张量占用的 GPU 内存不会被释放,因此它不能为 PyTorch 增加可用的 GPU 内存量。

3.4.1 设备诊断

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

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

  1. import argparse
  2. import torch
  3. parser = argparse.ArgumentParser(description='PyTorch Example')
  4. parser.add_argument('--disable-cuda', action='stroe_true', help='Disable CUDA')
  5. args = parser.parse_args()
  6. args.device = None
  7. if not args.disable_cuda and torch.cuda.is_available():
  8. args.device = torch.device('cuda')
  9. else:
  10. args.device = torch.device('cpu')

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

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

这可以在许多情况下用于生成设备不可知代码。以下是使用 dataloader 的例子:

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

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

  1. print("外部的设备是 0") # 在设备 0 上
  2. with torch.cuda.device(1):
  3. print("内部的设备是 1") # 设备 1
  4. print("外部的设备仍是 0") # 设备 0

如果您有一个张量,并且想要在同一个设备上创建一个相同类型的张量,那么您可以使用 torch.Tensor.new_* 方法(请参阅 torch.Tensor )。torch.Tensor.new_* 方法保留了设备和张量的其他属性,而前面提到的 torch.* 工厂函数(Creation Ops)创建的张量则取决于当前的 GPU 上下文和所传递的属性参数。

这是建立模块时推荐的做法,在前向传递期间需要在内部创建新的张量

  1. cuda = torch.device("cuda")
  2. x_cpu = torch.empty(2)
  3. y_gpu = torch.empty(2, device = cuda)
  4. x_cpu_long = torch.empty(2, dtype=torch.int64)
  5. y_cpu = x_cpu.new_full([3,2], fill_value=0.3)
  6. print(y_cpu)
  7. tensor([[ 0.3000, 0.3000],
  8. [ 0.3000, 0.3000],
  9. [ 0.3000, 0.3000]])
  10. y_gpu = x_gpu.new_full([3,2], fill_value=-5)
  11. print(y_gpu)
  12. tensor([[-5.0000, -5.0000],
  13. [-5.0000, -5.0000],
  14. [-5.0000, -5.0000]], device='cuda:0')
  15. y_cpu_long = x_cpu_long.new_tensor([[1,2,3]])
  16. print(y_cpu_long)
  17. tensor([[ 1, 2, 3]])

如果要创建与另一个张量相同类型和大小的张量,并将其填充为 1 或 0,则可以使用 ones_like()zeros_like() 作为便捷的辅助函数(也可以保留 torch.devicetorch.dtype 的张量)。

  1. x_cpu = torch.empty(2,3)
  2. x_gpu = torch.empty(2,3)
  3. y_cpu = torch.ones_like(x_cpu)
  4. y_gpu = torch.zeros_like(x_gpu)

3.4 使用固定的内存缓冲区

当数据源自固定(页面锁定)内存时,主机在 GPU 上的数据副本运行速度会更快。 CPU Tensors 和存储器暴露了一个 pin_memory() 方法,该方法返回对象的一个副本,并将数据放入固定区域。

一旦您固定(pin)一个张量或存储器,您就可以使用异步 GPU 副本。只需将一个额外的 non_blocking = True 参数传递给 cuda() 调用即可。这可以用于计算与数据传输的并行。

通过将 pin_memory = True传递给其构造函数,可以将 DataLoader 返回的批量数据置于固定内存(pin memory)中。

3.5 使用 nn.DataParallel 代替多线程处理

大多数涉及批量输入和多个 GPU 的使用案例应默认使用 DataParallel来利用多个 GPU。即使使用 GIL,单个 Python 进程也可以使多个 GPU 饱和。

从版本 0.1.9 开始,大量的 GPU(8+)可能未被充分利用。但是,这是一个正在积极开发中的已知问题。像往常一样,测试你的用例。

使用 CUDA 模型进行多线程处理(multiprocessing)存在重要的注意事项;除非准确的满足了数据处理的要求,否则很可能您的程序将具有不正确或未定义的行为。

译者署名

用户名 头像 职能 签名
风中劲草 CUDA 语义 - 图1 翻译 人生总要追求点什么