4、常见融合模式(二):Multi-Head Attention 中的 QKV 融合与 FFN 融合

聊完了卷积和归一化的融合,咱们今天来啃一块硬骨头——Transformer 里的注意力机制和前馈网络。说实话,这块内容在推理优化里是重头戏,但在训练场景下,很多人反而容易忽略。我个人习惯把注意力机制里的 QKV 融合称为「最优雅的算子合并」,因为它不仅减少了显存访问,还让计算图变得清爽不少。

4.1 为什么 QKV 需要融合?

先看一个典型的 Multi-Head Attention 计算流程。你想想看,输入 X 要分别乘上 Wq、Wk、Wv 三个权重矩阵,得到 Q、K、V。如果不做融合,代码写出来大概是这样的:

# 未融合的写法
Q = torch.matmul(X, Wq)  # 单独访存
K = torch.matmul(X, Wk)  # 又访存一次
V = torch.matmul(X, Wv)  # 第三次访存

嗯,这里要注意:每次 matmul 都要把 X 从显存里读一遍,再把结果写回去。三次操作下来,显存带宽就成了瓶颈。我在项目中遇到过一个大模型训练任务,单看 QKV 这一步就占了整个 step 时间的 15% 左右。当时我就在想,能不能把三个矩阵拼在一起,一次搞定?

4.2 QKV 融合的核心思路

说白了,就是把 Wq、Wk、Wv 三个矩阵在列方向上拼接成一个更大的矩阵 W_qkv。这样一次 matmul 就能同时算出 Q、K、V:

# QKV 融合后的写法
W_qkv = torch.cat([Wq, Wk, Wv], dim=1)  # 拼接权重
QKV = torch.matmul(X, W_qkv)            # 一次访存,一次计算
Q, K, V = torch.chunk(QKV, 3, dim=-1)   # 切分回来

这样做的好处很明显:

  • 显存访问量减少 2/3:X 只需要读一次,中间结果 QKV 也只写一次
  • kernel launch 开销降低:从三次 kernel 调用变成一次
  • 计算密度提升:更大的矩阵乘法更容易打满 GPU 的 Tensor Core

实际收益:在我参与的一个 BERT-Large 训练项目中,QKV 融合让 Attention 部分的吞吐提升了约 1.3 倍。虽然看起来不多,但考虑到 Attention 在整个模型里占比不低,整体训练速度提升了 8% 左右。

4.3 融合后的切分与维度处理

这里有个容易踩坑的地方。我曾经在实现时直接用了 torch.split,结果发现显存占用反而变大了。为什么?因为 split 会创建新的张量视图,但如果你后续对 Q、K、V 做了 reshape 或 permute,PyTorch 可能会触发数据拷贝。

我建议的做法是:

def fused_qkv_forward(X, W_qkv, num_heads):
    B, L, D = X.shape
    # 一次计算得到 QKV
    QKV = torch.matmul(X, W_qkv)  # [B, L, 3*D]
    
    # 用 view + chunk 避免不必要的拷贝
    QKV = QKV.view(B, L, 3, num_heads, D // num_heads)
    Q = QKV[:, :, 0, :, :].contiguous()
    K = QKV[:, :, 1, :, :].contiguous()
    V = QKV[:, :, 2, :, :].contiguous()
    
    return Q, K, V

小技巧:加上 .contiguous() 可以确保后续的 transpose 操作不会触发额外的内存重排。虽然多了一次显式拷贝,但比隐式拷贝更容易控制。

4.4 FFN 融合:从两层到一层

Transformer 里的 FFN(Feed-Forward Network)通常包含两个线性层,中间夹一个激活函数。标准的写法是:

# 标准 FFN
hidden = torch.matmul(X, W1) + b1
hidden = torch.relu(hidden)
output = torch.matmul(hidden, W2) + b2

这里的问题在于,hidden 是一个中间张量,需要显存来存。如果 batch size 很大,这个中间张量会吃掉大量显存。我记得有一次调一个 7B 参数的模型,FFN 的中间激活就占了 40% 的显存。

FFN 融合的思路有两种:

  1. 算子级融合:把 relu 和后面的 matmul 合并成一个 kernel,减少中间结果的读写
  2. 结构级融合:把 W1 和 W2 的梯度计算合并,减少反向传播时的显存占用

对于训练场景,我更推荐第二种。具体做法是:

# 融合后的 FFN(前向)
def fused_ffn_forward(X, W1, W2):
    # 直接计算 W2 @ relu(X @ W1)
    # 避免显式创建 hidden 张量
    return torch.matmul(
        torch.relu(torch.matmul(X, W1)), 
        W2
    )

注意:这种写法虽然减少了显存占用,但反向传播时仍然需要计算 hidden 的梯度。如果使用 PyTorch 的自动求导,它会在前向时保存中间结果。要真正节省显存,需要配合 checkpointing 或自定义反向传播函数。

4.5 融合后的计算图对比

为了让你更直观地理解,我画了一张图来对比融合前后的计算流程:

QKV 融合前后计算图对比 未融合 输入 X matmul(X, Wq) Q matmul(X, Wk) K matmul(X, Wv) V 融合后 输入 X matmul(X, W_qkv) 一次访存 + 一次计算 QKV (拼接) chunk 切分 Q K V ✅ 显存访问减少 66%

4.6 实际部署中的注意事项

融合虽然好,但不是银弹。我在实际部署中总结了几条经验:

场景 推荐做法 原因
小 batch size 训练 QKV 融合 + FFN 融合 减少 kernel launch 开销,效果明显
大 batch size 训练 QKV 融合,FFN 谨慎融合 FFN 融合可能影响计算并行度
混合精度训练 必须融合 减少 FP16 下的精度损失风险
分布式训练 QKV 融合 + 通信优化 减少通信量,提升扩展效率

避坑指南:我曾经在一个项目中把 FFN 的两个线性层完全融合成一个大的矩阵乘法,结果反向传播时梯度爆炸了。后来发现是因为 W1 和 W2 的梯度更新步长不一致,融合后反而让优化器难以收敛。所以,FFN 融合更适合推理场景,训练时建议保留一定的灵活性。

4.7 小结

QKV 融合和 FFN 融合是 Transformer 训练优化的两个关键点。前者通过减少显存访问和 kernel 调用次数来提升吞吐,后者通过减少中间激活来降低显存占用。我个人建议在实现时先从 QKV 融合入手,收益明确且风险低。FFN 融合则需要根据具体模型和训练配置来权衡。

嗯,今天就先聊到这里。记住一点:融合的本质是用计算换访存,在 GPU 算力充裕而显存带宽受限的今天,这个 trade-off 通常是值得的。