ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

cachebench实战:Linux缓存层级性能测试、避坑与回归基线

cachebench实战:Linux缓存层级性能测试、避坑与回归基线

简介:LLCbench 是一套面向 Linux 平台的底层表征基准测试工具,集成了 MPBench、CacheBench 与 BLASBench,此处聚焦其中的 CacheBench,用于评估处理器缓存层次与内存访问性能。适合系统软件、体系结构方向的学习者和开发者开展性能实验、对比不同硬件配置,也便于课程教学与科研测试。压缩包共 81 个文件,约 83KB,以 ps 结果图、gp 绘图脚本、Makefile 构建文件、C 源码、HTML 说明及 README 为主,另有 tex 文档和多种 sys.def 配置模板,兼顾编译、运行、结果可视化与平台适配。已有 873 人学习下载,说明其在缓存性能测试场景中具有一定参考价值。读者可从中获得 CacheBench 的完整源码与构建脚本、针对不同系统的参数配置样例、结果绘图与文档说明,便于快速搭建测试环境、复现缓存基准实验并分析输出数据。

1. 一台机器跑得慢,先别急着怪主频

做处理器性能测试最容易踩的坑,是拿一个只测主存带宽的工具去解释 L3 上的表现。数字出来了,图表也有了,可客户问一句“为什么 32MB 工作集比 1MB 慢了两倍”,答不上来。llcbench 这个 Linux 开源工具就是补这个空档的:它把一个 tar 包里的 cachebench、blasbench、mpbench 几个底层基准捆在一起,其中 cachebench 用一组从小到大翻倍的内存规模反复扫,把 L1、L2、L3、主存这四层台阶一级一级量出来,缓存性能的拐点在哪、掉得多狠,全在曲线上。

这套东西不新,代码风格还停在二十年前,但胜在够轻、够直接、没有花哨的统计层糊在中间。适合三类人:做服务器选型要交报告的、优化完代码想证明自己真变快了的、以及被“核数翻倍吞吐没翻倍”折腾过的。下面从编译到出图完整走一遍,重点讲参数怎么设、哪些地方会让数据失真。

2. 拆开 llcbench:cachebench 这一层到底在量什么

2.1 尺寸扫描为什么能把缓存层级量出来

处理器性能测试里,最没用的是一个孤立带宽数字,最有用的是带宽随工作集大小变化的曲线。原因不复杂:每一级存储的延迟差一个数量级、带宽差几倍,当工作集从 16KB 涨到 4MB,程序会从“数据全在 L1”滑到“大部分命中 L3”,曲线必然出现台阶。cachebench 干的事就是把这个过程参数化——固定访问模式,只改工作集大小,逐个尺寸跑一遍。

这里有个容易被忽略的点:带宽曲线的台阶往往不明显,延迟曲线的台阶才清晰。原因是带宽受预取和并行度补偿,掉档被掩盖了一部分;而延迟是单次访问的真实代价,工作集一越过 L1 容量,命中率断崖,延迟立刻跳一档。所以我在看 cachebench 结果时,第一眼永远先找拐点位置,而不是先看峰值多少——峰值受编译器、频率、NUMA 影响,拐点位置才是硬件容量对得上的硬证据。

另一件要提前想清楚的事是操作类型。读、写、拷贝三种访问对缓存子系统的压力完全不同:纯读最接近理论峰值,写会因为写分配(RFO)先读后写、把有效带宽压低一截,拷贝同时吃掉读带宽和写带宽。如果只跑一种,得到的结论往往偏乐观。我的习惯是三种都跑,最后拿读的曲线做容量判断,拿写的曲线做带宽上限判断。

2.2 先用系统接口把扫描范围算出来

很多人拿到包直接make然后跑,跑完发现扫的全是 1MB 以上的尺寸,L1 和 L2 的全错过了。扫描范围不该拍脑袋,应该从这台机器的实际缓存容量倒推。Linux 底下拿这组信息最快,不用装任何东西:

# 每级缓存的大小、类型、行大小,一目了然 lscpu | grep -i -E 'cache|model name|mhz' # 更细的:按 index 遍历,level/type/size/ways 都有 for i in /sys/devices/system/cpu/cpu0/cache/index*; do echo "$(basename "$i"): level=$(cat "$i/level") type=$(cat "$i/type") size=$(cat "$i/size")" done # 备选:glibc 的 getconf,部分平台缺 L3 项,取不到就忽略 getconf LEVEL1_DCACHE_SIZE getconf LEVEL2_CACHE_SIZE getconf LEVEL3_CACHE_SIZE

lscpu给的是汇总视图,适合快速过一眼;/sys/devices/system/cpu/cpu0/cache/这套 sysfs 接口给的是一级一级的原始值,level=1 type=Data那一条就是数据 L1,level=3就是末级缓存。getconf 那三条在有 L3 的机器上能直接给出字节数,但有些发行版裁剪了 L3 配置,取不到很正常,别当成机器有问题。

拿到容量以后,扫描范围的定法我一般这么来:下界取 L1 容量的 1/8 左右,保证能测到“全命中”的峰值;上界取 L3 容量的 4 到 8 倍,保证越过末级缓存、真正落到主存;中间按 2 倍或 4 倍递增,十个点左右就够画出一条形状正确的曲线。举个例子,L1 32KB、L2 256KB、L3 16MB 的机器,扫描点就是 1、4、16、64、256、1024、4096、16384、65536、262144(KB),最后那个点 256MB 已经明显在主存区间了。

提示:容器和虚拟机里 sysfs 报的缓存容量是宿主的,不是你实际分到的份额。共享 L3 的场景下,曲线拐点会比容量值对应的位置更早,这一点后面避坑那章还会说。

2.3 和 STREAM、perf 的分工,别拿错工具

选型这件事值得单独说清楚,因为这三类工具经常被混着用。STREAM 测的是主存带宽的那一个点,工程上很成熟,但它不扫尺寸,回答不了“L3 里到底能跑多快”。perf 拿的是硬件计数器,cache-misses、LLC-load-misses这些事件能告诉你程序崩在哪一级,但它是从已有程序反推,不是正向测硬件能力。cachebench 的位置在中间:正向、可扫、轻量。

所以真实的用法往往是组合拳。新机器到货,先 cachebench 扫一遍拿到硬件基线曲线;要评估某个业务程序的访存效率,再上 perf 看 miss rate;两边对不上就说明程序有问题——硬件曲线告诉你 L1 该有 800MB/s 级的水平,程序只有 300MB/s,那多出来的损失就在代码里。反过来只跑 perf 很难说清“是不是硬件本来就这水平”,因为缺了基线。

还有一类工具是内存延迟测试(指针追逐那一类),它们跟 cachebench 是互补关系,不是替代。延迟测准了,容量拐点未必准;cachebench 反过来。有条件的话两条曲线都跑,横向对一下,哪个尺寸上两个工具给出的拐点位置差得离谱,就说明那次测量本身有问题。

至于 llcbench 包里另外两个——blasbench 偏浮点吞吐,mpbench 偏进程间通信,跟缓存层级关系不大,除非你要做整机性能画像,否则先专注 cachebench 就够了。刚上手时把编译和参数摸熟,比一次性把所有子基准跑一遍有价值得多。

3. 从 tar 包到第一条曲线:编译参数与首次跑通

3.1 解包、摸底、找到真正生效的编译配置

拿到llcbench.tar.gz之后别急着make。老项目的目录结构和变量定义方式跟现在的 CMake 工程差别很大,先花两分钟摸清楚,后面能少走半小时弯路。

set -euo pipefail # 先只看不解,确认包结构正常、没有多余嵌套层级 tar -tzf llcbench.tar.gz | head -30 tar -xzf llcbench.tar.gz cd llcbench # 顶层有哪些目录、Makefile 在哪一层 find . -maxdepth 1 -type d | sort ls -la # 把所有可用编译目标列出来,挑和当前平台最接近的一条 grep -nE '^[a-zA-Z0-9_.-]+:' Makefile | head -40

tar -tzf先看再解,是因为不少源码包会多套一层同名目录,直接解压后进错路径很常见。grep那一行是关键:这类项目的顶层 Makefile 里通常铺着一排<平台>-<编译器>形式的目标(对应不同体系结构和编译器),你先看清楚有哪些,再挑最接近当前机器的一条,而不是盲目敲make。

真正生效的编译变量往往不在顶层 Makefile,而是在被 include 进来的配置文件里。这套结构的好处是平台差异集中管理,坏处是你改顶层 Makefile 的CFLAGS可能根本不起作用:

# 找出所有定义了 CFLAGS 的地方,看哪个被 include 进来 grep -rn "CFLAGS" --include="make*" . | head -20 grep -rn "include" Makefile | head -10

哪一行是CFLAGS = ...的硬赋值,哪一行是CFLAGS += ...的追加,决定了你后面改哪里。硬赋值的话命令行传参也会被覆盖,必须直接改文件;追加的话就灵活得多。这一步看着琐碎,但它是后面所有“口径一致”的前提。

3.2 挑目标、改 flag,把编译口径固定下来

编译选项对基准结果的影响,比大多数人想的大。-O3会打开自动向量化,单线程读循环被展开成 SIMD 指令以后,测出来的带宽能虚高一截;-march=native让二进制绑死在这台机器上,换个平台跑结果就没法比;反过来-O0又会把循环开销算进去,同样失真。

所以我的原则是:测量用的二进制,编译口径必须固定并且写下来,跨机器对比时口径要一致。常用的折中是-O2关掉向量化,兼顾代码优化和结果可比性:

make clean 2>/dev/null || true # 目标名以你 grep 出来的为准,这里假设是 linux-x86_64-gcc make linux-x86_64-gcc # 编译完确认可执行文件落在哪 find . -maxdepth 3 -type f -perm -u+x -name 'cachebench*'

如果make报错,最典型的两类是:老代码在新编译器上因为-fno-common默认值变化导致的重复定义报错,以及一些早已废弃的头文件包含。前者加-fcommon能解决,后者通常要-std=gnu89把语言标准降回去。改CFLAGS的时候把这两条顺手带上,能省掉反复试错:

# 直接改配置文件里的这一行,别在命令行传,很多 Makefile 是硬赋值 # CFLAGS = -O2 -g -fcommon -std=gnu89

改完重新make clean && make <目标>,看到可执行文件产出就算过了第一关。注意别在这一步“顺手优化”加一堆激进选项,测出来的数字漂亮但没人信,等于白测。

3.3 第一次跑通:先把机器控制住,再谈数据

第一次运行不建议直接进批量脚本,先让程序自己把用法吐出来,对着看一眼当前版本支持哪些开关——这类老工具的开关字母在不同版本之间是会调整的,照抄别人的命令十有八九对不上:

BIN=$(find . -maxdepth 3 -type f -perm -u+x -name 'cachebench*' | head -1) echo "binary: $BIN" "$BIN" -h 2>&1 | head -40

帮助信息里重点确认三件事:怎么指定内存规模、怎么指定重复次数、怎么指定操作类型。这三样定下来,后面所有脚本才有意义。

然后是控制噪声。基准测试里最玄学的一句话是“刚才还好好的,重跑就变了”,八成是机器没按住。三件事必做:绑核、看频率调节器、确认没有别的负载。

# 1. 绑核:避免进程在核间迁移,L1/L2 命中率会因此抖动 taskset -c 2 "$BIN" -m 64 -M 64 -n 1 2>&1 | tail -20 # 2. 频率:performance 调节器下频率才稳,ondemand 会跟着负载跳 cat /sys/devices/system/cpu/cpu2/cpufreq/scaling_governor 2>/dev/null || echo "no cpufreq" # 3. 噪声:跑之前瞄一眼负载,别在一堆编译任务中间插测 uptime

taskset -c 2把进程钉在 2 号核,这是最小成本的稳定性提升。频率调节器如果是ondemand或schedutil,跑基准时频率会跟着负载上下浮动,同一尺寸两次结果差 15% 很常见,要么切performance,要么在报告里注明这个前提。uptime那行看着简陋,但它救过我很多次——有一次三条曲线怎么都对不上,查了半天发现是后台在跑备份任务。

跑通一次以后,先看输出的量级对不对:L1 尺寸的带宽应该明显高于 L3 尺寸,L3 尺寸又高于主存尺寸,如果曲线是平的或者反的,先别往下走,回到编译口径和绑核这两步复查。

4. 参数怎么设:扫描范围、重复次数与结果口径

4.1 扫描范围与步进:从 1KB 到 8 倍 L3

有了 2.2 节算出来的缓存容量,扫描点可以直接落表。下面这张表以 L1 32KB、L2 256KB、L3 16MB 的机器为例,不同机器按比例替换即可:

扫描点 (KB)覆盖层级这一档要看什么
1 / 4 / 16L1 内部峰值带宽上限,判断编译器有没有把循环优化掉
64 / 256L1 溢出到 L2第一个拐点的位置和陡度
1024 / 4096 / 16384L2 到 L3第二、第三个台阶,末级缓存容量边界
65536 / 262144远超 L3,落主存和内存标称带宽对表,判断通道数是否正常

步进方式我一般用 2 倍递增而不是线性递增,理由是缓存层级本身就是按倍数设计的,2 倍步进在每个台阶上都能落到两三个点,够画出形状又不至于把运行时间拉太长。上界取到 L3 的 4 到 8 倍,是为了拿到一段干净的主存区间;只取 2 倍的话,末级缓存的尾巴可能还没散干净,主存那一段就不纯。

如果机器有多个 NUMA 节点,还有一件事要做:把进程和内存都钉在同一个节点上,否则跨节点访问会把主存那一段的曲线拉低一大截,而你会误以为是带宽不够。这一点在多路机器上尤其明显,单跑一遍根本看不出来。

4.2 重复次数、预热与取数口径

重复次数不是越多越好,是要够用来判断波动。我的经验值是每个尺寸跑 3 到 5 次:3 次能看出量级波动,5 次足够算中位数。低于 3 次,一次偶发调度就能把结论带偏;高于 5 次,收益递减,时间成本却上去了。

取数口径上,我坚持用中位数而不是最大值或平均值。最大值的理由通常是“硬件能跑到的上限”,但那往往是被频率瞬时拉高或者调度巧合喂出来的,不可复现;平均值又会被第一次运行的冷启动拖低。中位数对这两头都钝感,是最稳的选择。

预热是另一个细节。第一次访问某个尺寸时,页表还没建立、TLB 还没热,数据必然偏慢。cachebench 这类工具内部通常有一定的循环次数来摊平这件事,但如果你的重复次数设成 1,那第一次的冷启动就会被完整算进结果里。所以要么把内部循环次数调够,要么外部重复 3 次以上取中位数,两条路选一条,别都不做。

还有一个容易被忽略的口径问题:单位。有些输出给的是 MB/s(10^6),有些给的是 MiB/s(2^20),两者差 4.8%。跨工具对比时如果一边是 MB/s 一边是 MiB/s,那点差异足够让你误判“优化有没有效果”。我在 CSV 里固定存原始数值加一列单位,换算放到最后一步做,中间不算。

4.3 把文本输出转成 CSV,再画曲线

工具本身只吐文本,要出图就得自己转。批量跑的核心是每次运行一个尺寸、结果落一个独立文件,文件名里带上尺寸和轮次,这样解析的时候不用猜:

#!/usr/bin/env bash set -euo pipefail BIN=${BIN:-./bin/cachebench} CPU=${CPU:-2} REP=${REP:-3} SIZES=${SIZES:-"1 4 16 64 256 1024 4096 16384 65536 262144"} OUT=${OUT:-result} mkdir -p "$OUT" for s in $SIZES; do for r in $(seq 1 "$REP"); do f="$OUT/run_s${s}_r${r}.log" # -m/-M 分别是扫描下界和上界,这里固定成同一个值,等于一次只测一个尺寸 # -n 是内部循环次数;开关字母以你本机 -h 输出为准,对不上就改这三处 taskset -c "$CPU" "$BIN" -m "$s" -M "$s" -n 1 > "$f" 2>&1 echo "done size=${s} rep=${r}" done done

这段脚本的逻辑很直白:外层遍历尺寸,内层遍历轮次,每次的输出落到run_s<尺寸>_r<轮次>.log。taskset保证每次都在同一个核上跑,-m/-M设成同一个值是为了让工具只测单点——如果让工具自己扫,你拿到的是一整张表,反而不好跟轮次对齐。文件名规整带来的好处在下一步才体现出来。

解析脚本的关键是防御性:不同版本的输出格式有细微差别,别写死列位置,改成“找出所有纯数字行、按位置对齐”:

#!/usr/bin/env python3 import csv import re import statistics import sys from collections import defaultdict from pathlib import Path NUM = re.compile(r"^-?\d+(\.\d+)?([eE][+-]?\d+)?$") def is_num(tok: str) -> bool: return bool(NUM.match(tok)) def parse(path: Path): """返回 (尺寸列表, [(操作名, 数值列表), ...])""" sizes, rows = None, [] for raw in path.read_text(errors="ignore").splitlines(): toks = raw.split() if not toks: continue # 行首如果不是数字,当成标签剥掉,剩下的当数据体 body = toks if is_num(toks[0]) else toks[1:] if not body or not all(is_num(x) for x in body): continue if sizes is None: # 第一处纯数字行视为尺寸表头 sizes = [int(float(x)) for x in body] continue if len(body) != len(sizes): # 列数对不上,跳过而不是崩溃 continue label = toks[0] if not is_num(toks[0]) else f"op{len(rows)}" rows.append((label, [float(x) for x in body])) return sizes, rows def main() -> None: src, dst = Path(sys.argv[1]), sys.argv[2] buckets = defaultdict(list) for f in sorted(src.glob("run_s*_r*.log")): size = int(f.stem.split("_")[1][1:]) sizes, rows = parse(f) if not rows: print(f"[skip] {f.name}: 没有解析到数据行", file=sys.stderr) continue for label, vals in rows: buckets[(size, label)].append(vals[0]) with open(dst, "w", newline="") as fh: w = csv.writer(fh) w.writerow(["size_kb", "op", "median", "runs"]) for (size, label), vs in sorted(buckets.items()): w.writerow([size, label, f"{statistics.median(vs):.2f}", len(vs)]) if __name__ == "__main__": main()

几个设计点说明一下。is_num用正则而不是float()加异常,是因为老输出里常有1.0e+03这类科学计数法,float()能吃但正则需要单独写,这里两种情况都覆盖了。sizes is None那一段把第一处纯数字行当表头,是为了兼容“先打一行尺寸、再打若干行操作数据”的常见布局;如果你的版本没有表头行,第一个数据行会被误当表头,症状是少一行数据——看到这个现象就把它改成从固定尺寸取。

len(body) != len(sizes)的跳过逻辑是刻意的:宁可少一条数据,也不要在列对不齐的情况下错位取值,那种错误在图上表现为一个莫名其妙的尖刺,排查起来极其费劲。最后按(尺寸, 操作)分组取中位数,runs列留着自检——如果某个点只有 1 次,说明那次运行失败了,得回头看日志。

跑完拿到 CSV,画图用什么都行。我习惯横轴取对数,因为尺寸本身是 2 倍递增的,线性轴会把 L1 那一段挤成一坨看不见。

5. 避坑与排查:5 个把数据做假的细节

5.1 曲线在某个尺寸突然掉崖又回升

现象:扫描曲线上,某一个尺寸的带宽比前后都低 30% 以上,形状像个 V 型缺口,重复跑还是那个位置。

原因:多半是工作集跨页边界的对齐问题,或者进程在那一轮被调度到别的核上,导致 L1/L2 全冷。还有一种可能是这个尺寸刚好越过了某个 TLB 层级,页表遍历的代价突然显性化。

解决:先确认绑核生效(taskset有没有真的传进去),再对比开/关透明大页两种情况下这个点的表现。如果是 TLB 引起的,关掉透明大页后缺口位置会移动;如果关掉后缺口消失,那这个点就是环境造成的,报告里要单独标注,不能当成硬件特性写。

5.2 同一台机器两次跑,结果差 20%

现象:什么参数都没改,上午跑和下午跑,L3 那一段差了 20%,主存段反而稳定。

原因:最常见的是频率调节器在ondemand模式下跟着负载跳,L3 那一段单次访问延迟长、循环次数多,对频率更敏感;主存段受限于内存控制器,反而不吃 CPU 频率。其次是共享 L3 的其他进程在干扰。

解决:跑之前把调节器切到performance,并把uptime的负载压到接近 0。做不到独占环境的话,老老实实在报告里写明“共享环境、非独占”,并且所有对比都在同一次会话内连续完成,别跨时段比。

5.3 优雅得离谱:带宽超过了理论上限

现象:单线程读带宽跑出来比内存控制器标称的还高,或者 L3 那一段高得不符合任何一代硬件的常识。

原因:编译器把循环优化掉了。空的读循环如果不把结果写回内存,-O3下的自动向量化加死代码消除能把它整个删掉,剩下的只有计时开销——数字当然漂亮。

解决:先降优化级别复跑一次,如果数字掉下来一半以上,说明之前测的是编译器不是硬件。长期方案是编译时固定-O2并关掉向量化,同时用反汇编确认循环体还在(看有没有实际的加载指令)。这一步做完再谈结果。

5.4 编译报重复定义,或者找不到老头文件

现象:make在中途报multiple definition of ...,或者提示某个标准头文件路径不存在。

原因:新版本 GCC 默认-fno-common,老代码里把变量定义写在头文件里的写法不再被容忍;头文件那条则是标准库演进的正常结果。

解决:CFLAGS里补-fcommon和-std=gnu89,先把编译过掉。要注意的是这两个选项只影响编译,不改变测量逻辑,所以对结果没有系统性影响;但如果你的目标是跨机器对比,那就必须所有机器用同一套选项重编,不能这台加了那台没加。

5.5 容器里跑出来的数字特别好看

现象:同样的镜像、同样的参数,在容器里跑出来的带宽比物理机上高,L3 段尤其夸张。

原因:容器共享宿主的 L3,sysfs报的容量是宿主的,但实际可用的 cache 份额受其他容器影响;更麻烦的是 cgroup 的 CPU 配额会让进程被限流,绑核绑了个寂寞。

解决:这类环境只适合做相对比较(改代码前后对比),不适合做绝对性能报告。非要在容器里测,就把 CPU 配额设成独占、绑核命令在容器内再执行一次,并且在报告里注明“容器环境、共享 L3”,别把数字当硬件能力写。

6. 进阶:把 cachebench 跑成一条能报警的回归基线

单次测量只能回答“现在什么水平”,真正省事的是把它变成回归基线:每次改完代码或者换完编译选项,跑一遍,跟历史数据比,超出阈值就拦下来。这件事不需要多复杂的框架,一张历史 CSV 加一个比对脚本就够了。

核心是把前面产出的cachebench.csv按(size_kb, op)建索引,跟基线文件逐点比相对偏差。阈值我一般设 10%:低于 10% 当作噪声放过,高于 10% 打印明细让人看。设太紧会天天误报,设太松又拦不住真的退化,10% 是在绑核加频率固定的前提下试出来的经验值。

场景基线来源阈值触发后动作
编译选项调整上一个稳定版本8%回退 flag,重跑确认
业务代码优化主干分支最近一次10%查是否引入额外拷贝
换机器/换内核同型号参考机15%只记录不拦截,人工判断

有一件事值得单独提醒:基线的有效期。内核升级、BIOS 改功耗策略、换散热风扇,都可能让绝对数值整体平移,这时候旧基线就失效了。所以基线文件里必须带元信息(内核版本、调节器状态、编译选项),一旦这几项变了,基线要重建而不是硬比。我吃过这个亏——有次内核小版本升级后所有点整体掉了 6%,卡在阈值边缘反复报警,查了两天才发现是调度器行为变了,不是代码退化。

最后一步是把这套东西挂到持续集成里。不需要真跑满所有尺寸,挑三个关键点就够:一个 L1 内部的点当峰值哨兵,一个末级缓存边界的点当拐点哨兵,一个远超 L3 的点当主存哨兵。三个点在正常构建里多花不到一分钟,但能把“这次提交悄悄让缓存命中变差了”这种事挡在合并之前。

从那以后我每次动编译选项或者改访存密集的代码,都强制走一遍这三个点的比对,哪怕只改了注释也跑——因为真正翻车的那次,恰恰是“只改了一行”的那次。希望这些能帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进