4、常见融合模式(三):MatMul+Add融合、GELU近似融合、LayerNorm融合优化

好,我们继续往下聊。前面两章我们把基础融合和激活函数融合讲透了,这一章咱们来啃几个硬骨头——MatMul+Add、GELU近似,还有LayerNorm。这三个算子,说实话,在Transformer类模型里几乎无处不在。你优化好了,整个推理速度能翻倍;你优化不好,NPU的算力就全浪费在搬运数据上了。

4.1 MatMul+Add融合:省掉一次显存读写

先说说MatMul+Add。这个模式太常见了——Attention里的QKV投影、FFN里的线性层,几乎都是MatMul后面跟一个Add(偏置加法)。

我刚开始做NPU优化时,犯过一个低级错误:把MatMul和Add当成两个独立的算子去调度。结果呢?MatMul算完,把结果写回显存,Add再读出来,加上偏置,再写回去。一来一回,两次显存读写,白白浪费了带宽。

融合的思路其实很简单:在MatMul的计算过程中,直接把偏置加进去。具体来说,MatMul的每个输出元素是 sum(A[i][k] * B[k][j]),我们只需要在累加完成后,加上偏置 bias[j] 即可。

核心收益:减少一次中间结果的显存写回和一次读取。对于大矩阵(比如隐藏层维度4096),一次融合能省下几十微秒,累积起来非常可观。

代码层面,如果你用Triton或者TVM写自定义算子,可以这样实现:

// 伪代码:MatMul+Add融合
for i in range(M):
    for j in range(N):
        acc = 0
        for k in range(K):
            acc += A[i][k] * B[k][j]
        C[i][j] = acc + bias[j]  // 融合点:累加完直接加偏置

嗯,这里要注意:有些NPU的指令集支持“乘累加后加偏置”的单条指令,比如NVIDIA的Tensor Core配合CUDA Core做FMA(Fused Multiply-Add)。如果你的NPU不支持,那就得手动做循环融合,但效果依然很好。

我的经验:在华为昇腾NPU上,我试过把MatMul+Add融合写成自定义算子,比分开调用快了约15%。原因就是省掉了中间张量的DDR读写。你想想看,大模型推理时,显存带宽往往是瓶颈,能省一点是一点。

4.2 GELU近似融合:精度与速度的博弈

GELU激活函数,说白了就是 x * Φ(x),其中Φ是标准正态分布的CDF。直接算的话,涉及erf函数,计算量很大。所以业界普遍用近似公式。

最常用的近似有两种:

近似方法 公式 计算开销 精度损失
Tanh近似 0.5 * x * (1 + tanh(√(2/π) * (x + 0.044715 * x³))) 高(含tanh) ~1e-3
Sigmoid近似 x * sigmoid(1.702 * x) 低(含sigmoid) ~1e-2

我个人习惯用Tanh近似,因为精度更接近原始GELU。但如果你做的是量化推理,Sigmoid近似也够用,而且计算更快。

融合的关键在哪里?GELU通常跟在MatMul后面。如果我们能把MatMul的输出直接喂给GELU,而不写回显存,那就赚了。这就是“算子融合”的通用思路——减少中间结果的物化。

我曾经在一个BERT推理项目里,把MatMul+GELU融合成一个算子。做法是:在MatMul的每个输出元素计算完成后,立即调用GELU近似函数,然后才写回显存。这样,中间结果(MatMul的输出)根本不需要落地。

避坑指南:我曾经踩过一个坑——GELU近似公式里的常数精度问题。比如Tanh近似里的0.044715,如果你用float16表示,精度会丢失,导致最终结果偏差变大。建议用float32计算这个常数,或者干脆把常数预计算好存起来。

代码示例(伪代码,展示融合逻辑):

// MatMul + GELU融合
for i in range(M):
    for j in range(N):
        acc = 0
        for k in range(K):
            acc += A[i][k] * B[k][j]
        // 直接计算GELU,不写回中间结果
        float x = acc
        float gelu_out = 0.5 * x * (1 + tanh(0.79788456 * (x + 0.044715 * x * x * x)))
        C[i][j] = gelu_out

你想想看,如果不融合,你得先写一个M×N的矩阵,再读一遍,再算GELU。融合后,读写量直接减半。对于大模型,这省下的带宽可不是一星半点。

4.3 LayerNorm融合优化:减少访存次数

LayerNorm,说白了就是先算均值和方差,然后做归一化,最后再缩放和平移。公式是:

y = (x - mean) / sqrt(var + eps) * gamma + beta

这个算子的计算量不大,但访存开销很高——因为它需要多次遍历输入数据:第一次算mean,第二次算var,第三次做归一化。三次遍历,三次读写,对于大序列长度(比如2048),这开销很可观。

融合优化的思路:把三次遍历合并成一次。具体做法是:

  1. 在第一次遍历时,同时累加 x 和 x²,这样一次遍历就能算出mean和var。
  2. 第二次遍历时,直接做归一化和缩放平移。

这样,两次遍历就能完成LayerNorm,比原来的三次少了一次。

更进一步:如果你的NPU支持“在线统计”指令(比如一次遍历同时计算均值和方差),那甚至可以做到一次遍历完成所有操作。我在寒武纪的NPU上见过这种设计,效率极高。

代码实现(伪代码,展示两次遍历的融合):

// LayerNorm融合优化:两次遍历
// 第一次遍历:计算mean和var
float sum = 0, sum_sq = 0;
for i in range(N):
    sum += x[i]
    sum_sq += x[i] * x[i]
float mean = sum / N
float var = sum_sq / N - mean * mean

// 第二次遍历:归一化 + 缩放平移
for i in range(N):
    y[i] = (x[i] - mean) / sqrt(var + eps) * gamma[i] + beta[i]

嗯,这里要注意:第二次遍历里的 gamma[i] 和 beta[i] 是逐元素的,所以LayerNorm其实是对每个特征维度独立做归一化。如果你把LayerNorm和前面的MatMul融合,那就更复杂了——需要把MatMul的输出按行做LayerNorm。我建议先做MatMul+LayerNorm的融合,再考虑更复杂的模式。

我的建议:在实际项目中,LayerNorm融合优化的收益取决于序列长度。对于短序列(比如128),收益不明显;但对于长序列(比如2048),收益能到20%以上。所以,如果你的模型处理的是长文本或高分辨率图像,一定要做这个优化。

4.4 本章小结

好,我们快速回顾一下:

  • MatMul+Add融合:在MatMul累加完成后直接加偏置,省掉一次中间结果写回。收益约10-15%。
  • GELU近似融合:把GELU计算嵌入到MatMul的输出阶段,减少一次显存读写。注意常数精度问题。
  • LayerNorm融合优化:把三次遍历合并成两次,减少访存次数。长序列场景收益明显。

这三种融合模式,说白了都是围绕一个核心思想——减少中间结果的物化和访存。NPU的算力往往不是瓶颈,带宽才是。你只要把数据搬运的次数降下来,性能自然就上去了。

我个人习惯在做完算子融合后,用profiling工具对比一下融合前后的访存量。如果访存量没有明显下降,那说明融合没做到位,需要重新审视。

最后,送大家一句话:融合不是目的,减少数据搬运才是。

可可精品资料库