PyTorch分布式训练进阶:这些细节你都注意到了吗?

2023-11-09

310c1bddf6d66035a8f6206b735e0844.png

导语 | pytorch作为目前主流的深度学习训练框架之一,可以说是每个算法同学工作中的必备技能。此外,pytorch提供了极其方便的API用来进行分布式训练,由于最近做的工作涉及到一些分布式训练的细节,在使用中发现一些之前完全不会care的点,现记录于此,希望对有需求的同学有所帮助。

本文包含:

  • pytorch分布式训练的工作原理介绍。

  • 一些大家平时使用时可能不太注意的点,这些点并不会导致直观的bug或者训练中断,但可能会导致训练结果的偏差以及效率的降低。

  • 同时结合某些场景,介绍更为细粒度(group)的分布式交互方式。

名词解释 :

  • DP: DataParallel

  • DDP:DistributedDataParaller

基于DDP的多机单卡模型

  • world_size:并行的节点数

  • rank:节点的index,从0开始

  • group_size:并行group的节点数

  • group_ws:group数量

  • group_rank:group的index,从0开始

  • local_group_rank:一个group内部的节点index,从0开始

  • group_rank_base:一个group内local_group_rank为0的节点的rank

举例:

使用6节点,group_size=3,则group_ws=2则各个参数的对应关系如下:

587b8dd355885f31284858857c01856a.png

group 0的group_rank_base为0,group 1的group_rank_base为3。

一、DataParallel和DistributedDataParallel

pytorch提供了两种分布式训练的接口,DataParallel(单机多卡)和DistributedDataParallel(多机单卡,多机多卡)。

(一)DataParallel(DP)

先看下DataParallel的工作原理:

d550ded2ec341f1b6010f4ea8acbd7c1.png

  • module:即要进行的并行的模型,为nn.Module子类实例

  • device_ids:需要进行并行的卡

  • output_device:模型最终输出进行汇总的卡,默认是local_rank=0的卡(以下简称“卡0”)

以单机4卡为例,当接到一个batch size=128的数据时,卡0会将128的个数分成32*4,然后将模型拷贝到1~3卡,分别推理32个数据后,然后在output_device(默认为卡0)上进行输出汇总,因为每次推理都会需要进行模型的拷贝,整体效率较低。

注意:

当使用DP的时候,会发现卡0的显存占用会比其他的卡更多,原因便在于默认情况下,卡0需要进行输出的汇总,如果模型的输出是一个很大tensor,可能会导致卡0负载极其不均衡爆显存,从而不得不降低整体的bs导致其他卡的显存利用率低。

解决方案:

由于卡0进行输出的汇总,因此我们可以把loss的求解放到模型内部,这样模型的输出就是一个scalar,能够极大的降低卡0汇总带来的显存负载。

(二)DistributedDataParallel(DDP)

c291fb4a8d78e3a71b6b2912f311b37b.png

其他的参数含义类似DP,这里重点说下:

  • broadcast_buffers:在每次调用forward之前是否进行buffer的同步,比如bn中的mean和var,如果你实现了自己的SyncBn可以设置为False。

  • find_unused_parameters:是否查找模型中未参与loss“生成”的参数,简单说就是模型中定义了一些参数但是没用上时需要设置,后面会详细介绍。

  • process_group:并行的group,默认的global group,后面细粒度分布式交互时会详细介绍。

DistributedDataParallel的则很好的解决了DP推理效率低的问题,这里以多机单卡为例:DDP会在初始化时记录模型的参数和buffer等相关信息,然后进行一次参数和buffer的同步,这样在每次迭代时,只需要进行梯度的平均就能保证参数和buffer在不同的机器上完全一致。

多机多卡情况下,在一个机器内部的工作原理和DP一致,这也是为什么torch官方会说多机单卡是效率最高的方式。

目前主要使用DDP的多机单卡模式进行分布式训练,后文都将基于该设置进行介绍。

DDP训练中需要注意的点:

由于DDP在初始化会遍历模型获取所有需要进行同步操作的参数和buffer并记录,因此,一旦初始化了DDP就不要再对内部模型的参数或者buffer进行增删,否则会导致新增的参数或buffer无法被优化,但是训练不会报错。

如果你是做类似NAS这种需要进行子图推理的任务或者模型定义了未使用参数,则必须设置find_unused_parameters为True,否则设置为False。如果是后者,请检查模型删除无用的参数,find_unused_parameterss设置为True时会有额外的开销。

buffer是在forward前进行同步的,所以其实训练最后一个iter结束时,不同卡上的buffer是不一样的(虽然这个差异很小),如果需要完全一致,可以手动调用DDP._sync_params_and_buffers()

类似NAS这种动态子图,且你的优化器设置了momentum等除了grad以外其他需要参与梯度更新的参数时需要特别注意:在pytorch中,required_grad=False的参数在进行参数更新的时候,grad为None,所以torch中优化器的step中有一个p.grad is not None的判断用来跳过这些参数:

for group in self.param_groups:
    ....
    for p in group['params']:
        if p.grad is not None:
            params_with_grad.append(p)
            d_p_list.append(p.grad)
            state = self.state[p]
            if 'momentum_buffer' not in state:
                momentum_buffer_list.append(None)
            else:
                momentum_buffer_list.append(state['momentum_buffer'])
    ....

正常训练没有任何问题,但是使用动态子图时,即使对当前iter没有优化的子图的参数设置required_grad=False,如果该子图之前曾经被优化过,则它的grad会变成全0而不是None。例如有两个子图A和B,优化顺序为A->B->A:1.第一次优化A时,B的grad为None,一切正常;2.第一个优化B时,由于A已经被优化过,此时A的grad为0,优化器的判断无法过滤到该参数,因此会沿着第一次优化A时的buffer(如momentum)进行错误的优化。如果子图数量很多的话,某一个子图可能会被错误的优化成千上万次。解决方案有两个:一个是把优化器中的

if p.grad is not None:

改成

if p.grad is not None  (p.grad == 0).all():

或者在每次调用optim.step()之前,加一句:

for p in model.parameters():
    if p.grad is not None and  (p.grad == 0).all():
        p.grad = None

DDP的梯度汇总使用的是avg,因此如果loss的计算使用的reduce_mean的话,我们不需要再对loss或者grad进行/ world_size的操作。

二、使用DDP时的数据读取

DDP不同于DP需要用卡0进行数据分发,它在每个node会有一个独立的dataloader进行数据读取,一般通过DistributedSampler(DS)来实现:

66e468add726b6812a43d3d7f09553d8.png

DS会将range(len(dataset))的indices拆分成num_replicas(一般为word_size),不同rank的节点读取不同的数据进行训练,一个简单的分布式训练示例:

from torch import distributed as dist
from torch.utils.data.distributed import DistributedSampler
import torch.utils.data as Data


assert torch.cuda.is_available()
if not dist.is_initialized():
    dist.init_process_group(backend='nccl')


rank = dist.get_rank()
world_size = dist.get_world_size()


model = MyModel().cuda()
ddp_model = DistributedDataParallel(model, device_ids=[torch.cuda.current_device()]).cuda()


dataset = MyDataset()
sampler = DistributedSampler(dataset, rank, world_size, shuffle=True)


dataloader = Data.DataLoader(dataset, batch_size,  drop_last=False, sampler=sampler, shuffle=False, num_workers=8, pin_memory=True)


# training

注意:

如果你的模型使用了分布式评估:

  • 评估需要用到所有测试数据的结果进行整体统计。

  • 精度的计算和数据顺序相关,则你需要注意DS中:

  • 初始化时会对数据进行padding,padding后的数量为:

real_data_num = int(math.ceil(len(dataset) * 1.0 / world_size)) * world_size

因此直接评估可能会使得某些样本被重复评估导致精度结果误差,尤其是测试数据量不大,测试数据样本之间难易程度差距较大时

  • slice的方式为等间距slice,step为world size,因此直接将不同rank的输出拼接的话,顺序和原始的datast并不一致。

注意: 

可以看到,上述代码示例中DataLoader的pin_memory设置为True,torch会在返回数据前将数据直接放到CUDA的pinned memory里面,从而在训练时避免从一次从cpu拷贝到gpu的开销。但是只设置该参数不太会导致数据读取速度变快,原因是该参数需要搭配使用,要将代码中的数据拷贝由.cuda()变更为.cuda(non_blocking=True)

三、分布式训练进阶:Group

根据上述介绍,基本可以满足常规的分布式训练了。但是像诸如nas这种可能需要同时训练多个网络时,考虑到用户的不同需求(子网络可能需要并行,也可能并不需要并行),我们需要对分布式过程进行更加细粒度的控制,这种控制也可以让我们能在数据读取和通信开销做trade off。

在torch的分布式api中基本都包含group(或process_group)这个参数,只不过一般情况下不太需要关注。它的作用简言之就是对分布式的节点数进行划分成组,可以在组内进行分布式通信的相关操作。初始化api如下:

ranks = [0,1,2,3]
gp = dist.new_group(ranks, backend='nccl')

上述代码会将节点[0,1,2,3]作为一个group,在后续的分布式操作(如:broadcast/reduce/gather/barrier)中,我们只需传入group=gp参数,就能控制该操作只会在[0,1,2,3]中进行而不会影响其他的节点。

注意:

在所有的节点上都需要进行所有group的初始化,而不是只初始化当前rank所属的group,如使用12卡,group size设置为4,则12/4=3个group对应的rank分别为[0,1,2,3][4,5,6,7][8,9,10,11],这12个节点都需要初始化三个group,而不是rank0,1,2,3只用初始化group0:

rank = dist.get_rank()
group_ranks = [[0,1,2,3], [4,5,6,7],[8,9,10,11]]
cur_gp = None
for g_ranks in group_ranks:
      gp = dist.new_groups(g_ranks)
      if rank in g_ranks:
          cur_gp = gp
# 后续使用cur_gp即可

注意:

如果进行兼容性考虑的话,比如group_size=1或者group_size=world_size,此时不需要创建group,但是为了代码的一致性,所有的分布式操作都需要传入group参数,需要注意的是新版本的torch,分布式op的group参数缺省值为None,当检测到None会自动获取group.WORLD,但是旧版本的缺省参数为group.WORLD,传入None会报错,可以尝试做以下兼容(具体从哪个版本开始变更没有尝试过,以下仅为sample):

import torch
from torch.distributed.distributed_c10d import _get_default_group    
def get_group(group_size, *args, **kwargs):
    rank = dist.get_rank()
    world_size = dist.get_world_size()
    if group_size == 1:
        # 后续不会涉及到分布式的操作
        return None
    elif group_size == world_size:
        v = float(torch.__version__.rsplit('.', 1)[0])
        if v >= 1.8:
            return None
        else:
            return _get_default_group() 
    else:
        # 返回当前rank对应的group

(一)模型在group内的并行

只需在DDP初始化的时候把gp赋值给process_group即可

(二)数据在group内的读取

使用带group的DDP训练时,数据读取依旧使用DS,不同的是num_replicas和rank参数不再等于world_size和节点的真实rank,而要变更为group_size和local_group_rank(见名词解释部分)。这个也很好理解,举个例子:

  • 6卡,group_size为3,每个group内有3个节点,模型在这3个节点上并行。

  • 训练该模型相应的数据也应只在这3个节点上进行,所以DS的num_replicas变更为group_size。

  • 另外,DS中的rank参数决定了当前节点读取哪些数据(用来进行indices划分),因此,对于一个group内部而言,该参数需要变更为当前节点在当前group的序号,即local_group_rank。

dcaece1ea03f16af72eeabaa784b1bbd.png

四、某些分布式训练场景下IO瓶颈

这里只介绍多机单卡场景(即一个scheduler和多个worker,且scheduler和每个worker只有一张GPU),且针对某些对于小文件io密集型不太友好的文件系统:

对应数据集不大的,可以考虑做成lmdb或者运行时将数据拷贝到docker的路径下。

数据集大,无法采用上述方案时,如果进行大规模分布式,io问题会更加严重:调度系统可能将worker映射到物理机上,可能导致多个worker都映射到同一台物理机器,虽然设置的cpu核心和内存,不同的node还是会进行资源抢占,导致速度变慢,为此需要进行数据分发:

方式一:group0中的对应节点进行数据读取,然后分发到其他group的对应节点上,即rank0,1,2各自读取1/3的数据,然后通过broadcast将数据广播,rank0的数据广播至rank3,rank1至rank4以此类推。

方式二:rank0的节点读取所有数据,然后在group0内进行scatter,然后使用方式一broadcast到其他group。

采用方式一还是二取决于你的数据读取开销,如果group size很大,那么group0的资源抢占可能就很严重,导致速度降低,如果只有rank0进行数据读取的话,虽然不会存在资源抢占(gemini的scheduler不会和worker映射到同一台机器),但是bs会增大可能会导致读取变慢。

在gpu正常的情况下,数据broadcast的开销相对较小。

注意: 

  • 使用数据broadcast自然需要dataset返回的所有数据均是tensor,meta信息诸如字符串类型的数据无法broadcast。

  • 进行数据broadcast时需要新建一系列的data group,因为它的维度和模型并行的维度不一样,模型是在[0,1,2]和[3,4,5]上并行,数据是在0->3,1->4,2->5上broadcast,因此需要新建三个group[0,3][1,4][2,5]

  • broadcast自然需要知道数据维度,结合前面讲到的DS补齐操作,注意每个epoch最后一个batch数据的bs可能不到设置的bs(drop_last=False时),因此broadcast需要进行额外的处理。

  • 当不同的group之间代码的逻辑可能不一样时,使用broadcast需要额外注意,比如group0训练1个网络,group1训练2个网络,数据由group0进行broadcast,group0训完第一个网络就break,导致group1训练第二个网络时接受不到broadcast的数据而卡死。

 推荐阅读

3种方式!Go Error处理最佳实践

生于云,长于云,开发者如何更好地吃透云原生?

从0到1详解ZooKeeper的应用场景及架构原理!

分布式事务解决方案:从了解到放弃!

63748185e3dff447de4fdd3e0d242512.gif

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

PyTorch分布式训练进阶:这些细节你都注意到了吗? 的相关文章

随机推荐

  • Redis入门之一

    设置后台进程 进入redis conf中的136行改成 yes 设置后台进程 修改bind 改成qianfeng01 改密码 找到第500行左右 requorepass 改成123456 登录的代码 查看 ps ef grep redis
  • 数据挖掘基础之数据库

    最近出现的一种数据库结构是数据仓库 1 3 2 小节 这是一种多个异种数据源在单个站点以统一的模式组织的存储 以支持管理决策 数据仓库 技术包括数据清理 数据集成和联机分析处理 OLAP OLAP 是一种分析技术 具有汇总 合并和聚集功能
  • RSA算法Java版(不要求支持大数)但实现了(dog

    RSA算法 支持大数 题目描述 C 中数据的类型与长度参考 因此 C 最大能支持的十进制是19位的整数 如果要支持更大的整数 需要实现Big Number类 RSA目前比较安全的密钥长度是2048位二进制 即是617位的十进制 因此 C 自
  • grafana 使用 Node Exporter for Prometheus Dashboard 监控服务器基础信息

    使用 Node Exporter for Prometheus Dashboard 监控服务器CPU 内存 磁盘 IO 网络 流量 温度等指标 当需要监控服务器的CPU 内存 磁盘 IO 网络 流量 温度等指标 可以使用 grafana 的
  • Eclipse 直接开发HANA UI5 ABAP等(提供完整配套软件 和jar包及操作文档)_SAP刘梦_新浪博客...

    之前写了HANA Studio 安装 ABAP UI5 插件等等 现在写下直接用Eclipse 有人说了现象 打开HANA Studio 做HANA建模 然后呢 打开个eclipse 做UI5 其实没必要 本来HAHA studio就是ec
  • Vue源码解读(六):update和patch

    Vue 的 update 是实例上的一个私有方法 主要的作用就是把 VNode 渲染成真实的 DOM 它在首次渲染和数据更新的时候被调用 在数据更新的时候会发生新 VNode 和 旧 VNode 对比 获取差异更新视图 我们常说的 diff
  • ChatGPT Plus价格太贵,可以约上三五知己一起上车体验一下,这个项目就能帮到你

    对于想体验ChatGPT PLus的小伙伴 可能觉得自己一个人一个月花费20美元 相对于人民币每月137多 确实是一个不少的开支 如果 几个人合作一个账号 这样负担就减少了 刚好 最近逛github发现刚好有一个这样的项目 项目介绍 Cha
  • is running beyond physical memory limits. Current usage: 2.0 GB of 2 GB physical memory used; 2.6 GB

    背景 执行一个kylin任务 然后报错 TaskAttempt killed because it ran on unusable nodekylin1 dtwave dev local 8041 Container released on
  • excel发给别人图片全是代码_PHP读取Excel内的图片(附代码)

    php中文网最新课程 每日17点准时技术干货分享 今天接到了一个从Excel内读取图片的需求 在网上查找了一些资料 基本实现了自己的需求 不过由于查到的一些代码比较久远 里面一些库方法已经被移除不存在了 所以不能直接移植到自己的项目里 需要
  • 在python中如何让一个函数分段执行呢?

    在python中如何让一个函数分段执行呢 原因 最新在写分针的逻辑 最开始讲一大段代码分成好几个小的函数 讲函数放进数组中 每一帧执行一个 但总是觉得这样写不够优美 今天看到其他大神的代码 记录一下思路 前提要理解generator pyt
  • 【论文写作】——设置正文和标题格式

    目录 一 设置正文格式 二 设置标题格式 一 设置正文格式 找到功能区的样式 右键正文样式 点击修改 左下角选择字体 设置字体的字形字号 也可选择对正文的段落格式进行修改 二 设置标题格式 可直接将文中同一级别的大纲进行折叠 然后直接设置同
  • 拓数派发布新一代云原生虚拟数仓PieCloudDB

    3 月 14 日 2023 拓数派 Infinite Possibilities 战略暨新产品发布会在上海圆满落幕 拓数派创始人兼 CEO 冯雷 Ray Von 重磅发布基于新一代云原生数仓虚拟化打造的全新 PieCloudDB 云上云 版
  • (手工)【sqli-labs24】二次注入:原理、利用过程

    目录 推荐 一 手工 SQL注入基本步骤 二 Less24 POST Second Order injections Real treat Stored injections 简介 GET注入 二次注入 第一步 获知目标账号并注册 第二步
  • Git在码云上传大文件-lfs

    lfs的安装网上搜索 安装好后以下操作 注意 大文件使用SSH传输 上传前先配置好码云的私钥 git init 创建本地仓库环境 git lfs install 安装大文件上传应用 git lfs track 追踪要上传的大文件 表示路径下
  • 小程序拒绝摄像头授权,重新允许无法调起摄像头

    小程序拒绝摄像头授权 重新允许无法调起摄像头 公司项目需要做到自动拍照功能 发现如果用户拒绝了授权 再重新引导用户授权后 无法重新调用摄像头 然后做了各种尝试 发现是页面渲染camera问题 当用户进入页面拒绝或者允许授权时这个标签都已经被
  • Mysql 复习笔记- 基础篇3 [常见增删改查]

    Mysql 复习笔记 基础篇 3 常见增删改查 声明 此笔记不会出现比如说Mysql发展历史这种问题 多为实用的命令和使用中的必要知识 请海涵 这篇文档我们不会对查询进行复习 我们将会把查询的操作的部分放到了后面的查询文档中 我们将复习到级
  • qt 按钮单击的信号_QPushButton 点击信号分析

    QPushButton 点击信号分析 QPushButton有三个很重要的信号跟点击有关 pressed clicked toggled 表面上看 pressed和clicked都会在点击按钮时触发 它们有什么区别呢 toggled好像有时
  • React18:创建React项目(手动)

    项目结构 常规的React项目需要使用npm 或yarn 作为包管理器来对项目进行管理 并且React官方为了方便我们的开发 为我们提供react scripts包 包中提供了项目开发中的大部分依赖 大大的简化了项目的开发 开发步骤 1 创
  • GPIO口的脚本配置之——全志H3script.bin

    此脚本的作用之一是配置GPIO的默认状态 如 功能 内部电阻状态 驱动能力等 1 但是直接打开script bin 文件则会出现乱码 那么我们怎么才可以打开并更改该脚本的配置呢 在路径uboot kernel orangepi sdk to
  • PyTorch分布式训练进阶:这些细节你都注意到了吗?

    导语 pytorch作为目前主流的深度学习训练框架之一 可以说是每个算法同学工作中的必备技能 此外 pytorch提供了极其方便的API用来进行分布式训练 由于最近做的工作涉及到一些分布式训练的细节 在使用中发现一些之前完全不会care的点