一、几个比较常见的概念:

rank: 多机多卡时代表某一台机器,单机多卡时代表某一块GPU

world_size: 多机多卡时代表有几台机器,单机多卡时代表有几块GPU

local_rank: 多机多卡时代表某一块GPU, 单机多卡时代表某一块GPU

单机多卡的情况要比多机多卡的情况常见的多。

DP:适用于单机多卡(=多进程)训练。算是旧版本的DDP

DDP:适用于单机多卡训练、多机多卡。

二、常见的多gpu使用方法:

模型并行和数据并行。模型并行是指将模型分成几个部分,然后在不同gpu上训练,适用于模型很大的情况;数据并行是指将数据分成几个部分,然后在不同gpu上训练,适用于数据很大的情况。一般我们见到的都是数据并行

pytorch ddp各进程同步 pytorch单机多卡训练_深度学习

 

三、解决两个问题

(1)解决问题一:数据集如何在多个gpu之间分配?两个工具:DistributedSampler,BatchSampler

pytorch ddp各进程同步 pytorch单机多卡训练_深度学习_02

pytorch ddp各进程同步 pytorch单机多卡训练_pytorch ddp各进程同步_03

 (2)解决问题二:不同gpu上训练的梯度,要在每一次迭代的时候求一次平均,并不是gpu各训练各的,涉及误差梯度如何在不同设备间通信

*同步bn:标准化需要有均值和方差,假设有两个gpu,分别为cuda0、1,每块gpu上训练的数据为bs=2,共有2+2=4。在bn层进行标准化时,要先计算每个gpu内部的均值和方差,再加和求得所有gpu的均值和方差—这种整体的均值和方差更接近真实数据集的均值和方差。只有当不冻结(冻结则只训练连接层,而连接层无bn层)、模型中有bn层时才适用同步bn。

pytorch ddp各进程同步 pytorch单机多卡训练_深度学习_04

(bn层是让每个通道单独标准化,然后再叠放在一起)

pytorch ddp各进程同步 pytorch单机多卡训练_权重_05

 

(不同gpu分别计算均值和方差,再求和反映数据集整体情况)

四、源码解读

 1、源码见:GitHub - WZMIAOMIAO/deep-learning-for-image-processing: deep learning for image processing including classification and object-detection etc.

主要文件有:

train_multi_gpu_using_launch.py(主文件)

train_eval_utils.py(定义了train_one_epoch、evaluate两个重要方法)

distributed_utils.py(一些工具函数)

2、下面开始从train_multi_gpu_using_launch.py开始讲解:

步骤如下:
1、传入参数(num_classes;epochs;batch-size;lr;lrf;syncBN;weights;freeze-layers;device;world-size;dist-url)
       dist-url: 分布式训练的url ,默认是env://

2、main方法:
init_distributed_mode初始化各进程环境:(rank、world_size、gpu+启动分布式模式args.distributed为True+对当前进程set_device指定使用的GPU+设置dist_backend为'nccl'+初始化进程组init_process_group+barrier同步所有的进程)
设置rank、device、batch_size、weights_path + 学习率lr要根据GPU的数量(ws)进行倍增 + checkpoint_path
在第一个进程中:SummaryWriter初始化tensorboard+如果没有权重文件./weights就新建一个
+划分数据集(train和val的图片路径、label路径)+确认参数类别数量是否与模型相等+实例化数据集dataloader
+多线程(workers)+DataLoader创建train_loader和val_loader+实例化模型
+实例化模型model,并将模型传送到gpu上 + 重头训练要让所有的gpu初始化参数一样,统一使用第一个进程的初始化参数 + 当训练所有层且有bn层时设置同步bn
+DistributedDataParallel转为DDP模型,使得模型能够在各个gpu设备中进行通信
+初始化optim + 初始化Scheduler + 遍历每个epoch(shuffle打乱顺序 + train_one_epoch见下方 + step学习率 + evaluate见下方,所有gpu预测正确的数量然后计算准确率)
+tb_writer打印第一个GPU的信息 + 删除临时缓存文件(checkpoint_path) + cleanup销毁进程组
 

train_multi_gpu_using_launch.py 代码如下:

import os
import math
import tempfile
import argparse

import torch
import torch.optim as optim
import torch.optim.lr_scheduler as lr_scheduler
from torch.utils.tensorboard import SummaryWriter
from torchvision import transforms

from model import resnet34
from my_dataset import MyDataSet
from utils import read_split_data, plot_data_loader_image
from multi_train_utils.distributed_utils import init_distributed_mode, dist, cleanup
from multi_train_utils.train_eval_utils import train_one_epoch, evaluate

def main(args):
    if torch.cuda.is_available() is False:
        raise EnvironmentError("not find GPU device for training.") #如果没有多gpu就会报错

    # 初始化各进程环境。init_distributed_mode函数见multi_train_utils/distributed_utils.py
    init_distributed_mode(args=args)

    rank = args.rank
    device = torch.device(args.device)
    batch_size = args.batch_size
    weights_path = args.weights
    # 学习率要根据GPU的数量进行倍增:在训练的过程中,损失梯度决定下降的方向,学习率决定下降的步长。如果有两块gpu,前进的综合步长为:平均学习率*2
    args.lr *= args.world_size

    checkpoint_path = ""

    if rank == 0:  # 在第一个进程中:打印args参数信息,并实例化tensorboard,新建权重文件。通常保存、打印这些操作只用在第一个进程做就行了,其他进程不用做
        print(args)
        print('Start Tensorboard with "tensorboard --logdir=runs", view at http://localhost:6006/')
        tb_writer = SummaryWriter() #初始化一个tensorboard
        if os.path.exists("./weights") is False: #如果没有权重文件就新建一个
            os.makedirs("./weights")

    #划分数据集(train和val的图片路径、label路径)+确认参数类别数量是否与模型相等+实例化数据集dataloader
    train_info, val_info, num_classes = read_split_data(args.data_path)
    train_images_path, train_images_label = train_info
    val_images_path, val_images_label = val_info

    # check num_classes
    assert args.num_classes == num_classes, "dataset num_classes: {}, input {}".format(args.num_classes,
                                                                                       num_classes)

    data_transform = {
        "train": transforms.Compose([transforms.RandomResizedCrop(224),
                                     transforms.RandomHorizontalFlip(),
                                     transforms.ToTensor(),
                                     transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])]),
        "val": transforms.Compose([transforms.Resize(256),
                                   transforms.CenterCrop(224),
                                   transforms.ToTensor(),
                                   transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])])}

    # 实例化训练数据集
    train_data_set = MyDataSet(images_path=train_images_path,
                               images_class=train_images_label,
                               transform=data_transform["train"])

    # 实例化验证数据集
    val_data_set = MyDataSet(images_path=val_images_path,
                             images_class=val_images_label,
                             transform=data_transform["val"])

    # DistributedSampler (dataset)的处理,用来为不同的GPU分配样本索引(注意,没法平分就会用第一个数据来补充)
    train_sampler = torch.utils.data.distributed.DistributedSampler(train_data_set)
    val_sampler = torch.utils.data.distributed.DistributedSampler(val_data_set)

    # 将样本索引每batch_size个元素组成一个list。BatchSampler用来为当前GPU组织数据(此处以bs=2为例)。drop_last=True为将剩下来未能形成整组的数据打包成一组(比如剩了一个数据落单,是要直接扔掉还是直接算成一组,用drop_last决定)
    #注意验证集数据无需经过BatchSampler。
    train_batch_sampler = torch.utils.data.BatchSampler(
        train_sampler, batch_size, drop_last=True)

    nw = min([os.cpu_count(), batch_size if batch_size > 1 else 0, 8])  # number of workers
    if rank == 0:
        print('Using {} dataloader workers every process'.format(nw)) #每个进程(process)中会使用几个线程(workers)来加载数据
    train_loader = torch.utils.data.DataLoader(train_data_set,
                                               batch_sampler=train_batch_sampler,#通过BatchSampler来采样
                                               pin_memory=True,
                                               num_workers=nw,
                                               collate_fn=train_data_set.collate_fn)

    val_loader = torch.utils.data.DataLoader(val_data_set,
                                             batch_size=batch_size,
                                             sampler=val_sampler, #val_sampler就只是经过了DistributedSampler
                                             pin_memory=True,
                                             num_workers=nw,
                                             collate_fn=val_data_set.collate_fn)

    # 实例化模型,并将模型传送到gpu上
    model = resnet34(num_classes=num_classes).to(device)

    # 如果存在预训练权重则载入
    if os.path.exists(weights_path):
        weights_dict = torch.load(weights_path, map_location=device)
        load_weights_dict = {k: v for k, v in weights_dict.items()
                             if model.state_dict()[k].numel() == v.numel()}
        #遍历权重字典的每一层,然后再看权重的参数个数是否相同。结果就是全连接层的参数不符合,全连接层的权重不会被导入,因为本例中用到了预训练。
        model.load_state_dict(load_weights_dict, strict=False)
    else:
        checkpoint_path = os.path.join(tempfile.gettempdir(), "initial_weights.pt")
        # 如果不存在预训练权重,需要将第一个进程中的初始化权重保存,然后其他进程载入,保持初始化权重一致。
        # 注意,多gpu训练的时候,一定要保证所有进程的初始化参数时一样的,后面才能对所有进程求得的参数求和等等处理,得到整体数据的参数。
        if rank == 0:
            torch.save(model.state_dict(), checkpoint_path)

        dist.barrier()
        # 这里注意,一定要指定map_location参数,否则会导致第一块GPU占用更多资源
        model.load_state_dict(torch.load(checkpoint_path, map_location=device))

    # 是否冻结权重
    if args.freeze_layers: #只有连接层的参数要训练。
        for name, para in model.named_parameters():
            # 除最后的全连接层外,其他权重全部冻结
            if "fc" not in name:
                para.requires_grad_(False) #只有全连接层需要训练权重, para.requires_grad为true。其它层为false
    else:#所有层的参数都要训练。只有训练带有BN结构的网络时使用SyncBatchNorm才有意义,会将所有bn层变为具有同步功能的bn。
        if args.syncBN:
            # 使用SyncBatchNorm后训练会更耗时,对所有gpu上的batch计算均值和方差,再整体综合,再传递给下个batch。但会带来速度上的下降
            model = torch.nn.SyncBatchNorm.convert_sync_batchnorm(model).to(device)

    # 转为DDP模型:包装model,使得模型能够在各个gpu设备中进行通信。
    model = torch.nn.parallel.DistributedDataParallel(model, device_ids=[args.gpu])




    # optimizer
    pg = [p for p in model.parameters() if p.requires_grad] #遍历每一层。只有全连接层满足 if p.requires_grad。pg是输出的需要训练的参数
    optimizer = optim.SGD(pg, lr=args.lr, momentum=0.9, weight_decay=0.005) #momentum动量;weight_decay正则项
    # Scheduler https://arxiv.org/pdf/1812.01187.pdf
    lf = lambda x: ((1 + math.cos(x * math.pi / args.epochs)) / 2) * (1 - args.lrf) + args.lrf  # cosine余弦退火学习率
    scheduler = lr_scheduler.LambdaLR(optimizer, lr_lambda=lf)

    for epoch in range(args.epochs): #迭代每一轮
        train_sampler.set_epoch(epoch)
        #set_epoch是官方定义的函数。使多个 epoch 的数据能够在一开始DistributedSampler组装的时候就shuffle打乱顺序。 否则,dataloader迭代器产生的数据将始终使用相同的顺序。

        mean_loss = train_one_epoch(model=model,
                                    optimizer=optimizer,
                                    data_loader=train_loader,
                                    device=device,
                                    epoch=epoch)
        # train_one_epoch函数见 multi_train_utils/train_eval_utils.py

        scheduler.step() #更新学习率

        sum_num = evaluate(model=model,
                           data_loader=val_loader,
                           device=device)  #所有gpu预测正确的数量的总和
        #evaluate方法见multi_train_utils/train_eval_utils.py。
        acc = sum_num / val_sampler.total_size#正确数量/总数量=准确率

        if rank == 0:
            print("[epoch {}] accuracy: {}".format(epoch, round(acc, 3)))
            tags = ["loss", "accuracy", "learning_rate"]
            tb_writer.add_scalar(tags[0], mean_loss, epoch) #保存mean_loss到tb_writer
            tb_writer.add_scalar(tags[1], acc, epoch) #保存acc到tb_writer
            tb_writer.add_scalar(tags[2], optimizer.param_groups[0]["lr"], epoch) #保存lr到tb_writer

            torch.save(model.module.state_dict(), "./weights/model-{}.pth".format(epoch)) #保存当前epoch模型权重

    # 删除临时缓存文件:见上方,在选择预训练权重时,如果不预训练而是重头训练,将第一个进程中的初始化权重保存为checkpoint_path,为了所有gpu保持初始化权重一致。
    #该文件是临时文件,训练完可以删了
    if rank == 0:
        if os.path.exists(checkpoint_path) is True:
            os.remove(checkpoint_path)

    cleanup() #销毁进程组,释放资源。cleanup方法见multi_train_utils/distributed_utils.py


if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--num_classes', type=int, default=5)
    parser.add_argument('--epochs', type=int, default=30)
    parser.add_argument('--batch-size', type=int, default=16)
    parser.add_argument('--lr', type=float, default=0.001)
    parser.add_argument('--lrf', type=float, default=0.1) #因子。最终结果是最初的0.1倍。
    # 是否启用SyncBatchNorm
    parser.add_argument('--syncBN', type=bool, default=True)

    # 数据集所在根目录
    # https://storage.googleapis.com/download.tensorflow.org/example_images/flower_photos.tgz
    parser.add_argument('--data-path', type=str, default="/home/wz/data_set/flower_data/flower_photos")

    # resnet34 预训练官方权重下载地址
    # https://download.pytorch.org/models/resnet34-333f7ec4.pth
    parser.add_argument('--weights', type=str, default='resNet34.pth',
                        help='initial weights path')
    parser.add_argument('--freeze-layers', type=bool, default=False) #是否冻结,冻结则仅仅训练全连接层。
    # 不要改该参数,系统会自动分配
    parser.add_argument('--device', default='cuda', help='device id (i.e. 0 or 0,1 or cpu)')
    # 开启的进程数(注意不是线程),不用设置该参数,会根据nproc_per_node自动设置
    parser.add_argument('--world-size', default=4, type=int,
                        help='number of distributed processes')
    parser.add_argument('--dist-url', default='env://', help='url used to set up distributed training')
    opt = parser.parse_args()

    main(opt) #调用main方法,见上方


2.1 train_one_epoc model.train+CrossEntropyLoss+mean_loss清零+optimizer梯度清零+主进程设置进度条tqdm +遍历data_loader:(前向传播model获得pred,结合labels计算loss+对所有gpu上的loss进行求和得loss+整个训练过程的滑动损失均值mean_loss(返回出去)+在进程0中打印平均loss+无法收敛设置warning+step参数optimizer+optimizer参数清零) +synchronize同步多gpu进度


def train_one_epoch(model, optimizer, data_loader, device, epoch):
    model.train()
    loss_function = torch.nn.CrossEntropyLoss()
    mean_loss = torch.zeros(1).to(device)
    optimizer.zero_grad() #清空优化器梯度信息

    # 在进程0中打印训练进度。单gpu即只有一个进程,该进程就是主进程。如果是多gpu中打印进度条,也只会在主进程中打印进度条,其他进程不会打印进度条
    if is_main_process():
        data_loader = tqdm(data_loader, file=sys.stdout) #tqdm用来添加一个进度提示信息

    for step, data in enumerate(data_loader):#enumerate返回值有两个,一个是序号,一个是数据(包含训练数据和标签),参数1是设置从1开始编号。每一步 loader (step)释放一小批数据(data)用来学习
        images, labels = data #遍历数据,分为图像、标签

        pred = model(images.to(device)) #图像传入设备,model前向传播得到输出。

        loss = loss_function(pred, labels.to(device))#输出和真实标签,求损失。此处的损失是当前gpu上,针对当前批次的batch计算出来的损失
        loss.backward() #反向传播
        loss = reduce_value(loss, average=True)#单gpu训练没有这一步。这一步是多gpu上,对所有gpu上的loss进行求和。
        #reduce_value函数见multi_train_utils/distributed_utils.py
        mean_loss = (mean_loss * step + loss.detach()) / (step + 1)  # 整个训练过程的滑动损失均值=在历史平均损失的基础上,加上最新损失再求平均

        # 在进程0中打印平均loss
        if is_main_process():
            data_loader.desc = "[epoch {}] mean loss {}".format(epoch, round(mean_loss.item(), 3))
            #为进度条tqdm增加前缀信息。 desc:进度条的描述信息,也称进度条的前缀
        if not torch.isfinite(loss): #如果损失无穷大,就会warning然后终止训练
            print('WARNING: non-finite loss, ending training ', loss)
            sys.exit(1)

        optimizer.step() #更新参数
        optimizer.zero_grad() #参数清空

    # 等待所有进程计算完毕:如果使用多gpu,要同步一下多个gpu之间的进度
    if device != torch.device("cpu"):
        torch.cuda.synchronize(device)

    return mean_loss.item() #返回该轮的平均损失值


2.2 evaluate model.eval+sum_num清零+在进程0中打印验证进度+ +遍历data_loader:(前向传播model获得pred,结合labels计算预测正确的个数sum_num) +synchronize同步多gpu进度+计算多gpu的所有正确个数sum_num(返回出去)


@torch.no_grad()
def evaluate(model, data_loader, device):
    model.eval() #验证模式

    # 用于存储预测正确的样本个数(每个gpu会独立计算分配到该设备上数据,预测正确的总个数)
    sum_num = torch.zeros(1).to(device) #默认为0

    # 在进程0中打印验证进度。同train
    if is_main_process():
        data_loader = tqdm(data_loader, file=sys.stdout)

    for step, data in enumerate(data_loader):
        images, labels = data
        pred = model(images.to(device))
        pred = torch.max(pred, dim=1)[1] #求得预测概率最大的数,其对应的索引
        sum_num += torch.eq(pred, labels.to(device)).sum() #eq使得相同为1,不同为0.sumnum为当前批次的相同的个数

    # 等待所有进程计算完毕
    if device != torch.device("cpu"):
        torch.cuda.synchronize(device)

    sum_num = reduce_value(sum_num, average=False) #多gpu下要取均值(所有正确样本个数的均值)

    return sum_num.item() #预测正确的数量的总和

 

 学习率lr要根据GPU的数量(ws)进行倍增(图解)

pytorch ddp各进程同步 pytorch单机多卡训练_pytorch ddp各进程同步_06


(后续有空我会将代码上传至github)