☰
STREAM内存带宽测试实战:编译参数、NUMA绑定与性能调优
2026/10/6 19:39:22 网站建设 项目流程

简介:这份资源是一套面向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 或更大控制数组占用内存大小,决定数据是否完全压出缓存
NTIMES1020~100重复测试次数,用来平均计时噪声
CFLAGS-O2-O3 -fopenmp -march=native编译优化级别与多线程开关
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 -march=native -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 里 -march=native 让编译器根据当前 CPU 生成指令,-fopenmp 开启 OpenMP 多线程。有一点要注意:STREAM 的 OpenMP 版本行为依赖运行时环境变量 OMP_NUM_THREADS,Makefile 里设置了不等于不用管,启动时仍要显式赋值,否则某些环境会退化成单线程。Makefile 里如果原本写的是 -O2,直接替换成 -O3 即可,不用两行都留。

2.3 编译完成后如何确认优化生效

编译后先别急着看结果,用 30 秒确认参数真的传进去了。STREAM 程序启动时会把版本、数组大小、线程数这些信息打印出来,但它是等全部测试跑完才一次性输出,所以不要用管道加 head 截断,那样可能触发 SIGPIPE 把程序杀掉。正确做法是先重定向到文件,再从文件里看头几行。

# 彻底重新编译 make clean && make stream # 先跑一次,结果写到日志文件 ./stream > stream_first_run.log 2>&1 # 检查关键行 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 * 8
Scaleb = k * a112 * N * 8
Addc = a + b213 * N * 8
Triadc = 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/s
DDR4-32003200 MT/s25.60 GB/s51.20 GB/s102.40 GB/s
DDR5-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_THREADS=96 跑,Triad 反而比 24 线程低 10%,这就是典型的超线程副作用。

找饱和点最直接的方法就是扫线程数:

# 循环测试不同线程数下的 Triad 带宽 for t in 1 2 4 8 16 24 48; do echo "=== threads=$t ===" OMP_NUM_THREADS=$t numactl --cpunodebind=0 --membind=0 ./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 --cpunodebind=0 --membind=0 ./stream # 反面示例:只绑 CPU 不绑内存,结果可能偏低 20% 以上 numactl --cpunodebind=0 ./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 ARRAY=32000000 THREADS=${THREADS:-$(lscpu | awk '/^CPU\(s\)/{print $2}')} BASE_FILE=stream_baseline.log numactl --cpunodebind=0 --membind=0 \ 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 "scale=2; ($triad - $base) / $base * 100" | bc) if [ $(echo "$delta < -5" | bc) -eq 1 ]; then echo "Triad 带宽下降了 ${delta}%,请检查 NUMA/频率/内存通道" exit 1 fi echo "OK:Triad 与基线偏差 ${delta}%"

脚本逻辑不复杂:第一次跑完把结果存成 stream_baseline.log,之后每次跑完自动算带宽变化百分比。阈值 -5% 可以根据业务容忍度调整,数据库类应用对带宽敏感的话可以收紧到 -3%。awk 提 Triad 数值,bc 做浮点比较,避免整数比较把 -4.9% 误判成正常。

我吃过一次亏:某次微码更新后,一批机器的流式查询延迟涨了 30%,但 CPU 跑分完全正常。查根因就是内存带宽掉了 15%,而当时没有任何带宽基线数据,只能对着厂商的规格书猜。从那以后,我每次改动 BIOS 或内核参数都会先强制跑一遍这个带基线的 STREAM 脚本,确认带宽没有劣化再放量。这个习惯已经帮我在两个项目里提前抓出了内存通道配置错误,希望也能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询