STREAM内存带宽测试实战:编译参数、NUMA绑定与性能调优 简介这份资源是一套面向Linux系统管理员、性能优化工程师及开发者的内存基准测试工具包内含STREAM开源项目完整源码用于量化内存与CPU间的连续带宽并支持Copy、Scale、Add、Triad四类典型操作指标评估。包体共8个文件以C和Fortran源码、Makefile构建脚本、README说明及历史文档为主压缩包仅17KB结构精简且便于在服务器或嵌入式环境快速部署测试。已有2965人学习下载适用于系统选型、性能对比和内存子系统瓶颈分析。借助其中的源码与说明读者可自行编译生成可执行程序理解不同内存操作模式的带宽与耗时差异并结合实际业务负载解读测试数据为后续硬件升级或内核参数调优提供量化依据。1. Stream 是什么为什么测内存带宽绕不开它做性能测试的同行应该都有过这种体验CPU 主频往上拉了一两档跑业务程序却纹丝不动。十有八九瓶颈不在算术逻辑单元而在内存带宽。STREAM 是衡量可持续内存带宽最受认可的开源基准由几条简单循环构成Copy、Scale、Add、Triad分别模拟纯搬运、缩放和复合运算的访存模式。服务器选型、BIOS 调优、NUMA 验证场合都把 STREAM 当第一道门槛。这篇实战笔记面向两类读者新手可以直接照着编译、跑通、读懂四项输出熟手重点关注后面写的数组大小、线程绑定和频率控制这三个参数决定了你测的是缓存带宽还是真实内存带宽。2. 源码编译与参数选型O2、O3、OpenMP 与数组大小的取舍STREAM 不是一个需要安装的服务它本质上就是一个 stream.c 加一个 Makefile。正因为结构简单编译参数的选择反而成了决定结果可信度的第一道关卡。下面先说怎么拿源码、怎么改四个关键参数再说怎么确认优化真的生效了。2.1 源码从哪来版本怎么选STREAM 最经典的版本由 John McCalpin 维护在 GitHub 上有长期维护的仓库社区里 OpenMP、MPI 分支也都能找到。我一般直接拉最基础的 C 版因为它的输出格式是各家厂商和论文通用的标准格式便于和别人的数据对比。不建议用发行版自带的 stream 包比如某些 apt 源里的 stream 二进制是用默认参数编好的优化级别不可控可能连 OpenMP 都没开测出来的数字基本没有参考价值。版本选择上能拉到新版就用新版。新版主要修正了计时函数的精度比如从 gettimeofday 切换成更高精度的时钟这对短时间运行尤其重要。老版本不是不能用但你得知道它测出来的时间戳可能带几十毫秒的固定 tick数组小、运行时间短时误差会被放大到没法看。# 拉取经典 STREAM 源码并进入目录 git clone https://github.com/jeffhammond/STREAM.git stream cd stream ls -la # 目录里应该至少有 Makefile、stream.c 和 README拉下来后不要急着 make先改 Makefile。stream.c 里很多宏定义默认值是被 Makefile 里的编译参数覆盖的不改 Makefile 直接 build等于用一套保守的旧参数跑测试。README 里会写清楚每个宏的含义但日常用不到读源码只看几个关键参数就够。2.2 编辑 Makefile 的四个关键参数需要动的地方集中在 STREAM_ARRAY_SIZE、NTIMES、CFLAGS 这三个位置OpenMP 由 CFLAGS 里的 -fopenmp 控制。下表是这些参数的默认值和推荐值参数默认值推荐值作用STREAM_ARRAY_SIZE200000032000000 或更大控制数组占用内存大小决定数据是否完全压出缓存NTIMES1020100重复测试次数用来平均计时噪声CFLAGS-O2-O3 -fopenmp -marchnative编译优化级别与多线程开关OMP_NUM_THREADS未设默认 1 线程物理核数运行时线程数写进启动命令不改代码STREAM_ARRAY_SIZE 是最容易翻车的一个参数。默认的 200 万个 double 只有 16MB现代 CPU 的 L3 缓存动辄 32MB 甚至 64MB数组整个塞进缓存后测的是缓存带宽而不是内存带宽数字会高得离谱。常见做法是把它设成末级缓存容量的 4 倍以上。比如机器 L3 32MB数组 3200 万个 double 就是 256MB这样每个元素在迭代间必然被挤回内存测出来的才是真实 DDR 带宽。# 修改 Makefile 中的三处关键配置 STREAM_ARRAY_SIZE 32000000 NTIMES 20 CFLAGS -O3 -fopenmp -marchnative -DSTREAM_ARRAY_SIZE$(STREAM_ARRAY_SIZE) -DNTIMES$(NTIMES)这里的逻辑是把参数直接写进 Makefile编译时通过 -D 传递给 stream.c避免每次手动改源码。STREAM_ARRAY_SIZE 的单位是 double 个数不是字节数如果你的机器 L3 更大比如 64MB就把它提到 64000000对应 512MB 数组。NTIMES 设 20 是比较稳妥的起点跑得慢的机器可以减到 10但低于 8 的数值不建议用于正式报告因为一次中断或调度抖动就会让最小值失真。CFLAGS 里 -marchnative 让编译器根据当前 CPU 生成指令-fopenmp 开启 OpenMP 多线程。有一点要注意STREAM 的 OpenMP 版本行为依赖运行时环境变量 OMP_NUM_THREADSMakefile 里设置了不等于不用管启动时仍要显式赋值否则某些环境会退化成单线程。Makefile 里如果原本写的是 -O2直接替换成 -O3 即可不用两行都留。2.3 编译完成后如何确认优化生效编译后先别急着看结果用 30 秒确认参数真的传进去了。STREAM 程序启动时会把版本、数组大小、线程数这些信息打印出来但它是等全部测试跑完才一次性输出所以不要用管道加 head 截断那样可能触发 SIGPIPE 把程序杀掉。正确做法是先重定向到文件再从文件里看头几行。# 彻底重新编译 make clean make stream # 先跑一次结果写到日志文件 ./stream stream_first_run.log 21 # 检查关键行 head -20 stream_first_run.log这是标准的构建与验证流程。make clean 是为了确保旧的对象文件不残留否则改了 Makefile 而 stream.o 没重新编译参数仍是旧的。日志里重点看三行STREAM version 显示版本号Array size 显示数组元素个数Number of Threads 显示实际启动的线程数。如果 Array size 仍是 2000000说明 -DSTREAM_ARRAY_SIZE 没传进去常见原因是 Makefile 里变量名拼写不一致比如写了 STREAM_ARRARY_SIZE。线程数确认也很重要。很多环境默认 OMP_NUM_THREADS 不生效跑出来是 1 个线程而单线程和多线程 Triad 的结果可能相差三四倍。建议在 head 输出里找到 Number of Threads requested 这一行和 lscpu 看到的物理核数对一下不一致就先 export OMP_NUM_THREADS 再跑。3. 运行与结果解读Copy / Scale / Add / Triad 各代表什么跑 STREAM 本质上就是在做一次 DDR 带宽测试但很多人跑完只会看最后一个 Triad 的总分对另外三个数字的含义说不清楚。这一章把四种操作的访存模型、带宽计算方法和结果判读标准拆开讲。3.1 四种操作的含义与带宽怎么算STREAM 名字看起来神秘实际就是四个循环分别模拟四种典型的访存模式。下表把操作、伪代码、每次迭代的读写次数和对应的移动字节数写清楚操作伪代码读次数写次数每次迭代移动字节数Copyc a112 * N * 8Scaleb k * a112 * N * 8Addc a b213 * N * 8Triadc a k * b213 * N * 8这里的 N 是数组元素个数8 是 double 的字节数。带宽计算并不复杂总字节数除以耗时换算成 MB/s。STREAM 自带换算但你最好自己能算一遍否则没法验证它打印的数字对不对也没法判断这个数是否合理。一个反直觉的点是 Copy 通常比 Triad 低。Copy 只移动 2N 个元素字节看起来负担更小但现代内存控制器对读混合写的连续流处理效率其实更高。Triad 虽然字节多但读取是两个独立流流水线更容易填满Copy 的写流必须等读数据返回才能发出延迟暴露得更明显。所以看结果时不要把 Copy 当上限Triad 才是更接近真实负载的参考值。3.2 跑完怎么读数运行完之后输出大致是这个形态具体数值随机器不同差异很大Function Best Rate MB/s Avg time Min time Max time Copy: ... Scale: ... Add: ... Triad: ...读结果时我的习惯是只看三样东西。第一Solution Validates 是不是 yes不是的话结果直接作废。第二Best Rate MB/s它由 Min time 算出来是这轮测试里最接近无干扰状态的带宽。第三对比 Avg time 和 Min time如果两者差距超过 20%说明运行期间机器负载不稳或节能策略在起作用这组数据要打问号。# 只看校验结果和四项成绩 ./stream | grep -E Validates|Function|Copy:|Scale:|Add:|Triad:这条命令等价于把完整结果里最有信息量的部分挑出来。grep 到的每行格式都是固定的第一列操作名第二列 Best Rate第三列之后是平均时间、最小时间、最大时间。我要提醒的是Best Rate 的单位是 MB/s不是 GB/s。比如 40000 MB/s 是 40 GB/s很多人第一次读输出会在除以 1024 还是 1000 上犯糊涂STREAM 内部统一按 MB/s 输出也就是 10^6 字节不是 2^20。3.3 与理论峰值对比如何判断结果是否正常拿到数字后第一步是算理论峰值。内存带宽的理论值由内存类型和通道数决定公式是内存带宽 数据传输速率(MT/s) × 通道位宽(64bit 8字节) × 通道数举几个常见配置内存类型数据传输速率单通道带宽双通道四通道DDR4-29332933 MT/s23.46 GB/s46.93 GB/s93.86 GB/sDDR4-32003200 MT/s25.60 GB/s51.20 GB/s102.40 GB/sDDR5-48004800 MT/s38.40 GB/s76.80 GB/s153.60 GB/s对照实际结果Triad 达到理论峰值的 70%90% 属于健康区间Copy 通常会比 Triad 略低。如果你测出来只有峰值的 40%50%优先检查四个地方内存条是否插满了所有通道、是否跨 NUMA 访问、CPU 频率是否被限制、是否开着内存镜像之类的 BIOS 特性。反过来如果测出来超过理论峰值 100%那不是你的内存黑科技而是数组太小或测试时间太短数据根本没出缓存回到第 2 章把 STREAM_ARRAY_SIZE 调大再跑。4. 避坑测不准、测不高、测出负数的常见原因这个基准看起来简单四行循环而已但实际跑起来翻车概率极高。下面五条都是我在不同机器上真实踩过的坑每条按现象、原因、解决展开照着核对一遍能省一个下午。4.1 高频翻车现场五条实测记录第一现象Copy 分数比 Triad 高出一大截数值达到理论峰值的 1.5 倍以上。原因数组太小整个驻留在 L3 缓存里测的是缓存带宽而不是内存带宽。解决用 lscpu -C 查看 L3 容量把 STREAM_ARRAY_SIZE 提升到 L3 的 4 倍以上重新编译。这一步没做对后面所有分析都建立在错误数据上。第二现象同样命令跑三次Best Rate 波动超过 5%机器并无明显负载。原因CPU 频率调速器处于 powersave 或 ondemand 模式睿频时高时低或者邻居容器争抢 LLC。解决临时切到 performance governor命令是 cpupower frequency-set -g performance再用 numactl 绑核后重测容器环境还要确认 CPU quota 和 cpuset 是否收窄了核数。这种波动最容易被误读成“内存性能不稳定”实际上是 CPU 频率策略在背锅。第三现象单线程分数正常一开启 OpenMP 分数断崖式下跌。原因线程被调度器分摊到两个 CPU 节点导致一半访存变成 remote access跨 NUMA 带宽远低于本地带宽。解决用 numactl 绑定一个节点再跑并看 numastat 确认 local allocation 接近 100%。双路服务器上这是我见过次数最多的翻车现场。第四现象运行时间显示 0.00或者 Best Rate 大得离谱甚至出现负数。原因gettimeofday 计时受系统时钟跳变影响NTP 校时或虚拟机时钟同步瞬间会导致计时回拨。解决换用 clock_gettime 的 STREAM 新版本尽量在物理机测试NTIMES 提高到 20 以上减少单次计时误差在总时间里的占比。第五现象Solution Validates 显示 no。原因编译器过度优化改变了运算顺序或内存本身存在 ECC 错误导致数据翻转。解决先用 -O2 跑一遍验证校验是否通过仍失败则去 dmesg 查 EDAC 或 MCE 记录有内存错误就先换内存排除硬件问题再调优化级别。校验失败的结果没有任何意义直接丢弃。4.2 排错的顺序先看环境再看代码遇到分数不对不要第一时间怀疑 stream.c 有 bug。这个基准被用了二十多年源码层面的问题远比环境问题少。我一般按下面的顺序排查两步就能定位大部分问题# 1. 看 CPU 拓扑和 NUMA 节点 lscpu | grep -E Socket|NUMA|Core|Thread # 2. 看内存实际配置和通道 dmidecode -t memory | grep -E Size|Locator|Speed # 3. 看当前 CPU 频率策略 cpupower frequency-info | grep -E governor|hardware limits # 4. 找历史基线对比 grep Triad /path/to/old_stream_result.log这个顺序不是随便定的。lscpu 先确认拓扑如果拓扑显示两个 NUMA 节点而你以为只是单路后续所有判断都会错。dmidecode 需要 root 权限读出来的 Speed 和 Locator 能直接对上通道数cpupower 则告诉你分数波动是不是频率策略造成的。最后一步最容易被忽略但踩坑效率最高——和老基线一对是整体变差还是单项变差立刻清楚。现在网上很多关于 stream 性能测试的疑问都集中在结果偏低上面四条命令基本能把 90% 的偏低原因定位出来。剩下的情况才需要动编译参数比如确认 -O3 是否真的生效、NTIMES 是否足够。记住一个原则先确认结果可信再谈结果好坏。5. 进阶调参NUMA 绑定、线程数与内存通道的关系基础的编译和读分过关后想把 STREAM 跑出稳定可复现的数字就要处理访存路径上的三个变量线程数、NUMA 绑定、内存通道。这一章是给需要在多路服务器上反复对比数据的读者准备的。5.1 OpenMP 线程数不是越大越好带宽测试和延迟测试不一样。延迟测试吃力大小线程少反而不容易排队带宽测试吃并发但并发到一定程度就会撞上内存控制器的瓶颈再加线程只会增加 cache-line 竞争和调度开销。我见过有人在 48 物理核的机器上用 OMP_NUM_THREADS96 跑Triad 反而比 24 线程低 10%这就是典型的超线程副作用。找饱和点最直接的方法就是扫线程数# 循环测试不同线程数下的 Triad 带宽 for t in 1 2 4 8 16 24 48; do echo threads$t OMP_NUM_THREADS$t numactl --cpunodebind0 --membind0 ./stream | grep Triad done这个循环每次跑一组完整的 STREAM输出里 Triad 行的 Best Rate 就是该线程数下的带宽。观察数据时留意两点一是带宽从哪个 t 开始不再明显增长那个 t 就是这台机器的访存饱和点二是看有没有某个 t 反而下降下降通常意味着线程跨了物理核进入超线程或跨了 NUMA 节点。实际操作中单 socket 机器一般取物理核数的 50%100% 之间最佳双路机器则建议先单节点扫一遍再考虑跨节点。我一般会把扫描结果记下来附在性能测试报告后面。因为不同 BIOS 版本下饱和点会漂移下次换个微码版本后对比饱和点位置能顺带发现内存控制器的行为变化。5.2 NUMA 架构下如何绑定 CPU绝大多数分数偏低都发生在双路服务器上根因是 CPU 和内存没有绑定在同一个节点。numactl 有两个看似相近但作用不同的参数cpunodebind 只约束 CPU 亲和性决定进程运行在哪个核membind 才控制内存页分配位置。只绑核不绑内存进程仍然可能把页分配到另一个节点跑出来的带宽反而是最差的跨 NUMA 值。正确写法是同时指定两个# 进程绑定 node0 的 CPU内存也强制分配在 node0 numactl --cpunodebind0 --membind0 ./stream # 反面示例只绑 CPU 不绑内存结果可能偏低 20% 以上 numactl --cpunodebind0 ./stream上面两行的区别是踩坑的关键。cpunodebind 让内核只把线程放到 node0 的核上membind 让 stream 分配数组用的内存页都来自 node0 的内存控制器。对于只有单路 CPU 的机器numactl --hardware 会显示只有一个节点这时直接跑不需要绑定绑了也无效。判断绑定是否生效可以用 numastat 看内存分布# 查看 stream 进程的内存节点分布 numastat -v -p $(pgrep -f stream)输出里 Node0 对应的 local allocation 如果接近 100%说明绑定成功。如果大部分内存落在 Node1说明 membind 参数没起作用或进程 fork 后丢失了亲和性。这个检查比看 STREAM 输出本身更能说明问题因为输出数字只能告诉你坏了numastat 能告诉你坏在哪。5.3 用 likwid 交叉验证 NUMA 惩罚STREAM 是宏观基准要更细粒度地看访存行为可以上 likwid。likwid-bench 自带一个 stream 类型可以指定 NUMA 范围快速对比不同内存节点组合下的带宽# 用 likwid-bench 交叉验证本地/远端带宽差异 likwid-bench -t stream -W NUMA:0-0 2/dev/null likwid-bench -t stream -W NUMA:0-1 2/dev/null第一行测 node0 内部带宽第二行测 node0 到 node1 的跨节点带宽两者对比能直接量化 NUMA 惩罚。likwid-bench 和 STREAM 的算法不完全一致绝对值不能混着比但作为相对验证很好用。比如本地带宽 40 GB/s、远端只有 18 GB/s这个比值能帮你判断某个 STREAM 结果是绑定问题还是内存本身降频。我的建议是日常回归用 STREAM出问题后用 likwid-bench 定位 NUMA 惩罚两个工具搭配基本能把“带宽为什么低”解释清楚。环境里没有 likwid 时退而求其次用 numastat 对比两次运行的 local allocation 差异也能得到一半的结论。6. 把 Stream 做成回归守卫基线对比与快速判定脚本最后一个技巧是给团队用的。STREAM 不应该只在选型时跑一次它更适合当基线防线每次 BIOS 升级、微码更新、内核大版本变更后都跑一组和上线前的基线比对低于阈值就报警。下面这个脚本就是我日常在用的版本。#!/usr/bin/env bash # 每次改动 BIOS/内核参数后强制跑一组绑定后的 STREAM ARRAY32000000 THREADS${THREADS:-$(lscpu | awk /^CPU\(s\)/{print $2})} BASE_FILEstream_baseline.log numactl --cpunodebind0 --membind0 \ env OMP_NUM_THREADS$THREADS \ ./stream /tmp/stream_result.log triad$(awk /Triad:/{print $2} /tmp/stream_result.log) base$(awk /Triad:/{print $2} $BASE_FILE) delta$(echo scale2; ($triad - $base) / $base * 100 | bc) if [ $(echo $delta -5 | bc) -eq 1 ]; then echo Triad 带宽下降了 ${delta}%请检查 NUMA/频率/内存通道 exit 1 fi echo OKTriad 与基线偏差 ${delta}%脚本逻辑不复杂第一次跑完把结果存成 stream_baseline.log之后每次跑完自动算带宽变化百分比。阈值 -5% 可以根据业务容忍度调整数据库类应用对带宽敏感的话可以收紧到 -3%。awk 提 Triad 数值bc 做浮点比较避免整数比较把 -4.9% 误判成正常。我吃过一次亏某次微码更新后一批机器的流式查询延迟涨了 30%但 CPU 跑分完全正常。查根因就是内存带宽掉了 15%而当时没有任何带宽基线数据只能对着厂商的规格书猜。从那以后我每次改动 BIOS 或内核参数都会先强制跑一遍这个带基线的 STREAM 脚本确认带宽没有劣化再放量。这个习惯已经帮我在两个项目里提前抓出了内存通道配置错误希望也能帮到你。本文还有配套的精品资源点击获取