主题
CPU 与存储层次:流水线、缓存与分支预测
一句话:CPU 用流水线把「一条指令的延迟」换成「多条指令的吞吐」,再用多级缓存和分支预测掩盖访存与跳转的等待;代价是流水线冲突、缓存不命中和预测失败都会变成停顿。
适用:x86-64 / AArch64 通用服务器 CPU。流水线级数、缓存容量和延迟随微架构变化,具体数字以厂商优化手册为准。
为什么需要
一条指令走完取指、译码、执行、访存、写回要若干周期。如果严格串行,2.4GHz 的 CPU 每秒也只能完成几亿条指令,而大部分周期其实在等:等指令、等数据、等分支结果。
三种等待决定了现代 CPU 的形态:
- 等指令 → 流水线(pipeline)、超标量、乱序执行
- 等数据 → 多级缓存(cache hierarchy)、硬件预取
- 等分支 → 分支预测(branch prediction)、推测执行
设计目标只有一个:让常见情况不停顿。所有复杂度都是为这个目标付的代价。
核心机制
流水线:用吞吐换延迟
经典五级是取指(IF)、译码(ID)、执行(EX)、访存(MEM)、写回(WB)。现代高性能核的流水线深度在十几到二十几级,级数越深,单条指令的延迟越长,但同一时刻在跑的指令越多。
流水线要处理三类冲突:
- 结构冲突:两条指令抢同一个部件,只能加端口或停顿。
- 数据冲突:后一条指令要用前一条的结果。靠旁路/转发(forwarding)把结果提前送回,但「load 之后立刻用」仍然要等一个 load-use 停顿。
- 控制冲突:遇到跳转,下一条取哪还不知道。这是分支预测要解决的问题。
反直觉的结论:深流水线能抬高主频和吞吐,却会让预测失败、中断和异常的代价变大。Intel NetBurst 曾做到 31 级,最终因功耗和失败代价被放弃;现代核在深度和代价之间取平衡,而不是越深越好。
缓存层级:用局部性换延迟
CPU 和 DRAM 的速度差了几个数量级,单纯提高主频只会让核等得更久。缓存的思路是把「最近用过、附近会用」的数据放在更小更快的存储里。容量越小越快越贵,所以做成多级:
| 层级 | 典型容量 | 典型命中延迟 | 说明 |
|---|---|---|---|
| L1 数据 | 32–64 KB | 约 4–5 周期 | 每核私有,分指令/数据 |
| L2 | 512 KB–2 MB | 约 12–20 周期 | 每核私有 |
| L3 | 8–64 MB | 约 30–50 周期 | 多核共享,常按片切分 |
| DRAM | GB 级 | 约 200–400 周期(约 80–120 ns) | 容量大,带宽受通道数限制 |
以上是 Skylake 到 Zen 4 一代的量级,不同微架构差别明显,以厂商手册为准。
缓存按**缓存行(cache line)**搬运,x86 和多数 ARM 服务器是 64 字节。一次 miss 会把整行取进来,所以顺序访问能吃到空间局部性,指针追逐则经常只用到一行里的一小部分。
多级缓存还要保证多核看到一致的数据,靠 MESI 一类一致性协议。两个核写同一缓存行里的不同变量,会反复让对方的行失效,这就是伪共享(false sharing);变量本身不共享,缓存行共享。
分支预测:用猜测换流水线不断流
条件跳转的结果要到执行阶段甚至更晚才知道。如果每次都等结果再取下一条,深流水线会空掉一大段。预测器的做法是先猜一个方向,沿着猜测路径继续取指和执行;猜错了再清空流水线、从正确地址重取。
- 猜对:流水线满负荷,几乎没有额外代价。
- 猜错:丢掉已做的推测工作,惩罚大致与流水线深度同量级。现代 x86 上常见 15–20 周期,具体随微架构变化。
- 常见结构:两位饱和计数器(记录「上次猜得对不对」)、分支目标缓冲(BTB,记录跳转目标)、返回地址栈(预测函数返回)、更长的历史/全局历史预测器(如 TAGE)。
- 循环分支通常很好预测,因为模式稳定;依赖随机数据的
if很难预测,这就是「同样一段代码,排序后反而更快」的原因。
推测执行也带来安全边界:被丢弃的推测路径仍会留下缓存副作用,Spectre 一类攻击利用的就是这一点,缓解措施又会增加系统调用和上下文切换的成本。
三者如何共同作用
顺序遍历数组时,缓存行和硬件预取把数据提前搬进来,循环分支几乎全中,CPU 能跑满流水线。链表遍历每跳一次都可能 miss,且下一次地址依赖上一次结果,预取器帮不上忙;分支再不可预测,流水线就频繁断流。这就是「数据结构决定性能上限,指令优化只决定下限」的原因。
最小上手
Linux + gcc + perf(perf 需要内核支持 PMU,容器/虚拟机里可能不可用):
bash
lscpu | grep -E 'Model name|MHz|L1d|L2|L3|NUMA'
lscpu -e # CPU 拓扑与在线状态
getconf LEVEL1_DCACHE_LINESIZE # 常见 64
perf stat -e cycles,instructions,branches,branch-misses,cache-references,cache-misses ./prog用一个数据相关分支观察预测失败(编译器若把分支转成条件传送,branch-misses 不会变化,所以显式关掉 if-conversion):
c
// branch.c: gcc -O2 -fno-if-conversion -fno-if-conversion2 branch.c -o branch
#include <stdio.h>
#include <stdlib.h>
static int cmp(const void *a, const void *b) {
return *(const int *)a - *(const int *)b;
}
int main(int argc, char **argv) {
const int N = 1 << 16;
int *a = malloc(N * sizeof(int));
for (int i = 0; i < N; i++) a[i] = rand() % 256;
if (argc > 1) qsort(a, N, sizeof(int), cmp); // 排序后分支变得可预测
long sum = 0;
for (int r = 0; r < 2000; r++)
for (int i = 0; i < N; i++)
if (a[i] >= 128) sum += a[i];
printf("%ld\n", sum);
free(a);
return 0;
}bash
perf stat -e branch-misses ./branch # 随机数据,失败率高
perf stat -e branch-misses ./branch sort # 排序后,失败率明显下降两次的总和相同,耗时和 branch-misses 不同,说明差异来自分支可预测性,而不是计算量。
生产动作
纯基础主题没有「调这个参数就变快」的开关,能落地的是测量顺序和工程判断:
- 先看每周期指令数(IPC,instructions per cycle):
instructions / cycles低,说明核在等,不一定是指令写得差。再拆branch-misses、cache-misses、LLC-load-misses,判断是控制流还是访存问题。 - 定位热点:
perf record -g ./prog后perf report,再看perf annotate到指令级。没有 profile 的微优化通常是在优化噪声。 - 优先改数据布局:连续数组优于指针追逐;热字段放一起,冷字段拆开;避免每个元素一次
malloc。这些改动的收益通常大于换指令。 - 分支优化要克制:能排序/分组就先排序;查表、无分支写法(如
cmov)可以减少失败,但可能引入额外访存,必须用perf stat对比,不要凭直觉。 - 并发看伪共享:跨线程频繁写的变量按缓存行对齐(
alignas(64)或填充),用perf c2c record/report(Linux 4.10+,需硬件支持)确认是否同一行被多核争用。 - NUMA 机器:
numactl --cpunodebind=0 --membind=0 ./prog对比跨节点访问;lscpu看 NUMA 节点,跨节点延迟明显更高。 - 测量前提:固定 CPU、减少频率调节干扰(
cpupower frequency-set -g performance,需 root)、多次取中位数。容器里lscpu看到的是暴露给 guest 的拓扑,PMU 可能被禁用,报告里要写清环境。
边界
- 流水线深度、缓存容量、预测器行为都是微架构细节;同一段代码在 Intel、AMD、ARM 服务器上的结论可能相反,不能把一篇博客的周期数当保证。
- 编译器会向量化、重排、把分支改成条件传送;源码里的
if不一定是机器码里的分支,先看反汇编再下结论。 - 微基准在共享主机、虚拟机和开启缓解措施的环境里噪声很大;绝对值不可跨环境比较。
- 大多数业务系统的瓶颈在 I/O、锁、算法复杂度和下游依赖,不在几个周期的指令选择;先 profile,再决定是否值得动 CPU 层。
- 关闭 Spectre/KPTI 等缓解能换来跑分,但会扩大攻击面;生产环境不应为性能测试关闭。
讲清楚
CPU 的核心矛盾是「执行一条指令要很多周期,但大部分周期在等」。流水线让多条指令重叠执行,把吞吐做上去;缓存用时间局部性和空间局部性,把常用数据放在离核更近的地方;分支预测提前猜走向,避免流水线断流。三者都在掩盖等待,代价分别是冲突停顿、缓存不命中和预测失败后的清空重填。真正影响性能的往往不是主频,而是工作集装不装得进缓存、访存连不连续、分支可不可预测。优化顺序应该是先量:用
perf stat看 IPC、branch-misses、cache-misses,再改数据布局,最后才动指令;没有测量的微优化,通常只是把噪声换了个位置。