1. 算子融合概述

大家好,我是你们的老朋友。今天咱们聊聊算子融合——这个在深度学习编译器中绕不开的话题。

说实话,我第一次接触算子融合时,心里想的是:「不就是把几个算子拼在一起吗?有啥难的?」后来被现实狠狠教育了一顿。嗯,咱们慢慢聊。

1.1 什么是算子融合

算子融合,说白了就是把多个连续的计算操作合并成一个。

举个例子。你写了一段推理代码:

// 原始计算图:三个独立的算子
y = conv2d(x, weight)    // 卷积
z = batch_norm(y)        // 批归一化
w = relu(z)              // 激活函数

这三个算子,在传统框架里是逐个执行的。卷积算完,把中间结果写回显存;批归一化读出来,算完再写回去;ReLU 再读再写。你想想看,这中间有多少次显存读写是浪费的?

算子融合后,就变成了一个「超级算子」:

// 融合后的算子:Conv+BatchNorm+ReLU
w = fused_conv_bn_relu(x, weight, bn_params)

中间结果留在寄存器或共享内存里,不写回显存。速度能快多少?我在项目中遇到过,2~5倍的加速比比皆是。

核心思想:减少中间结果的显存读写,把多个小算子「捏」成一个大算子。

1.2 为什么需要算子融合

你可能要问:现在的 GPU 算力这么强,显存带宽也够大,为啥还要费劲搞融合?

原因有三点,我一个个说。

  1. 访存瓶颈:深度学习算子的计算密度通常不高。很多算子是访存受限的,计算单元在等数据。融合能减少数据搬运,直接缓解这个瓶颈。
  2. Kernel Launch 开销:每个算子对应一个 GPU Kernel。启动 Kernel 是有开销的——设置参数、分配线程块、同步等等。融合后 Kernel 数量减少,这部分开销就省下来了。
  3. 优化空间更大:融合后的算子,编译器可以做更多全局优化。比如寄存器分配、指令重排、共享内存复用。单个小算子反而不好优化。

避坑指南:我曾经在一个项目中,把 12 个小算子强行融合成一个。结果显存占用暴增,反而变慢了。为什么?因为融合后的算子需要更多寄存器,导致寄存器溢出。所以融合不是越多越好,要适可而止。

1.3 融合的分类

算子融合主要分两类:横向融合和纵向融合。我习惯用「兄弟融合」和「父子融合」来记,更形象。

1.3.1 横向融合(兄弟融合)

横向融合,指的是同一层级的、没有数据依赖的算子合并在一起。

比如下面这个场景:

// 横向融合前:两个独立的卷积
conv_a = conv2d(input, weight_a)
conv_b = conv2d(input, weight_b)

// 横向融合后:一个卷积搞定
conv_ab = fused_conv2d_dual(input, weight_a, weight_b)

这种融合的好处是:共享输入数据的读取。原来要读两次 input,现在只读一次。我在做 MobileNet 优化时,就用横向融合把 depthwise 卷积和 pointwise 卷积合并,推理速度提升了 30%。

融合类型 数据依赖 典型场景 加速效果
横向融合 无依赖(并行) 多分支卷积、多尺度特征 中等(共享访存)
纵向融合 有依赖(串行) Conv+BN+ReLU、MatMul+Add 较高(消除中间结果)

1.3.2 纵向融合(父子融合)

纵向融合,指的是有数据依赖的、前后串联的算子合并在一起。

这是最常见的融合方式。比如:

// 纵向融合前
h = matmul(x, w1)    // 矩阵乘法
y = add(h, bias)     // 加偏置
z = relu(y)          // 激活

// 纵向融合后
z = fused_matmul_add_relu(x, w1, bias)

纵向融合的核心收益是消除中间结果的显存读写。h 和 y 这两个中间张量,原本都要写回显存。融合后,它们只存在于寄存器中。

注意:纵向融合对计算精度有影响。比如 Conv+BN 融合时,BN 的均值和方差需要提前「吸收」到卷积权重里。如果 BN 是在训练过程中动态更新的,融合后的权重需要重新计算。我曾经因为忘记重新计算权重,导致模型精度掉了 2 个点,排查了一整天。

1.4 知识体系总览

下面这张图,是我自己整理的算子融合知识体系。你可以把它当作本章的「地图」:

算子融合知识体系 什么是算子融合 多个连续操作合并成一个 为什么需要算子融合 减少显存读写 降低Kernel启动开销 提供更多优化空间 融合的分类 横向融合 兄弟算子,无数据依赖 纵向融合 父子算子,有数据依赖

这张图把本章的核心内容串起来了。从上到下,先理解「是什么」,再明白「为什么」,最后掌握「怎么分」。后面的章节,我们会围绕这张图展开。

1.5 小结

算子融合,说白了就是用空间换时间——用更多的寄存器/共享内存,换取更少的显存读写。横向融合适合并行分支,纵向融合适合串行流水。

我个人习惯是:先做纵向融合,再做横向融合。因为纵向融合的收益更直接,而且不容易出错。横向融合要小心寄存器压力,搞不好会适得其反。

一个小技巧:如果你不确定某个融合是否有效,可以先用 nvprof 或 nsys 看看算子的访存带宽利用率。如果利用率低于 50%,融合大概率有收益。如果已经接近 80% 了,融合的意义就不大了。

好了,这一章就到这里。下一章我们聊聊手工算子融合——那些年我们手写的融合 Kernel,踩过的坑和收获的经验。

可可精品资料库