7、分支预测优化:likely/unlikely宏、减少复杂条件表达式、查表法替代分支
分支预测,这词儿听着挺唬人。说白了就是CPU猜你下一步要跳哪去。
我刚开始做嵌入式那会儿,压根没把这当回事。觉得if-else嘛,写就完了。直到有一次,我优化一个视频解码的模块,发现同样的数据量,换个判断顺序,性能差了将近一倍。嗯,从那以后,我再也不敢小看分支预测了。
7.1 为什么分支会拖慢速度?
现代CPU都是流水线架构。一条指令在执行,下一条已经在解码,再下一条已经在取指了。这叫流水线。
但遇到分支指令,比如if,CPU就犯难了——它不知道该提前加载哪条路。猜对了,流水线继续跑。猜错了,整条流水线要清空重来。这一清空,十几个时钟周期就没了。
你想想看,如果循环里有个分支,每次预测都错,那性能损失是累积的。数据量一大,差距就出来了。
核心观点:分支预测失败的代价,远比你想象的大。尤其是在循环体内部的热路径上。
7.2 likely/unlikely宏:给编译器递个话
Linux内核里大量使用了likely和unlikely宏。这俩宏的本质,是告诉编译器哪个分支更可能被执行。
我个人习惯,在写驱动或者底层库时,只要涉及错误检查,一定会用unlikely。因为正常路径下,错误很少发生。
// 定义(通常在编译器内置宏基础上封装)
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
// 使用示例
int process_packet(struct packet *pkt) {
if (unlikely(!pkt)) {
return -EINVAL; // 错误路径,很少执行
}
if (likely(pkt->type == TYPE_DATA)) {
// 数据包处理,这是常见情况
handle_data(pkt);
} else {
// 控制包处理,较少见
handle_control(pkt);
}
return 0;
}
编译器看到likely/unlikely后,会调整代码布局。把大概率走的分支放在紧挨着条件判断的位置,小概率分支放到远处。这样,CPU的指令缓存命中率更高,流水线也更顺畅。
小提示:不要滥用likely/unlikely。只有在你确定某个分支概率极高(比如>95%)时才用。乱用反而会误导编译器,适得其反。
7.3 减少复杂条件表达式
复杂的条件表达式,不仅人看着累,CPU也累。比如这种:
// 糟糕的写法
if ((a > 0) && (b < 100) && (c != 0) && (d == e) && (f || g)) {
do_something();
}
这种写法,编译器很难优化。而且每个条件都可能产生一个分支点。我建议的做法是:
- 把最可能为假的条件放最前面——利用短路求值,尽早跳出
- 拆分成嵌套if——让每个分支更简单
- 用位运算替代逻辑运算——位运算没有分支
// 优化后的写法
if (unlikely(c == 0)) goto skip; // 先排除不可能的情况
if (a <= 0) goto skip;
if (b >= 100) goto skip;
if (d != e) goto skip;
if (!f && !g) goto skip;
do_something();
skip: ;
你看,每个条件都独立了。编译器可以更好地安排指令顺序。而且用了unlikely,把异常情况提前处理掉。
注意:拆分成goto写法,只适合性能敏感的底层代码。普通应用层代码,还是以可读性为主。我曾经在一个网络协议栈里这么写过,后来同事差点没看懂……
7.4 查表法替代分支:最狠的一招
查表法,说白了就是用数组下标代替if-else或switch-case。这是消除分支最彻底的方式。
我记得有一次优化一个字符分类的函数。原来代码是:
// 原始版本:大量分支
int char_type(char c) {
if (c >= 'a' && c <= 'z') return 1; // 小写字母
if (c >= 'A' && c <= 'Z') return 2; // 大写字母
if (c >= '0' && c <= '9') return 3; // 数字
return 0; // 其他
}
这个函数在循环里被调了上百万次。每个字符都要做三次比较。我改成查表法后:
// 查表法版本:零分支
static const int char_type_table[256] = {
// 0-47: 其他
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
// '0'-'9': 数字
3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
// 其他符号
0, 0, 0, 0, 0, 0, 0,
// 'A'-'Z': 大写字母
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
// 其他符号
0, 0, 0, 0, 0, 0,
// 'a'-'z': 小写字母
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
// 剩余字符
0, 0, 0, 0, 0
};
int char_type_fast(char c) {
return char_type_table[(unsigned char)c];
}
性能提升非常明显。原来每次调用要3-4个分支,现在直接一次数组访问,零分支。而且数组访问的延迟是固定的,CPU可以很好地预测。
查表法的适用场景:
- 输入范围有限且已知(如字节值0-255)
- 映射关系固定不变
- 对延迟敏感的热路径代码
7.5 知识体系总览
下面这张图,把分支预测优化的几个方向串起来了:
7.6 避坑指南
我曾经在一个项目里,把所有的if都换成了查表法。结果代码体积暴涨,指令缓存命中率反而下降了。查表法不是银弹,它用空间换时间。如果表太大,频繁的缓存缺失反而得不偿失。
还有一次,我在一个循环里用了likely宏,但实际运行时两个分支的概率差不多。结果编译器把错误的分支放在了热路径上,性能反而更差了。所以,不确定概率时,不要用likely/unlikely。
我的建议:
- 先用性能分析工具(perf、gprof)找到真正的热点
- 热点里的分支,优先考虑查表法
- 查表法搞不定的,用likely/unlikely给编译器提示
- 最后才考虑手动拆分条件表达式
优化分支预测,说白了就是让CPU少猜错。猜对一次,省十几个周期。循环一千万次,省下来的时间就很可观了。
公众号:蓝海资料掘金营,微信deep3321