跳到正文

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 周期每核私有,分指令/数据
L2512 KB–2 MB约 12–20 周期每核私有
L38–64 MB约 30–50 周期多核共享,常按片切分
DRAMGB 级约 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,再改数据布局,最后才动指令;没有测量的微优化,通常只是把噪声换了个位置。

把一门技术讲透到既懂也会用