顺序和乱对分支预测的影响
实例1
我们首先看下,排序对分支预测的影响,测试代码如下:
cpp
// branch_predictability.cpp
//
// 聚焦实验: 分支预测准确率 = 分支方向序列有没有可学习的模式
//
// 同一份数据(同样的 byte 值), 只改变排列顺序, 跑同一个 if 分支:
// 1. 随机打乱 : 方向 ~50/50 且独立 -> 预测器学不到东西
// 2. 升序排序 : 一段不跳 + 一段跳 -> 1 次翻转, 几乎全对
// 3. 交替高低 : 跳/不跳周期重复 -> 周期模式, 可学习
// 4. 偏态随机(10%) : 方向偏向一边 -> 统计偏置, 可学习
//
// 对照组: 把同一个负载写成 cmov(无分支), 其耗时应当与排列方式无关
// -> 用来证明分支版的差异来自预测器, 不是 cache / 内存局部性。
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <random>
#include <vector>
using Clock = std::chrono::steady_clock;
volatile int64_t g_sink = 0;
template <typename F>
static double best_ms(F&& f, int reps = 5) {
double best = 1e30;
for (int i = 0; i < reps; ++i) {
auto t0 = Clock::now();
f();
auto t1 = Clock::now();
best = std::min(best, std::chrono::duration<double, std::milli>(t1 - t0).count());
}
return best;
}
// 分支版: 空 asm 阻止 if-convert, 保持真实条件跳转
__attribute__((noinline))
static int64_t sum_branch(const std::vector<uint8_t>& data) {
int64_t sum = 0;
for (uint8_t v : data)
if (v >= 128) {
asm volatile("");
sum += v;
}
return sum;
}
// 无分支版: 三元 -> cmov, 与预测器无关
__attribute__((noinline))
static int64_t sum_cmov(const std::vector<uint8_t>& data) {
int64_t sum = 0;
for (uint8_t v : data)
sum += (v >= 128) ? v : 0;
return sum;
}
int main() {
const size_t N = 1u << 22; // 4M bytes
std::mt19937 rng(42);
std::uniform_int_distribution<int> dist(0, 255);
// 基数据: 一半 < 128, 一半 >= 128 (保证 p(跳)=0.5)
std::vector<uint8_t> base(N);
for (size_t i = 0; i < N; ++i)
base[i] = (uint8_t)(i % 2 ? 200 : 50); // 交替的高低值
// ---------- 四种排列 ----------
std::vector<uint8_t> random_data = base;
std::shuffle(random_data.begin(), random_data.end(), rng); // 随机
std::vector<uint8_t> sorted_data = base;
std::sort(sorted_data.begin(), sorted_data.end()); // 升序
// 交替: 直接用 base (50,200,50,200,...), 周期=2
std::vector<uint8_t> alt_data = base;
// 偏态: 90% 低, 10% 高 -> p(跳)=0.1
std::vector<uint8_t> biased_data(N);
for (size_t i = 0; i < N; ++i)
biased_data[i] = (i % 10 == 0) ? 200 : 50;
std::shuffle(biased_data.begin(), biased_data.end(), rng);
struct Case {
const char* name;
const char* hint;
std::vector<uint8_t>* data;
};
Case cases[] = {
{"随机(50/50) ", "无规律, 不可学习", &random_data},
{"升序排序 ", "一次翻转, 可锁定", &sorted_data},
{"交替高低 ", "周期 2, 可学习 ", &alt_data},
{"偏态随机(10%)", "统计偏置, 可学习", &biased_data},
};
std::printf("%-14s %-14s %12s %12s %8s\n",
"排列", "预测器可学?", "分支版(ms)", "cmov版(ms)", "分支/cmov");
std::printf("------------------------------------------------------------\n");
for (auto& c : cases) {
double t_br = best_ms([&] { g_sink = sum_branch(*c.data); });
double t_cm = best_ms([&] { g_sink = sum_cmov(*c.data); });
std::printf("%-14s %-14s %12.2f %12.2f %7.1fx\n",
c.name, c.hint, t_br, t_cm, t_br / t_cm);
}
std::printf("\ncmov 版对所有排列恒定 -> 差异 100%% 来自分支预测器\n");
return 0;
}
我们比较了不同分布的一组数据,即随机程度不同的几种数据条件求和的效率,这里采用了两个函数sum_branch和sum_cmov,sum_branch(真实条件跳转)--- 6 条指令 + 分支:
bash
1890: movzbl (%rax), %edx ; 取 v
1893: test %dl, %dl ; 测最高位 (v >= 128 等价于 signed 为负)
1895: jns 189a ; ← 真实分支! 条件为假则跳过加法
1897: add %rdx, %r8 ; 条件为真才执行加法 (r8 = sum)
189a: add $0x1, %rax ; 指针 +1
189e: cmp %rcx, %rax ; 边界检查
18a1: jne 1890 ; 外层循环
sum_cmov(条件传送)--- 6 条指令 + 无分支
bash
18c0: movzbl (%rax), %edx ; 取 v
18c3: mov %rdx, %rcx ; 备份 v
18c6: add %r8, %rdx ; 先算好 sum + v
18c9: test %cl, %cl ; 测符号位
18cb: cmovs %rdx, %r8 ; ← 条件传送! 条件成立才把 rdx 写到 sum
18cf: add $0x1, %rax ; 指针 +1
18d3: cmp %rax, %rsi ; 边界检查
18d6: jne 18c0 ; 外层循环
所以 branch_predictability.cpp 42 - 42 是为了强制执行这个条件判断,如果不加的话,也会优化成sum_cmov的情况。
执行结果如下:
bash
排列 预测器可学? 分支版(ms) cmov版(ms) 分支/cmov
------------------------------------------------------------
随机(50/50) 无规律, 不可学习 14.80 1.83 8.1x
升序排序 一次翻转, 可锁定 2.79 1.88 1.5x
交替高低 周期 2, 可学习 2.78 1.85 1.5x
偏态随机(10%) 统计偏置, 可学习 5.39 1.88 2.9x
cmov 版对所有排列恒定 -> 差异 100% 来自分支预测器
关键点是cmovs是一条"条件 MOV"指令------它在一个周期内完成"看标志位 → 决定要不要写寄存器",不改变指令流方向。所以不管条件是真是假,CPU 前端都能按顺序预取、解码、发射,预测器根本不参与。而`asm volatile("")` 是一个"不可被移到条件分支之外的副作用点",编译器不敢把紧随其后的操作合并成 cmov,被迫保留真分支。
随着随机性增加性能变差的原意安"随机"要精确到"条件熵最大",而不是"看起来乱"。
预测器能学到的不是"有序数据",而是 历史里的可复用模式 。所以:
-
周期模式(跳-不跳-跳-不跳...)→ 即使数据值在变,方向也 100% 可学。
-
强偏态(10% 跳)→ 预测器学到"基本不跳",准确率 ~90%。
-
真正的死敌是: 方向独立、概率接近 50% ------这时候 H(方向 | 历史) = 1 bit,预测器翻遍所有历史位都找不到任何相关性,只能掷硬币。
一句话总结: 分支代价 ≈ P(方向与历史无关) × 误预测惩罚,跟助记符无关。
实例2
我们进一步通过更接近真实情况的实例来说明,代码如下:
cpp
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <random>
#include <vector>
using Clock = std::chrono::steady_clock;
volatile int64_t g_sink;
struct Msg {
uint32_t tag;
uint32_t payload;
};
template <typename F>
static double best_ms(F&& f, int reps = 21) {
double best = 1e30;
for (int i = 0; i < reps; ++i) {
auto t0 = Clock::now();
f();
auto t1 = Clock::now();
best = std::min(best, std::chrono::duration<double, std::milli>(t1 - t0).count());
}
return best;
}
#define COLD_OP(t, v) \
do { \
uint32_t x_ = (v) ^ (t); \
x_ = x_ * 1664525u + 1013904223u; \
x_ ^= x_ >> 13; \
x_ = x_ * 69069u + 1u; \
sum -= (int64_t)(x_ & 0xFF); \
} while (0)
#define LOOP_ATTR __attribute__((noinline, optimize("align-loops=32")))
// 无提示
LOOP_ATTR
static int64_t dispatch_plain(const std::vector<Msg>& v) {
int64_t sum = 0;
for (const auto& m : v) {
if (m.tag == 100) { COLD_OP(1, m.payload); }
else if (m.tag == 200) { COLD_OP(2, m.payload); }
else if (m.tag == 300) { COLD_OP(3, m.payload); }
else { sum += m.payload; } // 热路径排在最后
}
return sum;
}
// 提示前三条是冷分支
LOOP_ATTR
static int64_t dispatch_expect(const std::vector<Msg>& v) {
int64_t sum = 0;
for (const auto& m : v) {
if (__builtin_expect(m.tag == 100, 0)) { COLD_OP(1, m.payload); }
else if (__builtin_expect(m.tag == 200, 0)) { COLD_OP(2, m.payload); }
else if (__builtin_expect(m.tag == 300, 0)) { COLD_OP(3, m.payload); }
else { sum += m.payload; }
}
return sum;
}
int main() {
const size_t N = 1u << 22; // 4M 条消息
std::mt19937 rng(42);
std::uniform_int_distribution<uint32_t> d(1, 1000);
std::vector<Msg> msgs(N);
for (auto& m : msgs) {
m.tag = 0; // 全部走 else: 三条冷分支一次都不进
m.payload = d(rng);
}
double p = best_ms([&] { g_sink = dispatch_plain(msgs); });
double q = best_ms([&] { g_sink = dispatch_expect(msgs); });
std::printf("%-28s %10.3f ms\n", "无提示", p);
std::printf("%-28s %10.3f ms -> 快 %.1f%%\n", "expect 提示 3 条冷分支", q,
100.0 * (p - q) / p);
return 0;
}
执行结果如下:
bash
无提示 4.158 ms
expect 提示 3 条冷分支 2.545 ms -> 快 38.8%
这里的核心关键是__builtin_expect:
cpp
if (__builtin_expect(m.tag == 100, 0)) { ... }
__builtin_expect(exp, c)的语义是: exp`的值极大概率等于c。
第一个参数是要判断的表达式:m.tag == 100
第二个参数是"预期会得到的结果":0 (即false )
合起来就是:m.tag == 100几乎总是false,也就是这条分支几乎不会被进入------它是冷分支。
三条`__builtin_expect(..., 0)` 一起告诉编译器:"这三条`cmp/je` 都是冷分支"。编译器于是把三个冷块全部挪到循环外,热路径`sum += m.payload` 变成一段连续的顺流代码------这正是与`dispatch_plain` (第 65-67 行,没有任何提示)对比的全部差别来源。
dispatch_plain(无提示): 编译器虽然能通过代码结构(else 在最后)猜到 sum += m.payload 可能是常态,但在复杂的控制流中,编译器可能需要保守地按代码书写顺序或默认启发式规则来布局基本块。这可能导致冷分支的代码直接穿插在热路径的控制流上下文中。
dispatch_expect(..., 0)(有提示): __builtin_expect(expr, 0) 明确告诉编译器:expr 值为 0(即 m.tag == 100/200/300 不成立)的概率极高。
-
编译器会将
COLD_OP(冷路径)的代码剥离出主循环的连续执行流,移动到函数的末尾,甚至放到单独的.cold节(section)中。 -
主循环内的热路径(
sum += m.payload)会变成一个紧凑的、顺畅的直通(Fall-through)执行流,中间没有任何无用的跳转干扰。
用伪汇编代码来对比
假设 m.tag 在寄存器中,sum 在寄存器中。
1. dispatch_plain 的简化汇编(无提示,逻辑线性)
在没有提示的情况下,编译器通常会按照你书写的顺序来顺次翻译(或者保守猜测):
cpp
.L_loop_start:
# 检查 tag == 100
cmp [m.tag], 100
je .L_cold_1 # 如果等于100,跳去冷分支
# 检查 tag == 200
cmp [m.tag], 200
je .L_cold_2 # 如果等于200,跳去冷分支
# 检查 tag == 300
cmp [m.tag], 300
je .L_cold_3 # 如果等于300,跳去冷分支
# === 热路径(默认走向) ===
add [sum], [m.payload]
jmp .L_loop_next # 跳回循环头
.L_cold_1:
call COLD_OP_1 # 冷逻辑代码挤在主流程附近
jmp .L_loop_next
.L_cold_2:
call COLD_OP_2
jmp .L_loop_next
.L_cold_3:
call COLD_OP_3
.L_loop_next:
# 循环下一轮...
2. dispatch_expect 的简化汇编(有提示,冷热彻底分离)
通过 __builtin_expect(..., 0),编译器确信前三项极少发生,它会把它们从主循环内部踢出去,放到函数的尾部:
cpp
.L_loop_start:
# 检查这三个冷标签(合并或依次检查,但目标全部指向远端)
cmp [m.tag], 100
je .L_far_away_cold # 极少发生,直接跳转到函数末尾!
cmp [m.tag], 200
je .L_far_away_cold
cmp [m.tag], 300
je .L_far_away_cold
# === 极致紧凑的热路径(直通 Fall-through,中间无任何跳转干扰) ===
add [sum], [m.payload]
jmp .L_loop_start # 直接下一轮,流水线极度顺畅!
# ==================== 函数最末尾(远端代码段) ====================
.L_far_away_cold:
# 在这里处理 100 / 200 / 300 的冷逻辑
# 处理完后跳回循环
jmp .L_loop_start
核心差别总结
-
热路径的纯净度:
-
plain的热路径周围夹杂着跳转和冷代码。 -
expect的热路径极其干净,只有cmp和add,CPU 的分支预测和指令流水线(Pipeline)不会被冷代码打断。
-
-
代码排布(Layout):
-
plain的冷分支就在旁边。 -
expect把冷分支扔到了函数的"远端"(甚至不同内存页),让主循环代码块在 CPU 的指令缓存(I-Cache)里排列得更紧凑。
-
硬件的核心原理
决定性因素:I-Cache(指令缓存)的"污染"
现代 CPU 的 L1 指令缓存(L1 I-Cache)非常小(通常只有 32KB)。如果你的循环每秒要跑几千万次,整个循环的机器码必须死死焊在 L1 I-Cache 里,CPU 才能全速运转。
-
没有提示(Plain): 编译器可能会把
COLD_OP(冷逻辑的代码细节)直接排布在主循环的旁边。这会导致整个循环的代码体积变大,甚至不小心跨越了 Cache Line(缓存行),或者把其他有用的代码挤出 L1 缓存。 -
有提示(Expect): 编译器可以大胆地把冷代码整个搬到内存的"远端"(甚至单独开辟一个冷代码节)。主循环的机器码变得极其短小精悍,完美契合 L1 缓存,实现"零缓存未命中"。
-
通俗比喻: 就像你背书,如果核心公式旁边夹杂了大量生僻的注释和例外情况(冷代码),你的眼睛(CPU 缓存)就要多扫很多字;如果把生僻注释扔到书的附录(远端),你一眼就能扫完核心公式。
现代 CPU 的动态分支预测(其实没那么怕跳转)
你可能会想:现代 CPU 都有非常聪明的动态分支预测器 (例如 TAGE 预测器),它会自动学习历史记录------如果前 999 次 tag 都不等于 100、200、300,CPU 早就猜准了会走 else。为什么还要静态提示?
-
静态提示给编译器"壮胆": 动态预测器需要"犯错几次"才能学会。而
__builtin_expect允许编译器在编译期 就做出结构性决策:把热路径排成一条直线(Fall-through),不需要任何跳转指令就能顺畅执行。 -
即使动态预测很强,如果代码排布不合理(冷热交织),CPU 的取指单元(Fetch Unit)在预取指令时也会被冷代码带偏,导致流水线效率下降。
end