4、条件指令与分支优化:条件标志位、条件执行指令、分支指令、条件分支优化策略
分支和条件判断,是CPU流水线的天敌。我早年做网络处理器优化时,一个热点的条件分支预测错误,直接让吞吐量掉了30%。ARM64在这块给了我们不少武器——条件执行指令、精心设计的标志位系统,用好了能省掉大量分支开销。
4.1 条件标志位:NZCV的底层逻辑
ARM64的条件判断,核心就是PSTATE寄存器里的四个标志位:N(负)、Z(零)、C(进位/借位)、V(溢出)。
大部分算术逻辑指令都会更新这些标志。比如ADDS、SUBS、CMP。你想想看,CMP X0, X1其实就是SUBS XZR, X0, X1,只改标志不存结果。
| 标志位 | 含义 | 典型触发条件 |
|---|---|---|
| N | 结果为负(bit 63 = 1) | 有符号运算结果为负数 |
| Z | 结果为零 | 比较相等、运算结果为0 |
| C | 进位/无符号溢出 | 加法产生进位、减法无借位 |
| V | 有符号溢出 | 正+正=负、负+负=正 |
BGT(有符号大于)去判断无符号数,结果负数高位被当成大数。
4.2 条件执行指令:消灭小分支的利器
ARM64没有像ARM32那样的条件执行前缀(比如ADDEQ),但它提供了条件选择和条件增量指令。这些指令不改变程序流,没有分支预测惩罚。
4.2.1 CSEL:条件选择
CSEL Xd, Xn, Xm, cond——条件成立选Xn,否则选Xm。典型的min/max实现:
// 传统分支版本
cmp x0, x1
b.le .Lmax
mov x2, x0
b .Lend
.Lmax:
mov x2, x1
.Lend:
// CSEL版本(无分支)
cmp x0, x1
csel x2, x0, x1, ge // x0 >= x1 时选x0,否则选x1
我在数据库索引遍历中大量用CSEL,把热点路径上的小分支全替换掉,性能提升很明显。
4.2.2 CSINC / CSINV / CSNEG
这三条是CSEL的变体,在选值的同时做一次额外运算:
- CSINC:条件成立选Xn,否则选Xm+1
- CSINV:条件成立选Xn,否则选~Xm(按位取反)
- CSNEG:条件成立选Xn,否则选-Xm(取负)
一个经典用法——绝对值:
// 无分支绝对值
cmp x0, #0
csneg x0, x0, x0, ge // x0 >= 0 选x0,否则选 -x0
4.3 分支指令:B、BL、BR、RET
ARM64的分支指令不多,但每个都有明确用途。
| 指令 | 类型 | 跳转范围 | 典型场景 |
|---|---|---|---|
B label |
无条件分支 | ±128MB | 函数内跳转、循环 |
BL label |
带链接分支 | ±128MB | 函数调用(保存返回地址到X30) |
BR Xn |
寄存器间接分支 | 任意地址 | 函数指针、虚函数、跳转表 |
RET |
返回 | X30指向的地址 | 函数返回 |
嗯,这里要注意:B.cond(条件分支)的跳转范围只有±1MB,比无条件分支小得多。如果你的条件分支目标太远,编译器会生成B.cond + B的组合。
4.4 条件分支优化策略
这部分是我在实际项目中反复打磨出来的经验。
4.4.1 用条件指令替代短分支
规则很简单:如果分支体内只有1-3条指令,且没有函数调用,就用CSEL/CSINC/CSINV/CSNEG替代。
// 优化前
cmp w0, #0
b.eq .Lzero
sub w0, w0, #1
b .Lend
.Lzero:
mov w0, #0
.Lend:
// 优化后
cmp w0, #0
csel w0, wzr, w0, eq // 等于0选0,否则保持
sub w0, w0, #1 // 统一减1
STR或BL,老老实实用分支跳转,别硬套条件指令。
4.4.2 分支预测友好布局
现代分支预测器对后向跳转(循环)和前向跳转(if)有不同预测倾向。通常后向跳转预测为“跳”,前向跳转预测为“不跳”。
所以我的做法是:
- 热路径放前面:把大概率执行的代码放在条件分支后面紧挨着的位置
- 冷路径放远处:异常处理、错误检查放到函数末尾
// 不好的布局
cmp x0, #0
b.eq .Lerror // 错误处理在前,正常路径在后
// 正常处理代码...
b .Lend
.Lerror:
// 错误处理
.Lend:
// 好的布局
cmp x0, #0
b.ne .Lnormal // 正常路径直接fall through
// 错误处理
b .Lend
.Lnormal:
// 正常处理代码
.Lend:
4.4.3 跳转表 vs 条件链
当有多个离散值需要分支时,跳转表(BR + 地址数组)比一串CMP/B.EQ快得多。
// 跳转表示例
adrp x1, jump_table
add x1, x1, :lo12:jump_table
ldr x2, [x1, x0, lsl #3] // x0是索引
br x2
// 跳转表数据(在只读段)
.align 3
jump_table:
.quad case0_handler
.quad case1_handler
.quad case2_handler
跳转表适合连续且密集的索引值。如果索引值稀疏,用CSEL链或者二分查找更划算。
4.4.4 循环展开与分支消除
小循环展开后,循环控制分支可以被消除。比如一个固定4次的循环:
// 展开前
mov w2, #4
.Lloop:
str w0, [x1], #4
subs w2, w2, #1
b.ne .Lloop
// 展开后(无分支)
str w0, [x1], #4
str w0, [x1], #4
str w0, [x1], #4
str w0, [x1], #4
展开后代码体积变大,但消除了分支和标志位检查。我一般在循环次数小于8且体积极小时才做全展开。
4.5 知识结构图
下面这张图梳理了本章的核心脉络:
4.6 实战避坑清单
最后,我把自己踩过的坑整理成一份清单,你写代码时对照着看:
- 别在条件指令里写副作用操作——CSEL只选值,不执行内存访问或函数调用
- 注意标志位被覆盖——
CMP之后如果插了一条不影响标志的指令(比如MOV),条件判断依然有效;但如果插了ADDS,标志就变了 - 跳转表对齐——
LDR加载地址时,确保表是8字节对齐的,否则会有对齐异常 - 循环展开别过度——代码膨胀会导致指令缓存压力增大,得不偿失
- 条件分支范围限制——±1MB对于大部分函数够用,但如果你写巨型函数,注意编译器可能生成 trampoline
perf stat看分支预测失败率。如果低于1%,别花时间优化分支。如果高于5%,优先用条件指令替换,其次调整布局,最后才考虑跳转表和循环展开。