1
0

CPU 为什么会“猜”你的代码?一行 if 背后的分支预测

2026-06-23
cpp

假设有一千万个整数,我们只累加其中大于 128 的元素:

 std::uint64_t sum = 0;
 ​
 for (int value : data) {
     if (value >= 128) {
         sum += value;
     }
 }

现在准备两份内容完全相同的数据。第一份随机排列,第二份提前排好序。两段程序执行的判断次数相同,读取的元素数量相同,加法次数也相同。

它们会一样快吗?

在确实生成条件分支的机器码中,答案可能是否定的。即使没有改变算法,只是改变数据的排列方式,运行时间也可能出现明显差异。

问题不在 C++ 语法,而在 CPU 会“猜”下一步执行什么。

CPU 为什么不等判断结束?

现代 CPU 执行一条指令,需要经过取指、译码、执行等多个阶段。为了提高吞吐量,处理器不会等一条指令彻底完成后再处理下一条,而是让多条指令同时处于流水线的不同阶段。

这很像一条装配线:第一个产品正在喷漆时,第二个可以开始组装,第三个可以准备材料。流水线一旦填满,处理器就能持续完成指令。

if 带来了一个问题。处理器必须根据条件,决定接下来从哪个地址取指:

 if (condition) {
     do_a();
 } else {
     do_b();
 }

在条件真正计算出来之前,CPU 并不知道应该继续准备 do_a() 还是 do_b()。如果原地等待,流水线就会出现空档。于是现代处理器选择先预测一个方向,并沿着预测的路径继续取指和执行。

猜对了,时间被节省下来;猜错了,错误路径上已经进行的工作需要被丢弃,再从正确的位置重新开始。这个过程称为分支预测失败。它的代价因处理器和具体代码而异,通常会损失多个乃至十几个时钟周期。

CPU 很快,但它也很讨厌推倒重来。

排好序的数据为什么更容易预测?

假设数据只包含 0255,判断条件是 value >= 128

随机数据产生的结果可能是:

 真 假 真 真 假 真 假 假 真 ...

分支方向频繁变化,很难只根据近期历史准确预测。

排好序后,结果更接近:

 假 假 假 假 ... 假 真 真 真 ... 真

CPU 只需在跨过 128 时适应一次方向变化,之后便可以连续猜中。因此,即使高级语言中的循环完全相同,底层流水线的工作状态也可能不同。

真实处理器的预测器远比“上一次为真,这次也猜真”复杂。它会利用分支自身的历史、不同分支之间的关联等信息。但基本目标没有变化:从过去的执行模式中推测下一次分支方向。

先别急着跑经典实验

网上经常能看到“排序后循环快几倍”的例子,但在今天的编译器和处理器上,照抄代码不一定得到相同结果。

原因是编译器也参与了优化。开启 -O2-O3 后,这个循环可能被改写为条件移动、掩码运算,甚至被自动向量化。最终机器码中如果根本没有数据相关的条件跳转,分支预测自然就不再是主要因素。

此外,数据规模、元素类型、编译选项、CPU 型号和缓存状态都会影响结果。因此,一个没有说明编译环境的耗时数字,通常没有太大参考价值。

高性能编程的第一个反直觉事实是:你写下的是 C++,CPU 执行的却是编译器生成的机器码。

怎样做一个稍微靠谱的实验?

可以分别生成随机数据和排序数据,把排序过程排除在计时区间之外,然后多轮运行同一段循环。编译时开启优化,并确保计算结果真的被使用,避免整个循环被编译器删除。

 #include <algorithm>
 #include <chrono>
 #include <cstdint>
 #include <iostream>
 #include <random>
 #include <vector>
 ​
 std::uint64_t sum_large_values(const std::vector<int>& data) {
     std::uint64_t sum = 0;
     for (int value : data) {
         if (value >= 128) {
             sum += static_cast<std::uint64_t>(value);
         }
     }
     return sum;
 }
 ​
 int main() {
     constexpr std::size_t count = 10'000'000;
     std::mt19937 engine(42);
     std::uniform_int_distribution<int> distribution(0, 255);
 ​
     std::vector<int> random_data(count);
     for (int& value : random_data) {
         value = distribution(engine);
     }
 ​
     auto sorted_data = random_data;
     std::sort(sorted_data.begin(), sorted_data.end());
 ​
     const auto begin = std::chrono::steady_clock::now();
     const auto result = sum_large_values(random_data);
     const auto end = std::chrono::steady_clock::now();
 ​
     std::cout << "sum = " << result << '\n';
     std::cout << "time = "
               << std::chrono::duration_cast<std::chrono::microseconds>(end - begin).count()
               << " us\n";
 }

这段代码适合帮助理解,但还不能算严谨的基准测试。正式测试应使用 Google Benchmark 等工具,进行预热和多轮采样,并尽量固定 CPU 频率、核心与系统负载。

在 Linux 上,还可以使用硬件性能计数器观察分支情况:

 perf stat -e cycles,instructions,branches,branch-misses ./branch_test

其中,branch-misses 比单独的运行时间更接近我们想验证的原因。如果随机数据的分支预测失败更多,同时周期数上升,才能更有把握地把差异归因于分支预测。

最后还要查看生成的汇编。Compiler Explorer、objdump 或编译器的汇编输出都可以帮助确认循环中究竟使用了条件跳转、条件移动还是 SIMD 指令。

把 if 改成无分支代码会更快吗?

有人会把循环写成下面这样:

 sum += static_cast<std::uint64_t>(value) * (value >= 128);

条件为假时乘以 0,为真时乘以 1。表面上看,if 消失了,这类写法常被称为 branchless programming,也就是无分支编程。

但它不保证更快。

第一,C++ 源码中没有 if,不代表机器码中一定没有跳转;反过来,源码中写了 if,编译器也可能自动生成无分支指令。

第二,当分支高度可预测时,普通 if 的成本已经很低。无分支版本却可能无论条件是否成立都执行额外运算。

第三,无分支变换可能增加指令数量或拉长数据依赖链,反而限制 CPU 的乱序执行能力。

无分支代码真正适合的场景,通常是分支难以预测、预测失败成本明显,并且替代计算足够便宜。它是一种需要测量的优化策略,不是一条看到 if 就套用的公式。

性能优化的重点不是“少写一行代码”

从源代码看,这只是一个普通判断;从 CPU 看,它可能涉及预测、推测执行、流水线清空、条件移动和向量化。高性能 C++ 的难点,正是建立这两种视角之间的联系。

面对一段慢代码,更可靠的流程是:

  1. 用基准测试确认哪里慢;

  2. 用性能计数器判断时间消耗在哪里;

  3. 查看汇编,确认编译器实际生成了什么;

  4. 修改实现,再使用相同方法验证结果;

  5. 在不同数据分布下检查优化是否仍然成立。

如果跳过这些步骤,只凭感觉删除分支、加入内联或手写 SIMD,很容易得到更复杂却并不更快的代码。

结语

CPU 的分支预测说明了一件很有意思的事:程序的性能不仅取决于执行了哪些操作,还取决于这些操作是否形成了处理器容易理解的模式。

同一个循环、同一批数字,仅仅改变排列顺序,就可能改变预测器的命中情况;同一段 C++,换一个优化选项,又可能变成完全不同的机器码。

所以,当一行 if 出现在性能热点中时,真正值得问的不是“分支是不是很慢”,而是这个分支能够被预测吗?编译器保留了它吗?它真的构成当前瓶颈吗?

性能优化从来不是与语法较劲,而是在编译器、CPU 和真实数据之间寻找答案。

Comments