土法炼钢兴趣小组的算法知识备份

无分支编程:当 if 成为性能杀手

目录

我们写代码时几乎不假思索地使用 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 必须:

  1. 检测到预测错误(通常在 Execute 或更晚的阶段)
  2. 冲刷(flush)流水线中所有在错误路径上取得的指令
  3. 从正确的目标地址重新开始取指

这个过程的代价取决于流水线深度和微架构设计:

微架构 流水线深度 预测失败代价(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
CPU 流水线与分支预测失败的代价

上图展示了分支预测成功时流水线持续满载运行,以及预测失败时流水线被冲刷、产生气泡(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:

  1. 条件内有副作用(x > 0) ? f() : g() 无法 cmov,因为函数调用有副作用
  2. 条件内有内存访问(x > 0) ? *p : *q,如果 p 或 q 可能为 NULL,cmov 会导致不必要的内存读取
  3. 复杂条件表达式:多层嵌套的 if-else
  4. 编译器认为分支可预测:基于 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 - bab 差距很大时可能溢出。对于可能溢出的情况,应该使用编译器的 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 布局的优势:

  1. prefetch 友好。下一次访问的位置完全确定——就是当前节点的两个孩子。我们可以提前预取,隐藏内存延迟。
  2. 缓存行利用率高。树的同一层的节点在数组中相邻存储,一个缓存行可以容纳多个节点。
  3. 无分支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

可以看到:

这就是无分支编程的精髓:用恒定的中等开销换取对不可预测分支的免疫

八、完整实现:无分支二分查找 + 无分支 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 实战检查清单

在决定是否使用无分支编程之前,按以下步骤检查:

  1. 测量 branch-misses。用 perf stat -e branch-misses,branches 确认分支预测失败率。如果失败率 < 5%,不需要优化。

  2. 确认分支在热路径上。用 perf record -e branch-misses + perf report 确认哪些分支预测失败最多,以及它们是否在热点函数中。

  3. 尝试编译器优化。在尝试手写无分支代码之前,先试试 -O3、PGO(-fprofile-generate / -fprofile-use)、以及 __builtin_expect

  4. 检查生成的汇编。用 objdump -d 或 Compiler Explorer(godbolt.org)确认编译器生成了你期望的代码。

  5. 完整基准测试。不要只测最坏情况——也要测最好情况和典型情况,确保无分支版本不会在其他场景下退化。

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。手写无分支代码有时反而会干扰编译器的优化——编译器可能比你更了解目标微架构的特性。

然而,在特定领域,无分支编程确实是不可或缺的技术

  1. 排序库。pdqsort、BlockQuicksort、IPS4o 等现代排序算法的 partition 操作都使用了无分支技巧。这是因为 partition 中的比较天然不可预测。

  2. 数据库查询引擎。在 OLAP 场景中,过滤(filter)和聚合(aggregation)操作需要处理大量数据,列存储中的比较操作往往不可预测。DuckDB、ClickHouse 等数据库广泛使用 SIMD + 无分支技巧。

  3. 密码学。常数时间(constant-time)实现是密码学的基本要求——任何分支都可能导致时序侧信道攻击。所有比较、选择操作都必须用无分支实现。

  4. 游戏引擎和实时系统。物理模拟、碰撞检测中大量的 min/max/clamp 操作,SIMD 化的无分支实现可以带来显著提升。

最后一个建议:总是测量,永远不要猜perf stat 是你最好的朋友。在声称”分支是瓶颈”之前,先拿出 branch-misses 的数据。我见过太多人花几天时间把代码改成无分支版本,结果性能差距在噪声范围内——因为分支预测器已经工作得足够好了。

十二、总结

技术 适用场景 不适用场景
cmov 条件移动 不可预测的简单条件选择 可预测分支、昂贵操作
算术掩码 -(cond) 批量条件操作、位运算 需要处理浮点数
无分支二分查找 大数组随机查询 小数组(直接线性搜索更快)
Eytzinger 布局 大数组静态集合 频繁插入删除的动态集合
无分支 partition 随机数据排序 近似排序数据
SIMD 掩码操作 批量数据并行处理 标量操作、复杂控制流
查表替代分支 输入范围小且确定 输入范围大(表太大导致 cache miss)

核心原则:

  1. 先测量,后优化perf stat -e branch-misses 是起点。
  2. 理解交叉点。不可预测分支 → 无分支更快;可预测分支 → 保留分支更快。
  3. 缓存 > 分支。内存访问模式的优化(如 Eytzinger 布局)通常比纯粹消除分支更有效。
  4. 信任编译器。先试 -O2、PGO,再考虑手写。
  5. 验证汇编。确认编译器生成了你期望的代码。

参考文献

  1. Fog, Agner. “Microarchitecture of Intel, AMD, and VIA CPUs.” Agner’s Software Optimization Resources, 2023.
  2. Fog, Agner. “Optimizing Software in C++.” Agner’s Software Optimization Resources, 2023.
  3. Khuong, Paul-Virak, and Pat Morin. “Array Layouts for Comparison-Based Searching.” Journal of Experimental Algorithmics, 22(1), 2017.
  4. Edelkamp, Stefan, and Armin Weiß. “BlockQuicksort: Avoiding Branch Mispredictions in Quicksort.” Journal of Experimental Algorithmics, 24(1), 2019.
  5. Peters, Orson. “Pattern-Defeating Quicksort.” arXiv preprint arXiv:2106.05123, 2021.
  6. Schlegel, Benjamin, et al. “Fast Sorted-Set Intersection using SIMD Instructions.” ADMS Workshop, 2011.
  7. Mytkowicz, Todd, et al. “Producing Wrong Data Without Doing Anything Obviously Wrong!” ASPLOS, 2009.
  8. Seznec, André. “TAGE-SC-L Branch Predictors.” Journal of Instruction-Level Parallelism, 2016.
  9. Intel Corporation. “Intel 64 and IA-32 Architectures Optimization Reference Manual.” 2023.
  10. Lemire, Daniel. “The Surprising Cleverness of Modern Compilers.” lemire.me, 2019.

算法系列导航上一篇:缓存无关算法 | 下一篇:SIMD 算法设计模式

相关阅读pdqsort | Swiss Table


By .