3. 算子融合的数学基础:访存次数与计算强度的关系
聊算子融合,很多人上来就谈代码实现。但我个人习惯,先搞清楚数学本质。说白了,融合的核心就一句话:减少访存,提升计算密度。
你想想看,GPU算得再快,数据搬不动也是白搭。我见过太多项目,算子拆得挺漂亮,一跑起来性能惨不忍睹。查到最后,瓶颈全在内存带宽上。
3.1 访存次数:算子的“搬运成本”
每个算子执行时,都要从显存里读数据,算完再写回去。这个“读+写”的次数,就是访存次数。我习惯用 Bytes 来衡量。
举个例子,一个简单的向量加法:
// 向量加法:C = A + B
// 假设每个元素是 float32(4字节),向量长度 N
// 访存次数 = 读A(N*4) + 读B(N*4) + 写C(N*4) = 12N 字节
嗯,这里要注意:访存次数是硬成本。不管你用多牛的算子,只要数据量固定,这个数字就下不来。
核心公式:
访存次数 = 输入数据量 + 输出数据量
单位:字节(Bytes)
我在项目中遇到过一种情况:两个连续的卷积层,中间有个激活函数。如果分开写,激活函数要额外读写一次特征图。融合之后,激活函数直接在寄存器里算完,省掉了一次全局访存。别小看这一次,对于大特征图,能省几十MB的搬运量。
3.2 计算强度:算得快还是搬得快?
计算强度,英文叫 Arithmetic Intensity。定义很简单:
计算强度 = 总计算量(FLOPs) / 访存次数(Bytes)
单位是 FLOPs/Byte。这个值越大,说明算子越“计算密集”;越小,说明越“访存密集”。
为什么会这样?因为GPU里计算单元和内存单元是分开的。计算强度决定了瓶颈在哪:
- 计算强度高(比如矩阵乘法):瓶颈在计算单元,GPU算力吃满
- 计算强度低(比如逐元素操作):瓶颈在内存带宽,计算单元在“等数据”
我的经验: 计算强度低于 10 FLOPs/Byte 的算子,基本就是访存瓶颈。这时候融合的价值最大。
3.3 Roofline 模型:一眼看出瓶颈
Roofline 模型是我做性能分析时必看的工具。它把计算强度和性能(FLOPs/s)画在一张图上:
这张图怎么看?我简单解释下:
- 红色斜线是“带宽天花板”——受内存带宽限制
- 红色水平线是“算力天花板”——受计算单元限制
- 绿色点(逐元素操作)在左边,说明它被带宽卡住了
- 橙色点(矩阵乘法)在右边,说明它被算力卡住了
我曾经踩过的坑: 有个项目把两个逐元素操作分开写,结果每个都在 Roofline 的左边。融合之后,计算强度翻了一倍,直接跨过了转折点,性能提升了 40%。
3.4 融合如何改变计算强度?
算子融合的本质,就是把多个低计算强度的操作合并成一个。合并后,总计算量不变,但访存次数减少了。
来看个具体例子。假设有两个操作:
- ReLU 激活函数:计算量 1 FLOP/element,访存 8 Bytes(读+写)
- 卷积层:计算量 100 FLOPs/element,访存 12 Bytes(读输入+读权重+写输出)
分开执行时:
总计算量 = 1 + 100 = 101 FLOPs/element
总访存 = 8 + 12 = 20 Bytes/element
计算强度 = 101 / 20 = 5.05 FLOPs/Byte
融合之后(ReLU 嵌入卷积内部):
总计算量 = 100 + 1 = 101 FLOPs/element(不变)
总访存 = 12 Bytes/element(省掉了 ReLU 的读写)
计算强度 = 101 / 12 = 8.42 FLOPs/Byte
计算强度提升了 67%。这就是融合的数学魔力。
关键洞察: 融合不改变计算量,只改变访存次数。每减少一次全局访存,计算强度就提升一截。
3.5 实战中的计算强度估算
我平时做算子融合决策时,会先快速估算计算强度。方法很简单:
| 算子类型 | 典型计算强度 (FLOPs/Byte) | 瓶颈类型 | 融合优先级 |
|---|---|---|---|
| 逐元素操作(ReLU, Add, Mul) | 0.5 - 2 | 访存受限 | 高 |
| 降维操作(Pooling, Sum) | 1 - 5 | 访存受限 | 中高 |
| 小卷积(3x3, 小通道) | 5 - 20 | 混合型 | 中 |
| 大卷积(7x7, 大通道) | 20 - 100+ | 计算受限 | 低 |
| 矩阵乘法(大尺寸) | 50 - 500+ | 计算受限 | 极低 |
我的习惯: 拿到一个模型,先跑一遍 profiler,看每个算子的计算强度。凡是低于 5 的,我都标记为“融合候选”。然后按计算强度从低到高排序,优先融合最差的那些。
3.6 一个真实的融合案例
我记得有一次优化一个图像分类模型。模型里有这么一段:
// 原始代码(伪代码)
conv1_output = conv2d(input, weight1) // 计算强度 8.2
relu_output = relu(conv1_output) // 计算强度 1.1
pool_output = maxpool(relu_output, 2) // 计算强度 2.3
conv2_output = conv2d(pool_output, weight2) // 计算强度 7.8
你看,ReLU 和 MaxPool 的计算强度都低得可怜。我做了两步融合:
- Conv+ReLU 融合:把 ReLU 嵌入卷积内部,省掉一次读写
- ReLU+MaxPool 融合:在 ReLU 计算时直接做池化,再省一次读写
融合后的计算强度:
conv1_relu: 计算强度从 8.2 提升到 12.5
relu_pool: 计算强度从 1.1 提升到 3.8(虽然还是低,但好多了)
最终端到端性能提升了 28%。嗯,这就是数学带来的实实在在的收益。
注意: 不是所有融合都值得做。如果两个算子的计算强度都很高(比如两个大卷积),融合带来的访存收益很小,反而可能因为寄存器压力导致性能下降。我一般只对计算强度低于 10 的算子做融合。
总结一下:算子融合的数学基础,说白了就是用计算换访存。计算强度是衡量这个交换是否划算的标尺。下次你看到一段代码,先别急着写融合,掏出 Roofline 模型算一算——哪些算子在左边,哪些在右边,一目了然。