# 分布式训练相比较单线程训练需要做出什么改变?
1、启动的命令行
以前使用python train.py启动一个线程进行训练,现在需要一个新的启动方式,从而让机器知道现在要启动八个线程了。这八个线程之间的通讯方式完全由torch帮我们解决。而且我们还可以知道,torch会帮助我们产生一个local rank的标志,如果是八个线程,每个线程的local rank编号会是0-7。这样我们就可以通过local rank确定当前的线程具体是哪一个线程,从而将其绑定至对应的显卡。
2、model的改变
9个线程如果各自train各自的model,那也是白搭。所以我们要将model改成支持分布式的model。torch提供的解决思路是,分布式的model在多个线程中始终保持参数一致。任意一个线程梯度传播造成的参数改变会影响所有model中的参数。
3、数据分配的改变
假设我们仍然使用传统的dataloader去处理数据,会发生什么?八个线程,拿到了一样的set,然后进行训练。虽然这样训练一个周期相当于训练了八个周期,但是总感觉怪怪的。我们拿到一个任务,肯定希望它被拆分成多份给不同的工人去做,而不是每个工人都做一遍全部任务。所以我们希望dataloader可以把train set拆成八份,每个线程train自己获得的那一份data。
4、evaluation
train完了模型我们肯定要进行性能测试。由于八个线程上的model参数是一样的,所以我们在任意一个线程上evaluation就可以了。这个时候有人可能要说,我可不可以把evaluation也变成多线程的呢?我觉得可以,但是会有一个问题。我们通常希望获得model在test set上的表现比如mae loss。我们去看第三点,分布式的操作会把set分成多块,如果evaluation变成分布式会让八个线程得到八个mae loss,而这8个loss是对于8个被拆分的test set的。怎么把这8个loss结合成对于整体test set的loss是我们需要考虑的问题。mae loss还好,直接取平均看起来还比较合理,但是我面对的任务是要去评估precision_recall曲线的。我没有想好怎么分布式地评估P_R曲线,同时注意到test set相比较train set规模小了太多,我直接单线程的进行evaluation是一个比较方便的做法。当然我相信一定有方法可以分布式地进行eval,但是我面临的问题不需要这么做。(简言之,test的时候,batch_size可以增大N倍)
一、pytorch 使用单机多卡,大体上有两种方式:
- 简单方便的 torch.nn.DataParallel(很 low,但是真的很简单很友好)
- 使用 torch.distributed 加速并行训练(推荐,但是不友好)
二、torch.nn.DataParallel和torch.distributed 的优缺点:
nn.DataParallel(不推荐)
- 优点:简单
- 缺点:所有的数据要先load到主GPU上,然后再分发给每个GPU去train。此时主GPU显存占用很大。如果想提升batch_size,那主GPU就会限制batch_size,所以其实多卡提升速度的效果很有限。
注意: 模型被copy到每一张卡上的,而且对于每一个BATCH的数据,设置的batch_size会被分成几个部分,分发给每一张卡,意味着batch_size最好是卡的数量n的倍数,比如batch_size=6,而你有n=4张卡,那你实际上代码跑起来只能用3张卡,因为6整除3。
torch.distributed(推荐)
- 优点: 避免nn.DataParallel的主要缺点,数据不会再load到主卡上,所以所有卡的显存占用很均匀
- 缺点: 不友好,调代码需要点精力,有很多需要注意的问题。(问题见后面)
三、torch.nn.DataParallel
要的修改就是用nn.DataParallel
处理一下model
model = nn.DataParallel(model.cuda(), device_ids=gpus, output_device=gpus[0])
这个很简单,就直接上个例子,根据这个例子去改代码就好(按照这个代码去修改就行了,简单粗暴,暴力但有效),主要就是注意对model
的修改。注意model要放在主GPU上:model.to(device)
# main.py
import torch
import torch.distributed as dist
gpus = [0, 1, 2, 3]
torch.cuda.set_device('cuda:{}'.format(gpus[0]))
train_dataset = ...
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=...)
model = ...
model = nn.DataParallel(model.to(device), device_ids=gpus, output_device=gpus[0]) #注意model要放在主GPU上
optimizer = optim.SGD(model.parameters())
for epoch in range(100):
for batch_idx, (data, target) in enumerate(train_loader):
images = images.cuda(non_blocking=True)
target = target.cuda(non_blocking=True)
...
output = model(images)
loss = criterion(output, target)
...
optimizer.zero_grad()
loss.backward()
optimizer.step()
四、torch.distributed加速
与 DataParallel 的单进程控制多 GPU 不同,在 distributed 的帮助下,只需要编写一份代码,torch 就会自动将其分配给多个进程,分别在多个 GPU 上运行。
1、要使用torch.distributed
,需要在main.py(也就是主py脚本)
中的主函数中加入参数接口:--local_rank
parser = argparse.ArgumentParser()
parser.add_argument('--local_rank', default=-1, type=int,
help='node rank for distributed training')
args = parser.parse_args()
print(args.local_rank)
2、使用 init_process_group 设置GPU 之间通信使用的后端和端口:
dist.init_process_group(backend='nccl')
3、使用 DistributedSampler 对数据集进行划分:
train_sampler = torch.utils.data.distributed.DistributedSampler(train_dataset)
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=..., sampler=train_sampler)
4、使用 DistributedDataParallel 包装模型
model = torch.nn.parallel.DistributedDataParallel(model, device_ids=[args.local_rank])
举个栗子,参照这个例子去设置代码结构
# main.py
import torch
import argparse
import torch.distributed as dist
#(1)要使用`torch.distributed`,你需要在你的`main.py(也就是你的主py脚本)`中的主函数中加入一个**参数接口:`--local_rank`**
parser = argparse.ArgumentParser()
parser.add_argument('--local_rank', default=-1, type=int,
help='node rank for distributed training')
args = parser.parse_args()
#(2)使用 init_process_group 设置GPU 之间通信使用的后端和端口:
dist.init_process_group(backend='nccl')
torch.cuda.set_device(args.local_rank)
#(3)使用 DistributedSampler 对数据集进行划分:
train_dataset = ...
train_sampler = torch.utils.data.distributed.DistributedSampler(train_dataset)
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=..., sampler=train_sampler)
#(4)使用 DistributedDataParallel 包装模型
model = ...
model = torch.nn.parallel.DistributedDataParallel(model, device_ids=[args.local_rank])
optimizer = optim.SGD(model.parameters())
for epoch in range(100):
for batch_idx, (data, target) in enumerate(train_loader):
images = images.cuda(non_blocking=True)
target = target.cuda(non_blocking=True)
...
output = model(images)
loss = criterion(output, target)
...
optimizer.zero_grad()
loss.backward()
optimizer.step()
然后,使用以下指令,执行你的主脚本,其中--nproc_per_node=4
表示你的单个节点的GPU数量:
CUDA_VISIBLE_DEVICES=0,1,2,3 python -m torch.distributed.launch --nproc_per_node=4 main.py
五、问题
你可能会在完成代码之后遇到各种问题,我这里列举一些要注意的点,去避坑。如果你遇到的莫名奇妙报错的问题,尝试这样去修改你的代码。
device
的设置
需要设置一个device
参数,用来给数据加载到GPU上,由于你的数据会在不同线程中被加载到不同的GPU上,你需要传给他们一个参数device
,用于a.to(device)
的操作(a是一个tensor)
device = torch.device("cuda", args.local_rank)
- find_unused_parameters=True
这个是为了解决你的模型中定义了一些在forward函数中没有用到的网络层,会被视为”unused_layer“,这会引发错误,所以你在使用 DistributedDataParallel 包装模型的时候,传一个find_unused_parameters=True的参数来避免这个问题,如下:
encoder=nn.parallel.DistributedDataParallel(encoder, device_ids=[args.local_rank],find_unused_parameters=True)
- shuffle=False
DataLoader
不要设置shuffle=True
valid_loader = torch.utils.data.DataLoader(
part_valid_set, batch_size=BATCH, shuffle=False, num_workers=num_workers,sampler=valid_sampler)
- num_workers
很好理解,尽量不要给你的DataLoader
设置numworkers
参数,可能会有一些问题(不要太强迫症)
# 下划线-------------------------------------------------------
六、pytorch 1 .11之后的DDP
pytorch对于分布式训练有多次更新,导致网上看到的教程经常是过期的。比如大部分的教程启动多线程的命令还是python -m torch.distributed.launch, 但是torch 1.11.0提供了更好的启动命令torchrun,同时对于local rank的使用也有了优化。
未来的某一天,我这篇文章中提到的api也会被更新更好的api所取缔,但是只要我们理解了分布式训练的基础原理,遵循着pytorch官方文档,一定能写出非常优秀的分布式训练代码。
变化的地方:
1、代码支持分布式
引入一些支持分布式通讯的一些代码。需要在代码最前端添加:
import os
local_rank = int(os.environ["LOCAL_RANK"])
import torch.distributed as dist
dist.init_process_group(backend="gloo|nccl")
local rank是每个进程的标签,对于八个进程,local_rank这个变量最后会被分配0-7的整数。关于local rank这段代码的写法,也有教程是这么写的:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--local_rank", type=int)
args = parser.parse_args()
local_rank = args.local_rank
#在torch 1.10之前采用这段代码,1.10之后已经不这么写了
在1.10之前确实是用argparse拿local rank这个参数的。但是新版的api已经改成了用os的写法。所以看完教程之后一定要去看一眼官方文档,跟着官方文档写的代码永远是最靠谱的代码。
2、model支持分布式:
local_rank = int(os.environ["LOCAL_RANK"])
model = torch.nn.parallel.DistributedDataParallel(model, device_ids=[local_rank],output_device=local_rank)
第二句代码很有意思,device_ids=[local_rank]。假设我们有8个gpu,我们同时知道对于不同的线程local_rank被赋的值是不一样的。所以这一步在不同的线程中,会把model放在不同的gpu上,这样就非常简洁地完成了gpu和线程之间的对应操作。关于这句代码到底怎么写,我看不同人的教程写法也经常有些许出入,尤其是output_device那边。但是我这段代码是抄官方文档的。一切以官方文档为准。
3、代码运行:
OMP_NUM_THREADS=12 torchrun --standalone --nnodes=1 --nproc_per_node=8 train_du.py --trainfile train.tsv --testfile testh2_DU.tsv --train_epoch 5 --batch 256 --lr 1e-4 --task_name train --num_layers 3 --save_step 150
在torchrun和py文件之间又三个关于分布式的参数,如果是单机多卡前两个都是不变的,最后一个per_node填显卡个数即可。我有八张卡所以写的8。如果是多机多卡这边会有不同的写法,建议查询官方文档。
torchrun前面还有一个OMP_NUM_THREAD的参数,如果不写,程序也可以执行,但是会报一个warning: Setting OMP_NUM_THREADS environment variable for each process to be 1 in default, to avoid your system being overloaded, please further tune the variable for optimal performance in your application as needed。我的理解是由于你使用了多进程运行py文件,intel怕使用太多线程把cpu过载了,默认将每个进程可以使用的线程数调成了1,最大程度地避免过载。可以直接不写OMP参数并且忽略这个warning,因为我认为机器学习的主要时间耗费在gpu的运算上而不是cpu的调度。但是我的设备说明书上写了可以支持超过一百个线程,那我设置成12,同时使用8个进程也只会耗费96个thread不会过载,所以就写了这个参数。