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 后,会调整代码布局:

  • 把正常路径放在顺序执行的热区。
  • 把异常路径放到远离热区的冷段。
  • 还可能提示硬件预取。

这样流水线更顺畅,缓存命中率也更高。

likely/unlikely 使用建议
场景 建议 示例代码
错误检查 用 unlikely if (unlikely(ptr == NULL))
边界检查 用 unlikely if (unlikely(len < 0))
循环退出条件 用 unlikely while (unlikely(!done))
常见路径 用 likely if (likely(index >= 0))

我的习惯
只在关键的热点函数里用 likely/unlikely。其他地方用了,反而让代码晦涩。而且记住:这只是一个 hint,不是指令。老版本 GCC 还可能忽略它。

分支预测优化的综合策略

这一节的核心就是一张图,我把它放在这里:

分支预测优化策略图 CPU 分支预测优化决策流程 检测到高分支率的 热点代码 分支可以消除吗? (分支体计算轻量且可替换) 分支消除 ① 位运算转换 ② 查表法 ③ CMOV 指令 ④ 无分支算法 编译器指引 ① likely/unlikely ② PGO 反馈 ③ 代码布局调优 ④ 热冷分离 分支预测失败率降低 流水线停顿减少,吞吐量提升 💡 核心原则 先消除,再提示,最后改布局 消除不了才用 likely/unlikely 🚀 适用前提 分支处于热点路径 分支方向呈现偏斜分布

核心思路
“先消除,再提示,后布局”——这是我的优化三板斧。

先看能不能用无分支方式替代;实在替代不了,再用 likely/unlikely 给编译器递话;最后才考虑手动调整代码布局或启用 PGO。

总结几条实用原则

  • 不要盲目消除:如果一个 if 的分支体本身就很重(比如调用了复杂函数),强行消除反而得不偿失。
  • 热点才值得优化:一个每年只执行几千次的初始化路径,分支预测失败带来的开销可以忽略。
  • 用数据说话:做优化前、优化后,都用 perf stat -e branch-misses 看一下。我曾经看到一个项目,优化后分支预测率从 85% 提到 99.5%,吞吐量直接翻倍。
  • 留意编译器版本:GCC 10 之后的版本对分支优化的支持更好了。有些手动 likely/unlikely 的效果,新编译器通过 PGO 自己就能做出来。