13.3. 自动并行
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 SageMaker Studio Lab 中打开 Notebook

深度学习框架(例如,MXNet和PyTorch)会在后端自动构建计算图。通过计算图,系统可以了解所有依赖关系,并且可以选择性地并行执行多个没有相互依赖的任务以提高速度。例如,13.2节中的图13.2.2独立地初始化了两个变量。因此,系统可以选择并行执行它们。

通常,一个运算符会使用所有CPU或单个GPU上的所有计算资源。例如,即使单个机器上有多个CPU处理器,dot运算符也将使用所有CPU上的所有核心(和线程)。这同样适用于单个GPU。因此,对于单设备计算机而言,并行化并不是那么有用。对于多设备,并行化变得更加重要。虽然并行化通常与多个GPU最相关,但添加本地CPU会略微提高性能。例如,请参阅 Hadjis et al. (2016),该论文专注于结合GPU和CPU来训练计算机视觉模型。借助自动并行化框架的便利性,我们只需几行Python代码就可以实现相同的目标。更广泛地说,我们对自动并行计算的讨论侧重于使用CPU和GPU的并行计算,以及计算和通信的并行化。

请注意,要运行本节中的实验,至少需要两个GPU。

import torch
from d2l import torch as d2l
from mxnet import np, npx
from d2l import mxnet as d2l

npx.set_np()

13.3.1. GPU上的并行计算

让我们先定义一个用于测试的基准工作负载:下面的run函数使用分配到两个变量x_gpu1x_gpu2中的数据,在我们选择的设备上执行10次矩阵-矩阵乘法。

devices = d2l.try_all_gpus()
def run(x):
    return [x.mm(x) for _ in range(50)]

x_gpu1 = torch.rand(size=(4000, 4000), device=devices[0])
x_gpu2 = torch.rand(size=(4000, 4000), device=devices[1])

现在,我们将该函数应用于数据。为了确保缓存不会影响结果,在测量之前,我们在任一设备上执行一次传递来“预热”设备。torch.cuda.synchronize()会等待CUDA设备上所有流中的所有内核完成。它接受一个device参数,即我们需要同步的设备。如果device参数为None(默认值),它将使用current_device()给出的当前设备。

run(x_gpu1)
run(x_gpu2)  # Warm-up all devices
torch.cuda.synchronize(devices[0])
torch.cuda.synchronize(devices[1])

with d2l.Benchmark('GPU1 time'):
    run(x_gpu1)
    torch.cuda.synchronize(devices[0])

with d2l.Benchmark('GPU2 time'):
    run(x_gpu2)
    torch.cuda.synchronize(devices[1])
GPU1 time: 0.4660 sec
GPU2 time: 0.4510 sec

如果我们移除两个任务之间的synchronize语句,系统可以自动地在两个设备上并行化计算。

with d2l.Benchmark('GPU1 & GPU2'):
    run(x_gpu1)
    run(x_gpu2)
    torch.cuda.synchronize()
GPU1 & GPU2: 0.4659 sec
devices = d2l.try_all_gpus()
def run(x):
    return [x.dot(x) for _ in range(50)]

x_gpu1 = np.random.uniform(size=(4000, 4000), ctx=devices[0])
x_gpu2 = np.random.uniform(size=(4000, 4000), ctx=devices[1])
[22:23:34] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for GPU
[22:23:34] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for GPU

现在,我们将该函数应用于数据。为了确保缓存不会影响结果,在测量之前,我们在任一设备上执行一次传递来“预热”设备。

run(x_gpu1)  # Warm-up both devices
run(x_gpu2)
npx.waitall()

with d2l.Benchmark('GPU1 time'):
    run(x_gpu1)
    npx.waitall()

with d2l.Benchmark('GPU2 time'):
    run(x_gpu2)
    npx.waitall()
GPU1 time: 0.4465 sec
GPU2 time: 0.4519 sec

如果我们移除两个任务之间的waitall语句,系统可以自由地在两个设备上自动并行化计算。

with d2l.Benchmark('GPU1 & GPU2'):
    run(x_gpu1)
    run(x_gpu2)
    npx.waitall()
GPU1 & GPU2: 0.4535 sec

在上面的例子中,总执行时间小于各部分之和,因为深度学习框架自动在两个GPU设备上调度计算,而无需用户编写复杂的代码。

13.3.2. 并行计算与通信

在许多情况下,我们需要在不同设备之间移动数据,例如在CPU和GPU之间,或在不同的GPU之间。例如,当我们想要执行分布式优化时,就会发生这种情况,因为我们需要在多个加速卡上聚合梯度。让我们通过在GPU上计算,然后将结果复制回CPU来模拟这种情况。

def copy_to_cpu(x, non_blocking=False):
    return [y.to('cpu', non_blocking=non_blocking) for y in x]

with d2l.Benchmark('Run on GPU1'):
    y = run(x_gpu1)
    torch.cuda.synchronize()

with d2l.Benchmark('Copy to CPU'):
    y_cpu = copy_to_cpu(y)
    torch.cuda.synchronize()
Run on GPU1: 0.4656 sec
Copy to CPU: 2.3125 sec

这有些低效。请注意,当列表的其余部分仍在计算时,我们其实已经可以开始将y的部分数据复制到CPU了。这种情况发生在,例如,当我们在一个小批量上计算(反向传播)梯度时。一些参数的梯度会比其他参数的梯度更早可用。因此,在GPU仍在运行时开始使用PCI-Express总线带宽对我们有利。在PyTorch中,一些函数如to()copy_()接受一个显式的non_blocking参数,它允许调用者在不必要时绕过同步。设置non_blocking=True允许我们模拟这种情况。

with d2l.Benchmark('Run on GPU1 and copy to CPU'):
    y = run(x_gpu1)
    y_cpu = copy_to_cpu(y, True)
    torch.cuda.synchronize()
Run on GPU1 and copy to CPU: 1.6907 sec
def copy_to_cpu(x):
    return [y.copyto(npx.cpu()) for y in x]

with d2l.Benchmark('Run on GPU1'):
    y = run(x_gpu1)
    npx.waitall()

with d2l.Benchmark('Copy to CPU'):
    y_cpu = copy_to_cpu(y)
    npx.waitall()
Run on GPU1: 0.4788 sec
[22:23:37] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
Copy to CPU: 2.4304 sec

这有些低效。请注意,当列表的其余部分仍在计算时,我们其实已经可以开始将y的部分数据复制到CPU了。这种情况发生在,例如,当我们在一个小批量上计算梯度时。一些参数的梯度会比其他参数的梯度更早可用。因此,在GPU仍在运行时开始使用PCI-Express总线带宽对我们有利。移除两个部分之间的waitall可以让我们模拟这种情况。

with d2l.Benchmark('Run on GPU1 and copy to CPU'):
    y = run(x_gpu1)
    y_cpu = copy_to_cpu(y)
    npx.waitall()
Run on GPU1 and copy to CPU: 0.4530 sec

两个操作所需的总时间(如预期)小于它们各部分之和。请注意,这个任务与并行计算不同,因为它使用了不同的资源:CPU和GPU之间的总线。事实上,我们可以同时在两个设备上计算并进行通信。如上所述,计算和通信之间存在依赖关系:y[i]必须先计算出来,然后才能被复制到CPU。幸运的是,系统可以在计算y[i]的同时复制y[i-1],以减少总运行时间。

我们用一个简单的双层MLP在CPU和两个GPU上训练时的计算图及其依赖关系来说明,如图13.3.1所示。手动调度由此产生的并行程序将非常痛苦。这就是为什么拥有一个基于图的计算后端进行优化是有利的。

../_images/twogpu.svg

图 13.3.1 一个双层MLP在CPU和两个GPU上的计算图及其依赖关系。

13.3.3. 小结

  • 现代系统拥有多种设备,例如多个GPU和CPU。它们可以异步地并行使用。

  • 现代系统还拥有多种通信资源,例如PCI Express、存储(通常是固态硬盘或通过网络)和网络带宽。它们可以并行使用以达到最高效率。

  • 后端可以通过自动并行计算和通信来提高性能。

13.3.4. 练习

  1. 本节定义的run函数中执行了八个操作。它们之间没有依赖关系。设计一个实验,看看深度学习框架是否会自动并行执行它们。

  2. 当单个运算符的工作量足够小时,即使在单个CPU或GPU上,并行化也能有所帮助。设计一个实验来验证这一点。

  3. 设计一个实验,使用CPU、GPU上的并行计算以及两者之间的通信。

  4. 使用调试器,如NVIDIA的Nsight,来验证你的代码是否高效。

  5. 设计包含更复杂数据依赖关系的计算任务,并进行实验,看看是否能在提高性能的同时获得正确的结果。