
做性能优化的人迟早会被一个问题困住代码明明改得很努力但瓶颈到底在哪我见过太多人盯着 profile 数据瞎猜优化方向错了越改越慢。今天聊的 Roofline 模型就是专门用来治这个毛病的。它能在一个坐标系里同时画出 CPU 的理论计算上限和内存带宽上限然后把你的程序当成一个点放进去一眼就能看出它到底是“算不过来”还是“数据喂不上”。这篇文章就是一份完整的 CPU Roofline 模型手把手建立指南从原理、硬件参数获取、程序特征采集到实际绘图和瓶颈解读每一步都有具体的命令、公式和避坑经验。适合跑科学计算、做高性能优化、或者单纯想把程序性能吃透的开发者。1. Roofline 模型到底在衡量什么1.1 两个天花板计算峰值与内存带宽Roofline 模型本质上是在回答一个问题一个程序能跑多快究竟是被计算单元限制还是被内存系统限制任何 CPU 程序运行都要经历“从内存取数据 → 放进寄存器/缓存 → 做算术运算 → 把结果写回内存”这个过程。而这个过程的速率被两个硬件指标卡死计算峰值Peak PerformanceCPU 每秒钟最多能完成多少次浮点运算单位一般是 GFLOPS每秒十亿次浮点运算。内存带宽Memory BandwidthCPU 每秒钟能从内存子系统读取或写入多少字节单位是 GB/s每秒十亿字节。你可以把一个 CPU 核心想象成一家餐厅的后厨灶台计算单元炒菜再快如果传菜通道内存总线太窄食材送不进来翻台率照样上不去。反过来传菜通道再宽灶台数量不够菜也出不去。真实的程序性能永远被这两个环节中更慢的那一个锁死。现代 CPU 有一个很鲜明的特点单核浮点能力因为 SIMD单指令多数据技术的发展被拉得极高但内存带宽的提升速度远远跟不上。结果就是大量“看起来应该吃 CPU”的程序真正卡住的其实是内存带宽。Roofline 模型的价值就在于它把这两个限制同时画在一张图上让你不需要猜直接看图就知道瓶颈在哪。1.2 运算强度连接计算和访存的性价比要把计算峰值和内存带宽统一到一张图里需要引入一个桥梁概念运算强度Arithmetic IntensityAI。运算强度的定义非常直白AI 浮点运算次数FLOPs / 访问内存字节数Bytes单位是 FLOPs/Byte。它表示“每从内存里拿一个字节的数据程序平均要做多少次浮点运算”。这其实就是一个性价比指标花同样的访存代价赚回来多少计算量。举个生活化的例子。你有一个 100 平米的工作室里面有一台造价高昂的加工设备CPU设备旁边有一个传送带内存带宽原料通过传送带送进来设备加工完再送走。如果一道工序只需要传送带送来 1 公斤原料加工 5 分钟那 AI 就高设备计算利用率高如果送来 1 公斤原料只能加工 30 秒那 AI 就低你大概率是在等原料访存的路上浪费了设备性能。运算强度的单位看起来抽象但它是连接硬件参数和程序特征的枢纽。只要算出程序的 AI再结合 CPU 的峰值性能和带宽就能立刻判断这个程序属于哪一类计算密集compute-bound还是访存密集memory-bound。1.3 转折点ridge point 为什么重要现在把两个硬件参数放到一起。想象一张坐标图横轴是运算强度 AI纵轴是可实现的性能GFLOPS。CPU 的程序性能不可能超过两条线围成的区域当 AI 很小的时候程序能跑多快取决于“数据能喂多快”也就是性能 带宽 × AI。横轴越大纵轴越高这是一条从原点出发的斜线斜率就是内存带宽。当 AI 很大的时候程序能跑多快取决于“CPU 能算多快”也就是性能封顶在峰值 GFLOPS这是一条水平线。两条线交汇的那一点叫做转折点ridge point。它的横坐标 峰值性能 / 内存带宽纵坐标 峰值性能。这个点是最关键的信息。如果你的程序运算强度小于转折点它位于斜线区域增加计算优化没用必须优化访存如果大于转折点它位于水平线区域瓶颈是计算能力这时候才值得去抠浮点指令数、向量化程度和 CPU 指令流水线效率。用数字说话假设某台机器的双精度峰值是 3000 GFLOPS实测内存带宽 300 GB/s转折点就是 3000 / 300 10 FLOPs/Byte。你的程序 AI 是 2那它在 2 × 300 600 GFLOPS 这个“斜率线”上就算你把计算效率打磨到 100%也顶多跑到 600 GFLOPS离 3000 差得远。这时候的优化方向应该转向减少访存数据压缩、时间局部性复用、分块而不是折腾微指令。顺便说一句Roofline 模型可以按缓存层级拆成多层L1 Cache Roofline、L2/L3 Roofline、DRAM Roofline。不同的缓存级别有不同的带宽可以更精细地分析数据到底卡在哪一层。CPU 上最常见的分析目标是 DRAM 这一层因为规律性访问的大块数据最终都要过 DRAM本文先聚焦这一层把主线打通。2. 动手前的准备拿到 CPU 的两张“底牌”画 Roofline 图得先知道两个硬件参数理论浮点峰值和实测内存带宽。这俩参数新手最容易搞错所以我把获取的完整流程放在最前面。2.1 理论峰值 FLOPs 怎么算理论上峰值 FLOPs 可以直接查 CPU 厂商提供的规格表但我建议自己也学会估算因为查到的数字往往是“理想中的理想”实际跑起来会因为睿频、AVX 降频、内存竞争等因素大打折扣。估算公式如下理论峰值GFLOPS 物理核心数 × 主频GHz × 每核心每周期浮点运算次数FLOPs/cycle其中“每核心每周期浮点运算次数”要看 CPU 支持的指令集宽度和是否支持 FMA融合乘加一条指令完成 a×bc。以最常见的 x86 平台为例双精度浮点场景指令集类型向量位宽每周期双精度运算次数无 FMA每周期双精度运算次数含 FMA标量指令1 个 double12SSE2128 位2 个 double24AVX2256 位4 个 double48AVX-512512 位8 个 double816核心逻辑是FMA 一条指令同时算一次乘法和一次加法所以相比普通乘法/加法指令运算次数翻倍。然后SIMD 宽度决定了每条指令同时处理多少个双精度数。举一个实际的例子。假设某台双路服务器每颗芯片 32 物理核心基频 2.4 GHz睿频 3.8 GHz支持 AVX-512 FMA。按最高睿频估算每核心每周期含 AVX-512 FMA16 FLOPs/cycle单颗芯片峰值32 核 × 3.8 GHz × 16 1945.6 GFLOPS双路整机约 3891 GFLOPS也就是 3.9 TFLOPS但注意这个数值几乎不可能稳定跑到。因为 CPU 进入重负载 AVX-512 指令时功耗和发热飙升实际运行频率会降到 3.0 GHz 甚至更低。所以我更推荐把“理论峰值”用在 Roofline 图上时动手先用一个浮点密度极高的微基准比如稠密矩阵乘的优化库跑一轮实测一个“实际可达峰值”。提示画 Roofline 图时我们只关心“天花板”的位置不需要把峰值算到小数点后两位。算错了 30% 没关系离谱了 300% 才有问题。关键是你自己清楚这个数是怎么来的后面分析程序时心里才有底。2.2 实际带宽怎么测内存带宽不建议用理论值建议实测。理论带宽计算方法是内存频率 × 通道数 × 位宽但真实情况受内存控制器效率、ECC 校验、NUMA 拓扑、读/写模式影响很大理论值和实测值差 20% 以上很常见。最简单可靠的测带宽工具是 STREAM 基准测试这是一套公开的标准测试程序很多机器上直接就有。STREAM 会跑四类典型访存模式Copy、Scale、Add、Triad其中 Triad 的结果a[i] b[i] scalar × c[i]是最常用来代表峰值带宽的数字。编译并运行的命令大概是wget https://example.org/stream.tar.gz # 换成你本地可用的源码包路径 tar xzf stream.tar.gz cd stream cc -O3 -DSTREAM_ARRAY_SIZE80000000 -DNTIMES20 stream.c -o stream ./streamSTREAM_ARRAY_SIZE这个宏要足够大确保测试数组远大于 CPU 最后一级缓存比如 200MB 以上测到的才是真实 DRAM 带宽NTIMES20表示每项测试重复 20 次取最优值避免噪音。跑完看 Triad 那一栏的数字单位是 MB/s记下来除以 1000 就得到 GB/s。如果环境里没有 STREAM也可以自己写一段微基准测试核心是让循环体只做简单的加减乘不引入太多计算让执行单元不成为瓶颈#include stdio.h #include stdlib.h #include time.h #define N (16 * 1024 * 1024) // 每个数组 128MB双精度 double a[N], b[N], c[N]; int main(void) { for (int i 0; i N; i) { a[i] 1.0; b[i] 2.0; c[i] 0.0; } double start (double)clock() / CLOCKS_PER_SEC; for (int rep 0; rep 10; rep) { for (int i 0; i N; i) c[i] a[i] b[i]; } double end (double)clock() / CLOCKS_PER_SEC; double bytes 3.0 * N * sizeof(double); // 读2个数组写1个数组 double total_time end - start; double bandwidth (bytes * 10) / total_time / 1e9; // GB/s printf(Bandwidth: %.2f GB/s\n, bandwidth); return 0; }这个写法很粗糙但适合手边没工具时应急。需要注意单线程跑这个循环一般只能跑到一个内存通道的带宽要用多线程版本比如开满物理核心每个线程处理一段才能测出接近真实的多核内存带宽。实际测完我得到的经验是实测带宽通常只有理论带宽的 70%85%。如果要画 Roofline直接用实测值别用理论值。2.3 硬件参数检查清单在开始画图之前把下面这张清单过一遍能省掉后面很多返工参数获取方式注意事项物理核心数lscpu排除超线程逻辑核Roofline 算峰值时用物理核更贴近实际基频/睿频lscpu或厂商规格建议先用基频估再看程序的向量使用情况判断是否降频是否支持 AVX2/AVX-512/FMAlscpu的 Flags 字段FMA 对峰值影响巨大漏看会翻倍式偏差实测峰值 GFLOPS用优化后的稠密矩阵乘基准跑一轮不必追求极致接近量级即可实测内存带宽STREAM Triad用多线程、大数据集、开满核心跑这张清单里最容易踩的坑是“核心数”误算成带超线程的逻辑线程数。Roofline 模型关注的是硬件浮点执行单元的数量和频率超线程只是让单核同时跑两个逻辑流并不增加每周期指令吞吐量所以算峰值时不应该把逻辑线程数乘进去。3. 分析程序的访存与计算特征硬件参数拿到手接下来要处理程序本身了。这是整篇文章里最“手工”的部分也是最容易出偏差的部分。我们需要回答两个问题程序一共做了多少次浮点运算程序一共从 DRAM 访问了多少字节3.1 统计程序执行时间和指令现代 CPU 在性能计数器上提供了大量可编程事件可以用perfLinux 内核自带的性能剖析工具直接采样。先用一句话命令拿到程序运行时的基本统计perf stat -e cycles,instructions,fp_arith_inst_retired.avx512_double ./demo命令里的fp_arith_inst_retired.avx512_double表示“退休的 AVX-512 双精度浮点指令数”。对应不同指令集事件名会不同SSE 对应sse_doubleAVX2 对应avx2_double标量对应scalar_double。不同架构对这些事件名的支持不完全一致可以先跑perf list | grep fp_arith看一下硬件支持哪些浮点指令采样事件。注意一点perf stat统计的是整个进程生命周期内的累计值。如果你只想看核心计算段的指标就得在代码里用perf_event_open包裹关键函数或者把计算核单独抽成一个可执行文件避免把初始化和 IO 时间混进来。我一般更喜欢后者简单粗暴且可靠。还有一个非常普遍的现象编译器默认可能把关键循环自动向量化也可能因为别名问题放弃向量化。所以你在源码层面数出的浮点指令数和实际 CPU 执行的可能完全不同。这就是为什么一定要读取性能计数器而不是猜。3.2 计算 FLOPs 的常见坑拿到了浮点指令数算总浮点运算次数的公式看起来简单总 FLOPs Σ (各类浮点指令数 × 对应向量宽度 × FMA 系数)具体到这个公式里有几个坑我全部踩过FMA 指令算两次。一条 FMA 指令做了乘法和加法两个操作性能计数器只计“一条指令”但在 FLOPs 口径里要乘以 2。最容易忘的一个坑。向量宽度按精度折算。AVX-512 寄存器是 512 位如果处理的是双精度 double64 位一次装 8 个如果处理的是单精度 float32 位一次装 16 个。统计浮点指令数事件时通常分精度别混用。标量指令也是浮点运算。如果一个循环没被向量化浮点指令退化成标量 SSE/AVX这时候每条指令只处理一个 double不乘向量宽度。这个情况在新手写的复杂循环里很常见。编译器有隐藏的浮点操作。比如循环中涉及整数到浮点的转换、地址计算的浮点部分也会产生浮点指令。这类指令数量不多但在极致的 Roofline 分析里会造成小偏差一般可以容忍。如果嫌逐个算麻烦也可以直接用 LIKWID 这个开源工具一个轻量级的硬件性能计数器工具集里的likwid-perfctr来统计某个区域的浮点性能。它在 HPC 社区用得很广对 FMA 和向量宽度的处理已经封装好了。不过前提是编译时把循环体单独抽成函数然后用它按函数名进行统计。3.3 计算访存量的正确姿势访存量的统计比 FLOPs 更棘手因为现代 CPU 有三层缓存程序访问内存数据时大部分可能都命中 L1/L2/L3真正从 DRAM 走的只有最后一级缓存缺失的部分。画 DRAM Roofline 时我们关心的正是“DRAM 层面的字节数”而不是程序逻辑上读写的总字节数。最可靠的方式依然是 perf 硬件计数器看最后一级缓存缺失LLC miss事件。比如perf stat -e l2_request.all,cycle_activity.stalls_miss ./demo不同架构事件名差异很大最简单的办法是查看perf stat -d ./demo它会自动输出主要缓存失效的指标。不过 perf 给出的缓存缺失值通常是“按 cache line 计量”8 字节一个 double一个 cache line 往往是 64 字节共 8 个 double。具体折算成字节时要结合你数据的实际访问模式。在工程实践中我强烈建议对规律性强的循环最典型的是矩阵乘法和 stencil 计算采用手工估算访存量它比事件计数器更可靠访存字节 ≈ 逻辑上必须从 DRAM 读入的数据量 必须写回的数据量举例DAXPY 操作y[i] a * x[i] y[i]数据规模为 n逻辑上必须读 x 数组n 个 double 8n 字节、读 y 数组8n 字节、写回 y 数组8n 字节合计 24n 字节。只要数组放不进缓存这个估算就是准的。矩阵乘 C A × Bn 阶方阵的逻辑访存量更小读 A 和 B 各 n² 个元素加上写 C 的 n² 个元素合计 3n² 个 double24n² 字节。随着 n 增大计算量 n³ 级别的增长速度远超访存量 n² 级别所以矩阵乘天然是高运算强度的代表。手工估算的另一个好处是能直接从算法复杂度推 AI不用先跑一遍程序。这个能力在优化方案设计阶段特别有用后面讲案例时你们会看到。4. 绘制 Roofline 图并解读4.1 用脚本一键画图拿到两个硬件参数峰值 GFLOPS 和带宽 GB/s再加上程序的 AI 和实测性能画图就非常机械了。用 Python 的 Matplotlib 就能在一分钟内画出一张标准的 log-log 坐标 Roofline 图。下面这个脚本我几乎每次做性能分析都会用稍作修改就能复用到任意 CPU 平台import numpy as np import matplotlib.pyplot as plt # 硬件参数从第2节得到 peak_gflops 3891.0 # 双精度实际可达峰值 bandwidth 330.0 # 实测 DRAM 带宽 GB/s ridge peak_gflops / bandwidth # 转折点 ≈ 11.8 # 生成 roofline 曲线 ai np.logspace(-2, 2, 400) # 运算强度从 0.01 到 100 performance np.minimum(peak_gflops, bandwidth * ai) plt.figure(figsize(10, 6)) plt.loglog(ai, performance, k-, linewidth2, labelRoofline) plt.loglog([ridge], [peak_gflops], ko, markersize8) # 程序样本点 samples [ (DAXPY, 0.083, 12.0), (Small GEMM (n64), 5.0, 290.0), (Large GEMM (n1024), 48.0, 3100.0), (图像处理Demo, 1.2, 180.0), ] for name, ai_pt, perf_pt in samples: plt.loglog(ai_pt, perf_pt, ro, markersize8) plt.annotate(name, (ai_pt, perf_pt), textcoordsoffset points, xytext(10, 10), fontsize12) plt.xlabel(Arithmetic Intensity (FLOPs/Byte), fontsize14) plt.ylabel(Performance (GFLOPS), fontsize14) plt.title(CPU Roofline Model, fontsize16) plt.grid(whichboth, linestyle--, alpha0.5) plt.legend(fontsize12) plt.tight_layout() plt.savefig(roofline.png, dpi150) print(Roofline saved to roofline.png)运行python3 roofline.py得到一张图横轴是运算强度纵轴是实际性能。黑线的斜线部分是带宽限制区平的部分是计算限制区那两个区域交界处就是 ridge point。我自己画图时习惯把横轴范围设为 0.01100 FLOPs/Byte这个范围基本覆盖了从 DAXPY0.083到优化矩阵乘几十的所有典型程序。如果你的程序 AI 超过 100比如极端的大型矩阵乘法n 很大的时候可以把范围再扩大一档。4.2 把你程序点上去现在把第 3 节得到的程序数据转换成图上的一个点。一个点需要两个坐标横坐标AI 总 FLOPs / 总访存字节纵坐标实测性能 总 FLOPs / 执行时间换算成 GFLOPS拿 DAXPY 举例。假设数组长度 10,000,000双精度。手工估算浮点运算每轮 1 次乘 1 次加共 2 FLOPs总共 2 × 10⁷ 2×10⁷ FLOPs访存读 x 8×10⁷ 字节读 y 8×10⁷ 字节写 y 8×10⁷ 字节合计 24×10⁷ 字节AI 2×10⁷ / 2.4×10⁸ ≈ 0.083 FLOPs/Byte如果你实测这段代码运行时间是 0.02 秒那性能 2×10⁷ / 0.02 1×10⁹ FLOP/s 1 GFLOPS。这个点画在图上基本紧贴斜线说明程序已经基本达到了当前带宽允许的上限。想优化换算法别抠指令。再关键一点同一个程序不同的输入规模在 Roofline 图上的位置会移动。比如矩阵乘当 n 只有 64 时数据量小AI 不高大概率落在斜线上受带宽限制当 n 到 1024 时数据规模增长AI 升到高值就移到了计算限制区。这个移动轨迹本身就是优化信息小规模时优化访存大规模时优化计算。4.3 三维度解读程序属于哪类、离天花板多远、还能优化吗图出来了一眼扫过去有三个判断维度。第一维程序点落在斜线这边还是平线这边。落在斜线上说明它的性能公式是 带宽 × AI程序没有发挥出全部计算能力但这不是程序的错是数据的运算法则决定的——这种情况下再怎么优化循环、乱序、预取提升空间都很有限真正要做的是提高 AI分块提高缓存复用、压缩数据、减少不必要读写或者换更好的内存系统。第二维程序点离 Roofline 线有多远。如果点在线的下方离得很远说明程序连“当前访存或计算条件下的理论上限”都没达到优化空间非常大。常见原因是缓存未命中过高、并行度不足、分支预测失败、小循环开销太大。这种情况值得深挖往往几行改动就能带来翻倍提升。第三维程序当前的瓶颈是不是“训练有素的逼近”。有些程序点虽然在线下方但已经非常接近线了比如 90% 带宽效率这时候再优化就是吃平台红利性价比很低反之在线下方 30% 处收益会很明显。这个判断直接决定你的时间投入该放在哪里。实操中我还会把同一个程序的不同优化版本点在同一张图上看优化是否真的把点推向天花板方向。优化访存局部性点会向右移动优化计算流水线点会向上移动优化两者点会向右上方移动。这种对比视角比单纯看加速比更能说明问题本质。5. 常见问题与排查技巧实录5.1 参数搞错的典型症状画 Roofline 图最常见的问题就是参数拎不清症状很典型我把坑列成了一张速查表症状原因排查与修正转折点算出来巨大几百峰值用了理论值但没打折带宽用了单核测试值统一改成实测值实测 STREAM 多核带宽所有程序点都贴在斜线上访存量统计错低估了 DRAM 流量检查是否把 L2/L3 命中流量也算进 DRAM 流量所有程序点都贴在平线上峰值严重低估或者实际程序跑得远低于峰值检查 FMA 翻倍、检查 AVX-512 是否真正编码出来了有些点落在 Roofline 线上方数据口径不一致比如 FLOPs 按单精度算但带宽按双精度算统一精度口径复位重测经验证明第一次画 Roofline 图时绝大多数人都能错一次。别急对照这张表逐项检查基本都能纠回来。5.2 flops 统计偏差的排查FLOPs 统计偏差是另一个高频问题。我自己抓到过最典型的一个场景某图像处理 Demo 的循环源码里看起来每像素做 5 次浮点运算编译器开了-ffast-math之后自动把循环向量化顺带还做了一些强度削减实际浮点指令数比源码逻辑少了一半。如果按源码数 FLOPs点的横坐标直接就偏了。排查办法只有一个回归硬件计数器。先跑一遍不加优化编译的版本统计一条指令基线再跑-O3版本看指令数变化。大部分情况下两者差距都能对得上编译器优化说明。如果差异特别大用objdump反汇编热点循环人工确认向量化情况这个排查一次之后你就再也不会犯同类错误了。另外一个隐藏细节perf stat统计的时间是整个进程的墙钟时间如果程序里还有内存初始化、文件 IO这部分时间会稀释性能值导致点的纵坐标偏低。分析时最好在代码里用clock_gettime(CLOCK_MONOTONIC)精确记录核心计算段的耗时再手动计算 GFLOPS。这个做法也方便自动化批处理。5.3 我踩过的一个真实优化案例最后分享一个在模拟项目 X 里的实践展示 Roofline 怎么改变我的优化思路。项目 X 是一个跨平台的图像滤波内核输入一张大图每个像素要读取邻域窗口内的一组 double 数据做加权求和。刚开始我用性能分析工具看到 CPU 利用率只有 40%第一反应是“指令调度没做好”于是花了两周调整循环展开、软件预取、乱序优化性能提升不到 10%。后面我把这段核心逻辑用 Roofline 模型一画横轴 AI 只有 0.4 FLOPs/Byte远小于机器的 ridge point约 12程序点贴在斜线附近。这说明它天生就是一个访存密集型任务再折腾浮点指令是缘木求鱼。于是我换了个方向把图像分块处理让邻域数据尽量复用 L2 缓存再把输出写回的粒度从一次一个像素改成一次多个像素访存总量直接降了 40%。同一版代码性能提升接近 2.5 倍。这个案例给我的教训很直接优化之前先问 Roofline它告诉你瓶颈在哪剩下的力气才不会白费。很多程序的第一性原理就是“访存带宽撑死你能跑多快”早一点搞清楚这一点能节省大量无效劳动。5.4 一些小技巧和日常习惯最后一个很实用的小技巧建议你把它变成习惯每次写完一个高性能计算内核顺手跑一下 STREAM 和一个小规模的稠密矩阵乘基准把测出的峰值和带宽存成一个配置文件然后直接让脚本自动生成 Roofline 图并标注当前内核的数据点。这会让团队里所有人都能在几秒钟内看到这个新内核有没有达到硬件上限还是还有多少余量。相比翻资料、查 profile 报告Roofline 图的表达成本低很多讨论优化方向时也更容易达成共识。我个人往往还会把历史版本的数据也点上去图上一条从上到下的数据点轨迹就是一次完整优化历程。有时候翻回去看还能发现之前某个被放弃的版本其实离 Roofline 线更近只是当时没有画图凭感觉误判了。画图这件事值回票价。