基准测试方法论:先证明被测代码还在,再谈那个数字
环境:Windows 11 x64,GCC 13.1.0(MinGW-w64,全路径
D:/Qt/Tools/mingw1310_64/bin/gcc.exe),-O2/-O0两档对照,计时用QueryPerformanceCounter(本机频率 10,000,000 Hz)。指令数来自objdump -d --no-show-raw-insn原文。本节所有数字都是这一台机器、这一组探针的输出,未做任何”参考值”替换。同期机器上还有 4–6 个后台任务在跑,这一点本身是本节的一个实验变量(第六节)。
一、先量你的尺子:100 ns 的分辨率,和”0 个 tick”
QPC frequency = 10000000 Hz -> 分辨率 100.0 ns
两次连续 QueryPerformanceCounter 之差 = 0 tick = 0.0 ns
两次背靠背调用同一个计时器,差值是 0。 这句话的分量是:任何单次耗时低于 ~100 ns 的测量,其读数只能是 0 或 100 的整数倍,“平均 3.7ns”这类数字必须是几万到几百万次累计摊出来的,否则它就是量化噪声。本专栏所有微基准都是这么做的(第 2 节日志篇的 1–45ns/次,是 2 万–10 万次累计除出来的)。
顺带一条更容易踩的:clock() 在本机跟的是墙钟,不是 CPU 时间。
clock() 对照:Sleep(200) 期间 clock() 前进 209 (= 209.0 ms),QPC 前进 208.8 ms
一个纯 Sleep 的区间里 clock() 走了 209ms——如果它真在计 CPU 时间,这里应该接近 0。用 clock() 做微基准,量到的是”过了多久”而不是”算了多久”。
二、第一步不是计时,是证明”被测代码还在”
同一个 100 万次的 xorshift+mul 依赖链,写三个只差”结果怎么处置”的版本:
void b_unused(int n) { mix(n, 12345); } // 结果丢弃
void b_volatile(int n) { vsink = mix(n, 12345); } // 写进 volatile
void b_barrier(int n) { uint64_t v = mix(n,12345);
asm volatile("" ::"r"(v)); if (v==0) puts("never"); }
-O2 的实测:
| 版本 | 耗时(100 万次) | 折算 | objdump 里函数体 |
|---|---|---|---|
| 结果丢弃 | 0.0 / 100.0 ns | 0.00 ns/次 | ret(一条,外加对齐 nop) |
| 写入 volatile | 2218.2 – 2308.6 µs | 2.22 – 2.31 ns/次 | call mix + mov %rax,vsink(%rip) |
| 内联汇编屏障 | 0.0 / 100.0 ns | 0.00 ns/次 | call mix + test(asm 本身不产生指令) |
-O0 的同一组:三个版本都是 6.87 – 7.90 ns/次,b_unused 有 12 条指令,循环完好。
这张表有三层教训,一层比一层隐蔽:
- 编译器会整块删掉”算了但没人用”的代码。 “结果丢弃”版本的函数体真的只剩
ret,测出来的 0 是诚实的——什么都没发生。 volatile写入是最省事的保活法,代价是它自己也要一条 store,所以给基线时要用同一个 volatile 版本去减。asm volatile("" :: "r"(v))这一招在跨函数时不可靠。 反汇编里b_barrier确实还call mix(asm 屏障在函数内部起效了,它把v钉在寄存器里),但计时仍然测出 0——因为b_barrier整体被判定为”无副作用”,调用它的run(body, …)在过程间特化时把这次调用跳过了。保活必须一路保到最外层可观察的出口,中间某一层的屏障救不了整条链。
工程上的判据:任何微基准交付前,先用 objdump -d 数一遍被测函数的指令。 数字对不上预期(1 条、或者少了一个本该存在的 imul)就是白测。
三、“看起来在测”的两种假象
假象一:空循环。
int main(void){ for (int i = 0; i < 1000000; i++); return 0; }
-O2 后的 main:
140002650: sub $0x28,%rsp
140002654: call 140001500 <__main>
140002659: xor %eax,%eax
14000265b: add $0x28,%rsp
循环不见了。 用它当”计时器开销基线”会得出接近 0 的好看数字,而真实版本里循环是在的。
假象二:保活成功 ≠ 测到了你想测的东西。 我最初用 for (i=1..n) x+=i 做负载,-O2 下”丢弃版”和”volatile 版”测出完全相同的 197µs / 1.0e6 次(反汇编里出现 lea 0x1(%rcx),%r8d 和按奇偶分支的向量化骨架)。也就是说:编译器把整个求和循环向量化了,我”保活”住的是一段被改写过的算法,测出来的 ns/次不再是那个朴素循环。换掉负载(改成有数据依赖的 xorshift+mul)之后差异才显出来(第二节)。
结论:负载要挑”没法整体重写”的——存在循环携带依赖(下一次运算要用上一次的输出)是最简单的挑法。
四、进程启动本身是被测对象的一部分
100 次 gcc -fsyntax-only tiny.c = 16505 ms → 每次约 165 ms
tiny.c 只是一个 #include <stdio.h> 加两行 main。这 165 ms 里绝大部分是 MinGW 的进程创建 + CRT 初始化 + 头文件解析,跟”被测代码”几乎无关。这一条解释了本专栏好几个”绝对值难看但比值有意义”的场合:
- 编译类基准(构建专栏第三节)里”什么都不改也要 1.75 秒”和”全量 34 秒”的对比,前者的地板是 make 自身的依赖图 + 201 次
fork/exec风格启动。 - 任何”A 写法 vs B 写法”的对比,如果单次差异 < 每次进程启动的开销,就必须在同一进程内循环测,或者把次数放大到千次以上。
顺带一条 Windows 特有误判:mingw32-make 与 make 不是一个命令(PATH 上根本没有 make),基准脚本里 make 直接 127 退出,看起来像”被测程序失败”。
五、min、median、max 会给出不同结论,必须写清楚用了哪个
同一台机器、同一份代码、真实取到的三组样本(单位 ms,取自构建专栏第三节那次 201 文件实验):
| 场景 | 三个原始样本 | 最小值 | 中位数 | 最大/最小 |
|---|---|---|---|---|
全量 -j1 | 32916, 34112, 35315 | 32916 | 34112 | 1.073× |
全量 -j8 | 12655, 12953, 13586 | 12655 | 12953 | 1.074× |
| 只改一个文件 | 2126, 2413, 2695 | 2126 | 2413 | 1.268× |
| 什么都不改(空跑) | 1453, 1749, 1919 | 1453 | 1749 | 1.321× |
- 用最小值算加速比:
32916/12655 = 2.60×;用中位数算:34112/12953 = 2.63×。这两个碰巧接近,但分母不同时会拉开。 - 空跑那一行的 max/min 是 1.32——如果某项优化的预期收益是 25%,它就被样本散布整个吃掉了,一次运行不足以判生死。这就是本专栏反复写”落在区间内,属于噪声,不下结论”的量化依据。
- 散布随测量类型变化很大:纯 CPU 依赖链(第二节三组 2.22–2.31)散布约 4%,涉及进程启动与文件系统的(第五行 1.32)散布 32%。所以样本数要按类型给,不是一律”跑 3 次”。
规矩:报告里必须写”取的是中位数/最小值 + 区间”,并且比值优先于绝对值——比值在两个样本同向漂移时会抵消。
六、污染是真实存在的,但没想象中普适
我特意做了对照:同一台机器上先安静测一遍,再开 6 个 CPU 满载进程,立刻重测。
| 探针类型 | 独占 | 6 个抢占者 | 变化 |
|---|---|---|---|
| 单线程依赖链(100 万次 xorshift) | 2.22 ns/次 | 2.19 ns/次 | −1.4% |
进程启动型(100 次 gcc -fsyntax-only) | 16505 ms | 16741 ms | +1.4% |
等待型(Sleep(10) × 200) | 平均超出 5.29 ms(单次 14–21 ms) | 平均超出 5.26 ms(单次 13–17 ms) | −0.6% |
三条都没超过 ±1.5%。原因值得写下来,因为它推翻了我以前的笼统说法:
- 依赖链微基准是延迟受限的,抢占者抢的是”核数”,而本机 12 个逻辑处理器上仍有空闲核给被测进程;它的耗时不取决于吞吐竞争。
- 进程启动型虽然受调度影响,但 165 ms 里主导的是 CRT 初始化与头文件 I/O,这些路径的可变部分不随 CPU 抢占线性变化。
Sleep那一行最有意思:独占时单次最小 14 ms、最大 21 ms,负载下 13–17 ms——均值几乎没动,但抖动带整体平移。这印证了并发编程专栏量到的 ~15.6 ms 进程默认定时器 tick:Sleep(10)之所以至少睡到 14ms,是 tick 向上取整,而不是被人抢了 CPU。
那什么才真会被污染?这次没做成 IO 对照(探针写坏了没跑出有效数字,见第九节),但从另外两栏的记录里能拿到同一类证据:日志落盘(每行 fflush 6.5–15.1 µs 的区间,跨度 2.3 倍)、数据库批量写(单事务 404–538 ms,跨度 1.33 倍)。共同点是都碰磁盘或都碰共享锁。所以规矩应该是:
CPU 密集的延迟型测量:1–2 轮就能定比值。IO / 共享资源 / 等待型测量:必须 3–5 轮取中位数并写区间,且尽量错开别的 agent 的 IO 高峰。
七、“测不到”不等于”不存在”,必须写清单
本专栏已经攒了四条这类记录,它们的共同处理方式是把失败本身写成结论:
- Nagle 算法在回环上复现不出 40ms 停滞(网络专栏第 5 节):
TCP_NODELAY开关两版都是 8 段 8 ACK。根因是回环 RTT 14–38µs 而一次send()就要十几µs,“有未确认段”这个触发条件永远不成立。算法在,条件不在。 -j8竞态第一次 5 次全绿(构建专栏第 3 节):给生成步骤加 0.3s 延迟后-j13/3 绿、-j85/5 红。竞态窗口是宽度问题,不是有无问题;复现不出要先问”我的窗口够不够大”。- MinGW 13.1.0 没有
libubsan/libtsan,-fsanitize=thread|undefined直接链接失败。数据竞争只能靠”人为制造窗口 + 统计丢数据比例”来量(并发专栏丢了 70.7%)。工具链缺席也要写出来,否则读者会以为结论是”没竞争”。 --version-script在 PE 上被完全忽略(构建专栏第 4 节):ld 退出码 0,local: add3却没藏掉add3。“命令成功”不等于”参数生效”——必须回读产物(导出表条目数)来验证。
对应到写规矩:每个基准结论后面都要跟一句”在什么条件下会失效”。上面第 4 条那种”看退出码就以为生效”是最贵的一类。
八、可复算性:优先留能被一条命令复核的数字
一节文章里的数字分三种,价值差别很大:
| 种类 | 例子 | 复核成本 |
|---|---|---|
| 确定性计数 | 字节数、指令条数、导出表条目、重编文件数、退出码、报错原文 | 一条 ls/objdump -c/nm 就能核 |
| 确定性比值 | 空间比 1.83×、行数比 3000/3008/3000 | 需要重跑,但结果逐次一致 |
| 负载相关绝对值 | 34112 ms、2.22 ns/次 | 只能同机同条件复现,最脆 |
本专栏的做法是:能只用第一、二种下结论的,绝不用第三种;第三种必须写区间。这条也决定了审稿顺序——先核字节数/报错原文这类硬锚点,再谈耗时表。
还有一条更贵的教训:产物一变,派生数字全库都要 grep 复查。本专栏出现过”改了 .rc 之后所有引用过 exe 体积的句子都失效”、“重测了中位数之后小结里还留着旧的 27 倍”(正确值是 19 倍)这类事。只改显然相关的段落是不够的,比值/倍数/百分比也是派生数字。
九、本节未在本机实测
- IO 型测量的污染对照:第六节留了这一行,但探针(
fwrite32 MB)编译时漏了#include <stdio.h>没跑出有效数字,重跑窗口不够,故”IO 密集基准会被抢盘的 agent 打到什么程度”在本节没有实测值,那条规矩是从其它专栏已有区间推出来的经验判断。 - 统计学上的严格性:置信区间、t 检验、效应量、Bootstrap 都没做。本文只用了”最小/中位/区间”这三个不需要假设的统计量——在样本 3–5 的量级上,更花哨的统计不会带来更多信息。
- 专业工具:本机没有
perf/VTune/xperf 可用(无 ETW 抓取工具链),所以”采样式 profiler 与插桩式计时的偏差”只有并发/性能各栏的间接证据,未做同轮对照。 - 频率漂移与绑核:本机是笔记本平台(睿频存在),但未用
SetThreadAffinityMask+ 温度记录做长时序测试;“绑核吞吐受睿频干扰,需要 5 次取中位数”这条沿用并发编程专栏的实测结论。 std::benchmark/Google Benchmark 框架本身:本机未安装,相关结论都属于自研探针。
十、小结
- 先确认尺子:本机 QPC 分辨率 100 ns,两次连续调用差 0 tick,所以单次低于百纳秒的量必须靠万级累计摊出来;
clock()在 MinGW 跟的是墙钟(Sleep(200)期间前进 209 ms)。 - 再确认代码还在:
-O2下”结果丢弃”的 100 万次循环,函数体只剩一条ret,测出 0.00 ns/次;volatile版 2.22–2.31 ns/次;-O0三版都是 6.87–7.90 ns/次。asm volatile屏障只在本函数内起效,跨函数仍会被整体当副作用消除——交付前用objdump -d数指令。 - 假象两种:
for(i=0;i<1000000;i++);被删光;for(i=1..n) x+=i被向量化后”保活成功”却测的不是原算法。负载要选循环携带依赖的。 - 进程启动是被测成本的一部分:100 次
gcc -fsyntax-only= 16505 ms(165 ms/次),比多数被测代码本身贵;Windows 上 PATH 里根本没有make,基准脚本会 127 退出。 - 统计口径写清楚:真实样本里空跑的 max/min = 1.32、纯 CPU 的散布 ≈ 1.04——预期收益 25% 的优化会被散布吃掉;一律给中位数 + 区间,比值优先。
- 污染没想象中普适:6 个 CPU 抢占者下依赖链 −1.4%、进程启动 +1.4%、
Sleep(10)均值 −0.6%,三类都在 ±1.5% 内;真正脆的是 IO 与共享锁(其它专栏的区间跨度 1.3–2.3 倍都在这类)。 - 测不到 ≠ 不存在:Nagle 在回环不触发、
-j竞态窗口太窄时全绿、MinGW 没有 TSAN/UBSAN、--version-script在 PE 上被静默忽略——每条都要写”在什么条件下失效”,并且用退出码之外的证据(产物回读)验证。