Skip to content

分支预测器在帮你作弊

前两篇咱们拆掉了编译器和运行时噪声这两层骗子。这一篇讲一个更狡猾的:它住在 CPU 内部,绝大多数开发者根本意识不到它的存在,但它能把你的 benchmark 数字美化得连你自己都不认识。它就是分支预测器。

同样的代码,输入不同,慢 5.6 倍

咱们写一个带条件分支的小函数,结构跟 FizzBuzz 的判断一模一样——一串 if 配取模:

cpp
static int classify(int n) {
    if (n % 15 == 0) return 4;   // FizzBuzz
    if (n % 5  == 0) return 3;   // Buzz
    if (n % 3  == 0) return 2;   // Fizz
    return 1;                    // 普通数字
}

然后准备三组输入,每组都是 200 万个整数,区别只在于排列方式:

  • 全同值:200 万个 15,每个都命中第一个分支。
  • 顺序 1..N:按顺序排,每 15 个一循环,分支模式高度规律。
  • 打乱顺序:用 std::shuffle 彻底打乱,分支走向无法预测。

对每组输入都调用 classify 200 万次,中间不做别的,各跑 5 轮取中位数。代码和上一篇的噪声实验是一个骨架,这里只看结果:

text
全同值 15  (分支完美预测):    717.0 us  (0.36 ns/elem)
顺序 1..N  (周期模式):       1573.0 us  (0.79 ns/elem)
打乱顺序    (预测器失效):     4021.0 us  (2.01 ns/elem)

打乱 / 全同 = 5.61x —— 同样的代码同样的计算量, 分支预测一垮慢这么多

咱们对着这三行数字想一下。classify 函数一行没改,编译选项没改(-O2),计算量完全一样(都是 200 万次取模和比较)。唯一变的是输入的排列顺序。结果最快的和最慢的差了 5.61 倍

如果你写 benchmark 的时候,顺手用了一个固定值或者一个顺序数组当输入(这是绝大多数人会做的事,因为最省事),你测到的就是那个 0.36 ns/elem 的漂亮数字。你会觉得"我这个函数快得很"。然后它上了生产环境,面对真实世界里乱七八糟的输入,实际表现是 2.01 ns/elem 那一档——慢了五倍多,而且你完全不知道为什么。

分支预测器在干什么

要理解这件事,得先放下一个根深蒂固的错觉:CPU 不是老老实实一条一条执行指令的。

现代 CPU 的流水线很深,十几级甚至二十几级。当一条条件分支指令(比如上面那些 if 对应的 jne/je)走到流水线里时,它的"走哪边"要等好几个周期之后才能确定。但 CPU 等不起——它如果在这儿干等,后面十几级流水线就空了,这个代价叫流水线停顿。于是 CPU 猜了:它根据这条分支过去的历史,预测它这次会走哪边,然后提前把预测那一侧的指令取进来、解码、甚至开始执行。等真正的条件算出来,如果猜对了,皆大欢喜,等于白赚了那十几级流水线的时间;如果猜错了,CPU 得把提前做的那些工作全扔掉(这叫流水线冲刷),重新从正确的路径取指令。

猜对和猜错,代价天差地别。冲刷一次流水线,在当代 x86 上大概要浪费 15-20 个周期。这就是为什么"全同值 15"那种情况那么快——200 万次里每一次都走同一个分支,预测器看一次就学会了,猜对率接近 100%,几乎没有冲刷。而"打乱顺序"那种情况,每一次分支走向都不可预测,预测器只能瞎猜,猜对率退化到接近 50%(二选一),一半的分支都要冲刷流水线,慢就慢在这儿。

现代 CPU 的分支预测器相当聪明,内部用全局历史寄存器、局部历史表、甚至感知机算法来学习分支模式,能记住成千上万条分支的历史。但它的聪明有一个前提:你的分支得有模式可学。一旦输入真正随机,它就抓瞎了。

没有历史的时候,CPU 怎么猜

顺便提一个细节,面试偶尔会问。当一条分支第一次出现、预测器还没有任何历史数据时,CPU 用一条静态兜底规则:

  • 向后跳转的分支(比如循环底部的 jne 跳回循环头)默认预测为会跳转。因为循环绝大多数时候会继续。
  • 向前跳转的分支(比如 if 条件不满足时跳过一段代码)默认预测为不跳转。因为 if 的 then 分支通常比 else 分支常走。

这条规则解释了一个老生常谈的优化建议:把 if 里更常见的逻辑写在 then 分支、把罕见逻辑写在 else 分支,能让静态预测命中率高一点。当然,一旦预测器积累了足够的历史,动态预测就会覆盖这条静态规则,它的作用主要在程序刚启动、缓存还没热起来的时候。

怎么对付这个骗子

核心思路一句话:让你的 benchmark 输入,和你生产环境里的真实输入,是同一种分布

如果你的真实场景是均匀随机的,那 benchmark 里就用打乱过的随机输入;如果你的真实场景里 90% 的请求走 true 分支、10% 走 false,那你就按这个比例构造输入序列。千万别用一个固定常量、也别用一个 1..N 的顺序数组去测一个对分支敏感的函数,那样测出来的数字是你的代码在最理想输入下的天花板,不是它面对真实负载时的表现。

Google Benchmark 配合 <random> 是标准做法,本机没装 GB,但手写也不复杂:

cpp
#include <random>
#include <vector>
#include <algorithm>

std::mt19937 rng(42);
std::vector<int> inputs(2'000'000);
for (size_t i = 0; i < inputs.size(); ++i) inputs[i] = static_cast<int>(i + 1);
std::shuffle(inputs.begin(), inputs.end(), rng);  // 打乱, 让分支不可预测

// 测量时遍历这组输入, 而不是传固定值
size_t idx = 0;
for (auto _ : state) {
    do_not_optimize(classify(inputs[idx]));
    idx = (idx + 1) % inputs.size();
}

这里有个细节值得强调:inputs 数组要足够大,大到预测器没法把整段模式记全。如果数组只有几十个元素,就算打乱了,预测器跑几圈也能学进去。200 万个元素足够让它抓瞎。

如果分支走向对你的函数性能影响特别大,你还可以更进一步,按真实的命中比例构造输入。比如你的业务里查找命中率是 5%,那你的输入序列就应该让 95% 的查找落空、5% 命中,而不是均匀分布。benchmark 测的永远是"你的场景下的性能",不是"均匀分布下的性能"。

缓存也跟着一起作弊

分支预测器还有一个经常联手作案的搭档:缓存层级。它制造的幻觉跟分支预测器如出一辙——你的 benchmark 数据看起来漂亮,是因为缓存状态恰好有利。

最典型的一种:第一次访问慢,后面都快。你的数据第一次从内存读进来,触发 cache miss,要等几十上百个周期;但读进来之后它就留在 L1/L2 里了,后续访问只要几个周期。如果你的 benchmark 跑很多轮,只有第一轮付了 miss 的代价,后面全是命中,你算出来的平均值里混进了一个偏高的第一轮,然后被几百个偏低的命中轮拉平——这个平均值既不代表"冷启动",也不代表"热稳态",是个四不像。

更隐蔽的一种:没被调用的代码也能影响你。听起来离谱,但机制很直接。编译器把函数排到二进制里时是有顺序的,你多放一个函数(哪怕从没调用过),后面所有函数的地址都会被往后推。原本挤在同一个缓存行里的热代码,现在可能被拆到两个缓存行,甚至跨了页面边界。指令缓存命中率变了,你的 benchmark 数字就变了。有人踩过这种坑:删掉一个完全没用到的工具函数,另一个毫不相关的热点函数突然快了 8%。

这种缓存相关的偏差,本系列不展开实测,因为它是下一场演讲 Cache-Friendly C++ 的主战场。这里你只需要记住一个结论:任何看起来"莫名其妙变快/变慢了几个百分点"的现象,先把分支预测和缓存嫌疑排一遍,大概率根源在这两者之一。

这一层的边界

分支预测器和缓存联手制造的幻觉,本质都属于上一篇说的偏差——它们是系统性的,不是随机的,靠多跑压不下去,只能靠"让输入贴近真实分布"来对付。把它们拆掉之后,你的 benchmark 数字总算比较可信了。但还有最后一层认知陷阱在等着:你以为你在测的"快慢",到底指的是哪个维度?延迟和吞吐量,这两件事 CPU 处理它们的方式完全不同,把它们混为一谈,是又一个"测了等于没测"的重灾区。

下一篇:延迟、吞吐量、cycles:你到底在测什么 →

v0.10.0-6-gbcee94e · bcee94e · 2026-08-20