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 分支预测优化的知识体系
下面这张图总结了分支预测优化的核心思路:
6.6 实战中的避坑指南
我曾经在一个音频编解码项目里,用查表法替换了一个复杂的 switch-case。结果性能没提升,反而下降了。排查后发现,那个表太大了,每次查表都导致 L1 缓存 miss。
所以,查表法不是万能的。你得权衡:
- 表的大小:尽量控制在 64KB 以内,这是 L1 缓存的典型大小
- 访问模式:如果是随机访问,表太大容易 miss;如果是顺序访问,表大一点也没关系
- 分支的可预测性:如果分支模式很规律(比如循环里 99% 走同一个分支),那分支预测器本身就能做得很好,没必要强行优化
我的建议:先用 perf 工具看分支预测失败的次数。如果分支预测成功率低于 95%,才值得动手优化。别为了优化而优化,先测量,再动手。
嗯,分支预测优化就讲到这里。记住三个核心手段:减少难以预测的分支、用 likely/unlikely 引导编译器、查表代替分支。每个手段都有适用场景,别生搬硬套。
一句话总结:分支预测优化的本质,是让CPU的流水线少断流。要么让分支变得可预测,要么干脆消灭分支。
公众号:蓝海资料掘金营,微信deep3321