简介:面向Linux系统优化与性能分析人员,这份资源提供了STREAM内存基准测试工具的可直接编译源码,专门用于评估计算机内存连续带宽,解决内存性能难以量化对比的问题,适用于系统管理员、开发者和硬件测试工程师。压缩包共包含八个文件,涵盖C语言与Fortran源码、Makefile构建脚本、README说明文档以及历史版本记录,整体体积仅十七KB,结构简明清晰。已有2960人学习下载,工具运行时通过执行复制、缩放、相加与三元运算四类操作,输出对应的内存带宽和耗时数据,帮助定位内存子系统瓶颈。还可调整数组大小与并行度参数,深入考察内存控制器、总线与缓存层次结构的表现,为高性能计算、大数据处理等内存敏感场景的优化提供客观依据,亦可用于硬件选型与性能对比。
1. Linux内存性能测试工具STREAM:实测带宽才是调优的起点
Linux服务器内存性能测试,我一般先从STREAM跑分开始,而不是直接翻内核参数。STREAM是一套用C语言写成的内存带宽测试工具,通过Copy、Scale、Add、Triad四类循环操作,测出系统在持续读写负载下的真实带宽,和厂商标称的理论值往往差一大截。它能帮你判断内存通道是否完整、频率是否达标、NUMA拓扑有没有拖后腿,也能用于服务器选型时横向对比。适合运维工程师、性能测试人员和做硬件评估的人。跑分本身不复杂,但编译参数和运行方式选不对,结果会骗人,这篇笔记把从编译到避坑的完整过程拆一遍。
2. 先搞懂STREAM在测什么:四个内存操作模式与带宽模型
2.1 四个测试模式:Copy、Scale、Add、Triad的读写差异
STREAM的源码只有一个stream.c,核心跑了四个循环。用最直白的伪代码展示就是这样:
// copy: 读数组 a,写数组 b for (j = 0; j < N; j++) { b[j] = a[j]; } // scale: 读数组 a,乘常量后写数组 b for (j = 0; j < N; j++) { b[j] = scalar * a[j]; } // add: 读数组 a 和 b,相加后写数组 c for (j = 0; j < N; j++) { c[j] = a[j] + b[j]; } // triad: 读数组 a 和 b,乘加后写数组 c for (j = 0; j < N; j++) { c[j] = a[j] + scalar * b[j]; }四段代码虽然都只是循环体里几个赋值语句,访存特征却不一样。我整理成一张表方便对照:
| 模式 | 读操作 | 写操作 | 计算操作 | 访存特征 |
|---|---|---|---|---|
| Copy | 1 | 1 | 无 | 读写均衡,压力最纯粹 |
| Scale | 1 | 1 | 1次乘法 | 读写均衡,带轻微计算 |
| Add | 2 | 1 | 1次加法 | 读多写少,读带宽压力更大 |
| Triad | 2 | 1 | 1次乘加 | 读多写少,最接近科学计算 |
为什么不能只跑一个Copy就完事?我自己的理解是,真实程序里的内存访问几乎不会只有纯复制场景。数据库的顺序扫描接近Copy,列存计算接近Add,科学计算里的矩阵更新接近Triad。不同模式对内存控制器的调度压力不一样,Copy快不代表Add也快,把四种模式都跑一遍才能看出内存子系统的偏向性。比如某台机器Copy成绩正常而Add明显偏低,大概率是硬件prefetch对多读流支持得不好,也能侧面反映L2/L3预取器的工作状态。
这里补一个现象:你拿到的STREAM输出里,Copy经常是最低的那一项,Triad通常最高。这不是机器坏了。一种常见解释是,部分处理器架构对写操作采用写分配策略,一次写会先在Cache里占用一行,带出一次隐含读,让实际总线上的读流量高于统计值;而Triad里读操作更密集,硬件prefetch更容易满负荷工作,测出来的带宽反而更高。所以厂商公布内存带宽时引用的往往就是Triad值,你跟厂商数据对比前先确认对方用的是哪个模式。
2.2 数组大小与迭代次数:为什么要设成Cache的4倍以上
STREAM设计目标是测“持续内存带宽”,也就是数据流突破各级Cache之后,真正压到内存控制器和DRAM上的吞吐。如果数组太小,数据全被L2/L3装下,跑出来的是Cache带宽而不是内存带宽,数值可能膨胀到几十倍,毫无参考意义。
原始包默认的STREAM_ARRAY_SIZE是10000000,也就是1千万个double元素,单个数组80MB,四个数组加起来占320MB。这个默认值在十几年前够用,但现在的服务器L3动不动几十MB,EPYC这类大Cache平台默认值甚至可能落在L3里。John McCalpin最初的建议是单个数组大小至少是系统最大Cache的4倍。怎么判断数据有没有真正走出Cache?有个土办法:把数组大小翻一倍重跑一轮,如果跑分明显上升,说明刚才的数据没完全突破Cache,继续加大;如果跑分基本不变甚至略降,说明已经在访存了,当前值可用。
迭代次数由NTIMES宏控制,默认10次。它的作用是让同一批数据重复跑,取最小耗时来抵消系统调度的抖动。次数太少计时误差大,太多会让内存长时间满负载,反而触发CPU降频。我在生产机器上一般设20到30次,让单次运行时间控制在20秒到1分钟,既稳又不至于热过头。
数组大小还要考虑物理内存上限。四个数组加起来的字节数最好控制在物理内存的1/4以内,否则操作系统会不断做页面回收,测试过程里夹杂swap的代价,跑分被严重拖低。64GB内存的机器,四个数组加起来别超过16GB。
2.3 为什么STREAM适合做跨平台对比而不是微基准
STREAM代码是纯标准C,不依赖平台私有API,也没内联汇编,同一份stream.c在x86、ARM、RISC-V上都能编译。跨平台可比性是它被广泛接受的核心原因。再加上它测的是“编译器生成指令 + 内存控制器 + DRAM”的综合表现,和你业务程序实际跑出来的访存行为更接近,不像某些微基准会用特殊指令绕开瓶颈去压极限。
这也带来一个必须接受的事实:STREAM结果里包含了编译器的贡献。同一个CPU,gcc 9和gcc 12编译出的二进制,跑分可能差5%以上,因为优化器对循环的向量化方式、prefetch插入位置、循环展开次数都会变。所以横向对比时,编译器版本和优化参数不一致的结果不要拿来硬比。这一点在避坑章节我会再详细展开。
3. 从源码到跑分:STREAM编译参数与完整执行流程
3.1 获取源码与基础编译
STREAM官方提供的是单个stream.c源文件,不依赖第三方库,下载和编译都很轻量:
mkdir -p ~/stream && cd ~/stream wget https://www.cs.virginia.edu/stream/FTP/Code/stream.c gcc -O3 -o stream stream.c ./stream先解释这段命令做的事:wget把官方维护的stream.c拉到当前目录,gcc直接编出可执行文件,./stream跑默认配置。默认配置下STREAM_ARRAY_SIZE是1000万,单个数组80MB,NTIMES是10,单线程运行。这个基础编译只适合做“程序能跑”的冒烟验证,别拿它当结论。
提示:数组大小和迭代次数都可以通过编译期的-D宏覆盖,不需要手动改源码,后面会用到。
3.2 按CPU架构调整编译优化参数
要测出接近真实内存带宽的值,编译参数必须认真调。我在Linux物理机上最常用的一组命令是:
gcc -O3 -march=native -fopenmp \ -DSTREAM_ARRAY_SIZE=200000000 -DNTIMES=20 \ -o stream stream.c逐一说明参数含义。-O3是编译优化等级,启用循环展开、自动向量化和软件流水,不加O3的STREAM基本等于没测。-march=native让编译器按当前CPU支持的指令集生成代码,x86上会启用AVX2,支持AVX-512的平台会启用AVX-512。-fopenmp链接OpenMP运行时,STREAM源码里带有#ifdef _OPENMP并行分支,打开后才能多线程跑。-DSTREAM_ARRAY_SIZE=200000000把数组元素个数改成2亿,每个double占8字节,单个数组1.6GB,四个数组共6.4GB。-DNTIMES=20表示每个模式重复跑20次,取最小耗时。
Intel编译器也一样,常见写法是这样:
icx -O3 -xCORE-AVX2 -qopenmp \ -DSTREAM_ARRAY_SIZE=200000000 -DNTIMES=20 \ -o stream stream.c-xCORE-AVX2直接指定指令集,比-march=native的自动检测更可控。Intel在老代码上有时能压出比gcc更高一点的分数,因为对循环的展开更激进。AMD平台我基本用gcc -march=native,省事也稳定。还有一种玄学做法是只开-O3 -mtune=native,不引入新指令集,在某些降频敏感的CPU上反而成绩更高,感兴趣的可以对比试一轮。
3.3 执行STREAM与输出格式解读
单线程冒烟这样跑:
OMP_NUM_THREADS=1 ./stream整机多线程压测:
export OMP_NUM_THREADS=16 ./stream线程数不要盲目拉到满。先确认CPU拓扑再定线程数,常见做法:
lscpu | awk -F: '/^CPU\(s\):/{print $2}'跑完的典型输出长这样:
------------------------------------------------------------- Function Best Rate MB/s Avg time Min time Max time Copy: 112345.6 0.1021 0.0956 0.1032 Scale: 110234.5 0.1043 0.0974 0.1050 Add: 123456.7 0.1399 0.1298 0.1412 Triad: 124567.8 0.1408 0.1301 0.1425 -------------------------------------------------------------这里单位的细节要留意:Best Rate的MB/s是10^6字节/秒,不是MiB/s(1024^2)。Copy的112345.6 MB/s表示每秒搬运约112.3GB的数据,这个数字里同时包含了读和写的流量。厂商公布带宽时如果用的是GB/s而没有说明基数,对比时会差出大约2.4%,这是个容易忽略的换算坑。
输出里最该看的是Best Rate,它是多次运行里最快的一次,对应Min time。Avg time和Max time反映系统抖动。如果Max time比Min time大出20%以上,说明测试过程中有后台负载干扰或CPU降频,这次跑分需要重测。
多线程线程数的影响也值得记一笔:单线程跑反映单个核心能压出的访存带宽,多线程跑反映整机内存控制器上限。线程数从1逐步加到满核,带宽会先涨后平,持平的那条线就是平台真实带宽上限。如果线程数翻倍带宽还在明显涨,说明之前没喂饱内存控制器。
4. 跑分前的关键配置:数组大小、NUMA绑定与带宽验证
4.1 数组大小怎么改才算合理
数组大小决定测试是否真正压到内存而不是Cache。先说结论:上限是四个数组总字节数不超过物理内存的1/4,避免swap干扰;下限是单个数组大小超过L3 Cache容量的4倍。结合常见机器配置给个参考表:
| 机器配置 | L3 Cache参考值 | STREAM_ARRAY_SIZE建议 | 四个数组占内存 |
|---|---|---|---|
| 消费级台式机,16GB内存 | 32MB | 50,000,000 | 1.6GB |
| 双路服务器,64GB内存 | 64MB | 200,000,000 | 6.4GB |
| 大Cache平台,256GB内存 | 以lscpu实测为准 | 建议按L3的4倍换算 | 不超过总内存1/4 |
判断当前数组大小是否合适,最直接的办法是翻倍重跑:把STREAM_ARRAY_SIZE改成原来的2倍,重新编译后再跑一轮。如果Best Rate涨幅超过5%,说明之前的数据没完全走出Cache,需要继续加大;如果变化在2%以内,说明带宽已到平台上限,这个数组大小就够用。
有些场景下数组大小改太大反而坏事。四数组总字节数超过物理内存一半后,页面回收压力陡增,测试时间也变长,跑分里混进不少非内存带宽的时间损耗。我一般宁愿多跑几轮小数组,也不让单机测试里出现swap痕迹。
4.2 NUMA拓扑确认与绑定
多路服务器上,NUMA是STREAM最大的变量,不处理直接就跑可能得到一份废数据。先确认拓扑:
lscpu | grep -E "NUMA node|CPU\(s\)" numactl --hardware输出里能看到available: 2 nodes (0-1),node0有16个CPU和64GB内存,node1同规格。在这种机器上直接./stream,线程可能被调度到两个节点,内存页面也会在两个节点的本地内存里交叉分配,跨节点访问的延迟和带宽消耗会明显干扰结果。
单节点评估按这样绑定:
export OMP_NUM_THREADS=16 numactl --cpunodebind=0 --membind=0 ./stream--cpunodebind=0把进程限定在node0的CPU核心上,--membind=0把内存分配限制在node0的本地内存。两个参数同时给,才能保证线程和内存页面都在同一个节点内。
如果目标是测整机所有内存控制器同时工作的聚合带宽,用interleave策略:
numactl --interleave=all ./streaminterleave模式会把内存页依次轮转分配到所有节点,避免热点内存集中在某个节点导致的带宽倾斜。多路服务器做整机评估时interleave跑出来的值才有意义,单节点评估还是用membind。
OpenMP线程的分布方式也要管,否则可能两个线程挤在同一物理核上抢执行单元。配合使用这两个环境变量:
export OMP_PLACES=cores export OMP_PROC_BIND=closeOMP_PLACES=cores让线程绑定到整核,OMP_PROC_BIND=close让线程按紧凑方式依次占用相邻核心。检查CPU是否开了超线程用lscpu看Thread(s) per core,如果为2,这两个变量能避免超线程带来的额外干扰。
4.3 结果计算与理论峰值带宽验证
跑完别只记分数,先用理论公式反推一下合理性。DDR4理论带宽公式:
理论带宽(MB/s) = 内存频率(MT/s) × 单通道位宽(8字节) × 通道数DDR4-3200搭配8通道的计算:
3200 × 8 × 8 = 204800 MB/s ≈ 200 GB/s常见平台的参考值:
| 内存规格 | 通道数 | 理论峰值带宽 |
|---|---|---|
| DDR4-2666 | 4 | 85.3 GB/s |
| DDR4-2933 | 8 | 187.7 GB/s |
| DDR4-3200 | 8 | 204.8 GB/s |
| DDR5-4800 | 8 | 307.2 GB/s |
注意DDR5的架构和DDR4不同,单条模组内部通道逻辑有变化,单纯用上述公式算出来的值通常偏乐观,DDR5平台建议直接拿STREAM实测值跟同平台已知成绩做对比。
确认当前内存实际频率用dmidecode:
sudo dmidecode -t memory | grep -E "Type:|Speed:|Configured Memory Speed:|Size:"输出里Speed是内存条标称最高速度,Configured Memory Speed是当前实际运行速度,两者不一致就是降频的最好证据。比如8根DDR4-3200跑在2666,STREAM分数会比标称低一截,这类问题单独看lscpu根本不暴露,必须靠dmidecode加STREAM一起定位。
最后给一个效率判断清单:
| 指标 | 合理区间 | 建议动作 |
|---|---|---|
| Triad带宽/理论峰值 | DDR4通常65%~85% | 低于60%查通道数和频率 |
| Copy带宽/理论峰值 | 通常略低于Triad | 注意写分配影响 |
| 多线程带宽/单线程带宽 | 应接近内存控制器上限 | 若翻倍线程分数仍涨,继续加线程 |
5. 避坑指南:STREAM跑分常见的五个翻车现场与排查方法
STREAM的坑大多不在工具本身,而在编译选项和运行环境。下面五条都是我在不同机器上实测踩过的,按现象、原因、解决的顺序写。
5.1 编译器把循环优化没了,跑分虚高到离谱
现象:Copy跑出300GB/s甚至更高,明显超过内存理论峰值;或者四个模式分数基本相同,像是从Cache里读出来的。
原因:编译器在高优化级别下分析出循环写入的数据从未被使用,直接把循环体删除,这就是dead code elimination。STREAM源码里有一段用于校验和值的机制,就是为了防这种优化。改动过源码,或者加了-lto这类链接时优化选项,就很容易踩上。
解决:编译命令保持gcc -O3 -march=native,不加-flto。运行后留意输出末尾的校验信息,只要校验通过就说明循环没有被删。如果校验被绕过了,结果一律作废。还有一种类似现象来自数组太小,数据全命中L2,Copy成绩可能超过理论峰值三倍,但那种情况改大数组大小就能解决,两个原因要对号入座。
5.2 双路机跨NUMA节点访存,分数反而低于单路
现象:双路机器默认方式跑STREAM,成绩比同配置单路机还低;或者同一台机器两次跑分波动超过10%。
原因:进程被任意调度到node0或node1,内存页面由伙伴系统从某个节点分配,两边没有对齐。节点间的UPI链路带宽有限,跨节点流量一大,整体带宽就被链路卡住。
解决:先numactl --hardware摸清拓扑,单节点对比用--cpunodebind=0 --membind=0,整机聚合测试用--interleave=all,并配合OMP_PLACES=cores控制线程分布。虚机里没装numactl的话用taskset也能绑CPU,但物理机上还是numactl最省事。
5.3 CPU降频和超线程让成绩波动不止
现象:连续三轮STREAM,Triad最好值和最差值相差3%以上,Max time是Min time的两倍。
原因:STREAM的向量化循环负载重,AVX-512指令会让CPU频率自动下探,散热不够再叠加降频,带宽数据自然漂移。
解决:跑分前让机器空转1分钟预热,跑分过程记录频率:
turbostat --show freq -i 2 # 或者轻量一点 watch -n 2 "grep MHz /proc/cpuinfo"如果看到频率阶梯式下探,等系统凉了再重测。比较严谨的做法是BIOS里固定P-state(服务商允许的前提下)做同一组对比,否则每次跑分都记录温度和频率区间,标注在结果里。超线程方面,把OMP_NUM_THREADS设成物理核心数,往往比直接设满逻辑线程数更稳:
lscpu | awk -F: '/^CPU\(s\):/{cores=$2} /^Thread.s per core:/{tpc=$2} END{print cores/tpc}'5.4 内存实际频率和通道数与标称不符
现象:DDR4-3200标称搭配8通道,STREAM只跑出100GB/s多一点,效率不到60%。
原因:内存条插槽顺序不对,8根内存只工作在了4通道;或者BIOS没开XMP/EXPO,内存跑在默认的2400MT/s档位;还有可能是不同容量的条子混插触发了Flex Mode。
解决:用dmidecode查每根条的Speed和Configured Memory Speed,对照主板手册确认插槽所属通道。BIOS里恢复成标称频率后再跑一轮对比。物理机上可以拔掉一半内存跑,如果跑分几乎不变,说明当前配置的内存通道本来就没喂满,或者通道数冗余,插槽顺序需要调整。
5.5 对比基准不统一导致结果误判
现象:同一台机器内核升级、BIOS更新前后跑STREAM,分数差15%;两台硬件完全相同的机器分数差6%,查了半天发现是gcc版本不同。
原因:STREAM结果的噪声来源太多,编译器版本、优化参数、数组大小、线程数、NUMA绑定策略、频率策略全都影响最终值。
解决:每次跑分把编译命令、CPU策略、频率温度记录到同一份测试日志里。内部建立基准时,固定gcc版本和编译选项,用脚本一键执行。跨平台对比时还有个细节:数组大小不要盲目统一成同一个数字,因为Cache更大的机器会因此占便宜;更好的做法是各自按L3 Cache的4倍换算数组大小,这样对比的才是内存子系统的真实能力。
6. STREAM结果的应用与验证:把跑分变成调优决策
6.1 用STREAM验证内存配置变更的效果
最常见的用法是BIOS调整前后各跑一次。我习惯把基准测试固化成一个脚本,每次跑完自动生成带时间戳的结果文件:
#!/bin/bash # 简易STREAM基准脚本:记录环境信息与跑分 LOG=stream_$(date +%Y%m%d_%H%M%S).log { echo "== CPU ==" lscpu | grep -E "Model name|CPU\(s\)|NUMA node" echo "== MEM SPD ==" sudo dmidecode -t memory | grep -E "Speed:|Configured Memory Speed:" echo "== STREAM ==" OMP_PLACES=cores OMP_PROC_BIND=close numactl --cpunodebind=0 --membind=0 ./stream } | tee "$LOG"这个脚本把环境信息和跑分一起落盘,回看日志时能直接判断分数变化是来自内存配置改动,还是来自系统环境漂移。每次改完内存时序或频率,我都强制过一遍这个脚本。
6.2 多轮跑分与波动判断
单轮跑分容易踩到随机干扰,我一般跑3轮取中间值:
for i in 1 2 3; do OMP_PLACES=cores OMP_PROC_BIND=close \ numactl --cpunodebind=0 --membind=0 ./stream | grep Triad done三轮结果相差在2%以内可以接受,超过3%就要查频率、温度和后台负载来源。别取最大值当结论,取中间值更稳。
6.3 结合perf与业务负载验证
STREAM是平台能力参考,最后还是要落到实际业务。数据库或数据仓库压测场景里,我会用perf看实际程序对访存的消耗:
perf stat -e task-clock,cycles,instructions ./your_benchmarkSTREAM告诉你系统上限能跑多快,perf和业务压测告诉你业务实际用了多少带宽,两者放在一起,才能判断“瓶颈在内存带宽”是硬瓶颈还是业务侧没用好。
结尾收一下:我有一次为了压榨性能,把一台数据库服务器的内存频率往上拉了一档,没跑STREAM就直接上生产,晚高峰时系统响应明显劣化,回滚配置一查,果然是内存带宽反而下降了。从那以后,每次调内存频率或加内存条,我都先跑一轮三轮循环的STREAM基准,确认Triad稳定且没有明显回落才放行。这个习惯帮我避开了好几次“看似配置升级、实则性能回退”的情况,希望帮到你。
本文还有配套的精品资源,点击获取