这里首先列举一下借鉴的博客:
DistributedDataParallel 多卡可以分为数据并行(常用方式,每张卡具有相同的模型和参数,训练时将 batch 数据拆分输入不同的模型中),模型并行(将模型拆分,不同部分放置在不同的 GPU 上,并行计算),Workload Partitioning(将模型拆分,不同部分放置在不同的 GPU 上,串行计算)。
由于第一种数据并行方式是最常用,并且是官方实践性能最佳的方式,因此本文相关内容主要针对数据并行方式给出。
GPU 并行计算基础处理流程:设置可见 GPU 并配置并行化参数,创建模型,通过 API 将模型并行化,将模型和数据搬到 GPU,进行前向和后向传播。在前向传播中,会自动将 batch_size 切分后分配到可见的 GPU 上并行计算。结束后,会有一台或其他方式收集前向传播计算结果并根据 loss 更新每块 GPU 上模型参数。
因此在多 GPU 训练时,可以提高整体 batch_size,同时增加 learning_rate(一般为 batch_size 增加倍数的一半)。
torch.cuda
torch.distributed
其他
首先给出几种并行化方案的性能对比图。
测试结果发现 Apex 的加速效果最好,但与 Horovod/Distributed 差别不大,平时可以直接使用内置的 Distributed。
而 torch.nn.DataParallel 效果不好的原因主要是其全程维护一个 optimizer,对各 GPU 上梯度进行求和,而在主 GPU 进行参数更新,之后再将模型参数 broadcast 到其他 GPU。而 DistributedDataParallel 在每次迭代中,每个进程具有自己的 optimizer ,并独立完成所有的优化步骤。在各进程梯度计算完成之后,各进程需要将梯度进行汇总平均,然后再由 rank=0 的进程,将其 broadcast 到所有进程。相较于 DataParallel,torch.distributed 传输的数据量更少,因此速度更快,效率更高。
同时 pytorch 官方文档也建议使用 DistributedDataParallel(DDP)替换 DataParallel(DP),因此本文着重介绍 DistributedDataParallel(DDP)的相关内容。
DDP 多卡运行的本质还是通过创建多个进程来实现并行,但由于多个进程使用的是同一份代码,因此需要在代码中增加相关逻辑来指定进程与 GPU 硬件之间的关联关系。同时为了实现数据的并行化,需要为不同的进程(不同的 GPU)加载不同的数据,因此需要一个特殊的 data sampler 来实现,这个 DDP 通过 torch.utils.data.distributed.DistributedSampler 来实现。
Pytorch 中分布式的基本使用流程如下:
DDP 启动方式存在 TCP 和环境变量启动两种方式,下面给出环境变量启动方式。
## main.py文件
import torch
import argparse
# 新增1:依赖
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
# 新增2:从外面得到local_rank参数,在调用DDP的时候,其会自动给出这个参数,后面还会介绍。所以不用考虑太多,照着抄就是了。
# argparse是python的一个系统库,用来处理命令行调用,如果不熟悉,可以稍微百度一下,很简单!
parser = argparse.ArgumentParser()
parser.add_argument("--local_rank", default=-1)
FLAGS = parser.parse_args()
local_rank = FLAGS.local_rank
# 新增3:DDP backend初始化
# a.根据local_rank来设定当前使用哪块GPU
torch.cuda.set_device(local_rank)
# b.初始化DDP,使用默认backend(nccl)就行。如果是CPU模型运行,需要选择其他后端。
dist.init_process_group(backend='nccl')
# 新增4:定义并把模型放置到单独的GPU上,需要在调用`model=DDP(model)`前做哦。
# 如果要加载模型,也必须在这里做哦。
device = torch.device("cuda", local_rank)
model = nn.Linear(10, 10).to(device)
# 可能的load模型...
# 新增5:之后才是初始化DDP模型
model = DDP(model, device_ids=[local_rank], output_device=local_rank)
model = DDP(model) 实现了把parameter,buffer从master节点传到其他节点,使所有进程上的状态一致。所以,请确保在这一步之后,你的代码不会再修改模型的任何东西了,包括添加、修改、删除parameter和buffer!
其次 model 的定义时,请要求按照网络计算顺序,依次创建网络层。因为为了加速,DDP reducer 内部会根据注册的顺序反向判断各 GPU 中对应模型梯度是否已经全部求出。如果已经全部求出,那么就可以提前进行异步的 all-reduce 梯度平均操作。否则,reducer 会卡在某一个 bucket(将某些相邻的 parameter 组成 bucket 简化计算)等待,使训练时间延长!
因为 optimizer 和 DDP 是没有关系的,所以 optimizer 初始状态的同一性是不被 DDP 保证的!不过,大多数官方optimizer,其实现能保证从同样状态的 model 初始化时,其初始状态是相同的。所以这边我们只要保证在 DDP 模型创建后才初始化 optimizer,就不用做额外的操作。但是,如果自定义optimizer,则需要你自己来保证其统一性!
my_trainset = torchvision.datasets.CIFAR10(root='./data', train=True)
# 新增1:使用DistributedSampler,DDP帮我们把细节都封装起来了。用,就完事儿!
# sampler的原理,后面也会介绍。
train_sampler = torch.utils.data.distributed.DistributedSampler(my_trainset)
# 需要注意的是,这里的batch_size指的是每个进程下的batch_size。也就是说,总batch_size是这里的batch_size再乘以并行数(world_size)。
trainloader = torch.utils.data.DataLoader(my_trainset, batch_size=batch_size, sampler=train_sampler)
for epoch in range(num_epochs):
# 新增2:设置sampler的epoch,DistributedSampler需要这个来维持各个进程之间的相同随机数种子
trainloader.sampler.set_epoch(epoch)
# 后面这部分,则与原来完全一致了。
for data, label in trainloader:
prediction = model(data)
loss = loss_fn(prediction, label)
loss.backward()
optimizer = optim.SGD(ddp_model.parameters(), lr=0.001)
optimizer.step()
DistributedSampler的实现方式是,不同进程会使用一个相同的随机数种子,这样shuffle出来的东西就能确保一致。具体实现上,DistributedSampler使用当前epoch作为随机数种子,从而使得不同epoch下有不同的shuffle结果。所以,记得每次 epoch 开始前都要调 用一下 sampler 的 set_epoch 方法,这样才能让数据集随机 shuffle 起来。
# 1. save模型的时候,和DP模式一样,有一个需要注意的点:保存的是model.module而不是model。
# 因为model其实是DDP model,参数是被`model=DDP(model)`包起来的。
# 2. 我只需要在进程0上保存一次就行了,避免多次保存重复的东西。
if dist.get_rank() == 0:
torch.save(model.module, "saved_model.ckpt")
针对 DDP 并行化,需要用 torch.distributed.launch 来启动训练。其中可选参数如下
## Bash运行
# 假设我们只在一台机器上运行,可用卡数是8
python -m torch.distributed.launch --nproc_per_node 8 main.py
# 假设我们在2台机器上运行,每台可用卡数是8
# 机器1:
python -m torch.distributed.launch --nnodes=2 --node_rank=0 --nproc_per_node 8 \
--master_adderss $my_address --master_port $my_port main.py
# 机器2:
python -m torch.distributed.launch --nnodes=2 --node_rank=1 --nproc_per_node 8 \
--master_adderss $my_address --master_port $my_port main.py
# 假设我们只用4,5,6,7号卡
CUDA_VISIBLE_DEVICES="4,5,6,7" python -m torch.distributed.launch --nproc_per_node 4 main.py
# 假如我们还有另外一个实验要跑,也就是同时跑两个不同实验。
# 这时,为避免master_port冲突,我们需要指定一个新的。这里我随便敲了一个。
CUDA_VISIBLE_DEVICES="4,5,6,7" python -m torch.distributed.launch --nproc_per_node 4 \
--master_port 53453 main.py
这里需要注意:如果连接上的进程数量不足约定的 word_size,进程会一直等待。同时 torch.multiprocessing.spawn 可以对 DDP 进行封装从而实现基于单卡相同的方式启动。
torch.distributed.init_process_group(backend,
init_method=None,
timeout=datetime.timedelta(0, 1800),
world_size=-1,
rank=-1,
store=None)
该函数需要在每个进程中进行调用,用于初始化该进程。在使用分布式时,该函数必须在 distributed 内所有相关函数之前使用。
torch.nn.parallel.DistributedDataParallel(module,
device_ids=None,
output_device=None,
dim=0,
broadcast_buffers=True,
process_group=None,
bucket_cap_mb=25,
find_unused_parameters=False,
check_reduction=False)
将给定的 module 进行分布式封装, 其将输入在 batch 维度上进行划分,并分配到指定的 devices 上。module 会被复制到每台机器的每个 GPU 上,每一个模型的副本处理输入的一部分。在反向传播阶段,每个机器的每个 GPU 上的梯度进行汇总并求平均。与 DataParallel 类似,batch size 应该大于 GPU 总数。
torch.utils.data.distributed.DistributedSampler(dataset, num_replicas=None, rank=None)
1、多 GPU 训练模型保存时会将模型参数保存在 module 域下,如 module.linears.0.weight
torch.save() 和 torch.load() 函数本质将模型参数转存为一个 OrderDict 字典。但当模型是在多 GPU 方式下训练时,标识对应模型参数的 key 会自动添加上 module 中间层。这在重新加载模型时可能造成错误,可以使用如下代码去除 module 层
from collections import OrderedDict
new_state_dict = OrderedDict()
for k, v in state_dict_load.items():
namekey = k[7:] if k.startswith('module.') else k
new_state_dict[namekey] = v
2、想用更大的 batch_size 进行训练,可以使用梯度累加
梯度累加的基本思想在于,在优化器更新参数前,也就是执行 optimizer.step() 前,进行多次反向传播,是的梯度累计值自动保存在 parameter.grad 中,最后使用累加的梯度进行参数更新。这个在 PyTorch 中特别容易实现,因为 PyTorch 中,梯度值本身会保留,除非我们调用 model.zero_grad() 或 optimizer.zero_grad()。
model.zero_grad() # 重置保存梯度值的张量
for i, (inputs, labels) in enumerate(training_set):
predictions = model(inputs) # 前向计算
loss = loss_function(predictions, labels) # 计算损失函数
loss.backward() # 计算梯度
if (i + 1) % accumulation_steps == 0: # 重复多次前面的过程
optimizer.step() # 更新梯度
model.zero_grad() # 重置梯度
3、DDP 中设置的 batch_size 就是这个进程 forward 时使用的 batch_size,因此应该根据 world_size 和 整体需要的 batch_size 来调整每个 DDP 中设置的 batch_size
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
world_size = dist.get_world_size()
assert total_batch_size % opt.world_size == 0, '--batch-size must be multiple of CUDA device count'
train_loader = torch.utils.data.DataLoader(dataset=data_train,
batch_size=total_batch_size // world_size,
# shuffle=True,
num_workers=2,
sampler=train_sampler)
4、由于 BN 层需要基于传入模型的数据计算均值和方差,造成普通 BN 在多卡模式下实际上就是单卡模式。此时需要使用 SyncBN 利用DDP的分布式计算接口来实现真正的多卡BN。
SyncBN利用分布式通讯接口在各卡间进行通讯,传输各自进程小 batch mean 和小 batch variance,在传输少量数据的基础上利用所有数据进行BN计算。不过,当前PyTorch SyncBN只在DDP单进程单卡模式中支持。
同时由于 SyncBN 用到 all_gather 这个分布式计算接口,而使用这个接口需要先初始化DDP环境,因此 SyncBN 需要在 DDP 环境初始化后初始化,但是要在 DDP 模型前就准备好。
最后由于 SyncBN 是直接搜索 model 中每个 module,如果这个 module 是 torch.nn.modules.batchnorm._BatchNorm 的子类,就将其替换为 SyncBN。因此如果你的 Normalization 层是自己定义的特殊类,没有继承过 _BatchNorm 类,那么convert_sync_batchnorm 是不支持的,需要你自己实现一个新的SyncBN!
# DDP init
dist.init_process_group(backend='nccl')
# 按照原来的方式定义模型,这里的BN都使用普通BN就行了。
model = MyModel()
# 引入SyncBN,这句代码,会将普通BN替换成SyncBN。
model = torch.nn.SyncBatchNorm.convert_sync_batchnorm(model).to(device)
# 构造DDP模型
model = DDP(model, device_ids=[local_rank], output_device=local_rank)
5、使用 gradient accumulation 时,减少每一次梯度累加时的无效 all_reduce 从而实现 DDP 的进一步加速。
from contextlib import nullcontext
# 如果你的python版本小于3.7,请注释掉上面一行,使用下面这个:
# from contextlib import suppress as nullcontext
if local_rank != -1:
model = DDP(model)
optimizer.zero_grad()
for i, (data, label) in enumerate(dataloader):
# 只在DDP模式下,轮数不是K整数倍的时候使用no_sync
my_context = model.no_sync if local_rank != -1 and i % K != 0 else nullcontext
with my_context():
prediction = model(data)
loss_fn(prediction, label).backward()
if i % K == 0:
optimizer.step()
optimizer.zero_grad()
DDP 的梯度 all_reduce 发生在 loss.backward() 时,而由于在使用梯度累加时,K 次 backward 之后才会真正使用更新后的梯度,因此前 K-1 次 all_reduce 其实都是无效的。因此可以通过 DDP 给我们提供的 no_sync 函数暂时取消梯度同步,从而进一步加速性能。
6、随机数种子
import random
import numpy as np
import torch
def init_seeds(seed=0, cuda_deterministic=True):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
# Speed-reproducibility tradeoff https://pytorch.org/docs/stable/notes/randomness.html
if cuda_deterministic: # slower, more reproducible
cudnn.deterministic = True
cudnn.benchmark = False
else: # faster, less reproducible
cudnn.deterministic = False
cudnn.benchmark = True
def main():
rank = torch.distributed.get_rank()
# 问题完美解决!
init_seeds(1 + rank)
在实验过程中应该避免 DDP 的每一个进程使用相同的随机数种子,因为这会造成各个进程生成的数据带有一定的同态性,从而降低训练数据的质量和训练效率。
这个和数据并行化时,使用 trainloader.sampler.set_epoch(epoch) 道理一致,这里是为了保证不同的 epoch 间不同进程将得到不同的训练数据。
7、日志的简化输出
import logging
# 给主要进程(rank=0)设置低输出等级,给其他进程设置高输出等级。
logging.basicConfig(level=logging.INFO if rank in [-1, 0] else logging.WARN)
# 普通log,只会打印一次。
logging.info("This is an ordinary log.")
# 危险的warning、error,无论在哪个进程,都会被打印出来,从而方便debug。
logging.error("This is a fatal log!")
这个比较直接,通过配置不同的日志输出等级,从而避免每个进程频繁的输出日志。