算子优化与显存协同调优实战 - 第3节
一、算子的两种“性格”:计算密集型 vs 访存密集型
做算子优化这么多年,我最大的体会就是:你得先搞清楚你的对手是谁。
什么意思呢?每个算子都有它的“性格”。有的算子天生爱算数,比如矩阵乘法、卷积——它们把数据拉进来,翻来覆去地算,算完才放出去。这种算子,我们叫它计算密集型。
有的算子呢?恰恰相反。比如元素级操作(像 ReLU、加法、乘法),或者某些 reduce 操作。它们几乎不怎么算,就是把数据从显存搬到寄存器,简单处理一下,再搬回去。这种算子,我们叫它访存密集型。
你可能会问:“知道这个有什么用?”
用处大了。我刚开始做优化时,花了两周去优化一个 ReLU 算子的计算逻辑,结果性能纹丝不动。后来才发现——瓶颈根本不在计算,而在显存带宽。说白了,你优化了一个它根本不需要的东西。
1.1 怎么区分?看计算密度
区分这两种算子,有一个很直观的指标:计算密度(Arithmetic Intensity)。
公式很简单:
计算密度 = 总计算量(FLOPs) / 总访存量(Bytes)
单位是 FLOPs/Byte。这个值越大,说明算子越“爱算”;越小,说明算子越“爱搬数据”。
我个人的经验是:
- 计算密度 > 10:大概率是计算密集型。比如大矩阵乘法(GEMM),密度轻松上百。
- 计算密度 < 1:基本是访存密集型。比如 ReLU,密度可能只有 0.1 左右。
- 中间地带(1~10):需要具体分析,看硬件特性。
一个常见的误区:很多人觉得“计算量大”就是计算密集型。不对。你得看计算量和访存量的比值。一个算子计算量很大,但访存量更大,它依然是访存密集型的。
1.2 两类算子的优化思路完全不同
搞清楚了算子的性格,优化方向就清晰了:
| 算子类型 | 瓶颈在哪 | 优化重点 |
|---|---|---|
| 计算密集型 | 计算单元(ALU) | 提高计算并行度、利用向量化指令、减少流水线停顿 |
| 访存密集型 | 显存带宽 | 减少访存次数、合并访存、利用缓存、数据复用 |
嗯,这里要注意:千万别用优化计算密集型的方法去优化访存密集型算子。我曾经见过有人给 ReLU 算子做循环展开、指令重排,折腾半天,性能提升不到 5%。后来我建议他把重点放在数据排布和访存合并上,直接翻了一倍。
二、Roofline 模型:一眼看穿算子的瓶颈
光靠感觉区分还不够。我们需要一个定量分析的工具。这就是 Roofline 模型。
Roofline 模型,说白了就是一张图。横轴是计算密度,纵轴是性能(FLOPs/s)。它画出了硬件的两个天花板:
- 计算天花板:硬件的最大算力(比如 GPU 的 TFLOPS)
- 访存天花板:硬件的最大带宽(比如显存带宽 GB/s)
这两条线交叉的地方,就是所谓的“拐点”。拐点左侧,性能受限于带宽;拐点右侧,性能受限于算力。
我的一个小习惯:每次拿到一个新的算子,第一件事就是算它的计算密度,然后扔到 Roofline 模型里看它在哪个区域。这能帮我快速判断优化方向,避免走弯路。
2.1 Roofline 模型的可视化
下面我用 SVG 画了一张典型的 Roofline 模型图。你可以直观地看到算子的位置和瓶颈:
你看,ReLU 和向量加法落在访存受限区,离计算天花板还远着呢。你就算把 GPU 的算力翻一倍,它们也快不起来——因为瓶颈在带宽。而大矩阵乘法(GEMM)已经顶到了计算天花板,这时候你该想的是怎么提高计算效率。
2.2 怎么用 Roofline 指导优化?
我一般分三步走:
- 定位:把算子放到 Roofline 图上,看它在哪个区域。
- 分析:如果是访存受限,看看能不能减少访存量(比如用更小的数据类型、做数据复用)。如果是计算受限,看看能不能提高并行度(比如用 Tensor Core、做算子融合)。
- 验证:改完之后再测一次,看它是不是往拐点方向移动了。
我曾经踩过的一个坑:有一次优化一个卷积算子,我拼命做计算优化,结果性能提升很小。后来用 Roofline 一分析,发现它其实在访存受限区。问题出在数据排布上——我用的 NHWC 格式导致访存不连续。改成 CHWN 之后,带宽利用率从 30% 提升到了 80%,性能直接翻倍。
三、实战:用 Roofline 分析一个简单算子
光说不练假把式。我们拿一个具体的例子来走一遍流程。
假设我们要优化一个 向量加法:C = A + B,每个向量有 1024 个 float 元素。
先算计算密度:
- 计算量:1024 次加法 = 1024 FLOPs
- 访存量:读 A (1024×4=4096 Bytes) + 读 B (4096 Bytes) + 写 C (4096 Bytes) = 12288 Bytes
- 计算密度 = 1024 / 12288 ≈ 0.083 FLOPs/Byte
这个值非常小。放到 Roofline 模型里,它妥妥地在访存受限区。
那优化方向就很明确了:
- 减少访存量:能不能用 half 精度?或者把多次向量加法融合成一个 kernel?
- 提高带宽利用率:保证访存是连续的,用向量化加载指令(比如 float4)。
我个人的经验是,对于这种极低计算密度的算子,算子融合往往是性价比最高的优化手段。比如把 A+B 和后面的 ReLU 融合成一个 kernel,这样 A 和 B 只需要读一次,中间结果不用写回显存。
一个小技巧:当你不知道从何下手时,先算计算密度。如果小于 1,优先考虑访存优化;如果大于 10,优先考虑计算优化。中间地带,那就得具体分析了。
四、总结
好了,这一章的核心内容就这些。我帮你捋一下:
- 计算密集型 vs 访存密集型:看计算密度,决定优化方向。
- Roofline 模型:一个可视化工具,帮你快速定位瓶颈。
- 优化原则:访存受限优化带宽,计算受限优化算力。
记住,做算子优化,方向比努力更重要。用 Roofline 模型找准方向,你的优化才能事半功倍。