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 模型示意图 计算强度 (FLOPs/Byte) 0 10 100 1000 性能 (FLOPs/s) 0 50% 75% 100% 转折点 访存受限 带宽瓶颈 计算受限 算力瓶颈 逐元素操作 矩阵乘法 融合后 融合提升计算强度

这张图怎么看?我简单解释下:

  • 红色斜线是“带宽天花板”——受内存带宽限制
  • 红色水平线是“算力天花板”——受计算单元限制
  • 绿色点(逐元素操作)在左边,说明它被带宽卡住了
  • 橙色点(矩阵乘法)在右边,说明它被算力卡住了

我曾经踩过的坑: 有个项目把两个逐元素操作分开写,结果每个都在 Roofline 的左边。融合之后,计算强度翻了一倍,直接跨过了转折点,性能提升了 40%。

3.4 融合如何改变计算强度?

算子融合的本质,就是把多个低计算强度的操作合并成一个。合并后,总计算量不变,但访存次数减少了。

来看个具体例子。假设有两个操作:

  1. ReLU 激活函数:计算量 1 FLOP/element,访存 8 Bytes(读+写)
  2. 卷积层:计算量 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 的计算强度都低得可怜。我做了两步融合:

  1. Conv+ReLU 融合:把 ReLU 嵌入卷积内部,省掉一次读写
  2. ReLU+MaxPool 融合:在 ReLU 计算时直接做池化,再省一次读写

融合后的计算强度:

conv1_relu: 计算强度从 8.2 提升到 12.5
relu_pool:  计算强度从 1.1 提升到 3.8(虽然还是低,但好多了)

最终端到端性能提升了 28%。嗯,这就是数学带来的实实在在的收益。

注意: 不是所有融合都值得做。如果两个算子的计算强度都很高(比如两个大卷积),融合带来的访存收益很小,反而可能因为寄存器压力导致性能下降。我一般只对计算强度低于 10 的算子做融合。

总结一下:算子融合的数学基础,说白了就是用计算换访存。计算强度是衡量这个交换是否划算的标尺。下次你看到一段代码,先别急着写融合,掏出 Roofline 模型算一算——哪些算子在左边,哪些在右边,一目了然。

可可精品资料库