PyTorch教程-13.5。在多个 GPU 上进行训练

电子说

1.3w人已加入

描述

到目前为止,我们讨论了如何在 CPU 和 GPU 上高效地训练模型。在13.3 节中,我们甚至展示了深度学习框架如何允许人们在它们之间自动并行计算和通信 。我们还在6.7 节中展示了如何使用nvidia-smi 命令列出计算机上所有可用的 GPU。我们没有讨论的是如何真正并行化深度学习训练。相反,我们暗示传递一个会以某种方式将数据拆分到多个设备并使其工作。本节填写详细信息并展示如何从头开始并行训练网络。有关如何利用高级 API 中的功能的详细信息归入 第 13.6 节. 我们假设您熟悉 minibatch 随机梯度下降算法,例如12.5 节中描述的算法。

13.5.1。拆分问题

让我们从一个简单的计算机视觉问题和一个稍微陈旧的网络开始,例如,具有多层卷积、池化,最后可能还有一些完全连接的层。也就是说,让我们从一个看起来与 LeNet (LeCun等人,1998 年)或 AlexNet (Krizhevsky等人,2012 年)非常相似的网络开始。给定多个 GPU(如果是桌面服务器则为 2 个,在 AWS g4dn.12xlarge 实例上为 4 个,在 p3.16xlarge 上为 8 个,或在 p2.16xlarge 上为 16 个),我们希望以实现良好加速的方式对训练进行分区同时受益于简单且可重现的设计选择。毕竟,多个 GPU 会增加内存和计算能力。简而言之,给定我们要分类的小批量训练数据,我们有以下选择。

首先,我们可以跨多个 GPU 划分网络。也就是说,每个 GPU 将流入特定层的数据作为输入,跨多个后续层处理数据,然后将数据发送到下一个 GPU。与单个 GPU 可以处理的数据相比,这使我们能够使用更大的网络处理数据。此外,可以很好地控制每个 GPU 的内存占用量(它只占网络总占用量的一小部分)。

然而,层(以及 GPU)之间的接口需要紧密同步。这可能很棘手,特别是如果层与层之间的计算工作负载没有正确匹配。对于大量 GPU,问题会更加严重。层与层之间的接口也需要大量的数据传输,例如激活和梯度。这可能会超出 GPU 总线的带宽。此外,计算密集型但顺序的操作对于分区来说并不重要。参见例如Mirhoseini等人。( 2017 年)在这方面尽最大努力。这仍然是一个难题,尚不清楚是否有可能在非平凡问题上实现良好的(线性)缩放。我们不推荐它,除非有出色的框架或操作系统支持将多个 GPU 链接在一起。

其次,我们可以分层拆分工作。例如,与其在单个 GPU 上计算 64 个通道,不如将问题拆分到 4 个 GPU,每个 GPU 生成 16 个通道的数据。同样,对于全连接层,我们可以拆分输出单元的数量。 图 13.5.1(取自 Krizhevsky等人(2012 年))说明了这种设计,其中这种策略用于处理内存占用非常小(当时为 2 GB)的 GPU。如果通道(或单元)的数量不太小,这就可以在计算方面实现良好的缩放。此外,由于可用内存线性扩展,多个 GPU 可以处理越来越大的网络。

pytorch

图 13.5.1由于 GPU 内存有限,原始 AlexNet 设计中的模型并行性。

然而,我们需要大量的同步或屏障操作,因为每一层都依赖于所有其他层的结果。此外,需要传输的数据量可能比跨 GPU 分布层时更大。因此,由于带宽成本和复杂性,我们不推荐这种方法。

最后,我们可以跨多个 GPU 对数据进行分区。这样,所有 GPU 都执行相同类型的工作,尽管观察结果不同。在每个小批量训练数据之后,梯度在 GPU 之间聚合。这是最简单的方法,适用于任何情况。我们只需要在每个小批量之后进行同步。也就是说,非常希望在其他仍在计算的同时开始交换梯度参数。此外,更大数量的 GPU 会导致更大的小批量大小,从而提高训练效率。然而,添加更多 GPU 并不能让我们训练更大的模型。

pytorch

图 13.5.2多 GPU 上的并行化。从左到右:原始问题、网络分区、分层分区、数据并行。

图 13.5.2描绘了多 GPU 上不同并行化方式的比较。总的来说,数据并行是最方便的方法,前提是我们可以访问具有足够大内存的 GPU。另请参阅 ( Li et al. , 2014 )以了解分布式训练分区的详细描述。在深度学习的早期,GPU 内存曾经是一个问题。到目前为止,除了最不寻常的情况外,所有问题都已解决。下面我们重点介绍数据并行性。

13.5.2。数据并行

假设有k机器上的 GPU。给定要训练的模型,每个 GPU 将独立维护一组完整的模型参数,尽管 GPU 之间的参数值是相同且同步的。例如,图 13.5.3说明了在以下情况下使用数据并行性进行训练k=2.

pytorch

图 13.5.3在两个 GPU 上使用数据并行计算小批量随机梯度下降。

一般来说,训练过程如下:

在训练的任何迭代中,给定一个随机小批量,我们将批量中的示例分成k部分并将它们均匀地分布在 GPU 上。

每个 GPU 根据分配给它的小批量子集计算模型参数的损失和梯度。

每个的局部梯度kGPU 被聚合以获得当前的小批量随机梯度。

聚合梯度被重新分配给每个 GPU。

每个 GPU 使用这个小批量随机梯度来更新它维护的完整模型参数集。

请注意,在实践中我们增加了小批量大小k-训练时折叠kGPU 这样每个 GPU 都有相同数量的工作要做,就好像我们只在单个 GPU 上训练一样。在 16-GPU 服务器上,这会大大增加小批量大小,我们可能不得不相应地增加学习率。另请注意,第 8.5 节中的批量归一化需要进行调整,例如,通过为每个 GPU 保留一个单独的批量归一化系数。下面我们将使用玩具网络来说明多 GPU 训练。

 

%matplotlib inline
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l

 

 

%matplotlib inline
from mxnet import autograd, gluon, np, npx
from d2l import mxnet as d2l

npx.set_np()

 

13.5.3。玩具网络

我们使用7.6 节中介绍的 LeNet (稍作修改)。我们从头开始定义它以详细说明参数交换和同步。

 

# Initialize model parameters
scale = 0.01
W1 = torch.randn(size=(20, 1, 3, 3)) * scale
b1 = torch.zeros(20)
W2 = torch.randn(size=(50, 20, 5, 5)) * scale
b2 = torch.zeros(50)
W3 = torch.randn(size=(800, 128)) * scale
b3 = torch.zeros(128)
W4 = torch.randn(size=(128, 10)) * scale
b4 = torch.zeros(10)
params = [W1, b1, W2, b2, W3, b3, W4, b4]

# Define the model
def lenet(X, params):
  h1_conv = F.conv2d(input=X, weight=params[0], bias=params[1])
  h1_activation = F.relu(h1_conv)
  h1 = F.avg_pool2d(input=h1_activation, kernel_size=(2, 2), stride=(2, 2))
  h2_conv = F.conv2d(input=h1, weight=params[2], bias=params[3])
  h2_activation = F.relu(h2_conv)
  h2 = F.avg_pool2d(input=h2_activation, kernel_size=(2, 2), stride=(2, 2))
  h2 = h2.reshape(h2.shape[0], -1)
  h3_linear = torch.mm(h2, params[4]) + params[5]
  h3 = F.relu(h3_linear)
  y_hat = torch.mm(h3, params[6]) + params[7]
  return y_hat

# Cross-entropy loss function
loss = nn.CrossEntropyLoss(reduction='none')

 

 

# Initialize model parameters
scale = 0.01
W1 = np.random.normal(scale=scale, size=(20, 1, 3, 3))
b1 = np.zeros(20)
W2 = np.random.normal(scale=scale, size=(50, 20, 5, 5))
b2 = np.zeros(50)
W3 = np.random.normal(scale=scale, size=(800, 128))
b3 = np.zeros(128)
W4 = np.random.normal(scale=scale, size=(128, 10))
b4 = np.zeros(10)
params = [W1, b1, W2, b2, W3, b3, W4, b4]

# Define the model
def lenet(X, params):
  h1_conv = npx.convolution(data=X, weight=params[0], bias=params[1],
               kernel=(3, 3), num_filter=20)
  h1_activation = npx.relu(h1_conv)
  h1 = npx.pooling(data=h1_activation, pool_type='avg', kernel=(2, 2),
           stride=(2, 2))
  h2_conv = npx.convolution(data=h1, weight=params[2], bias=params[3],
               kernel=(5, 5), num_filter=50)
  h2_activation = npx.relu(h2_conv)
  h2 = npx.pooling(data=h2_activation, pool_type='avg', kernel=(2, 2),
           stride=(2, 2))
  h2 = h2.reshape(h2.shape[0], -1)
  h3_linear = np.dot(h2, params[4]) + params[5]
  h3 = npx.relu(h3_linear)
  y_hat = np.dot(h3, params[6]) + params[7]
  return y_hat

# Cross-entropy loss function
loss = gluon.loss.SoftmaxCrossEntropyLoss()

 

13.5.4。数据同步

为了进行高效的多 GPU 训练,我们需要两个基本操作。首先,我们需要能够将参数列表分发到多个设备并附加梯度 ( get_params)。没有参数就不可能在 GPU 上评估网络。其次,我们需要能够跨多个设备对参数求和,即,我们需要一个 allreduce函数。

 

def get_params(params, device):
  new_params = [p.to(device) for p in params]
  for p in new_params:
    p.requires_grad_()
  return new_params

 

 

def get_params(params, device):
  new_params = [p.copyto(device) for p in params]
  for p in new_params:
    p.attach_grad()
  return new_params

 

让我们通过将模型参数复制到一个 GPU 来尝试一下。

 

new_params = get_params(params, d2l.try_gpu(0))
print('b1 weight:', new_params[1])
print('b1 grad:', new_params[1].grad)

 

 

b1 weight: tensor([0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
    device='cuda:0', requires_grad=True)
b1 grad: None

 

 

new_params = get_params(params, d2l.try_gpu(0))
print('b1 weight:', new_params[1])
print('b1 grad:', new_params[1].grad)

 

 

b1 weight: [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.] @gpu(0)
b1 grad: [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.] @gpu(0)

 

由于我们还没有执行任何计算,因此关于偏置参数的梯度仍然为零。现在让我们假设我们有一个分布在多个 GPU 上的向量。以下allreduce 函数将所有向量相加并将结果广播回所有 GPU。请注意,为了使其正常工作,我们需要将数据复制到设备以累积结果。

 

def allreduce(data):
  for i in range(1, len(data)):
    data[0][:] += data[i].to(data[0].device)
  for i in range(1, len(data)):
    data[i][:] = data[0].to(data[i].device)

 

 

def allreduce(data):
  for i in range(1, len(data)):
    data[0][:] += data[i].copyto(data[0].ctx)
  for i in range(1, len(data)):
    data[0].copyto(data[i])

 

让我们通过在不同设备上创建具有不同值的向量并聚合它们来对此进行测试。

 

data = [torch.ones((1, 2), device=d2l.try_gpu(i)) * (i + 1) for i in range(2)]
print('before allreduce:n', data[0], 'n', data[1])
allreduce(data)
print('after allreduce:n', data[0], 'n', data[1])

 

 

before allreduce:
 tensor([[1., 1.]], device='cuda:0')
 tensor([[2., 2.]], device='cuda:1')
after allreduce:
 tensor([[3., 3.]], device='cuda:0')
 tensor([[3., 3.]], device='cuda:1')

 

 

data = [np.ones((1, 2), ctx=d2l.try_gpu(i)) * (i + 1) for i in range(2)]
print('before allreduce:n', data[0], 'n', data[1])
allreduce(data)
print('after allreduce:n', data[0], 'n', data[1])

 

 

before allreduce:
 [[1. 1.]] @gpu(0)
 [[2. 2.]] @gpu(1)
after allreduce:
 [[3. 3.]] @gpu(0)
 [[3. 3.]] @gpu(1)

 

13.5.5。分发数据

我们需要一个简单的效用函数来在多个 GPU 上平均分配一个小批量。例如,在两个 GPU 上,我们希望将一半数据复制到其中一个 GPU。由于它更方便、更简洁,我们使用深度学习框架的内置函数来尝试一下4×5矩阵。

 

data = torch.arange(20).reshape(4, 5)
devices = [torch.device('cuda:0'), torch.device('cuda:1')]
split = nn.parallel.scatter(data, devices)
print('input :', data)
print('load into', devices)
print('output:', split)

 

 

input : tensor([[ 0, 1, 2, 3, 4],
    [ 5, 6, 7, 8, 9],
    [10, 11, 12, 13, 14],
    [15, 16, 17, 18, 19]])
load into [device(type='cuda', index=0), device(type='cuda', index=1)]
output: (tensor([[0, 1, 2, 3, 4],
    [5, 6, 7, 8, 9]], device='cuda:0'), tensor([[10, 11, 12, 13, 14],
    [15, 16, 17, 18, 19]], device='cuda:1'))

 

 

data = np.arange(20).reshape(4, 5)
devices = [npx.gpu(0), npx.gpu(1)]
split = gluon.utils.split_and_load(data, devices)
print('input :', data)
print('load into', devices)
print('output:', split)

 

 

input : [[ 0. 1. 2. 3. 4.]
 [ 5. 6. 7. 8. 9.]
 [10. 11. 12. 13. 14.]
 [15. 16. 17. 18. 19.]]
load into [gpu(0), gpu(1)]
output: [array([[0., 1., 2., 3., 4.],
    [5., 6., 7., 8., 9.]], ctx=gpu(0)), array([[10., 11., 12., 13., 14.],
    [15., 16., 17., 18., 19.]], ctx=gpu(1))]

 

为了以后重用,我们定义了一个split_batch函数来拆分数据和标签。

 

#@save
def split_batch(X, y, devices):
  """Split `X` and `y` into multiple devices."""
  assert X.shape[0] == y.shape[0]
  return (nn.parallel.scatter(X, devices),
      nn.parallel.scatter(y, devices))

 

 

#@save
def split_batch(X, y, devices):
  """Split `X` and `y` into multiple devices."""
  assert X.shape[0] == y.shape[0]
  return (gluon.utils.split_and_load(X, devices),
      gluon.utils.split_and_load(y, devices))

 

13.5.6。训练

现在我们可以在单个 minibatch 上实现多 GPU 训练。它的实现主要基于本节中描述的数据并行方法。我们将使用我们刚刚讨论的辅助函数allreduce和split_and_load来同步多个 GPU 之间的数据。请注意,我们不需要编写任何特定代码来实现并行性。由于计算图在 minibatch 中没有任何跨设备依赖性,因此它会自动并行执行。

 

def train_batch(X, y, device_params, devices, lr):
  X_shards, y_shards = split_batch(X, y, devices)
  # Loss is calculated separately on each GPU
  ls = [loss(lenet(X_shard, device_W), y_shard).sum()
     for X_shard, y_shard, device_W in zip(
       X_shards, y_shards, device_params)]
  for l in ls: # Backpropagation is performed separately on each GPU
    l.backward()
  # Sum all gradients from each GPU and broadcast them to all GPUs
  with torch.no_grad():
    for i in range(len(device_params[0])):
      allreduce([device_params[c][i].grad for c in range(len(devices))])
  # The model parameters are updated separately on each GPU
  for param in device_params:
    d2l.sgd(param, lr, X.shape[0]) # Here, we use a full-size batch

 

 

def train_batch(X, y, device_params, devices, lr):
  X_shards, y_shards = split_batch(X, y, devices)
  with autograd.record(): # Loss is calculated separately on each GPU
    ls = [loss(lenet(X_shard, device_W), y_shard)
       for X_shard, y_shard, device_W in zip(
         X_shards, y_shards, device_params)]
  for l in ls: # Backpropagation is performed separately on each GPU
    l.backward()
  # Sum all gradients from each GPU and broadcast them to all GPUs
  for i in range(len(device_params[0])):
    allreduce([device_params[c][i].grad for c in range(len(devices))])
  # The model parameters are updated separately on each GPU
  for param in device_params:
    d2l.sgd(param, lr, X.shape[0]) # Here, we use a full-size batch

 

现在,我们可以定义训练函数。它与前面章节中使用的略有不同:我们需要分配 GPU 并将所有模型参数复制到所有设备。显然,每个批次都是使用train_batch处理多个 GPU 的函数进行处理的。为了方便(和代码的简洁性),我们在单个 GPU 上计算精度,但由于其他 GPU 处于空闲状态,因此效率很低。

 

def train(num_gpus, batch_size, lr):
  train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
  devices = [d2l.try_gpu(i) for i in range(num_gpus)]
  # Copy model parameters to `num_gpus` GPUs
  device_params = [get_params(params, d) for d in devices]
  num_epochs = 10
  animator = d2l.Animator('epoch', 'test acc', xlim=[1, num_epochs])
  timer = d2l.Timer()
  for epoch in range(num_epochs):
    timer.start()
    for X, y in train_iter:
      # Perform multi-GPU training for a single minibatch
      train_batch(X, y, device_params, devices, lr)
      torch.cuda.synchronize()
    timer.stop()
    # Evaluate the model on GPU 0
    animator.add(epoch + 1, (d2l.evaluate_accuracy_gpu(
      lambda x: lenet(x, device_params[0]), test_iter, devices[0]),))
  print(f'test acc: {animator.Y[0][-1]:.2f}, {timer.avg():.1f} sec/epoch '
     f'on {str(devices)}')

 

 

def train(num_gpus, batch_size, lr):
  train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
  devices = [d2l.try_gpu(i) for i in range(num_gpus)]
  # Copy model parameters to `num_gpus` GPUs
  device_params = [get_params(params, d) for d in devices]
  num_epochs = 10
  animator = d2l.Animator('epoch', 'test acc', xlim=[1, num_epochs])
  timer = d2l.Timer()
  for epoch in range(num_epochs):
    timer.start()
    for X, y in train_iter:
      # Perform multi-GPU training for a single minibatch
      train_batch(X, y, device_params, devices, lr)
      npx.waitall()
    timer.stop()
    # Evaluate the model on GPU 0
    animator.add(epoch + 1, (d2l.evaluate_accuracy_gpu(
      lambda x: lenet(x, device_params[0]), test_iter, devices[0]),))
  print(f'test acc: {animator.Y[0][-1]:.2f}, {timer.avg():.1f} sec/epoch '
     f'on {str(devices)}')

 

让我们看看它在单个 GPU 上的表现如何。我们首先使用 256 的批量大小和 0.2 的学习率。

 

train(num_gpus=1, batch_size=256, lr=0.2)

 

 

test acc: 0.84, 4.1 sec/epoch on [device(type='cuda', index=0)]

 

pytorch

 

train(num_gpus=1, batch_size=256, lr=0.2)

 

 

test acc: 0.83, 6.3 sec/epoch on [gpu(0)]

 

pytorch

通过保持 batch size 和 learning rate 不变,并将 GPU 的数量增加到 2,我们可以看到测试准确率与之前的实验相比大致保持不变。就优化算法而言,它们是相同的。不幸的是,这里没有获得有意义的加速:模型太小了;此外,我们只有一个小数据集,其中我们实现多 GPU 训练的稍微简单的方法遭受了显着的 Python 开销。我们将遇到更复杂的模型和更复杂的并行化方式。让我们看看 Fashion-MNIST 会发生什么。

 

train(num_gpus=2, batch_size=256, lr=0.2)

 

 

test acc: 0.83, 4.6 sec/epoch on [device(type='cuda', index=0), device(type='cuda', index=1)]

 

pytorch

 

train(num_gpus=2, batch_size=256, lr=0.2)

 

 

test acc: 0.85, 13.7 sec/epoch on [gpu(0), gpu(1)]

 

pytorch

13.5.7。概括

有多种方法可以在多个 GPU 上拆分深度网络训练。我们可以在层之间、跨层或跨数据拆分它们。前两者需要精心编排的数据传输。数据并行是最简单的策略。

数据并行训练很简单。但是,它增加了有效的小批量大小以提高效率。

在数据并行中,数据被拆分到多个 GPU,其中每个 GPU 执行自己的前向和后向操作,随后聚合梯度并将结果广播回 GPU。

对于较大的小批量,我们可能会使用稍微增加的学习率。

13.5.8。练习

训练时kGPU,将小批量大小从 b到k⋅b,即,按 GPU 的数量进行扩展。

比较不同学习率的准确性。它如何随 GPU 数量扩展?

实现一个更高效的allreduce函数,在不同的 GPU 上聚合不同的参数?为什么效率更高?

实现多 GPU 测试精度计算。

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分