大模型流水线并行实战:模型切分、FQN保真与零气泡调度
2026/9/7 13:03:52 网站建设 项目流程

最近在把一个 7B 级的 Transformer 模型迁到多机多卡环境里训练时,我把 TorchTitan 的流水线并行完整过了一遍:模型怎么切分、状态字典里的 FQN 怎么保真、调度器怎么做到接近零气泡。这四件事单独拆开都不复杂,串在一起却藏着不少需要提前想清楚的坑。这篇文章就围绕流水线并行、模型切分、FQN 保真、零气泡调度这四个关键词展开,把我在移植和实测中的思考、决策和教训一次讲完,适合正在做 LLM 预训练、大规模微调,或者自己搭分布式训练框架的同学参考。

1. 为什么流水线并行是大模型训练绕不开的一环

1.1 数据并行和张量并行解决不了的问题

先说结论:当模型小到单卡能放下,数据并行几乎是最省心的事,梯度 all-reduce 一遍结束。但当模型涨到 7B、13B、70B,显存从参数、优化器状态、激活值三个方向同时压过来的时候,数据并行天然不解决单卡容量问题,除非你同时开分片。

张量并行能把一个层的矩阵切到多卡上,但它把层内部的计算路径打断了:每过一个 attention 或 MLP 块,就要做一次较高的通信同步。单机 8 卡 NVLink 还好,跨机跨网后就非常痛苦。张量并行是“包治百病”的假象,通信量不会随着距离变远而打折,这也是很多分布式训练在跨节点时性能断崖式下跌的原因之一。

流水线并行则把视野从“一层”拉到了“一整段”。它不再试图把一个层拆碎,而是按层把模型切成若干段,每一张卡或者每一组卡只管其中一段。这样跨节点的通信被压缩到相邻 stage 之间,而且传输的只是边界激活值和梯度张量,频率也比张量并行低得多。

1.2 流水线并行的本质:把模型切成串行消费的段

流水线并行的模型切分思路很直白:把网络看作一条流水线,rank 0 处理最底部几层,rank 1 处理往上几层,依此类推。训练输入被拆成多个 micro-batch,像流水线上的工件一样依次被每个 stage 消费:

rank0: embed + layers 0-7 -> activation rank1: layers 8-15 -> activation rank2: layers 16-23 -> activation rank3: layers 24-31 + lm_head

这种切法本质上选择了“时间换空间”:单卡只需保存自己那段层的参数和激活,所以显存压力大大降低;代价是前后 stage 之间天然存在依赖关系,如果调度做得不好,就会出现大量 GPU 空转。

这也是为什么流水线并行很少单独使用。实际生产场景通常是“数据并行 × 张量并行 × 流水线并行”组合:数据并行负责复制多份完整流水线,张量并行负责把单层变大时塞进一张卡,流水线并行负责把网络的纵向深度拆开。

1.3 TorchTitan 在这里扮演的角色

TorchTitan 是 PyTorch 原生的大模型训练参考实现,它不像一个复杂的框架,更像一套“官方做给你看的最佳实践”。你可以在里面看到 PyTorch 的 FSDP、DTensor、张量并行、流水线并行是如何拼装起来的,它把很多分布式训练的细节直接用代码暴露出来。

对我来说,TorchTitan 最大的价值是开源且简洁:当我想搞清楚一个并行策略到底怎么落地时,顺着代码能找到答案,而不是只能读论文猜。下面的模型切分、FQN 保真和零气泡调度,虽然不是 TorchTitan 独有的概念,但它在工程实现上给了我们一组很好的示范。

2. 模型切分:怎样把 Transformer 安全地拆到多张卡上

2.1 按层切还是按张量切:先分清粒度

模型切分的关键是选对粒度。按张量切分,比如把 embedding 的 hidden dim 切到多卡上,会显著降低单卡显存,但要求组内通信非常快;按层切分则是把连续的多个层放到同一 rank,跨 rank 通信次数取决于 micro-batch 数量和调度方式,而不是层内的每个矩阵运算。

在 TorchTitan 里,流水线并行默认采用“层段”切分:每个 pipeline stage 拿到连续的若干层,而不是把某一个算子拆碎。这样做的好处是切分逻辑清晰,每个 rank 上的子模块是完整的“Transformer 片段”,容易复用 checkpoint 和单机训练的逻辑。

2.2 切分点不是随便定的:哪些层必须绑定在一起

一个最容易犯错的地方是:均匀切层看起来简单,但嵌入层、LayerNorm、最后的 lm_head 往往会打破均匀性。

如果模型使用 tied embeddings,那么首尾两个 stage 可能都要访问同一个 embedding 参数。这种情况要么让某个 stage 同步一份拷贝,要么在 checkpoint 阶段做特殊合并,否则你保存的权重会只覆盖其中一份。对于没有 tied 的模型,通常把 embedding 放在第一个 stage,把 final norm 和 lm_head 放在最后一个 stage,中间的 stage 只负责 Transformer 层。

还有一个隐性约束是 LayerNorm 或 RMSNorm。有些模型的 final norm 单独定义,如果切分时把它漏掉,或者把它和 lm_head 切到了不同 stage,反向传播时梯度路径会变得很绕,单从损失数值上很难排查。建议在切分前先用脚本打印每个 stage 的 FQN 列表,确认所有规约层都在边界以内。

2.3 在 TorchTitan 里快速落到配置

我实际使用时,会先在配置里确定三个数字:pipeline_parallel_degreedata_parallel_degreetensor_parallel_degree。以 32 层模型为例,如果单机 8 卡、两机 16 卡,一个常见的组合是:

parallelism.data_parallel_degree = 2 parallelism.tensor_parallel_degree = 4 parallelism.pipeline_parallel_degree = 4 training.batch_size = 256 training.micro_batch_size = 4

这里每个 DP 组是一个完整的 4-stage 流水线,TP 在每个 stage 内部再做层内切分,DP 则在不同机器上复制流水线。从这个配置能推出每个 DP 组每次要吃掉多少条样本:

num_micro_batches = batch_size / (data_parallel_degree * micro_batch_size)

在这个例子中,每个 DP 组需要跑 32 个 micro-batch 才能凑够一个训练 step。这个数字非常重要,因为它直接决定后面的 1F1B 和零气泡调度有没有足够的“工件”来填满流水线。

3. FQN 保真:为什么 state_dict 的全限定名不能乱

3.1 FQN 是什么:给每个参数一个完整门牌号

FQN(Fully Qualified Name)在 PyTorch 里就是state_dict的键,比如model.layers.12.self_attn.q_proj.weight。它从顶层模块开始,一路走到具体参数对象,相当于每个参数的完整门牌号。

没有流水线并行时,FQN 很少被人注意,因为保存和加载时整个模型都在同一个进程里。但一旦模型被切到多个 rank,每个 rank 只拥有完整模型的一部分,FQN 就成了恢复全局状态的唯一线索。

3.2 流水线并行下最容易出现的状态错位

我见过最多的问题是这样:某个 stage 为了拿到模型片段,构建了一个只包含 layer 8 到 layer 15 的子模型;训练结束保存权重时,这个子模型的named_parameters()返回的是layers.8.self_attn.q_proj.weight,而不是完整模型里的model.layers.8.self_attn.q_proj.weight。少了一个model.前缀,后面的加载脚本就会报 missing key。

更隐蔽的是当你做断点续训时,加载器按 FQN 把权重填回去。如果 rank 0 和 rank 2 因为包装方式不同,产生了相同后缀但不同前缀的键,加载过程不会报错,但权重会落到错误的位置,损失曲线在 resume 后出现诡异的跳变。

所谓 FQN 保真,简单说就是:不管模型被切到几个 rank,保存时每个参数的前缀、层级关系、名字都必须与原始完整模型严格一致。切分的是计算图,不能切分“参数在全局状态字典里的坐标”。

3.3 参数命名保真的实操方案

我在迁移时习惯先建立一个“FQN 基线”。在模型还没有切分之前,把完整模型的named_parameters()键全部存下来,生成一份 JSON 清单。之后无论任何并行策略生效,最终保存的 checkpoint 都应该包含这个清单里的所有键,并且不能多出无意义的module._orig_mod.前缀。

视觉上,检查脚本可以这样写:

expected_keys = set(dict(model.named_parameters()).keys()) # save_plan 来自每个 pipeline stage 汇总后的全局保存计划 actual_keys = set(save_plan.keys()) assert expected_keys == actual_keys, ( f"missing: {expected_keys - actual_keys}, " f"extra: {actual_keys - expected_keys}" )

在 TorchTitan 的代码里,保存 checkpoint 的核心思路也是先把多个 rank 的局部状态收集到统一的全局状态视图,再按 FQN 写出。这样做除了让恢复方便,还让模型导出、下游量化、推理部署都能直接复用同一套命名。

我自己踩过的坑是:某次为了加速推理,在原始模型外面包了一层带_orig_mod.前缀的 wrapper,FQN 全变了,训练端保存的 checkpoint 无法直接加载。听起来很蠢,但在多机流水线并行环境下,这种前缀污染特别难发现,因为每个 rank 的日志里都是“部分状态保存成功”。

4. 零气泡调度:把 GPU 的空转时间压到接近零

4.1 气泡是怎么来的:从 1F1B 说起

流水线并行最经典的调度是 1F1B(一个 forward 紧跟一个 backward)。每个 stage 先做一轮 forward,再开始 backward,理论上能减少激活缓存,但问题是流水线启动和收尾阶段天然存在空档。

想象一条 4-stage 的流水线,有多个 micro-batch。第一个 micro-batch 走到最后一个 stage 前,后半段 stage 是空闲的;最后一个 micro-batch 的 backward 结束后,前半段 stage 也没有活干。这个空转时间就是气泡。假设有 P 个 stage 和 M 个 micro-batch,气泡比例大概可以用(P - 1) / (M + P - 1)来估计。微批数量越大,气泡占比越低,但激活显存也会跟着涨。

所以传统的调参思路是:把 micro-batch 数量往上堆,堆到气泡可以接受为止。问题在于显存有限,不是想堆多少就堆多少。

4.2 不是魔法:把反向传播拆成两个阶段

零气泡调度的核心想法是把反向传播拆成两部分:一部分计算权重梯度,一部分计算输入梯度。权重梯度可以直接用来更新参数;输入梯度需要继续回传给前一个 stage。

如果按照“所有 forward 做完,再统一 backward”的思路,每个 micro-batch 会占用一条连续的时间轨。但有了拆分,调度器可以把某些 micro-batch 的权重梯度计算,放到原本空转的 slot 里,而输入梯度的传递仍然遵守链式法则。这样流水线就不再是单纯的 1F1B,而是更像一个把不阻塞路径的算子填进空档的“拼图游戏”。

TorchTitan 这类框架在处理时,会先把每个 stage 可拆的 backward chunk 建模成两种任务:Backward-WBackward-I。然后按拓扑序生成新的任务序列,确保每个任务的输入依赖满足,同时尽量让不同 stage 的Backward-W落到别的 stage 的空闲区间。这样同样数量的 micro-batch 下,气泡可以大幅缩小。

4.3 零气泡不是免费的:内存和通信的交换

零气泡看起来很香,但它并不是毫无代价。第一个代价是调度算法复杂,任务队列不再像 1F1B 一样简单直观,调试难度更高;第二个代价是显存峰值可能上升,因为拆分反向任务后,激活缓冲区的生命周期变长了。

所以实际使用时,我通常会做一次快速实验对比:同一个模型、同一批数据,分别跑 1F1B 和零气泡调度,看吞吐提升与显存峰值变化。如果显存余量很小,零气泡带来的吞吐提升可能被频繁换出激活值吃掉,反而得不偿失。

还有一个容易被忽略的点:零气泡的收益依赖充足的 micro-batch 数量。如果你的微批数量只有 2 或 4,拆分反向任务后不够填满所有空档,提升有限;微批数量达到两位数后,收益才开始明显。

5. 实操复盘:把 TorchTitan 的流水线并行跑通并验证正确性

5.1 一份可抄作业的最小配置

假设你有一台 4 卡实验机,想先把 7B 模型的 small 版本跑通流水线并行。我会先从最小配置开始:

parallelism.data_parallel_degree = 1 parallelism.tensor_parallel_degree = 1 parallelism.pipeline_parallel_degree = 4 parallelism.pipeline_schedule = "zero_bubble" training.batch_size = 64 training.micro_batch_size = 4 training.max_steps = 100

这里有几个要点:先用 DP=1、TP=1 把流水线本身验证干净;batch_size = 64micro_batch_size = 4,所以每个 DP 组有 16 个 micro-batch。16 对 4 个 stage 来说已经足够展示零气泡的调度效果,又不会让显存爆掉。

跑通之后,再把 TP 开到 2 或者把 DP 开到 2,观察吞吐变化。不要一上来就直接 8 卡 16 卡,否则模型切分、通信依赖、初始化顺序这几个变量混在一起,出问题很难定位。

5.2 训练前先跑稳定性和一致性检查

启动训练前,我先做三个验证:

第一,用随机输入跑几步 step,确认 loss 能正常下降。一个安全的小技巧是固定 seed,跑 10 步后保存一份 weight,再重置参数加载一次,对比 loss 是否一致。做断点续训之前,至少要保证这一步是零误差的。

第二,导出当前所有 rank 的 FQN 集合,和原始完整模型比对。这个我在第 3 节已经说过,是排查权重错位最直接的手段。

第三,给前几个 rank 的通信加上更严格的多卡同步检查,确保没有死锁或 hang。流水线并行一旦 hang,通常表现为某几个 rank 卡在某段通信原语,后面所有日志都停止不动。这时候不要反复看日志,先把pipeline_schedule临时切回 1F1B,排除调度器的问题。

5.3 如何从日志判断调度是否健康

正常跑起来后,我会盯两个指标:

第一,单 step 耗时要稳定。零气泡调度在稳态阶段应该有规律的小幅抖动,但如果每个 step 的耗时差异超过 20%,大概率是某一个 stage 负载不均,或者有通信阻塞。流水线并行的稳态耗时应该近似等于单个 stage 处理速度加上微批排队时间,不会出现大的毛刺。

第二,看每个 stage 的峰值显存。零气泡会改变激活值生命周期,显存曲线往往比 1F1B 更“胖”。如果某个 stage 显存异常高,检查是不是调度器把过多的Backward-I堆到了同一个阶段。

6. 几个容易翻车的细节与速查

6.1 微批次和 stage 数怎么配

微批数量小于 stage 数时,流水线后半段大部分时间空转,气泡比例高。微批数量太大时,显存又撑不住。我自己的经验是:先让num_micro_batches >= 2 * pipeline_parallel_degree,跑通后再尝试往上加。

6.2 零气泡调度退化的典型场景

零气泡并不是在所有模型上都优于 1F1B。如果你的模型反向传播里自定义了很多不能拆分的算子,或者激活重计算策略很重,调度器能用于填缝的Backward-W任务就很少。这种情况下零气泡可能退化成几乎等价的 1F1B。遇到时不要勉强,直接切回 1F1B 并增加 micro-batch 数,反而更省事。

6.3 常见问题速查表

现象可能原因解决办法
checkpoint 加载报 missing keyFQN 前缀被 wrapper 改变去掉module.等前缀,用完整模型基线校验
loss 在 resume 后跳动权重被错误映射到别的层训练前用全局 FQN 集合做一致性断言
训练时某个 rank 卡死调度器任务队列死锁先切 1F1B 验证通信;检查 connected graph
零气泡吞吐不升反降micro-batch 数量太密,显存挤压降低微批数量或切回 1F1B
显存峰值集中在某个 stage层切分不均匀或输入梯度任务堆积调整 stage 边界,或开启激活重计算

我个人在实际操作中的体会是:模型切分、FQN 保真和零气泡调度这三件事,其实都在回答同一个问题——如何在不破坏全局状态一致性的前提下,把计算尽量填满每一张卡。TorchTitan 让我最舒服的地方,是它把这几个问题都放在了一套干净的原生 PyTorch 代码里,遇到 bug 可以直接往下追,而不是面对一个黑盒分布式框架。如果你的项目也到了“单机单卡放不下模型、跨机通信又很贵”的阶段,建议先拿这套思路搭一个最小验证环境,数据驱动地决定要不要上零气泡,而不是照搬别人的配置。

最后再分享一个小技巧:任何流水线并行改动,先做一次“单机 4 卡、模型缩小到 8 层”的快速实验,把调度、checkpoint、重启恢复全流程跑通,再回到真实模型规模。这样能让你避开 90% 的“显存溢出 + 卡死 + 权重错位”三连击。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询