26.6.8 更新(增加ZeRO-DP内存优化阶段的设备内存消耗对比)
分布式训练的目的:减少每个GPU显存占用
单机单卡:从硬盘提取数据,CPU读取数据,将数据组成一个batch传入GPU进行网络前向传播,算出loss,然后进行后向传播,计算梯度,用梯度更新网络参数,完成一次训练。
DP DATA PARALLEL
从硬盘读取数据,通过一个CPU进程将数据分为多份,给每个GPU一份,每个GPU独立进行网络的前向传播,后向传播,计算出各自的梯度,然后所有其他的GPU都将自己计算的梯度传递到GPU0上进行梯度平均,GPU0用全局平均的梯度更新自己的网络参数,然后将更新后的参数广播到其他GPU上。


为什么DDP和DP比起来要使用多个进程?
Python 的底层设计有一个 GIL 锁,导致在同一个进程中,同一时刻只能有一个线程在执行 CPU 指令。
- DP 的困境:DP 是单进程多线程架构。虽然 GPU 计算是并行的,但控制这些 GPU、处理数据分发和收集结果的“调度逻辑”都在一个进程的多个线程里运行。当模型很大或 GPU 很多时,CPU 端的调度会成为严重的瓶颈,GIL 会导致多线程频繁争抢执行权,效率大打折扣。
- DDP 的优势:DDP 是多进程架构。每个进程都有自己独立的内存空间和 GIL 锁。这意味着各个进程在 CPU 端是完全真正并行运行的,互不干扰,彻底释放了 CPU 的调度能力。
DDP DISTRIBUTED DATA PARALLEL
RING-ALLREDUCE: 把多个节点连成一个环进行通信。目的是让每个GPU上都有所有参数的梯度值的和
阶段一:scatter reduce

阶段二:ALL GATHER 将各个参数梯度求和值同步到其他GPU

同时发送和接收,最大限度地利用每个显卡的上下行带宽

多进程,数据不同,计算出来梯度不同,RING-ALLREDUCE同步梯度,更新网络,下一次计算
细节:
GPU0加载模型,它把模型同步到GPU1和GPU2
按照神经网络参数定义的反序把参数进行排列,即输出层排在前面,输入层排在后面,然后对每个参数注册一个监听器,将监听器放到桶里
为什么要反序排列呢?
因为反向传播计算梯度时,后面参数的梯度先计算出来,前面参数的梯度后计算出来,所以输出层的参数放在前面,输入层的参数放在后面。
先计算出的梯度先同步,为了节省开销,收集满一个桶再进行同步,注册的监听器是为了让DDP知道哪个参数的梯度计算好了。
在反向传播过程中,DDP 将模型参数划分为多个桶。当某个桶内所有参数的梯度计算完成后,系统会立即启动该桶的 Ring AllReduce 通信,同时其他桶继续计算梯度。这种设计实现了计算与通信的重叠,提升了整体效率。只有当所有桶的梯度都完成同步后,各 GPU 才会调用优化器更新参数。这样做是为了保证参数的绝对一致性。

将所有需要同步的参数除以所有的GPU作为Ring AllReduce同步的一个块,然后传递N-1次
可以看到总传入和传出和整个集群有多少GPU无关
deepspeed zero架构(零冗余优化器)
zero1
每个GPU占用显存最大的就是优化器状态,优化器状态会产生冗余,目标是让整个系统只有一份完整的优化器状态。

GPU0存储前三层神经网络对应的优化器状态,负责前三层网络参数的更新
GPU1存储中间三层的优化区状态更新中间三层的网络参数
GPU2存储最后三层的优化区状态更新最后三层的网络参数
前向传播没有问题,因为每个GPU都存储着FP16的网络参数
后向传播时,每个GPU都从后向前计算出每一层的参数的梯度,GPU0 GPU1不负责更新后面三层的参数,而是将自己计算的后三层的梯度发送给GPU2,GPU1 GPU2不负责更新前三层的参数,而是将自己计算的前三层的梯度发送给GPU0,GPU0 GPU2不负责更新中间三层的参数,而是GPU0 GPU2将自己计算的中间三层的梯度发送给GPU1,在他们发送梯度的同时,GPU还在计算其他层的梯度。GPU0/1/2汇总结果算出梯度平均值。
反向传播后,每个GPU都拿到自己优化器对应部分参数的梯度均值,然后将梯度转化为FP32进行缩放,然后更新优化器里的一阶和二阶动量,最后优化器更新FP32位的参数。接下来更新各自优化器对应的那部分FP16的网络参数,然后再把各自更新后的FP16的网络参数广播给其他GPU,从而完成一次训练

每个GPU为了更新优化器状态对应的1/N的参数,需要知道整个模型对应的梯度,从而接受N-1个节点的数据
由于每个GPU只更新1/N的参数,但是下一轮训练的前向传播需要完整的模型参数,所以进行广播,从而每个GPU从其他N-1个GPU那里接受它们负责的参数分片,从而拼凑出完整的模型参数
总通信量=梯度相关通信+参数相关通信
zero2
把FP16的梯度也按照GPU进行了划分
后向传播时,GPU0和GPU1计算出最后一层的梯度,这些梯度以桶的形式发送给负责更新这部分参数的GPU2,然后只有GPU2保存最后汇总的平均梯度,GPU0和GPU1就立即释放这部分梯度占用的显存。以此类推计算每一层的梯度,并把梯度发送给负责更新这部分参数的GPU,其他GPU立即释放这部分的显存占用,反向操作完成后,每个GPU都有自己负责更新参数的汇总后的平均梯度,然后更新优化器状态,优化器更新参数。接着每个GPU再把自己更新后的参数广播给其他GPU
zero3
对参数也进行划分
前向传播和后向传播要使用参数时,使用其他GPU广播,使用后立即丢弃,不占用显存。
前向传播和后向传播一共执行两次参数广播
下图是ZeRO-DP内存优化阶段的设备内存消耗对比

其中K表示优化器相比于参数数量的内存倍数,比如这个例子中优化器总共12倍于参数数量的内存开销。
ψ表示模型参数数量
Nd表示DP的GPU数量
1)BASELINE中使用了传统的DP方法,每个GPU都有完整的参数(蓝色),梯度(橙色),优化器状态(绿色),内存消耗中2ψ表示参数(采用FP16格式存储,一个FP16占用2字节)2ψ表示梯度,Kψ表示优化器所需内存。
2)Pos将优化器状态均匀划分到Nd个GPU上,每个GPU只存储负责的优化器状态,所以优化器状态除以Nd,带来约4倍的内存缩减
3)Pos+g进一步对梯度分区,所以梯度也除以Nd,带来约8倍的内存缩减
4)Pos+g+p进一步对参数分区,所以参数也除以Nd,带来约64倍的内存缩减
参考视频:
RethinkFun的个人空间-RethinkFun个人主页-哔哩哔哩视频



