4. CPU 分支预测优化:让流水线不再“空转”
说起分支预测,大家可能觉得这是 CPU 硬件的事,跟我写代码有啥关系?
其实关系大了去了。
你想想看,现代 CPU 都是流水线架构。一条指令没执行完,后面十几条已经排队进来了。如果遇到一个 if 语句,CPU 得猜到底走哪条路。猜对了,流水线哗哗往前跑。猜错了?那不好意思,后面排队的指令全得作废,流水线清空重来。
**一次预测失败的代价,大约是 10-20 个 CPU 周期。** 循环里如果频繁猜错,性能能掉一大截。
分支预测器是怎么工作的?
现代 CPU 里的分支预测器,不是什么玄学。它主要靠两种策略:
- 静态预测:简单粗暴。向后跳转的(比如循环)预测为“真”,向前的跳转预测为“假”。
- 动态预测:记录历史。用过去几次的执行结果,猜下一次。有 2 位饱和计数器、全局历史、局部历史等玩法。
动态预测是主流。但它的能力有限。我见过一个项目,性能测试死活上不去。后来用 perf 一看,分支预测失败率高得吓人。原因就是数据分布太随机,预测器根本无从下手。
核心公式
预测失败代价 = 预测错误率 × 清空流水线惩罚周期
要优化,要么降错误率,要么直接干掉分支。
分支消除:从根源解决问题
既然分支这么贵,那最直接的办法是——不写分支。也就是所谓的“分支消除”。
我个人的习惯是,在热点路径里,能用条件赋值就绝不用 if-else。条件赋值对应的是一条 CMOV 指令,它不走分支预测。
方法一:用位运算代替分支
// 原始版本:有分支
int abs(int x) {
if (x < 0)
return -x;
else
return x;
}
// 分支消除版:无分支
int abs_branchless(int x) {
int mask = x >> (sizeof(int) * 8 - 1);
return (x + mask) ^ mask;
}
这里用算术右移生成了一个掩码。x 是负数时,mask 全 1;非负数时,mask 全 0。然后通过 (x + mask) ^ mask 算出值。整个过程只有移位、加法和异或,没有任何分支。
经验之谈
位运算适合简单的数值计算。曾经在图像处理库里,我把一段 RGB 转灰度的 if-else 换成了查表和位运算,那部分快了 30%。
方法二:用查表代替分支
当分支条件是基于离散输入值时,查表法很有效。尤其适合状态机或分类处理。
// 原始版本:多个 if-else
int classify(int type) {
if (type == 0) return 10;
if (type == 1) return 20;
if (type == 2) return 30;
return -1;
}
// 分支消除版:查表
static const int table[] = {10, 20, 30};
int classify_branchless(int type) {
return (type >= 0 && type < 3) ? table[type] : -1;
}
方法三:使用条件移动指令
// 原始:if-else 赋值
int a, b, result;
if (condition)
result = a;
else
result = b;
// 分支消除:编译器会生成 CMOV
int result = condition ? a : b;
不过要注意,CMOV 并非总是更快。如果两个分支的计算量都很小,而且能预测准确,那原本的分支可能比 CMOV 快。CMOV 会同时计算两个值,相当于浪费了一部分算力。
避坑指南
我曾经在一个字符串解析函数里,把所有 if 都硬改成 CMOV,结果性能反而下降。原因是每个分支都要做 strlen() 计算,很重。对于这种计算密集的分支,if 反而好。
所以,分支消除只适用于“计算代价小、分支预测失败率高”的场景。
likely/unlikely:给编译器递个话
有些分支我们心里门儿清——某个条件几乎总是成立,或者几乎总是失败。但编译器不知道。这时候可以给它提个醒。
// Linux 内核里常见的用法
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
int handle_error(int error_code) {
if (unlikely(error_code != 0)) {
// 只会偶尔执行
log_error(error_code);
return -1;
}
// 正常路径
return process_request();
}
编译器看到 unlikely 后,会调整代码布局:
- 把正常路径放在顺序执行的热区。
- 把异常路径放到远离热区的冷段。
- 还可能提示硬件预取。
这样流水线更顺畅,缓存命中率也更高。
| 场景 | 建议 | 示例代码 |
|---|---|---|
| 错误检查 | 用 unlikely |
if (unlikely(ptr == NULL)) |
| 边界检查 | 用 unlikely |
if (unlikely(len < 0)) |
| 循环退出条件 | 用 unlikely |
while (unlikely(!done)) |
| 常见路径 | 用 likely |
if (likely(index >= 0)) |
我的习惯
只在关键的热点函数里用 likely/unlikely。其他地方用了,反而让代码晦涩。而且记住:这只是一个 hint,不是指令。老版本 GCC 还可能忽略它。
分支预测优化的综合策略
这一节的核心就是一张图,我把它放在这里:
核心思路
“先消除,再提示,后布局”——这是我的优化三板斧。
先看能不能用无分支方式替代;实在替代不了,再用 likely/unlikely 给编译器递话;最后才考虑手动调整代码布局或启用 PGO。
总结几条实用原则
- 不要盲目消除:如果一个
if的分支体本身就很重(比如调用了复杂函数),强行消除反而得不偿失。 - 热点才值得优化:一个每年只执行几千次的初始化路径,分支预测失败带来的开销可以忽略。
- 用数据说话:做优化前、优化后,都用
perf stat -e branch-misses看一下。我曾经看到一个项目,优化后分支预测率从 85% 提到 99.5%,吞吐量直接翻倍。 - 留意编译器版本:GCC 10 之后的版本对分支优化的支持更好了。有些手动
likely/unlikely的效果,新编译器通过 PGO 自己就能做出来。