跳到正文

基准测试方法论:先证明被测代码还在,再谈那个数字

环境: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 ns0.00 ns/次ret(一条,外加对齐 nop)
写入 volatile2218.2 – 2308.6 µs2.22 – 2.31 ns/次call mix + mov %rax,vsink(%rip)
内联汇编屏障0.0 / 100.0 ns0.00 ns/次call mix + test(asm 本身不产生指令)

-O0 的同一组:三个版本都是 6.87 – 7.90 ns/次,b_unused 有 12 条指令,循环完好。

这张表有三层教训,一层比一层隐蔽:

  1. 编译器会整块删掉”算了但没人用”的代码。 “结果丢弃”版本的函数体真的只剩 ret,测出来的 0 是诚实的——什么都没发生。
  2. volatile 写入是最省事的保活法,代价是它自己也要一条 store,所以给基线时要用同一个 volatile 版本去减。
  3. 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 文件实验):

场景三个原始样本最小值中位数最大/最小
全量 -j132916, 34112, 3531532916341121.073×
全量 -j812655, 12953, 1358612655129531.074×
只改一个文件2126, 2413, 2695212624131.268×
什么都不改(空跑)1453, 1749, 1919145317491.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 ms16741 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 高峰。

七、“测不到”不等于”不存在”,必须写清单

本专栏已经攒了四条这类记录,它们的共同处理方式是把失败本身写成结论:

  1. Nagle 算法在回环上复现不出 40ms 停滞(网络专栏第 5 节):TCP_NODELAY 开关两版都是 8 段 8 ACK。根因是回环 RTT 14–38µs 而一次 send() 就要十几µs,“有未确认段”这个触发条件永远不成立。算法在,条件不在。
  2. -j8 竞态第一次 5 次全绿(构建专栏第 3 节):给生成步骤加 0.3s 延迟后 -j1 3/3 绿、-j8 5/5 红。竞态窗口是宽度问题,不是有无问题;复现不出要先问”我的窗口够不够大”。
  3. MinGW 13.1.0 没有 libubsan/libtsan,-fsanitize=thread|undefined 直接链接失败。数据竞争只能靠”人为制造窗口 + 统计丢数据比例”来量(并发专栏丢了 70.7%)。工具链缺席也要写出来,否则读者会以为结论是”没竞争”。
  4. --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 型测量的污染对照:第六节留了这一行,但探针(fwrite 32 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 上被静默忽略——每条都要写”在什么条件下失效”,并且用退出码之外的证据(产物回读)验证。