Element-wise融合:把多个逐元素操作塞进一个Kernel

逐元素操作,说白了就是那种「一个输入对应一个输出」的计算。像什么ReLU、Sigmoid、加法、乘法、缩放——每个元素独立计算,谁也不靠谁。这类操作在深度学习模型里遍地都是,尤其是那些激活函数、归一化层的前后处理。

但问题来了:如果你每个操作都单独 launch 一个 kernel,那性能就全浪费在访存上了。我见过不少团队,模型结构设计得挺漂亮,结果一 profiling,发现大量时间花在了这些小 kernel 的启动和带宽瓶颈上。说白了,计算量太小,数据搬运才是大头。

为什么Element-wise融合能提速?

你想想看,一个典型的场景:

// 三个逐元素操作,分开写
float *x, *y, *z;
// kernel 1: y = sigmoid(x)
// kernel 2: z = y * 2.0  
// kernel 3: w = z + 1.0

每个 kernel 都要从全局内存读一次、写一次。三次操作,六次访存。但如果融合成一个 kernel:

// 融合后:w = sigmoid(x) * 2.0 + 1.0
// 一次读 x,一次写 w,中间结果留在寄存器里

两次访存搞定。对于带宽受限的场景,这提升是实打实的。我在优化一个推荐模型时遇到过,光是把三个连续的逐元素操作融合,端到端推理速度就快了 15%。

融合的核心原则

不是所有操作都能随便揉在一起的。我个人习惯先看三点:

  • 数据依赖关系:后一个操作是否依赖前一个操作的完整输出?如果是,那就能融合。
  • 计算强度:每个元素的计算量是否远小于访存量?如果是,融合收益大。
  • 寄存器压力:中间结果太多,寄存器放不下,反而会 spill 到 local memory,那就得不偿失了。

核心公式:融合后的访存量 = 输入大小 + 输出大小。融合前 = 每个操作的输入 + 输出之和。节省的带宽 = (融合前 - 融合后) / 融合前。

实战:写一个融合Kernel

假设我们要做:output = relu(scale(input, alpha)) + bias。其中 scale 是逐元素乘一个标量,relu 是激活,再加一个偏置。

分开写要三个 kernel,融合后一个就够了:

__global__ void fused_scale_relu_add(
    const float* input,
    float* output,
    float alpha,
    float bias,
    int n
) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        float val = input[idx];
        // scale
        val = val * alpha;
        // relu
        val = val > 0.0f ? val : 0.0f;
        // add bias
        val = val + bias;
        output[idx] = val;
    }
}

你看,中间变量 val 一直待在寄存器里,根本没出去过。这就是融合的精髓——减少全局内存的读写次数。

我的习惯:写融合 kernel 时,我会先用一个临时变量把中间结果串起来,等确认逻辑正确后,再考虑是否用 __launch_bounds__ 或 #pragma unroll 做微调。别一开始就搞优化,先保证正确性。

什么时候不该融合?

嗯,这里要注意。不是所有情况都适合融合。我曾经踩过一个坑:

  • 操作之间有复杂的数据重排:比如一个操作是 element-wise,下一个是 reduction(求和、求最大值)。这种融合会导致线程间依赖,反而降低并行度。
  • 中间结果太大:如果每个元素需要多个中间变量(比如 4 个 float),而你的 GPU 寄存器有限(比如每个线程 32 个),那强行融合会导致寄存器溢出,性能反而下降。
  • 操作本身已经是计算密集型:比如一个 element-wise 操作后面跟着一个大型矩阵乘法。这时候融合的收益微乎其微,因为瓶颈在矩阵乘,不在访存。

避坑指南:我曾经把一个 element-wise 的 sigmoid 和后面的加法融合了,结果因为 sigmoid 的 exp 计算占用了太多寄存器,导致 occupancy 下降,最终性能还不如分开写。所以,融合前最好用 nvcc 的 --ptxas-options=-v 看看寄存器使用情况。

融合的常见模式

在实际项目中,我总结了几种高频出现的融合模式:

模式 示例 收益
激活 + 线性 ReLU + Scale 减少一次写
多个激活串联 Sigmoid + Tanh 减少两次访存
归一化前后处理 Sub + Mul + Add 减少三次访存
残差连接 Add + ReLU 减少一次读一次写

性能对比:融合 vs 不融合

我拿一个简单的实验数据给你看。在 A100 上,处理 1 亿个 float 元素:

方案 耗时 (ms) 带宽利用率
三个独立 kernel 12.3 32%
融合 kernel 4.1 89%

3 倍加速,带宽利用率从 32% 飙升到 89%。这就是融合的魅力——不增加计算量,只减少访存,就能白捡性能。

知识体系图

下面这张图帮你理清 Element-wise 融合的核心逻辑:

Element-wise 融合核心逻辑 输入数据 (Global Mem) 融合 Kernel (一次 Launch) scale → relu → add (全在寄存器) 输出数据 (Global Mem) 融合前 vs 融合后 融合前:3次Kernel Launch 读3次 + 写3次 = 6次访存 带宽利用率低,Kernel启动开销大 融合后:1次Kernel Launch 读1次 + 写1次 = 2次访存 寄存器暂存中间结果,带宽利用率高

说白了,Element-wise 融合就是「用寄存器换访存」。你想想看,GPU 的寄存器带宽是 TB/s 级别的,而全局内存带宽只有几百 GB/s。把中间结果留在寄存器里,就是让数据待在快车道,别去慢车道堵着。

小技巧:如果你不确定哪些操作能融合,可以用 NVIDIA Nsight Compute 的 roofline 分析。如果某个 kernel 的访存瓶颈特别明显(在 roofline 的「带宽受限」区域),那它旁边的 element-wise 操作大概率能融合进来。

好了,Element-wise 融合的核心就这些。记住一句话:能在一个 kernel 里算完的,就别分两次。下一节我们会聊更复杂的融合模式,但基础打牢了,后面才不慌。

可可精品资料库