写基准测试的人最容易犯的错,不是代码写错,而是「测的根本不是你以为在测的东西」。一个累加循环可能被编译器按 as-if 规则整段删掉,让你测出「接近 0 秒」的假象;一次运行里 CPU 可能正忙着升频,下一次又在降频节流,两次耗时根本不可比;第一轮遍历大数组时缓存是冷的,后面才是热的。这篇把这三个坑挖开,给你一套能落地的正确测量手法。
1. 陷阱一:结果没被观测,计算被优化掉
这是最隐蔽、也最伤人的一个。C++ 的 as-if 规则允许编译器做任何优化,只要程序的「可观测行为」不变。如果一个累加循环的结果从头到尾没被任何人读取,编译器就可以认为这段计算没有可观测效果,把它整个删掉。
官方文档:as-if 规则——「可观测行为」是优化边界,结果没人读就等于没发生。
看汇编就。下面两个函数,唯一的区别是第二个把结果喂给了do_not_optimize(一个空的内联汇编屏障):
template<classT>inlinevoiddo_not_optimize(Tconst&value){asmvolatile(""::"r,m"(value):"memory");}voidbench_discarded(intn){// 反例:sum 从未被读取longsum=0;for(inti=0;i<n;++i)sum+=i;}voidbench_kept(intn){// 正例:用 do_not_optimize 观测 sumlongsum=0;for(inti=0;i<n;++i)sum+=i;do_not_optimize(sum);}g++ -O2编译后(godbolt,gcc 13.2)的汇编:
bench_discarded(int): ret ← 整个循环被删,只剩一条返回 bench_kept(int): test edi, edi jle .L6 movsx rcx, edi xor eax, eax xor edx, edx and edi, 1 je .L5 mov eax, 1 cmp rcx, 1 je .L4 .L5: lea rdx, [rdx+1+rax*2] add rax, 2 cmp rcx, rax jne .L5 .L4: ret .L6: xor edx, edx retbench_discarded只剩一条ret。如果你拿它测「这个循环要跑多久」,会得到「几乎 0 纳秒」的假数据。do_not_optimize的作用,是借内联汇编的"r,m"(value)约束告诉编译器「value 被用到了」,同时volatile让编译器不敢删这条 asm,于是循环被保留。
官方文档:GCC 内联汇编 Extended Asm——
asm volatile与"r,m"约束的语义来源。
这个do_not_optimize写法来自 Google Benchmark 和 Chandler Carruth 的演讲,是事实上的标准手法(MSVC 上需换成_ReadWriteBarrier)。下面是它的最小可运行示例:
#include<cstdio>template<classT>inlinevoiddo_not_optimize(Tconst&value){asmvolatile(""::"r,m"(value):"memory");}intmain(){longsum=0;constexprintn=1000;for(inti=0;i<n;++i){sum+=i;}do_not_optimize(sum);// 观测 sum,防止循环被整体删掉std::printf("sum = %ld\n",sum);}sum = 4995002. 陷阱二:CPU 频率漂移与节流
就算循环没被删掉,两次运行之间还隔着「CPU 频率不稳定」这道坎。现代 CPU 有动态调频(dynamic frequency scaling):
CPU 频率漂移与节流(为什么耗时不可比) ═══════════════════════════════════════════════════════════════ turbo boost ──▶ 负载低时单核冲高频,跑一小段热代码特别快 降频节流 ──▶ 温度/功耗撞墙后,频率被拉低(thermal/power throttle) 后台抢占 ──▶ 别的进程抢 CPU,你的进程被切走,计时被拉长 ═══════════════════════════════════════════════════════════════ 结果:同一段代码,前 1 秒和后 1 秒测出的耗时可能差出几十个百分点, 单次计时几乎不可信。解法不是「测得更准」,而是「多轮取中位数」。说明:这篇在在线沙箱里跑,无法控制 CPU 频率、也没有
perf/taskset这类工具,
所以频率漂移这部分讲原理为主、不实测。真正做基准时用taskset绑核、关 turbo、
固定 governor,再谈数字。
这引出一条硬规矩:耗时类数字一律不写死。同一个循环,换台机器、换个负载,数字就变了;写进文章里只会误导半年后回看的自己。要下结论,用确定性的指标(循环次数、分配次数、汇编里有没有某条指令),耗时只用来做同一进程内的相对比较。
3. 陷阱三:缓存冷热,第一轮不算数
第三个坑藏在「第一轮」里。第一次遍历一个大数组时,数据还在主存,缓存是冷的,每一行都要真去内存取;第二轮开始数据已经在 L2/L3 里,是热的,速度完全不同:
#include<cstdio>#include<vector>#include<chrono>template<classT>inlinevoiddo_not_optimize(Tconst&value){asmvolatile(""::"r,m"(value):"memory");}intmain(){constexprstd::size_t n=8*1024*1024;// 8M 个 int,约 32MBstd::vector<int>data(n,1);longsum=0;autot0=std::chrono::steady_clock::now();for(std::size_t i=0;i<n;++i)sum+=data[i];// 冷缓存:第一遍autot1=std::chrono::steady_clock::now();doublecold=std::chrono::duration<double,std::milli>(t1-t0).count();t0=std::chrono::steady_clock::now();for(std::size_t i=0;i<n;++i)sum+=data[i];// 热缓存:第二遍t1=std::chrono::steady_clock::now();doublehot=std::chrono::duration<double,std::milli>(t1-t0).count();do_not_optimize(sum);std::printf("cold=%.3f ms hot=%.3f ms sum=%ld\n",cold,hot,sum);}cold=~~ ms hot=~~ ms sum=16777216要点:sum=16777216是确定性结果(两遍各累加 8M 个 1),说明循环确实跑完了、没被优化掉;而cold/hot的耗时在沙箱里不稳定、也可能被向量化抹平差异,所以只做相对演示、不写死具体毫秒数。真实场景里「冷缓存第一遍」通常会明显更慢,正确做法是先跑一遍预热轮,把缓存和分支预测器焐热,再开始正式计时。
4. 正确手法:多轮取中位数 + steady_clock
把三个坑合起来,正确的基准骨架长这样:
一次可信的基准测量流程 ═══════════════════════════════════════════════════════════════ 预热轮 ──▶ 焐热缓存、稳定频率 │ 多轮计时(每轮独立计时,用 steady_clock) │ 丢弃最慢/最快的离群值,取中位数(不是平均值) │ 用 do_not_optimize 确保结果被观测、计算没被删 ═══════════════════════════════════════════════════════════════计时一定要用std::chrono::steady_clock。它是单调时钟,不会像system_clock那样被系统时间调整(NTP 校时、用户改时间)影响。完整示例:
#include<cstdio>#include<vector>#include<algorithm>#include<chrono>template<classT>inlinevoiddo_not_optimize(Tconst&value){asmvolatile(""::"r,m"(value):"memory");}intmain(){constexprintrounds=5;constexprintn=100000;std::vector<int>data(static_cast<std::size_t>(n),3);// 工作负载:数组求和std::vector<double>times;times.reserve(rounds);// 预热轮:焐热缓存,不计入结果{longwarm=0;for(inti=0;i<n;++i)warm+=data[i];do_not_optimize(warm);}longchecksum=0;for(intr=0;r<rounds;++r){autot0=std::chrono::steady_clock::now();longs=0;for(inti=0;i<n;++i)s+=data[i];do_not_optimize(s);autot1=std::chrono::steady_clock::now();times.push_back(std::chrono::duration<double,std::micro>(t1-t0).count());checksum+=s;}std::sort(times.begin(),times.end());// 排序后取中位数,抗离群值doublemedian=times[rounds/2];std::printf("rounds=%d median=%.1f us checksum=%ld\n",rounds,median,checksum);}rounds=5 median=~~ us checksum=1500000这段代码把三条规矩都落到了实处:steady_clock计时、多轮取中位数(checksum=1500000是确定性校验,证明每轮都真算了)、do_not_optimize防止数组求和被删。中位数比平均值更抗离群。某一轮恰好被系统抢占拖慢,平均值会被带偏,中位数不会。
三大陷阱与对策汇总:
| 陷阱 | 症状 | 对策 | 确定性指标 |
|---|---|---|---|
| 结果没观测被优化掉 | 汇编只剩ret,耗时≈0 | do_not_optimize观测结果 | 汇编里有没有那条指令 |
| CPU 频率漂移/节流 | 同一段代码耗时忽快忽慢 | 多轮取中位数、绑核、关 turbo | 循环次数 |
| 缓存冷热 | 第一轮明显偏慢 | 预热轮 + 正式计时 | 预热后轮次的耗时 |
5. 延伸阅读
- as-if 规则:编译器能删掉「结果无人读」的计算的理论依据
- std::chrono::steady_clock:为什么计时要用单调时钟
- GCC Extended Asm:
do_not_optimize里asm volatile的语法来源 - Compiler Explorer:写完基准先看汇编,确认循环没被删、该向量化的向量化了
- C++ Core Guidelines:性能相关的 Per 系列规则
本知识库内的相关篇目:
- 《SIMD 与自动向量化:march=native、__restrict 与 likely》 —— 自动向量化(auto-vectorization)让编译器把标量循环翻译成 SIMD 指令
- 《PGO 与 LTO:profile-guided 与链接时优化》 —— LTO(链接时优化)把「跨编译单元」的优化推迟到链接期
- 《C++ 缓存预热与 prefetch:什么时候该手写》 —— C++ 缓存预热与 prefetch,同体系里最该一起看的另一篇。
6. 一句话总结
基准测试的三大坑——结果没被观测会被 as-if 规则优化掉(用do_not_optimize防)、CPU 频率漂移让单次耗时不可比(多轮取中位数)、缓存冷热让第一轮失真(先预热)——核心心法是:耗时数字不写死、用确定性指标(循环次数、汇编指令、校验和)支撑结论,计时用steady_clock,结论只建立在同一进程内的相对比较上。