条件执行与分支优化:B.cond、CSEL、CCMP

条件执行,说白了就是让CPU根据某个条件来决定下一步干什么。很多刚接触ARM64的朋友会觉得这玩意儿不就是个if-else吗?其实没那么简单。我最早在x86上做优化时,条件跳转用得很随意,后来转到ARM64平台,才发现这里面的门道深得很。

ARM64的条件执行机制,核心就三条指令:B.cond(条件跳转)、CSEL(条件选择)、CCMP(条件比较)。这三兄弟配合好了,能让你的代码跑得飞快;用不好,流水线一停,性能直接腰斩。

核心观点:条件分支的代价远比你想象的大。一次分支预测失败,代价是20-40个时钟周期。而条件选择指令(CSEL)和条件比较指令(CCMP)可以完全避免分支,代价只有1-2个周期。

3.1 条件跳转指令 B.cond

B.cond 是最直观的条件执行方式。它根据 PSTATE 中的条件标志位(NZCV)来决定是否跳转。ARM64 提供了16种条件码,我列个表给你看:

条件码 含义 标志位条件 等价C语言
EQ 等于 Z == 1 ==
NE 不等于 Z == 0 !=
CS/HS 无符号大于等于 C == 1 >= (unsigned)
CC/LO 无符号小于 C == 0 < (unsigned)
MI 负数 N == 1 < 0
PL 非负数 N == 0 >= 0
VS 溢出 V == 1 溢出
VC 无溢出 V == 0 无溢出
HI 无符号大于 C == 1 && Z == 0 > (unsigned)
LS 无符号小于等于 C == 0 || Z == 1 <= (unsigned)
GE 有符号大于等于 N == V >= (signed)
LT 有符号小于 N != V < (signed)
GT 有符号大于 Z == 0 && N == V > (signed)
LE 有符号小于等于 Z == 1 || N != V <= (signed)
AL 无条件 总是 无条件
NV 从不 从不 不可用

嗯,这里要注意:AL 和 NV 在 ARM64 中基本不用。AL 就是无条件跳转,直接用 B 就行;NV 是保留编码,用了会触发未定义指令异常。

举个例子,一个简单的 if-else:

// C 代码
if (x > 0) {
    y = 1;
} else {
    y = 2;
}

// ARM64 汇编
cmp    x0, #0          // 比较 x0 和 0
b.le   .L_else         // 如果 x0 <= 0,跳转到 else
mov    x1, #1          // then 分支:y = 1
b      .L_end
.L_else:
mov    x1, #2          // else 分支:y = 2
.L_end:

这段代码看着没问题,对吧?但我在项目中遇到过一个问题:当 x0 的值随机分布时,分支预测器经常猜错,性能惨不忍睹。后来我换成了 CSEL,效果立竿见影。

避坑指南:我曾经在一个网络包处理模块里大量使用 B.cond,结果吞吐量死活上不去。用 perf 一查,分支预测失败率高达 15%。换成 CSEL 后,预测失败率降到接近 0,吞吐量提升了 30%。

所以我的建议是:能用 CSEL 的地方,就别用 B.cond。

3.2 条件选择指令 CSEL

CSEL 是 ARM64 的明星指令。它根据条件码从两个源寄存器中选择一个写入目标寄存器。没有分支,没有跳转,没有流水线冲刷。

指令格式:

CSEL  Xd, Xn, Xm, cond
// 如果 cond 为真,Xd = Xn;否则 Xd = Xm

刚才那个 if-else 用 CSEL 改写:

cmp    x0, #0          // 比较 x0 和 0
csel   x1, xzr, x2, gt // 如果 x0 > 0,x1 = 0;否则 x1 = x2
// 等等,这里逻辑不对。我们想要的是 y = (x > 0) ? 1 : 2
// 正确写法:
mov    x2, #1
mov    x3, #2
cmp    x0, #0
csel   x1, x2, x3, gt  // 如果 x0 > 0,x1 = 1;否则 x1 = 2

你想想看,这段代码没有分支,CPU 可以一直流水线执行,效率高得多。

CSEL 还有几个变种:

  • CSINC:条件选择并加1。如果条件为真,返回 Xn;否则返回 Xm+1。
  • CSINV:条件选择并取反。如果条件为真,返回 Xn;否则返回 ~Xm。
  • CSNEG:条件选择并取负。如果条件为真,返回 Xn;否则返回 -Xm。

这些变种在实现绝对值、饱和运算时特别有用。比如求绝对值:

// 求 x0 的绝对值,结果存到 x1
// 如果 x0 >= 0,返回 x0;否则返回 -x0
cmp    x0, #0
csneg  x1, x0, x0, ge  // 如果 x0 >= 0,x1 = x0;否则 x1 = -x0

一条指令搞定,没有分支,没有跳转。这就是 ARM64 的魅力。

小技巧:CSEL 系列指令的延迟只有 1-2 个周期,而一次分支预测失败要损失 20+ 个周期。所以,任何简单的条件赋值都可以用 CSEL 替代。但要注意,如果条件分支内部有复杂的计算或函数调用,CSEL 就不合适了。

3.3 条件比较指令 CCMP

CCMP 是个很有意思的指令。它允许你在条件满足时才执行比较操作,否则设置一个默认的标志位值。这玩意儿在实现复杂条件判断时特别有用。

指令格式:

CCMP  Xn, Xm, #nzcv, cond
// 如果 cond 为真,比较 Xn 和 Xm,更新 NZCV
// 否则,将 NZCV 设置为 #nzcv 指定的值

为什么要用 CCMP?我举个例子。假设你要判断一个数是否在 [10, 20] 范围内:

// C 代码
if (x >= 10 && x <= 20) {
    // do something
}

// 传统汇编写法
cmp    x0, #10
b.lt   .L_out           // 如果 x < 10,跳出去
cmp    x0, #20
b.gt   .L_out           // 如果 x > 20,跳出去
// 在范围内,执行操作
.L_out:

// 使用 CCMP 的写法
cmp    x0, #10          // 第一次比较:x >= 10?
ccmp   x0, #20, #4, ge  // 如果 x >= 10,再比较 x <= 20
                        // 否则,设置 Z=0, C=0, V=0, N=0 (nzcv=0100)
b.gt   .L_out           // 如果最终结果 x > 20,跳出去
// 在范围内,执行操作
.L_out:

你发现没有?CCMP 把两次比较合并成了一次条件比较,减少了一条分支指令。更重要的是,它让 CPU 可以提前预判后续的跳转方向。

#nzcv 参数的含义:

位 标志 说明
bit[3] N 负数标志
bit[2] Z 零标志
bit[1] C 进位/借位标志
bit[0] V 溢出标志

比如 #4 就是 0100,表示 Z=0, C=1, N=0, V=0。这个值的选择取决于你想让后续的条件判断得到什么结果。

避坑指南:我曾经在写一个状态机时,用 CCMP 实现多层条件判断。结果调试了整整一天,发现 #nzcv 参数设错了。记住:#nzcv 的值决定了条件不满足时标志位的状态,这个状态必须能让后续的条件跳转走正确的路径。建议先在纸上画好真值表再写代码。

3.4 三种指令的对比与选择

我把这三种指令的适用场景整理了一下:

指令 适用场景 性能代价 推荐指数
B.cond 分支内部代码量大(>5条指令)、有函数调用、循环 预测成功:1-2周期
预测失败:20-40周期
★★☆☆☆
CSEL 简单的条件赋值、绝对值、min/max 1-2周期(无分支) ★★★★★
CCMP 复杂条件判断(&&、||)、范围检查 1-2周期(减少分支) ★★★★☆

我个人习惯是:能用 CSEL 绝不用 B.cond,能用 CCMP 减少分支就尽量用。但也不是绝对的,如果分支内部有几十条指令,那分支预测的收益可能更大。

3.5 知识体系总览

下面这张图把本章的核心逻辑串起来了:

条件执行与分支优化知识体系 条件执行与分支优化核心逻辑 条件执行 B.cond 条件跳转 CSEL 条件选择 CCMP 条件比较 特性 • 有分支,可能预测失败 • 适合复杂分支内部 • 代价:1-40周期 特性 • 无分支,零预测代价 • 适合简单条件赋值 • 代价:1-2周期 特性 • 减少分支数量 • 适合复杂条件判断 • 代价:1-2周期 核心原则:能用 CSEL/CCMP 就尽量不用 B.cond

3.6 实战建议

说了这么多,我总结几条实战经验:

  1. 热点路径优先用 CSEL:在循环内部、频繁执行的路径上,能用 CSEL 替代 B.cond 就替代。性能提升立竿见影。
  2. CCMP 适合范围检查:判断一个数是否在某个区间内,用 CCMP 可以减少一条分支指令。
  3. 不要滥用:如果分支内部有函数调用、循环或者大量计算,B.cond 可能更合适。CSEL 和 CCMP 只适合简单的条件操作。
  4. 用 perf 验证:我每次做完优化,都会用 perf stat 检查分支预测失败率。如果 branch-misses 占比超过 5%,就说明还有优化空间。

调试小技巧:用 GDB 调试条件执行代码时,可以设置断点观察 NZCV 标志位的变化。命令是 info registers cpsr,或者用 p $cpsr。看到标志位的变化,你就知道条件判断是怎么走的了。

嗯,条件执行这块内容就讲到这里。记住一句话:分支是性能的敌人,条件选择是分支的克星。写 ARM64 代码时,多想想能不能用 CSEL 和 CCMP 替代分支,你的代码会跑得更快。