☰
用stream定位服务器内存带宽瓶颈:Linux性能测试实战
2026/10/6 9:43:30 网站建设 项目流程

简介:Linux 平台下评估内存带宽性能的 STREAM 基准测试工具源码包,面向系统管理员、性能优化工程师以及需要进行硬件选型和底层调优的开发者。STREAM 由 John D. McCalpin 博士研发,通过 Copy、Scale、Add、Triad 四类典型内存操作,分别测试内存复制、比例运算、加法运算和组合计算场景下的连续带宽,是业界广泛认可的内存性能衡量方法。资源包共包含 8 个文件,压缩后仅 17KB,主要由 Fortran 与 C 语言两套实现源码、Makefile 构建脚本、使用说明文档、许可证及版本历史记录组成,便于在 Linux 环境中直接编译与运行。已有 2965 人学习使用。读者可获得完整可编译的基准测试套件,通过调节数组规模、优化编译选项等参数复现不同负载下的带宽表现,帮助定位内存控制器、缓存层次结构等硬件瓶颈,为系统性能评估、硬件选型与配置调优提供可靠依据。同时可对比不同主机、内核参数或容器化部署环境下的测试数据,提升服务器部署与调优的可观测性。

1. stream 测的不是缓存容量:内存带宽瓶颈为什么更难被发现

服务器“越用越慢”,看 CPU 占用不高、磁盘没写满,业务就是起不来。这类机器我一般先跑一遍 Linux 内存性能测试工具 stream,不是看内存够不够,而是看 CPU 从内存里拿数到底有多快。内存带宽这个指标很隐蔽,CPU 补丁、编译器版本、NUMA 节点跨了,甚至 BIOS 里一个和节能相关的开关,都会让带宽掉两三成,而 top 和 free 却一点异常都看不出来。

stream 解决的就是这个诉求:用 Copy、Scale、Add、Triad 四种规则操作去压内存子系统,量出持续带宽和对应的延迟表现,判断瓶颈到底在访存通路还是在 CPU 算力。它适合三类人:给服务器做性能评估的运维,写高性能计算的开发,以及刚拿到新机器想验证多通道内存是否生效的采购验收人员。测完得到一组数字后,能不能信、和什么比,才是真正花时间的部分。

2. 先读懂 stream 在测什么:带宽、翻倍率与访存模型

2.1 内存性能不是看容量,是看 CPU 能不能“吃饱”

随手打开一个 Linux 终端,看到的 free -g 只是容量,它告诉你还能塞多少数据,却不告诉你数据到 CPU 要等多久。真正卡住 CPU 的是内存带宽,也就是单位时间内内存子系统能吐给 CPU 的字节数。现代服务器 CPU 的 L3 缓存已经做到 32MB 甚至 64MB,写得很好的循环可以完全命中和它无关,但数据规模一大,缓存装不下,就只能反复走内存控制器,这时候带宽比频率更致命。

带宽的上限是硬件决定的:内存通道数、DDR 代数、频率、CPU 集成的内存控制器数,这几个一起构成了理论峰值。但实际带宽还要看读写比例、访问是否连续、多核抢内存的并发程度,这也就是 stream 这类基准工具存在的理由。CPU 主频高不代表带宽好,我见过 3.0GHz 的至强跑不过 2.2GHz 的同代产品,原因就是被测程序只跑单核,内存控制器根本没被喂满,或者编译器把循环优化成了寄存器操作,压根没访问内存。

2.2 看懂 stream 的四个内核:Copy、Scale、Add、Triad 各自意味着什么

stream 的源码由主程序和计时函数组成,主程序里定义了四个数组 a、b、c,然后反复执行四段循环,分别叫 Copy、Scale、Add、Triad。它们不是四种测试模式,而是四种最典型的访存模式,覆盖了读密集、写密集和混合读写这三种情况。

Copy 做的是c[i] = a[i],对每个元素读一次、写一次,操作字节数是数组中元素大小乘以二。Scale 是b[i] = scalar * c[i],同样读一次写一次,量上和 Copy 一样。Add 是c[i] = a[i] + b[i],读两个数组、写一个数组,操作的字节数变成原来的三倍。Triad 是a[i] = b[i] + scalar * c[i],和 Add 一样读两个写一个,但顺序和乘法结构略有不同,软件优化时编译器能做的变换更多,所以它最常被用来衡量内存带宽上限。

这四个内核的价值在于:某种内存控制器可能读带宽很好、写带宽很差,只测 Copy 会误以为整体没问题。把四段循环的结果放在一起看,才能判断瓶颈是出在读通路还是写通路。很多公开的 HPC 性能数据只报 Triad 数字,那是因为 Triad 的数值最高,最能彰显硬件峰值,实际应用中反而要看 Add 和 Copy 的最低值,那个才是你在常规业务里最常撞到的天花板。

2.3 翻倍率是判断多通道是否生效的标尺

翻倍率这个词不是 stream 官方输出的字段,而是工程师拿单线程和满线程结果做除法得到的。计算方式很简单:先记录单线程跑 Triad 的 Best Rate,再记录全核跑同样操作的 Best Rate,后者除以前者,得到的就是扩展倍数。常见情况里,普通双通道服务器能跑到 8 到 15 倍,单通道或者跨 NUMA 节点访问时,这个倍数掉到 3 以下很常见。

这个数和带宽数值是互相校验的关系。如果你看到 Triad 带宽标称 40GB/s,但翻倍率只有 2.5,那就要怀疑带宽数字是缓存命中刷出来的虚高,或者是 OpenMP 线程全部挤在同一个物理核上,根本没有并发。反过来,翻倍率看着有 12 倍,但绝对带宽还不到理论峰值的六成,那说明硬件多通道在,但某个环节没接通,多半是 BIOS 里隐通道配置有问题,或者内存条没有按双通道规则插。拿翻倍率做第一层筛选,能让你快速定位要不要深入看硬件配置。

3. 下载编译到跑通:Linux 上 stream 的最小操作路径

3.1 编译前先确认 CPU 指令集与内存通道数

stream 本身不挑指令集,它有标准 C 的版本,任何 Linux 发行版都能编译。但在编译之前花两分钟确认两件事,能省掉后面大半的排查时间:第一是 CPU 型号和内存通道数,第二是末级缓存大小。这个信息后面确定数组大小时直接用到。

# 查看 CPU 型号与编号 lscpu | grep -E "Model name|Socket|Core|Thread|NUMA node" # 查看内存条配置和通道数 sudo dmidecode -t memory | grep -E "Size|Speed|Locator" # 查看内存控制器的最大带宽能力 lspci | grep -i memory

lscpu 输出里的 Socket 数量决定了有没有 NUMA 拓扑,Thread 数决定了 OpenMP 线程数的上限。dmidecode 能看到每条内存条的大小和频率,如果四条内存条分别落在一个 CPU 的不同通道上,带宽表现会完全不一样。先记下这些信息,后面跑出的数字才有参照物。预先知道物理通道数也很重要:一台双路服务器如果只有单根内存条,stream 无论怎么优化,带宽数字都只有理论值的零头,这是硬件配置问题,不是工具没用好。

3.2 用 gcc 编译 stream:默认版和 OpenMP 版

stream 源码发布时包含了 stream.c、mysecond.c 和几个 Makefile 文件。不需要安装额外依赖,只需要系统里有 gcc 和 make。下载源码包解压后,进入目录直接编译:

# 方式一:默认编译,串行执行 gcc -O3 stream.c mysecond.c -o stream # 方式二:OpenMP 并行版,推荐日常使用 gcc -O3 -fopenmp stream.c mysecond.c -o stream_omp # 方式三:显式指定数组大小(后面会重点讲) gcc -O3 -fopenmp -DSTREAM_ARRAY_SIZE=33554432 stream.c mysecond.c -o stream_omp_big

三种方式的区别在于是否需要多线程支持。默认版适用于单核虚拟机或者只想快速验证工具本身的场景,多数服务器上直接被编译出 OpenMP 版本。-DSTREAM_ARRAY_SIZE这个宏是编译期覆盖默认数组大小的入口,stream 源码里默认定义了 2000000 个 double 元素,约为 16MB 的数组,这个尺寸在现代 CPU 上基本只测到了 L3 的那一小块,所以必须改大。

如果系统里没有 gcc,AlmaLinux、Rocky Linux、Ubuntu 这些发行版都能用包管理器快速安装。在 Debian 系上执行 apt install gcc make,在 Red Hat 系上执行 dnf install gcc make,Windows 上的 WSL 环境也可以照这个流程跑。有些发行版把 stream 直接打进仓库,叫 stream 或者 stream-benchmark,装完就能执行,但这种情况还是建议手动编译一次,因为你会同时掌握修改数组大小和线程数两个日后必用的参数。

3.3 最小运行命令与输出识别

编译完成后直接运行:

# 指定 4 个线程,跑 OpenMP 版 export OMP_NUM_THREADS=4 ./stream_omp # 输出关键信息预览 # ------------------------------------------------------------- # STREAM version # ------------------------------------------------------------- # This system uses 8 bytes per array element. # Array size = 2000000, Offset = 0 # Total memory required = 48.0 MB. # Each test will run 10 times. # ------------------------------------------------------------- # Function Best Rate MB/s Avg time Min time Max time # Copy: 8561.2 0.007504 0.007473 0.007552 # Scale: 8590.6 0.007496 0.007465 0.007520 # Add: 8391.1 0.010282 0.010245 0.010310 # Triad: 8544.8 0.010134 0.010101 0.010188

这个输出里第一段是配置回显:数组大小 2000000 是默认值,内存需求 48MB 对应三个 double 数组的总量,每个测试跑 10 次。第二段才是结果,Best Rate MB/s 是各内核的最高带宽,Avg time、Min time、Max time 是三列时间统计,时间越短越好。一组完整输出末尾还有 Solution Validates 的字样,代表结果没有被编译器优化到“没干活”的状态,这行字不能忽略。

Min time 是分析时要用的主值,因为它最接近没有任何调度噪声的状态。Copy 和 Scale 的操作字节数一样,Rate 通常在 5% 以内;如果 Copy 和 Scale 差多了,优先怀疑写缓冲策略出了问题。首次跑通后先别急着改参数,这个默认尺寸的结果不要当作最终结论,它只能证明工具链没问题,真要拿来评估机器,尺寸必须按下一章的方法调整。

4. 参数对照表:数组大小、OpenMP 线程与 NUMA 绑核怎么搭

4.1 从缓存容量反推 STREAM_ARRAY_SIZE:避开缓存命中陷阱

stream 默认的数组大小 2000000 个 double,三个数组共约 48MB,这个尺寸放在十年前够用,放到现在只能和 L3 缓存玩捉迷藏。数据如果整体能被 CPU 的 L3 装下,循环就会一直命中和内存无关,跑出的带宽数字会高得离谱,完全不能反映真实访存压力。让内存成为瓶颈的关键是数据体积要远超末级缓存,常见做法是取 L3 容量的四到八倍。

# 查看 L3 大小(单位:字节) getconf LEVEL3_CACHE_SIZE # 例如 L3 = 33554432(32MB),数组字节数取 8 倍 # STREAM_ARRAY_SIZE = 32MB * 8 / 8B = 33554432 个元素 gcc -O3 -fopenmp -DSTREAM_ARRAY_SIZE=33554432 stream.c mysecond.c -o stream_omp

这个计算的本质就是保证三个数组的总占用显著大于最后一级缓存。以 L3 32MB 的机器为例,三个数组每个 32MB,总 96MB,按 8 倍取更稳,每个数组 256MB,总 768MB,无论编译器怎么调度,任何一段数据都不可能被完整留在缓存里。数组也不是越大越好,超过单个 NUMA 节点内存的一半,页表开销会明显拉高,测试时长也成倍增加,我一般控制在 L3 容量的八倍以内。

改完数组大小后重看输出回显,Total memory required 应该变成约 768MB 而不是 48MB,这一步就是验证宏是否生效最直接的方法。如果你用的是-DSTREAM_ARRAY_SIZE但输出还停在 2000000,多半是编译时用了缓存的旧对象文件,先 make clean 再重编。

4.2 OMP_NUM_THREADS 与 OMP_PROC_BIND 的配合

OpenMP 版的 stream 默认用机器所有逻辑核,在超线程开启的服务器上,线程数顶到 64、96 甚至更多时,带宽不会线性上升,反而因为线程在物理核之间反复抢内存控制器而出现震动。常见做法是把线程数设成物理核心数,而不是逻辑核心数,比如一台 32 逻辑核、16 物理核的机器,先试OMP_NUM_THREADS=16。

语法参数绑定同样不能省。运行时加上OMP_PROC_BIND=close,让 OpenMP 把线程集中于靠近的核上,避免它们被调度器打散到两个实体内存区域。绑核工具 numactl 则更彻底,能同时限制使用的 CPU 节点和内存节点:

# 确认 NUMA 拓扑 lscpu | grep -A 5 "NUMA node" # 绑定到 node0 的 16 个物理核,并从 node0 分配内存 export OMP_NUM_THREADS=16 export OMP_PROC_BIND=close numactl --cpunodebind=0 --membind=0 ./stream_omp

--cpunodebind=0把进程锁在第一个 CPU 插槽的核上,--membind=0保证内存分配也只落在同一实体内存区域。跨 NUMA 节点的访问是 stream 跑出“越优化越慢”最常见的元凶,线程在 node0、内存却全部压在 node1 上时,每一次读都变成远端的 UPI 传输,带宽可以掉 40% 甚至更多。双路机器上如果仅测单路能力,这套组合是必加的,否则结果里混入了跨路开销,无法定位问题。

4.3 一个可以直接抄的编译运行脚本

参数组合通常在三次实验后才会稳定下来,我把常用的一套直接写成脚本,避免每次手敲漏项:

#!/bin/bash # stream_bench.sh - 依次编译并运行 stream,输出带时间标记的结果 L3_BYTES=$(getconf LEVEL3_CACHE_SIZE) ARRAY_ELEMS=$((L3_BYTES * 8 / 8)) PHYSICAL_CORES=$(lscpu | awk '/^CPU\(s\):/{print $2}') # 编译 gcc -O3 -fopenmp -DSTREAM_ARRAY_SIZE=$ARRAY_ELEMS \ stream.c mysecond.c -o stream_omp # 运行:线程数取物理核数 export OMP_NUM_THREADS=$PHYSICAL_CORES export OMP_PROC_BIND=close echo "===== $(date) =====" echo "L3 Cache = $L3_BYTES bytes, Array size = $ARRAY_ELEMS" echo "Threads = $PHYSICAL_CORES" # 用 numactl 绑在 node0 时,把下面这行注释切换 # numactl --cpunodebind=0 --membind=0 ./stream_omp ./stream_omp

这个脚本里L3_BYTES * 8 / 8的乘除看似绕了一圈,其实含义是“取 L3 容量的八倍做数组总字节数,再除以每个元素的 8 字节”。写成分步算式而不是直接乘倍数,是为了在日志里一眼看出推导路径。实际使用中如果机器有两个 NUMA 节点且内存分配不均,数字还是偏低,可以手动跑一次 numactl 版本对比,差异超过 15% 就说明有跨节点访问,需要在脚本里固定绑核策略。

4.4 一些必须当场修正的编译选项

stream 源码对编译器优化挺敏感,-O2 和 -O3 的结果差距能到 10% 以上,-O3 几乎是必选。-march=native可以针对当前 CPU 指令集做优化,能小幅提升数字,但它会让二进制失去可移植性,同目录的 stream 结果不能直接跨机器比对,如果不是在同一批机器上做横向测试,慎用。-ffast-math这类激进优化我从来不开,它可能让编译器将浮点运算重排,无论结果是更好还是更差,测量口径都已经不干净了。

还有一类情况是编译器直接把循环识别成死代码给优化没了,输出里 Solution Validates 缺失就是典型信号。遇到这种只能改回更保守的优化等级,或者检查源码里有没有被改坏。stream 的源码主体很短,某行#ifndef STREAM_ARRAY_SIZE一旦弄坏,整个评测逻辑就不可信了,所以每次都重新解压一份干净源码再改参数,是我逃避编译器魔改最省心的办法。

5. stream 结果解读与避坑:从五组数字中看出机器的真实情况

5.1 带宽怎么算出来的:Copy、Scale、Add、Triad 的字节口径

stream 每个内核的带宽都是拿“操作的总字节数”除以测试时间得到的,单位 MB/s。Copy 操作中每个元素被读一次、写一次,共操作 2 个 double,所以总字节数是 2 × 数组元素数 × 8 字节。Add 和 Triad 都读两个数组、写一个数组,总字节数是 3 × 数组元素数 × 8 字节。用之前改的 N=33554432 举例,Copy 总字节数就是 2 × 33554432 × 8 = 536,870,912 字节,约 512MB;Triad 是 805,306,368 字节,约 768MB,拿这个数除以 Min time 得到 Rate。

四个 Rate 值的大小关系也有规律:Add 和 Triad 因为字节数多,Rate 数值通常高于 Copy 和 Scale。如果一个结果里 Copy 反而最高,那要么是写缓冲策略差异,要么是数据被缓存命中了。做对比时只挑一个值没有意义,要四个一起看,特别是 Copy 和 Add 的差。

现实的对照基准是内存理论带宽:DDR4-2666 单通道的理论峰值是 21.3GB/s,双通道是 42.6GB/s,实际 stream 能跑到理论值的 75% 到 85% 就算正常。如果标称双通道 42.6GB/s 的机器 Triad 只有 15GB/s,问题基本不在工具,在硬件配置或绑核上。拿这个百分比值去衡量机器健康度,比纠结某一个内核的快慢更稳。

5.2 常见跑分翻车现场:五个坑

坑一:数组太小,带宽虚高到吓人

现象:Triad 跑到 20000MB/s 以上,远超硬件理论值;数组默认 2000000 没改。

原因:三个数组一共 48MB,L3 缓存大一点的处理器直接全部命中,测的是缓存带宽不是内存带宽。

解决:用 getconf LEVEL3_CACHE_SIZE 查缓存容量,把数组改成 L3 的四到八倍,重编重测。

坑二:单线程结果低得离谱,就断言机器不行

现象:Copy 或 Triad 只有 3000MB/s 上下,翻倍率接近 1。

原因:编译时忘了加-fopenmp,或者运行时没设置 OMP_NUM_THREADS,serial 版只跑了一个核。

解决:确认编译命令含 -fopenmp,运行前 export OMP_NUM_THREADS=物理核数。单线程数字不能代表整机带宽能力。

坑三:线程数拉满,带宽反而比 8 线程还低

现象:一台 2 路 64 逻辑核的机器,OMP_NUM_THREADS=64 时 Triad 只有 25GB/s,16 线程时反而有 50GB/s。

原因:超线程把两个线程挤在同一个物理核上,争抢执行单元和缓存,没有增加实际并行度;线程又跨了 NUMA 节点,内存访问全部变成远端。

解决:线程数设成物理核数,默认开超线程的机器在 lscpu 里 Thread(s) per core 是 2,用lscpu | grep "Core(s)"或者nproc --all的逻辑核心数除超线程倍数;绑核加 OMP_PROC_BIND=close。

坑四:同一台机器两次跑,数字波动超过 10%

现象:前一次 Triad 48GB/s,后一次只有 41GB/s,也没改任何配置。

原因:后台有监控进程、日志备份任务或另一波测试在抢内存带宽,stream 对任何额外的访存都非常敏感。

解决:选系统空闲时跑,重复 5 到 10 次取中位数而不是最大值。用taskset -c 2,6,10,14把测试线程钉在固定核上,减少调度干扰。被干扰的测试结果别当作结论,重跑一遍最稳妥。

坑五:重定向输出后,Best Rate 变成了 0 或一个固定值

现象:./stream_omp > result.log后打开文件,只有 0 或者频频出现完全相同的时间。

原因:这是典型的计时函数适配问题,stream 自带的 mysecond.c 在某些 Linux 发行版上拿不到高精度时钟,串行版本会退化为粗糙的固定分辨率。

解决:重新编译时用系统自带的clock_gettime替换计时文件,或者在源码目录里换用性能计数接口的版本。发行版里的 stream 包自带的 second.c 对多数 Linux 是兼容的,但自己编的时候要注意这个问题。

6. 把 stream 当基准工具用:稳定复测与对照实验的闭环

6.1 用硬件计数器交叉验证结论

stream 的带宽是软件算出来的,它把操作字节数除以耗时,中间假设了每次访存都真实落到了内存。硬件事件计数器能验证这个假设:Intel 平台的uncore_imc计数器可以读实际内存控制器流量,AMD 平台对应有类似事件,很多服务器自带 PMU 工具。交叉验证的做法很简单:跑 stream 前后各读一次计数器值,看差值是否接近 stream 报出的总字节数。

# 常见做法:用 perf 读取 uncore 内存带宽事件,需要 root perf stat -e uncore_imc/data_read/ ./stream_omp

如果硬件计数读出来的吞吐量和 stream 的 Best Rate 相差在两成以内,说明软件数值可信。差值过大时,优先怀疑数组没有真正访问到内存,因为缓存命中的部分不会出现在内存控制器计数里。这个闭环对长期追踪服务器性能特别有用:每一次 stream 跑分都可能存在无法量化的缓存噪声,交叉验证把噪声的范围划出来了,结论才经得住追问。

6.2 前后对照实验的同口径原则

做硬件改造、BIOS 升级、内核参数调整后,大多数人会再跑一遍 stream 看效果,但这中间的“对比”很容易翻车。同口径不只是相同命令,它要求数组大小、编译器版本、是否 OpenMP、绑核策略完全一致,任何一项变了,结果差异里就混进了方法噪声。

变量对照组实验组必须一致
STREAM_ARRAY_SIZE3355443233554432一致
OMP_NUM_THREADS1616一致
OMP_PROC_BINDcloseclose一致
gcc 版本与优化参数-O3 -fopenmp-O3 -fopenmp一致
绑核策略numactl node0numactl node0一致
被测硬件或系统参数默认 BIOS开启新特性唯一变量

实验组和对照组之间只允许有一个变量,这是基准测试的铁律。内存参数、NUMA 策略这些隐性因素很难一眼看出,对照时最好保留上个月的完整运行日志,我吃过亏:更新了一个内核小版本后,stream 数字掉了 8%,第一反应查硬件,最后发现是内核默认的透明大页策略变了,数组页被拆成了小页,TLB 未命中变多。如果当时没有记录内核参数,这个结论很难定位。

6.3 我常用的三条验证习惯

第一,结果取中位数而不是最高值。最高值反映的是最理想状态,中位数接近稳定运行时的常态,当作基线更有参考价值。第二,日志里写全环境信息:CPU 型号、内存条规格、编译参数、线程数,这几项缺了任何一项,三个月后回看结果就是灾难。第三,如果只是临时复测某台有问题的机器,我会同时跑 lscpu、free -h、dmesg 尾部,把系统状态一条命令打成一个包,排查时少走弯路。这个习惯帮我避免了很多次“只测到现象,没测到原因”的无效结论。

最后说个真实教训:有次给一台 2 路服务器做采购验收,第一次 stream 跑出非常漂亮的数字,我直接在报告里写了“性能达标”。后来同事复测发现数字掉了一半,查了一圈才确认我第一次是跑在单路节点上,另一个 CPU 插槽根本没启用。那次之后,我养成一个习惯——任何 stream 结果都要先确认 lscpu 里的 NUMA node 数量和线程绑核日志,再下结论;不然你就会在数字上建立一个完全不存在的“性能基线”,以后所有对照全歪。希望做这步验证的你,能绕过这个坑。

提示:stream 的数组大小、线程配置、绑核方法会直接影响结果可对比性,正式记录时把编译选项和运行环境一起存下来,才能让测试数据有长期参考价值。

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

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

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

立即咨询