12.6. 多GPU的简洁实现
Open the notebook in Colab
Open the notebook in Colab
Open the notebook in Colab

每个新模型的并行计算都从零开始实现是无趣的。此外,优化同步工具以获得高性能也是有好处的。下面我们将展示如何使用深度学习框架的高级 API 来实现这一点。数学和算法与 12.5节 中的相同。不出所料,你至少需要两个 GPU 来运行本节的代码。

from mxnet import autograd, gluon, init, np, npx
from mxnet.gluon import nn
from d2l import mxnet as d2l

npx.set_np()
import torch
from torch import nn
from d2l import torch as d2l

12.6.1. 简单网络

让我们使用一个比 12.5节 的 LeNet 更有意义的网络,它依然能够容易地和快速地训练。我们选择的是 [He et al., 2016a] 中的 ResNet-18。因为输入的图像很小,所以稍微修改了一下。与 7.6节 的区别在于,我们在开始时使用了更小的卷积核、步长和填充,而且删除了最大汇聚层。

#@save
def resnet18(num_classes):
    """稍加修改的 ResNet-18 模型。"""
    def resnet_block(num_channels, num_residuals, first_block=False):
        blk = nn.Sequential()
        for i in range(num_residuals):
            if i == 0 and not first_block:
                blk.add(d2l.Residual(
                    num_channels, use_1x1conv=True, strides=2))
            else:
                blk.add(d2l.Residual(num_channels))
        return blk

    net = nn.Sequential()
    # 该模型使用了更小的卷积核、步长和填充,而且删除了最大汇聚层。
    net.add(nn.Conv2D(64, kernel_size=3, strides=1, padding=1),
            nn.BatchNorm(), nn.Activation('relu'))
    net.add(resnet_block(64, 2, first_block=True),
            resnet_block(128, 2),
            resnet_block(256, 2),
            resnet_block(512, 2))
    net.add(nn.GlobalAvgPool2D(), nn.Dense(num_classes))
    return net
#@save
def resnet18(num_classes, in_channels=1):
    """稍加修改的 ResNet-18 模型。"""
    def resnet_block(in_channels, out_channels, num_residuals,
                     first_block=False):
        blk = []
        for i in range(num_residuals):
            if i == 0 and not first_block:
                blk.append(d2l.Residual(in_channels, out_channels,
                                        use_1x1conv=True, strides=2))
            else:
                blk.append(d2l.Residual(out_channels, out_channels))
        return nn.Sequential(*blk)

    # 该模型使用了更小的卷积核、步长和填充,而且删除了最大汇聚层。
    net = nn.Sequential(
        nn.Conv2d(in_channels, 64, kernel_size=3, stride=1, padding=1),
        nn.BatchNorm2d(64),
        nn.ReLU())
    net.add_module("resnet_block1", resnet_block(64, 64, 2, first_block=True))
    net.add_module("resnet_block2", resnet_block(64, 128, 2))
    net.add_module("resnet_block3", resnet_block(128, 256, 2))
    net.add_module("resnet_block4", resnet_block(256, 512, 2))
    net.add_module("global_avg_pool", nn.AdaptiveAvgPool2d((1,1)))
    net.add_module("fc", nn.Sequential(nn.Flatten(),
                                       nn.Linear(512, num_classes)))
    return net

12.6.2. 网络初始化

initialize 函数允许我们在所选设备上初始化参数。请参阅 4.8节 复习初始化方法。这个函数在多个设备上初始化网络时特别方便。让我们在实践中试一试它的运作方式。

net = resnet18(10)
# 获取GPU列表
devices = d2l.try_all_gpus()
# 初始化网络的所有参数
net.initialize(init=init.Normal(sigma=0.01), ctx=devices)

使用 12.5节 中引入的 split_and_load 函数可以切分一个小批量数据,并将切分后的分块数据复制到 devices 变量提供的设备列表中。网络实例自动使用适当的 GPU 来计算前向传播的值。我们将在下面生成 \(4\) 个观测值,并在 GPU 上将它们拆分。

x = np.random.uniform(size=(4, 1, 28, 28))
x_shards = gluon.utils.split_and_load(x, devices)
net(x_shards[0]), net(x_shards[1])
(array([[ 2.2610238e-06,  2.2045988e-06, -5.4046791e-06,  1.2869939e-06,
          5.1373154e-06, -3.8298003e-06,  1.4338991e-07,  5.4683424e-06,
         -2.8279187e-06, -3.9651113e-06],
        [ 2.0698699e-06,  2.0084674e-06, -5.6382478e-06,  1.0498467e-06,
          5.5506403e-06, -4.1065500e-06,  6.0830121e-07,  5.4521761e-06,
         -3.7365019e-06, -4.1891644e-06]], ctx=gpu(0)),
 array([[ 2.4629785e-06,  2.6015518e-06, -5.4362636e-06,  1.2938228e-06,
          5.6387912e-06, -4.1360131e-06,  3.5758842e-07,  5.5125242e-06,
         -3.1957325e-06, -4.2976335e-06],
        [ 1.9431677e-06,  2.2600434e-06, -5.2698206e-06,  1.4807442e-06,
          5.4830939e-06, -3.9678885e-06,  7.5754315e-08,  5.6764375e-06,
         -3.2530229e-06, -4.0943960e-06]], ctx=gpu(1)))

一旦数据通过网络,网络对应的参数就会在 有数据通过的设备上初始化。这意味着初始化是基于每个设备进行的。由于我们选择的是 GPU 0 和 GPU 1,所以网络只在这两个 GPU 上初始化,而不是在 CPU 上初始化。事实上,CPU 上甚至没有这些参数。我们可以通过打印参数和观察可能出现的任何错误来验证这一点。

weight = net[0].params.get('weight')

try:
    weight.data()
except RuntimeError:
    print('not initialized on cpu')
weight.data(devices[0])[0], weight.data(devices[1])[0]
not initialized on cpu
(array([[[ 0.01382882, -0.01183044,  0.01417866],
         [-0.00319718,  0.00439528,  0.02562625],
         [-0.00835081,  0.01387452, -0.01035946]]], ctx=gpu(0)),
 array([[[ 0.01382882, -0.01183044,  0.01417866],
         [-0.00319718,  0.00439528,  0.02562625],
         [-0.00835081,  0.01387452, -0.01035946]]], ctx=gpu(1)))

接下来,让我们使用在多个设备上并行工作的代码来替换前面的评估模型的代码。 这里主要是 6.6节evaluate_accuracy_gpu 函数的替代,代码的主要区别在于在调用网络之前拆分了一个小批量,其他在本质上是一样的。

#@save
def evaluate_accuracy_gpus(net, data_iter, split_f=d2l.split_batch):
    """使用多个GPU计算数据集上模型的精度。"""
    # 查询设备列表
    devices = list(net.collect_params().values())[0].list_ctx()
    # 正确预测的数量,预测的总数量
    metric = d2l.Accumulator(2)
    for features, labels in data_iter:
        X_shards, y_shards = split_f(features, labels, devices)
        # 并行运行
        pred_shards = [net(X_shard) for X_shard in X_shards]
        metric.add(sum(float(d2l.accuracy(pred_shard, y_shard)) for
                       pred_shard, y_shard in zip(
                           pred_shards, y_shards)), labels.size)
    return metric[0] / metric[1]

我们将在训练回路中初始化网络。请参见 4.8节 复习初始化方法。

net = resnet18(10)
# 获取GPU列表
devices = d2l.try_all_gpus()
# 我们将在训练代码实现中初始化网络

12.6.3. 训练

如前所述,用于训练的代码需要执行几个基本功能才能实现高效并行:

  • 需要在所有设备上初始化网络参数。

  • 在数据集上迭代时,要将小批量数据分配到所有设备上。

  • 跨设备并行计算损失及其梯度。

  • 聚合梯度,并相应地更新参数。

最后,并行地计算精确度和发布网络的最终性能。除了需要拆分和聚合数据外,训练代码与前几章的实现非常相似。

def train(num_gpus, batch_size, lr):
    train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
    ctx = [d2l.try_gpu(i) for i in range(num_gpus)]
    net.initialize(init=init.Normal(sigma=0.01), ctx=ctx, force_reinit=True)
    trainer = gluon.Trainer(net.collect_params(), 'sgd',
                            {'learning_rate': lr})
    loss = gluon.loss.SoftmaxCrossEntropyLoss()
    timer, num_epochs = d2l.Timer(), 10
    animator = d2l.Animator('epoch', 'test acc', xlim=[1, num_epochs])
    for epoch in range(num_epochs):
        timer.start()
        for features, labels in train_iter:
            X_shards, y_shards = d2l.split_batch(features, labels, ctx)
            with autograd.record():
                ls = [loss(net(X_shard), y_shard) for X_shard, y_shard
                      in zip(X_shards, y_shards)]
            for l in ls:
                l.backward()
            trainer.step(batch_size)
        npx.waitall()
        timer.stop()
        animator.add(epoch + 1, (evaluate_accuracy_gpus(net, test_iter),))
    print(f'test acc: {animator.Y[0][-1]:.2f}, {timer.avg():.1f} sec/epoch '
          f'on {str(ctx)}')
def train(net, 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)]
    def init_weights(m):
        if type(m) in [nn.Linear, nn.Conv2d]:
            nn.init.normal_(m.weight, std=0.01)
    net.apply(init_weights)
    # 在多个 GPU 上设置模型
    net = nn.DataParallel(net, device_ids=devices)
    trainer = torch.optim.SGD(net.parameters(), lr)
    loss = nn.CrossEntropyLoss()
    timer, num_epochs = d2l.Timer(), 10
    animator = d2l.Animator('epoch', 'test acc', xlim=[1, num_epochs])
    for epoch in range(num_epochs):
        net.train()
        timer.start()
        for X, y in train_iter:
            trainer.zero_grad()
            X, y = X.to(devices[0]), y.to(devices[0])
            l = loss(net(X), y)
            l.backward()
            trainer.step()
        timer.stop()
        animator.add(epoch + 1, (d2l.evaluate_accuracy_gpu(net, test_iter),))
    print(f'test acc: {animator.Y[0][-1]:.2f}, {timer.avg():.1f} sec/epoch '
          f'on {str(devices)}')

让我们看看这在实践中是如何运作的。我们先在单个GPU上训练网络进行预热。

train(num_gpus=1, batch_size=256, lr=0.1)
test acc: 0.93, 13.4 sec/epoch on [gpu(0)]
../_images/output_multiple-gpus-concise_2e111f_47_1.svg
train(net, num_gpus=1, batch_size=256, lr=0.1)
test acc: 0.92, 14.0 sec/epoch on [device(type='cuda', index=0)]
../_images/output_multiple-gpus-concise_2e111f_50_1.svg

接下来我们使用 2 个 GPU 进行训练。与 12.5节 中评估的 LeNet 相比,ResNet-18 的模型要复杂得多。这就是显示并行化优势的地方,计算所需时间明显大于同步参数需要的时间。因为并行化开销的相关性较小,因此这种操作提高了模型的可伸缩性。

train(num_gpus=2, batch_size=512, lr=0.2)
test acc: 0.91, 7.8 sec/epoch on [gpu(0), gpu(1)]
../_images/output_multiple-gpus-concise_2e111f_56_1.svg
train(net, num_gpus=2, batch_size=512, lr=0.2)
test acc: 0.75, 9.0 sec/epoch on [device(type='cuda', index=0), device(type='cuda', index=1)]
../_images/output_multiple-gpus-concise_2e111f_59_1.svg

12.6.4. 小结

  • Gluon 通过提供一个上下文列表,为跨多个设备的模型初始化提供原语。

    • 神经网络可以在(可找到数据的)单 GPU 上进行自动评估。

  • 注意每台设备上的网络需要先初始化,然后再尝试访问该设备上的参数,否则会遇到错误。

  • 优化算法在多个 GPU 上自动聚合。

  • 神经网络可以在(可找到数据的)单 GPU 上进行自动评估。

  • 注意每台设备上的网络需要先初始化,然后再尝试访问该设备上的参数,否则会遇到错误。

  • 优化算法在多个 GPU 上自动聚合。

12.6.5. 练习

  1. 本节使用 ResNet-18。尝试不同的迭代周期数、批量大小和学习率,以及使用更多的 GPU 进行计算。如果使用 \(16\) 个 GPU(例如,在 AWS p2.16xlarge 实例上)尝试此操作,会发生什么?

  2. 有时候不同的设备提供了不同的计算能力,我们可以同时使用 GPU 和 CPU,那应该如何分配工作?为什么?

  3. 如果去掉 npx.waitall() 会怎样?你将如何修改训练,以使并行操作最多有两个步骤重叠?

Discussions

  1. 本节使用 ResNet-18。尝试不同的迭代周期数、批量大小和学习率,以及使用更多的 GPU 进行计算。如果使用 \(16\) 个 GPU(例如,在 AWS p2.16xlarge 实例上)尝试此操作,会发生什么?

  2. 有时候不同的设备提供了不同的计算能力,我们可以同时使用 GPU 和 CPU,那应该如何分配工作?为什么?

Discussions