CPU 微架构基础:缓存行、分支预测、乱序执行
做性能调优,不能只盯着代码和算法。
你得知道底层硬件是怎么处理你的指令的。CPU 不是老老实实一条接一条地执行代码,它背后有很多「花招」。弄懂这些花招,你就能写出更「听话」的程序。
我个人习惯把这三个概念叫作性能调优的「三大天王」。它们直接影响程序的执行效率,比单纯数循环次数有用得多。
3.1 缓存行:性能的隐形瓶颈
先说说缓存行。
很多开发者知道 CPU 有 L1、L2、L3 缓存,但不知道缓存是分「行」读取的。现代 CPU 一次从内存加载 64 字节的数据,这就是一个缓存行。
你想想看,如果两个线程各自修改相邻的变量,这两个变量刚好落在同一个缓存行里。会发生什么?
伪共享(False Sharing)。每个核心修改自己的变量,都会导致对方的缓存行失效,引发大量的缓存同步流量。性能会断崖式下跌。
我在项目中遇到过最典型的一次伪共享排错。一个多线程日志库,明明任务量不大,CPU 利用率却飙到 95% 以上。用 perf 追踪,发现大部分时间都花在 __sync_fetch_and_add 上。最后定位到两个线程频繁操作的结构体字段挤在同一个 64 字节内。
解决办法很简单:
- 用
__attribute__((aligned(64)))或 C++17 的std::hardware_destructive_interference_size做内存对齐。 - 或者在热数据之间填充无意义的字节,拉开距离。
// 错误示例:两个字段可能落在同一缓存行
struct BadCounter {
long long a;
long long b;
};
// 正确示例:用填充分离
struct GoodCounter {
long long a;
char padding[56]; // 填充到 64 字节
long long b;
};
| 操作 | 耗时(周期) | 说明 |
|---|---|---|
| L1 缓存命中 | 3~4 | 最快 |
| L2 缓存命中 | 10~12 | 还算快 |
| L3 缓存命中 | 40~50 | 开始慢了 |
| 内存访问 | 100~300 | 真正的瓶颈 |
| 伪共享抖动 | 500+ | 灾难级 |
我的经验: 写多线程代码时,先画个数据布局图。把热数据放在不同的缓存行里,收益比微调循环大得多。
3.2 分支预测:别让 CPU 猜错
接下来是分支预测。这是 CPU 预取指令和乱序执行的基础。
CPU 执行一条分支指令(比如 if、switch),需要等条件结果出来才能知道往哪跳。那它傻等着吗?不会。它会根据历史记录「猜」一个方向,提前执行后续指令。
猜对了,一切顺利。猜错了,不好意思,已经预执行的那些指令全部作废,流水线清空重来。
我曾经踩过的一个坑: 一个排序程序里用 if (data[i] > threshold) 做分类处理。数据是完全随机的,分支预测准确率只有 50%。一个 switch 的误预测惩罚就高达 15~20 个周期。改成分支消除(比如用位运算代替分支),性能直接翻倍。
为什么会这样?因为分支预测器会「学习」你的模式。
- 有规律的数据: 预测准确率 95% 以上。
- 完全随机的数据: 准确率降到 50%,近乎抛硬币。
怎么优化?
- 用查表代替分支。 比如那句经典的
int sign = (v > 0) - (v < 0);。 - 把高频分支放在
if的真分支。 有些 CPU 的预测器偏向「默认预测真」。 - 用
__builtin_expect(x, 1)给编译器提示。 这只是微调,但聊胜于无。
// 分支错误率高
int classify(int v) {
if (v > 100) return 1;
else if (v > 50) return 2;
else return 3;
}
// 分支消除版(用条件移动)
int classify2(int v) {
int r0 = (v > 100) ? 1 : 0;
int r1 = (v > 50) ? (v <= 100 ? 2 : 0) : 3;
return r0 ? 1 : r1;
}
小技巧: 如果热循环里有 if-else 但数据无法控制,试试把数据预排序再遍历。代价是一次性排序的 O(n log n),换来的可能是几十倍的循环加速。
3.3 乱序执行:你以为的顺序,CPU 不认
最后是乱序执行。这是现代 CPU 提升并行度的核心机制。
CPU 不一定按你写的顺序执行指令。它会分析指令间的数据依赖关系,把不依赖前面结果的指令提前执行。
举个例子:
int a = load(x); // 慢,从内存读
int b = load(y); // 慢,从内存读
int c = a + b; // 必须等 a,b 都准备好
int d = e * f; // 这条不依赖前面
乱序执行会让 d = e * f 在 a、b 还在等待内存时提前算完。这叫「隐藏内存延迟」。
但是,这会带来一个问题:对程序员的直觉是反的。你以为是顺序执行,CPU 其实是乱序的。尤其是多核环境下,你在一个核上写的变量顺序,在另一个核上看可能是乱的。
一个真实案例: 我调试过一个生产者-消费者模型,消费者总是读到「半成品」状态。代码逻辑上是先写数据再设标志位。但 CPU 乱序先把标志位写成了 1,消费者看到标志位就去读数据,此时数据还没写进去。这就是典型的「内存重排序」。
解决办法:用内存屏障(Memory Barrier)。
- C++11 里的
std::atomic<>默认带全屏障。 - 性能敏感场景可以用
std::memory_order_acquire/release做更细粒度的控制。
std::atomic<bool> ready{false};
int data = 0;
// 生产者
data = 42;
ready.store(true, std::memory_order_release); // 写屏障:确保前面的写操作先完成
// 消费者
while (!ready.load(std::memory_order_acquire)); // 读屏障:确保后续读操作在获取标志位之后
int value = data; // 现在读到的 data 一定是 42
3.4 知识速览:CPU 微架构核心逻辑
下面这张图总结了这三个概念的关系和影响。你可以把它当作调优时的检查清单。
嗯,上面这张图把 CPU 内部几个关键单元串起来了。左侧的分支预测单元决定指令流怎么走,中间的乱序执行引擎调度指令执行,右侧的缓存子系统负责数据供应。它们之间的交互,就是你调优时要关注的焦点。
3.5 实战清单:快速自检
最后,给你们一份我在项目中常用的自检清单。做性能调优前,先过一遍:
| 检查项 | 关注点 | 常见问题 |
|---|---|---|
| 缓存行对齐 | 多线程共享数据 | 伪共享导致 CPU 内核间串行化 |
| 分支预测 | 热循环里的条件判断 | 杂乱的分支模式,预测准确率低 |
| 乱序与内存模型 | 无锁数据结构 | 缺少屏障导致读到过期或错误值 |
| 缓存利用率 | 数据访问模式 | 跳跃访问导致缓存命中率低 |
一个小建议: 每次改完代码,用 perf stat -e branch-misses, cache-misses 看看指标。数值降下去,性能自然就上来了。
做个总结吧。弄懂缓存行、分支预测、乱序执行,你就拥有了从「代码层」看到「硬件层」的能力。很多优化,不是为了写更多代码,而是为了让 CPU 少犯错误。