01

TP 核心思想

层内切分 NVLink 节点内

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 独立计算部分结果,最后通过通信合并。

两种矩阵切分方式 Megatron 论文用 Y = XA 的记法,A = [h_in, h_out](注意不是 PyTorch 的转置存储) Column Split(按列切分 A,沿 h_out 维度): A = [h_in, h_out] 沿输出维度把 A 切成 N 份 h_out/2 h_out/2 ┌────────┬────────┐ │ │ │ │ A_0 │ A_1 │ h_in 行 │(GPU 0) │(GPU 1) │ │ │ │ └────────┴────────┘ Y_i = X · A_i 每个 GPU 用完整的 X,乘 A 的第 i 列块 结果: Y_i = [s, b, h_out/N] 输出按列自然切分,不需要通信 PyTorch 存储: self.weight = A_i^T = [h_out/N, h_in] ───────────────────────────────────────────────────── Row Split(按行切分 A,沿 h_in 维度): A = [h_in, h_out] 沿输入维度把 A 切成 N 份 ┌─────────────────┐ │ A_0 (GPU 0) │ h_in/2 ├─────────────────┤ │ A_1 (GPU 1) │ h_in/2 └─────────────────┘ h_out Y_i = X_i · A_i 每个 GPU 用 X 的第 i 列块,乘 A 的第 i 行块 结果: Y_i = [s, b, h_out] 每个 GPU 得到部分和,需要 AllReduce 求和 Y = ΣY_i PyTorch 存储: self.weight = A_i^T = [h_out, h_in/N]
核心洞察:矩阵乘法的可分解性
Column Split 不需要对输入做任何处理(每个 GPU 复制完整输入),输出自然分片。Row Split 需要输入也被对应切分,输出需要 AllReduce 聚合。Megatron 巧妙地组合这两种切分方式 —— 让 Column 的分片输出直接作为 Row 的分片输入,省去中间通信。
02

f 和 g 算子

mappings.py autograd Function 共轭配对

TP 的通信逻辑封装在 mappings.py7 个 torch.autograd.Function 中。每个 Function 定义了前向和反向的通信行为。核心设计思想是共轭配对:前向做什么通信,反向就做"逆"通信。

类名前向反向用途
_CopyToModelParallelRegion (f)IdentityAllReduceColumn Parallel 输入端
_ReduceFromModelParallelRegion (g)AllReduceIdentityRow Parallel 输出端
_ScatterToModelParallelRegionSplitAllGatherRow Parallel 分发输入
_GatherFromModelParallelRegionAllGatherReduceScatterColumn Parallel 收集输出
_GatherFromSequenceParallelRegionAllGatherReduceScatterSP:进入 TP 区域
_ReduceScatterToSequenceParallelRegionReduceScatterAllGatherSP:离开 TP 区域
_AllToAllAllToAllAllToAllCP 布局切换

其中最重要的是 fg 两个算子,它们是整个 TP 设计的基石。代码中通过小写函数调用:

  • fcopy_to_tensor_model_parallel_region() — 内部调用 _CopyToModelParallelRegion.apply()
  • greduce_from_tensor_model_parallel_region() — 内部调用 _ReduceFromModelParallelRegion.apply()

后续章节代码中出现的这些小写函数,就是 f 和 g 的实际调用入口。

f 和 g 的共轭配对关系 f 算子(_CopyToModelParallelRegion): forward: Identity 输入直接透传,不做任何通信 backward: AllReduce 梯度从所有 TP rank 聚合 g 算子(_ReduceFromModelParallelRegion): forward: AllReduce 部分结果从所有 TP rank 求和 backward: Identity 梯度直接透传,不做任何通信 共轭关系: f.forward = g.backward = Identity f.backward = g.forward = AllReduce 为什么是"共轭"? 前向路径:f → [计算] → g 只有 g 处做 1 次 AllReduce 反向路径:g → [计算] → f 只有 f 处做 1 次 AllReduce 总计:前向 1 次 + 反向 1 次 = 每子层仅 2 次通信
tensor_parallel/mappings.py — f 算子 L85-108
# 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)
tensor_parallel/mappings.py — g 算子 L111-131
# 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
为什么 g 的反向是 Identity?
g 的前向做了 AllReduce:每个 rank 得到相同的 \(Y = \sum Y_i\)。下游的 LayerNorm、Dropout、残差连接等操作在每个 rank 上对相同的 Y 执行相同的计算,产生相同的梯度 \(\frac{\partial \mathcal{L}}{\partial Y}\)。所以反向时梯度在每个 rank 上已经一致,不需要再通信。
03

ColumnParallelLinear

layers.py 按列切分权重 gather_output

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 算子透传)。

ColumnParallelLinear:相同输入 X + 权重 W 的不同分片 → 输出的不同片段
输入输出 (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]
A_0, A_1 是同一个权重矩阵 A 的第 0、1 列块 → 输出自然按列切分,无需通信

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 的场景,省去一次通信
tensor_parallel/layers.py — ColumnParallelLinear.forward 核心逻辑 L976-1073
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
MLP 和 Attention 中 gather_output=False
在 MLP 中,fc1 是 ColumnParallel,输出直接传给 fc2(RowParallel)。fc2 的 input_is_parallel=True 表示它接受已切分的输入。这样 fc1 和 fc2 之间不需要任何通信 —— 切分的输出直接就是切分的输入。Attention 中 QKV 投影和 Output 投影也是同样的配对。
04

RowParallelLinear

layers.py 按行切分权重 input_is_parallel

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\)。

RowParallelLinear:输入的不同分片 + 权重的不同分片 → 部分和 → AllReduce
输入 (已切分)局部输出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]
g 算子: AllReduce(Y_0 + Y_1) → 每个 GPU 得到相同的完整 Y

input_is_parallel 选项

  • input_is_parallel=True(常用):输入已按 TP 维度切分,直接使用。配合上游 ColumnParallel 的 gather_output=False
  • input_is_parallel=False:输入是完整的,需要先 Scatter 切分再计算。
tensor_parallel/layers.py — RowParallelLinear.forward 核心逻辑 L1268-1324
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
RowParallel 的通信发生在前向
与 ColumnParallel(通信在反向)不同,RowParallel 的 g 算子在前向做 AllReduce。这是因为下游需要完整的输出(残差连接、LayerNorm)。反向时 g 的 backward=Identity,梯度直接透传,不通信。
05

Column + Row 组合

MLP TP 策略 Attention TP 策略 每子层仅 1 次通信

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(反向)。

flowchart TD X1["Input X [s,b,h]"] F1["f fwd:Identity bwd:AllReduce"] FC1_1["ColParallel fc1 gather=False"] ACT1["SwiGLU 逐元素,无通信"] FC2_1["RowParallel fc2 input_is_parallel"] G1_1["g fwd:AllReduce bwd:Identity"] Y1["Output Y [s,b,h]"] X1 --> F1 --> FC1_1 --> ACT1 --> FC2_1 --> G1_1 --> Y1 style X1 fill:#0d1117,stroke:#3fb950,color:#e6edf3 style Y1 fill:#0d1117,stroke:#3fb950,color:#e6edf3 style F1 fill:#1a2332,stroke:#58a6ff,color:#58a6ff style G1_1 fill:#1a2332,stroke:#58a6ff,color:#58a6ff
MLP TP — fc1 和 fc2 之间零通信
前向 1 次 AllReduce (g) + 反向 1 次 AllReduce (f)
flowchart TD X2["Input X [s,b,h]"] F2_a["f fwd:Identity bwd:AllReduce"] QKV_a["ColParallel QKV gather=False"] CA_a["CoreAttention 各head独立"] OP_a["RowParallel O_proj input_is_parallel"] G2_a["g fwd:AllReduce bwd:Identity"] Y2["Output Y [s,b,h]"] X2 --> F2_a --> QKV_a --> CA_a --> OP_a --> G2_a --> Y2 style X2 fill:#0d1117,stroke:#3fb950,color:#e6edf3 style Y2 fill:#0d1117,stroke:#3fb950,color:#e6edf3 style F2_a fill:#1a2332,stroke:#58a6ff,color:#58a6ff style G2_a fill:#1a2332,stroke:#58a6ff,color:#58a6ff
Attention TP — heads 天然可分,无跨GPU通信
前向 1 次 AllReduce (g) + 反向 1 次 AllReduce (f)
Column + Row 配对的通信效率
如果 MLP 用两个 ColumnParallel(fc1 和 fc2 都按列切),fc1 的输出需要 AllGather 成完整 tensor 才能给 fc2 用,fc2 的输出又需要 AllGather 才能给残差连接用 —— 前向就要 2 次 AllGather,反向还有 2 次 AllReduce(f 的 backward),总共 4 次通信。而 Column + Row 配对只需要 前向 1 次 + 反向 1 次 = 2 次通信,因为 Column 的切分输出恰好是 Row 需要的切分输入,省掉了中间的 AllGather。这就是 Megatron 论文(Shoeybi et al., 2019)的核心贡献之一。
为什么激活函数不破坏 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 这类需要跨元素归约的操作不能在切分维度上独立计算。

06

Sequence Parallelism

layers.py mappings.py 激活内存优化

问题:在纯 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 = 4DD + D + D + D = 4D
代码中是 if/else 二选一,不是叠加
ColumnParallel.forward 中: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、残差连接)。

flowchart TD H["[s,b,h] 完整副本"] LN1["LayerNorm 冗余"] F1["f fwd:0 bwd:2D"] QKV["ColParallel QKV"] CA["CoreAttention"] OP["RowParallel O_proj"] G1["g fwd:2D bwd:0"] BDA1["BDA+Residual 冗余"] LN2["LayerNorm 冗余"] F2["f fwd:0 bwd:2D"] FC1["ColParallel fc1"] ACT["SwiGLU"] FC2["RowParallel fc2"] G2["g fwd:2D bwd:0"] BDA2["BDA+Residual 冗余"] OUT["[s,b,h]"] H --> LN1 --> F1 --> QKV --> CA --> OP --> G1 --> BDA1 --> LN2 --> F2 --> FC1 --> ACT --> FC2 --> G2 --> BDA2 --> OUT style H fill:#0d1117,stroke:#3fb950,color:#e6edf3 style OUT fill:#0d1117,stroke:#3fb950,color:#e6edf3 style F1 fill:#1a2332,stroke:#58a6ff,color:#58a6ff style G1 fill:#1a2332,stroke:#58a6ff,color:#58a6ff style F2 fill:#1a2332,stroke:#58a6ff,color:#58a6ff style G2 fill:#1a2332,stroke:#58a6ff,color:#58a6ff style QKV fill:#161b22,stroke:#58a6ff,color:#e6edf3 style CA fill:#161b22,stroke:#58a6ff,color:#e6edf3 style OP fill:#161b22,stroke:#58a6ff,color:#e6edf3 style FC1 fill:#161b22,stroke:#58a6ff,color:#e6edf3 style ACT fill:#161b22,stroke:#58a6ff,color:#e6edf3 style FC2 fill:#161b22,stroke:#58a6ff,color:#e6edf3 style LN1 fill:#161b22,stroke:#30363d,color:#8b949e,stroke-dasharray:5 5 style LN2 fill:#161b22,stroke:#30363d,color:#8b949e,stroke-dasharray:5 5 style BDA1 fill:#161b22,stroke:#30363d,color:#8b949e,stroke-dasharray:5 5 style BDA2 fill:#161b22,stroke:#30363d,color:#8b949e,stroke-dasharray:5 5
TP-only
前向: 0+2D+0+2D = 4D
反向: 2D+0+2D+0 = 4D
激活内存: s*b*h / GPU
flowchart TD H2["[s/N,b,h] 1/N序列"] LN3["LayerNorm 只算s/N"] AG1["AG替代f fwd:D bwd:D"] QKV2["ColParallel QKV"] CA2["CoreAttention"] OP2["RowParallel O_proj"] RS1["RS替代g fwd:D bwd:D"] BDA3["BDA+Residual 只算s/N"] LN4["LayerNorm 只算s/N"] AG2["AG替代f fwd:D bwd:D"] FC3["ColParallel fc1"] ACT2["SwiGLU"] FC4["RowParallel fc2"] RS2["RS替代g fwd:D bwd:D"] BDA4["BDA+Residual 只算s/N"] OUT2["[s/N,b,h]"] H2 --> LN3 --> AG1 --> QKV2 --> CA2 --> OP2 --> RS1 --> BDA3 --> LN4 --> AG2 --> FC3 --> ACT2 --> FC4 --> RS2 --> BDA4 --> OUT2 style H2 fill:#0d1117,stroke:#3fb950,color:#e6edf3 style OUT2 fill:#0d1117,stroke:#3fb950,color:#e6edf3 style AG1 fill:#1a2332,stroke:#3fb950,color:#3fb950 style RS1 fill:#1a2332,stroke:#3fb950,color:#3fb950 style AG2 fill:#1a2332,stroke:#3fb950,color:#3fb950 style RS2 fill:#1a2332,stroke:#3fb950,color:#3fb950 style QKV2 fill:#161b22,stroke:#58a6ff,color:#e6edf3 style CA2 fill:#161b22,stroke:#58a6ff,color:#e6edf3 style OP2 fill:#161b22,stroke:#58a6ff,color:#e6edf3 style FC3 fill:#161b22,stroke:#58a6ff,color:#e6edf3 style ACT2 fill:#161b22,stroke:#58a6ff,color:#e6edf3 style FC4 fill:#161b22,stroke:#58a6ff,color:#e6edf3 style LN3 fill:#161b22,stroke:#3fb950,color:#e6edf3 style LN4 fill:#161b22,stroke:#3fb950,color:#e6edf3 style BDA3 fill:#161b22,stroke:#3fb950,color:#e6edf3 style BDA4 fill:#161b22,stroke:#3fb950,color:#e6edf3
TP + SP
前向: D+D+D+D = 4D
反向: D+D+D+D = 4D
激活内存: s/N*b*h / GPU
SP 的本质:免费的内存优化
SP 不增加任何通信开销(AllReduce 本身就等于 AllGather + ReduceScatter),只是把一个原子操作拆成两个半量操作,分别放在 TP 区域的入口和出口。拆开后,中间的非 TP 区域可以在 s/N 的激活值上计算,每个 GPU 的激活内存减少到 1/N。这就是为什么 Megatron 默认同时开启 TP 和 SP。
tensor_parallel/layers.py — SP 对 ColumnParallel 的影响 L998-1015
# 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_)
tensor_parallel/layers.py — SP 对 RowParallel 的影响 L1300-1315
# 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 置零,不依赖其他元素。残差连接也是逐元素加法。这些操作都天然支持序列维度切分。

07

词表并行

layers.py cross_entropy.py VocabParallelEmbedding

词表(Vocabulary)通常很大(32K~256K),Embedding 层和最终的 Output Linear 层的权重维度为 [V, h]。TP 同样按列切分词表维度:每个 GPU 持有 [V/N, h] 的 Embedding 分片。

VocabParallelEmbedding:词表按 TP 切分 词表大小 V = 8, TP = 2 GPU 0 持有: E[0:4, :] 词 0, 1, 2, 3 的 embedding GPU 1 持有: E[4:8, :] 词 4, 5, 6, 7 的 embedding 查询 token_id = 5: GPU 0: 5 不在 [0, 4) 范围内 → 输出 [0, 0, 0](零向量) GPU 1: 5 在 [4, 8) 范围内 → 输出 E[5] = [0.3, 0.1, 0.7] AllReduce(SUM): [0, 0, 0] + [0.3, 0.1, 0.7] = [0.3, 0.1, 0.7] ← 正确的 embedding 原理:每个 GPU 对不属于自己范围的 token 输出零向量, AllReduce 后只有持有该 token 的 GPU 贡献非零值, 等价于在完整词表上查询。
tensor_parallel/layers.py — VocabParallelEmbedding.forward L282-315
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 在大词表下非常大。

cross_entropy.py — _VocabParallelCrossEntropy.forward 核心步骤 L124-189
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
避免 AllGather 完整 logits 的意义
词表 V=128K、序列长度 s=8192、batch b=4 时,完整 logits tensor = 8192 x 4 x 128K x 2B = 8 GB。并行 CrossEntropy 只需要通过 3 次小量 AllReduce(标量 max、标量 sum、标量 logit)就能计算 loss,完全避免了 AllGather 这个巨大 tensor。
08

通信量总结

总结

下表汇总每个 TransformerLayer 的 TP 通信量。设 \(M = s \times b \times h\)(激活值大小),TP 组大小为 \(N\)。

子层模式通信类型前向通信量反向通信量总计
AttentionTP-onlyAllReduce (f+g)\(\frac{2(N-1)}{N}M\)\(\frac{2(N-1)}{N}M\)\(\frac{4(N-1)}{N}M\)
MLPTP-onlyAllReduce (f+g)\(\frac{2(N-1)}{N}M\)\(\frac{2(N-1)}{N}M\)\(\frac{4(N-1)}{N}M\)
AttentionTP+SPAG + RS\(\frac{2(N-1)}{N}M\)\(\frac{2(N-1)}{N}M\)\(\frac{4(N-1)}{N}M\)
MLPTP+SPAG + 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\)。

TP 为什么只用在节点内
TP 每层需要 \(\approx 8M\) 通信量,且在前向和反向的关键路径上(不可隐藏)。以 Llama-7B (h=4096, s=4096, b=1) 为例,\(M = 4096 \times 4096 \times 2B = 32\) MB,每层通信 \(\approx 256\) MB。32 层共 8 GB。
  • NVLink(节点内):带宽 900 GB/s (NVSwitch),8 GB 通信仅需 ~9 ms
  • InfiniBand(跨节点):带宽 50-100 GB/s,8 GB 通信需 80-160 ms
跨节点 TP 会使通信时间增加 10-20 倍,成为严重瓶颈。所以 TP 通常 = 节点内 GPU 数(8),跨节点交给 DP 和 PP(通信量更小或可重叠)。
各并行策略的通信特性对比 策略 通信量/步 是否在关键路径 通信介质 典型规模 ──────────────────────────────────────────────────────────────────── TP ~8M × L 是(无法隐藏) NVLink 8(节点内) SP 与 TP 相同 NVLink 与 TP 相同 PP 微批次激活值 是(bubble) NVLink/IB 2-8 DP ~2 × 参数量 可重叠 IB/RoCE 8-1024 DP(分布式) 与 DP 相同 可重叠 IB/RoCE 与 DP 相同 TP 和 PP 的通信在前向/反向的关键路径上,无法完全隐藏。 DP 的通信可以和反向计算重叠(overlap_grad_reduce),理想情况下完全隐藏。
相关文档