2026年7月26日
一台高噪声基准测试机器险些让我付出的代价
一项看似结论确凿的编译器否定性结果,直到更安静的机器揭开基准测试噪声所掩盖的事实,才显出它是错的。
最近,我一直在深入研究 GCC 的一个角落。GCC 是支撑 Linux 世界很大一部分软件的编译器。
这是一个关于否定性结果的故事:那个结果后来证明是错的,而我险些就把它发表了出去。
我正在改动的编译器部分
程序循环遍历数组时,CPU 必须记录自己走到了哪里。
GCC 有一个名为 ivopts 的编译器遍,意思是归纳变量优化。它负责决定这套记录工作应该怎样完成:要维护多少个计数器,以及每个计数器应该计算什么。
选对了,循环就能保持紧凑高效。
选错了,每次迭代都要付出代价。
编译器不可能生成每一种版本,再逐一进行基准测试。编译本身也必须保持足够快。因此,GCC 使用一个成本公式给各个候选方案打分,然后选择胜者。
我研究的正是这个公式。
在源码中,主要计算位于名为 iv_ca_cost 的函数里。遇到难分高下的选择时,则由 cheaper_cost_pair 中大约二十行代码作出裁决。
我的理论
编译器里到处都是这样的公式:多年前写下的、经过推断的猜测,再由人手工调校。
我的理论很简单:
如果搜索本身是好的,打分公式却错了呢?
如果 GCC 总能可靠地找到公式眼中的最佳答案,而这个公式实际上无法预测执行速度呢?
于是,我搭建了一套测试工具,去做编译器承受不起的事:枚举所有候选方案,并逐一测量。
我测试了大约 55,000 个循环,它们来自 GCC 自己的测试套件和 LLVM 基准测试套件。
第一个答案是错的
搜索表现得非常出色。
它有 99.9% 的概率找到得分最高的候选方案。
尽管如此,我还是构建了一个更复杂的搜索。它为五个循环找到了得分更高的答案,却没有带来任何可测量的性能改善。
我还检查了现有公式是否至少能够最小化代码体积。
它已经做到了。
所有结果都指向同一个结论:这里没有任何收益可挖。
我已经准备把这项工作作为一个否定性结果发表,然后继续做别的事。
为什么那是一条错误线索
所有这些测量都来自一台 Intel 台式机。
那台机器在撒谎。
一台繁忙的消费级台式机,是精密基准测试的糟糕仪器。时钟频率不断变化;每个物理核心都与同一核心上的另一个超线程共享资源;浏览器还开着;其他活动时有时无。
不同的基准测试中,我的测量噪声介于 1% 到 19% 之间。
任何更小的变化实际上都不可见。
更糟的是,噪声不只会掩盖真实效果,还会制造虚假效果。
有三次,我发现了一个结果,随后起了疑心,增加重复次数,然后眼看着它消失。
10% 的加速变成了 0.02%。
1% 的回退变成了 0.3% 的改善。
每个结果起初看起来都完全令人信服。
更好的测量仪器揭示了什么
我把实验搬到云端的一台 ARM 服务器上:没有超线程,没有动态频率的干扰,也没有争抢资源的其他工作负载。
噪声降到了 0.007%,改善了大约一千倍。
结果终于出现了。
在一个矩阵乘法循环中,GCC 公式排在第六名的候选方案,比它排在第一名的方案快 17%。
这可能只是 ARM 特有的现象,于是我又在一台安静、空闲的 48 核 Xeon 上重复了实验。
它与那台什么也没显示出来的台式机大体同属一个架构家族,但它禁用了睿频,也没有其他负载争抢资源。
它的噪声底限是 0.05%。
同样的结果再次出现。
在这台机器上,GCC 排在第四名的候选方案比第一名快 4.8%。
这是测得噪声底限的 96 倍,绝不是一个难以判定的边缘结果。
原来的台式机并没有揭示另一个答案。
它只是一把坏尺子。
原理,这才是最令人满意的部分
在 Xeon 上,三个替代候选方案执行的指令都比 GCC 选中的版本少大约 12%。
其中一个快 4.8%。
另外两个反而稍慢。
它们退役的指令数量大致相同,执行时间却相差约 5%。
区别在于,处理器能够并行执行其中多少条指令。
对于胜出的候选方案,每周期指令数从大约 2.6 上升到 3.1。
GCC 的公式主要依靠计算指令数量。
即使从原理上说,它也无法区分这些候选方案。
这个公式不只是调校得不够好。
它测量的根本就是错误的东西。
我的收获
有两点。
显而易见的教训是,基准测试机器是一件科学仪器。
未经校准的仪器会产出信心十足的胡说八道。
由于超线程、不断变化的时钟频率、后台活动和一个浏览器标签页,我险些发表了一个错误的否定性结果。
不那么明显的教训是,我在同一天里反复犯错,而每一次都是因为我太早停止了测量。
解决办法从来不是让自己变得更聪明。
而是重新运行那些我已经确信无误的实验。
工作还在继续。
现在,终于有了真正值得追下去的东西。