首页 / 资讯中心 / 文章详情

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

cachebench实战:Linux缓存层级性能测试、避坑与回归基线 ★ FEATURED ARTICLE
简介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. 拆开 llcbenchcachebench 这一层到底在量什么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_SIZElscpu给的是汇总视图适合快速过一眼/sys/devices/system/cpu/cpu0/cache/这套 sysfs 接口给的是一级一级的原始值level1 typeData那一条就是数据 L1level3就是末级缓存。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、262144KB最后那个点 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 -40tar -tzf先看再解是因为不少源码包会多套一层同名目录直接解压后进错路径很常见。grep那一行是关键这类项目的顶层 Makefile 里通常铺着一排平台-编译器形式的目标对应不同体系结构和编译器你先看清楚有哪些再挑最接近当前机器的一条而不是盲目敲make。真正生效的编译变量往往不在顶层 Makefile而是在被 include 进来的配置文件里。这套结构的好处是平台差异集中管理坏处是你改顶层 Makefile 的CFLAGS可能根本不起作用# 找出所有定义了 CFLAGS 的地方看哪个被 include 进来 grep -rn CFLAGS --includemake* . | head -20 grep -rn include Makefile | head -10哪一行是CFLAGS ...的硬赋值哪一行是CFLAGS ...的追加决定了你后面改哪里。硬赋值的话命令行传参也会被覆盖必须直接改文件追加的话就灵活得多。这一步看着琐碎但它是后面所有“口径一致”的前提。3.2 挑目标、改 flag把编译口径固定下来编译选项对基准结果的影响比大多数人想的大。-O3会打开自动向量化单线程读循环被展开成 SIMD 指令以后测出来的带宽能虚高一截-marchnative让二进制绑死在这台机器上换个平台跑结果就没法比反过来-O0又会把循环开销算进去同样失真。所以我的原则是测量用的二进制编译口径必须固定并且写下来跨机器对比时口径要一致。常用的折中是-O2关掉向量化兼顾代码优化和结果可比性make clean 2/dev/null || true # 目标名以你 grep 出来的为准这里假设是 linux-x86_64-gcc make linux-x86_64-gcc # 编译完确认可执行文件落在哪 find . -maxdepth 3 -type f -perm -ux -name cachebench*如果make报错最典型的两类是老代码在新编译器上因为-fno-common默认值变化导致的重复定义报错以及一些早已废弃的头文件包含。前者加-fcommon能解决后者通常要-stdgnu89把语言标准降回去。改CFLAGS的时候把这两条顺手带上能省掉反复试错# 直接改配置文件里的这一行别在命令行传很多 Makefile 是硬赋值 # CFLAGS -O2 -g -fcommon -stdgnu89改完重新make clean make 目标看到可执行文件产出就算过了第一关。注意别在这一步“顺手优化”加一堆激进选项测出来的数字漂亮但没人信等于白测。3.3 第一次跑通先把机器控制住再谈数据第一次运行不建议直接进批量脚本先让程序自己把用法吐出来对着看一眼当前版本支持哪些开关——这类老工具的开关字母在不同版本之间是会调整的照抄别人的命令十有八九对不上BIN$(find . -maxdepth 3 -type f -perm -ux -name cachebench* | head -1) echo binary: $BIN $BIN -h 21 | head -40帮助信息里重点确认三件事怎么指定内存规模、怎么指定重复次数、怎么指定操作类型。这三样定下来后面所有脚本才有意义。然后是控制噪声。基准测试里最玄学的一句话是“刚才还好好的重跑就变了”八成是机器没按住。三件事必做绑核、看频率调节器、确认没有别的负载。# 1. 绑核避免进程在核间迁移L1/L2 命中率会因此抖动 taskset -c 2 $BIN -m 64 -M 64 -n 1 21 | tail -20 # 2. 频率performance 调节器下频率才稳ondemand 会跟着负载跳 cat /sys/devices/system/cpu/cpu2/cpufreq/scaling_governor 2/dev/null || echo no cpufreq # 3. 噪声跑之前瞄一眼负载别在一堆编译任务中间插测 uptimetaskset -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/s10^6有些给的是 MiB/s2^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 21 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(errorsignore).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 fop{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}: 没有解析到数据行, filesys.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.0e03这类科学计数法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和-stdgnu89先把编译过掉。要注意的是这两个选项只影响编译不改变测量逻辑所以对结果没有系统性影响但如果你的目标是跨机器对比那就必须所有机器用同一套选项重编不能这台加了那台没加。5.5 容器里跑出来的数字特别好看现象同样的镜像、同样的参数在容器里跑出来的带宽比物理机上高L3 段尤其夸张。原因容器共享宿主的 L3sysfs报的容量是宿主的但实际可用的 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 的点当主存哨兵。三个点在正常构建里多花不到一分钟但能把“这次提交悄悄让缓存命中变差了”这种事挡在合并之前。从那以后我每次动编译选项或者改访存密集的代码都强制走一遍这三个点的比对哪怕只改了注释也跑——因为真正翻车的那次恰恰是“只改了一行”的那次。希望这些能帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站