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 有符号溢出 正+正=负、负+负=正
我的习惯:做无符号比较时只看C和Z,有符号比较才看N和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
性能关键:条件执行指令的延迟通常是1-2个周期,而分支预测错误一次就要浪费10-20个周期。在95%以上的场景里,用CSEL系列替换短分支都是赚的。

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 知识结构图

下面这张图梳理了本章的核心脉络:

条件指令与分支优化知识结构 条件指令与分支优化知识结构 条件指令与分支优化 条件标志位 NZCV 条件执行指令 分支指令 优化策略 N 负标志 Z 零标志 C 进位标志 V 溢出标志 CSEL CSINC CSINV CSNEG B 无条件 BL 调用 BR 间接 RET 返回 条件指令替代 布局优化 跳转表 循环展开 核心原则:能用条件指令消灭的分支,就不要让CPU去赌 分支预测错误代价 >> 条件指令执行代价,这是所有优化的出发点

4.6 实战避坑清单

最后,我把自己踩过的坑整理成一份清单,你写代码时对照着看:

  • 别在条件指令里写副作用操作——CSEL只选值,不执行内存访问或函数调用
  • 注意标志位被覆盖——CMP之后如果插了一条不影响标志的指令(比如MOV),条件判断依然有效;但如果插了ADDS,标志就变了
  • 跳转表对齐——LDR加载地址时,确保表是8字节对齐的,否则会有对齐异常
  • 循环展开别过度——代码膨胀会导致指令缓存压力增大,得不偿失
  • 条件分支范围限制——±1MB对于大部分函数够用,但如果你写巨型函数,注意编译器可能生成 trampoline
我的建议:先用perf stat看分支预测失败率。如果低于1%,别花时间优化分支。如果高于5%,优先用条件指令替换,其次调整布局,最后才考虑跳转表和循环展开。