微基准变快,不代表程序变快
前四篇咱们一层层拆:编译器会删你的循环、噪声会抖、偏差会系统性拉偏、分支预测器和缓存会联手美化数字、延迟和吞吐量会被搅成一团。把它们都对付好,你的微基准总算做得无可挑剔了——数字真实、方法正确、误差可控。然后呢?然后你拿着这个漂亮的数字去优化代码,上线,结果线上性能纹丝不动,甚至更慢了。
这不是灵异事件。这是 Kris 整场演讲最想塞进你脑子里的那个概念:相关性(correlation)。你的微基准测出来的快慢,和你真正想优化的那个程序的快慢,是不是同一件事?
先看一个"优化成功"的微基准
咱们拿 FizzBuzz 当例子,因为它简单到能让注意力全部落在测量本身。先写两个版本。第一个是朴素的 if 链,做三次取模:
const char* fizzbuzz_naive(int n) {
if (n % 15 == 0) return "FizzBuzz";
if (n % 5 == 0) return "Buzz";
if (n % 3 == 0) return "Fizz";
return "";
}第二个是"理论上更优"的查表法——只算一次取模,剩下靠查表:
static const char* lookup[16] = {
"","","","Fizz","","Buzz","Fizz","","","Fizz",
"Buzz","","Fizz","","","FizzBuzz"
};
const char* fizzbuzz_lookup(int n) {
return lookup[n % 15];
}直觉上查表法应该更快:它只做一次取模,而朴素版最多做三次。咱们各跑 200 万轮,每轮覆盖 1..15,本机(GCC 16.1.1, -O2)结果:
naive if链: 20355.0 us, 20586.0 us
lookup 查表: 13001.0 us, 12595.0 us查表法快了大约 37%。如果你只看到这个微基准,你会兴冲冲地把查表法合进代码,觉得"优化成功"。但这一步什么都没证明——它只证明了"在微基准这个特定的、数据量极小、输入高度规律的环境里,查表法赢了"。
看一眼汇编,胜利就不那么香了
咱们先别急着庆祝,低头看看编译器到底生成了什么。把 naive 版本编译成汇编:
$ g++ -std=c++20 -O2 -S corr.cpp -o corr.s
$ grep -E "imul|div|idiv|sar" corr.s | head -8
imull $-286331153, %edi, %eax
imull $-858993459, %edi, %eax
imull $-1431655765, %edi, %edi
...全是 imull(整数乘法),没有一条 div 或 idiv。编译器把 n % 15、n % 5、n % 3 这些取模,通通强度削减(strength reduction)成了"乘一个魔数再移位"。那些看起来很吓人的常数(-286331153、-858993459)就是编译器为"除以 15/5/3"算出来的等价乘法替换。
这件事意味着:你朴素版里"最多三次取模"的劣势,在 -O2 下其实根本不是取模——是三次乘加序列。查表法省掉的,只是"算两次额外的乘法魔数"这点开销。在微基准里数据量小、那张 16 项的 lookup 表全程窝在 L1 缓存里,查表几乎免费,所以它赢了。
但在真实程序里呢?那张表可能被前后不相关的代码挤出 L1;真实输入分布可能让 n % 15 的结果高度集中,使得朴素版第一个 if 就命中返回,后面的取模根本不执行;又或者这个函数被内联进一个大循环,寄存器分配的压力让查表法的内存访问代价暴露出来。这些场景下,微基准里的 37% 优势可能蒸发,甚至反转。
这就是相关性问题的核心:你的微基准环境,和你想优化的真实环境,往往不是同一个东西。
F1 风洞的比方
Kris 在演讲里打了一个特别贴切的比方:F1 赛车的风洞测试。
车队在风洞里把赛车模型吹出漂亮的空气动力学数据,然后照着改设计。但如果风洞里的气流条件,和赛道上赛车真正面对的气流不是一回事,风洞数据再好看也是废的——它和赛道表现没有相关性。
微基准就是程序员的风洞。你把一段代码单独拎出来,在一个精心控制的小环境里测量,得到精确的数字。这个过程本身没错,它快、迭代成本低、适合验证想法。但它有一个致命的前提条件,经常被忽略:微基准的结果,只有在你验证过它和真实表现存在相关性之后,才有意义。
笔者自己也踩过这个坑。有一回优化一个字符串处理函数,微基准里它快了 40%,因为函数的热路径恰好完美地塞进了 L1 缓存。合进服务之后,那个函数前面是一大段 JSON 解析代码,把缓存搞得一团糟,"优化"反而引入了更多内存访问,最终变慢了。当时百思不得其解,现在回头看,就是相关性没建立。
怎么建立相关性
建立相关性不是一个动作,是一个从微观到宏观的验证链条。笔者现在的工作流是这样的:
第一步,微基准。它快、便宜,适合快速试想法。但它的结果只能当作候选信号,不是结论。你在这一步拿到的是"这个改动在理想条件下可能带来 X% 提升"。
第二步,中等规模测试。把优化放进一个调用它的完整模块里跑,看看在更接近真实的上下文里,优化还在不在。这一步会暴露很多微基准里藏不住的问题:寄存器压力、缓存争用、和其他代码的交互。
第三步,生产负载测试。放到真实的流量、真实的数据分布、真实的并发下,看端到端的指标是不是真的改善了。只有这三个层面的结果方向一致,你才能说"这个优化有效",也就是建立了相关性。
如果某一步对不上——比如微基准变快了但端到端没变化——别沮丧,这是最好的学习机会。去挖为什么对不上,你会对 CPU 和系统的行为理解深一层。对不上的时候,往往藏着一个你还不知道的噪声源或偏差源。
一个必须回答的问题
测量做完之后,你必须能回答这个问题:为什么是这个数字? 如果你说不出"它快了 20%"背后的原因——是少了指令、是缓存命中率高了、是分支预测准了、还是执行端口冲突缓解了——那这个测量就只是个碰巧的数字,你没法判断它在别的场景下还成不成立。理解原因,比拿到数字本身重要得多。
让优化持续生效,别让它悄悄消失
假设你建立了相关性,优化真的生效了。还有最后一件事会让你的努力打水漂:这个优化能不能在生产环境里持续存在?
你今天测出来有效的优化,明天别人改了一行无关的代码、升了一下编译器版本、或者换了个构建配置,它可能就悄悄没了。而你在微基准里根本感知不到——因为触发那个优化的调用路径可能已经被内联替代了。Kris 给出的解法是把"观察"升级成"测试":把那些你用来手动看的工具输出,变成机器自动检查的断言。
最实用的一种叫反汇编契约测试。你期望编译器在特定代码上做出某种优化,就把这个期望写成测试。比如你希望 n % 15 被强度削减成乘法、不出现除法指令,就检查函数的机器码里有没有 idiv:
// 把函数的机器码 dump 出来, 检查有没有除法指令 (x86 的 idiv 是 0xF7 系列)
bool has_division_instruction(const void* func, std::size_t bytes) {
const auto* p = static_cast<const std::uint8_t*>(func);
for (std::size_t i = 0; i < bytes; ++i) {
// 简化: 精确判断要考虑前缀和 ModRM, 这里只示意思路
if (p[i] == 0xF7) return true; // 粗略命中
}
return false;
}这种测试有个明显缺点:它依赖具体的字节码,换一个编译器版本或编译参数就可能失效,所以更适合在工具链版本锁死的项目里做回归检测。但它的价值在于把"编译器应该生成什么样的代码"这件事,从靠人眼看的偶发行为,变成了 CI 里每次都会跑的断言。优化一旦回退,测试立刻红。
正确性验证也是同理,而且更重要:你的 benchmark 跑得快但结果错了,那毫无意义。Google Benchmark 提供了 state.PauseTiming() 和 state.ResumeTiming() 这一对接口,让你在计时循环里插入一段不计时的正确性检查:
for (auto _ : state) {
std::vector<int> data = base;
my_sort(data);
benchmark::DoNotOptimize(data.data());
state.PauseTiming(); // 验证不计入耗时
verify_sorted(data); // assert 检查有序
state.ResumeTiming();
}这样验证逻辑跑得再慢也不污染性能数据,但排序一旦出错,assert 立刻让你知道。笔者在好几个项目里都靠这个模式抓出过"优化完快了三倍、结果数据全乱"的事故。
Kris 留下的方法论
走到这儿,咱们把整条链路走完了:从"为什么纳秒这么重要",到一层层拆穿骗子,到建立相关性,再到让优化持续生效。回头梳理一下,一套靠谱的微基准纪律大致是这么几条:
第一,永远要测量,但别用"跑一次除以次数"那种聊胜于无的方式。 那种做法比不测更危险,因为它给你一种虚假的确定性。你拿到一个精确到纳秒的数字,却不知道它被多少你不知道的因素扭曲了。
第二,分层理解干扰源。 编译器是一层,运行时的噪声和偏差是一层,分支预测器和缓存是一层,测量维度本身是一层。每一层有每一层的对付办法,弄混了就白费。
第三,控制变量。 绑核、锁频、预热、固定编译选项和链接顺序、让输入贴近真实分布。这些动作不复杂,但容易忘,忘了结果就不可复现。
第四,统计上别偷懒。 看分布,别只看平均值;偏态分布用中位数;对比要带误差线。
第五,也是最根本的一条:测完之后,回答"为什么是这个数字"。 能把因果链从头到尾讲清楚——从 cycles 差异追溯到缓存、从缓存追溯到数据布局、从数据布局追溯到那一次代码改动——那个时刻,你才是真的懂了你的程序在做什么,而不是碰巧蒙对一个数字。
这套东西听起来啰嗦,代价也不小。你可能会问,值得吗?如果你做的是高频交易、内核、数据库内核、游戏引擎这类纳秒就是钱或者纳秒就是用户体验的领域,几乎没有第二条路。但就算你只是写普通的业务代码,理解"我的测量是否可信"这件事,也是通用的——它能帮你避开无数"优化了个寂寞"的尴尬,也能在你真正需要抠性能的时候,知道该往哪个方向使劲。
这是 Kris 这场演讲给笔者最大的收获:不是某个具体技巧,而是一种纪律感——对每一个数字保持怀疑,直到你能解释它为什么是这个数字。 咱们共勉。