想象一个大型图书馆需要完成抄书任务:一本书有十万页,一个抄书员要抄整整一个月。但如果把书按章节撕开,分给一百个抄书员同时抄写,一天就能完成。这背后的智慧,正是大模型训练中“行并行”的精髓——把巨大的权重矩阵按行切开,分给多张显卡(GPU)各自计算,最后合到一起。就像图书馆管理员把书按章节分给不同的抄书员,每人都只负责自己那一部分,最终拼回一本完整的书。这篇文将带你走进行并行的世界,看它如何让大模型训练从“一个人抄书”变成“千人协作”。

岗位关联

掌握行并行原理,是理解 Megatron 张量并行框架的基石,也是你在多卡训练调优时看懂通信日志、定位性能瓶颈的必备技能。

目录
  1. 可视化演示
  2. 行并行概念
  3. 数学原理
  4. 前向传播流程
  5. 反向传播流程
  6. 通信机制
  7. 与列并行配合
  8. 通信开销分析
  9. 工程实践要点
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示SQL查询从解析到执行的计划树构建过程,对比不同访问路径和连接策略的代价估算,动态演示优化器选择最优执行计划的全流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

查询执行计划树构建 从叶节点扫描开始,逐层向上构建执行计划树 每个节点代表一个物理操作:扫描、过滤、连接、排序 优化器基于代价模型选择最小代价的执行路径 Cost = IO_Cost + CPU_Cost

行并行概念

图书馆管理员发现,与其把整本书交给一个抄书员,不如按章节把书拆开。每个抄书员拿到属于自己的那几章,抄完后把内容重新拼成一本书。这个“按章节拆书”的动作,就是行并行的核心直觉——矩阵乘法中的权重矩阵是按“行”组织的,每一行都独立参与计算,所以可以像撕书一样把矩阵从中间撕开。

在深度学习中,一个线性层做的事情就是 Y = XW。其中 W 是权重矩阵,X 是输入。行并行的做法是:把 W 从中间水平切一刀,上半部分放到 GPU0,下半部分放到 GPU1。于是 GPU0 只需要计算 X 与 W 上半部分的乘积,GPU1 计算 X 与 W 下半部分的乘积。两边的计算结果合在一起,就是完整的输出。

这个方案的美妙之处在于“互不干扰”——因为矩阵乘法中,输出 Y 的第 i 行只依赖于 W 的第 i 行(严格说是 Y[i,:] = X × W[i,:] 的转置)。切分 W 的行,就像把一条生产线上的工序按产品型号分开,每个工厂生产自己负责的那几种型号,互不依赖。

数学原理

为什么行并行在数学上是成立的?因为我们利用了矩阵乘法的分块性质。具体来说,如果把权重矩阵 W 按行分成上下两块:

这意味着,输出矩阵 Y 的上半部分是 X 与 W 上半行的乘积,Y 的下半部分是 X 与 W 下半行的乘积。两块计算完全独立,可以分给不同的 GPU 同时执行。

回到图书馆的比喻:把书按章节撕开后,每个抄书员手里的内容合起来就是完整的书,章节的顺序没有变,内容没有增删,只是写的人多了。行并行就是这样——数学保证了“拼回去”和“没撕开”的结果一模一样。

Y=X×W=X×[W(0)W(1)]=[XW(0)XW(1)]\displaystyle Y = X \times W = X \times \begin{bmatrix} W^{(0)} \\ W^{(1)} \end{bmatrix} = \begin{bmatrix} X W^{(0)} \\ X W^{(1)} \end{bmatrix}
这是行并行能工作的基石:矩阵乘法的分块性质保证“先切分再计算”和“先计算再拼接”得到完全一样的结果。
  1. 权重矩阵切分把形状为 $(d_{out} \times d_{in})$ 的权重矩阵 $W$ 沿行方向切成两块
    W=[W(0)W(1)],W(0):f×din,W(1):(dout−f)×din\displaystyle W = \begin{bmatrix} W^{(0)} \\ W^{(1)} \end{bmatrix}, \quad W^{(0)}: f \times d_{in}, \quad W^{(1)}: (d_{out}-f) \times d_{in}
  2. 乘法分配矩阵乘法对分块满足分配律,X 分别乘以每一块
    XW=X[W(0)W(1)]=[XW(0)XW(1)]\displaystyle XW = X \begin{bmatrix} W^{(0)} \\ W^{(1)} \end{bmatrix} = \begin{bmatrix} XW^{(0)} \\ XW^{(1)} \end{bmatrix}
  3. 输出拼接将两块独立计算结果按行方向拼接,还原为完整输出
    Y=[Y(0)Y(1)],Y(0)=XW(0),Y(1)=XW(1)\displaystyle Y = \begin{bmatrix} Y^{(0)} \\ Y^{(1)} \end{bmatrix}, \quad Y^{(0)} = XW^{(0)}, \quad Y^{(1)} = XW^{(1)}

前向传播流程

前向传播是行并行最直观的舞台。假设我们有 2 张 GPU(叫 TP=2),一个权重矩阵 W 被切成上下两半,分别放在 GPU0 和 GPU1 上。现在的任务是计算 Y = XW。

第一步,把输入 X 广播给所有 GPU。这个过程就像图书馆管理员把原始书籍的复印本发给每位抄书员——所有人都拿到完整的 X。第二步,GPU0 和 GPU1 各自用自己手里的那半块 W,与完整的 X 做矩阵乘法,算出各自的“部分输出”。第三步,把部分输出沿行方向拼接起来(这个过程对应集合通信中的 All-gather),得到完整的 Y。

这个过程就像四个抄书员各自抄完自己的章节后,把稿纸交回给主编,主编按章节号把稿纸钉成一本完整的书。前向传播的结束标志,就是这本完整的书(输出 Y)被送到了下一层。

GPU0 计算: Y(0)=XW(0),GPU1 计算: Y(1)=XW(1)→All-gatherY=[Y(0)Y(1)]\displaystyle \text{GPU0 计算: } Y^{(0)} = X W^{(0)} \quad,\quad \text{GPU1 计算: } Y^{(1)} = X W^{(1)} \quad \xrightarrow{\text{All-gather}} \quad Y = \begin{bmatrix} Y^{(0)} \\ Y^{(1)} \end{bmatrix}
前向传播中每张卡都拿到完整的 X,但只持有部分权重行,所以每张卡只做了一半的乘法工作量。
  1. 广播输入输入 X 通过广播通信原语复制到全部 TP 张卡上
    X→broadcast{X,X,…,X}(TP 份)\displaystyle X \xrightarrow{\text{broadcast}} \{X, X, \dots, X\} \quad (TP \text{ 份})
  2. 本地部分计算第 $i$ 张卡只用自己持有的行切片 $W^{(i)}$ 计算部分输出
    Y(i)=XW(i),i∈{0,1,…,TP−1}\displaystyle Y^{(i)} = X W^{(i)}, \quad i \in \{0, 1, \dots, TP-1\}
  3. 拼接输出All-gather 把各卡的部分输出收集并拼接成完整结果,同时各卡都保留一份
    Y=[Y(0)Y(1)⋮Y(TP−1)]\displaystyle Y = \begin{bmatrix} Y^{(0)} \\ Y^{(1)} \\ \vdots \\ Y^{(TP-1)} \end{bmatrix}

反向传播流程

反向传播是训练中“往回传误差”的阶段。这时候,图书馆的抄书员们需要根据书评(梯度)来修改自己抄写的内容(权重)。行并行的反向传播稍微绕一点,因为梯度需要“沿着来路返回”。

关键直觉还是那句话:行切分让前向输出是“拼接”出来的,所以反向传播时梯度要先“拆开”。具体来说,输出 Y 的梯度 dY 被拆成上下两块,各自回到对应的 GPU。每张卡用自己收到的梯度块 dY 和自己的权重切片 W,计算出对输入 X 的梯度 dX;同时用 dY 和缓存的前向输入 X 计算自己那部分权重 W 的梯度 dW。最后,所有卡对 dX 做一次 All-reduce(把大家算出的 dX 梯度求和),因为完整的 dX 是所有卡贡献之和。

回到抄书的比喻:主编发现书的第二章有错(梯度),就通知负责第二章的抄书员修改。每个抄书员只修改自己负责的章节,不需要管别人的部分。但有一点特殊——如果某个观点(输入特征)被多个章节引用,那么这些章节的修改意见都要汇总起来,才能决定这个观点要怎么改。这就是 All-reduce 在做的事。

dW(i)=(W(i))T×dY(i)(各卡独立计算自己的权重梯度)\displaystyle dW^{(i)} = (W^{(i)})^T \times dY^{(i)} \quad \text{(各卡独立计算自己的权重梯度)}
dX=∑i=0TP−1dX(i)(All-reduce 求和得到完整输入梯度)\displaystyle dX = \sum_{i=0}^{TP-1} dX^{(i)} \quad \text{(All-reduce 求和得到完整输入梯度)}
反向传播中,不同卡对 dX 的梯度做了部分求和,需要通过 All-reduce 把“局部梯度”合成“完整梯度”。
  1. 梯度切片损失对输出的梯度 dY 被按行切成 TP 块,与切分 W 的方式对应
    dY=[dY(0)dY(1)]\displaystyle dY = \begin{bmatrix} dY^{(0)} \\ dY^{(1)} \end{bmatrix}
  2. 局部梯度计算第 $i$ 张卡利用链式法则计算对输入梯度的贡献 dX 和自身权重梯度 dW
    dW(i)=(W(i))TdY(i),dX(i)=dY(i)(W(i))T\displaystyle dW^{(i)} = (W^{(i)})^T dY^{(i)}, \quad dX^{(i)} = dY^{(i)} (W^{(i)})^T
  3. 梯度聚合因为输入 X 被所有卡共享,完整的输入梯度需要跨卡求和
    dX=∑i=0TP−1dX(i)→All-reducedX\displaystyle dX = \sum_{i=0}^{TP-1} dX^{(i)} \xrightarrow{\text{All-reduce}} dX

通信机制

图书馆里有一个专门的“传话员”系统,负责在抄书员之间传递稿件。在行并行中,GPU 之间的通信也有一套标准化的“传话”流程,这就是集合通信原语。

行并行涉及三种核心通信原语。第一种是 Broadcast(广播):把输入 X 从一张卡复制到所有卡,就像主编把原书复印发给大家。第二种是 All-gather(全收集):每张卡把自己算好的部分输出发给所有其他卡,最后每张卡都拥有完整的输出 Y,就像抄书员写完后互相传阅,让每个人都拿到完整的书。第三种是 All-reduce(全归约):每张卡把自己的局部梯度贡献出来,先“求和”再分发回所有卡,就像每个抄书员提出修改意见后,主编汇总所有人的意见,再把汇总结果通知所有人。

通信时机也很有讲究。前向传播中,输入 X 广播是一次通信,输出 Y 的 All-gather 是一次通信。反向传播中,dY 需要类似切分的操作(实际上不需要显式通信,因为 Y 被 All-gather 过,梯度按切片逻辑本地分割即可),然后 dX 的 All-reduce 是一次通信。所以每个 Transformer 层大约有两次通信。

1. 广播 Broadcast:假设数据在 GPU0 上,需要被复制到所有卡

2. 全收集 All-gather:每张卡持有部分输出 $Y^{(i)}$,通信后每张卡都持有完整 $Y$

3. 全归约 All-reduce:每张卡持有部分梯度贡献,通信后每张卡都持有梯度总和

通信就像图书馆的“传话员”系统。广播是群发消息,全收集是收齐大家的部分拼成完整版再群发回去,全归约是把大家意见汇总后再通知所有人。传话员虽然高效,但毕竟要花时间跑腿,这就是通信开销的来源。

X∈GPU0→broadcastX∈{GPU0,GPU1,…,GPUTP−1}\displaystyle X \in \text{GPU0} \xrightarrow{\text{broadcast}} X \in \{\text{GPU0}, \text{GPU1}, \dots, \text{GPU}_{TP-1}\}
{Y(0),Y(1)}→All-gather{Y,Y}\displaystyle \{Y^{(0)}, Y^{(1)}\} \xrightarrow{\text{All-gather}} \{Y, Y\}
{dX(0),dX(1)}→All-reduce{dX(0)+dX(1),dX(0)+dX(1)}\displaystyle \{dX^{(0)}, dX^{(1)}\} \xrightarrow{\text{All-reduce}} \{dX^{(0)}+dX^{(1)}, dX^{(0)}+dX^{(1)}\}

与列并行配合

行并行不是孤军奋战的,它有一个好搭档——列并行。如果说行并行是把书“横着撕”,列并行就是把书“竖着切”。两者交替使用,构成了 Megatron 张量并行的核心循环。

为什么要交替使用?关键在于避免不必要的通信。考虑一个 Transformer 层:前一个线性层的输出马上要作为后一个线性层的输入。如果两层都用行并行,那么第一层输出的 All-gather 结果会被第二层再次广播——这中间多了一次完整的通信往返。但如果第一层用行并行、第二层用列并行,情况就不同了:行并行的输出(各卡持有部分行)恰好就是列并行需要的输入分布,不需要重排数据。

图书馆的比喻在这里同样贴切:如果一个抄写流程是“按章节分给抄书员,抄完后的章节正好是下一个环节”排版“环节需要的版面布局”,那就不需要重新分发稿件,省去了中间的传阅时间。行并行和列并行就是这样默契配合,让数据“无缝衔接”。

行并行: Y行=[Y(0)Y(1)]→无需通信列并行: Z=[Zcol(0),Zcol(1)]\displaystyle \text{行并行: } Y_{\text{行}} = \begin{bmatrix} Y^{(0)} \\ Y^{(1)} \end{bmatrix} \quad \xrightarrow{\text{无需通信}} \quad \text{列并行: } Z = [Z^{(0)}_{col}, Z^{(1)}_{col}]
Megatron 的设计精髓是在每个 Transformer 块内部交替使用行并行和列并行,让通信次数减半。
  1. 行并行输出前一层用行并行,输出 Y 按行分布在各卡上
    Y=[Y(0)Y(1)](GPU0 持有 Y(0),GPU1 持有 Y(1))\displaystyle Y = \begin{bmatrix} Y^{(0)} \\ Y^{(1)} \end{bmatrix} \quad \text{(GPU0 持有 } Y^{(0)} \text{,GPU1 持有 } Y^{(1)} \text{)}
  2. 无需重排列并行要求输入按列切片分布,恰好与行并行输出按行切片分布匹配
    Y(i)正是下一层列并行所需的输入切片\displaystyle Y^{(i)} \quad \text{正是下一层列并行所需的输入切片}
  3. 列并行输出后一层用列并行,输出 Z 按列切片拼成完整结果
    Z=[Z(0),Z(1)](拼接后得到完整的 Z)\displaystyle Z = [Z^{(0)}, Z^{(1)}] \quad \text{(拼接后得到完整的 } Z \text{)}

通信开销分析

通信是有代价的。抄书员之间传一次稿子需要时间,GPU 之间搬运数据也需要时间。理解通信开销,就是要回答一个问题:行并行到底能省多少时间,又额外花了多少时间在通信上?

对于一个形状为 $(d_{out} \times d_{in})$ 的权重矩阵,行并行把计算量均分到 TP 张卡上,每张卡只需要做原来的 $1/TP$ 计算量。但通信量呢?前向传播中,输入 X 需要广播(通信量正比于输入大小 $d_{in}$),输出 Y 需要 All-gather(通信量正比于输出大小 $d_{out}$)。假设 batch size 为 B,序列长度为 L,那么一次前向的通信量大约是:

这里乘以 2 是因为有前向和反向各一次(近似),乘以 4 是因为浮点数占 4 字节。这个公式告诉我们:通信量随模型宽度(隐藏层维度)线性增长,而不是随卡数增长。也就是说,卡越多,每卡计算量下降,但通信量不变——这就是为什么并行度不能无限增加。

回到图书馆:如果 100 个人抄书,传稿子的时间不会因为人多了而变少,每次传稿子还是要走同样的路。如果书足够厚、每人的抄写时间足够长,那么传稿子的时间占比就小;如果书很薄但人很多,传稿子的时间可能比抄写时间还长,那就得不偿失了。这就是为什么行并行通常用在“单层足够大”的场景。

Comm=2×B×L×(din+dout)×4 bytes\displaystyle \text{Comm} = 2 \times B \times L \times (d_{in} + d_{out}) \times 4 \text{ bytes}
行并行在卡数增多时,每卡计算量减少但通信量不变,最终通信占比升高,这就是“扩展效率”问题的源头。
  1. 单卡计算量权重矩阵被切成 TP 份,每卡只算 $1/TP$ 的矩阵乘法
    计算量/卡=2×B×L×din×doutTP\displaystyle \text{计算量/卡} = \frac{2 \times B \times L \times d_{in} \times d_{out}}{TP}
  2. 通信总量广播 X 加上 All-gather Y,各一次,总共两个方向
    通信量=2×B×L×(din+dout)\displaystyle \text{通信量} = 2 \times B \times L \times (d_{in} + d_{out})
  3. 通信计算比通信时间与计算时间的比值,决定扩展效率
    CommComp=(din+dout)×TPdin×dout\displaystyle \frac{\text{Comm}}{\text{Comp}} = \frac{(d_{in} + d_{out}) \times TP}{d_{in} \times d_{out}}

工程实践要点

了解了原理和代价,我们来看看实际使用行并行时需要注意的工程细节。这里不是烧脑的数学,而是前辈们在实践中踩过的坑。

第一个要点是并行度的换算。假设你有 16 张卡,数据并行度 DP=4,张量并行度 TP=4,那么实际采用的 batch size 要相应调整。这是因为数据并行“复制模型、切分数据”,而张量并行“切分模型、共享数据”,两者相反。在 Megatron 框架中,你可以通过 --tensor-model-parallel-size 参数指定 TP 大小,框架自动帮你切分权重矩阵。

第二个要点是通信体积的优化。有时候权重矩阵太大,通信量惊人。一个常用技巧是批量通信合并——把多个小的通信合并成一个大通信,减少通信启动次数。这就像把多个小包裹打成一个大包裹一次性寄出,省去了多次跑邮局的时间。

第三个要点是通信与计算重叠。现代 GPU 有独立的通信单元(如 NVLink 和 RDMA),可以边计算边通信。在 Megatron-LM 的实现中,前向传播的 All-gather 可以与当前层的矩阵乘法并行执行(称为“通信计算重叠”)。这就像抄书员一边写新的章节,一边让传话员把上一章送去审阅——两件事同时进行,总时间大幅缩短。

1. 并行度换算:总卡数 $N$ 分解为数据并行度 $DP$ 和张量并行度 $TP$ 的乘积

2. 通信合并:将 $k$ 个小通信合并为一个大通信,减少启动开销 $O(k)$ 次为 $O(1)$ 次

3. 计算重叠:设计算时间为 $T_{\text{calc}}$,通信时间为 $T_{\text{comm}}$,重叠后总时间

回到图书馆的比喻:选择 16 本书由 4 人负责管理、每本书 4 人抄写,这是并行度换算;把几章的内容打包一起传阅,这是通信合并;边抄写边送审,这是计算通信重叠。掌握了这三招,你就能在分布式训练的“图书馆”里,调度得高效从容。

N=DP×TP,batch实际=batch全局DP\displaystyle N = DP \times TP, \quad \text{batch}_{\text{实际}} = \frac{\text{batch}_{\text{全局}}}{DP}
Tcomm=k×tstart+k×ttransfer→Tcomm=tstart+k×ttransfer\displaystyle T_{\text{comm}} = k \times t_{\text{start}} + k \times t_{\text{transfer}} \quad \rightarrow \quad T_{\text{comm}} = t_{\text{start}} + k \times t_{\text{transfer}}
Ttotal=max⁡(Tcalc,Tcomm)(理想情况)\displaystyle T_{\text{total}} = \max(T_{\text{calc}}, T_{\text{comm}}) \quad \text{(理想情况)}

AI 应用场景

场景一:Megatron-LM 中的多头注意力并行化

阶段:模型训练(分布式训练部署)

解决的问题:大模型的注意力层包含 4 个巨大的权重矩阵(Q、K、V 和输出投影),单卡显存放不下。传统做法是把整个模型塞进一张卡,导致 OOM;行并行把每个矩阵按行切分到多卡,让百亿参数模型也能训练。

具体案例:在训练 GPT-3 175B 模型时,Megatron-LM 使用 TP=8 的张量并行。其中注意力层的 QKV 权重矩阵(形状 $8 \times 128 \times 128$)被按行切成 8 块,每卡持有 128 行。前向传播时,每卡计算部分注意力头,最后 All-gather 拼接输出。这个设计让 175B 模型能在 1024 张 A100 上高效训练。

场景二:DeepSpeed 与张量并行的组合使用

阶段:模型推理(多卡推理加速)

解决的问题:模型推理时延迟敏感,单卡推理太慢。行并行将模型切到多卡,每卡并行计算一部分,把推理延迟从秒级降到毫秒级。

具体案例:在部署 Llama-2-70B(70B 参数)时,单张 A100 推理延迟约 380ms/请求。使用张量并行 TP=4 后,权重矩阵按行切分到 4 张卡,延迟降到 120ms/请求,吞吐量提升约 3 倍。

Python 演示

python102 行
import torch def row_parallel_forward(X, W, tp_size=2):    """    简化版行并行前向传播(模拟 2 卡场景)    参数说明:      X: 输入矩阵, 形状 (batch, in_features)      W: 权重矩阵, 形状 (in_features, out_features)      tp_size: 张量并行度(切成几份)    返回:      Y_full: 完整输出, 形状 (batch, out_features)    """    batch, in_features = X.shape    in_f, out_f = W.shape    assert in_features == in_f, "输入维度与权重维度不匹配"     # 1. 把权重矩阵 W 按行切成 tp_size 块    rows_per_gpu = out_f // tp_size  # 每卡分到的行数    W_chunks = []    for i in range(tp_size):        start = i * rows_per_gpu        end = start + rows_per_gpu        W_chunks.append(W[:, start:end])  # 每块形状: (in_features, out_f/tp_size)     # 2. 每张卡用完整的 X 和属于自己的 W 行切片做矩阵乘法    partial_outputs = []    for i in range(tp_size):        chunk = W_chunks[i]              # 第 i 张卡的权重切片        part = torch.matmul(X, chunk)    # 部分输出 (batch, out_f/tp_size)        partial_outputs.append(part)     # 3. 拼接所有部分输出得到完整结果(模拟 All-gather)    Y_full = torch.cat(partial_outputs, dim=1)    return Y_full  def row_parallel_backward(dY, W, X, tp_size=2):    """    简化版行并行反向传播(模拟 2 卡场景)    计算权重梯度 dW 和输入梯度 dX    """    batch, in_features = X.shape    in_f, out_f = W.shape    rows_per_gpu = out_f // tp_size     # 1. 把 dY 按列切成 tp_size 块(对应输出拼接的逆操作)    dY_chunks = []    for i in range(tp_size):        start = i * rows_per_gpu        end = start + rows_per_gpu        dY_chunks.append(dY[:, start:end])     # 2. 每张卡独立计算权重梯度 dW_chunk = X^T @ dY_chunk    dW_chunks = []    dX_chunks = []    for i in range(tp_size):        chunk = dY_chunks[i]        dW_chunk = torch.matmul(X.T, chunk)      # 本卡权重梯度        dW_chunks.append(dW_chunk)        dX_chunk = torch.matmul(chunk, W[:, i*rows_per_gpu:(i+1)*rows_per_gpu].T)        dX_chunks.append(dX_chunk)               # 本卡对输入梯度的贡献     # 3. 拼接 dW(各卡独立更新权重)    dW_full = torch.cat(dW_chunks, dim=1)     # 4. All-reduce: 输入的梯度 = 所有卡贡献之和    dX_full = torch.stack(dX_chunks, dim=0).sum(dim=0)    return dX_full, dW_full  # 冒烟测试: 验证行并行结果与标准矩阵乘法一致if __name__ == "__main__":    torch.manual_seed(42)    batch, in_f, out_f = 4, 8, 6   # 测试维度: 输出 6 列,可被 2 整除    tp_size = 2     X = torch.randn(batch, in_f)    W = torch.randn(in_f, out_f)     # 标准矩阵乘法    Y_standard = torch.matmul(X, W)     # 行并行前向    Y_row_parallel = row_parallel_forward(X, W, tp_size)     # 验证    assert torch.allclose(Y_standard, Y_row_parallel, atol=1e-6), \        "行并行结果与标准结果不一致!"    print("前向传播验证通过: 行并行结果与标准矩阵乘法一致")    print("Y_standard 形状:", Y_standard.shape)    print("Y_row_parallel 形状:", Y_row_parallel.shape)     # 测试反向传播    dY = torch.randn(batch, out_f)    dX_parallel, dW_parallel = row_parallel_backward(dY, W, X, tp_size)     # 验证 dW: 标准梯度 = X^T @ dY    dW_standard = torch.matmul(X.T, dY)    assert torch.allclose(dW_parallel, dW_standard, atol=1e-6), \        "权重梯度不一致!"    print("反向传播验证通过: 权重梯度与标准反向传播一致")    print("dX 形状:", dX_parallel.shape, " dW 形状:", dW_parallel.shape)
目的:用 PyTorch 模拟行并行张量并行的前向和反向传播,验证“切分后计算再拼接”与“完整矩阵直接计算”在数学上完全等价。
  1. W_chunks.append(W[:, start:end])这是行并行的核心操作,把权重矩阵按行切片,第 i 张卡拿到属于自己的那部分列(PyTorch 中“行”维度的切片用列索引表示)
  2. partial_outputs.append(torch.matmul(X, chunk))每张卡用完整的 X 与自己持有的权重切片独立做矩阵乘法,互不干扰
  3. Y_full = torch.cat(partial_outputs, dim=1)模拟 All-gather,把部分输出在列方向拼接成完整输出
  4. torch.matmul(X.T, chunk)反传权重梯度的局部计算,每张卡算出属于自己的那部分梯度
  5. torch.stack(dX_chunks, dim=0).sum(dim=0)模拟 All-reduce,对输入梯度做跨卡求和

常见误区

误区我一直以为行并行就是把大矩阵切开让每张卡都能放下,只有模型太大单卡装不下时才需要行并行。小模型用行并行是浪费资源。

正解显存放不下只是行并行的众多动机之一。行并行还能显著降低单卡计算量、提升计算吞吐。即使模型能塞进单卡,为了加速训练、提升 batch size,行并行也是常用的手段。而且小模型用行并行也有意义——比如推理场景下,行并行可以降低单卡延迟,让整个模型推理更快。

为何常见:因为大家接触分布式训练的第一课通常是“模型太大装不下”,于是很自然地把并行切分和显存容量绑定在一起。

误区既然行并行是把 W 横着切,列并行是竖着切,那我在任何层想用哪个就用哪个,效果应该差不多吧?

正解行并行和列并行的通信开销不同。行并行需要广播 X 和 All-gather 输出 Y,而列并行只需对输入 X 做切分、对输出做 All-reduce。在 Transformer 层间交替使用(先列后行或先雨后列)可以避免重复通信。选错顺序会让通信量翻倍甚至更多。

为何常见:因为初学者从纸上谈兵的角度看,行切和列切都是“分块”,容易忽略它们在通信模式上的本质差异。

误区行并行把计算量分到多卡上,所以我从 TP=2 加到 TP=8,训练速度应该提升 4 倍吧?

正解行并行不是免费的午餐。卡数增加时,每卡计算量线性下降,但通信量不变(通信量取决于模型宽度,与 TP 无关)。当通信时间超过计算时间时,增加 TP 反而会让总时间变长。这就是为什么 Megatron 论文中 TP 通常不超过 8——再大通信就变成瓶颈了。

为何常见:直觉上“人多力量大”,但忽略了协作需要沟通成本。这正是分布式系统“复杂度守恒”的体现。

面试题

Q1. 行并行中,权重矩阵 W 是如何被切分的?

单选题 · mid

  • A.按列切成多块,每卡独立处理
  • B.按行切成多块,每卡处理一部分← 正确答案
  • C.按元素随机打散分配到各卡
  • D.不切分,每卡都持有完整的 W

答案:B。行并行正是把权重矩阵按“行”切开,每张卡只持有 W 的部分行(如上半行或下半行),与输入 X 的部分计算后拼接得到完整输出。按列切分是列并行;随机打散和完整复制都不是行并行的做法。

Q2. 行并行的数学基础是什么?

单选题 · easy

  • A.矩阵乘法的结合律
  • B.矩阵乘法的分块性质← 正确答案
  • C.矩阵乘法的交换律
  • D.矩阵乘法的分配律

答案:B。行并行利用了矩阵乘法的分块性质:把 W 按行切成上下两块后,[XW上; XW下] = X[W上; W下],即“先切再算”和“先算再拼”结果相同。结合律、交换律、分配律都不是对“切分等价性”的直接描述。

Q3. 行并行前向传播包含哪些通信操作?

多选题 · mid

  • A.广播(Broadcast)输入 X 到所有卡← 正确答案
  • B.All-gather 拼接部分输出← 正确答案
  • C.All-reduce 对输入梯度求和
  • D.每张卡独立更新自己的权重参数

答案:AB。行并行前向传播先广播输入 X 给所有卡,再通过 All-gather 把各卡的部分输出拼接成完整输出。All-reduce 是反向传播中用于聚合输入梯度的操作;权重更新不需要跨卡通信(由优化器本地完成)。

Q4. 行并行中,每张卡在计算时使用的是哪种数据?

单选题 · easy

  • A.完整的输入 X 和部分的权重行← 正确答案
  • B.部分的输入 X 和完整的权重 W
  • C.部分的输入 X 和部分的权重行
  • D.完整的输入 X 和完整的权重 W

答案:A。行并行的核心设计是每张卡都收到完整的输入 X(通过广播),但只持有权重矩阵的部分行切片 W。这样每张卡独立计算自己的部分输出,最后拼接。如果输入也被切分,那就变成数据并行了。

Q5. 行并行中,反向传播对输入 X 的梯度需要如何聚合?

单选题 · hard

  • A.直接使用本卡计算的部分梯度,无需通信
  • B.各卡梯度做最大值运算后取回
  • C.各卡梯度做求和后分发回所有卡← 正确答案
  • D.仅第一张卡计算完整梯度再广播

答案:C。由于输入 X 被所有卡共享,每张卡通过链式法则算出的是 dX 的部分贡献,必须通过 All-reduce 把所有贡献求和,才能得到完整的 dX 梯度。最大值聚合不是梯度聚合的标准方式,只由第一张卡计算也不是行并行的做法。

Q6. 关于行并行与列并行的配合,正确的说法有哪些?

多选题 · mid

  • A.行并行输出按行分布,恰好是列并行所需输入分布← 正确答案
  • B.两层都用行并行会导致多余的广播和收集通信← 正确答案
  • C.交替使用行并行和列并行可以减少通信次数← 正确答案
  • D.行并行和列并行无法在同一个模型中共存

答案:ABC。Megatron 的精髓就是交替使用行和列并行:前一层行并行输出的分布恰好是后一层列并行需要的输入分布,免去一次重排通信;两层都用行并行确实会多出“重复的广播+收集”。行并行和列并行当然可以共存。

Q7. 行并行的通信量主要与哪些因素成正比?

单选题 · mid

  • A.张量并行度 TP
  • B.隐藏层维度 d
  • C.数据并行度 DP
  • D.每个批次样本数 B 和序列长度 L← 正确答案

答案:D。通信量公式为 2 × B × L × (d_in + d_out),与 batch size、序列长度、模型宽度成正比,与 TP 无关。这正是“卡越多通信占比越高”的原因——计算量随 TP 下降但通信量不变。

Q8. 在实际训练中,当 TP 从 2 增加到 8 时,通信量会发生什么变化?

单选题 · hard

  • A.通信量变为原来的 1/4
  • B.通信量变为原来的 4 倍
  • C.通信量基本不变← 正确答案
  • D.通信量线性减少

答案:C。通信量公式 2 × B × L × (d_in + d_out) 中不含 TP,增加卡数只是把同样的通信分摊到更多的卡上执行(实际上是每卡通信量下降但通信次数增加),总量不变。而每卡计算量变为原来的 1/4,所以通信占比上升。

Q9. 哪些方法可以减少行并行中的通信开销?

多选题 · mid

  • A.把多个小通信合并成一个大通信← 正确答案
  • B.让通信与计算重叠执行← 正确答案
  • C.增加 batch size 降低通信占比← 正确答案
  • D.使用更大的 TP 值

答案:ABC。通信合并减少启动次数;通信计算重叠让通信“隐藏”在计算后面;增大 batch 让计算时间变长,通信占比下降。增大 TP 反而会让通信占比升高,不是减少开销的方法。

Q10. 行并行中“All-gather 拼接输出”这一步,所有卡最终持有的数据是?

单选题 · extreme

  • A.只持有自己的部分输出
  • B.持有完整的输出矩阵 Y← 正确答案
  • C.持有输出矩阵的梯度
  • D.持有部分输入 X 的切片

答案:B。All-gather 的操作语义是“每张卡提供一部分,最后所有卡都得到完整的数据”。在行并行中,通信结束后每张卡都持有完整的输出 Y,这样下一层(无论行并行还是列并行)都能继续计算。如果只持有部分输出,下一层就无法工作。