1. 从一次扩展效率腰斩说起:集合通信究竟卡在哪
半夜两点,某实验室的几十张加速卡一起跑一个百亿参数级别的训练任务,单卡吞吐看着挺漂亮,扩到八卡也还行,扩到三十二卡的时候每秒处理的样本数只涨了不到一半,扩到六十四卡直接原地踏步甚至回退。日志里没有报错,显存没爆,CPU 也不是瓶颈,监控面板上唯一跳起来的是网络吞吐曲线——跑满了,而且是一直跑满。
这类现场我见过太多次。绝大多数人第一次做分布式训练,注意力都放在模型结构、学习率、数据清洗上,等真正卡住了才发现,集合通信与模型并行策略才是决定这套集群能不能跑起来的那道门槛。集合通信说白了就是"一群卡之间怎么开会":谁把数据发给谁、发多少、按什么顺序发、什么时候该等别人。模型并行策略则回答另一个问题:一张卡装不下这个模型,我该沿哪个维度把它切开,切完之后这些碎片之间需要多久通一次话。这两个问题是一体两面——你选了哪种切法,就决定了你要付多少通信账单。
这篇内容适合三类人看。第一类是刚接手多卡训练、被"扩展效率上不去"折磨的工程师,你需要知道瓶颈到底在哪一层。第二类是要在多机集群上把一个几十亿到几百亿参数的模型塞进去、并且跑得不太慢的算法同学,你需要一套选型逻辑而不是拍脑袋。第三类是写训练框架或者做性能分析的,你想搞清楚各种并行组合背后的通信量公式,从而判断某个方案值不值得尝试。我不会只讲概念,会把通信量算成 MB、把耗时算成毫秒,把配置写成能直接改参数复现的形式。
先说结论性的判断:在大规模训练里,计算是线性的,通信是对数的甚至更差。你加一倍卡,算力翻倍,但通信的参与方也翻倍,延迟项和带宽争抢同时恶化。所有并行策略的设计,本质上都是在做同一件事——把不可避免的通信压缩到最小、藏到计算后面去、或者干脆限制在最快的那条链路上。
2. 集合通信原语与性能模型:先把"通信贵"算成具体数字
2.1 六个基本原语,各自对应什么训练动作
集合通信的原语不多,掰着手指头数得过来,但每一个在训练里都有明确的对应动作,搞清楚映射关系,后面看任何并行方案都不会迷糊。
Broadcast(广播):一个卡的数据发给所有卡。典型场景是训练开始时把初始权重从 rank 0 分发出去,或者加载 checkpoint 后对齐参数。通信量就是一份数据的大小 S,延迟是 log N 级别(树形)或 N 级别(线性),实现上一般走树。
AllReduce(全规约):所有卡各自有一份数据,规约(求和/求平均)之后每张卡都拿到完整结果。这是数据并行的命脉——每张卡算自己那部分 batch 的梯度,然后 AllReduce 求平均。也是张量并行里每层都要做的事。
ReduceScatter(规约散射):规约之后,每张卡只保留结果的一个分片。它是 AllReduce 的前半段,也是分片式数据并行(ZeRO、FSDP 那一类方案)省显存的关键动作。
AllGather(全收集):每张卡有一片数据,收集之后每张卡都拿到全部。是 AllReduce 的后半段,也是分片方案在需要用到完整参数时的回收集动作。
AllToAll(全交换):每张卡给其他每张卡各发一份不同的数据。专家混合模型(MoE)里把 token 路由到对应专家所在的卡,靠的就是它;某些张量并行的布局转换也用它。
Reduce(规约到单卡)和Scatter/Gather属于变体,实际训练里用得少,但做指标统计(比如把所有卡的 loss 汇总到一张卡打印)时会碰到。
这里有个很多人忽略的等价关系:AllReduce = ReduceScatter + AllGather。这不是学术上的拆解,而是实打实的工程事实,几乎所有高性能实现内部都是这么干的。理解这一点,你就能明白为什么分片式并行不会额外增加通信量——它只是把一次 AllReduce 拆成两半,前半段顺手把结果切了,后半段再按需收集。
2.2 通信量公式:环算法的 2(N-1)/N 是怎么来的
集合通信的性能模型可以用一个极简的式子概括:
T ≈ α + S / Bα 是单次消息的固定延迟(微秒级),S 是消息大小,B 是有效带宽。小消息看 α,大消息看 B,这一条能解释后面 80% 的性能现象。
以最常用的环形 AllReduce 为例。设卡数 N,每张卡的数据量 S(字节)。算法分两个阶段:
第一阶段叫 reduce-scatter,做 N-1 步。每步中,每张卡把自己手上的一块数据发给下游邻居,同时接收上游邻居的一块并做累加。走完 N-1 步之后,每张卡持有某一个分片的完整规约结果。
第二阶段叫 all-gather,同样 N-1 步。每张卡把自己持有的那块完整分片沿着环传一圈,最终所有卡拼出完整结果。
两个阶段的传输量分别都是 S(N-1)/N,加起来:
AllReduce 总通信量 = 2S(N-1)/NN 很大时这个系数趋近于 2。也就是说,不管你用多少张卡,AllReduce 每个卡实际收发的数据量最多是数据本身的两倍。这是环算法的漂亮之处,也是它成为默认选择的原因——通信量对卡数不敏感,只跟数据量有关。
但代价藏在延迟项里:总步数是 2(N-1),每一步都有一次完整的网络往返握手。当 S 很小(比如每步只同步几百 KB 的指标)时,2(N-1)·α 会完全主导,这时候环算法反而慢,树形或递归倍增这类 log N 步的算法更合适。这就是为什么集合通信库会针对不同消息大小自动切换算法——小消息走树,大消息走环,这个阈值通常在几百 KB 到几 MB 之间。
其余原语的通信量可以一并记住:Broadcast 是 S,AllGather 是 S(N-1)/N,ReduceScatter 是 S(N-1)/N,AllToAll 是 S(N-1)/N(但每张卡发出的数据互不相同,无法用规约压缩)。
2.3 从算法带宽到总线带宽:为什么你的实测值和理论值差一倍
新手最容易被这个坑住。拿一个 7B 参数模型做例子,bf16 精度,梯度也是 bf16,一份梯度 14GB。八卡 AllReduce,按公式算通信量 = 2 × 14 × 7/8 = 24.5GB。如果卡间链路是 50GB/s,理论耗时 0.49 秒。实测往往只有理论的一半甚至三分之一,于是开始怀疑人生。
问题出在"带宽"这个词的口径上。厂商标的 50GB/s 是单向单端口带宽,而环形 AllReduce 里每张卡同时在做"收"和"发",收和发各占一半;再加上算法内部把数据切成 N 份流水线传输,实际每个端口的有效利用率会打折扣。所以性能分析里要用**总线带宽(bus bandwidth)**这个概念,换算关系是:
busbw = algbw × 2(N-1)/N其中 algbw 是你用"数据量 / 耗时"直接算出来的算法带宽。只有当 busbw 接近硬件链路的物理上限时,你的实现才算跑满。做性能基线时,先把 busbw 测出来,再去看训练里的通信占比,才能判断到底是通信实现不行,还是你的并行策略本身通信量就太大。
提示:做基线测试时一定要用和训练时相同的数据规模和相同的进程拓扑。八卡单机跑出来的数字,直接拿去推多机场景,误差会大到没有参考价值,因为跨机链路的带宽通常只有机内高速互联的几分之一。
2.4 拓扑决定天花板:为什么张量并行几乎从不出单机
集群的物理结构是分层的:卡内显存带宽最高,卡间高速互联次之,跨机网络再次之,通常还差一个数量级。这个"带宽悬崖"直接决定了并行策略的适用边界。
单机内那几条高速链路,延迟在微秒级、带宽在数百 GB/s;跨机网络即使是很好的无损网络,单端口也就几十 GB/s,延迟高一个数量级。于是逻辑很清晰:
- 通信频繁、对延迟敏感的并行方式,必须关在单机里。张量并行每层都要同步,属于这一类。
- 通信稀疏、只同步梯度的并行方式,可以跨机。数据并行属于这一类,它每一步只通信一次。
- 流水线并行介于两者之间,它只在阶段边界传激活值,量不大但有时序耦合,跨机可接受,但需要精心安排 micro-batch 调度来掩盖。
我在实际配置里见过最常见的错误,是把张量并行度设成跨越机器边界的数值。表面上看模型切开了、显存够了,跑起来发现每层的 AllReduce 需要跨机往返,单步时间直接翻三倍。这个问题后面在选型章节还会展开。
3. 并行策略拆解:数据、张量、流水线各自的分工与代价
3.1 数据并行:最简单,也最容易撞上通信墙
数据并行的逻辑最直观——每张卡放一份完整模型,各自喂不同的数据,反向传播后把梯度 AllReduce 求平均,然后各自更新。它的优点是实现简单、收敛行为和单卡几乎一致、模型代码不用改。缺点也很直白:每张卡都要存一份完整的模型、梯度和优化器状态。
算一笔账。一个 13B 参数的模型,混合精度训练时显存占用大致是:
| 组成部分 | 精度 | 大小 |
|---|---|---|
| 参数 | bf16 | 26 GB |
| 梯度 | bf16 | 26 GB |
| 优化器一阶矩 | fp32 | 52 GB |
| 优化器二阶矩 | fp32 | 52 GB |
| fp32 主权重副本 | fp32 | 52 GB |
| 合计 | — | 约 208 GB |
单卡 80GB 显存,装不下。这就是为什么纯数据并行在十亿参数以上基本走不通,必须上分片或者混合并行。
即便显存够,通信也是问题。每步一次全量梯度 AllReduce,通信量约 2 倍梯度大小(前面算过),而且这个量与 batch size 无关。这意味着你把 batch 调小、想多跑几步,通信占比反而更高。数据并行的扩展效率曲线通常是:小规模线性得很好,过了某个卡数开始掉头向下,那个拐点就是通信时间开始追平计算时间的位置。
工程上缓解这个问题的常规手段是梯度分桶 + 反向传播重叠。思路是:反向传播是从后往前一层层算的,某几层的梯度算完就可以立刻发出去做 AllReduce,不必等整个反向结束。把梯度按 25MB 左右切成桶(这个默认值是有讲究的——太小则延迟项占主导,太大则重叠不充分),桶一满就触发异步规约,让通信藏在后面的反向计算里。实测下来,这一步能把数据并行的有效通信开销压掉 60% 到 80%,是性价比最高的优化之一。
3.2 张量并行:把矩阵切开,代价是每层都要对话
张量并行解决的是"单层都放不下"的问题。以最常见的两层线性变换为例,做法是把第一个矩阵按列切、第二个矩阵按行切,中间结果在两张卡上各持一半,经过一次 AllReduce 之后得到完整输出。反向传播时,梯度也需要一次 AllReduce 来对齐。
所以张量并行的通信特征是:每个 Transformer 层、每个 micro-batch,前向一次 AllReduce、反向一次 AllReduce。一个 32 层的模型,一层里注意力块和 MLP 块各有一组,一次前向就是几十次集合通信。
通信量怎么估?它同步的不是参数,而是激活值,大小约为batch × seq_len × hidden_dim × 2 字节。取 batch=4、seq=2048、hidden=5120,单次约 84MB,一次前向 64 次 AllReduce(32 层 × 2 个块),累计就是 5GB 级别。这个量在机内高速链路上还能扛,跨机就完全没法看。
这就解释了行业里那条不成文的规矩:张量并行度不要超过单机的卡数,而且尽量贴着高速互联的拓扑边界走。八卡一机,张量并行度就设 8;四卡一机,就设 4。设成 16 意味着有一半的通信要跨机,性能必然塌方。
还有一个容易被忽略的点:张量并行的通信量与 batch 无关。你把 micro-batch 调小,计算量按比例下降,但集合通信的次数不变、延迟不变,于是通信占比反而升高。所以用了张量并行之后,micro-batch 不宜切得太碎,否则延迟项会把收益吃光。
3.3 流水线并行:用时间换空间,代价是空泡
流水线并行切的是"层"。前几层放机器 A,中间放机器 B,最后放机器 C,数据像流水线一样依次流过。每张卡只需要存自己那几层的参数,显存压力大幅下降,而且卡之间只传激活值和梯度,通信量小、频率低,跨机也能接受。
它的核心问题是空泡——流水线要填满才有意义。最朴素的实现(所有 micro-batch 先做完全部前向、再做全部反向)在开始和结束阶段会有大量卡闲着。设流水线阶段数 p、micro-batch 数 m,气泡率的近似公式是:
气泡占比 ≈ (p - 1) / (m + p - 1)代入具体数字感受一下。p=4、m=4 时,气泡率 3/7 ≈ 43%,几乎一半时间在等;p=4、m=32 时,气泡率 3/35 ≈ 8.6%,可以接受。所以流水线并行的关键是 micro-batch 数要远大于阶段数,一般建议 m ≥ 4p。
主流调度是 1F1B(一前一后交替),每张卡做完一个 micro-batch 的前向就紧接着做另一个 micro-batch 的反向,让激活值的内存占用保持平稳(而不是 GPipe 那样全部前向做完再全部反向,那个方案显存会爆)。更激进的交错式调度(把每张卡负责的层打散成多个虚拟阶段)能把气泡进一步压到 (p-1)/(m·v+p-1),v 是虚拟阶段数,但通信次数也随之上升,属于精细调优的范畴。
实操上还有两个细节。第一,流水线的第一个和最后一个阶段负载天然不均衡(第一个只做前向的一部分、最后一个只做反向的一部分),会导致参数更新节奏不齐,需要额外的 embedding 层拆分或损失缩放对齐。第二,流水线要求所有 micro-batch 大小严格一致(除非做变长调度),数据加载器那边要保证最后一个 batch 能被整除,否则会挂住——这个坑我踩过,表现是训练跑到第 N 步就不动了,日志毫无提示。
3.4 混合并行:真实集群里的组合方式
到了几百卡的规模,没有哪个单一策略能撑住,标准做法是三维混合:TP × PP × DP = 总卡数。
举个具体配置。64 张卡,单机 8 卡。合理的切法是 TP=8(关在单机内)、PP=4(跨 4 台机器)、DP=2。这样每份模型被切成 4 段流水线、每段内部再切 8 份张量,一共 32 张卡装一份完整模型副本,两份副本做数据并行。
选型的判断顺序我一般是这么走的:
- 先看单层能不能放下。单层参数 + 激活超过单卡显存,必须上张量并行,且并行度不超过单机卡数。
- 再看总层数够不够切。流水线并行度受层数限制,32 层的模型切 8 段,每段 4 层,已经比较勉强;切 16 段就是每段 2 层,通信和调度开销会盖过收益。
- 剩下的卡全给数据并行,因为它的通信效率最高。
- 最后看全局 batch 够不够。数据并行度越大,需要的全局 batch 越大才能保证每卡上的 batch 不至于太小(一般每卡不小于 1,理想是 4 以上),否则梯度噪声大、收敛变差。
对于混合专家(MoE)这类结构,还要额外引入专家并行,靠 AllToAll 把 token 送到对应专家所在的卡上。这个通信模式的麻烦之处在于它是动态的——每个 token 去哪张卡取决于路由结果,负载天然不均衡,要做容量因子限制和 token 丢弃策略,否则最慢的那张卡会拖住整个 AllToAll。
4. 显存与通信的再平衡:分片、重计算与重叠
4.1 分片式数据并行:用通信换显存的三档开关
前面算过,13B 模型纯数据并行要 208GB 显存。分片式方案(ZeRO / FSDP 这一类)的思路是:既然每张卡都存一份完整的优化器状态太浪费,那就切开,每张卡只存 1/N。
这个思路分三档递进,代价各不相同:
| 档位 | 切分对象 | 单卡显存(13B,N=8) | 通信量变化 |
|---|---|---|---|
| 第一档 | 只切优化器状态 | 约 39 GB | 与纯数据并行相同 |
| 第二档 | 优化器状态 + 梯度 | 约 32 GB | 与纯数据并行相同 |
| 第三档 | 优化器状态 + 梯度 + 参数 | 约 26 GB | 约增加 50% |
第一档和第二档是"白捡"的——显存省了,通信量没变,因为梯度 AllReduce 之后本来就要扔掉(或者说规约后每张卡只需要自己那份),顺手做成 ReduceScatter 就行。这是最划算的优化,任何显存吃紧的场景都应该先开这一档。
第三档才是真正的取舍。参数被切开了,前向传播走到某一层时,需要先把这一层的完整参数 AllGather 回来,用完立刻释放。通信量因此增加约 50%(前向一次 gather、反向一次 gather、反向一次 reduce-scatter,加起来是原来的 1.5 倍)。但它能把显存压到接近线性可分片的程度,是单卡装不下大模型的兜底方案。
实操上要注意,第三档的 AllGather 是按层触发的,层与层之间可以流水。好的实现会把下一层的参数预取和当前层的计算重叠起来,把增加的那 50% 通信藏掉大半。判断一个实现好不好,就看它的通信覆盖率——如果监控显示网络吞吐曲线有明显的空隙,说明预取没做好。
4.2 激活重计算:拿算力换显存的另一条路
参数、梯度、优化器状态之外,还有一块常被低估的显存消耗:激活值。长序列场景下,激活值能占到总显存的一半以上,因为它跟 batch、序列长度、层数都成正比。
激活重计算(也叫梯度检查点)的做法是:前向时不保存中间激活,反向时重新算一遍。代价是多一次前向计算,大约增加 30% 的计算量;收益是激活显存降到原来的 1/√L 到 1/L(L 是层数),取决于切分粒度。
这个取舍值不值,要看你的瓶颈在哪。如果计算利用率已经很高(比如 50% 以上),再增加 30% 计算会更慢;但如果因为显存不足被迫把 batch 设得很小、导致通信占比居高不下,那重计算反而是提速的——它让你能把 batch 放大,摊薄通信开销。我在一个长序列任务上做过对比,开重计算之后 micro-batch 从 1 提到 4,单步时间只增加了 12%,整体吞吐提升近一倍。
4.3 通信与计算重叠:把账单藏起来
所有优化里,重叠是最"无本万利"的一类——通信时间没有减少,但它发生在计算的同时,等于不要钱。
三个最有效的重叠点:
梯度规约与反向计算重叠。前面提过,按桶触发,反向算到哪层就发哪层。这是数据并行的标配,框架默认基本都开了,但要确认桶大小设置合理。
张量并行的 AllReduce 与前向计算重叠。这个需要框架层面的支持,把矩阵乘法和集合通信放到不同的流上,让第 i 层的通信和第 i+1 层的计算并行。手写的话要小心同步点,很容易写出隐藏的死锁或者竞态。
分片方案的参数预取与计算重叠。第三档分片在前向时需要 gather 下一层的参数,这个 gather 可以和当前层的计算并行发起。
重叠做得好不好,看一个指标就够了:通信覆盖率 = 通信时间被计算掩盖的比例。经验上,调得好的数据并行能到 90% 以上,张量并行因为通信密集,能到 60% 到 70% 就算不错。
5. 一套可复现的实操流程与配置
5.1 第一步:把集群拓扑摸清楚
任何调优之前,先测基线。要测的东西就三样:机内卡间带宽、跨机带宽、跨机延迟。用集合通信库自带的带宽测试工具跑一遍标准的 AllReduce,数据量从 1MB 扫到 1GB,画出带宽随消息大小的曲线。
看曲线上的三个位置:
- 小消息区(<64KB):曲线平缓且低,说明延迟主导,这时候优化方向是减少通信次数或者合并消息。
- 中段拐点:带宽开始爬升的位置,这个拐点对应"延迟项和带宽项相等"的消息大小,通常是几百 KB。
- 大消息平台:能否跑到硬件标称值的 80% 以上,跑不到就说明网络配置或者拓扑有问题。
同时要记录实际的进程到卡的映射关系。这一段经常出岔子:容器环境下如果没把 rank 和物理卡对应关系固定好,框架可能把本该同机通信的两个 rank 排到了跨机,性能莫名其妙掉一半。检查办法是打印每个 rank 的主机名和本地设备号,肉眼看一遍是不是连续的。
5.2 第二步:并行策略选型的判断流程
我把上面几节的判断逻辑整理成一张可执行的表:
| 判断问题 | 判断依据 | 结论 |
|---|---|---|
| 单卡能否装下参数+梯度+优化器状态 | 参数量 × 16 字节(混合精度粗估) | 能装下则不需要切参数 |
| 单层参数能否装进单卡 | 最大单层参数量 × 2 字节 | 装不下必须上张量并行 |
| 张量并行度上限 | 单机卡数 | 不超过这个值 |
| 模型层数是否够切流水线 | 层数 / 流水线度 ≥ 4 | 否则气泡太大 |
| 剩余卡数 | 总卡数 / (TP × PP) | 全部给数据并行 |
| 全局 batch 是否够 | 每卡 batch ≥ 1,理想 ≥ 4 | 不够就减小并行度或增大全局 batch |
按这个流程走一遍,先得到主要是"能跑"的配置,再进入调优。别一上来就追求最优,先跑通再跑快。
5.3 第三步:初始化与通信原语的写法
分布式初始化的顺序很关键,错一步就是几小时的排查。常见的正确顺序是:设定当前设备、初始化进程组、打印 rank 信息对齐、再做任何集合通信。下面这段是等价写法示意,注释里标了每一步的用意:
# 伪代码:常见分布式接口的习惯写法 # 1) 先绑定本地设备,避免所有进程挤在 0 号卡上 local_rank = env_int("LOCAL_RANK") set_device(local_rank) # 2) 初始化进程组。跨机必须走高速网络后端, # 并显式指定通信网卡,不要依赖自动探测 init_process_group( backend="nccl", init_method="env://", timeout=timedelta(minutes=30), # 大模型加载慢,默认超时常常不够 ) # 3) 打印拓扑,肉眼核对排布是否符合预期 if global_rank() == 0: print_topology() # 4) 屏障一次,确保所有进程都起来了再往下走 barrier()几个容易翻车的点。超时时间一定要调大——几百亿参数的模型加载 checkpoint 动辄十几分钟,默认的十分钟超时会在你还没开始训练的时候就报错,而且报错信息通常只有一句"连接超时",看不出根因。通信网卡要显式指定,多网卡机器上自动探测经常选错,选到管理网卡上,带宽差十倍。设备绑定要在初始化进程组之前做,顺序反了会出现莫名其妙的显存分配失败。
集合通信原语的使用上,有一个新手常犯的错误:在条件分支里调用集合通信。比如if rank == 0: all_reduce(tensor),这会导致除 0 号以外的所有进程都在等一个永远不会到来的消息,整个任务挂死,且没有任何报错。铁律是:集合通信必须被所有进程以相同的顺序调用。如果你确实只想对某部分数据做规约,用 mask 或者传入不同形状的张量,不要用分支。
5.4 第四步:跑起来之后看什么指标
训练跑通只是开始,真正决定成败的是几个观测量。
扩展效率。用 N 卡的吞吐除以单卡吞吐再除以 N。八卡能到 0.85 以上算好,六十四卡能到 0.6 就算合格。低于这个数,先查是不是并行度的切法和拓扑不匹配。
通信占比。用性能分析工具抓一段 profile,看集合通信的总耗时除以总步时间。数据并行健康值在 10% 以内,张量并行在 30% 以内。超过这个范围,说明通信没被藏住。
计算利用率。用"实际达到的浮点运算量 / 硬件峰值"估算,大模型训练通常在 40% 到 55% 之间。数字低但通信占比也不高,说明瓶颈在数据加载或者算子效率上,别往通信方向查了。
每卡负载均衡。把所有卡的步时间打出来,看最大值和最小值的差距。超过 5% 就要查——可能是流水线阶段分配不均,也可能是数据加载的随机性导致某张卡总是分到长样本。MoE 结构下这个指标尤其重要,一个专家的负载爆炸能让整个集群等它。
5.5 第五步:一个具体的调参示例
假设场景:32 层、13B 参数、hidden 5120、序列长度 2048,32 张卡、单机 8 卡。
第一轮,按流程判断:单卡装不下(前面算过 208GB),需要切。单层参数约 0.4B,bf16 下 800MB,单卡放得下,理论上不需要张量并行。但考虑到激活值,8 卡一机内上 TP=4 更稳。流水线 32 层切 4 段,每段 8 层,m 取 16 的话气泡率 3/19 ≈ 16%。剩余 32/(4×4) = 2,数据并行度 2。
全局 batch 设为 64,每卡 micro-batch = 64 / (2 × 16) = 2,梯度累积 1 步。这个配置能跑,但数据并行只有 2,通信占比不高,主要开销在流水线气泡上。
第二轮优化:把 micro-batch 数提到 32,气泡率降到 3/35 ≈ 8.6%,但每卡 micro-batch 变成 1,计算粒度太细,算子启动开销上来。折中取 m=24。
第三轮,开梯度分桶重叠,桶大小从默认调到 32MB(因为这里是跨机通信,延迟高,桶大一点能减少握手次数);开激活重计算,把 micro-batch 从 2 提到 4,同时把梯度累积降到 0.5(即用更大的全局 batch)。
每轮都要记录三个数字:单步时间、每卡吞吐、通信占比。三轮下来的典型结果可能是单步时间从 1.8s 降到 1.2s,提升 33%。这个量级的优化,绝大多数来自调度和重叠,而不是换了什么高深的并行方案。
6. 常见问题与排查速查
6.1 挂起与超时:九成是集合通信不匹配
分布式训练最让人抓狂的故障是"没有任何报错,就是不动了"。这类问题的根因几乎都是集合通信的两个参与方对不上——要么次数不一致,要么顺序不一致,要么张量形状不一致。
排查顺序我一般是这样的:
| 现象 | 最可能的原因 | 验证方法 |
|---|---|---|
| 训练启动后无输出、无进展 | 某个 rank 在集合通信前就崩了,其余在等 | 看每张卡的日志最后一行,找到那个没打印"ready"的 |
| 跑到某一步卡住,前一步正常 | 条件分支里有集合通信,或 batch 数不能整除 | 检查数据加载器是否 drop_last,检查循环里的 if |
| 报连接超时 | 网卡选错、超时设置太小、防火墙规则 | 显式指定网卡,超时调到 30 分钟以上 |
| 报张量形状不匹配 | 各 rank 传入的 tensor 大小不同 | 打印每个 rank 的形状,注意最后一个 batch |
| 偶发挂死,重启就好 | 网络抖动或显存碎片 | 开集合通信库的调试日志,看是不是有重传 |
一个非常实用的技巧:开集合通信库的调试日志(把日志级别设成 INFO 或 WARN),它会把每次集合通信的操作类型、数据量、参与方都打出来。挂住的时候看最后一条日志停在哪个操作上,再对比各个 rank 的日志,立刻就能定位是谁没跟上。这个手段比看 Python 堆栈有用得多,因为挂起通常发生在 C++ 层面的等待上,Python 栈是看不到的。
注意:调试日志会显著拖慢性能,绝对不能在生产训练里长期开着。定位完立刻关掉,否则你会以为是训练本身变慢了。
6.2 扩展效率不线性:按层次往下剥
加卡不提速,是最高频的问题。我的排查顺序是固定的,从便宜到贵:
第一步,确认是不是数据加载拖累。把所有卡的 GPU 利用率打出来,如果普遍在 60% 以下且波动大,先查数据管道。多卡场景下数据加载的压力是线性增长的,单卡时不是瓶颈的预处理,多卡时很容易成为瓶颈。解决办法是增加加载进程数、把预处理放到卡上做、或者提前把数据编码成定长格式。
第二步,确认并行度切法和拓扑匹配。打印 rank 到物理机的映射,看通信量最大的那组(通常是张量并行组)是不是都落在同一台机器内。这一步能解决相当一部分"莫名掉速"的问题。
第三步,看通信覆盖率。抓 profile,看通信时间有没有和计算重叠。如果通信时间几乎等于理论值、没有重叠,说明桶大小设置不合理或者框架的重叠开关没打开。
第四步,才去看算法层面的通信量。这时候要算:当前配置下每步的总通信量是多少 GB,除以实测带宽得到理论耗时,和 profile 里的通信时间对比。如果实测远大于理论,说明有争抢或者拥塞;如果基本吻合,那就是通信量本身就太大了,得换并行方案。
这个顺序很重要。我见过太多人一上来就怀疑算法,改来改去,最后发现是数据加载的进程数设成了 1。
6.3 那些文档里不会写的经验
显存碎片比显存不足更麻烦。训练跑了几百步突然 OOM,重启就好,这是典型的碎片问题。原因是不同 batch 的形状不一致(比如变长序列没有 padding 到固定长度),导致缓存块无法复用。解决办法是强制把所有 batch pad 到固定长度,或者设置显存分配策略为可扩展段。这个问题在长序列场景下尤其常见。
checkpoint 保存会打断流水线。大规模训练里保存一次 checkpoint 可能要几十秒,所有卡都在等主进程写完。做法是异步保存——先把张量拷到 CPU 内存,立刻返回继续训练,后台线程慢慢写盘。要注意拷到 CPU 这一步本身也是同步的,最好用 pinned memory 加快。
学习率要随全局 batch 调整。这一点经常被忽略:你为了填满流水线把全局 batch 从 64 提到 512,学习率还按原来的设,收敛曲线会明显变差。经验规则是线性缩放加 warmup,warmup 步数也要按比例增加。
别在训练中途改并行配置。想从 32 卡扩到 64 卡继续训练,需要重新切分参数和优化器状态,这个过程叫 checkpoint 重切分。它要求源配置和目标配置的切分维度能整除,否则某些参数分片对不上。稳妥做法是每换一次配置就从最近的 checkpoint 重新开始一小段 warmup,不要指望无缝衔接。
监控里一定要有每步耗时的分布,而不只是平均值。平均值 1.2 秒看起来很稳,但如果分布是 0.8 秒和 1.6 秒各一半,说明存在系统性的抖动,通常是某些卡在等 straggler。看 P99 和最大值,能提前发现负载不均。
序列长度变化会连带改变最优配置。短序列(512)场景下激活值小,显存瓶颈在优化器状态,优先开第一档分片;长序列(8192)场景下激活值占大头,优先开激活重计算和序列并行。同一个模型换任务,并行配置往往要重调,不要照搬。
测试扩展效率时用弱扩展,不要用强扩展。强扩展是固定全局 batch、加卡减每卡 batch,这种测法在小 batch 下通信占比会急剧上升,测出来的效率天然难看,不代表实际部署效果。实际训练通常是全局 batch 随卡数增加,所以要用弱扩展来测,每卡 batch 保持不变。
最后一件事,我自己的习惯是每换一套配置,都把关键参数、实测的单步时间、通信占比、扩展效率记到一张表里。跑过十几组之后,你会对"这个规模的集群、这种模型、大概应该在什么区间"建立起直觉,下次换模型时不需要从零试起,直接从表里最接近的那一行改两个参数就能开工。这个习惯比任何调优技巧都值钱——它把每次踩坑变成了可复用的资产,而不是一次性消耗。