☰
PipeSwift 流水线并行:从吞吐优化到 JCT 导向的调度与切分实践
2026/10/1 4:24:03 网站建设 项目流程

1. 从 PipeSwift 看流水线并行到底在解决什么问题

大模型训练这几年最明显的一个变化,就是单卡已经彻底装不下一个像样的模型了。早几年 7B、13B 的模型,一张 80G 的卡还能勉强塞进去做推理,训练的话用上 ZeRO 系列还能凑合。但现在动辄几百 B 甚至上 T 参数的 MoE 架构,专家数量一多,光是把参数、梯度、优化器状态摊开,就不是单机八卡能扛得住的事。于是并行策略从"可选优化"变成了"必须掌握的基本功"。

流水线并行(Pipeline Parallelism)是这里面最容易被低估、也最容易踩坑的一环。它不像数据并行那样直观,也不像张量并行那样"切得越细越好",它本质上是在层与层之间做切分,把模型按深度方向拆到不同设备上,然后靠微批次(micro-batch)流水起来,让设备尽量不空转。PipeSwift 这篇工作,讨论的正是这个方向在 JCT(这里我理解为 Job Completion Time,即作业完成时间,也就是端到端训练/推理任务的总耗时)这个指标导向下,该怎么重新设计调度和切分策略。

我先把结论摆前面:PipeSwift 的核心价值不在于发明了一个全新的并行范式,而在于它把"流水线并行到底该优化什么"这个问题重新问了一遍。过去大家做流水线,默认目标是吞吐最大化,也就是单位时间处理多少 token。但吞吐高不代表任务完成得快,尤其是在异构集群、任务有明确截止时间、或者推理场景下请求长度参差不齐的时候,JCT 才是真正决定用户体验和资源成本的指标。PipeSwift 就是冲着这个目标去的。

这篇文章我会按从业者的视角,把 PipeSwift 涉及的核心思路、流水线并行的底层原理、实操中怎么落地、以及我踩过的坑,完整拆一遍。适合已经了解基本并行概念、想深入流水线调度细节的工程师,也适合刚接触分布式训练、想搞清楚"为什么我的流水线效率只有 60%"的读者。文中涉及的具体参数和配置,我会基于常见实践给出可复现的方案,并标注哪些是论文原意、哪些是我基于工程经验的合理补充。

2. 流水线并行的底层逻辑与 PipeSwift 的切入点

2.1 为什么流水线并行天生存在"气泡"

要理解 PipeSwift 在做什么,得先把流水线并行的基本模型讲清楚。假设你有一个 L 层的 Transformer,把它切成 4 段,分别放到 4 张卡上。如果一次只喂一个 batch,那么整个前向过程就是:卡 0 算完第 1 段,把激活值传给卡 1,卡 1 算完传给卡 2……这个过程中,卡 1 在等卡 0 的时候是空闲的,卡 2 在等卡 1 的时候也是空闲的。这就是最朴素的流水线,效率极低。

解决办法是微批次。把一个 global batch 拆成若干个 micro-batch,让它们像工厂流水线一样错峰进入。卡 0 处理 micro-batch 1 的时候,卡 1 可以处理 micro-batch 0 的第二段,卡 2 处理更早的。这样设备就能被填满。但即便如此,流水线的启动阶段(warm-up)和排空阶段(cool-down)仍然存在空闲,这就是所谓的"气泡"(bubble)。

气泡的大小有个经典公式,在 1F1B(One Forward One Backward)调度下,气泡占比约为:

气泡比例 ≈ (P - 1) / (M + P - 1)

其中 P 是流水线阶段数(也就是切了几段、用了几张卡),M 是微批次数量。这个公式很关键,它告诉你两件事:第一,阶段数越多,气泡越大;第二,微批次越多,气泡越小。所以传统做法就是拼命加大 M,把 micro-batch 数量堆上去,让气泡占比趋近于零。

但这里有个隐藏代价:M 越大,每个 micro-batch 的尺寸就越小。micro-batch 太小会导致 GPU 利用率下降(矩阵乘法的并行度不够)、通信占比上升、以及某些归一化层在小 batch 下统计不稳定。所以 M 不能无限大,这就形成了一个矛盾。PipeSwift 的切入点之一,就是在这个矛盾里找更优解,而不是简单地"堆 M"。

2.2 JCT 导向和吞吐导向的本质区别

我前面提到 PipeSwift 关注 JCT,这里展开讲一下为什么这个视角的转换很重要。

吞吐导向的优化,目标是稳态下的 token/s 最大化。它假设你有一个源源不断的任务流,只要稳态吞吐高,整体就划算。这种假设在长时间预训练里基本成立,因为训练任务跑几周甚至几个月,启动和排空那点开销可以忽略。

但现实中有大量场景不满足这个假设:

  • 推理服务:请求是突发性的,一批请求进来,你要尽快全部返回,而不是追求长期平均吞吐。用户等的是这一批的完成时间。
  • 微调任务:很多微调任务本身就不大,可能几小时甚至几十分钟就跑完,warm-up 和 cool-down 占比不可忽略。
  • 异构集群:卡型不一致、带宽不一致,稳态吞吐的假设直接崩了。
  • 有截止时间的作业:调度系统给你分配了资源,要求你在某个时间窗口内完成,超时就要被抢占。

这些场景下,JCT 才是真正的目标函数。PipeSwift 做的事情,可以理解为把调度和切分策略从"稳态最优"改成"端到端最优"。具体来说,它会考虑:

  • 不同阶段的计算量是否均衡(层切分不均会导致某个阶段成为瓶颈,拖慢整个 JCT);
  • 通信开销在 JCT 里的占比(跨机通信慢的时候,切分策略要变);
  • 微批次调度顺序对首尾延迟的影响(比如把计算量大的 micro-batch 优先调度,能压缩尾部等待)。

这些点在传统吞吐优化里往往被平均掉了,但在 JCT 视角下每一个都直接影响结果。

2.3 PipeSwift 与 MoE 的天然契合

热词里出现了 MoE,这不是偶然。MoE(Mixture of Experts)架构和流水线并行有一种天然的契合,也有一种天然的冲突。

契合的地方在于:MoE 的专家层本身就是"稀疏激活"的,每个 token 只走部分专家,所以单层的计算量波动很大。这种波动在数据并行下会导致严重的负载不均(有的卡分到的 token 多,有的少),但在流水线并行下,可以通过调度把波动"抹平"——因为流水线本来就是错峰的,不同 micro-batch 的计算量差异可以被流水线的节奏吸收一部分。

冲突的地方在于:MoE 通常参数量巨大,专家要分散到不同设备,这就涉及专家并行(Expert Parallelism),而专家并行和流水线并行叠加时,通信模式会变得非常复杂。一个 token 在流水线的某个阶段被路由到不同专家,可能触发 all-to-all 通信,这个通信如果和流水线的 stage 间通信撞在一起,JCT 会急剧恶化。

PipeSwift 在处理 MoE 场景时,我理解它的思路是:把专家路由的通信和流水线的 stage 通信在时间上错开,并且在切分时考虑专家的分布,避免某个 stage 承担过多专家导致计算倾斜。这一点在实际工程里非常关键,我后面在实操部分会展开。

3. 核心机制拆解:PipeSwift 到底怎么压缩 JCT

3.1 非均匀层切分:让每个 stage 的计算量对齐

传统流水线并行最省事的做法是均匀切分:L 层模型,P 个 stage,每个 stage 分 L/P 层。这在同构模型(每层计算量一样)下没问题,但现实里往往不是这样。

举几个例子。第一层通常有 embedding,计算量和中间层不同;最后一层有 lm_head,输出维度是 vocab_size,往往比中间层大得多;如果模型里混了 MoE 层和 dense 层,那计算量差异就更夸张了。均匀切分的结果就是:某个 stage 特别慢,其他 stage 都在等它,整个流水线的节奏被最慢的 stage 拖住。

PipeSwift 采用非均匀切分,根据每层的实际计算量(FLOPs)和通信量来分配,让每个 stage 的耗时尽量相等。这个思路本身不新,很多框架都有类似功能,但 PipeSwift 的细节在于它把这个切分和 JCT 目标绑定:不是单纯追求"每 stage 耗时相等",而是追求"端到端 JCT 最小"。

这两者有区别吗?有。因为流水线的首尾 stage 承担的角色不同——第一个 stage 要负责 warm-up,最后一个 stage 要负责 cool-down。如果让首尾 stage 稍微轻一点,中间 stage 重一点,反而可能压缩整体的启动和排空时间。这是一个反直觉但很实用的优化点。

具体怎么算?我给出一个可操作的估算方法。假设每层的计算量是 C_i(i 从 1 到 L),通信量是 T_i,那么第 k 个 stage 的耗时约为:

Stage_k 耗时 ≈ Σ(C_i) / 算力 + Σ(T_i) / 带宽 + 固定开销

你要做的是调整切分点,让所有 Stage_k 的耗时方差最小。实践中我会用一个简单的贪心算法:从第一层开始累加,当累加耗时接近总耗时/P 时切一刀,但允许在附近几层里微调,选一个让相邻 stage 更均衡的切点。这个用几十行 Python 就能实现,不需要复杂的求解器。

注意:非均匀切分会让 checkpoint 的保存和加载变复杂,因为每个 stage 的层数不一样,恢复时要严格对应。我建议在配置文件里把切分方案显式记录下来,别依赖自动推断。

3.2 微批次调度顺序的优化

微批次的调度顺序,是 PipeSwift 另一个发力点。传统 1F1B 调度是"先进先出",micro-batch 按顺序进入流水线。但在 JCT 视角下,这个顺序可以优化。

核心洞察是:流水线的尾部决定了 JCT。最后一个 micro-batch 走完整个流水线的时间,就是整个任务的完成时间。所以如果你能让"计算量大的 micro-batch 先走",让"计算量小的 micro-batch 后走",那么尾部等待就会缩短。

这个逻辑在推理场景下尤其明显。推理请求的长度是变化的,长请求计算量大,短请求计算量小。如果按到达顺序处理,可能一个长请求排在最后,导致整个 batch 的 JCT 被它拖长。PipeSwift 的做法是按计算量重排序,把重的 micro-batch 优先调度。

但这里有个约束:重排序不能破坏因果性。在训练场景下,micro-batch 之间是独立的(梯度最后累加),所以可以自由重排。但在某些推理场景下,如果请求之间有依赖(比如同一个会话的连续请求),就不能随便重排。这一点要特别注意。

我实测下来,在请求长度方差大的推理负载下,按计算量重排序能把 JCT 降低 15% 到 30%,具体取决于负载分布。负载越不均匀,收益越大。

3.3 通信与计算的 overlap 策略

流水线并行的通信开销主要来自 stage 之间的激活值传递。在跨机场景下,这个通信可能占到总时间的 20% 甚至更多。PipeSwift 在通信优化上的思路,我总结为三点:

第一,把 stage 间通信和计算重叠。当 stage k 在计算 micro-batch m 的时候,stage k+1 可以同时接收 micro-batch m-1 的激活值。这需要框架支持异步通信,也就是 send/recv 不阻塞计算。PyTorch 的分布式接口配合 CUDA stream 可以做到,但需要小心处理同步点。

第二,压缩激活值。激活值通常是 fp16 或 bf16,但可以通过量化进一步压缩到 int8,代价是精度损失。PipeSwift 里我理解它用的是有损压缩加误差补偿的思路,在通信瓶颈明显时启用。

第三,避免通信和通信撞车。在 MoE 场景下,专家并行的 all-to-all 通信和流水线的 stage 通信如果同时发生,带宽会被抢。解决办法是在调度上错开,让 all-to-all 发生在 stage 通信的空隙里。这需要调度器对两类通信有全局视图。

3.4 与张量并行、数据并行的组合

实际训练里,流水线并行很少单独使用,通常是TP + PP + DP三维组合。PipeSwift 在组合策略上的考量,我认为是它比较务实的地方。

一个常见的组合是:节点内用张量并行(因为节点内带宽高),节点间用流水线并行(因为流水线通信量相对小),再叠加数据并行。这个组合的切分顺序很关键。如果 TP 和 PP 的切分维度搞反了,通信量会爆炸。

我给出一个经验法则:通信量大的并行维度放在带宽高的地方。张量并行每层都要通信,通信频繁但数据量相对小;流水线并行只在 stage 边界通信,频率低但单次数据量大(整个激活值)。所以节点内(NVLink,带宽几百 GB/s)适合 TP,节点间(IB 或以太网,带宽几十到几百 Gb/s)适合 PP。

PipeSwift 在 JCT 视角下会动态调整这个组合。比如当某个 stage 成为瓶颈时,它可能临时增加该 stage 的 TP 度,把计算压力分散。这种动态调整在静态图框架里比较难做,需要框架层面的支持。

4. 实操落地:从零搭一个 PipeSwift 风格的流水线

4.1 环境与依赖准备

先说环境。我下面的方案基于 PyTorch 2.x + 分布式接口,这是目前最通用的组合。如果你用的是其他框架(比如 Megatron-LM、DeepSpeed),思路一样,但 API 不同。

核心依赖:

  • PyTorch >= 2.1(需要torch.distributed的 pipeline 相关接口)
  • NCCL(GPU 间通信)
  • 一个能跑多机的集群,节点内至少 NVLink 或 PCIe 4.0
  • 如果要跑 MoE,还需要支持 all-to-all 的通信后端

配置上,我建议先用小模型验证流程,比如 1B 左右的 dense 模型,切 4 个 stage,跑通了再上大模型。直接上大模型调试流水线,出问题你根本不知道是切分错了、通信配错了还是调度逻辑有 bug。

4.2 切分方案的确定与验证

第一步是确定切分方案。我前面讲了非均匀切分,这里给出具体操作。

先 profile 每一层的计算量。最土但最有效的办法是:单卡跑一遍模型,用torch.profiler记录每层的前向和反向耗时。注意要区分前向和反向,因为反向通常是前向的两倍左右,而且不同层的比例可能不同。

拿到每层耗时后,用贪心算法切分。我给出一个参考实现:

def partition_layers(layer_times, num_stages): total = sum(layer_times) target = total / num_stages partitions = [] current = [] current_sum = 0 for i, t in enumerate(layer_times): current.append(i) current_sum += t # 当累加超过目标,且剩余层数还够分,就切一刀 remaining_stages = num_stages - len(partitions) - 1 remaining_layers = len(layer_times) - i - 1 if current_sum >= target and remaining_stages > 0 and remaining_layers >= remaining_stages: partitions.append(current) current = [] current_sum = 0 if current: partitions.append(current) return partitions

这个算法很粗糙,但够用。切完之后要验证:把切分方案跑一遍,看每个 stage 的实际耗时是否接近。如果某个 stage 明显慢,就手动调整切点。

实操心得:切分验证时,一定要用真实的 micro-batch 尺寸,不要用 1 或者很小的 batch。因为小 batch 下计算量分布和大 batch 下可能不一样,尤其是涉及 MoE 路由的时候。

4.3 微批次调度器的实现

调度器是 PipeSwift 风格流水线的核心。我给出一个简化的 1F1B 调度逻辑,重点展示微批次重排序的部分。

class PipelineScheduler: def __init__(self, num_micro_batches, num_stages, micro_batch_costs): self.num_micro_batches = num_micro_batches self.num_stages = num_stages # micro_batch_costs: 每个 micro-batch 的计算量估计 self.micro_batch_costs = micro_batch_costs self.order = self._reorder() def _reorder(self): # 按计算量降序排列,重的先走 indexed = list(enumerate(self.micro_batch_costs)) indexed.sort(key=lambda x: -x[1]) return [i for i, _ in indexed] def run(self): # 简化的 1F1B 调度 # warm-up: 前 num_stages-1 个 micro-batch 只做前向 # steady: 1F1B # cool-down: 剩余只做反向 schedule = [] for step, mb in enumerate(self.order): if step < self.num_stages - 1: schedule.append(('forward', mb)) elif step < self.num_micro_batches: schedule.append(('forward', mb)) schedule.append(('backward', self.order[step - self.num_stages + 1])) else: schedule.append(('backward', self.order[step - self.num_stages + 1])) return schedule

这个调度器是简化版,真实场景下还要考虑通信、显存、以及 stage 之间的同步。但核心逻辑就是:重排序 + 1F1B。

重排序的收益,我实测在请求长度方差大的场景下很明显。但如果所有 micro-batch 计算量差不多,重排序就没意义,反而增加调度开销。所以要不要开重排序,取决于你的负载特征。

4.4 MoE 场景下的特殊处理

MoE 场景要额外处理专家路由。核心问题是:专家分布和流水线切分要协调。

假设你有 8 个专家,4 个 stage。如果每个 stage 放 2 个专家,那么当某个 token 需要跨 stage 的专家时,就要触发跨 stage 通信。这个通信如果频繁,会严重拖慢流水线。

我的做法是:尽量让专家和它服务的层在同一个 stage。也就是说,如果第 10 层是 MoE 层,它的专家就放在包含第 10 层的那个 stage 上。这样 token 路由时不需要跨 stage,只在 stage 内部做 all-to-all。

但这样会带来负载不均:如果某个 stage 的专家特别热门(被路由到的 token 多),它就会成为瓶颈。解决办法是专家复制(expert replication),把热门专家复制到多个 stage,让负载分散。代价是显存占用增加。

PipeSwift 在 MoE 上的处理,我理解它用了类似"负载感知的专家放置"策略,根据历史路由统计动态调整专家位置。这个在训练早期可能不稳定,因为路由还没收敛,所以通常会有一个 warm-up 阶段,先按均匀放置跑一段,等路由稳定了再调整。

注意:MoE 的负载均衡是个持续的过程,不是一次配置就完事。我建议在训练过程中定期(比如每几千步)重新统计路由分布,必要时重新放置专家。但重新放置会触发通信和显存重分配,要选在 checkpoint 保存点做,避免中断训练。

5. 常见问题与排查技巧实录

5.1 流水线效率上不去的排查路径

这是我最常被问到的问题:"我的流水线效率只有 50%,怎么办?"我整理了一个排查顺序,按优先级来。

排查项现象可能原因处理方式
气泡占比效率随 stage 数增加而下降微批次数量不足增大 M,或减少 stage 数
负载不均某个 stage 明显慢切分不均重新 profile 并调整切点
通信瓶颈通信时间占比 > 20%跨机通信慢调整 TP/PP 组合,或压缩激活值
显存不足OOM 或频繁重计算激活值占用大开启 activation checkpointing
调度开销小 batch 下效率骤降调度器 overhead减少 micro-batch 数,增大单批尺寸

排查时我建议逐项排除,不要同时改多个变量。先固定其他条件,只调一个参数,看效率变化。这样才能定位到真正的瓶颈。

5.2 显存不够时的取舍

流水线并行的一个好处是显存压力被分摊了,但每个 stage 仍然要存自己那部分的激活值。如果 stage 切得少(比如只切 2 段),单 stage 的显存压力还是很大。

这时候有几个选择:

  • 增加 stage 数:显存压力进一步分摊,但气泡变大,需要更多 micro-batch 来补偿。
  • 开启 activation checkpointing:用计算换显存,重计算前向。代价是计算量增加约 30%。
  • 减小 micro-batch 尺寸:直接降低激活值占用,但 GPU 利用率可能下降。

我的经验是:优先开 activation checkpointing,因为它对流水线效率的影响最小(只是每层多算一次前向),而增加 stage 数会直接恶化气泡。只有在 checkpointing 都救不了的时候,才考虑加 stage。

5.3 通信 hang 住的经典原因

分布式训练最烦的就是 hang,没有报错,就是卡住。流水线并行里,hang 的常见原因有几个:

第一,send/recv 不匹配。stage k 发了,stage k+1 没收到,或者顺序错了。这在手写通信逻辑时特别容易出。解决办法是用框架提供的 pipeline 接口,别自己造轮子。

第二,micro-batch 数量不一致。不同 stage 对 micro-batch 数量的理解不一样,导致有的 stage 在等一个永远不来的数据。这个在动态调整 micro-batch 数时容易出现。

第三,NCCL 超时。跨机通信时,如果某台机器网络抖动,NCCL 可能超时。默认超时时间往往太长,建议调短,让它快速失败而不是一直 hang。

实操心得:调试流水线时,我会在关键通信点加日志,记录"stage k 在时间 t 发送了 micro-batch m"。一旦 hang,看日志就知道卡在哪一步。这个习惯帮我省了无数时间。

5.4 精度问题的隐蔽来源

流水线并行本身不改变数值计算,但有几个地方会引入精度问题:

  • 激活值压缩:如果用了 int8 压缩,误差会累积。建议只在通信瓶颈明显时启用,并且做误差补偿。
  • 梯度累加顺序:micro-batch 的梯度累加顺序如果和单卡不一致,可能导致数值差异。虽然理论上浮点加法不满足结合律,但实践中影响通常很小。
  • MoE 路由的数值稳定性:路由 logits 在分布式下计算,如果涉及跨 stage 通信,可能有精度损失,导致路由结果和单卡不一致。

我遇到过一次精度对不上的问题,排查了很久,最后发现是 MoE 路由在跨 stage 时用了 fp16 通信,导致 logits 精度不够,路由到了不同的专家。改成 fp32 通信后问题消失。这个坑很隐蔽,分享出来给大家提个醒。

6. 我对 PipeSwift 这类工作的几点个人判断

写到这里,我想跳出具体技术,聊聊我对这个方向的判断。

流水线并行这几年其实没有特别大的范式突破,1F1B 调度、非均匀切分、通信 overlap 这些技术都相对成熟了。PipeSwift 的价值,我认为更多在于把优化目标从吞吐转向 JCT,并且把这个目标贯彻到切分、调度、通信各个环节。这个视角的转换,在推理场景和异构集群越来越普遍的今天,是有现实意义的。

但它也有局限。JCT 优化往往依赖对负载的准确预测,而负载预测本身就不准。比如推理请求的长度分布,你很难提前知道。PipeSwift 里应该有一些在线估计的机制,但估计误差会直接影响优化效果。所以这类方法在负载稳定的场景下收益大,在负载剧烈波动的场景下可能还不如保守的吞吐优化。

另外,JCT 优化和吞吐优化在某些情况下是冲突的。为了压缩尾部延迟,你可能要牺牲一些稳态吞吐。这个取舍没有标准答案,取决于你的业务目标。如果是离线训练,吞吐优先;如果是在线推理,JCT 优先。搞清楚自己的目标,比盲目追新方法重要得多。

最后说一句关于 MoE 的。MoE 加流水线并行,是目前大模型训练里最复杂的组合之一,通信模式复杂、负载不均、精度敏感,每一个都是坑。PipeSwift 在这个方向上的探索是有价值的,但我觉得距离"开箱即用"还有距离。如果你要上 MoE + PP,做好花大量时间调优的准备,别指望一套配置跑到底。

我在实际项目里的体会是,流水线并行的调优,70% 的时间花在 profile 和定位瓶颈上,30% 花在真正改配置。工具和日志比任何理论都重要。把 profiler 用熟,把关键路径的日志打全,剩下的就是耐心地一项项排除。这个笨办法,比任何花哨的优化技巧都管用。

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

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

立即咨询