3、张量并行(Tensor Parallelism):原理与实现,以Transformer为例的列切分与行切分

张量并行,说白了就是把一个巨大的矩阵运算拆成几块,让多张卡一起干活。你想想看,当模型大到单卡放不下的时候,除了流水线并行那种按层切的方式,还有一种更细粒度的切法——把每一层的参数矩阵都切成片,分到不同设备上。

我个人习惯把张量并行叫做“真正的并行”。为什么?因为流水线并行其实还是串行的,只不过把串行任务分到了不同设备上。但张量并行不一样,它是真正让多张卡同时计算同一个矩阵乘法的一部分。嗯,这里要注意,它和流水线并行的核心区别就在这里。

3.1 为什么需要张量并行?

我在项目中遇到过这样一个场景:训练一个 175B 的 GPT 模型,单卡显存 80GB,但光一个参数矩阵就 200GB 了。流水线并行虽然能解决,但你会发现一个问题——GPU 利用率上不去。因为流水线里总有设备在空等。

张量并行能解决两个核心痛点:

  • 单卡放不下大矩阵:把一个大矩阵切成几块,每块放一张卡上
  • 减少通信瓶颈:相比数据并行那种全量同步,张量并行只在矩阵乘法前后做一次 all-reduce

核心思想:把矩阵乘法拆成独立子任务,多卡同时计算,最后合并结果。

3.2 列切分(Column-wise Partition)

我们先看列切分。以 Transformer 的 FFN 层为例,它有两个线性变换:

# 标准实现
hidden = gelu(x @ W1 + b1)  # W1: [d_model, d_ff]
output = hidden @ W2 + b2    # W2: [d_ff, d_model]

列切分怎么做?把 W1 按列切成两块:

# 假设 2 张卡
# 卡0 持有 W1[:, :d_ff//2]
# 卡1 持有 W1[:, d_ff//2:]

# 每张卡独立计算
hidden_local = gelu(x @ W1_local + b1_local)
# 此时 hidden_local 是 [batch, d_ff//2]

这里有个关键点:列切分后,每张卡只需要计算自己那部分。但注意,列切分不需要通信,因为输入 x 是完整的,每张卡拿到的 W1 列不同,计算出的 hidden 也不同维度。

我的经验:列切分适合第一层线性变换。因为输入 x 需要完整广播到所有卡,但每张卡只算自己那部分,通信量很小。

3.3 行切分(Row-wise Partition)

行切分就有点意思了。还是那个 FFN 层,但这次我们切 W2:

# 标准实现
output = hidden @ W2 + b2  # W2: [d_ff, d_model]

# 行切分:把 W2 按行切成两块
# 卡0 持有 W2[:d_ff//2, :]
# 卡1 持有 W2[d_ff//2:, :]

# 每张卡计算
output_local = hidden_local @ W2_local + b2_local
# 此时 output_local 是 [batch, d_model]

等等,这里有个坑。hidden_local 是列切分后的结果,每张卡只有一半的维度。但 W2_local 是完整行数的一半。所以每张卡算出来的 output_local 都是完整的 [batch, d_model] 维度,但数值不同。

为什么会这样?因为行切分后,每张卡拿到的 W2 行不同,但列相同。所以每张卡算出的 output 是完整维度的,但只包含了部分行的贡献。最后需要做一次 all-reduce 求和

我曾经踩过的坑:行切分后忘记做 all-reduce,结果模型 loss 死活不降。排查了半天才发现输出没合并。记住:行切分必须通信,列切分不需要。

3.4 Transformer 中的完整切分方案

我们来看一个完整的 Transformer 层怎么切。以 Megatron-LM 的方案为例:

# 伪代码:2 卡张量并行
class TransformerLayerTP:
    def __init__(self, tp_size=2):
        # 列切分:Q, K, V 的权重矩阵
        self.qkv_w = [W_qkv[:, :d//tp_size], W_qkv[:, d//tp_size:]]
        # 行切分:输出投影
        self.out_w = [W_out[:d//tp_size, :], W_out[d//tp_size:, :]]
        # FFN 第一层:列切分
        self.ffn1_w = [W1[:, :d_ff//tp_size], W1[:, d_ff//tp_size:]]
        # FFN 第二层:行切分
        self.ffn2_w = [W2[:d_ff//tp_size, :], W2[d_ff//tp_size:, :]]

    def forward(self, x, rank):
        # 1. 自注意力:列切分 QKV
        qkv_local = x @ self.qkv_w[rank]
        q, k, v = split_qkv(qkv_local)

        # 2. 注意力计算(每张卡独立算自己的头)
        attn_out = attention(q, k, v)

        # 3. 输出投影:行切分,需要 all-reduce
        out_local = attn_out @ self.out_w[rank]
        out = all_reduce(out_local)

        # 4. FFN 第一层:列切分
        hidden_local = gelu(out @ self.ffn1_w[rank])

        # 5. FFN 第二层:行切分,需要 all-reduce
        ffn_out_local = hidden_local @ self.ffn2_w[rank]
        ffn_out = all_reduce(ffn_out_local)

        return ffn_out

关键通信点:整个 Transformer 层只需要 2 次 all-reduce(输出投影和 FFN 第二层)。相比数据并行每步都要同步梯度,通信量少得多。

3.5 张量并行的通信开销分析

我建议你用这个公式估算通信量:

切分方式 通信操作 通信量 适用场景
列切分 0 第一层线性变换
行切分 all-reduce 2 × 输出大小 第二层线性变换
混合切分 2 次 all-reduce 4 × 隐藏层大小 完整 Transformer 层

你想想看,如果隐藏层大小是 4096,batch size 是 4,那一次 all-reduce 也就传 64KB 数据。相比模型参数动辄几百 GB,这点通信开销完全可以接受。

我的建议:张量并行最适合 GPU 间有高速互联的场景(比如 NVLink)。如果设备间是千兆以太网,通信延迟会吃掉所有并行收益。

3.6 实现注意事项

嗯,这里有几个实际工程中容易忽略的点:

  • 负载均衡:确保每张卡分到的参数数量尽量相等。我见过有人把 d_ff 设成质数,结果切分不均匀,慢卡拖慢整体。
  • 数值精度:all-reduce 求和时,不同卡的浮点数精度可能不同。建议用 FP16 时做一次精度对齐。
  • 显存碎片:张量并行会创建很多小张量,容易产生显存碎片。我习惯用缓存分配器来管理。

我曾经踩过的坑:在 8 卡环境下做张量并行,结果发现通信时间比计算时间还长。后来排查发现是 NCCL 的 all-reduce 实现默认用 ring 算法,改成 tree 算法后快了 3 倍。所以,通信库的调优很重要

3.7 知识体系图

下面这张图展示了张量并行的核心逻辑:

张量并行核心逻辑 输入 x 列切分(Column-wise) W1 按列切 → 每卡独立计算 → 无需通信 输出维度:每卡 [batch, d_ff/tp_size] 中间激活(如 GELU) 每卡独立计算,无通信 行切分(Row-wise) W2 按行切 → 每卡计算完整维度 → 需要 all-reduce 输出维度:每卡 [batch, d_model],但数值不同 All-Reduce 求和 输出 y

这张图把整个流程串起来了。从输入开始,经过列切分(无通信),中间激活,再到行切分(需要 all-reduce),最后输出。说白了,张量并行就是通过巧妙的切分策略,把通信次数降到最低。

总结:张量并行的精髓在于“列切分省通信,行切分做聚合”。实际工程中,我通常把列切分放在计算密集且输出维度大的地方,行切分放在需要合并结果的地方。这样通信开销最小,并行效率最高。


专注资料整理