分布式通信与并行策略
1.通信方式(NVIDIA NCCL标准)
1.1 Broadcast

1.2 AllGather

这里是使用的ring方法,即每个卡在每个时间段接收上一张卡的数据并发送当前卡的数据,每次发送第(k-1)步接收到的数据,如果k=1,即发送自己卡上的数据,经过N-1步(N为rank数),每张卡均可得到全量的信息。
1.3 ReduceScatter



目前比较常用的就是一个ring结构;reduceScater遵循以下规则,假设有n 个rank,首先会将每个rank上的tensor,切分成个块,紧接着将其按照如下规则,每一个时间步,都按以下规则发送和接收块tensor,并且接收的块做reduce,最终rank_i得到了第(rank_i+1)%num_ranks的结果。
规则:
第 k 步 (k 从 0 到 N-2, 这里 N=4):
- 发送:将本地缓冲区的第
(rank - k) % N块发送给下一个邻居(rank+1)。 - 接收:从上一个邻居(
rank-1)接收一个数据块,并把这个数据块与本地缓冲区的第(rank - k - 1) % N块相加(Reduce)。
1.4 Reduce

reduce的ring实现方法是reduce_scatter+p2p,即先进行reduce_scatter每张卡保留部分数据,然后直接进行p2p通信,都把自己卡上的数据发送给root。
1.5 AllReduce

Allreduce的底层一般分为两种实现,一种是ring,一种是tree,当然还有蝶形的,因为前面两者用的比较多,所以主要了解的是前两个。
首先环状结构的实现其实就是reduce_scatter+allgather的组合,先进行(n-1)步的reduce_scatter,每个rank发送(rank-k)%N块的数据给相邻的下一个rank,k表示步数,并且从上一个rank接收数据,并将此数据加在(rank-k-1)%N块数据上,也就是做reduce,最终每个rank都有完整out的部分块,并且合并起来就是完整的out,此时再做一个allgather即可让每个rank都得到完整out,总步数为2(n-1)。特点:带宽利用率高,每一时刻所有rank都在收发数据,但延迟较高。
其次是树状结构的实现,这个主要是解决Ring在大规模节点下延迟过高的问题,适合节点极多或中小数量的场景。它将节点组织成二叉树,叶子节点把数据发送给父节点,直到根节点,即可拿到全局数据,然后使用Broadcast(向下广播),把总和发送给子节点。特点:带宽利用率低,且越接近根节点,通信压力越大,但延迟低,为2log2(N)。
当tensor小,节点数多,延迟高时,用tree,反之用ring。一般GPU用NV Link通信,都是用ring比较多。
1.6 All-To-All
All-To-All 将每个参与进程将其数据分割成 N 份(N 是参与进程的总数),并将第 i 份发送给第 i 个进程。每个进程都会向其他所有进程发送数据,并从其他所有进程接收数据。如下图,All-to-All的底层实现是用P2P实现,即点对点通信:

1.7 总结

这里怎么理解AllReduce = ReduceScatter + AllGather,主要是因为ReduceScatter 把每张卡的值汇总起来,做reduce(可能是sum,average等),然后把结果分成rank个sub_result部分,分别给每个rank,而AllGather再把这rank个sub_result给聚合起来到每个rank上,则每个rank都有完整的result。其机制就跟直接做allreduce一样,对每个rank的数据汇总做reduce,然后把result分到每个rank上。
reduce_scatter使得每个rank_i获得了第(rank_i+1)%num_ranks的结果。这是需要n-1步,然后all gather需要把每张卡上reduce的结果,分发到其它卡,再走n-1步,所以一共的通信步是2*(n-1)。
2. Paddle三类通信区分动手,动半,静态全自动

同时,在动手的案例中不会出现dist_tensor,shard这些东西,因为这是动半特有的,对通信进行标记使用shard,而tensor被shard处理后,即变成了dist_tensor。
3.分布式并行方式
分布式并行方式包括:数据并行(DP),张量并行(tp),流水并行(pp),专家并行(ep),序列并行(sp),上下文并行(Context Parallel)
3.1 数据并行。

1通信为了让数据并行模型参数保持一致,3通信是为了让梯度累加;在反向过程中,本层的∇x梯度计算完毕后,可以继续计算下一层的∇x梯度,而不需要等待∇w的梯度计算,做∇x梯度的计算时,又可以做∇w的allreduce通信,从而达到通信与计算重叠。
必须注意,∇x不需要做allreduce,只有∇w需要,因为∇w做allreduce是为了同步每一份相同参数的grad,最后更新后的参数也在DP组间保持一致,而∇x两张卡上是分 别不同的batch数据计算得来的,没有数学依赖关系。
注意: 数据并行分两种,上面是DDP,即Distribute_DP是基于多进程实现的,还有一种是DP,是单进程多线程实现,如下图所示,它使用一个进程来计算模型权重。

注意: 二者效果是等价的,因为假设我们DP实现batchsize为64的数据量,对于DP来说,它会在计算loss时,汇总64条数据的output,然后和lable计算出loss,并得到一个平均的loss,再传给各GPU进行反向传播,此时每个grad就是平均之后的,即除以了64的。而DDP 则分给两个GPU各自去计算梯度,每个GPU计算的梯度也是mean的,不过是除以32的,所以要再allreduce一下,两个相加除以2,即同样是除以64。
补充说明:ZeRo(DeepSpeed,微软提出) 以及FSDP(Pytorch最新数据并行方案,ZeRo3和FSDP思想等价)
Group Sharded:
(这里存在一个组shard的概念,即按param的属性,将其划分到一个group,这些param在group里面同步和通信,与其它group相对独立,可以看一下记录的Dynamic optimizer shardingV1,V2的Fused_Buffer的概念)

ZeRO-1(pytorch:OSS):
ZeRO-1没有将模型本身进行分片,也没有将Gradient进行分片,而是只将优化器进行分片。训练过程与DDP类似。
- forward过程由每个rank的GPU独自完整的完成,然后进行backward过程。在backward过程中,梯度通过allReduce进行同步。
- Optimizer state 使用贪心策略基于参数量进行分片,以此确保每个rank几乎拥有相同大小的优化器内存。
- 每个rank只负责更新当前优化器分片的部分,由于每个rank只有分片的优化器state,所以当前rank忽略其余的state。
- 在更新过后,通过广播或者allGather的方式确保所有的rank都收到最新更新过后的模型参数。
ZeRO-1 非常适合使用类似Adam进行优化的模型训练,因为Adam拥有额外的参数m(momentum)与v(variance),特别是FP16混合精度训练。ZeRO-1 不适合使用SGD类似的优化器进行模型训练,因为SGD只有较少的参数内存,并且由于需要更新模型参数,导致额外的通讯成本。ZeRO-1只是解决了Optimizer state的冗余。
ZeRO-2(pytorch:SDP):
相比于ZeRO-1,ZeRO-2除了对optimizer state进行切分,还对Gradient进行了切分。
像ZeRO-1一样将optimizer的参数进行分片,并安排在不同的rank上。在backward过程中,gradients被reduce操作到对应的rank上,取代了all-reduce,以此减少了通讯开销。 每个rank独自更新各自负责的参数。在更新操作之后,广播或allGather保证所有的ranks接收到更新后的参数。
ZeRO-3(pytorch:FSDP):
为了进一步节省更多的内存,ZeRO-3提出进行模型参数的分片。类似以上两种分片方式,ranks负责模型参数的切片。可以进行参数切片的原因主要有以下两点:
- All-Reduce操作可以被拆分为Reduce与allgather操作的结合。
- 模型的每一层拥有该层的完整参数,并且整个层能够直接被一个GPU装下。所以计算前向的时候,除了当前rank需要的层之外,其余的层的参数可以抛弃。从这个层面上来说,Zero相当于数据并行+模型并行。
FSDP(Fully Sharded Data Parallel):
FSDP 是一种新型数据并行训练方法,但与传统的数据并行不同,传统的数据并行维护模型参数、梯度和优化器状态的每个 GPU 副本,而 FSDP 将所有这些 状态跨数据并行工作线程进行分片,并且可以选择将模型参数分片卸载到 CPU。
下图显示了 FSDP 如何在 2 个数据并行进程中工作流程:

通常,模型层以嵌套方式用 FSDP 包装,因此,只有单个 FSDP 实例中的层需要在前向或后向计算期间将完整参数收集到单个设备。 计算完成后,收集到的完整参数将立即释放,释放的内存可用于下一层的计算。 通过这种方式,可以节省峰值 GPU 内存,从而可以扩展训练以使用更大的模型大小或更大的批量大小。 为了进一步最大化内存效率,当实例在计算中不活动时,FSDP 可以将参数、梯度和优化器状态卸载到 CPU。
3.2 张量并行(TP,MP)
1 基础并行层
1.1 ColumnParallelLayer与RowParallelLayer同时使用的关系
ColumnParallelLayer

RowParallelLayer

可以看到,RowParallelLayer在计算的过程中,需要把输入拆分成两列分别在两张卡上做计算,最终两张卡都得到Parital状态的数据,而如果上一层是ColumnParallel则其计算的结果刚好分配到两个设备上(即结果被按列切分),而此结果正是RowParallelLayer需要的输入,那么就无需做通信,直接继续计算最后再做allreduce即可。
1.2 ColumnParallelLayer与RowParallelLayer的w和bias的切分

注意,在做y=x*W^T+b的计算时,首先乘积得到的数据是[batchsize,output_size],每一行表示一个数据,而bias是分别和每一行相加,因此bias是一个一维的向量,因此,当W按列切分时,bias需要按行切分,从而保持正确的计算关系。
当添加了bias的时候,做RowParallelLayer和ColumnParallelLayer情况如下:
RowParallelLayer:

RowParallelLayer只切w,不切bias
ColumnParallelLayer:

ColumnParallelLayer切w的axis=1,切bias的axis=0
1.3 Vocab Parallel Embedding
Vocab Embedding
示例:
文本输入
用户输入: "Hello world, how are you?"
分词(Tokenization)
分词结果: ["Hello", "world", ",", "how", "are", "you", "?"]
词汇表映射(Vocabulary Mapping)
词汇表: {"<PAD>": 0, "<UNK>": 1, "<BOS>": 2, "<EOS>": 3,
"Hello": 4, "world": 5, ",": 6, "how": 7, "are": 8, "you": 9, "?": 10, ...}
映射结果: [4, 5, 6, 7, 8, 9, 10]
输入到模型为词汇ID序列
模型接收的输入: x = [4, 5, 6, 7, 8, 9, 10] (词汇ID序列)
因此,Vocab Embedding接收到的输入x是[batch_size,seq_length],即多组词汇ID序列。Vocab Embedding的大小为(vocab_size,hidden_dim),vocab_size这个维度即表示了所有token的ID,根据token_id找到对应的行,该行所有的列则表示当前这个token的向量化表示,即包含其数学维度信息的特征向量。初始时,这些数据是没有意义的,经过训练后,则可以表示每个词的空间特征,越相近的词,在空间上离得越近。
根据 x,在vocab表中查找对应token的向量化表示,最终输出为[batch_size,sequence_lenth,hidden_dim]。

如图,以batch_size=1为例,查表后,通过当前输入的sequence_lenth中存在的token_ids,选择k行,最终构成[batch_size,sequence_lenth,hidden_dim]的输出。
反向的grad形状与forward输出时形状相同,而因为Vocab_Embedding的操作是从整个词表中选出k行数据,因此最后更新grad的时候,也是指有这k行数据有grad,而其它行均默认为0,若有相同token_id,则做梯度累加。
Vocab Parallel Embedding

如果是词表并行,则按vocab_size维度做切分,多路并行时,在forward阶段需要做all_reduce,因为只在本rank的词表查找,若查找到,则返回词表的这一行值,即当前token的向量化表示;若超过此范围,则返回的是默认值0。all_reduce之后,每个rank都能拿到全部token的向量化表示。反向梯度更新的时候无需通信,直接选当前rank的词表范围内的梯度进行累积,并更新词表对应行的数据即可。
一般来说,输入层和输出层共用一个word embedding,所以在backward过程,我们会分别对输入,输出层做一次梯度计算,而最终更新embedding的时候应该是两次梯度的总和,所以如果是TP没什么问题,因为TP下,输入和输出层都在一个GPU上,而当PP时,会分在不同卡上,这时候就需要对两卡的word embedding 梯度做一次all reduce。
1.4 输出层的 Vocab Embedding
输出层的vocab embedding和输入层的操作刚好相反,输入层是将对应的token_id的embedding数据取出,即将seq_len扩展成,而输出层的vocab embedding则是将embedding映射回词表,即变成。推理的话只需要取seq_len的最后一行的数据,做softmax即可。具体计算如下:

可以发现,这时候得到的Y1,Y2是Y被列切的状态,我们可以用all_gather去让每个rank得到完整的Y,但是这样做的单卡通信量是,所以为了减少计算量,Megatron并不用allgather做实现,而是用allreduce,如下方法:

将每个rank上的输出Yi按行求和,此时得到的是局部和,再做一个allreduce,则得到全局和,有了全局和,就可以做softmax计算每个Yi每一行logits的概率,并按行计算loss,这里计算loss,比如当前预测的token前[1,v/2],而在rank2上就会发现找不到这部分的概率,即这一行预测值和真实标签计算Cross Entropy的时候会出现全0,但是这一半在rank0上算了,而Cross Entropy本身也就只有一个位置是有值的(其它标签都为0,相当于没值)所以做一个allreduce即可得到最终的全局loss。
那么就可以发现,此时的单卡通信量就是,一条数据一个loss,所以loss的通信量是b。
2.TP的forward与backward过程

通信分析(行列切的详细分析可以见基础并行层):
1.初始化:
各卡训练参数为partial状态,即部分参数,对于切分的参数,分别初始化;对于没有切分的参数,初始化后同步为一致的参数(此时需要通信同步)。
2.Forward
各卡的网络使用切分后的网络,即网络层数不变,但shape为切分后的shape。如果是column parallel,则得到的每个rank的output也是经过了column parallel的output,因此需要做allgather,将输出拼接;而如果是row paralle,则得到的每个rank的output,形状与output相同,但数据属于partial状态,需要做all-reduce获得完整张量。
看上图,假设Linear1是ColumnParallel,Linear2是RowParallel,则列切计算出来后的结果,可以直接作为行切的输入(如果tp数相同,即Y1,Y2),因此无需通信(注意,若此时下一层做全参数计算,而不是RowParallel,则需要做all-gather聚合输出,拼接成完整的输出Y),而Linear2的结果要作为完整参数输入到Linear3(和单卡视角对齐),因此需要对Z1,Z2做allreduce,得到完整输入Z。这里需要做一次通信。
3.Backward
在反向传播过程中,因为Linear3以后的层都是完整参数,所以纯TP下,反向计算的梯度都是一样的,无需通信,
Linear2的反向梯度计算:
完整参数下:
而由于卡1和卡2分别占据部分参数B1、B2,因此只需要B1、B2部分参数分别对应的梯度即可(Y1,Y2分别是卡1,卡2此时的输入,因此分别计算自己卡上的数据,无需通信):
即
同理,B2的梯度也是,所以求B1的梯度无需通信。
对于输入Y,由于对称性,可知:
Y2同理,此时计算出来的梯度无需通信,因为Y1,Y2即分别对应rank0,rank1的上一层Linear1的输出,上一层也是切分状态,所以,求输入的梯度Y1,Y2也无需通信。注意:若上一层为完成的参数做forward,即rank0,rank1输入都是完整的Y,则backward时,需要做allgather将Y1_grad,Y2_grad做聚合
Linear1的反向梯度计算:
X*A1=Y1,X*A2=Y2
则
每个rank都有全量X,因此也无需通信。
但是,如果X需要继续传播梯度:
对于rank1:
对于rank2:
此时两个rank的是partial的状态,因此需要做allreduce,才能得到X的完整梯度。如果要继续往下传递grad,且接下来的参数都是未切分的,则这里需要做all-reduce通信合并成完整梯度。
4.Optimizer
每张卡获得grad后,在optimizer中进行参数更新,无需通信。
3.Megatron中TP的使用
通常MLP层是两个FFN,则都是列切和行切配对实现的,即TP层一般输入都用列切接收,并用行切处理后,再输出结果。即TP处理后的模型层,最终输出是一个partial的状态,需要做all-reduce来累加得到全局结果,但是每个rank上的形状都完全一样。MLP层切分的维度即model_dim
而对于Attention层来说,正好存在多头注意力机制,而每个头的self-attention计算是独立的,因此TP可以直接按照头的维度切分。

注意,一般multi-head-attention,在单卡计算时,是多个attention score(已经和V矩阵计算完)计算完成后,拼接成一个大的矩阵,再和输出的Wo矩阵做计算,得到最终的输出,但是在TP下,如果仍然按这个操作,会导致每张卡上都保留有一个完整的Wo矩阵的副本,浪费显存,因此megatron把Wo矩阵也按head的维度做了切分,则每个tp rank上算出来的输出O,均是一个partial的状态,经过一次all_reduce即可得到完整的输出,这就是为什么这里是all_reduce通信而不是allgather。

如图即为TP下一层Attention+一层MLP层的forward的过程。在整个前向+反向共4次all-reduce。
DP与TP混合时,由于DP每一层之间没有通信依赖,即当前层做allreduce时,可以继续算下一层的梯度,而对于TP来说,它计算完当前层的grad,需要卡间做allreduce后,得到全局的grad,才能继续下一层的梯度计算,因此DP和TP混合时,最好将TP放在同一台机器,DP可以看情况放在多条机器间,从而提高效率。
3.3 流水并行(PP)

流水并行是在模型层间实现并行,以层为粒度将不同的层和参数划分到不同卡上并行计算。如上图所示,将Linear1和Linear2切分到0号卡,Linear3和Relu切分到1号卡。流水并行钟,loss层和准确率计算都在前向计算的最后1卡上,也仅有这张卡上能获取到loss值。
同样进行通信分析:
1.各卡的训练参数按流水线切分后的参数分配,分别初始化,不需要通信。
2.各卡的网络使用切分后的网络,在前向计算中,当前卡计算完成后的数据要send到下一阶段的卡上,同时需要从其它卡通过recv接收数据。此时需要通信。
3.反向计算过程与前向一样,需要发送和接收grad数据,因此也需要通信。
4.由于optimizer不做切分,因此各卡都有完整优化器,用于更新参数,无需通信。
流水并行的基本示意图如下:

而为了优化流水线并行中设备的计算效率,可以进一步将mini-batch切分成粒度更小的micro-batch,来提升流水并行的并发度,进而达到提升设备利用率和计算效率的目的。如下是以更小 粒度的micro-batch做编排:
FthenB

上图这样的方法是最基础的micro-batch方法。即先做forward,所有forward做完再做backward。假设forward和backward的时间分别是tf,tb,并且共有m个micro_batch,则理想的流水并行时间为:

但是注意,由于pp并行设备会经过warmup和cooldowm阶段,即存在设备空转的时期,因此导致并不是每时每刻所有设备都在处理micro_batch,所以实际时间还要加上空转的时间,其实就是bubble的时间。bubble占用时间:
并且FthenB存在大量activation数据被保存在显存等待backward的时候调用的问题,显存利用率很低。
1F1B编排

在此基础上,1F1B编排被提了出来,现在当第一个micro_batch做完所有的forward之后,就会立即开始做backward。

可以看到:
- gpu1的峰值动态内存最多只用保存3份forward的中间变量
- gpu3的峰值动态内存最多只用保存1份forward的中间变量
相比于之前的FthenB,如果现在用FthenB,则每张卡都要存4份,峰值显存相比之下降低了很多。
但是,需要注意的是,buble率并没有用下降,原因很简单,forward的时候,最后一个rank会空转直到第一个micro_batch的forward进行到最后一个模型块;而反向的时候第一个rank会空转直到第一个micro_batch的backward进行到第一个模型块,这在1F1B和FthenB中都是一样的结果。


可以看到无论是非理想状态(即时空图不对称),还是forward,backward不相等,或者num_micro_batches>pp_degree,总体上1F1B都的编排都遵循如下规律,这里设forward为f,backward为b,流水并行数为数为N,流水并行编排阶段号为:
warmup阶段:
1F1B阶段:
cooldown阶段:
所以1F1B和FthenB的bubble占理想时间的比例均为:(p为pp_degree数)
![]()
激活重计算

这是一种时间换空间的方法,可以看到backward是forward时间的两倍,这里表示forward过程不保留激活值,而是在backward的时候,重新推理一遍前向获取激活值,这样每张卡都不用拿多的显存来保留激活值了。
VPP

VPP是1F1B的进阶版,主要是采用Round Robin的方式把不同层按顺序依次划分到N个GPU上,即每个gpu会经过多轮的forward,才完成一次完整的前向。

从图中我们也可以看出来,VPP相比于1F1B主要做的工作其实从逻辑上看,是把原来的一个比较大块的forwrad切分成了更小的粒度,这么做的好处是,虽然编排后,bubble的率,即bubble个数占整个job编排个数的比例并没有变,但是每个bubble时间占总时间的比例变小了,原来forward一次需要tf的时间,切分成v份后,就只需要tf/v的时间,因此bubble的时间也相应减少为1/v。

那是不是我们把模型分成更多份,让这个V越来越大,整个bubble时间越小,效率就越高呢?
答案肯定是否定的,因为这里要注意一个问题,GPU间点对点通信次数,为vpp_degree倍,切分的越多,意味着通信量成倍的上升,这也是影响性能的关键,因此不能盲目的增加vpp_degree。
VPP的编排公式如下(cool_down阶段与warm_up阶段对称,因此不需要写,注意需要判断一下total_steps和warm_up_steps的大小,):
这里warm_up_steps的公式怎么理解呢?
这里可以拆解为两步计算如下:
1. 计算 warmup_steps 的初始值:
2.更新 warmup_steps:
这里解读一下这两个公式的具体来源:
首先我们先解释公式2的由来:可以注意到,最先开始做1F1B的一定是最后一个rank,即当第一个micro_batch经过vpp_degree轮在所有rank间的循环后,它来到最后一个模型块,做最后的forward,接下来就准备开始它的backward的阶段了。所以最后一个rank一定会做(vpp_degree-1)*pp_degree步的forward(这里把最后一个模型块做forward的部分给剔除了,因为他会被算在steady阶段),既然最后一个rank会做这么多forward,那么其它rank也同样会做这么多的forward。
其次我们分析一下第一个公式,由这个对称性其实就可以知道,它是由forward和backward共同产生的。其实这个原因跟1F1B很相似,就是因为最开始forward过程,第一个rank先开始,所以后续rank会空转,那么相对于其它rank,rank0就会多处理个数据,而backward也同样如此。所以在这里读者估计会和我有一样的困惑,为什么1F1B不乘以2呢?
我们对比一下1F1B和VPP,首先从全局视角来看,最早能出现backward的时间步位置在(假设forward和backward的时间相同):
这里也比较容易理解,第一个micro_batch传到从当前stage传到最后 一个stage的过程中,当前stage会产生个时间步,而当第一个micro_batch的backward从最后一个stage传到当前stage,当前stage又会产生个时间步,而最后一个stage做一个forward加一个backward,当前stage会产生2个时间步,所以最终得到上面的式子。
这个公式说明了什么?
说明在warm_up阶段,每个stage最 多可以安排个时间步的操作,即,即使不按传统的1F1B或VPP来安排warm_up的时间步,也不会影响流水线正常进行,即整个时间不会增加也不会减少。可以从如下图看出:

先看左边1F1B的图,左上角第一个是pp_degree=4,micro_batch=4的流水并行图,紧接着我们插入新的micro_batch,为了严格遵守warm_up_step=(pp_degree-pp_stage-1),所以我们新的micro_batch选择插空填在后面,而紧接着下面一幅图,我们不遵守warm_up_step,直接插空填新的micro_batch。可以发现,因为最后一个rank始终是紧密排列的,所以总的PP并行的时间其实二者没有区别。但是我们会发现,两种方法,前者的显存峰值,要低于后者,因为前者将更多的micro_batch安排在1F1B过程进行,则每次都会释放一个activation值,这样可以降低峰值显存。所以其实1F1B也可以warm_up_step=(pp_degree-pp_stage-1)*2,但是并不减少时间,还会增大显存峰值,因此没有必要。
再看右边的VPP的图,第一幅图是标准的pp_degree=4,micro_batch=4的流水并行图。紧接着我们插入新的micro_batch,同样严格遵守warm_up_step=(pp_degree-pp_stage-1)*2+(vpp_degree-1)*pp_degree(此时pp_degree=8,micro_batch=4),而我们可以联想到,既然1F1B可以把warm_up_step控制在(pp_degree-pp_stage-1),那除去(vpp_degree-1)*pp_degree表 示当前GPU上前几个chunk必须要经过的时间步,我们是不是同样可以不乘以2,让warm_up_step最小呢,即等于(pp_degree-pp_stage-1),考虑到这一点,我就对原来的pp_degree=8,micro_batch=4进行了重排,可以发现,这时候总时间仍然没变,但是可以相对减少GPU的显存峰值,当然,二者只是一个二倍的关系,并且只与设备数有关,所以优化不是特别明显,但这个工作证明了,VPP在最后一层的数量计算上,确实可以和1F1B对齐。

这里展示了4卡下的VPP,使用llama2-7b模型进行测试,因为显存有限,将调整至1024,设置为8,vppdegree设置为2,ppdegree设置为4,设置为8,优化前后优化后,显存峰值,如上,由于笔者机器有限,大家可以自行尝试,也许在像生产中96卡的情况下,还是能节省较大显存的。如下为各卡的峰值显存对比:
每个设备显存峰值对比(单位 MB):
GPU0: 旧=6879 新=6047 | 变化(新-旧)=-832 | 降低=832 | 降低效率=12.09%
GPU1: 旧=5817 新=5241 | 变化(新-旧)=-576 | 降低=576 | 降低效率=9.90%
GPU2: 旧=5241 新=4921 | 变化(新-旧)=-320 | 降低=320 | 降低效率=6.11%
GPU3: 旧=10067 新=10067 | 变化(新-旧)=0 | 降低=0 | 降低效率=0.00%
这里没有画最后一张卡的原因在于,最后一张卡的最后一个模型块的编排始终是1,即做完forward,立即做backward,所以优化前后是不变的,上面的效率降低的也证明了,0卡显存峰值是降低最多的,而最后一张卡不变。降低的效率在6.11%~12.09%。且训练总时间上是基本不变的。
VPP如何编排不同chunk(即vpp_stage)的forward和backward?
前面讲了如何去计算每个阶段的stage数,而有了stage数,对于VPP,由于每个GPU上存在不同的模型块,因此我们在编排的时候还需要知道,什么时候该编排哪一块,所以一般传统的VPP遵循的公式即:
这里的公式也比较好理解,首先求出来是一轮完整的forward或者bakcward经过的pp_stage数。而用micro_step求余,我们可以知道当前micro_batch在一轮中的哪个pp_stage上。我们注意当的时候,则step最大值刚好等于一轮完整的pp_stage数,因为经过某一个GPU的micro_batch数为,而一轮完整的forward或者backward经过的pp_stage数为,所以此时step刚好等于一轮的pp_stage数,所以也可以看出,当micro_batch大于pp_stage数时,就会超过,因此这里用的求余操作。而知道在哪个pp_stage后,再用公式2即可得到其在当前GPU的哪一个模型块上(每个模型块编号都从0开始)。
1F1B和VPP都可能出现的死锁问题!

注意看,对于最后两个pp_rank来说,存在一个现象即GPU3做完forward给GPU4发送计算的activation值,而GPU4做完了B1向GPU3发送backward的grad值,此时GPU3等待GPU4接收到数据,再做下一个计算,而GPU4也在等待GPU3接收数据,再做接收GPU3传来的forward的数据做下一个计算,此时就会造成通信死锁。因此需要对send,recv通信和forward,backward计算做一个解耦,如下所示:

将forward操作拆解为recv_forward和forward(注意这里只拆了recv_forward,没有拆send_forward),将backawrd拆解为backward和send_backward(只拆了send_backward,没拆recv_backward),这样在1F1B阶段,始终保持先recv_forwrad再send_backwrad的顺序,即可解决死锁问题。
Zero Bubble

首先Zero Bubble的第一个优化点,就是将backward过程拆解成两个阶段,一个是对weight求导,一个是对输入input求导,我们需要注意,在backward的求导过程中,我们对weight和input求导,需要获取到链式法则传递下来的导数,▽L/▽Z,而这个就是当前层输出的导数(也就是下一层的输入的导数),而有了这个导数就可以对weight和input求导。而当我们对input求导完成后,即使weight没有求导,仍然可以计算传递链式导数,到下一层求导,weight是否求导完成仅仅影响最后梯度的更新,因此我们可以将backward拆解,仅仅保证对输入input求导有序进行,并在这之间插入对weight的求导即可。(注意当前层weight的求导在插入时,需要保证上一层的input求导已经完成。)

1F1B

Zero Bubble
Zero Bubble也是在1F1B的基础上进行改进,分为两种,这里以ZB-H1和ZB-H2来讲解。首先ZB-H1是保证显存峰值不超过1F1B来编排的,可以看到,相比于1F1B来说,整个时间降低到了原来的1/3。而ZB-H2是显存峰值超过1F1B的情况进行编排,这样能在流水并行的过程中首先零bubble,但是注意这里移除了优化器步骤之间的同步。
在传统的PP实践中,为了数值稳健性,通常会在优化器步骤执行的管道阶段加上同步,例如需要计算全局梯度范数进行梯度裁剪、或者混合精度中执行NAN和INF值得全局检查,这两者都要跨所有阶段进行全局规约。因此论文移除优化器步骤需要考虑这个问题,于是提出了后验证的方式。

如图,逐个rank可以 一次从前一个阶段接收到一个部分规约的局部信息,和当前局部状态融合,传递到下一个阶段,而当发现NAN或部分归约梯度范数超过裁剪阈值时,跳过更新。在下一次迭代的warmup阶段,完全规约的全局状态从最后一个阶段传播回第一个阶段,这时候每个阶段都有了全局状态,并以此来验证前一个优化器是否合法。如果不合法,则回滚,根据完全规约的全局状态,重新执行优化器步骤。
优化器回滚

当回滚优化器的时候,一种方法是保存全部的历史版本的参数和优化器抓鬼太,并在需要时恢复,但这种方法在内存上效率低下。因此论文针对AdamW优化器提出了回滚方法,因为优化器的更新公式,是可以逆运算的,所以根据逆运算计算回之前的状态即可。
最佳调度方案
同事为了适应更多的场景,论文提出了一种可自动搜索最佳调度方案的算法,一种启发式策略,可以在为批次足够大时生成接近最优解。
启发式算法有以下步骤:
- 热身阶段: 在内存允许的情况下,尽可能安排更多的 F,以减少第一个 B 前的等待时间。如果内存还有余量,可以安排额外的 F,但可能会延迟后续的 B。
- 稳定阶段: 在热身阶段后,我们交替安排 F 和 B。当有空闲时间超过 W 时,插入 W填充等待时间。即使等待时间不足 W,但当前等待时间会增加所有阶段中最大的等待时间时,我们也会插入 W。当内存接近饱和时,也会插入 W 释放一些内存(W需要activation的数据)。
- 阶段间调度: 确保每个阶段在用尽 F之前至少安排一个比下一个阶段更多的 F。当差异超过一定阈值时,考虑跳过某些阶段中的 F。
- 资源用尽: 在每个阶段,当 F 和 B 任务完成时,按顺序安排所有剩余的 W任务。
3.4 序列并行(SP)
Megatron提出的序列并行

其实是结合了TP的特点,因为TP的RowParral输出时,需要调用all-reduce进行通信,将不同rank上的partial状态的输出给求和,所有rank得到全局输出,而all-reduce可以拆解为reduce_scatter和allgather。而引入SP后,我们可以发现,我们先做reduce_scatter,让每个rank得到全局输出的一部分,这样每个rank只需要计算部分数据的LayerNorm和Dropout,整个计算量被分摊到不同rank上,这样保存的在backward需要用到的activation数据也可以减少,从而减少每个rank的显存。而在进入如Multi-self_attention 和Feed Forward这种需要TP的层时,再调用all gather通信把SP层的数据收集起来,丢给TP层做计算。相当于把TP的通信拆分,这样整个过程SP+TP相比于TP通信量并没有变,并且还减少了显存的压力。