PyTorch 单机模型并行最佳实践
随着深度学习模型的不断增大和复杂化,传统的单 GPU 训练方式已难以满足模型对计算资源的需求。模型并行作为一种有效的解决方案,通过将模型的不同部分分配到多个 GPU 上进行计算,使得在单机环境下也能高效地训练大型模型。本文将深入探讨 PyTorch 中单机模型并行的最佳实践方法,帮助您在有限的硬件资源下实现模型的高效训练和推理。
一、模型并行的基本概念
模型并行的核心思想是将模型的不同子网络放置在不同的 GPU 上,并在训练过程中实现各子网络之间的高效通信。与数据并行(DataParallel)不同,模型并行将模型的不同部分分配到多个 GPU 上,而不是在每个 GPU 上复制整个模型。例如,假设一个模型包含 10 层神经网络,使用模型并行时,可以将前 5 层放在一个 GPU 上,后 5 层放在另一个 GPU 上。
通过这种方式,每个 GPU 只需处理模型的一部分,从而能够容纳更大规模的模型。然而,模型并行也带来了额外的通信开销,因为中间输出需要在 GPU 之间进行传输。因此,在实际应用中需要权衡模型并行的性能和通信成本。
二、实现简单的模型并行示例
接下来,我们将通过一个简单的玩具模型来演示如何在 PyTorch 中实现模型并行。
(一)定义模型
import torch
import torch.nn as nn
import torch.optim as optim
class ToyModel(nn.Module):
def __init__(self):
super(ToyModel, self).__init__()
self.net1 = torch.nn.Linear(10, 10).to('cuda:0') # 将第一层线性层放置在 GPU0 上
self.relu = torch.nn.ReLU()
self.net2 = torch.nn.Linear(10, 5).to('cuda:1') # 将第二层线性层放置在 GPU1 上
def forward(self, x):
x = self.relu(self.net1(x.to('cuda:0'))) # 将输入数据移动到 GPU0
return self.net2(x.to('cuda:1')) # 将中间输出移动到 GPU1
(二)定义损失函数和优化器
model = ToyModel()
loss_fn = nn.MSELoss() # 定义均方误差损失函数
optimizer = optim.SGD(model.parameters(), lr=0.001) # 定义随机梯度下降优化器
(三)训练模型
optimizer.zero_grad() # 清空梯度
outputs = model(torch.randn(20, 10)) # 生成随机输入数据并进行前向传播
labels = torch.randn(20, 5).to('cuda:1') # 将标签数据移动到 GPU1 上
loss_fn(outputs, labels).backward() # 计算损失并进行反向传播
optimizer.step() # 更新模型参数
在上述代码中,我们将模型的不同部分分别放置在两个 GPU 上,并通过 .to(device)
方法将输入数据和中间输出在 GPU 之间进行传输。同时,损失函数和优化器的使用与单 GPU 训练时保持一致。
三、将模型并行应用于现有模块
对于已有的模型,我们可以通过继承原模型类并重新定义 forward
方法来实现模型并行。以下以 ResNet50 模型为例进行说明。
(一)定义模型并行的 ResNet50
from torchvision.models.resnet import ResNet, Bottleneck
num_classes = 1000
class ModelParallelResNet50(ResNet):
def __init__(self, *args, **kwargs):
super(ModelParallelResNet50, self).__init__(Bottleneck, [3, 4, 6, 3], num_classes=num_classes, *args, **kwargs)
self.seq1 = nn.Sequential(
self.conv1,
self.bn1,
self.relu,
self.maxpool,
self.layer1,
self.layer2
).to('cuda:0') # 将部分层序列放置在 GPU0 上
self.seq2 = nn.Sequential(
self.layer3,
self.layer4,
self.avgpool,
).to('cuda:1') # 将剩余层序列放置在 GPU1 上
self.fc.to('cuda:1') # 将全连接层放置在 GPU1 上
def forward(self, x):
x = self.seq2(self.seq1(x).to('cuda:1')) # 将中间输出从 GPU0 传输到 GPU1
return self.fc(x.view(x.size(0), -1)) # 在 GPU1 上进行全连接层计算
(二)训练并行模型
import torchvision.models as models
import timeit
import matplotlib.pyplot as plt
import numpy as np
## 定义训练函数
def train(model):
model.train(True)
loss_fn = nn.MSELoss()
optimizer = optim.SGD(model.parameters(), lr=0.001)
one_hot_indices = torch.LongTensor(batch_size).random_(0, num_classes).view(batch_size, 1)
for _ in range(num_batches):
inputs = torch.randn(batch_size, 3, image_w, image_h)
labels = torch.zeros(batch_size, num_classes).scatter_(1, one_hot_indices, 1)
optimizer.zero_grad()
outputs = model(inputs.to('cuda:0')) # 输入数据移动到 GPU0
labels = labels.to(outputs.device) # 标签数据移动到输出所在 GPU
loss_fn(outputs, labels).backward()
optimizer.step()
## 设置训练参数
num_batches = 3
batch_size = 120
image_w = 128
image_h = 128
num_repeat = 10
## 测试模型并行的 ResNet50
model = ModelParallelResNet50()
mp_run_times = timeit.repeat("train(model)", globals=globals(), number=1, repeat=num_repeat)
mp_mean, mp_std = np.mean(mp_run_times), np.std(mp_run_times)
## 测试单 GPU 的 ResNet50
model = models.resnet50(num_classes=num_classes).to('cuda:0')
rn_run_times = timeit.repeat("train(model)", globals=globals(), number=1, repeat=num_repeat)
rn_mean, rn_std = np.mean(rn_run_times), np.std(rn_run_times)
## 绘制执行时间对比图
def plot(means, stds, labels, fig_name):
fig, ax = plt.subplots()
ax.bar(np.arange(len(means)), means, yerr=stds, align='center', alpha=0.5, ecolor='red', capsize=10, width=0.6)
ax.set_ylabel('ResNet50 Execution Time (Second)')
ax.set_xticks(np.arange(len(means)))
ax.set_xticklabels(labels)
ax.yaxis.grid(True)
plt.tight_layout()
plt.savefig(fig_name)
plt.close(fig)
plot([mp_mean, rn_mean], [mp_std, rn_std], ['Model Parallel', 'Single GPU'], 'mp_vs_rn.png')
从实验结果可以看出,模型并行的实现虽然能够解决模型过大无法放入单个 GPU 的问题,但其执行时间通常会长于单 GPU 实现,这是由于 GPU 之间的通信开销所致。
四、通过流水线输入加速模型并行
为了进一步提升模型并行的训练效率,可以采用流水线技术对输入数据进行划分和并行处理。
(一)定义流水线并行模型
class PipelineParallelResNet50(ModelParallelResNet50):
def __init__(self, split_size=20, *args, **kwargs):
super(PipelineParallelResNet50, self).__init__(*args, **kwargs)
self.split_size = split_size
def forward(self, x):
splits = iter(x.split(self.split_size, dim=0)) # 将输入数据按批次划分
s_next = next(splits)
s_prev = self.seq1(s_next).to('cuda:1') # 将第一个批次的数据传入 GPU1
ret = []
for s_next in splits:
s_prev = self.seq2(s_prev) # 在 GPU1 上计算当前批次
ret.append(self.fc(s_prev.view(s_prev.size(0), -1)))
s_prev = self.seq1(s_next).to('cuda:1') # 将下一个批次的数据传入 GPU1,与 GPU1 的计算并行进行
s_prev = self.seq2(s_prev)
ret.append(self.fc(s_prev.view(s_prev.size(0), -1)))
return torch.cat(ret)
(二)测试流水线并行模型
## 测试流水线并行的 ResNet50
model = PipelineParallelResNet50(split_size=20)
pp_run_times = timeit.repeat("train(model)", globals=globals(), number=1, repeat=num_repeat)
pp_mean, pp_std = np.mean(pp_run_times), np.std(pp_run_times)
## 绘制包含流水线并行的执行时间对比图
plot([mp_mean, rn_mean, pp_mean], [mp_std, rn_std, pp_std], ['Model Parallel', 'Single GPU', 'Pipelining Model Parallel'], 'mp_vs_rn_vs_pp.png')
流水线技术通过将输入数据划分为多个批次,并在多个 GPU 上并行处理这些批次,能够显著提高模型并行的训练效率。
五、总结与展望
通过本文,您已经学习了如何在 PyTorch 中实现单机模型并行,包括基本的模型并行概念、实现方法以及通过流水线技术加速模型并行训练的技巧。尽管模型并行在处理大型模型时具有显著优势,但其通信开销也可能成为性能瓶颈。在实际应用中,需要根据模型结构和硬件资源合理选择并行策略,并通过实验优化相关参数。
未来,您可以进一步探索分布式模型并行训练、多机多 GPU 并行等更高级的并行技术,以应对更大规模的模型和更复杂的训练任务。编程狮将持续为您带来更多深度学习模型并行训练的优质教程,助力您在高性能计算领域不断前行。
更多建议: