ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CPU Roofline模型全解析:从原理到绘图,看清性能天花板

CPU Roofline模型全解析:从原理到绘图,看清性能天花板

面对一坨密密麻麻的火焰图、perf stat 输出的几十个计数器、以及一次次改代码却提速不到 1.2 倍的诡异结果,我强烈建议你先停下来,别再盲目做微优化,而是建立一张属于这台 CPU 的 Roofline 模型。这东西就像一张性能“天花板图”,一旦画出来,你就会清楚地知道:当前程序离硬件极限还有多远,到底应该优化计算还是优化访存,如果今天只有两小时,突破口应该在哪里。

这篇文章我把 CPU 版的 Roofline 模型从原理到绘制的完整过程拆开,你跟着一步步来,最后能自己用 Python 画出一条带真实参数参考线的屋顶线,并把手里的程序作为一个个点映射上去。全文面向的是做 HPC 优化、高性能计算的开发者,也适合刚入门性能分析、想知道“我的程序为什么跑不满 CPU”的同学。

1. 面对一坨性能数字,为什么非画屋顶线不可

1.1 你需要的不是“调优技巧”,而是“天花板意识”

很多优化新手拿到一个耗时几十秒的程序,第一反应是加编译选项-O3 -march=native,然后测一测,没提升就换-funroll-loops,再不行就尝试手动向量化。这种做法不是完全错,但属于典型的“没有方向的瞎撞”。原因很简单:你根本不知道这台机器能给你什么,也不知道你的程序到底要的是什么。

Roofline 模型的厉害之处,是它把一台 CPU 最核心的两个能力,也就是“每秒能算多少浮点运算”和“每秒能从内存搬多少字节”,用一条简单的折线画在同一张图上。横轴是计算强度(Arithmetic Intensity,AI),单位是 FLOP/Byte,意思是每个字节的数据搬运能支撑多少次浮点运算;纵轴是可达性能(Attainable Performance),单位是 GFLOP/s。任何一个程序经过测量后都能在图上得到一个点,这个点如果贴着屋顶线,说明它已经把硬件发挥到极致;如果远在屋顶之下,说明瓶颈不是硬件,而是你的代码质量、并行度或者访存模式。

我见过太多人把时间浪费在“优化向量化”上,结果程序本身是典型的带宽型任务,比如流式计算y[i] = a * x[i] + y[i]。这种代码读一遍数组、写一遍数组,计算量很小,再怎么折腾 AVX512,性能也只能被内存带宽锁死。另一种情况是,程序明明是计算密集型的,但因为每次循环都去重新读一遍已经加载到 cache 的数据,导致实际表现严重偏带宽型。这两种情况如果不先画图,靠肉眼很难判断。

1.2 一张图让“优化目标”从模糊变具体

Roofline 模型把优化工作总结成一句话:先判断程序的点落在屋顶线的哪个区域,再决定优先优化什么。如果点落在陡峭的左侧(低计算强度区域),说明程序是带宽受限的,这时候加缓存分块、数据复用、非 temporal 访存,效果立竿见影;如果点落在屋顶线的拐点右侧且紧贴屋顶,说明是计算受限的,那么你要关心的事情是:有没有用到 CPU 的 FMA 指令?有没有用满 AVX 向量宽度?线程调度是否有负载均衡问题?

这类判断在日常优化中有大量实际意义。比如一次寒冬夜里调一个大规模稀疏迭代求解器,我用 Roofline 看出所有内核强度只有 0.3 FLOP/Byte 左右,带宽已经接近极限,于是果断放弃微调循环,直接改稀疏矩阵的存储格式和重排算法,最终把带宽效率从 60% 拉高到 85%,整体提速 1.8 倍。如果当时闷头去优化数学函数调用,可能连 10% 都省不出来。

这一节的结论是:Roofline 不是某种高性能计算竞赛里的理论模型,而是一个平凡但实用的决策工具。它把“性能调优”这种动态、模糊的工程问题,映射成了一张静态、直观的二维图。基于这台结论,接下来就进入实战,咱们把这张图一笔一笔画出来。

2. Roofline 的数学骨架:三条线、一个强度、一个屋顶

2.1 计算强度:把“每字节算多少”作为核心指标

计算强度是 Roofline 模型的灵魂。它的定义很简单:

[ I = \frac{W}{Q} ]

其中 (W) 是程序或内核完成的浮点操作次数(通常按 FLOP 计),(Q) 是程序与内存系统交换的数据量,单位是字节。这里的“内存系统”需要特别注意:在经典 Roofline 模型中,一般指的是 DRAM 层的数据搬运量,也就是数据从主存读入 CPU 或者从 CPU 写回主存的字节数。如果把中间各级 cache 的数据搬运也算进去,问题就变成“存储层级模型”,属于更细粒度的分析,但上手阶段先不用管。

举例来说,一个saxpy内核:

for (i = 0; i < N; i++) { y[i] = a * x[i] + y[i]; }

它读取了 x 的 N 个浮点数(4N 字节),读取 y 的 N 个浮点数(4N 字节),然后写回 y 的 N 个浮点数(4N 字节)。总访存量为 (4N + 4N + 4N = 12N) 字节。浮点操作数则是一次乘加操作算 2 次浮点运算(一次乘法一次加法),总共 (2N) FLOP。所以计算强度:

[ I = \frac{2N}{12N} = \frac{1}{6} \approx 0.167 \text{ FLOP/Byte} ]

这个数值小于 1,非常低,意味着每字节数据只能支撑不到 0.2 次浮点运算。你马上就能预感到这程序内存带宽一定是瓶颈。这就是计算强度的直观价值:它让你用一两个数字就知道程序“吃带宽”还是“吃算力”。

2.2 屋顶线的斜率与屋顶高度:从带宽瓶颈到计算瓶颈

Roofline 曲线由两根线构成:一条是水平线,代表这台 CPU 的峰值浮点性能 (P_{max}),单位 GFLOP/s;另一条是从原点出发斜率为 (b) 的直线,代表峰值内存带宽 (b)(单位 GB/s)。这条倾斜直线的含义是:如果程序的计算强度是 (I),那么它能实现的最大性能不会超过:

[ P_{attainable} \le \min(P_{max}, b \times I) ]

因为数据搬运量和计算量必须在一个时间段内完成。假设每秒可以从内存搬运最多 (b) 字节,而程序每字节只能带来 (I) 次计算,那么理论上每秒最多完成 (b I) 次浮点运算。但是就算这个值再高,也不能超过 CPU 本身的计算能力 (P_{max})。

把这两条线放在同一坐标系下,交点代表临界强度(ridge point):

[ I_{ridge} = \frac{P_{max}}{b} ]

这个点的含义是:当程序的计算强度恰好等于这个值时,带宽和算力同时达到饱和,处于完美平衡。低于它,程序属于带宽受限;高于它,程序属于计算受限。

举个具体数字。假设一台 CPU 单条内存通道的理论带宽是 25 GB/s,实际 STREAM 测得 20 GB/s;CPU 支持 AVX2 双发射 FMA,在 3.5 GHz 下单核的峰值浮点性能约 56 GFLOP/s。那么临界强度 (I_{ridge} = 56 / 20 = 2.8) FLOP/Byte。如果程序强度只有 0.5,那么就算把算术单元全用上也白搭,因为内存搬不过来;这时候优化访存模式、减少数据搬运,才是让点向右侧移动的关键。

很多教材喜欢画“多层屋顶线”,比如分别针对标量、SSE、AVX、AVX512 画不同高度的水平线,每条水平线对应不同-march级别的指令集。CPU 版的 Roofline 如果用多层屋顶,复杂度和表达能力都有提升,但对于首次建立模型的人,我建议先画整体单层屋顶,后面再按需分层。基础模型永远是从单层开始的。

2.3 别忘了“可达性能”和“峰值性能”之间的差

理论数学公式摆在这儿,但实际硬件有个残酷的现实:没有任何实测程序能做到 100% 的峰值带宽或者 100% 的峰值浮点性能。STREAM 测试通常只能跑到理论带宽的 80% 左右,计算密集型的高性能代码能跑到峰值 FLOPs 的 60%-90% 已经相当不错,峰值只是理论上限,Roofline 的屋顶线应当画在“实际可达到的峰值”上,否则会给人一种错误的乐观预期。

具体到实践,我通常的做法是:用 STREAM benchmark 实测的内存带宽作为屋顶斜率基准,用峰值浮点性能乘以一个 0.8~0.9 的经验系数作为水平屋顶。这样画出的模型更贴近真实“极限”。后面写代码时你会看到,这类务实指标让优化决策更有操作性。

3. 手把手测出 CPU 的两个天花板参数

3.1 峰值 FLOPs:不要盲目相信宣传单,自己算一遍

建立 Roofline 模型的第一件事,是测量或推导出这台 CPU 的峰值浮点性能。最靠谱的方法不是去搜索引擎查“某某 CPU FLOPs”,因为厂商给的数字通常用满全部核、最高 Turbo 频率和 FMA 指令集,说不定还带稀疏算法加速,未必能直接套用到你的程序上。更可靠的是自己根据 CPU 规格推算一遍。

对于现代 x86 CPU,峰值浮点每秒运算次数可以按如下公式估算:

[ P_{max} = \text{核数} \times \text{每核每周期浮点操作数} \times \text{CPU频率} ]

其中“每核每周期浮点操作数”取决于指令集。以 Intel/AMD 的 AVX2 为例,一个 256 位向量可以容纳 4 个双精度浮点数(或者 8 个单精度)。FMA 指令在一个周期内完成一次乘加,相当于 2 次浮点操作。如果普通标量乘法需要 1 个 FLOP,那么 4 颗执行单元并行执行 FMA 时,每周期可以完成 (4 \times 2 = 8) 次双精度浮点操作。不过这是简单基于向量宽度和 FMA 计算的“理想值”,不少 CPU 实际还受执行端口数量限制,所以更保险的方法是用一个小基准测一下。

我常用的是下面这段 C 代码,编译时开启-O3 -march=native,利用 OpenMP 并行,尽量贴近 FMA 的极限。

#include <stdio.h> #include <omp.h> void fma_loop(double *a, double *b, double *c, int N) { #pragma omp parallel for for (int i = 0; i < N; i++) { for (int k = 0; k < 10000; k++) { a[i] = a[i] * b[i] + c[i]; } } } int main() { int N = 1 << 20; double *a = (double*)malloc(N * sizeof(double)); double *b = (double*)malloc(N * sizeof(double)); double *c = (double*)malloc(N * sizeof(double)); // 初始化略 double start = omp_get_wtime(); fma_loop(a, b, c, N); double end = omp_get_wtime(); double flops = (double)N * 10000 * 2 * omp_get_max_threads(); printf("GFLOP/s = %f\n", flops / (end - start) / 1e9); }

这段代码每个 a[i] 被反复更新,数组总大小 24 MB,足够把 L3 缓存之外的内存也压一遍。不过注意,由于它一个数组被反复访问,实际可能被 cache 吸收一部分访存,但它的设计目标是压计算指令吞吐,所以并不是在测带宽,而是在测 FLOPs 的实践上限。实测得到的数值比我基于规格推算的峰值低 10%~20%,这很正常,因为 RAM 情形依赖具体的迭代指令流。

如果你不想写代码,还可以直接读 CPU 的规格参数,比如象棋跑分、linpack之类,但一定要关注测试用的线程数和频率。用lscpu看核心数、看 CPU 主频,再用turbo状态校准,这些细节都会影响精度。

3.2 实测内存带宽:STREAM 基准的正确跑法与常见陷阱

内存带宽这个值,标准做法是用 STREAM benchmark。它的核心是一组简单的内存操作:

  • copy: (c[i] = a[i])
  • scale: (b[i] = s \cdot c[i])
  • add: (c[i] = a[i] + b[i])
  • triad: (a[i] = b[i] + s \cdot c[i])

运行后它会报告每项操作的平均带宽(MB/s)。这个数值基本代表了你用普通memcpy风格代码时能达到的“实际最大带宽”。

STREAM 的正确跑法有几个关键点:

  1. 数组规模必须显著超过 CPU 的最大 cache 容量。比如一块 CPU 有 32 MB L3,那么数组长度建议设置成最大 cache 的 4~6 倍,保证数据真的从内存中来。跑 STREAM 时默认数组长度是 2,000,000 个 double(16 MB),对现代 CPU 通常偏小,我一般用-DSTREAM_ARRAY_SIZE=80000000让它变成 640 MB,确保绝对不依赖 cache。

  2. 使用与目标应用一致的线程数。STREAM 支持 OpenMP,可以用-OMP_NUM_THREADS=核心数跑满核,也可以单独跑单核版本,两者测出的带宽差异非常大,对 Roofline 的屋顶斜率影响直接。

  3. 注意 NUMA 内存分配。多路服务器上默认 STREAM 跑出来的带宽可能因为所有线程只绑定在一个内存节点上而低于理论值。尽量用numactl --interleave=all或者--physcpubind合理绑定线程。

  4. 结果取多次运行的最大值,而不是平均值。因为内存带宽受系统噪声和频率波动影响很大,取最大值更能代表硬件天花板。

编译命令很简单:

gcc -O3 -fopenmp -DSTREAM_ARRAY_SIZE=80000000 -o stream stream.c OMP_NUM_THREADS=16 ./stream

观察 STREAM 输出中的Triad值即可。比如双通道 DDR4-3200 的理论带宽约 51.2 GB/s,实测 Triad 通常能跑到 45~48 GB/s。一旦拿到这个数值,就相当于拿到了 Roofline 模型里最关键的斜率。

3.3 用硬件计数器验证而不是靠口头估计

如果想把峰值浮点性能也评估得更准,可以借助perf stat来统计实际运行的 FLOPs,然后用实测运行时间反推。比如对一个已知浮点数量的程序:

perf stat -e fma_retired,avx_aux,avx_instr,inst_retired ./myapp

得到 FMA 指令数后,再根据每条 FMA 指令的向量宽度(比如 AVX2 是 4 个 double)换算成 FLOPs。这个数比“我估摸大概 60 GFLOP/s”可靠得多。不过初学者一开始不必深挖硬件计数器,先把模型的原始参数用“自己推导 + STREAM”得到,等后期定位优化效果时再用 perf 也不迟。

4. 画一条属于自己的 Roofline 曲线

4.1 用 Python 三步画出屋顶线

拿到 P_max 和 b 后,画图就非常快了。我用的是 Python + Matplotlib,不需要任何重型依赖。下面的代码请直接保存为roofline.py使用。

import numpy as np import matplotlib.pyplot as plt # 参数:根据自己实测结果替换 P_max = 280.0 # GFLOP/s,多核AVX2 FMA实际峰值 bw = 45.0 # GB/s,STREAM Triad实测带宽 I = np.logspace(-3, 3, 500) # 计算强度从0.001到1000 attainable = np.minimum(P_max, bw * I) plt.figure(figsize=(8, 6)) plt.loglog(I, attainable, linewidth=2, label='Roofline (CPU)') # 标注临界强度点 I_ridge = P_max / bw plt.axvline(I_ridge, color='gray', linestyle='--', alpha=0.6) plt.text(I_ridge * 1.2, P_max * 0.5, f'Ridge Point: {I_ridge:.2f} FLOP/Byte', rotation=90, va='center', fontsize=9) plt.xlabel('Arithmetic Intensity (FLOP/Byte)') plt.ylabel('Attainable Performance (GFLOP/s)') plt.title('CPU Roofline Model') plt.grid(which='both', alpha=0.3) plt.legend() plt.tight_layout() plt.savefig('roofline_cpu.png', dpi=150)

这段代码只有几十行,但以下几个细节值得注意:

  • 横纵坐标都采用对数刻度,因为计算强度和性能跨度往往有几个数量级,线性坐标会把低强度区域压扁,看着不明显。对数坐标是 Roofline 模型的标配。
  • 两个关键参数用变量放在开头,后续如果换了机器,只改两个数字就能画出新图。
  • 临界强度点用竖虚线标注出来,一眼就能知道当前程序属于哪个区域。

4.2 多核与 SIMD 对屋顶线的影响:CPU 版本的专属变体

同一颗 CPU,用单线程和用完整 16 线程,绘制的屋顶线差别巨大。因为多核下的总内存带宽虽然也会提升,但往往不会随核心数线性增加,比如两颗 CPU 在单核时内存带宽只有 12 GB/s,16 核全开则可以到 80 GB/s,而峰值浮点性能会从 12 GFLOP/s 变成 200 GFLOP/s。这意味着不同线程数下的临界强度完全不同,所以当你分析多线程程序时,一定要使用“与该程序实际线程数相匹配的屋顶线”。

同样,SIMD 指令集的宽度也会改变屋顶高度。一台支持 AVX512 的 CPU,单核浮点峰值比只跑 SSE 高 4 倍,但内存带宽不变。于是这类 CPU 的临界强度会右移,更多程序会落到带宽受限区。这在实践中是一个非常深刻的洞察:每次引入更宽的 SIMD,本质上就是把屋顶的水平线抬得更高,而很多原本计算受限的程序会因此慢慢变成功带宽受限的程序,于是接下来优化的重心就必须从向量化转向数据驻留和带宽效率。

为了反映这种差异,可以在一张图上画多条屋顶线:单核标量、单核 AVX2、多核 AVX2 等。这是 CPU 版 Roofline 的最常见形式。绘图逻辑就是循环不同的 P_max 和 b,每次用同一数学模型生成一条线,只是参数不同。

我实际做过类似的图,左侧低速率的屋顶线来自单核 SSE,中段是单核 AVX2,再往上是多核 AVX2,最后全核带 FMA 的水平线最高。应用点只有落在最上面的那条线上,才说明你榨干了 CPU 的每一滴性能。

5. 把真实程序钉到图上:从性能计数器到计算强度

5.1 用 perf 统计指令数/事件,算出程序的实际强度

有了屋顶线,下一步是要把你正在调优的程序表示成图上的一个点。这个点的横坐标是程序的计算强度,纵坐标是它实际达到的 GFLOP/s。

计算强度需要两个数据:浮点操作数和访存字节量。前者可以用性能计数器近似。Linux 下最方便的工具是perf stat,常见计数器名字如下:

  • fma_retired:退休的 FMA 指令数量,每条按向量宽度换算 FLOPs。
  • fp_arith_inst_retired/fp_arith_simd_inst_retired:现代 CPU 上浮点算术相关指令的计数,有的还区分标量、128 位、256 位、512 位指令。
  • cache-misses/l3_cache_misses:可用于估算内存层级的存取次数。
  • instructions/cycles:用于流水线统计。

一条典型的测量命令是:

perf stat -e instructions,cycles,fp_arith_simd_inst_retired.256b,cache-misses ./myapp

注意:不同架构的事件名可能不同,建议先用perf list查看当前 CPU 支持的具体事件名。拿到 FP 指令数后,做 FLOPs 换算:

  • 一条 256 位 AVX 指令处理 4 个双精度(或 8 个单精度)和 4 个乘、4 个加,等价于 8 次浮点操作。
  • 一条标量 FMA 指令等价于 2 次浮点操作。
  • 一条 512 位 AVX512 指令如果包含 FMA,等价于 16 次双精度浮点操作。

如果你的循环代码比较规整,也可以直接从逻辑上统计 FLOPs。比如矩阵乘法 C[M][N] += A[M][K] * B[K][N],每个输出元素需要 K 次乘加,所以总 FLOPs 约 (2 \times M \times N \times K)。这种方式简单可靠,只要源码是你自己写的,不会引入计数器解析的误差。

访存字节量的估算同样重要。估算思路是:用cache-misses粗略代表从 DRAM 读数据的事件数,每次 cache miss 实际搬运一个 cache line(通常 64 字节)。写数据要看你使用的是普通写(最终会回写)还是无临时写(直接写)。普通写也算一个 cache line 的写带宽。一个更粗略但常见的做法是直接用程序的数据结构大小乘以访问次数,比如流式程序就按遍历数组的总字节数算。由于 Roofline 的目的不是做极精确的硬件建模,而是提供量级判断,所以这种估算误差在 2~3 倍以内都可以接受,因为它们反映的是“程序本质属性”,而不会改变它落在屋顶左侧还是右侧。

5.2 读图:你的程序是“带宽型”还是“计算型”?

把横纵坐标都算出来后,立马在图上打点。我习惯写一个小脚本批量处理多个内核,比如把单核版本、多核版本、不同分块策略分别打上不同颜色,一眼就能看出优化措施的效果。

读图的逻辑可以拆成以下几步:

  1. 如果点在屋顶右侧且远离屋顶,比如纵坐标只有屋顶的 30%,说明程序是计算密集型,但并没有把计算单元吃满。这时候应该查编译器生成的指令是否包含 FMA、是否用了 AVX、是否因为依赖链导致每周期执行的指令数太低。
  2. 如果点在屋顶左侧且远离屋顶,说明程序访存强度低又没吃到带宽,可能存在大量 cache miss、伪共享、或者内存随机访问。优化方向是空间局部性、数据结构紧凑化、分块缓存。
  3. 如果点贴着屋顶线,说明硬件资源已经饱和,这时候再优化这条内核的收益会非常有限,不如从算法层面减少 FLOPs 或者数据搬运量。

举个例子,我调过一个稀疏矩阵向量乘(SpMV),一开始的强度只有 0.2 FLOP/Byte,点在屋顶左侧且低于屋顶 40%。于是我做了重新排序和压缩存储,把访存连续化,实测带宽利用效率提升,强度的确没多大变化,但点往上走贴近屋顶了,耗时降了 1.6 倍。这就是“在带宽受限区优化访存”典型收益。

6. 一个实例:矩阵乘法与稀疏向量到底差在哪

6.1 密集矩阵乘:理论上更接近计算密集区,但实际需要调优

以经典的 (C = A B) 为例,(M=N=K=1024) 的双精度矩阵乘法。总 FLOPs 为:

[ 2 \times 1024^3 \approx 2.1 \times 10^9 ]

如果数据全部放在内存中,一次访存就把所有 A、B、C 三个矩阵的各 8 MB 读一遍,总数据量约 24 MB。计算强度为:

[ I = \frac{2.1 \times 10^9}{24 \times 10^6} \approx 87 \text{ FLOP/Byte} ]

这个强度远大于大多数 CPU 的临界强度(通常 1~10),所以矩阵乘法属于天然的计算密集型算法,理论优化核心方向一定是提高计算指令的效率,而不是压内存带宽。这也是为什么矩阵乘法基本都要搬出BLAS库,其中一个关键原因是 BLAS 能通过分块把数据复用到极致,实际访存量比朴素三重循环低得多,强度变得更高,甚至变成 100 FLOP/Byte 量级。

实测表现上,普通的 (i-j-k) 三重循环写的矩阵乘跑出来的点大概率不在屋顶上,而是明显偏下。原因不是数据带宽的问题,而是循环结构太差,没有复用寄存器,也没有用 SIMD,计算单元效率极低。此时 Roofline 判断它“计算受限”并不意味着你已经把计算能力用满了,而只是说硬件理论上限由计算能力决定,你的代码离极限还远。要做的仍旧是优化计算路径、展开、向量化。

6.2 稀疏操作:强度低得吓人,优化内存布局才见效

反过来看 SpMV,每行为非零元素做一次乘加读取,同时读取向量 x。假设每行平均 10 个非零元素,双精度值 + 列号各占 8 字节和 4 字节(如果用 32 位索引),加上 x 访问和 y 的写回。粗略估算一个非零元素的计算量 2 FLOP,而访存量至少 12 字节,计算强度:

[ I = \frac{2}{12} \approx 0.17 \text{ FLOP/Byte} ]

比 Roofline 的临界强度低十几倍。这就注定 SpMV 无论怎么加 SIMD、怎么展开循环,都不可能达到很高的 GFLOP/s,因为内存带宽约束了性能上限。优化方向应该是减少非零数据存储开销(例如用 CSR 的优化 variant、用 16 位索引、对 x 做 cache blocking)、或压缩向量访问的自定义格式,这些办法都不能提高强度,但可以让程序在带宽受限屋顶下跑得更接近极限。

把这两个例子放到同一张 Roofline 图上,你会发现矩阵乘法的点远远落在右侧高处,SpMV 的点则被“钉”在左侧低处。这种视觉冲击比任何文字解释都更能让你理解“为什么不同算法优化策略完全不一样”。

6.3 实际优化验证:以点移动方向作为指标

我曾经给一个模拟计算项目做过小型实证:先用朴素算法跑一遍矩阵乘和 SpMV,算出各自的点,然后分别做针对性优化。矩阵乘优化使用-O3 -march=native并改用分片循环,点从纵坐标 20 GFLOP/s 升到 180 GFLOP/s,逼近屋顶线,但横坐标没变多少;SpMV 改用压缩的本地索引并融合写,点从带宽屋顶下 28% 升到 82%,横坐标也从 0.17 提到 0.21。这两条路径的优化工作完全不同,但验证标准都是“点的坐标是否向屋顶移动”。如果工作中你发现某个优化耗时多但点没动,甚至横纵坐标都没变,那基本可以判断这次优化没触及瓶颈,赶紧换个方向。

7. 建立 Roofline 模型时最容易翻车的四件事

7.1 峰值 FLOPs 的估算翻车:忽略 FMA、Turbo、核数

第一个坑是拿着 CPU 标称主频算峰值,但忘记 Turbo Boost 睿频的存在。现代 CPU 全核睿频一般低于单核睿频,而且温度墙、功耗墙都会让实际频率浮动。比如一颗标称 3.8 GHz 的 CPU,全核满载可能只有 3.2 GHz。如果不注意,你画出的屋顶线会虚高 20%,用这个作为参照系,很容易得出“程序只用了 60% 峰值”的错误结论。

第二个坑是忽略 FMA 指令。早期 SSE 时代一个周期做一次乘或一次加,算峰值时只按 1 次操作/周期/单元。但现代 CPU 有了 FMA,乘加合并成一条指令,每周期能完成 2 次浮点操作。如果你只按基础 SSE 宽度的 1 倍算,屋顶会明显偏低,很多计算受限程序会被错误地归类为“已经接近峰值”。我用英特尔的实测算过,忽略 FMA 会让峰值估算低一半,这是影响最大的一个失误。

第三个坑是核数。多核时峰值浮点性能基本按核数线性扩展,但内存带宽扩展不是线性的。所以你要特别注意给模型标注场景:这是“单核模型”还是“16 核模型”,不同场景画出来的屋顶线完全不是一回事。

7.2 带宽测量被缓存欺骗,或反被超线程拖垮

STREAM 测量如果数组太小,比如只有 2 MB,被测对象就完全落在 L3 或者 L2 里,报出来的“带宽”能跑几百 GB/s,这其实是缓存带宽,根本不能画 Roofline。正确做法是让数组总量远超最后一级缓存。我习惯使用 512 MB 到 1 GB 的数组进行测试,同时在结果中注意每项报告的带宽是否随数组尺寸变化,如果变小说明起效了。

另一个坑是超线程。超线程会在物理核之间共享执行资源,但不同核的并发访问对内存控制器压力很大。如果你用OMP_NUM_THREADS=物理核数*2去跑 STREAM,可能带宽反而比只用物理核数更低,因为逻辑核竞争导致频繁切换,所以通常建议绑定物理核,而不是全部逻辑处理器。

7.3 计算强度里的“字节”到底按什么算——有符号与无符号

这是初学者最容易懵的地方。Roofline 的横轴计算强度,它的分母 Q 是该从“主存-缓存接口”上统计的字节数,而不是程序源码里sizeof(double)*N那么简单。举例来说,y[i] = a * x[i] + y[i],如果 y 先 write-allocate,写操作会先读入一个 cache line,再修改,最后回写。这里实际访存量可能是 (4N)(x 读)+ (4N)(y 读,因为 write-allocate)+ (4N)(y 写回)总共 12N,而如果你用了 non-temporal store 来避免写分配,则可能少掉 4N 的读,变成 8N。同样的计算逻辑,计算强度会从 0.167 变成 0.25,这足以改变你把它判断为“带宽极窄型”还是“中等带宽型”的结论。

因此,当你看到某个程序计算强度报告是 0.3,不要直接跑去改算子顺序,先自问:我统计访存字节时有没有算上写分配的额外开销?不同 CPU 和编译器会有不同策略,如果不确定,宁可采用偏保守(较大访存量)的估计,这样不会得出过于乐观的结论。

7.4 只画一条屋顶线,没有画多核/AVX 的“多层屋顶”

最后这个坑更多是绘图层面。CPU 版 Roofline 与传统单处理器版本相比,一个重要特性就是“屋顶不止一层”。用多线程时,水平屋顶升高;用 AVX512 时,水平屋顶进一步升高;但带宽屋顶通常只和内存控制器有关,多核时斜率会变陡,但并非线性。如果你只画一条单核 SSE 屋顶线,然后拿一个全核 AVX512 程序去对照,哪怕程序性能再强,点也永远在屋顶以上,这就闹笑话了。

我的建议是,建图时用三种配色分别画三层屋顶线:单核标量、单核 AVX2、全核 AVX2(如果支持 AVX512 再叠加一条)。然后你把自己的程序按照线程数和指令集标到对应的屋顶底下。分析时只和“同场景屋顶线”做对比,这样才会得到有效结论。其实,不同的编译器选项、不同的指令集扩展,本质上改变了程序可用的屋顶高度。想要看-mavx2对程序的影响,就分别用两种编译方式跑同一程序,看它的点是否在同一屋顶下移动。


到这里,你已经完整掌握了 CPU 版 Roofline 模型的建立流程。最后再分享一个我自己的小习惯:我不是等程序写完了才画 Roofline,而是每写一个重要计算内核,就顺手把它的强度和实测性能点更新到图上,长期维护这张图。这样做的好处是,项目里所有核心模块的性能天花板一目了然,看到新需求时第一反应不是“再去网上搜一段优化技巧”,而是“我这张图告诉我,这个模块应该属于哪一侧,它的优化预算应该是多少”。很多连续调优的项目,最终形成的就是一张布满点、注满注释的 Roofline 图,它比任何优化报告都诚实、都清晰。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进