TP 核心思想
Tensor Parallelism(TP)的目标是把单个 Linear 层的权重矩阵切分到多个 GPU 上。与 Pipeline Parallelism(按层切分)和 Data Parallelism(按数据切分)不同,TP 在层内切分 —— 每个 GPU 只持有权重的一个分片,只计算输出的一部分。
为什么需要层内切分?大语言模型的单层参数量可以非常大。以 Llama-70B 为例,单个 MLP 层的 fc1 权重为 \([8192, 28672]\),占 \(8192 \times 28672 \times 2 = 450\) MB(FP16)。加上 QKV、Output Projection、fc2 等,一层总参数约 1.2 GB。如果不切分,单个 GPU 需要存储所有层的完整参数。
TP 的数学基础很简单:矩阵乘法可以分解。对于 \(Y = XW^T\),我们可以把 \(W\) 按行或列切分,各 GPU 独立计算部分结果,最后通过通信合并。
f 和 g 算子
TP 的通信逻辑封装在 mappings.py 的 7 个 torch.autograd.Function 中。每个 Function 定义了前向和反向的通信行为。核心设计思想是共轭配对:前向做什么通信,反向就做"逆"通信。
| 类名 | 前向 | 反向 | 用途 |
|---|---|---|---|
_CopyToModelParallelRegion (f) | Identity | AllReduce | Column Parallel 输入端 |
_ReduceFromModelParallelRegion (g) | AllReduce | Identity | Row Parallel 输出端 |
_ScatterToModelParallelRegion | Split | AllGather | Row Parallel 分发输入 |
_GatherFromModelParallelRegion | AllGather | ReduceScatter | Column Parallel 收集输出 |
_GatherFromSequenceParallelRegion | AllGather | ReduceScatter | SP:进入 TP 区域 |
_ReduceScatterToSequenceParallelRegion | ReduceScatter | AllGather | SP:离开 TP 区域 |
_AllToAll | AllToAll | AllToAll | CP 布局切换 |
其中最重要的是 f 和 g 两个算子,它们是整个 TP 设计的基石。代码中通过小写函数调用:
- f:
copy_to_tensor_model_parallel_region()— 内部调用_CopyToModelParallelRegion.apply() - g:
reduce_from_tensor_model_parallel_region()— 内部调用_ReduceFromModelParallelRegion.apply()
后续章节代码中出现的这些小写函数,就是 f 和 g 的实际调用入口。
# f: 前向 Identity,反向 AllReduce
class _CopyToModelParallelRegion(torch.autograd.Function):
@staticmethod
def forward(ctx, input_):
# 前向:直接返回输入,不做任何通信
# 每个 TP rank 拿到相同的输入 X
return input_
@staticmethod
def backward(ctx, grad_output):
# 反向:AllReduce 梯度
# 因为前向时每个 rank 用相同的 X 算了不同的 Y_i
# 反向时 dL/dX 需要把所有 rank 的梯度贡献求和
return _reduce(grad_output)
# g: 前向 AllReduce,反向 Identity
class _ReduceFromModelParallelRegion(torch.autograd.Function):
@staticmethod
def forward(ctx, input_):
# 前向:AllReduce 部分结果
# 每个 TP rank 有 Y_i(部分和),需要 ΣY_i 得到完整输出
return _reduce(input_)
@staticmethod
def backward(ctx, grad_output):
# 反向:直接返回,不通信
# 因为前向 AllReduce 后每个 rank 有相同的 Y
# 下游对 Y 计算的梯度在每个 rank 上也相同
# 不需要再聚合
return grad_output
ColumnParallelLinear
ColumnParallelLinear 将权重矩阵 按输出维度(列)切分。用 \(Y = XA\) 的记法,\(A = [h_{in}, h_{out}]\) 按列分成 N 份,GPU i 持有 \(A_i = A[:, i \cdot h_{out}/N : (i+1) \cdot h_{out}/N]\)(PyTorch 中存储为 self.weight = \(A_i^T\) = \([h_{out}/N, h_{in}]\))。输入 \(X\) 在所有 GPU 上保持完整(通过 f 算子透传)。
| 输入 | 输出 (gather_output=False) | |
|---|---|---|
| GPU 0X [s,b,h] · A_0 [h,h_out/2] | → | GPU 0Y_0 [s,b,h_out/2] |
| GPU 1X [s,b,h] · A_1 [h,h_out/2] | → | GPU 1Y_1 [s,b,h_out/2] |
gather_output 选项控制是否在前向结束时做 AllGather:
gather_output=True:AllGather 输出 → 每个 GPU 得到完整 Y [s,b,h]。用于最终输出层等需要完整结果的场景。gather_output=False(常用):输出保持切分 → 每个 GPU 只有 Y_i [s,b,h/N]。用于 MLP、Attention 等紧跟 RowParallel 的场景,省去一次通信。
def forward(self, input_):
# ① 如果开启 SP:输入是 [s/tp, b, h],需要 AllGather 拼回 [s, b, h]
if self.sequence_parallel:
input_parallel = gather_from_sequence_parallel_region(input_, ...)
else:
# ② 普通 TP:f 算子 — forward=Identity,直接透传
input_parallel = copy_to_tensor_model_parallel_region(input_)
# ③ 矩阵乘法:每个 GPU 用自己的 W_i 分片计算
# output_parallel: [s, b, h_out/tp]
output_parallel = linear_with_grad_accumulation(
input_parallel, self.weight, self.bias, ...
)
# ④ 是否收集完整输出
if self.gather_output:
# AllGather 拼回完整输出 [s, b, h_out]
output = gather_from_tensor_model_parallel_region(output_parallel)
else:
# 保持切分,直接传给下游 RowParallel
output = output_parallel
return output
input_is_parallel=True 表示它接受已切分的输入。这样 fc1 和 fc2 之间不需要任何通信 —— 切分的输出直接就是切分的输入。Attention 中 QKV 投影和 Output 投影也是同样的配对。
RowParallelLinear
RowParallelLinear 将权重矩阵 按输入维度(行)切分。用 \(Y = XA\) 的记法,\(A = [h_{in}, h_{out}]\) 按行分成 N 份,GPU i 持有 \(A_i = A[i \cdot h_{in}/N : (i+1) \cdot h_{in}/N, :]\)(PyTorch 中存储为 self.weight = \(A_i^T\) = \([h_{out}, h_{in}/N]\))。要求输入也按对应维度切分(通常来自上游 ColumnParallel 的输出)。
每个 GPU 计算 \(Y_i = X_i \cdot A_i\),得到的是部分和。最终通过 g 算子做 AllReduce 得到完整输出 \(Y = \sum Y_i\)。
| 输入 (已切分) | 局部输出 | AllReduce 后 | ||
|---|---|---|---|---|
| GPU 0X_0 [s,b,h/2] A_0 [h/2,h_out] | → | GPU 0Y_0 [s,b,h_out] (部分和) | → | GPU 0Y [s,b,h_out] |
| GPU 1X_1 [s,b,h/2] A_1 [h/2,h_out] | → | GPU 1Y_1 [s,b,h_out] (部分和) | → | GPU 1Y [s,b,h_out] |
input_is_parallel 选项:
input_is_parallel=True(常用):输入已按 TP 维度切分,直接使用。配合上游 ColumnParallel 的gather_output=False。input_is_parallel=False:输入是完整的,需要先 Scatter 切分再计算。
def forward(self, input_):
# ① 处理输入
if self.input_is_parallel:
# 输入已切分(来自 ColumnParallel 的输出),直接用
input_parallel = input_
else:
# 输入是完整的,Scatter 切分到各 GPU
input_parallel = scatter_to_tensor_model_parallel_region(input_)
# ② 矩阵乘法:每个 GPU 用自己的 W_i 分片计算部分和
# output_parallel: [s, b, h_out](每个 GPU 得到部分和)
output_parallel = linear_with_grad_accumulation(
input_parallel, self.weight, self.bias, ...
)
# ③ 如果开启 SP:ReduceScatter(归约+切分到 [s/tp, b, h])
if self.sequence_parallel:
output_ = reduce_scatter_to_sequence_parallel_region(output_parallel, ...)
else:
# ④ 普通 TP:g 算子 — AllReduce 部分和
output_ = reduce_from_tensor_model_parallel_region(output_parallel)
# ⑤ 加 bias(如果有)
output = output_ + self.bias if self.bias is not None else output_
return output
Column + Row 组合
TP 的精髓不在于单个 ColumnParallel 或 RowParallel,而在于组合。Megatron 中所有可 TP 化的子层都采用 Column + Row 配对:
- MLP:fc1 (Column) → 激活函数 → fc2 (Row)
- Attention:QKV (Column) → Core Attention → Output Proj (Row)
配对的好处:Column 的分片输出直接喂给 Row 的分片输入,中间不需要通信。整个子层只在 Row 的输出端做 1 次 AllReduce(前向),在 Column 的输入端做 1 次 AllReduce(反向)。
前向 1 次 AllReduce (g) + 反向 1 次 AllReduce (f)
前向 1 次 AllReduce (g) + 反向 1 次 AllReduce (f)
为什么激活函数不破坏 Column + Row 的配合? 可选
ColumnParallel fc1 的输出按列切分:GPU i 持有 \(Y_i = XW_{1,i}^T\),是输出 tensor 的第 i 个分片。SwiGLU/GeLU 等激活函数是逐元素操作,作用在每个元素上独立计算,不依赖其他元素的值。所以 \(\sigma(Y_i)\) 就是 \(\sigma(Y)\) 的第 i 个分片,激活后的切分状态不变。
这个性质对所有逐元素操作都成立:Dropout、残差加法(如果两个加数都按同一维度切分)、逐元素乘法等。只有 LayerNorm、Softmax 这类需要跨元素归约的操作不能在切分维度上独立计算。
Sequence Parallelism
问题:在纯 TP 模式下,LayerNorm、Dropout、残差连接等非 TP 区域的操作在每个 GPU 上处理完整的 [s, b, h] 激活值。这些操作不参与 TP 切分,每个 GPU 上冗余地持有完整激活,浪费显存。
SP 方案:在 TP 区域外,改为按序列维度切分激活值。每个 GPU 只持有 [s/N, b, h]。进入 TP 区域(Attention、MLP)前用 AllGather 拼回完整序列,离开 TP 区域后用 ReduceScatter 切回分片。
通信量不变:AllReduce = AllGather + ReduceScatter。SP 只是把 AllReduce 拆成了两个半量操作,分别放在 TP 区域的入口和出口。总通信量完全相同,但激活内存减少到 1/N。
| 位置 | 方向 | TP-only(f/g) | TP+SP(替代品) |
|---|---|---|---|
| TP 入口 (ColumnParallel 前) | 前向 | f: Identity (0) | AllGather (D) |
| 反向 | f.bwd: AllReduce (2D) | AG.bwd = ReduceScatter (D) | |
| TP 出口 (RowParallel 后) | 前向 | g: AllReduce (2D) | ReduceScatter (D) |
| 反向 | g.bwd: Identity (0) | RS.bwd = AllGather (D) | |
| 每子层合计 | 前向+反向 | 0 + 2D + 2D + 0 = 4D | D + D + D + D = 4D |
if sequence_parallel: AllGather(...) else: f(...)RowParallel.forward 中:
if sequence_parallel: ReduceScatter(...) else: g(...)SP 模式下 f 和 g 不存在,被 AG 和 RS 完全替代。反向通信由 autograd 自动处理(AG 的 backward 是 RS,RS 的 backward 是 AG)。TP 区域内部的计算完全相同。
下面两张图展示同一个 TransformerLayer 在两种模式下的完整数据流。六边形节点是通信算子,标注了前向和反向各自的操作与通信量(D = s×b×h)。虚线框标记非 TP 区域(LayerNorm、Dropout、残差连接)。
前向: 0+2D+0+2D = 4D
反向: 2D+0+2D+0 = 4D
激活内存: s*b*h / GPU
前向: D+D+D+D = 4D
反向: D+D+D+D = 4D
激活内存: s/N*b*h / GPU
# ColumnParallelLinear.forward 中的 SP 分支
if self.sequence_parallel:
# SP:输入是 [s/tp, b, h],先 AllGather 拼回完整序列
# 用的是 _GatherFromSequenceParallelRegion
# forward: AllGather [s/tp, b, h] → [s, b, h]
# backward: ReduceScatter [s, b, h] → [s/tp, b, h]
input_parallel = gather_from_sequence_parallel_region(
input_, tensor_parallel_output_grad=...
)
else:
# 普通 TP:f 算子 — Identity
input_parallel = copy_to_tensor_model_parallel_region(input_)
# RowParallelLinear.forward 中的 SP 分支
if self.sequence_parallel:
# SP:AllReduce 拆成 ReduceScatter
# 用的是 _ReduceScatterToSequenceParallelRegion
# forward: ReduceScatter [s, b, h] → [s/tp, b, h]
# backward: AllGather [s/tp, b, h] → [s, b, h]
output_ = reduce_scatter_to_sequence_parallel_region(output_parallel, ...)
else:
# 普通 TP:g 算子 — AllReduce
output_ = reduce_from_tensor_model_parallel_region(output_parallel)
SP 下 LayerNorm 如何在 s/N 上工作 可选
LayerNorm 沿 hidden 维度做归一化:对每个 token 位置计算 mean 和 variance。它不需要跨 token 位置的信息,所以可以在 [s/N, b, h] 上独立计算。每个 GPU 处理自己负责的 s/N 个 token 位置,结果和在完整 [s, b, h] 上计算完全一致。
Dropout 同理 — 每个元素独立地以概率 p 置零,不依赖其他元素。残差连接也是逐元素加法。这些操作都天然支持序列维度切分。
词表并行
词表(Vocabulary)通常很大(32K~256K),Embedding 层和最终的 Output Linear 层的权重维度为 [V, h]。TP 同样按列切分词表维度:每个 GPU 持有 [V/N, h] 的 Embedding 分片。
def forward(self, input_):
# ① 确定本 GPU 负责的词表范围
# vocab_start_index = tp_rank * vocab_per_partition
# vocab_end_index = vocab_start_index + vocab_per_partition
# ② 把超出范围的 token ID 映射到 0(避免越界)
input_mask = (input_ < self.vocab_start_index) | \
(input_ >= self.vocab_end_index)
masked_input = input_.clone() - self.vocab_start_index
masked_input[input_mask] = 0
# ③ 查表:用局部偏移后的 ID 查本 GPU 的 embedding 分片
output_parallel = self.weight[masked_input]
# ④ 把不属于自己的 token 的输出置零
output_parallel[input_mask, :] = 0.0
# ⑤ AllReduce:所有 GPU 的输出求和 → 正确结果
output = reduce_from_tensor_model_parallel_region(output_parallel)
return output
并行 CrossEntropy:如果最后一层是 ColumnParallel Linear(输出 logits [s, b, V/N]),计算 CrossEntropy loss 时避免 AllGather 成完整的 [s, b, V] — 这个 tensor 在大词表下非常大。
def forward(ctx, vocab_parallel_logits, target, label_smoothing):
# vocab_parallel_logits: [s, b, V/tp] — 每个 GPU 只有部分词表的 logits
# ① 数值稳定:求全局 max
logits_max = vocab_parallel_logits.max(dim=-1).values # 本地 max
torch.distributed.all_reduce(logits_max,
op=torch.distributed.ReduceOp.MAX, group=tp_group) # 全局 max
vocab_parallel_logits = vocab_parallel_logits - logits_max.unsqueeze(-1)
# ② 计算分母:本地 exp 求和,AllReduce 得到全局 sum(exp)
exp_logits = vocab_parallel_logits.exp()
sum_exp = exp_logits.sum(dim=-1) # 本地 sum(exp)
torch.distributed.all_reduce(sum_exp, group=tp_group) # 全局 sum(exp)
# ③ 计算分子:只有持有目标 token 的 GPU 贡献 logit 值
target_mask = (target >= vocab_start) & (target < vocab_end)
masked_target = target.clone() - vocab_start
# 查表获取 target 对应的 logit(不持有的 GPU 得到 0)
predicted_logits = vocab_parallel_logits[..., masked_target] * target_mask
torch.distributed.all_reduce(predicted_logits, group=tp_group)
# ④ loss = log(sum_exp) - predicted_logits
loss = torch.log(sum_exp) - predicted_logits
return loss
通信量总结
下表汇总每个 TransformerLayer 的 TP 通信量。设 \(M = s \times b \times h\)(激活值大小),TP 组大小为 \(N\)。
| 子层 | 模式 | 通信类型 | 前向通信量 | 反向通信量 | 总计 |
|---|---|---|---|---|---|
| Attention | TP-only | AllReduce (f+g) | \(\frac{2(N-1)}{N}M\) | \(\frac{2(N-1)}{N}M\) | \(\frac{4(N-1)}{N}M\) |
| MLP | TP-only | AllReduce (f+g) | \(\frac{2(N-1)}{N}M\) | \(\frac{2(N-1)}{N}M\) | \(\frac{4(N-1)}{N}M\) |
| Attention | TP+SP | AG + RS | \(\frac{2(N-1)}{N}M\) | \(\frac{2(N-1)}{N}M\) | \(\frac{4(N-1)}{N}M\) |
| MLP | TP+SP | AG + RS | \(\frac{2(N-1)}{N}M\) | \(\frac{2(N-1)}{N}M\) | \(\frac{4(N-1)}{N}M\) |
| 每层总计 | TP-only | \(\frac{4(N-1)}{N}M\) | \(\frac{4(N-1)}{N}M\) | \(\approx 8M\) | |
| 每层总计 | TP+SP | \(\frac{4(N-1)}{N}M\) | \(\frac{4(N-1)}{N}M\) | \(\approx 8M\) |
两种模式的通信量完全相同。SP 的优势体现在激活内存:非 TP 区域每个 GPU 只存 \(\frac{M}{N}\) 而非 \(M\)。
- NVLink(节点内):带宽 900 GB/s (NVSwitch),8 GB 通信仅需 ~9 ms
- InfiniBand(跨节点):带宽 50-100 GB/s,8 GB 通信需 80-160 ms