https://github.com/rickyang1114/DDP-practice/tree/main

https://github.com/pytorch/examples/tree/main/imagenet

简易DDP模版,只考虑单机多卡,完整程序在这里

入口

首先,我们在if __name__ == '__main__'中启动 DDP:

spawn函数的主要参数包括以下几个:

  1. fn,即上面传入的main函数。每个线程将执行一次该函数

  2. args,即fn所需的参数。传给fn的参数必须写成元组的形式,哪怕像上面一样只有一个

  3. nprocs启动的进程数,将其设置为world_size即可。不传默认为1,与world_size不一致会导致进程等待同步而一直停滞。

入口 → spawn nprocs=4 → 启动4个训练进程 ↘ 每个子进程 → 初始化 DDP(world_size=4, rank=0~3) ↘ 所有进程通过通信组形成 DDP 同步机制

再假设有个复杂场景:跨两台机器进行 DDP 训练,但每台机器上使用的 GPU 数不同:

总共 7 个进程 / 7 张卡,此时:

参数说明
world_size7全部进程数量 = 3 + 4
nprocs每台机器的卡数A 机器是 3,B 机器是 4
rank每个进程的全局编号A 机器是 0~2,B 机器是 3~6

机器A启动:

机器B启动:

所以多机多卡对我们训练来说差不多的。类似 rank = int(os.environ["RANK"]) 获取一下global rank即可。 底层通信机制不一样,但无需感知。

本地进程间通信(通过 NCCL 共享内存 + socket)、跨节点网络通信(NCCL over TCP)

单机多卡时MASTER_ADDR配置为本地回环地址localhost,而多机多卡时会配置为主节点IP。

初始化

prepare函数里面,也进行了一些 DDP 的配置:

主函数

再来看看main函数里面添加了什么。首先是其添加一个额外的参数local_rank(在mp.spawn里面不用传,会自动分配,但都是从0开始,所以只能叫local rank)

DDP初始化

首先,根据用init_ddp函数对模型进行初始化。这里我们使用 nccl 后端,并用 env 作为初始化方法:

不过我觉得device = torch.device('cuda:{}'.format(rank)),然后统一to(device)更好

在完成了该初始化后,可以很轻松地在需要时获得local_rankworld_size,而不需要作为额外参数从main中一层一层往下传。

比如需要print, log, save_state_dict时,由于多个进程拥有相同的副本,故只需要一个进程执行即可,比如:

模型

为了加速推理,我们在模型的forward方法里套一个torch.cuda.amp.autocast()

使得forward函数变为:

autocast 也可以在推理的时候再套,但是在这里套最方便,而且适用于所有情况。

在模型改变之后,使用convert_sync_batchnormDistributedDataParallel对模型进行包装。

scaler

创建 scaler,用于训练时对 loss 进行 scale:

训练

训练时,需要使用 DDP 的sampler,并且在num_workers > 1时需要传入generator,否则对于同一个worker,所有进程的augmentation相同,减弱训练的随机性。详细分析参见这篇文章

并且在多个epoch的训练时,需要设置train_dloader.sampler.set_epoch(epoch)

下面来看看train函数。

最后三行发生了改变。相较于原始的loss.backwardoptimizer.step(),这里通过scaler对梯度进行缩放,防止由于使用混合精度导致损失下溢,并且对scaler自身的状态进行更新呢。如果有多个loss,它们也使用同一个scaler。如果需要保存模型的state_dict并且在后续继续训练(比如预训练-微调模式),最好连带scaler的状态一起保留,并在后续的微调过程中和模型的参数一同加载。

测试

测试时,需要将多个进程的数据reduce到一张卡上。注意,在test函数的外面加上if local_rank == 0,否则多个进程会彼此等待而陷入死锁。

注释的两行即为所需添加的reduce操作。

至此,添加的代码讲解完毕。

启动的方式变化不大:

相应的结果:

用torchrun启动

上述是通过mp.spawn启动。mp模块对multiprocessing库进行封装,并没有特定针对DDP。我们还可以通过官方推荐的torchrun进行启动。完整的程序在这里

相比mp.spawn启动,torchrun自动控制一些环境变量的设置,因而更为方便。我们只需要设置os.environ['CUDA_VISIBLE_DEVICES']即可(不设置默认为该机器上的所有GPU),而无需设置os.environ['MASTER_ADDR']等。此外,main函数不再需要local_rank参数。程序入口变为:

运行脚本的命令由python变为了torchrun,如下:

其中,nproc_per_node表示进程数,将其设置为使用的GPU数量即可。

 

torchrun 是 PyTorch 官方推荐的启动分布式训练(DDP)的工具,是对早期 python -m torch.distributed.launch 的升级替代。它非常适合 单机多卡多机多卡 的训练脚本。

常用启动参数(OPTIONS)

参数作用示例
--nproc_per_node当前节点使用的 GPU 数量(即几张卡)--nproc_per_node=4
--nnodes总共多少个节点(机器)--nnodes=2
--node_rank当前节点的编号(0 开始)--node_rank=0
--master_addr主节点 IP(或 hostname)--master_addr=192.168.1.100
--master_port主节点端口(通信端口)--master_port=29500
--rdzv_backend通信后端,一般默认不写默认使用 c10d
--max_restarts进程最大重启次数可选,调试用
--monitor_interval监测失败进程的间隔时间(秒)可选

每个进程的 RANK 会自动计算为 node_rank * nproc_per_node + local_rank

小知识:不同节点GPU数量不一样,nproc_per_node设置为不同即可。只是很少有这种场景。

 

Checklist

在写完 DDP 的代码之后,最好检查一遍,否则很容易因为漏了什么而出现莫名奇妙的错误,比如程序卡着不动了,也不报错)

大致需要检查:

  1. DDP 初始化有没有完成,包括if __name__ == '__main__'里和main函数里的。退出main函数时摧毁进程。

  2. 模型的封装,包括autocast,BN 层的转化和 DDP 封装

  3. 指定train_dloadersamplergeneratorshuffle,并且在每个epoch设置sampler,测试集、验证集同理。

  4. 训练时使用scalerloss进行scale

  5. 对于printlogsave等操作,仅在一个线程上进行。

  6. 测试时进行reduce

PS

多个线程大致相当于增大了相应倍数的batch_size,最好相应地调一调batch_size和学习率。本文没有进行调节,导致测试获得的准确率有一些差别。

模型较小时速度差别不大,反而DDP与混合精度可能因为一些初始化和精度转换耗费额外时间而更慢。在模型较大时,DDP + 混合精度的速度要明显高于常规,且能降低显存占用。