cachebench 实战:MB/s 曲线定位 L1/L2/L3 和内存带宽

发布时间:2026/10/23 15:11:10
cachebench 实战:MB/s 曲线定位 L1/L2/L3 和内存带宽

简介:LLCbench 缓存性能测试工具包面向 Linux 平台下从事处理器缓存层次分析、体系结构实验与平台性能对比的开发者、教师和学生,核心集成 MPBench、CacheBench 与 BLASBench,其中 CacheBench 侧重考察不同缓存层级、工作集大小和访存模式下的性能变化,可用于课程实验、硬件选型、编译优化验证及论文实验数据采集等场景,测试模块可单独编译选用。压缩包共 81 个文件、约 83KB,以 C 源码与头文件承载测试主体,配合多套 Makefile 及 sys 平台配置模板适配不同 Linux/Unix 环境,另有 shell 脚本、ps 与 gp 绘图脚本、HTML 说明和 README 文档,目录按 cachebench、mpbench、blasbench、doc 等模块划分,源码、配置与说明文档相互配套,体量轻便而结构完整。已有 873 人学习下载,适合希望获得可编译的缓存基准代码、复现缓存性能实验并借助脚本生成结果图表的读者参考;通过结果图表可直观比较不同平台和编译选项的缓存表现,帮助定位访存瓶颈,也能为性能测试方案设计提供可复用的配置与排错线索。

1. 新机器跑分和老机器持平:我为什么又翻出 cachebench 这包老代码

上个月给一台新装的机器做处理器性能测试,单核浮点分数涨了快一半,可一跑真实业务里的查表逻辑,吞吐几乎没动。这种“算力涨了、访存没跟上”的场面见得太多了,最后能一句话说清问题的,还是 cachebench 这种老派 linux 开源工具——喂它一个从 1KB 到几百 MB 的数组,它把每一档的读写带宽用 MB/s 打出来,拐点在哪、L3 有多大、内存实际能喂多快,一眼就能看出来。llcbench.tar.gz 就是这套东西的源码包,里头不止 cachebench,还带着单机浮点算力和多进程消息传递两套测试。适合谁:要给出机器性能结论的人、要做选型对比的人、以及怀疑自己程序被内存卡住却拿不出证据的人。

2. 解包到跑通:把 llcbench.tar.gz 编成三个可执行文件的完整路径

2.1 先看骨架再动手,别上来就 make

拿到 tar 包第一件事不是编译,是看结构。这类九十年代风格延续下来的 C 工程,不同人打包时目录层级会变:有可能顶层就是一个llcbench/,也有可能解压出来直接是一堆并列的子目录;编译入口可能是顶层Makefile加一堆Makefile.<arch>模板,也可能是一个make.def让你改。先花三十秒看清结构,比后面make报错十次再去猜要快得多。

包里三个主要工具分工很明确,我一般按“先 cachebench、需要时再补另外两个”的顺序推进:

子目录(名称以实际解包为准)测什么什么时候必须跑
cachebench单一地址流下的读、写、拷贝、memset 带宽,按数组尺寸逐档输出每次都要跑,是判断缓存层级的核心
blasbench单机 BLAS 级别的浮点算力,单线程/多线程各一份需要把“算力”和“访存”拆开归因时
mpbench多进程之间消息传递的延迟与带宽多路机器怀疑跨节点扩展性时才需要
Makefile / make.def 之类各架构编译模板复制一份改成自己的,别直接改原文件
# 不解压也能看结构,先确认顶层目录名叫什么 tar tzf llcbench.tar.gz | head -40 # 解压到独立目录,避免污染工作区 mkdir -p ~/bench && tar xzf llcbench.tar.gz -C ~/bench cd ~/bench && ls -la # 找编译入口:顶层 Makefile、各架构模板、以及可能的 make.def find . -maxdepth 2 \( -iname 'Makefile*' -o -iname 'make.def' -o -iname '*.mk' \) | sort

这段的目的只有一个:确定“编译入口文件名”和“产物会落在哪个目录”。tar tzf只列名字不落盘,几百 KB 的包秒出结果;find的-maxdepth 2是刻意的,这类老工程子目录里常有一堆测试脚本也叫 Makefile,搜太深反而干扰判断。如果find出来的模板里有Makefile.linux,基本就是它了;只有make.def的话,通常是先改make.def里的CC、CFLAGS,再回顶层make。

2.2 编译选项定死:为什么我把 -O3 换成了 -O2

压测程序最怕的事情,是编译器比你还聪明。cachebench 的读测试本质上就是“把数组从头到尾读一遍,把结果攒起来”,如果编译器判定这个循环的最终结果没人用,它会直接删掉——你测出来的“带宽”其实是空循环的速度。这类工程内部一般会用一些手段防止被优化掉(把结果写进全局变量、提供保留结果的开关),但你的-O3会额外打开向量化和更激进的循环变换,让读循环一次搬 32 字节甚至 64 字节,测出来的数就变成了“SIMD 宽度测试”,跨机器一比全是噪声。

所以我的固定做法是:统一-O2,不用-march=native,把gcc --version和uname -a一起写进报告。-march=native在同一台机器上自测很爽,但只要涉及两台以上机器对比,编译选项不一致,数据就没有可比性——这是血泪经验,不是洁癖。另外注意 32 位与 64 位构建:指针宽度变了,数组内部地址计算的指令序列也会变,同一个 size 下的带宽能差出百分之十几。

# 环境快照先落盘,后面写报告直接引用,省得回头补 { uname -a gcc --version | head -1 lscpu | grep -Ei 'model name|mhz|cache|numa' free -g } > ~/bench/env.txt # 编译:优先用顶层入口,失败再退回模板 make clean >/dev/null 2>&1 make # 若提示需要指定架构,改成 make linux 或 make -f Makefile.linux # 产物一般在各子目录下,按可执行权限筛一遍 find . -maxdepth 2 -type f -perm -u+x -name '*bench*' -print

这段里make clean先跑一遍很关键:老工程的 Makefile 常常不写头文件依赖,改过配置后不清理会链接到上一次的.o,症状是“明明改了选项,结果一点没变”。find那条是按可执行权限加名字双重过滤,比ls */靠谱,因为部分版本会把产物放在bin/或上一级。编译失败最常见的两类原因:一是模板里的编译器名写死成了某个厂商的cc,改CC=gcc即可;二是 64 位环境下老代码里的指针强转报 warning 被-Werror升级成 error,去掉-Werror就好,不用去修代码。

2.3 先跑一次 smoke test,把用法抄下来

不同打包版本的参数集合会有出入,所以第一步永远是“不带参数跑一次”,让它自己把 usage 打出来。把 usage 存进文件,后面写脚本时对着抄,比记 memo 靠谱。

# 不带参数:多数版本会打印用法后退出,把 usage 留档 cd ~/bench/llcbench # 目录名以实际解压结果为准 ./cachebench 2>&1 | head -30 | tee ~/bench/usage.txt # 最小一次可跑:只做读测试,最大数组给 64MB taskset -c 2 ./cachebench -r -s 67108864 | tee ~/bench/read_smoke.txt

-r是读测试,-s给的是“扫到多大为止”的上限而不是单点尺寸——这一点最容易误解,很多人以为-s是“测一个 64MB 的数组”,结果看到输出里一长串不同尺寸的行就懵了。taskset -c 2把进程钉在 2 号核上,是为了排除调度器在核间搬进程带来的抖动;具体绑哪个核要看lscpu -e,尽量避开 0 号核(很多系统中断默认落在 0 号)。跑完先别急着看图,head -20 read_smoke.txt人工确认两件事:第一列到底是不是字节数、最后一列到底是不是 MB/s。这个确认动作只花十秒,但决定了后面解析脚本写得对不对。

3. cachebench 的四种访存模式:读、写、拷贝、memset 的扫参与落盘

3.1 四种模式分别对应什么真实负载

读、写、拷贝、memset 这四种模式不是凑数的,它们对应四类完全不同的访存路径。读测试走的是 load 通路,最接近查表、遍历、哈希探测这类逻辑;写测试走 store 通路,现代 CPU 上写往往是“写分配”,会先把 cache line 读进来再改,所以写带宽经常比读低一截,看到这个不要以为机器有问题;拷贝测的是memcpy类路径,同时吃读写两条通路,还要看有没有非临时存储指令参与;memset 则是纯写、且可以整行覆盖,硬件能做写合并,通常跑出来是四个里最“好看”的一个。

如果你只跑一个-r,很容易得出“这台机器内存带宽 20GB/s”的结论,但这个数字换到 memcpy 密集的场景完全不成立。我的习惯是四个模式一起跑,落成四份原始文本,后面既画单条曲线,也画四条叠在一起的对比图。

mkdir -p ~/bench/raw # 固定绑核 + 固定最大尺寸,四个模式各跑一遍;-x 是每个尺寸的重复次数 for mode in r w b f; do taskset -c 2 ./cachebench -$mode -s 268435456 -x 3 \ > ~/bench/raw/${mode}.txt 2>&1 done # 顺手把每份输出的前几行打出来,确认格式没变 for f in ~/bench/raw/*.txt; do echo "== $f"; head -5 "$f"; done

-x 3是每个尺寸重复三次,取其中的最好值或中位数(不同版本策略不同,看输出里是不是出现多列)。重复的意义在于压制瞬时噪声:后台的一次日志刷盘、一次中断,就能让某一个尺寸点掉 15%,单次采样根本分不清是噪声还是真实的层级跌落。-s 268435456给到 256MB,是为了确保一定能跨过 L3 落到内存——现在不少服务器 L3 就有 32MB 到 64MB,上限给小了,曲线只到 L3 就结束了,看不到内存那一段。

注意:-s的单位在不同版本里可能按字节、也可能按元素个数解释。跑完先看输出的尺寸列最大值是不是接近你给的数字,对不上就按元素个数理解,乘以元素大小换算回来。

3.2 想要更细的采样点,就改进尺寸递增步长

这类工具内部通常是按 2 的幂往上翻:1KB、2KB、4KB……一路翻到上限。粗粒度扫的时候够用,但拐点会被夹在两个点中间,你只能得出“L2 在 256KB 到 512KB 之间”这种模糊结论。更麻烦的是纯 2 的幂会引入组相联索引冲突,这个在 4.2 节单独说。想细化,就去源码里找那条递增语句,把翻倍改成 1.25 倍。

# 定位尺寸递增的位置:常见写法是 size <<= 1 或 size *= 2 grep -n -E '<<=|<< ?1|\*= ?2|size ?\*=' cachebench/*.c | head -20

改之前把原文件备份一份,改完重新make。步长改成 1.25 倍之后,采样点数会从十几个涨到六十几个,单次运行时间大概是原来的三四倍,但拐点位置能精确到一个窄区间,值得。改完记得在报告里注明“步长经过修改”,否则别人拿原版跑出来的点数和你对不上,会以为你数据有问题。

3.3 把文本表格变成 CSV:解析脚本与列校验

原始输出是给人看的,要做对比就得转成表。不同版本的列数和列序有差异,所以解析脚本不能写死列号,我的做法是“取行内第一个数字当尺寸、最后一个数字当带宽”,再加一道合理性校验,异常行直接丢掉并在日志里计数。

import re import csv import pathlib # 版本间列序有差异,用宽松策略:首个数字=尺寸(字节),末个数字=带宽(MB/s) NUM = re.compile(r'[-+]?\d+(?:\.\d+)?') rows, dropped = [], 0 for p in sorted(pathlib.Path('raw').glob('*.txt')): for line in p.read_text(errors='ignore').splitlines(): s = line.strip() if not s or s.startswith('#'): # 跳过注释与空行 continue nums = NUM.findall(s) if len(nums) < 2: dropped += 1 continue size, bw = float(nums[0]), float(nums[-1]) # 合理性校验:尺寸递增、带宽为正且不超过 1e7 MB/s if size <= 0 or bw <= 0 or bw > 1e7: dropped += 1 continue rows.append({'mode': p.stem, 'size_bytes': int(size), 'mb_per_s': bw}) rows.sort(key=lambda r: (r['mode'], r['size_bytes'])) with open('cache_curve.csv', 'w', newline='') as f: w = csv.DictWriter(f, fieldnames=['mode', 'size_bytes', 'mb_per_s']) w.writeheader() w.writerows(rows) print(f'保留 {len(rows)} 行,丢弃 {dropped} 行') for r in rows[:5]: # 人工核对前几行 print(r)

这段的逻辑说明:errors='ignore'是因为个别版本会在输出里混入非 UTF-8 的控制字符,不忽略会直接抛异常;nums[-1]取最后一个数字而不是固定列,是为了兼容“带宽前面还有一列耗时”的版本;bw > 1e7这个上限看着随意,实际非常有用——解析错位时经常把行号或时间戳当成带宽读进来,会出现上千万的荒谬值,用一条上限就能整行拦掉。最后打印前五行是强制的人工校验环节,绝不能省。

提示:把dropped打进日志。如果丢弃比例超过两成,说明输出格式和你预期不符,先回去看原始文件,别硬跑。

4. 处理器性能测试的读数:MB/s 曲线怎么对上 L1、L2、L3 和内存

4.1 拐点的判定方法,以及各层的典型量级

把size_bytes取对数做横轴、mb_per_s做纵轴,画出来是一条阶梯:最左边平、然后掉一档、再平、再掉,最后到内存段变成一条缓坡。判拐点我不用肉眼,用相邻点的衰减比:bw[i]/bw[i-1] < 0.85就记一次“掉层”,记录下来那个尺寸区间,再和lscpu报的 cache 容量对照。

层级典型容量单线程读带宽量级曲线特征
L132KB~64KB数十 GB/s 到上百 GB/s全程最平最高的一段,尾部开始抖
L2256KB~2MB几十 GB/s第一次明显跌落,跌幅常在三成以上
L38MB~64MB(部分型号更大)十几到几十 GB/s第二次跌落,坡度变缓但不消失
内存按实际装机单线程常见几 GB/s 到十几 GB/s缓坡,尺寸再翻倍带宽也基本不动

要强调的是“单线程”这三个字。cachebench 是单线程的,量出来的内存带宽只代表一个核能拉起来的部分,跟厂商宣传的整机峰值带宽差好几倍是正常的。拿这个数去和整机峰值比,然后得出结论“内存性能不达标”,是新手最容易犯的错。

4.2 2 的幂尺寸会造出假拐点

这是我在这个工具上踩过最隐蔽的坑。数组尺寸全部取 2 的幂,而且起始地址又是对齐的,那么步长和 cache 的组相联索引之间就会形成固定关系:地址低位截出来的组号总是落在很少的几个组上,L1 或 L2 里明明还有空间,却因为冲突缺失被迫反复回下层取数。表现在曲线上,就是“本该在 L2 段的位置提前掉了一档”,你会误判 L2 只有一半大。

识别方法很简单:把起始地址人为错开。要么改步长(3.2 节的 1.25 倍递增),要么在数组前面多申请几十个字节做偏移。做完这两件事其中一个,如果那一档突然回升,就确认是冲突而不是容量。我现在的固定做法是步长用 1.25 倍并加偏移,两条都上,省得事后怀疑。

注意:不要用-O3加手动改源码的方式“优化”这个问题,动了访存循环,测出来的就不是同一件事了。

4.3 和 perf、blasbench 交叉验证,确认你测的是缓存

单一工具的数字再漂亮也只是自证。我的验证办法是用perf stat挂在同一次运行上,看 LLC 相关的计数有没有随尺寸同步变化。

# 在带宽明显跌落的尺寸上挂一次 perf,看末级缓存缺失是否同步跳起 perf stat -e cycles,instructions,cache-references,cache-misses,LLC-loads,LLC-load-misses \ taskset -c 2 ./cachebench -r -s 33554432 # 算力侧对照:blasbench 拉一遍单线程浮点,确认算力不是瓶颈 cd ../blasbench && taskset -c 2 ./blasbench 2>&1 | tail -20

判读逻辑:如果尺寸刚从 L3 跨到内存,LLC-load-misses占LLC-loads的比例应该同步升高,同时 cycles 里等待访存的比例上升。反过来,如果带宽掉了但 LLC 缺失率没动,说明你测的可能不是缓存层级,而是被别的因素(频率降了、页没驻留、进程被抢)干扰了。blasbench 那一侧的作用是排除法:算力分数正常而 cachebench 曲线异常,问题就落在访存路径上,方向立刻收窄。

5. 避坑与排查:五个让缓存测试数据白测的坑

5.1 读带宽高得离谱,而且整条曲线是平的

现象:-r跑出来每一档都在同一个极高的数值上,从 1KB 到 256MB 几乎不掉,看着像“这台机器没有缓存层级”。 原因:读循环被编译器优化掉了,或者结果被判定为无用而整段删除,测到的是空循环速度。老代码里防优化手段有限,遇上新一代编译器就顶不住。 解决:先降到-O2重编;如果依然平,看工具是否提供“保留结果”的开关(usage 里常有一个类似的选项)并打开;再不行就手动在循环末尾把累加结果赋给一个全局变量,重编后再跑。验证方式是整条曲线的绝对值应该落在合理量级,而不是高出十倍。

5.2 同一份二进制,两次运行差出百分之二十

现象:什么都没改,连跑两遍,内存段的带宽一次 12GB/s 一次 9GB/s。 原因:多次运行之间变量太多——CPU 频率还在爬升、页表还没热、进程被调度到别的核、邻居进程在抢 LLC。第一次运行尤其吃亏,大量缺页发生在测试过程中。 解决:先空跑一轮作为预热并丢弃结果;用taskset把进程钉死在一个核上;把-x重复次数提高,取重复里的稳定值;跑之前确认机器上没有别的重负载。做完这四件事,两次运行的差异一般能压到 5% 以内。

5.3 拐点位置和 lscpu 报的 cache 容量对不上

现象:lscpu显示 L2 是 1MB,曲线却在 256KB 就掉了一档。 原因:多半是 4.2 节的索引冲突,少数情况是透明大页把相邻页合并,改变了实际的地址映射,或者数组没有按大页对齐。 解决:先把步长改成 1.25 倍、给数组加偏移重测;仍然对不上就临时把透明大页设为madvise并确认进程没有主动使用大页;最后检查是不是把“每核 L2”看成了“整颗 L2”,多核机器的lscpu输出容易看串行。

5.4 大尺寸段的带宽低到不像内存,像磁盘

现象:数组开到 512MB 以上,带宽掉到几百 MB/s,且抖动极大。 原因:申请的数组超过了可用物理内存,或者第一次触碰触发大量缺页,测试过程里混进了换页和页清零。 解决:把-s的上限压到物理内存的一半以内,free -g先看一眼可用量;测试前确认vmstat 1里si/so一直是 0;如果机器上还跑着别的服务,换一台干净的机器测,这种测量对内存压力非常敏感。

5.5 多进程那套工具起不来,或者只跑了一个进程

现象:想顺手验证多核扩展性,结果进程数始终是 1,或者直接报连接错误。 原因:多进程部分需要一个启动器来拉起多个实例,单独执行可执行文件只会跑单进程;另外 MPI 环境没配对时,进程间握手会直接失败。 解决:先看该子目录里有没有配套的启动脚本,按脚本里的参数格式传进程数;确认mpirun之类的前置环境可用且版本一致;实在跑不通就先放弃它,cachebench 的单线程结论已经足够支撑大部分判断,不必为了“跑全”卡在这里。

6. 进阶:把一次性的缓存测试做成可回归的基线

单次测量只能回答“现在是多少”,真正省事的是把它变成一条能对比的基线。我的做法是把第 3 章的采集脚本和第 3.3 节的解析脚本串起来,一次跑完输出一个带环境指纹的 CSV:文件名里带日期和 CPU 型号,内容第一行写uname -r、gcc版本、-s上限、是否改过步长。以后任何人问“这台机器缓存是不是变慢了”,拿两份 CSV 对齐size_bytes做差值就行,不用重新组织测试。

对比的时候必须设阈值,否则你会被噪声牵着走。我的经验阈值是:同一尺寸两次测量差异在 5% 以内视为一致;5% 到 15% 之间需要复测一次再判断;超过 15% 才认为发生了变化。这个阈值不是拍脑袋,是按 5.2 节把预热、绑核、重复次数都做到位之后,实测残留的波动范围定的。

# 一键基线:采集 + 解析 + 打指纹,产物可直接进版本库 STAMP=$(date +%Y%m%d)-$(lscpu | awk -F: '/Model name/{gsub(/^ +/,"",$2); print $2}' | tr -d ' ') bash sweep.sh && python3 parse.py mv cache_curve.csv "baseline-${STAMP}.csv" sha256sum "baseline-${STAMP}.csv" >> baseline.sha256

这套东西跑顺之后,还有个额外收益:换编译选项、换内核、换固件这类变更,只要跑一遍基线就能看出访存路径有没有受影响,比等业务侧反馈“最近好像慢了”要早得多。绑核那一步别偷懒,我吃过亏——有次忘了taskset,曲线整体平移了 8%,排查了半天以为是驱动问题,最后发现是进程被搬到了另一个 NUMA 节点的核上。从那以后我每次跑基线都强制走一遍taskset绑核加一轮预热丢弃,脚本里写成默认行为,不给自己留手动省略的机会。希望帮到你。

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