6. 分支预测优化:减少难以预测的分支、使用likely/unlikely宏、查表代替分支

分支预测,这玩意儿是CPU流水线上一个挺有意思的机制。说白了,CPU为了不闲着,会提前猜一猜你下一步要跳转到哪。猜对了,流水线跑得飞起;猜错了,流水线得清空重来,那代价可不小。

我早年做网络协议栈优化的时候,就吃过这个亏。一个简单的if判断,因为数据分布完全随机,导致CPU预测成功率只有50%左右。性能直接掉了30%多。从那以后,我对分支预测就格外上心。

6.1 分支预测的基本原理

现代CPU都有分支预测器。它会记录历史跳转信息,然后预测下一次跳转。预测成功,流水线继续跑;预测失败,流水线要冲刷掉已经预取的指令,重新加载正确的路径。

一次预测失败的代价有多大?

CPU架构 预测失败惩罚(时钟周期)
Intel Skylake 15-20 cycles
ARM Cortex-A72 11-13 cycles
RISC-V (BOOM) 8-12 cycles

你想想看,一个分支预测失败,就浪费了十几个时钟周期。如果循环里频繁出现,那性能损失是累积的。

6.2 减少难以预测的分支

什么样的分支难以预测?说白了,就是跳转模式没有规律。比如数据是随机分布的,或者分支条件依赖于外部输入。

我建议的做法是:把分支条件转化为数据驱动的逻辑

来看个例子。假设你要根据一个标志位数组来决定是否累加:

// 难以预测的分支版本
int sum = 0;
for (int i = 0; i < N; i++) {
    if (flag[i]) {
        sum += data[i];
    }
}

// 无分支版本
int sum = 0;
for (int i = 0; i < N; i++) {
    sum += data[i] * flag[i];  // flag[i] 为 0 或 1
}

第二个版本没有分支,CPU不需要预测。乘法虽然比比较指令慢一点,但避免了分支预测失败的惩罚。在数据随机分布时,性能提升非常明显。

核心原则:能用算术运算代替分支,就尽量用算术运算。分支预测失败的代价,往往比多执行几条指令还高。

6.3 使用 likely/unlikely 宏

GCC 和 Clang 都提供了 __builtin_expect 宏,用来告诉编译器哪个分支更可能被执行。Linux 内核里封装成了 likely() 和 unlikely()。

用法很简单:

#define likely(x)   __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)

// 使用示例
if (unlikely(ptr == NULL)) {
    // 错误处理,很少执行
    return -1;
}
// 正常路径,经常执行
return ptr->value;

这个宏的作用是什么?它告诉编译器:把大概率执行的路径放在紧挨着条件判断的位置,这样CPU取指令时能更快拿到正确的指令。

我曾经在数据库引擎的查询解析器里用过这个。解析器里大部分输入都是合法的SQL,错误路径很少走。加上 unlikely 后,编译出来的代码布局更紧凑,指令缓存命中率也提高了。

小技巧:likely/unlikely 主要影响代码布局,不影响逻辑。你可以在关键的热点路径上使用,但别到处乱加。加多了反而让编译器难以优化。

6.4 查表代替分支

查表法,这是我最喜欢用的优化手段之一。当分支条件很多,或者分支逻辑复杂时,查表往往能带来质的飞跃。

举个例子。假设你要根据一个枚举值做不同的处理:

// 分支版本
int process(int type, int value) {
    switch (type) {
        case TYPE_A: return value * 2;
        case TYPE_B: return value + 10;
        case TYPE_C: return value >> 1;
        case TYPE_D: return value * value;
        default: return value;
    }
}

// 查表版本
typedef int (*op_func)(int);
op_func table[] = {
    [TYPE_A] = mul2,
    [TYPE_B] = add10,
    [TYPE_C] = shr1,
    [TYPE_D] = square,
};

int process(int type, int value) {
    return table[type](value);
}

查表版本没有分支,只有一次函数指针调用。CPU可以完美预测返回地址,流水线不会断。

更极端的例子是状态机。我曾经在嵌入式项目里用查表法实现了一个协议解析器:

// 状态转移表
uint8_t next_state[STATE_MAX][INPUT_MAX] = {
    [STATE_IDLE] = {
        [INPUT_START] = STATE_HEADER,
        [INPUT_DATA]  = STATE_ERROR,
        [INPUT_END]   = STATE_IDLE,
    },
    [STATE_HEADER] = {
        [INPUT_START] = STATE_ERROR,
        [INPUT_DATA]  = STATE_BODY,
        [INPUT_END]   = STATE_ERROR,
    },
    // ... 更多状态
};

// 查表实现状态转移
state = next_state[state][input];

这个状态机没有 if-else,没有 switch-case。就一行查表,性能极稳。

注意:查表法虽然快,但会占用更多内存。在缓存敏感的场景下,表太大可能导致缓存 miss,反而得不偿失。我建议表的大小控制在 L1 缓存能容纳的范围内。

6.5 分支预测优化的知识体系

下面这张图总结了分支预测优化的核心思路:

分支预测优化知识体系 分支预测优化 减少难以预测的分支 算术运算代替分支 数据驱动逻辑 likely/unlikely宏 优化代码布局 提高指令缓存命中率 查表代替分支 函数指针表 状态转移表 目标:减少流水线冲刷,提高指令级并行

6.6 实战中的避坑指南

我曾经在一个音频编解码项目里,用查表法替换了一个复杂的 switch-case。结果性能没提升,反而下降了。排查后发现,那个表太大了,每次查表都导致 L1 缓存 miss。

所以,查表法不是万能的。你得权衡:

  • 表的大小:尽量控制在 64KB 以内,这是 L1 缓存的典型大小
  • 访问模式:如果是随机访问,表太大容易 miss;如果是顺序访问,表大一点也没关系
  • 分支的可预测性:如果分支模式很规律(比如循环里 99% 走同一个分支),那分支预测器本身就能做得很好,没必要强行优化

我的建议:先用 perf 工具看分支预测失败的次数。如果分支预测成功率低于 95%,才值得动手优化。别为了优化而优化,先测量,再动手。

嗯,分支预测优化就讲到这里。记住三个核心手段:减少难以预测的分支、用 likely/unlikely 引导编译器、查表代替分支。每个手段都有适用场景,别生搬硬套。

一句话总结:分支预测优化的本质,是让CPU的流水线少断流。要么让分支变得可预测,要么干脆消灭分支。


公众号:蓝海资料掘金营,微信deep3321