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%,近乎抛硬币。

怎么优化?

  1. 用查表代替分支。 比如那句经典的 int sign = (v > 0) - (v < 0);。
  2. 把高频分支放在 if 的真分支。 有些 CPU 的预测器偏向「默认预测真」。
  3. 用 __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 微架构核心逻辑 CPU 核心 分支预测单元 乱序执行引擎 缓存子系统 L1/L2/L3 缓存行 内存控制器 伪共享 / 缓存同步 调优要点 1. 缓存行对齐:热数据之间填充至 64 字节边界,避免伪共享。 2. 分支预测:让分支有规律可循,或用查表、条件移动消除分支。 3. 乱序执行:多核环境使用 atomic / 内存屏障保证正确可见性。 三者共同决定了单线程 / 多线程场景下的真实吞吐量。

嗯,上面这张图把 CPU 内部几个关键单元串起来了。左侧的分支预测单元决定指令流怎么走,中间的乱序执行引擎调度指令执行,右侧的缓存子系统负责数据供应。它们之间的交互,就是你调优时要关注的焦点。

3.5 实战清单:快速自检

最后,给你们一份我在项目中常用的自检清单。做性能调优前,先过一遍:

检查项 关注点 常见问题
缓存行对齐 多线程共享数据 伪共享导致 CPU 内核间串行化
分支预测 热循环里的条件判断 杂乱的分支模式,预测准确率低
乱序与内存模型 无锁数据结构 缺少屏障导致读到过期或错误值
缓存利用率 数据访问模式 跳跃访问导致缓存命中率低

一个小建议: 每次改完代码,用 perf stat -e branch-misses, cache-misses 看看指标。数值降下去,性能自然就上来了。

做个总结吧。弄懂缓存行、分支预测、乱序执行,你就拥有了从「代码层」看到「硬件层」的能力。很多优化,不是为了写更多代码,而是为了让 CPU 少犯错误。