我们写代码时几乎不假思索地使用
if。判断一个条件,选择一条执行路径——这是最基本的控制流。然而在高性能计算的世界里,一个
if 语句可能比你想象的昂贵得多。
问题不在 if
本身的语义,而在它编译为机器码后的行为:条件跳转指令(jcc)会迫使
CPU
在流水线中做出”赌博”——在还不知道条件结果的时候,就猜测接下来应该执行哪条路径。如果赌对了,一切照常;如果赌错了,CPU
必须冲刷整条流水线,回到正确的分支重新开始。在 20+
级流水线的现代处理器上,这意味着浪费 15-20 个时钟周期。
当分支的结果高度随机时——比如二分查找中每次比较的结果几乎是 50/50——分支预测器的命中率会暴跌到接近随机猜测的水平。此时,无分支编程(branchless programming)就成为了一种有效的优化手段。
本文将从 CPU 流水线与分支预测的原理讲起,逐步展开无分支编程的基本技巧、实际应用(无分支二分查找、无分支 partition),以及何时应该、何时不应该使用这类优化。
一、CPU 流水线与分支预测
1.1 流水线的基本思想
现代 CPU 不会一条一条地执行指令。相反,它采用流水线(pipeline)设计:将一条指令的执行拆分为多个阶段,不同指令的不同阶段可以同时进行。
以经典的 5 级流水线为例:
┌───────┐ ┌────────┐ ┌─────────┐ ┌────────┐ ┌───────────┐
│ Fetch │──>│ Decode │──>│ Execute │──>│ Memory │──>│ WriteBack │
└───────┘ └────────┘ └─────────┘ └────────┘ └───────────┘
理想情况下,每个时钟周期都有一条指令完成(吞吐量 = 1 IPC)。但这只在指令之间没有依赖、没有分支跳转时才成立。
现代 CPU 的流水线远不止 5 级。Intel Skylake 有约 14-19 级,AMD Zen 3 有约 19 级,Apple M1 的性能核约 13 级。某些微架构(如 Intel Prescott)甚至达到 31 级。流水线越深,单个时钟周期能做的工作越少(每级更简单),频率可以更高,但分支预测失败的惩罚也越大。
1.2 为什么分支是流水线的大敌
考虑这段代码:
if (x > 0) {
y = a + b; // 路径 A
} else {
y = a - b; // 路径 B
}编译后大致为:
cmp x, 0
jle .path_b ; 条件跳转
; path A
add eax, ebx
jmp .done
.path_b:
; path B
sub eax, ebx
.done:问题在于:当 cmp 指令刚进入 Fetch
阶段时,CPU 还不知道 x > 0 的结果——这要等到
Execute
阶段才能确定。但流水线不能停下来等:它必须继续取下一条指令。于是
CPU 只能猜——猜你会走路径 A 还是路径 B。
这就是分支预测器(branch predictor)的工作。
1.3 分支预测器的演进
分支预测器从简单到复杂,经历了几代演进:
静态预测。最简单的策略:总是预测”不跳转”,或者”后向跳转总是跳(循环),前向跳转总是不跳”。准确率大约 60-70%。
bi-modal 预测器。为每个分支维护一个 2-bit 饱和计数器:
强不跳转 (00) ──跳转──> 弱不跳转 (01) ──跳转──> 弱跳转 (10) ──跳转──> 强跳转 (11)
^ │
└──────────────不跳转──────────────不跳转──────────────不跳转───────────────┘
只有连续两次错误预测才会翻转。准确率约 85-90%。
局部历史预测器。记录每个分支自身最近 N 次的跳转历史,用这个历史去索引一张模式表。可以捕捉到”交替跳转”(TNTNT…)这类模式。
全局历史预测器(gshare)。记录全局最近 N 次任意分支的跳转历史,与分支地址做异或来索引模式表。能捕捉到不同分支之间的相关性,例如”如果前一个 if 成立,后一个 if 通常也成立”。
竞赛预测器(tournament predictor)。同时维护局部和全局预测器,再用一个选择器(meta-predictor)根据历史表现选择使用哪个。Alpha 21264 和 AMD K8 使用这种设计。
TAGE 预测器。Tagged Geometric history length predictor,是现代高性能处理器的主流选择(Intel Haswell 及以后,AMD Zen 系列)。核心思想是维护多张表,每张表使用不同长度的全局历史(几何级数增长:如 2、4、8、16、32、64、128……条历史记录)。查表时选择匹配的最长历史对应的预测结果。TAGE 的准确率在典型工作负载上可以超过 95%。
全局历史寄存器 (GHR)
┌──────────────────────────────────────┐
│ T N T T N T T T N T N N T T T N ... │
└──────────────────────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│ T1 │ │ T2 │ │ T3 │ ... │ Tn │
│ L=2 │ │ L=4 │ │ L=8 │ │L=128│
└──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘
│ │ │ │
▼ ▼ ▼ ▼
pred1 pred2 pred3 predn
│ │ │ │
└───────┴───────────┴───────┬───────┘
▼
选择最长匹配历史的预测
1.4 分支预测失败的代价
分支预测失败时,CPU 必须:
- 检测到预测错误(通常在 Execute 或更晚的阶段)
- 冲刷(flush)流水线中所有在错误路径上取得的指令
- 从正确的目标地址重新开始取指
这个过程的代价取决于流水线深度和微架构设计:
| 微架构 | 流水线深度 | 预测失败代价(cycles) |
|---|---|---|
| Intel Skylake | ~14-19 | ~15-17 |
| Intel Alder Lake (P-core) | ~14-19 | ~16-18 |
| AMD Zen 3 | ~19 | ~15-18 |
| AMD Zen 4 | ~19 | ~13-16 |
| Apple M1 (P-core) | ~13 | ~11-14 |
| ARM Cortex-A77 | ~13 | ~10-13 |
上图展示了分支预测成功时流水线持续满载运行,以及预测失败时流水线被冲刷、产生气泡(bubble),直到正确路径的指令重新填满流水线。
1.5 perf stat 实测:分支预测的表现
在 Linux 系统上,我们可以用 perf stat
直接观测分支预测的命中率:
$ perf stat -e branches,branch-misses ./sorted_binary_search
Performance counter stats for './sorted_binary_search':
1,234,567,890 branches
12,345,678 branch-misses # 1.00% of all branches
$ perf stat -e branches,branch-misses ./random_binary_search
Performance counter stats for './random_binary_search':
1,234,567,890 branches
185,185,185 branch-misses # 15.00% of all branches对于标准二分查找,在 10^7 个元素中查找随机 key,分支预测失败率大约在 15-20%——因为每次比较的结果几乎是 50/50,预测器只能接近随机猜测。
二、CMOV 条件移动指令
2.1 x86 的 cmov 指令家族
cmov(conditional move)是 x86 ISA
中消除分支的核心指令。它在 Pentium Pro(1995
年)引入,现在所有 x86-64 处理器都支持。
基本语义非常简单:
cmovcc dst, src ; if (condition) dst = src; else dst unchanged其中 cc 是条件码,与 jcc
跳转指令使用相同的标志位:
cmove ; ZF=1 (equal / zero)
cmovne ; ZF=0 (not equal / not zero)
cmovg ; ZF=0 && SF=OF (greater, signed)
cmovge ; SF=OF (greater or equal, signed)
cmovl ; SF!=OF (less, signed)
cmovle ; ZF=1 || SF!=OF (less or equal, signed)
cmova ; CF=0 && ZF=0 (above, unsigned)
cmovae ; CF=0 (above or equal, unsigned)
cmovb ; CF=1 (below, unsigned)
cmovbe ; CF=1 || ZF=1 (below or equal, unsigned)关键特性:cmov 不产生分支。CPU 不需要预测任何东西——它同时准备好两个源操作数,根据条件标志位选择其中一个写入目标寄存器。延迟固定约 1-2 个周期。
2.2 cmov vs 分支:性能模型
对于一个简单的条件赋值
y = (x > 0) ? a : b:
使用分支(jcc): -
预测成功:~1 cycle(分支预测正确,流水线不中断) -
预测失败:~15-20 cycles(流水线冲刷) -
期望延迟:1 * hit_rate + penalty * (1 - hit_rate)
使用 cmov: - 始终:~2-3 cycles(无论条件如何) - 但 cmov 有数据依赖:两个源操作数都必须就绪
当分支的可预测性(predictability)很高时(比如 > 95%),分支版本更快:
分支期望延迟 = 1 × 0.95 + 17 × 0.05 = 0.95 + 0.85 = 1.80 cycles
cmov 延迟 = 2-3 cycles
→ 分支更快
当分支不可预测时(比如 50/50):
分支期望延迟 = 1 × 0.50 + 17 × 0.50 = 0.50 + 8.50 = 9.00 cycles
cmov 延迟 = 2-3 cycles
→ cmov 快 3-4 倍
这个分析揭示了一个关键洞察:无分支编程不是无条件更好,而是在不可预测的分支上更好。
2.3 编译器何时会自动使用 cmov
现代编译器(GCC、Clang、MSVC)在优化级别 -O2
及以上时,会自动将某些简单的条件赋值转换为
cmov。但编译器的启发式(heuristic)相当保守:
// 编译器通常会自动转换为 cmov
int min(int a, int b) {
return a < b ? a : b;
}
// GCC -O2 生成的代码:
// cmp edi, esi
// cmovle eax, esi ; 如果 a <= b,eax = b(即返回 a)
// 注:实际生成可能因版本略有不同但以下情况编译器通常不会自动使用 cmov:
- 条件内有副作用:
(x > 0) ? f() : g()无法 cmov,因为函数调用有副作用 - 条件内有内存访问:
(x > 0) ? *p : *q,如果 p 或 q 可能为 NULL,cmov 会导致不必要的内存读取 - 复杂条件表达式:多层嵌套的 if-else
- 编译器认为分支可预测:基于 PGO(profile-guided optimization)数据
2.4 __builtin_expect 与 [[likely]] 提示
GCC 和 Clang 提供 __builtin_expect
来给编译器提供分支概率的提示:
// 告诉编译器:x == 0 不太可能发生
if (__builtin_expect(x == 0, 0)) {
handle_error();
}
// Linux 内核中常见的宏封装
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
if (unlikely(ptr == NULL)) {
return -EINVAL;
}C++20 引入了标准属性 [[likely]] 和
[[unlikely]]:
if (x > 0) [[likely]] {
// 编译器会把这个分支放在直通路径上
process(x);
} else [[unlikely]] {
handle_error();
}这些提示影响的是代码布局(code layout),而非分支预测器本身。编译器会把”likely”的路径放在直通路径(fall-through path),减少一次跳转;把”unlikely”的路径放到函数末尾甚至另一个 section 中,改善指令缓存的利用率。
但注意:__builtin_expect
不会让编译器生成
cmov。恰恰相反——当你告诉编译器一个分支是可预测的,编译器更倾向于保留分支跳转而非使用
cmov。
三、无分支基本技巧
3.1 条件掩码:mask = -(condition)
无分支编程的核心技巧是将布尔条件转换为全 1 或全 0 的位掩码(bitmask):
// condition 为 0 或 1
// -0 = 0x00000000(全 0)
// -1 = 0xFFFFFFFF(全 1)
int32_t mask = -(int32_t)(condition);有了掩码,条件赋值就变成了位运算:
// branchless: result = condition ? a : b
int32_t mask = -(int32_t)(a < b);
int32_t result = (a & mask) | (b & ~mask);等价于
result = (a < b) ? a : b,但没有任何条件跳转。
更通用的形式:
// branchless select: result = condition ? a : b
static inline int32_t select_branchless(int32_t condition, int32_t a, int32_t b) {
// condition 必须是 0 或 1
int32_t mask = -condition;
return (a & mask) | (b & ~mask);
}
// 或者用异或的方式(可能少一条指令)
static inline int32_t select_xor(int32_t condition, int32_t a, int32_t b) {
int32_t mask = -condition;
return b ^ ((a ^ b) & mask);
}3.2 无分支 min/max
// 标准版(带分支)
int min_branch(int a, int b) {
return a < b ? a : b;
}
// 无分支版(纯算术)
int min_branchless(int a, int b) {
int diff = a - b;
// 如果 a < b,diff 为负数,算术右移得全 1
int mask = diff >> 31; // 算术右移:负数得 0xFFFFFFFF,非负得 0
return b + (diff & mask);
// a < b: mask = 0xFFFFFFFF, diff & mask = diff = a - b
// result = b + (a - b) = a ✓
// a >= b: mask = 0, diff & mask = 0
// result = b + 0 = b ... 但 a >= b 时应返回 b ✓
}
// 无分支 max
int max_branchless(int a, int b) {
int diff = a - b;
int mask = diff >> 31;
return a - (diff & mask);
}注意:上面的 a - b 在
a 和 b
差距很大时可能溢出。对于可能溢出的情况,应该使用编译器的
cmov 生成或显式的汇编:
// 安全的无分支 min(依赖编译器生成 cmov)
int min_safe(int a, int b) {
int result = a;
__asm__ volatile(
"cmp %[b], %[result]\n\t"
"cmovg %[b], %[result]\n\t"
: [result] "+r" (result)
: [b] "r" (b)
: "cc"
);
return result;
}3.3 无分支 abs
// 标准版
int abs_branch(int x) {
return x < 0 ? -x : x;
}
// 无分支版
int abs_branchless(int x) {
int mask = x >> 31; // 负数: 0xFFFFFFFF, 非负: 0x00000000
return (x + mask) ^ mask; // 负数: (x - 1) ^ (-1) = ~(x - 1) = -x
// 非负: (x + 0) ^ 0 = x
}
// 等价写法
int abs_branchless_v2(int x) {
int mask = x >> 31;
return (x ^ mask) - mask;
// 负数: (x ^ -1) - (-1) = ~x + 1 = -x
// 非负: (x ^ 0) - 0 = x
}3.4 无分支 clamp
// clamp(x, lo, hi) = min(max(x, lo), hi)
int clamp_branchless(int x, int lo, int hi) {
// 先 max(x, lo)
int diff1 = x - lo;
int mask1 = diff1 >> 31;
int clamped_lo = lo - (diff1 & mask1); // 如果 x < lo 则 lo,否则 x
// 实际上这里有个巧妙之处:
// x >= lo: mask1 = 0, result = lo - 0 = lo ... 错了!
// 让我们用另一种方式:
// 用 select 方式更清晰
int t = x;
// max(t, lo)
int d1 = t - lo;
int m1 = d1 >> 31;
t = t - (d1 & m1); // if t < lo: t = t - (t-lo) = lo; else t unchanged
// min(t, hi)
int d2 = t - hi;
int m2 = ~(d2 >> 31); // 注意这里取反:非负时为 0xFFFFFFFF
t = t - (d2 & m2); // 错了,让我重新推导
// 更直接的方式:组合两个无分支 min/max
return min_branchless(max_branchless(x, lo), hi);
}
// 更实用的写法:直接用条件掩码
int clamp_v2(int x, int lo, int hi) {
int mask_lo = -((int32_t)(x < lo)); // x < lo 时全 1
int mask_hi = -((int32_t)(x > hi)); // x > hi 时全 1
// x < lo: 返回 lo
// x > hi: 返回 hi
// 否则: 返回 x
x = (lo & mask_lo) | (x & ~mask_lo);
x = (hi & mask_hi) | (x & ~mask_hi);
return x;
}3.5 无分支 sign
// sign(x) = -1 if x < 0, 0 if x == 0, 1 if x > 0
int sign_branchless(int x) {
// (x > 0) - (x < 0)
// x > 0: 1 - 0 = 1
// x == 0: 0 - 0 = 0
// x < 0: 0 - 1 = -1
return (x > 0) - (x < 0);
// 注意:编译器通常会把 (x > 0) 和 (x < 0) 编译为
// 不带分支的 set 指令(setg, sets)
}
// 另一种方式:利用算术右移
int sign_branchless_v2(int x) {
return (x >> 31) | ((unsigned)(-x) >> 31);
// x < 0: (0xFFFFFFFF) | (正数 >> 31) = -1 | 0或1 = -1
// x > 0: 0 | ((-x) >> 31) = 0 | 1 = 1 (因为 -x 为负数,无符号右移最高位为 1... 不对)
// 这个版本有陷阱,用第一种更安全
}3.6 无分支条件加法/减法
// if (condition) x += delta;
// 无分支版:
x += delta & (-(int32_t)condition);
// condition = 1: mask = -1 = 0xFFFFFFFF, delta & mask = delta, x += delta
// condition = 0: mask = 0, delta & mask = 0, x += 0
// 常见应用:计数
int count_positives_branchless(const int *arr, int n) {
int count = 0;
for (int i = 0; i < n; i++) {
count += (arr[i] > 0); // 无分支:(arr[i] > 0) 产生 0 或 1
}
return count;
}四、无分支二分查找
标准二分查找是分支预测的噩梦:每次比较的结果几乎完全随机(50/50),预测器毫无办法。无分支二分查找从两个方面解决这个问题:消除条件跳转,以及改善内存访问模式。
4.1 标准二分查找的问题
// 标准二分查找
int binary_search_standard(const int *arr, int n, int key) {
int lo = 0, hi = n - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
if (arr[mid] == key) return mid; // 分支 1
else if (arr[mid] < key) lo = mid + 1; // 分支 2
else hi = mid - 1;
}
return -1;
}每次循环有 2 个条件分支,每个几乎不可预测。对于 n = 10^7 的数组,查找需要约 23 次迭代,产生约 46 次分支——其中大约一半预测失败。
用 perf stat 测量:
标准二分查找(10^7 元素,10^6 次随机查找):
branches: ~46,000,000
branch-misses: ~11,500,000 (25%)
cycles/lookup: ~450
4.2 无分支二分查找:消除条件跳转
第一步优化:把 if-else
转换为无分支的条件更新:
// 无分支二分查找(lower_bound 语义)
int binary_search_branchless(const int *arr, int n, int key) {
const int *base = arr;
int len = n;
while (len > 1) {
int half = len / 2;
// 无分支:如果 base[half] < key,base 前进 half 步
// __builtin_expect 提示编译器不要对此做分支预测优化
base += (base[half] < key) * half;
len -= half;
}
return (*base < key) + (base - arr);
}编译器生成的代码大致为:
.loop:
mov ecx, edx ; ecx = len
shr ecx, 1 ; half = len / 2
mov eax, [rdi + rcx*4] ; load base[half]
cmp eax, esi ; compare with key
lea rax, [rdi + rcx*4] ; rax = base + half(预计算)
cmovl rdi, rax ; if base[half] < key: base = base + half
sub edx, ecx ; len -= half
cmp edx, 1
jg .loop核心的条件更新
base += (base[half] < key) * half 被编译为
cmovl——没有分支预测,没有流水线冲刷。
4.3 Eytzinger 布局:缓存友好的二分查找
标准二分查找的另一个问题是内存访问模式:访问位置在整个数组中跳来跳去,对 cache 极不友好。特别是前几次访问(访问数组的 1/2、1/4、1/8 处),几乎每次都是 cache miss。
Eytzinger 布局(也叫 BFS 布局)将排序数组按照完全二叉树的 BFS 顺序重排:
原始排序数组: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
完全二叉树:
8
/ \
4 12
/ \ / \
2 6 10 14
/ \ / \ / \ / \
1 3 5 7 9 11 13 15
Eytzinger 数组(BFS 顺序,1-indexed):
index: [_, 8, 4, 12, 2, 6, 10, 14, 1, 3, 5, 7, 9, 11, 13, 15]
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
在 Eytzinger 布局中: - 节点 i 的左孩子在 2i - 节点 i 的右孩子在 2i + 1
二分查找变成了从根(index 1)开始的树遍历,每次向左或向右走一步:
// 将排序数组转换为 Eytzinger 布局
void build_eytzinger(const int *sorted, int *eyt, int n, int i, int *k) {
if (i <= n) {
build_eytzinger(sorted, eyt, n, 2 * i, k);
eyt[i] = sorted[(*k)++];
build_eytzinger(sorted, eyt, n, 2 * i + 1, k);
}
}
// Eytzinger 布局的无分支二分查找
int eytzinger_search(const int *eyt, int n, int key) {
int i = 1;
while (i <= n) {
// 预取下一层的两个孩子节点
__builtin_prefetch(&eyt[2 * i]);
__builtin_prefetch(&eyt[2 * i + 1]);
// 无分支:i = 2*i + (eyt[i] < key)
// 如果 eyt[i] < key,走右孩子 (2*i+1)
// 否则走左孩子 (2*i)
i = 2 * i + (eyt[i] < key);
}
// i 现在指向叶子下方,需要回溯找到实际位置
// 右移直到 i 是偶数位(从左孩子来的)或者 i 回到根
i >>= __builtin_ffs(~i); // 找到最低的 0 bit
return (i <= n) ? i : -1;
}Eytzinger 布局的优势:
- prefetch 友好。下一次访问的位置完全确定——就是当前节点的两个孩子。我们可以提前预取,隐藏内存延迟。
- 缓存行利用率高。树的同一层的节点在数组中相邻存储,一个缓存行可以容纳多个节点。
- 无分支。
i = 2 * i + (eyt[i] < key)完全无分支。
4.4 性能对比
典型性能数据(Intel i7-12700K,N = 2^23):
| 方法 | 每次查询耗时 | 相对标准二分 |
|---|---|---|
| 标准二分查找 | ~450 ns | 1.0× |
| 无分支二分查找 | ~300 ns | 1.5× |
| Eytzinger + prefetch | ~160 ns | 2.8× |
Eytzinger 布局的提升来自两方面:无分支消除了预测失败的惩罚(约 1.5× 提升),prefetch 隐藏了内存延迟(再提升约 1.8×),组合起来约 2.8× 提升。
五、无分支 partition(pdqsort 中的 branchless partition)
快排的核心操作是 partition:将数组分为小于 pivot 和大于 pivot 两部分。标准的 Hoare partition 或 Lomuto partition 都包含条件分支,而在数据随机时这些分支几乎不可预测。
pdqsort(Pattern-Defeating Quicksort)的关键创新之一就是无分支的 partition 操作。
5.1 Lomuto partition 的分支问题
// 标准 Lomuto partition
int lomuto_partition(int *arr, int lo, int hi) {
int pivot = arr[hi];
int i = lo;
for (int j = lo; j < hi; j++) {
if (arr[j] <= pivot) { // 这个分支几乎不可预测!
swap(&arr[i], &arr[j]);
i++;
}
}
swap(&arr[i], &arr[hi]);
return i;
}当数据随机时,arr[j] <= pivot 约 50%
的时间为 true——分支预测器的噩梦。
5.2 无分支 Lomuto partition
BlockQuicksort 论文提出的思路:把比较结果缓存到一个 block 中,然后统一处理:
// 无分支 Lomuto partition(简化版)
int branchless_lomuto(int *arr, int lo, int hi) {
int pivot = arr[hi];
int i = lo;
for (int j = lo; j < hi; j++) {
// 无分支交换:总是做"交换"操作,但当条件不满足时
// 交换的是 arr[i] 和 arr[i] 自己(no-op)
int should_swap = (arr[j] <= pivot);
// 无条件交换 arr[i] 和 arr[j]
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
// 无分支递增:i += should_swap
// 如果 should_swap = 0,下一次循环会把 arr[i](刚放入的值)
// 再换走,相当于撤销了这次交换
i += should_swap;
}
// 最后把 pivot 放到正确位置
int tmp = arr[i];
arr[i] = arr[hi];
arr[hi] = tmp;
return i;
}这个版本看起来做了更多工作(每次循环都做交换),但由于消除了分支,在随机数据上通常更快。
5.3 pdqsort 的 block partition
pdqsort 采用了更高效的方法:将 partition 分成 block 处理,每个 block 先收集需要交换的元素索引,然后批量交换。核心的无分支技巧在于收集阶段:
#define BLOCK_SIZE 64
unsigned char offsets[BLOCK_SIZE];
int num = 0;
for (int k = start; k < start + BLOCK_SIZE; k++) {
offsets[num] = (unsigned char)(k - start);
num += (arr[k] >= pivot); // 无分支递增
}每次循环都写入
offsets[num],但只有当条件满足时
num
才递增。如果条件不满足,下一次循环会覆盖这个位置。这完全消除了条件跳转。左右两边各用一个
block 收集后,再批量配对交换。
5.4 实际性能对比
排序 10^7 个随机 int(perf stat 数据):
标准 Hoare partition (introsort):
branch-misses: ~12.5%
cycles: ~1.2 × 10^9
无分支 block partition (pdqsort):
branch-misses: ~1.8%
cycles: ~0.7 × 10^9
加速比: ~1.7×
但在已排序或近似排序的数据上,标准 partition 的分支变得高度可预测(几乎总是走同一条路径),此时无分支版本反而可能更慢——因为它做了不必要的额外工作(总是写 offsets 数组)。pdqsort 正是利用了这一点:它先检测数据模式,在数据看起来已排序时使用标准 partition 而非无分支 partition。
六、SIMD 的天然无分支性
SIMD(Single Instruction, Multiple Data)指令天然就是无分支的。SIMD 不使用条件跳转,而是使用比较指令生成掩码(mask),然后用掩码选择性地执行操作。
6.1 SIMD 比较与掩码
以 SSE2/AVX2 为例:
#include <immintrin.h>
// SSE2:比较 4 个 int
__m128i a = _mm_set_epi32(10, -5, 3, -8);
__m128i zero = _mm_setzero_si128();
__m128i mask = _mm_cmpgt_epi32(a, zero);
// mask = [0xFFFFFFFF, 0x00000000, 0xFFFFFFFF, 0x00000000]
// (10 > 0) (-5 > 0) (3 > 0) (-8 > 0)
// 用掩码选择:正数保留,负数置零
__m128i result = _mm_and_si128(a, mask);
// result = [10, 0, 3, 0]6.2 SIMD blendv:向量化的条件选择
blendv(blend with variable mask)是 SIMD
世界的 cmov:
// 向量化的 min
__m256i vec_min(__m256i a, __m256i b) {
// 直接用 _mm256_min_epi32 即可,这里展示原理
__m256i mask = _mm256_cmpgt_epi32(a, b); // a > b 的位置为全 1
return _mm256_blendv_epi8(a, b, mask); // mask 为 1 的位置选 b
// 等价于:逐元素 (a > b) ? b : a = min(a, b)
}6.3 SIMD 无分支计数
// 计数数组中正数的个数(SIMD 版)
int count_positives_simd(const int *arr, int n) {
__m256i count = _mm256_setzero_si256();
__m256i zero = _mm256_setzero_si256();
int i = 0;
for (; i + 8 <= n; i += 8) {
__m256i v = _mm256_loadu_si256((__m256i *)(arr + i));
__m256i mask = _mm256_cmpgt_epi32(v, zero);
// mask 中每个元素为 0xFFFFFFFF(-1)或 0x00000000(0)
// 减去 mask 就是加 1 或加 0
count = _mm256_sub_epi32(count, mask);
}
// 水平求和
int result[8];
_mm256_storeu_si256((__m256i *)result, count);
int total = 0;
for (int j = 0; j < 8; j++) total += result[j];
// 处理剩余元素
for (; i < n; i++) total += (arr[i] > 0);
return total;
}整个循环没有任何条件跳转(除了循环控制本身,而循环控制是高度可预测的)。
6.4 SIMD 在排序中的应用
现代排序库(如 Intel 的 x86-simd-sort、Google 的 Highway
排序)大量使用 SIMD 来实现无分支的排序网络(sorting
network)。核心思想是用 _mm_min_epi32 /
_mm_max_epi32 做比较-交换,用
_mm_shuffle_epi32 调整元素位置,用
_mm_blend_epi32 合并结果。一个 4 元素排序只需要
3 步 shuffle + min/max + blend,完全无分支。
七、何时不应该去除分支
无分支编程不是万能药。有几种情况下,保留分支比消除分支更好。
7.1 可预测分支比 cmov 更快
当分支高度可预测(> 95% 的命中率)时,分支版本更快:
// 场景:过滤稀疏数组中的非零元素
// 大部分元素为 0,所以 (arr[i] != 0) 大约 99% 为 false
void filter_sparse_branch(const int *arr, int *out, int n, int *count) {
int c = 0;
for (int i = 0; i < n; i++) {
if (arr[i] != 0) { // 高度可预测!保留分支
out[c++] = arr[i];
}
}
*count = c;
}
// 如果用无分支版本:
void filter_sparse_branchless(const int *arr, int *out, int n, int *count) {
int c = 0;
for (int i = 0; i < n; i++) {
out[c] = arr[i];
c += (arr[i] != 0); // 无分支,但总是写 out[c]
}
*count = c;
}
// 无分支版本总是写入 out[c],即使不需要
// 这增加了不必要的存储带宽开销
// 而且 cmov 引入的数据依赖链会限制乱序执行经验法则:如果分支预测命中率 > 90-95%,保留分支通常更好。
7.2 cmov 的数据依赖问题
cmov 虽然没有控制依赖(control dependency),但引入了数据依赖(data dependency)。CPU 的乱序执行引擎可以推测执行分支的一条路径(即使另一条还没准备好),但 cmov 必须等两个操作数都就绪:
// 分支版本:CPU 可以推测执行一条路径
if (expensive_condition) {
result = complex_computation_a(); // 即使 condition 未知,CPU 可以推测执行这个
} else {
result = complex_computation_b();
}
// cmov 版本:两个计算都必须完成
int a = complex_computation_a(); // 必须执行
int b = complex_computation_b(); // 也必须执行
result = expensive_condition ? a : b;当两条路径中有一条涉及昂贵的操作(如内存加载、除法、函数调用)时,分支版本可以避免执行不需要的那条路径。
7.3 分支帮助编译器和 CPU 优化
分支给编译器和 CPU 提供了有价值的信息:“这两条路径是互斥的”。编译器可以基于此做更激进的优化:
// 编译器知道 p != NULL 在这个路径上
if (p != NULL) {
// 编译器可以假设 p != NULL,省略后续的 NULL 检查
// 向量化器也可以更激进
for (int i = 0; i < n; i++) {
p[i] *= 2;
}
}7.4 branch-misses 随可预测性变化的实测
下面用一个实验展示分支预测命中率如何影响性能。核心思路:控制数组中”满足条件”的元素比例,对比带分支和无分支两个版本:
// 带分支版本
long long sum_branch(const int *arr, int n, int threshold) {
long long sum = 0;
for (int i = 0; i < n; i++) {
if (arr[i] < threshold) sum += arr[i];
}
return sum;
}
// 无分支版本
long long sum_branchless(const int *arr, int n, int threshold) {
long long sum = 0;
for (int i = 0; i < n; i++) {
int mask = -(arr[i] < threshold);
sum += arr[i] & mask;
}
return sum;
}
// 数组元素为 rand() % 256,threshold 从 0 变到 256典型输出(Intel i7-12700K,N = 16M):
threshold pct_true branch_ms branchless_ms
0 0.0% 5.12 8.41
13 5.1% 6.15 8.42
64 25.0% 16.72 8.40
128 50.0% 22.38 8.43 <-- 最不可预测
192 75.0% 16.58 8.41
243 95.0% 6.10 8.40
256 100.0% 5.08 8.39
可以看到:
- 分支版本:在条件高度可预测时(接近 0% 或 100%)非常快,但在 50% 时性能暴跌——因为分支预测命中率最低
- 无分支版本:性能完全不受条件概率影响,始终恒定
- 交叉点:大约在 15-20% 和 80-85% 附近,两者性能相当
这就是无分支编程的精髓:用恒定的中等开销换取对不可预测分支的免疫。
八、完整实现:无分支二分查找 + 无分支 partition
下面给出一个完整的、可编译运行的 C 实现,整合前文的无分支二分查找、Eytzinger 布局查找和无分支 partition:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <stdint.h>
/* 无分支 lower_bound */
static int branchless_lower_bound(const int *arr, int n, int key) {
const int *base = arr;
int len = n;
while (len > 1) {
int half = len / 2;
base += (base[half] < key) * half;
len -= half;
}
return (int)(base - arr) + (*base < key);
}
/* 标准 lower_bound(对照组) */
static int standard_lower_bound(const int *arr, int n, int key) {
int lo = 0, hi = n;
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (arr[mid] < key) lo = mid + 1;
else hi = mid;
}
return lo;
}
/* Eytzinger 布局构建与查找 */
static void eytzinger_build_impl(const int *sorted, int *eyt,
int n, int node, int *idx) {
if (node <= n) {
eytzinger_build_impl(sorted, eyt, n, 2 * node, idx);
eyt[node] = sorted[*idx]; (*idx)++;
eytzinger_build_impl(sorted, eyt, n, 2 * node + 1, idx);
}
}
static int eytzinger_lower_bound(const int *eyt, int n, int key) {
int i = 1;
while (i <= n) {
__builtin_prefetch(&eyt[2 * i]);
i = 2 * i + (eyt[i] < key);
}
i >>= __builtin_ctz(~(unsigned)i);
return (i == 0) ? n : i;
}
/* 无分支 Lomuto partition */
static inline void swap_int(int *a, int *b) {
int t = *a; *a = *b; *b = t;
}
static int branchless_partition(int *arr, int lo, int hi) {
int mid = lo + (hi - lo) / 2;
if (arr[mid] < arr[lo]) swap_int(&arr[lo], &arr[mid]);
if (arr[hi] < arr[lo]) swap_int(&arr[lo], &arr[hi]);
if (arr[mid] < arr[hi]) swap_int(&arr[mid], &arr[hi]);
int pivot = arr[hi];
int i = lo;
for (int j = lo; j < hi; j++) {
int should_swap = (arr[j] <= pivot);
int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
i += should_swap;
}
swap_int(&arr[i], &arr[hi]);
return i;
}
/* 无分支快排(含 insertion sort 收尾) */
#define INSERTION_THRESHOLD 24
static void insertion_sort(int *arr, int lo, int hi) {
for (int i = lo + 1; i <= hi; i++) {
int key = arr[i], j = i - 1;
while (j >= lo && arr[j] > key) { arr[j + 1] = arr[j]; j--; }
arr[j + 1] = key;
}
}
static void bqsort(int *arr, int lo, int hi, int depth) {
while (hi - lo >= INSERTION_THRESHOLD) {
if (depth == 0) break;
depth--;
int p = branchless_partition(arr, lo, hi);
if (p - lo < hi - p) { bqsort(arr, lo, p - 1, depth); lo = p + 1; }
else { bqsort(arr, p + 1, hi, depth); hi = p - 1; }
}
if (hi > lo) insertion_sort(arr, lo, hi);
}
static void branchless_quicksort(int *arr, int n) {
if (n <= 1) return;
int d = 0; for (int t = n; t > 0; t >>= 1) d++;
bqsort(arr, 0, n - 1, d * 2);
}
/* 基准测试 */
static double ms(clock_t s, clock_t e) {
return (double)(e - s) / CLOCKS_PER_SEC * 1000.0;
}
int main(void) {
const int N = 1 << 23, Q = 1 << 20;
int *sorted = malloc(N * sizeof(int));
int *eyt = calloc(N + 1, sizeof(int));
int *queries = malloc(Q * sizeof(int));
for (int i = 0; i < N; i++) sorted[i] = 2 * i + 1;
int idx = 0;
eytzinger_build_impl(sorted, eyt, N, 1, &idx);
srand(42);
for (int i = 0; i < Q; i++) queries[i] = rand() % (2 * N);
volatile int sink = 0;
clock_t s, e;
s = clock();
for (int i = 0; i < Q; i++) sink += standard_lower_bound(sorted, N, queries[i]);
e = clock();
printf("standard: %7.2f ms\n", ms(s, e));
s = clock();
for (int i = 0; i < Q; i++) sink += branchless_lower_bound(sorted, N, queries[i]);
e = clock();
printf("branchless: %7.2f ms\n", ms(s, e));
s = clock();
for (int i = 0; i < Q; i++) sink += eytzinger_lower_bound(eyt, N, queries[i]);
e = clock();
printf("eytzinger: %7.2f ms\n", ms(s, e));
/* 排序测试 */
int *data = malloc(N * sizeof(int));
int *copy = malloc(N * sizeof(int));
for (int i = 0; i < N; i++) data[i] = rand();
memcpy(copy, data, N * sizeof(int));
s = clock(); branchless_quicksort(copy, N); e = clock();
printf("bqsort: %7.2f ms\n", ms(s, e));
int ok = 1;
for (int i = 1; i < N; i++) if (copy[i] < copy[i-1]) { ok = 0; break; }
printf("correctness: %s\n", ok ? "PASS" : "FAIL");
free(sorted); free(eyt); free(queries); free(data); free(copy);
return 0;
}九、工程实践注意事项
9.1 常见陷阱
| 陷阱 | 说明 | 解决方案 |
|---|---|---|
| 整数溢出 | a - b 在 a、b 符号不同且绝对值大时溢出 |
使用更宽的类型或编译器内建 cmov |
| 有符号右移未定义行为 | C 标准未规定有符号整数算术右移的行为 | 实际所有主流平台都用算术右移,但严格来说不可移植 |
| cmov 阻碍乱序执行 | cmov 的两个操作数都必须就绪才能执行 | 当一条路径很昂贵时,保留分支更好 |
| 编译器”聪明过头” | 编译器可能把你的无分支代码又优化回分支 | 检查生成的汇编,必要时使用内联汇编或 volatile |
| 不必要的内存写入 | 无分支 partition 中无条件交换增加存储压力 | 在数据量小时切换到标准分支版本 |
| 测试不充分 | 无分支代码逻辑隐晦,容易出 bug | 写详尽的边界测试,与分支版本对拍 |
| 忽略缓存效应 | 消除分支带来的加速可能被 cache miss 淹没 | 优先优化内存访问模式(如 Eytzinger 布局) |
| 过度优化 | 在分支高度可预测的地方使用无分支编程 | 先用 perf stat 测量 branch-misses,确认问题存在 |
9.2 实战检查清单
在决定是否使用无分支编程之前,按以下步骤检查:
测量 branch-misses。用
perf stat -e branch-misses,branches确认分支预测失败率。如果失败率 < 5%,不需要优化。确认分支在热路径上。用
perf record -e branch-misses+perf report确认哪些分支预测失败最多,以及它们是否在热点函数中。尝试编译器优化。在尝试手写无分支代码之前,先试试
-O3、PGO(-fprofile-generate/-fprofile-use)、以及__builtin_expect。检查生成的汇编。用
objdump -d或 Compiler Explorer(godbolt.org)确认编译器生成了你期望的代码。完整基准测试。不要只测最坏情况——也要测最好情况和典型情况,确保无分支版本不会在其他场景下退化。
9.3 编译器优化选项
不同编译器对无分支代码生成的支持程度不同:
# GCC:-O2 即可生成 cmov,-O3 更激进
gcc -O2 -march=native -o bench bench.c
# Clang:通常比 GCC 更倾向于使用 cmov
clang -O2 -march=native -o bench bench.c
# PGO(Profile-Guided Optimization):让编译器根据实际运行数据决定
gcc -O2 -fprofile-generate -o bench bench.c
./bench # 运行生成 profile 数据
gcc -O2 -fprofile-use -o bench bench.c # 用 profile 数据重新编译十、更多无分支技巧
10.1 无分支条件取模
// 计数器循环递增:i = (i + 1) % n
// n 是 2 的幂时用掩码
i = (i + 1) & (n - 1);
// n 不是 2 的幂时
int next = i + 1;
i = next - (n & -(next == n));10.2 无分支 popcount
// 并行归约(无分支,无循环)
int popcount_branchless(uint32_t x) {
x = x - ((x >> 1) & 0x55555555);
x = (x & 0x33333333) + ((x >> 2) & 0x33333333);
x = (x + (x >> 4)) & 0x0F0F0F0F;
return (x * 0x01010101) >> 24;
}
// 现代 CPU 直接用 __builtin_popcount 编译为 popcnt 指令10.3 无分支向上取整除法
// ceil(a / b),a >= 0, b > 0
int ceildiv(int a, int b) {
return a / b + (a % b != 0); // (a % b != 0) 产生 0 或 1,无分支
}10.4 查表消除多路分支
当输入范围有限且确定时,查表(lookup
table)是消除多路分支的通用方法。典型例子包括 UTF-8
字节长度判断、字符分类(isalpha/isdigit)等——用
256 项查找表替代 4-5 个 if-else 分支,完全消除条件跳转:
// 替代多路 if-else
int utf8_byte_length(unsigned char c) {
static const uint8_t table[256] = {
// 0x00-0x7F: 1, 0x80-0xBF: 1, 0xC0-0xDF: 2, 0xE0-0xEF: 3, 0xF0-0xFF: 4
[0x00 ... 0xBF] = 1, [0xC0 ... 0xDF] = 2,
[0xE0 ... 0xEF] = 3, [0xF0 ... 0xFF] = 4,
};
return table[c];
}十一、个人观点与经验
写了这么多无分支技巧,我想分享一些实际工作中的体会。
首先,无分支编程是一种”最后手段”级别的优化。在实际项目中,算法选择、数据结构设计、内存布局优化带来的收益通常远超微观层面的分支消除。如果你的程序慢,99% 的情况下应该先看算法复杂度和缓存利用率。
其次,现代编译器在分支消除方面已经做得相当好了。在
-O2 或 -O3
优化级别下,编译器会自动将很多简单的条件赋值转换为
cmov。手写无分支代码有时反而会干扰编译器的优化——编译器可能比你更了解目标微架构的特性。
然而,在特定领域,无分支编程确实是不可或缺的技术:
排序库。pdqsort、BlockQuicksort、IPS4o 等现代排序算法的 partition 操作都使用了无分支技巧。这是因为 partition 中的比较天然不可预测。
数据库查询引擎。在 OLAP 场景中,过滤(filter)和聚合(aggregation)操作需要处理大量数据,列存储中的比较操作往往不可预测。DuckDB、ClickHouse 等数据库广泛使用 SIMD + 无分支技巧。
密码学。常数时间(constant-time)实现是密码学的基本要求——任何分支都可能导致时序侧信道攻击。所有比较、选择操作都必须用无分支实现。
游戏引擎和实时系统。物理模拟、碰撞检测中大量的 min/max/clamp 操作,SIMD 化的无分支实现可以带来显著提升。
最后一个建议:总是测量,永远不要猜。perf stat
是你最好的朋友。在声称”分支是瓶颈”之前,先拿出 branch-misses
的数据。我见过太多人花几天时间把代码改成无分支版本,结果性能差距在噪声范围内——因为分支预测器已经工作得足够好了。
十二、总结
| 技术 | 适用场景 | 不适用场景 |
|---|---|---|
| cmov 条件移动 | 不可预测的简单条件选择 | 可预测分支、昂贵操作 |
算术掩码 -(cond) |
批量条件操作、位运算 | 需要处理浮点数 |
| 无分支二分查找 | 大数组随机查询 | 小数组(直接线性搜索更快) |
| Eytzinger 布局 | 大数组静态集合 | 频繁插入删除的动态集合 |
| 无分支 partition | 随机数据排序 | 近似排序数据 |
| SIMD 掩码操作 | 批量数据并行处理 | 标量操作、复杂控制流 |
| 查表替代分支 | 输入范围小且确定 | 输入范围大(表太大导致 cache miss) |
核心原则:
- 先测量,后优化。
perf stat -e branch-misses是起点。 - 理解交叉点。不可预测分支 → 无分支更快;可预测分支 → 保留分支更快。
- 缓存 > 分支。内存访问模式的优化(如 Eytzinger 布局)通常比纯粹消除分支更有效。
- 信任编译器。先试
-O2、PGO,再考虑手写。 - 验证汇编。确认编译器生成了你期望的代码。
参考文献
- Fog, Agner. “Microarchitecture of Intel, AMD, and VIA CPUs.” Agner’s Software Optimization Resources, 2023.
- Fog, Agner. “Optimizing Software in C++.” Agner’s Software Optimization Resources, 2023.
- Khuong, Paul-Virak, and Pat Morin. “Array Layouts for Comparison-Based Searching.” Journal of Experimental Algorithmics, 22(1), 2017.
- Edelkamp, Stefan, and Armin Weiß. “BlockQuicksort: Avoiding Branch Mispredictions in Quicksort.” Journal of Experimental Algorithmics, 24(1), 2019.
- Peters, Orson. “Pattern-Defeating Quicksort.” arXiv preprint arXiv:2106.05123, 2021.
- Schlegel, Benjamin, et al. “Fast Sorted-Set Intersection using SIMD Instructions.” ADMS Workshop, 2011.
- Mytkowicz, Todd, et al. “Producing Wrong Data Without Doing Anything Obviously Wrong!” ASPLOS, 2009.
- Seznec, André. “TAGE-SC-L Branch Predictors.” Journal of Instruction-Level Parallelism, 2016.
- Intel Corporation. “Intel 64 and IA-32 Architectures Optimization Reference Manual.” 2023.
- Lemire, Daniel. “The Surprising Cleverness of Modern Compilers.” lemire.me, 2019.
算法系列导航:上一篇:缓存无关算法 | 下一篇:SIMD 算法设计模式
相关阅读:pdqsort | Swiss Table