模型并行基础:张量并行(Tensor Parallelism)的原理与实现
张量并行,说白了就是把一个巨大的矩阵运算,切分成几块,让不同的GPU各自算一块。我刚开始接触这个概念时,总觉得这不就是矩阵分块乘法吗?后来真正上手才发现,里面的门道比想象中多得多。
为什么需要张量并行?
你想想看,现在的大模型动辄几百亿参数。单张GPU的显存,就算H100也才80GB。一个175B的GPT-3,光参数就要350GB。模型并行就派上用场了。
模型并行有两种主流方式:
- 流水线并行(Pipeline Parallelism):按层切分,每张卡负责几层
- 张量并行(Tensor Parallelism):按矩阵维度切分,每张卡负责一部分计算
我个人习惯把张量并行看作是「更细粒度的模型并行」。它解决的是单层计算量太大的问题。举个例子,一个隐藏层维度为12288的FFN层,单卡算起来显存压力巨大,但切成4份后,每张卡只算3072维,压力就小多了。
核心思想:将单个张量运算(如矩阵乘法)沿某个维度切分,分配到多个设备上并行计算,最后再合并结果。
张量并行的两种切分方式
这里我重点讲两种最常见的切分策略。我在项目中遇到过不少同学搞混它们,结果通信开销比计算还大,得不偿失。
1. 按行切分(Row-wise Parallelism)
假设我们要计算 Y = X × W,其中X是输入,W是权重矩阵。
按行切分就是把W按行切成两块:
W = [W1] → GPU0: Y1 = X × W1
[W2] GPU1: Y2 = X × W2
AllReduce: Y = Y1 + Y2
这里有个关键点:每张卡都需要完整的输入X。所以输入需要广播到所有卡上。计算完成后,需要做一次AllReduce把结果合并。
我的经验:按行切分适合权重矩阵行数远大于列数的情况。比如FFN层的第一个线性变换,输入维度小、输出维度大,用行切分就很合适。
2. 按列切分(Column-wise Parallelism)
按列切分则是把W按列切开:
W = [W1 | W2] → GPU0: Y1 = X × W1
GPU1: Y2 = X × W2
拼接: Y = [Y1 | Y2]
每张卡只需要计算一部分输出维度。最后通过AllGather把结果拼起来就行。
嗯,这里要注意:按列切分不需要AllReduce,只需要AllGather。通信量比行切分小一些。我一般优先考虑按列切分,除非模型结构不允许。
Transformer中的张量并行实现
我们拿Transformer的一个Transformer Block来举例。它包含两个主要部分:多头注意力(MHA)和前馈网络(FFN)。
先看MHA层的张量并行:
# 伪代码:4卡张量并行下的MHA
class TensorParallelMHA:
def __init__(self, world_size=4):
self.world_size = world_size
# QKV权重按列切分
self.q_proj = ColumnParallelLinear(dim, dim // world_size)
self.k_proj = ColumnParallelLinear(dim, dim // world_size)
self.v_proj = ColumnParallelLinear(dim, dim // world_size)
# 输出投影按行切分
self.out_proj = RowParallelLinear(dim, dim)
def forward(self, x):
# 每张卡计算部分QKV
q = self.q_proj(x) # 输出维度: dim/world_size
k = self.k_proj(x)
v = self.v_proj(x)
# 本地计算注意力
attn_output = self_attention(q, k, v)
# 输出投影需要AllReduce
output = self.out_proj(attn_output)
return output
FFN层的实现类似:
class TensorParallelFFN:
def __init__(self, world_size=4):
# 第一个线性层:按列切分
self.gate_proj = ColumnParallelLinear(dim, 4*dim // world_size)
self.up_proj = ColumnParallelLinear(dim, 4*dim // world_size)
# 第二个线性层:按行切分
self.down_proj = RowParallelLinear(4*dim, dim)
def forward(self, x):
gate = self.gate_proj(x)
up = self.up_proj(x)
hidden = silu(gate) * up
output = self.down_proj(hidden)
return output
我曾经踩过的坑:在实现ColumnParallelLinear时,忘记在反向传播时对梯度做AllReduce。结果模型训练出来loss死活不降,排查了两天才发现是梯度同步的问题。记住:前向做了通信,反向也要做对应的通信。
通信开销分析
张量并行不是免费的午餐。它引入了额外的通信开销。我整理了一个对比表:
| 切分方式 | 前向通信 | 反向通信 | 通信量 |
|---|---|---|---|
| 按行切分 | AllReduce | AllReduce | 2 × 输出大小 |
| 按列切分 | AllGather | ReduceScatter | 1 × 输出大小 |
你看,按列切分的通信量只有按行切分的一半。所以能按列切分的地方,我尽量按列切分。
实际部署中的注意事项
我在部署千亿模型时,总结了几条经验:
- 通信拓扑很重要:张量并行要求卡间通信带宽极高。最好用NVLink连接的卡做张量并行,跨节点用流水线并行。
- 切分粒度要合适:不是切得越细越好。切太细了,通信开销会吃掉计算加速。我一般控制在4-8卡之间。
- 注意显存碎片:张量并行后,每张卡的显存使用不均衡。我曾经遇到过某张卡显存爆了,其他卡还有富余的情况。需要仔细规划切分策略。
一个小技巧:用torch.distributed的all_to_all操作可以实现更灵活的切分。不过一般场景下,AllReduce和AllGather就够用了。
张量并行整体架构
下面这张图展示了张量并行在Transformer Block中的完整流程:
这张图展示了一个完整的张量并行流程。输入先广播到4张卡上,每张卡独立计算部分QKV和注意力,然后通过AllGather合并,再经过FFN层,最后AllReduce得到最终输出。
性能调优建议
最后分享几个我在实际调优中总结的点:
- 计算通信重叠:把通信操作和计算操作重叠起来。比如在AllReduce的同时,让GPU继续算下一层。这需要仔细设计流水线。
- 选择合适的通信后端:NCCL是首选,但如果你用的是AMD的卡,得用RCCL。我踩过这个坑,换了后端后性能提升明显。
- 注意负载均衡:确保每张卡的计算量差不多。如果某张卡算得快,其他卡算得慢,快的卡就得空等。
一句话总结:张量并行是把大矩阵切成小块,让多卡一起算。切得好,性能翻倍;切不好,通信开销让你怀疑人生。