一、算子融合概述
什么是算子融合
算子融合,说白了就是把多个连续的计算操作合并成一个。
我打个比方你就明白了。假设你要做一道菜:先切菜,再洗菜,再炒菜。传统做法是切完放回碗里,洗完再放回碗里,最后再倒进锅里。算子融合就像——你切完直接在水龙头下冲,冲完直接下锅。省掉了中间倒腾的功夫。
在深度学习里,算子就是那些基本的计算单元,比如卷积、ReLU、BatchNorm、加法等等。模型推理时,这些算子一个接一个地执行。每个算子都要读写显存,这个开销其实很大。
核心思想:将多个连续算子合并为一个内核(kernel),减少中间数据的读写开销。
举个例子,一个典型的卷积+BN+ReLU模式:
// 未融合:三步走
conv_output = conv(input) // 写显存
bn_output = batch_norm(conv_output) // 读+写显存
output = relu(bn_output) // 读+写显存
// 融合后:一步到位
output = fused_conv_bn_relu(input) // 只读一次,只写一次
我在项目中遇到过,一个简单的ResNet-50模型,光这种Conv-BN-ReLU模式就有几十处。融合前后,推理速度能差30%以上。嗯,这个数字我记得很清楚。
为什么需要算子融合
你可能会问:不就是多读写几次显存吗?能有多大影响?
我跟你算笔账。GPU计算有个著名的「屋顶模型」(Roofline Model)。它告诉我们:
- 计算密集型操作:算得快,瓶颈在计算能力
- 访存密集型操作:算得慢,瓶颈在带宽
小算子恰恰属于后者。每个算子计算量不大,但都要读写显存。GPU的显存带宽虽然高,但跟计算速度比起来,还是差了好几个数量级。
| 操作 | 计算量 | 访存量 | 计算/访存比 |
|---|---|---|---|
| Conv 3x3 | 高 | 中 | 高 |
| ReLU | 极低 | 高 | 极低 |
| Elementwise Add | 低 | 高 | 低 |
看到没?ReLU和加法这种操作,计算量几乎为零,但访存量跟卷积差不多。这就是典型的「算得少、搬得多」。
避坑指南:我曾经天真地以为,只要算子实现得好,性能就不会差。后来发现,很多时候瓶颈不在计算,而在数据搬运。算子融合就是解决这个问题的。
除了减少访存,算子融合还有几个好处:
- 减少内核启动开销:每个算子都要启动一次GPU内核,启动本身就有延迟。融合后启动次数变少。
- 提高缓存利用率:数据还在缓存里,下一个算子直接用,不用再去显存取。
- 简化计算图:图更小,更容易优化。
算子融合的历史与发展
算子融合不是新鲜事。我入行那会儿,它就已经存在了。
最早期的深度学习框架,比如Caffe,其实没怎么考虑融合。那时候模型小,GPU也慢,瓶颈主要在计算上。后来模型越来越大,GPU越来越快,访存瓶颈就凸显出来了。
我记得大概是2016年左右,NVIDIA推出了cuDNN的融合API。那时候我还在做图像识别,第一次用上融合卷积,速度提升让我印象深刻。
发展脉络大致是这样的:
- 手工融合时代(2015-2017):工程师手动写融合内核,比如Conv+ReLU、Conv+BN+ReLU。效率高但工作量大。
- 编译器自动融合时代(2018-2020):TVM、XLA等编译器出现,能自动识别可融合的算子模式。我那时候参与过TVM的开发,说实话,自动融合的效果有时候不如手工,但胜在省力。
- 图级别融合时代(2021-至今):不仅融合相邻算子,还能跨层融合。比如把整个残差块融合成一个内核。这需要更复杂的分析和调度。
下面这张图展示了算子融合的核心逻辑:
现在的编译器,比如XLA和TVM,已经能自动做很多融合了。但说实话,自动融合还不够完美。有些复杂的融合模式,还是需要人工介入。
注意:不是所有算子都适合融合。融合后内核变复杂,寄存器压力增大,有时候反而会变慢。我踩过这个坑——把两个大卷积强行融合,结果寄存器溢出,性能反而下降了20%。
所以我的建议是:先用编译器自动融合,遇到瓶颈再手工调优。别一上来就手写融合内核,那是最后的手段。
好了,这一章就到这里。算子融合是个大话题,后面我们会深入讲各种融合策略和实现细节。