把模型摊到多张卡上的时候,真正难住的往往不是"怎么切"这个数学问题,而是切完之后每一次前向、每一次反向要发多少条消息、每条多大、走哪条链路、能不能被计算盖住。集合通信与模型并行这两件事在我看来越来越像是同一枚硬币的两面:模型并行决定了"切法",而切法直接决定了通信模式;集合通信原语的特性又反过来限制了你能切多狠。很多人第一次把单机多卡的数据并行脚本改成张量并行,会发现 loss 曲线完全一致、代码也没报错,但吞吐掉了一半——问题不在正确性,在通信没算清楚。
这篇内容想解决的就是这个断层。它适合已经跑过单机多卡、能看懂基本的分布式启动脚本,但对"All-Reduce 到底是几步""张量并行一层要通信几次""流水线的气泡怎么算"这类问题只有模糊印象的人。我会从切分动机讲到原语原理,再给可复现的通信量估算方法和真实的排障路径。文中涉及的具体框架接口用通用的dist命名空间代指,各家实现的函数签名大同小异,思路是通用的。
1. 从"单卡装不下"倒推模型并行的三条切分路径
1.1 显存账本的四个组成部分
要先搞清楚模型并行在切什么,得先把显存账本拆开。一张卡上的显存大致被四样东西占据:参数、梯度、优化器状态、激活值。前三个是静态的,规模跟参数量成正比;激活值是动态的,跟 batch size 和序列长度成正比。
按混合精度训练算一笔账:一个参数量为 P 的模型,参数本身(bf16)占 2P 字节,梯度(bf16)占 2P,优化器如果用的是带动量与方差的一阶二阶状态,且主权重保持 fp32,那部分大约占 12P 到 16P。加起来静态开销就是 16P 到 20P 字节。一个 70B 的模型,光静态部分就要 1.1 到 1.4 TB,八张 80G 的卡加起来才 640G,怎么都塞不下。
这时候有三条路:把参数本身切开(张量并行、流水线并行),把优化器和梯度切开(分片式数据并行),或者把激活值切开(序列并行、上下文并行、激活重计算)。模型并行特指前一类——它改变的是"某一层由谁来算",而不是"哪些数据由谁来算"。
这个区分很关键。数据并行里每张卡都有完整的模型副本,通信发生在梯度聚合阶段,属于"一次大批量同步";模型并行里没有任何一张卡持有完整模型,通信发生在层与层之间、甚至层内部,属于"高频小批量同步"。后者对延迟更敏感,这也是为什么模型并行对互联带宽的要求远高于数据并行。
1.2 张量并行切的是层内,流水线并行切的是层间
张量并行的思路是把单个算子内部的矩阵乘法拆开。一个线性层的权重矩阵按列或按行切成若干块,分给不同设备,每张卡算出一部分结果,再通过集合通信拼回完整输出。它的优点是单层内就能并行,负载均衡好做,没有空闲等待;缺点是每层都要通信,而且通信发生在关键路径上,无法被计算完全掩盖。
流水线并行的思路是沿网络深度切。前若干层放卡 0,接着若干层放卡 1,以此类推。数据像流水线一样从前往后流动,卡 0 算完第一批数据把激活传给卡 1,同时开始算第二批。它的优点是通信量极小——只在阶段边界传一次激活,量级比张量并行低一到两个数量级;缺点是有"气泡",即流水线填充和排空阶段部分卡在空转。
实际的大规模训练基本是两者叠加再叠数据并行,业界常见的组合是"节点内张量并行 + 跨节点流水线并行 + 全局数据并行"。为什么这么配?因为张量并行的通信频率高,必须放在高带宽的节点内互联上;流水线并行的通信频率低,跨节点也能接受。这个组合不是拍脑袋定的,是被互联拓扑逼出来的。
1.3 一个常被忽略的判断:什么时候不该上模型并行
单卡能放下、单机八卡能放下的时候,优先选数据并行加分片优化器,而不是模型并行。原因很直接:数据并行的通信是每步一次全量梯度同步,通信量与参数量成正比但频率低,而且可以和反向传播重叠;模型并行的通信在关键路径上,改起来牵一发动全身,调试成本高一个量级。
我见过的典型误判是:模型 13B,八卡 80G,明明用分片式数据并行就能跑,却先上了张量并行,结果每层四次全局同步把吞吐拖到不如数据并行。判断标准其实很简单——算一下你的模型静态开销除以卡数,如果单卡余量还超过 30%,就别碰模型并行。
真正必须上模型并行的场景只有两类:单卡装不下最小可训练单元(哪怕 batch=1、开了重计算),或者单机装不下、必须跨机但仍希望保持较高的计算效率。
2. 集合通信原语:把 All-Reduce 拆开看才明白瓶颈在哪
2.1 八种原语里,真正高频使用的只有四种
教科书上列出的集合通信原语一般有 Broadcast、Scatter、Gather、Reduce、All-Gather、Reduce-Scatter、All-Reduce、All-to-All 这么几种。但在分布式训练里高频出现的其实只有四个:All-Reduce(梯度同步)、All-Gather(参数或激活拼接)、Reduce-Scatter(分片归约)、All-to-All(专家路由、序列重排)。
理解它们的关系有个讨巧的办法:All-Reduce 等于 Reduce-Scatter 加 All-Gather。也就是说,先把数据切成 N 份,每份在一个设备上完成归约(Reduce-Scatter),再把每份结果广播给所有设备(All-Gather)。这个分解不是概念游戏,分片式数据并行正是利用了这个等价关系——它不需要完整的 All-Reduce,只需要前半段把梯度归约到对应的持有者手里,需要时再用后半段把参数拼回来。
Broadcast、Scatter、Gather、Reduce 这几个"单向"原语在训练主路径上用得少,但在初始化(广播初始权重保证各卡一致)、检查点保存(Gather 到 rank 0 落盘)这些环节绕不开。All-to-All 平时用不上,一旦涉及专家混合模型或者需要做全局的 token 重排,它就是绝对主角,而且它的通信模式和其他几个完全不同——每个设备给其他每个设备发不同的数据,是唯一一个"人人对人人的全连接"式通信。
2.2 环形 All-Reduce 的两次扫描与 2(N-1)/N 这个系数
以 N 个设备、待归约数据量 S 字节为例,环形算法把数据切成 N 块,每块 S/N。
第一阶段 Reduce-Scatter 走 N-1 步。第 k 步时,设备 i 把当前手上的某一块发给下游设备 i+1,同时接收上游设备 i-1 发来的块并累加。走完 N-1 步后,设备 i 恰好持有第 i 块的完整归约结果(所有设备的该块都累加过了)。
第二阶段 All-Gather 同样走 N-1 步,把各自持有的那一块沿环传播一圈,最终每个设备都拿到全部 N 块的完整结果。
算一下总传输量:每个阶段每个设备发出 (N-1) 份、每份 S/N,两个阶段合计 2(N-1)S/N。当 N 增大时,这个系数趋近于 2,也就是说 8 卡和 512 卡的 All-Reduce,每个设备承担的数据量几乎一样。这是环形算法最漂亮的地方——通信量对规模几乎不敏感。
作为对比,朴素做法是"先全部 Reduce 到 0 号卡,再 Broadcast 出去",通信量是 2(N-1)S,随 N 线性增长。8 卡时是 14S,环形是 1.75S,差 8 倍。所以环形(以及它的变体)能成为默认选择,不是因为实现简单,而是因为带宽复杂度最优。
代价在延迟上。环形走 N-1 步意味着 N-1 次往返,N 越大延迟越高。当消息很小时,带宽项可以忽略,延迟项主导,这时树形算法(N 的以 2 为底的对数量级步数)反而更快。这就是为什么通信库会根据消息大小自动切换算法,也是为什么小张量必须合并发送。
2.3 用 alpha-beta 模型估一次通信要多久
工程上估算集合通信时间,用 alpha-beta 模型就够了:
T = alpha + S / B其中 alpha 是单次消息的固定延迟(微秒级,跨机比机内高一个数量级),S 是消息字节数,B 是有效带宽。代入环形 All-Reduce 的步数和步长:
T_allreduce ≈ 2(N-1) * alpha + 2(N-1)/N * S / B这个式子能解释好几个现象。第一,小消息场景下 T 几乎只跟 N 和 alpha 有关,跟你传 1KB 还是 10KB 关系不大——所以反向传播里按层逐个 All-Reduce 是灾难,必须攒成大桶。第二,N 越大延迟项越重,跨机场景下 alpha 本身就大,所以跨机做高频小消息通信非常吃亏。第三,带宽项里 S 是被 N 除过的,所以提高 N 不会让带宽项变差,只会让延迟项变差——大消息场景下加卡几乎不损失效率。
举一组具体数字感受一下:机内互联 alpha 约 5 微秒、有效带宽约 150 GB/s;跨机 RDMA 的 alpha 约 20 到 50 微秒、有效带宽约 25 GB/s。同样是 8 卡、64 MiB 的消息,机内耗时约 275us + 1.7564MiB/150GB/s ≈ 70us + 0.78ms ≈ 0.85ms;跨机则是 2730us + 1.7564MiB/25GB/s ≈ 420us + 4.7ms ≈ 5.1ms。差了六倍。这个差距就是"张量并行为什么不跨机"的全部答案。
2.4 通信量的三种表达方式别搞混
新手最容易在三个数字上绕晕:算法传输量、网络实际流量、应用层消息大小。算法传输量是上面算的 2(N-1)S/N,指单个设备网卡上累计收发的字节数;网络实际流量要考虑协议头、重传、多路径分摊,通常比算法量高 5% 到 15%;应用层消息大小就是你传进all_reduce的那个张量有多少字节。
做容量规划时要看的是算法传输量乘以迭代次数,做网络选型时要看的是峰值流量,做代码优化时要盯的是应用层消息大小和消息个数。三个数字混着用,估算就会偏出好几倍。
3. 张量并行:切矩阵的配对规则决定了通信次数
3.1 列切与行切必须成对出现的原因
一个线性层做的是 Y = XW。列切是把 W 按列拆成 [W1, W2],每张卡持有完整输入 X,各算 Y1 = XW1、Y2 = XW2,输出在特征维上拼接。行切是把 W 按行拆成 [W1; W2],输入 X 也按列拆成 [X1, X2],各算 Y1 = X1W1、Y2 = X2W2,两个部分结果需要相加才是最终 Y。
区别就在最后一步:列切之后输出是分片但完整的(只是分在不同卡上),行切之后输出是部分和(需要归约)。所以列切后面接 All-Gather(如果需要完整输出),行切后面接 All-Reduce。
但如果一个列切紧跟着一个行切,情况就变得很划算:列切的输出分片恰好就是行切需要的输入分片,中间的 All-Gather 可以完全省掉。这就是经典的"列-行配对",一个 Transformer 层里的两层 MLP(先升维再降维)正好是这个结构,attention 里的 QKV 投影加输出投影也是这个结构。配对之后,每对线性层只需要在行切之后做一次 All-Reduce。
这个设计不是巧合,是专门为减少通信次数设计的。如果两个都按列切,中间要插一次 All-Gather;如果两个都按行切,中间要插一次 All-Reduce 而且输出还是部分和。只有列跟行配对,中间零通信。
3.2 一个 Transformer 层前向要发几次 All-Reduce
按标准的切法,一个 Transformer 层(self-attention 加 MLP)在张量并行下的前向通信是两次 All-Reduce:一次在 attention 的输出投影之后,一次在 MLP 的第二个线性层之后。
反向传播是对称的,每个 All-Reduce 的梯度回传还需要一次对应的 All-Reduce,所以反向也是两次。合起来,每层每个训练步是四次 All-Reduce。
每层的通信量是batch × seq_len × hidden × dtype_bytes乘以四。注意这里用的是全局 batch 和全局序列长度,不是除以 TP 之后的。这一点非常关键——张量并行让每张卡的计算量降到 1/TP,但通信量保持不变。
结果是计算通信比随 TP 增大而线性恶化。TP 从 1 加到 2,单卡计算量减半、通信量翻倍(因为多了一次跨卡同步),比值降到四分之一;从 4 加到 8,同样的规律再来一遍。这就解释了为什么张量并行超过 8 之后效率曲线会明显下折,除非你的互联带宽也跟着涨。
3.3 一组具体的通信量算例
设定:全局 batch = 4,序列长度 = 2048,hidden = 4096,bf16 精度(2 字节),TP = 8,模型 100 层。
单次 All-Reduce 的数据量:
S = 4 × 2048 × 4096 × 2 = 67,108,864 字节 = 64 MiB按环形算法,单卡实际传输量:
2 × (8-1)/8 × 64 MiB = 112 MiB每层四次,单层单卡 448 MiB。100 层就是约 43.75 GiB,约合 47 GB。按机内 150 GB/s 有效带宽算,单步通信约 0.31 秒。如果同样的事情放在跨机 25 GB/s 的链路上,就是 1.9 秒——而这一层的计算时间可能才零点几秒,通信直接成为绝对瓶颈。
这个算例还有一层意思:如果把 batch 从 4 降到 1,通信量降到四分之一,但计算量也降到四分之一,比例不变。所以调小 batch 救不了张量并行的效率,只有换更快的互联或者减少 TP 才行。
3.4 序列并行补上了张量并行的一个漏洞
标准张量并行有个浪费:LayerNorm、Dropout、残差相加这些逐元素操作的输入在每张卡上是完全重复的。也就是说,本来只要存一份的激活,被 TP 倍地冗余存了。
序列并行的做法是在这些逐元素操作处把激活按序列维切开,每张卡只处理自己那段序列,到需要完整 hidden 维的线性层之前再用 All-Gather 拼回来、算完再用 Reduce-Scatter 切回去。
通信量上,All-Gather 加 Reduce-Scatter 的组合与一次 All-Reduce 大致相当,也就是说通信成本没有实质增加,但激活显存降下来了。这笔买卖在长序列场景下非常划算——序列长度 8K 以上时,激活显存往往是压死骆驼的最后一根稻草。实现在工程上需要注意切分点的插入位置,切早了会多出无谓的拼装,切晚了省不下显存。
4. 流水线并行:气泡公式背后的取舍
4.1 全前向全后向的气泡与显存双重代价
最朴素的流水线调度是把一个 batch 切成 m 个微批,先让所有微批依次走完整个前向,再依次走完整个反向。这就是所谓的"全前向全后向"方案。
它的时间开销很好算:流水线深度为 p,微批数为 m,每个微批在前向或反向占一个时间槽。总槽数是 m + p - 1,其中真正满载的槽只有 m 个,剩下 p - 1 个是填充和排空,也就是气泡。气泡占比:
bubble_ratio = (p - 1) / (m + p - 1)p = 8、m = 16 时是 7/23 ≈ 30%,三成的算力在空转。要压到 10% 以下,m 得提到 64 左右(7/71 ≈ 10%)。
但真正致命的是显存。全前向全后向要求所有 m 个微批的激活同时驻留,因为反向传播要等全部前向做完才开始。这意味着流水线并行省下来的参数显存,被 m 倍的激活显存吃回去了。m 想开大压气泡,激活就爆;m 开小省显存,气泡就大。这个矛盾在用这个方案的时候几乎无解。
4.2 一前一后交替调度把峰值激活压到常数级
改进方案的核心思想是把反向提前。具体做法是:先做 p-1 个微批的前向把流水线灌满(warmup),进入稳态后每做一次前向就紧接着做一次最早那个微批的反向(一前一后交替),最后把剩余的微批反向排空(cooldown)。
这样做的好处是,稳态阶段每个流水级上同时在飞的微批数量被限制在 p 附近,而不是全部 m 个。峰值激活显存从 O(m) 降到大约 O(p),m 就可以放心开大,气泡自然被压薄。
代价是调度复杂度和通信模式的改变。一前一后交替意味着前向和反向的通信交织在一起,如果通信和计算的重叠没做好,反而可能比全前向全后向更慢。另外反向提前意味着某些参数在被使用前就已经算过梯度,需要注意梯度累积的顺序正确性,实现上要维护好每个微批的状态机。
4.3 交错式调度的气泡公式与它的隐性成本
再进一步的优化是把每个设备负责的连续层块再拆成 v 个小块,让一个设备负责多个不连续的层块。这样相当于把流水线深度虚拟地扩大了 v 倍,气泡占比变成:
bubble_ratio = (p - 1) / (m × v + p - 1)p = 8、m = 32、v = 2 时是 7/71 ≈ 10%,同样的 m 下气泡减半。看着很美。
但隐性成本在两处。第一,通信次数增加 v 倍——设备之间要来回跳,每个小块边界都要传一次激活。虽然单次数据量不变,但消息个数翻倍,延迟项跟着翻倍,在网络延迟高的跨机场景下这个代价不小。第二,调度状态机复杂度再上一个台阶,每块的微批编号、前向反向的依赖关系都要单独维护,调试难度显著上升。
我的经验是:v = 2 在机内互联上收益明确,值得上;v 再往上加,收益递减而调试成本陡增,除非你用的是专门的成熟调度框架,否则不建议手写。
4.4 微批数量怎么定:三个约束取交集
微批数 m 的选择同时受三个约束。
第一是气泡约束,上面那个公式决定了 m 至少要多大才能把气泡压到可接受范围。通常目标是气泡占比低于 15%,反推 m ≥ (p-1)/0.15 - p + 1。
第二是显存约束,m 越大同时在飞的微批越多,激活显存越高。虽然一前一后调度把峰值压到 O(p),但 warmup 阶段还是要同时持有 p-1 个微批的激活,这部分省不掉。
第三是整除约束,一个全局 batch 要能被 m 和流水线级数整除,而且切出来的微批大小要能填满计算单元,太小了算子效率会掉。
三个约束一取交集,可选的 m 往往只有两三个值。实践中的典型配置是 p = 8、m = 32 或 64,气泡占比落在 10% 到 20% 之间,同时把全局 batch 定在 128 到 512 的量级。
5. 让通信藏进计算里:重叠、分桶与通信域
5.1 梯度分桶是最容易拿到的免费收益
反向传播有个天然的有利性质:梯度是从后往前逐层算出来的,算完一层的梯度其实就可以开始同步了,不必等全部算完。但要真正利用这一点,必须解决消息太小的问题——单层梯度可能只有几十 KB,按 alpha-beta 模型,这时候延迟项占绝对主导,逐层同步的效率极低。
解决方案是分桶:把梯度按固定大小(常见是 25 MB 到 100 MB 之间)装进若干个桶,一个桶填满了就立刻发起 All-Reduce,同时反向传播继续算下一层往下一个桶里填。这样既保证了消息足够大(带宽利用率高),又实现了通信与计算的重叠。
分桶顺序有讲究。因为反向是逆序的,最先算完的是最后几层,所以桶的填充顺序应该和反向顺序对齐。如果装桶逻辑和反向顺序不一致,会出现"桶等着后面某层的梯度才能发"的情况,重叠窗口被白白浪费掉。
分桶大小也不能一味求大。桶太大,第一个桶要等很久才能填满,开头那段通信完全无法重叠;桶太小,消息碎,延迟开销上来了。25 MB 是个常见的经验甜点,具体还要看你的互联带宽——带宽越高,最优桶越小。
5.2 通信域分组:把全局同步拆成互不干扰的子集
分布式训练里最常见的并行组合是"张量并行组 + 数据并行组"嵌套。一个 8 卡张量并行、8 组数据并行的配置,总共 64 张卡,但全局 All-Reduce 是绝对不该出现的。
正确的做法是建两个正交的通信域:张量并行组只包含同一份模型副本内的 8 张卡,数据并行组包含跨副本的 8 张卡(每张卡对应一个位置)。张量并行的 All-Reduce 只在组内做,数据并行的梯度同步也只在组内做,两个组互不干扰。
这么设计的原因是通信量差异巨大。张量并行的通信每层四次、频率极高,必须限制在高带宽的机内范围;数据并行的通信每步一次、量级相同但频率低得多,跨机也能承受。如果用一个全局通信域,所有通信都会扩散到最大范围,性能会塌方式下降。
建组的时候有个细节容易错:dist.new_group(ranks)这个调用本身是集合通信操作,所有进程必须以相同的顺序、相同的方式调用它,否则会挂死。常见写法是先算好各个维度的分组划分,再在一个循环里按固定顺序把所有组都建出来,保证每个 rank 都执行了相同的调用序列。
# 分组构建必须所有 rank 顺序一致 tp_groups, dp_groups = [], [] for i in range(dp_size): for j in range(tp_size): ranks = [i * tp_size + j] # 具体切分逻辑按你的拓扑调整 # 每个 rank 都要参与所有 group 的创建调用 g = dist.new_group(ranks) if rank in ranks: tp_groups.append(g)上面这段是示意,实际切分索引要按你的 rank 排布来算。核心是那个"每个 rank 都参与创建"的原则。
5.3 拓扑感知:跨机带宽才是真正的天花板
所有通信量估算最后都要落到一条物理链路上。同一台机器内的卡通过高带宽互联直连,带宽是跨机链路的五到十倍,延迟低一个数量级。所以任何高频通信都必须收敛在机内。
判断自己的配置是否踩线,看两个数:张量并行组是不是跨机了?数据并行组的通信量占单步时间的比例是多少?前者一旦跨机,基本可以判定性能不可接受;后者如果超过 30%,说明通信没被计算盖住,要么加大计算密度(提高 batch 或序列长度),要么减少通信频率。
还有一个容易漏的点是网卡和卡的亲和性。多网卡机器上,每张卡应该优先走物理上最近的那张网卡。如果进程没有正确绑定,可能出现卡 0 的数据绕到卡 7 那边的网卡出去,多绕一跳交换机,带宽减半。这类问题不会报错,只会让吞吐莫名其妙地低,排查起来特别费时间。
6. 真机排障:卡死在集合通信上的那几个小时
6.1 无报错挂起:先查集合通信的调用顺序
分布式训练最让人抓狂的症状是所有 rank 都停住、CPU 占用为零、没有任何报错,连超时都要等很久才触发。
绝大多数情况下,根因是各 rank 的集合通信调用序列不一致。比如某个分支只在 rank 0 上执行,或者在某个 rank 上提前continue跳过了一次 All-Reduce,其他 rank 就会永远等在那里。集合通信是强同步的,一个都不能少。
排查路径是这样走的:第一步,看日志最后一条打印在哪个位置,确认挂在了前向、反向还是优化器更新阶段。第二步,在可疑区域把每个集合通信调用前后都打上带 rank 号的日志,跑一次看哪个 rank 少了一条。第三步,检查所有条件分支,凡是可能跳过的路径,要么改成所有 rank 都执行、用 mask 控制数据,要么改成非集合通信的本地操作。
还有一类隐蔽的情况:某些 rank 用了不同的通信域。组 A 的成员在组 A 上做 All-Reduce,另一个 rank 却在全局组上做,两边永远对不上。这类问题在有嵌套并行、动态建组的代码里特别容易出。
另一类会导致挂起的硬件层原因是共享内存段不够。数据加载的多进程和某些通信后端会用到共享内存,容器环境里默认的共享内存往往只有 64 MB,跑起来没多久就卡死。这个坑我踩过不止一次,特征是一开始能跑、跑一会就挂,而且挂的位置随机。把共享内存挂载调大就能解决。
6.2 吞吐只有预期一半:从拓扑和网卡查起
不报错、能跑完、但速度明显不对,这类问题比挂起更难查,因为你没有明确的错误信息。
我的排查顺序是固定的:先确认实际走的是哪条链路,再确认每张卡的实际收发速率,最后才怀疑算法。
确认链路可以看通信库的初始化日志,里面会打印检测到的互联类型(机内高带宽互联还是走网络),以及用了哪些网卡。如果发现本该走机内高速链路的通信走了网络,那配置肯定有问题,常见原因是可见设备的环境变量设置不一致导致各进程看到的卡编号错位。
确认收发速率可以在训练跑起来之后抓一下网卡的实时流量。如果流量远低于链路容量,说明要么消息太小没打满带宽,要么重叠没做好导致通信和计算串行。前者调大分桶,后者检查分桶逻辑。
算法层面的怀疑放在最后。因为集合通信的算法选择通常是库自动做的,手动指定反而容易选错——大消息选了低带宽算法,或者小消息选了高延迟算法,两种情况都会让性能显著下降,但表现不同:前者是带宽跑不满,后者是延迟占比异常高。
6.3 症状与方向的对照表
| 症状 | 大概率方向 | 优先验证手段 |
|---|---|---|
| 全 rank 静止、无报错、CPU 空闲 | 集合通信调用序列不一致 / 通信域不匹配 | 在每次集合通信前后打 rank 日志,比对调用次数 |
| 跑一段时间后随机挂死 | 共享内存不足 / 文件句柄耗尽 / 内存泄漏 | 检查共享内存挂载大小,观察常驻内存曲线 |
| 能跑完但吞吐只有一半 | 通信未被重叠 / 拓扑走错 / 消息过碎 | 抓网卡实时流量,对照估算值 |
| 加卡后单卡吞吐反降 | 通信域跨了慢链路 / 计算通信比恶化 | 确认张量并行组是否跨机 |
| 只有部分 rank 慢 | 进程与设备的亲和性绑定错误 | 检查 NUMA 与网卡绑定关系 |
| 首步极慢、后续正常 | 通信库初始化与拓扑探测开销 | 属正常现象,可用缓存缩短,不必深究 |
这张表不是穷举,但覆盖了实际碰到的大多数场景。有个通用原则值得记住:分布式问题的定位成本主要在信息收集,不在修复。所以花时间把日志打全、把监控指标铺开,比事后猜要划算得多。
7. 几个我在选型和调参上的实际体会
第一,先把单机跑满再考虑跨机。我见过太多在跨机配置上反复调优、最后发现是单机内部的通信域都没建对的例子。单机八卡的吞吐基线如果没打准,后面所有的优化都失去了参照物。
第二,估算表要在动手前就写好。不用很复杂,一张纸列清楚:参数量、hidden、层数、batch、序列长度、计划用几路张量并行几路流水线并行,然后按第三节的方法把每层通信量和单步总通信量算出来,再对照你的互联带宽估一个通信时间。这个数字如果已经超过你预期的单步时间,说明方案选错了,改结构比改代码便宜。
第三,气泡和显存是一对反向指标,任何声称"两个都优化了"的方案要么是换了硬件,要么是藏了别的代价。看到气泡占比降下来,第一反应应该是去查激活显存或者通信次数有没有涨。
第四,别迷信大 TP。张量并行的效率曲线在超过单机卡数之后会陡降,这不是实现问题,是计算通信比的数学规律。真要往上堆规模,走流水线并行或者数据并行加分片,把高频通信留在机内。
第五,超时时间要主动调。默认的超时对大规模训练来说往往太短,一次正常的检查点保存就可能超时触发重启。但反过来,把超时设得极长也有问题——真挂了你要等很久才发现。折中的做法是把超时设成正常单步时间的几十倍,并且打开心跳监控,让异常通过监控暴露而不是靠超时报错。
第六,保留一份"通信量基线"脚本。我通常会在代码里留一个函数,输入并行配置就输出理论通信量,每次改并行策略先跑一遍。这东西看着简陋,但能在改配置之前就判断出"这条路走不通",省下的调试时间远超写它的成本。
def estimate_tp_comm(batch, seq_len, hidden, dtype_bytes, tp_size, n_layers, steps=1): """估算张量并行下每卡每步的 All-Reduce 传输量(字节)""" s = batch * seq_len * hidden * dtype_bytes per_ar = 2 * (tp_size - 1) / tp_size * s # 环形算法单卡传输量 return per_ar * 4 * n_layers * steps # 每层前后向各两次 # 示例:batch=4, seq=2048, hidden=4096, bf16, TP=8, 100 层 print(estimate_tp_comm(4, 2048, 4096, 2, 8, 100) / 1024**3, "GiB")跑出来的结果和第三节手工算的一致,约 43.75 GiB。把它和你的互联带宽一除,就知道理论通信下限是多少。剩下的工作就是让实际耗时尽量逼近这个下限,而不是想方设法绕开它——通信量是结构决定的,绕不开,只能藏。