CPU 体系结构 入门
我们的 curl 命令编译下来就是一长串机器指令 —— 压寄存器、跳到某个函数、从内存读一个字节。每一条指令都会访问 CPU 核心里 同一小组硬件结构。5 个 section 把全貌搭出来:fetch / decode / execute 循环和它碰的寄存器;流水线 + 一个交互式 5 级走读,展示数据冒险和停顿;分支预测 —— CPU 怎么在「if 本该把 20 级流水线清空」 的情况下还能保持流水线繁忙;乱序执行 —— 让单核每周期持续超过一条指令的那套簿记;最后是 速查表, 值得能冷讲清楚的那几个问题。
一颗核心,一个循环
把别的都剥掉,一颗 CPU 就是一台小小的有限状态机,永远跑一个循环:取下一条指令、解码、执行。 这一篇 primer 里所有其他硬件,都是为了让这个循环跑得更快。
一颗核心内部,3 个结构最重要:寄存器堆(x86-64 上 16 个具名 64 位寄存器,ARM64 上 31 个,外加 FP 和向量堆)、ALU(数学电路 —— 加法、比较、位运算)、控制单元(协调一切的那个小状态机)。 外加一个程序计数器寄存器,通常叫 PC 或 RIP/EIP, 装着下一条要执行的指令的地址。
+------------------- 一颗核心 -------------------+ | | | PC → 取 → 解码 → 执行 → 写回 | | ↑ ↑ ↑ ↑ | | L1-I 解码器 ALU/FPU 寄存器 | | | | ↓ 系统调用、中断、异常 | | | +------------------- 一颗核心 -------------------+
循环概念上很简单:从 L1 指令缓存读 PC 处的字节, 解析出它编码的是哪个操作(解码),从寄存器堆取操作数跑那个操作(执行), 把结果写回到某个寄存器(写回),把 PC 推进到下一条指令,循环。 这就是你的电脑做过的每一件事。
时钟频率到底意味着啥
3 GHz CPU 每秒嘀嗒 30 亿次,所以一次嘀嗒(一个时钟周期)是 ~0.33 ns。 营销暗示是每核每秒能跑 30 亿条指令,现实更乱。 一条指令可以从 1 周期(寄存器上的整数加)到几百周期(除法,或者所有 cache 都未命中的 load)。 现代核常常持续跑出每周期超过 1 条指令(IPC > 1), 这要归功于后面 3 节里的招式。所以「3 GHz × N 核 = 30N 亿指令/秒」 两个方向都估错了。
寄存器 vs 内存
寄存器是动作发生的地方。每个 ALU 操作从寄存器读操作数、把结果写回寄存器。 要跟内存打交道,需要显式的 load 和 store 指令 把字节在寄存器和内存地址之间拷贝。寄存器本身没有「地址」—— 它们是有名字的(x86 上 rax、rbx……),指令操作码用位来编码名字。 访问寄存器是执行指令本身的一部分,不额外占时间。 访问内存要 1 ns(L1)到 100 ns(DRAM)到 10 ms(磁盘)—— 详见内存层级 primer。
为啥寄存器这么少?编码空间问题:x86 指令里每个操作数槽是 4 位 (16 个选择,REX 前缀加些技巧后变 32 个)。寄存器更多就意味着指令更长、代码体积更大、 指令缓存密度更差。ARM64 选了 31 个,因为定长 32 位指令格式里有 5 位操作数字段的空间, 设计者拿一些位换来了这个。然后编译器做寄存器分配—— 在每个点上选哪些变量在寄存器、哪些「溢出」回内存 —— 这是它最重要的优化之一。
编译器还会激进内联,因为跨函数调用把中间值留在寄存器很难; 一旦跨过函数边界,调用约定就强制保存某些寄存器。 看起来「每个请求 50 个小函数调用」的现代代码,常常实际执行成一大块跨所有函数的内联块, 所有东西都在寄存器里活着。这就是很多工程师在 profiler 里碰到的「机器 vs 源码行」不匹配的来源。
这给我们留下了啥
朴素图景 —— 每周期 1 条指令、串行 —— 几乎从来不是现代核实际的执行方式。 接下来 3 节解释 CPU 给这个循环加的 3 层机械:流水线(从一条顺序流里同时做 5 件事)、分支预测(猜下一条是啥,让流水线在每个 if 处都保持满)、乱序执行(只要可观察结果看起来顺序就放心乱序)。 合起来,在同样时钟下,它们换来比朴素实现大约 10–20× 的吞吐。
要点。「一颗核心就是程序计数器驱动的取 - 解码 - 执行循环, 操作数通过小小的寄存器堆穿梭。时钟频率定上限,但真实吞吐取决于硬件多巧妙地在 这个循环背后重叠和重排工作 —— 后面 3 节就是这套巧妙的全貌。」
流水线 —— 同时做 5 件事
朴素 CPU 跑完一条指令再开始下一条,任意时刻 5 个硬件单元里有 4 个闲着。 流水线的规则是:别等 —— 当前指令一推进到下一级,就立刻开始下一条。
把指令循环拆成几级。经典 5 级 MIPS 流水线是Fetch → Decode → Execute → Memory → Writeback。 每级有自己的硬件(指令缓存、解码器、ALU、load/store 单元、寄存器堆写端口), 每级占 1 周期。一级装一条指令、只有一个单元在干活。 流水线的把戏是所有级同时干活 —— 每周期往 Fetch 塞一条新指令。 4 周期热身后,流水线每周期退役 1 条指令 —— 5 条指令在 9 周期完成,而不是 25 周期。
现代 x86 和 ARM 核有 10 到 20 级流水线,不是 5。为啥这么多? 因为每级必须装进 1 个时钟周期,而现代时钟非常快。 把工作拆成更多更小的级,每级一周期做的事更少,这就允许更高的时钟频率。 代价是任何清空流水线的小事故,现在浪费 15+ 周期而不是 4。 下一节讲怎么应对。
3 种冒险
- 数据冒险(data hazard)。指令 B 要读一个寄存器,而指令 A (还在流水线里)还没写到。流水线必须把 B 停下来,等 A 的结果可用。 现代 CPU 用转发(forwarding)把结果直接从 Execute 的输出送到下一周期的 Execute 输入, 消除大部分停顿 —— 但 load-use 冒险(B 要的是 A 正在从内存 load 的值)绕不开: 值要等 A 走过 Memory 才可用,比 Execute 晚一周期。
- 控制冒险(control hazard)。下一条指令的地址取决于某个还在流水线里的分支的结果。 没有预测,fetcher 必须停到分支解决 —— 20 级流水线里 15 周期的 bubble。 有预测(下一节),它就猜;猜错的话,流水线把沿错的路径取的所有指令都冲掉。 分支预测错误是 branchy 代码的主要代价。
- 结构冒险(structural hazard)。两条指令同一周期想用同一硬件单元 —— 比如两条都要用那唯一一个 load/store 端口。现代设计往这砸硬件 (更多端口、更多功能单元),结构冒险在性能分析里很少出现, 但它约束了顺序流水线能多激进。
为啥流水线没让你免费拿到 1 IPC
两条上限制约顺序流水线的有效 IPC。第一,冒险:任何停顿或冲洗都插入 bubble,把 IPC 拖到 1 以下。 第二,最长那级决定周期时间:如果一级要 1.5 ns、其他都 1.0 ns,时钟就必须按 1.5 ns 走, 不管别的。设计师花巨大工夫平衡各级 —— 这就是为啥现代 Apple M 系或 Intel 核里, 经典的 5 「级」中每一级实际都有很多子级。
对软件工程师有意思的是:这大多是隐形的 —— 直到不是。 在数组上的线性循环跑接近 1 IPC。带不可预测分支的循环掉到 0.5 IPC 或更糟。 每次迭代都依赖上一次的循环(循环带依赖)被 load-use 延迟约束, 不管流水线怎么样。向量化和乱序执行就是为了突破这些天花板而存在的。
要点。「流水线在硬件级之间重叠 5(或 20)条指令, 稳态吞吐是每周期 1 条指令。冒险 —— 数据、控制、结构 —— 都插入 bubble。 冲洗的代价跟流水线深度线性增长,这就是为啥现代 20 级流水线下大力气避免冲洗 (分支预测、乱序执行)。」
分支预测 —— 先猜、再验证
每个 if、每个循环回边、每个虚方法分发都是一个分支。 等每个分支解决再取下一条指令,会把 20 级深的流水线掏空 15 周期。 CPU 不等 —— 它猜,而且大多数时候猜对。
现代分支预测器在典型代码上能命中 95–99%。实现这一点的硬件比大多数工程师以为的复杂得多 —— 现代 Intel 和 AMD 预测器用 perceptron 风格的神经网络逻辑,或 TAGE (TAgged GEometric history length)预测器,跟踪上千个过去分支结果的模式。 细节按代际变;对软件重要的是它们擅长哪类形状、不擅长哪类。
预测器擅长什么
- 循环回边。一个跑 N 次的循环,回边被走 N-1 次、不走 1 次。 预测器执行一次后就学会,后续都对。这就是为啥在数组上的直线迭代能跑到峰值 IPC 附近。
- 极度偏斜的分支。一个 0.1% 才失败的错误检查, 观察一次后就被永久预测为「不走」。慢路径就是慢;快路径继续快。
- 模式分支。一个 true/false 交替的循环,预测器一旦锁住周期就预测对。 预测器跟踪的历史够长,能抓到 16–32 个结果深的重复模式。
预测器不擅长什么
- 无模式的数据依赖分支。那道著名的 Stack Overflow 问题: 一个对每个数组元素是否 > 128 分支的循环,在排序后的数组上比未排序的快 ~5 倍。 排序后:分支先一直走、然后一直不走 —— 完美可预测。未排序:50/50, 所以 ~50% 误测率 × 15 周期冲洗 = 糟糕的一天。 热点 filter 前的
std::sort能多次回本。 - 有很多目标的间接分支。对一个多态对象
std::vector的虚方法分发、对 50 个 case 的 enum switch、megamorphic JIT 调用点。 分支的目标依数据而变,不只是方向。 间接分支预测器(BTB — Branch Target Buffer)容量更小、问题更难。 - Spectre 风格对抗模式。一个攻击者反复训练预测器预期某个结果, 再用不同数据触发分支,可以通过随之而来的推测执行的 cache 时序侧信道泄漏受害者内存。 缓解措施(LFENCE、retpoline 等)在受影响负载上损失 ~5–10% 吞吐。
作为程序员怎么应对
大多数代码不需要担心分支预测。对于那些少见的、热到值得在意的内层循环:
- 预先排序或分区数据,如果分支是数据依赖的、而你又在它上面有紧凑的内层循环。
- 用无分支算术替换分支,在可能的地方 ——
max写成(a - b) & ((a - b) >> 31)这种把戏, 或__builtin_expect,或条件 move(cmov)。 编译器能做的时候会自动做。 - 把循环不变的分支从内层循环里 hoist 出来。编译器通常会做,但模板很多、间接很多的代码可能藏住优化器看不穿的分支。
- 用 Linux 上的
perf stat -e branch-misses量。 如果每指令误测率低于 1%,分支不是你的问题。
要点。「分支预测器在典型代码上命中 95–99%,流水线一直保持满。 它们漏掉的那 1–5%,在现代流水线里每次 ~15 周期。 病理情况 —— 无模式的数据依赖分支、有很多目标的间接分支 —— 才是热循环前 std::sort、无分支算术、模式标记分发表能真正赢的地方。 其余时候,预测器是隐形的。」
乱序执行 —— 每周期持续超过一条指令
流水线化的顺序 CPU 在 1 IPC 封顶。现代核常常跑到 3–4。 诀窍:输入一就绪就乱序发射指令,然后按程序顺序提交, 这样可观察行为不变。
看一个每次迭代读上一次结果的紧凑循环。即便流水线完美,你也得等 N 算完才能开始 N+1 ——循环带依赖把一切串行化。再看一个每次迭代都独立的循环: load a[i]、加 b[i]、store 到 c[i]。 光流水线给 1 IPC。但 CPU 有多个 ALU、多个 load 单元、多个 store 单元 —— 如果碰巧有独立指令,它能同时跑几条。难点在指令流里找到它们。
乱序执行(OoO)就是这个机制。3 个结构合作:
- 重排序缓冲(ROB)。按程序顺序保留在飞指令的 FIFO。 指令解码后进 ROB,结果可以架构可见时离开(提交)。 老核 ~150 项,Apple M 系 ~600 项。
- 保留站(RS,也叫调度器)。等待输入的已解码指令池。 每个输入一到达,RS 就广播;输入都齐了的指令被发射到执行单元,可能是乱序的。 RS 是 CPU 找独立工作的地方 —— 它是「并行发现」引擎。
- 寄存器重命名。16 个(x86)或 31 个(ARM)架构寄存器是几百个物理寄存器的别名。指令要写
rax时,硬件实际写一个新的物理寄存器、 把rax重映射到它。这消除了假依赖:两个独立的、都恰好写rax的操作能并行跑,因为各拿一个物理寄存器。
这 3 个一起工作,单核每周期可以发射 4–6 条指令,ROB 保证提交顺序看起来顺序。 著名的 IPC 指标(instructions per cycle)度量这个东西工作得多好。 在结构良好的代码上,Apple M 系和 Intel Sapphire Rapids 都能持续在 IPC ~3.5 附近; 在依赖受限的代码(链式指针追踪、紧凑 reduce)上,它们突破 1 都难。
软件能做(和不能做)什么
编译器做大部分工作 —— 指令调度、断依赖、循环展开 —— 给 CPU 大量独立指令去找。作为程序员,你主要靠这些帮忙:
- 打断依赖链。一个把 100 万个数累加到单个 accumulator 的循环, 有一条 100 万长的串行依赖链。累加到 4 个 accumulator、最后合并 —— 答案一样、并行度 ×4,你写得干净的话编译器常常自动做。
- 让 CPU 看得见未来。大 ROB 能装 ~600 条在飞指令,但需要看见未来 —— 也就是别每几条指令就跳到一个不可预测的目标。内联函数、hoist 出来的循环不变量、 可预测的分支,都给 OoO 窗口更多东西嚼。
- 指针追踪是 OoO 杀手。链表遍历每一步都依赖前一节点的 load, 会被内存延迟卡住,不是被 ALU 宽度卡住。数组和 SoA(struct-of-arrays)布局 让 prefetcher 和 OoO 机器真正能干活。这就是为啥「cache-friendly」其实意味着 「布局数据让 OoO 能跑并行 load」。
推测执行与 Spectre / Meltdown
聪明有其黑暗面。推测执行 —— 执行结果可能会被丢掉的操作(因为误测或异常)—— 历史上对软件是隐形的:推测的副作用在放弃推测时被回滚。 Spectre 和 Meltdown(2018)展示这其实不真:推测期间改变的缓存状态在回滚后仍然保留,精心构造的攻击能通过触发 攻击者控制地址的推测 load、之后测 cache 时序,读出受害者进程的内存。
缓解措施 —— 推测屏障、间接分支用 retpoline、用户和内核间的页表隔离 —— 付出真实性能代价,通常按负载 5–30%。它们也不完整:大约每 6 个月, 一个新的微体系结构侧信道就浮出水面。 总的教训是推测执行侧信道现在是任何多租户系统(云 VM、浏览器、 任何在共享硬件上跑不受信任代码的东西)的威胁模型的一部分。
要点。「OoO 执行通过早早发射独立指令、按程序顺序提交, 让单核持续 IPC > 1。瓶颈是依赖链(循环带、指针追踪)和前瞻窗口大小。 推测执行就是 Spectre / Meltdown 利用的同一个机制 —— 它对缓存状态的可观察副作用,事实证明不可逆。」
速查表
6 道值得能冷讲清楚的核心问题、5 个 code review 时一眼看出来的红旗。 先把问题刻进脑子;答题骨架就跟在后面。
为啥 20 级流水线比 5 级跑得快,如果一次分支预测错误代价更大?
级数更多意味着每级做的事更少,这允许时钟跑更高的频率。 稳态下深流水线每周期退役 1 条指令,跟短的一样 —— 但每周期更短。 冲洗代价随深度线性增长,但分支预测器靠在典型代码上 > 95% 的命中率来补偿。 净效应正向:高时钟 × 类似稳态吞吐 >(偶尔更大的)冲洗代价。
90 秒内解释分支预测。
每个 if、循环回边、间接调用都是分支。没有预测,流水线必须停到分支解决 —— 20 级流水线里 15 周期的 bubble。现代预测器用 neural/TAGE 算法追踪过去 32+ 个分支结果, 典型代码上 95–99% 命中。误测冲掉所有取了但还没退役的指令。 病理情况是无模式的数据依赖分支(先排序数据)和有很多目标的间接分支(megamorphic 分发)。
顺序执行和乱序执行的区别?
顺序(in-order):CPU 严格按程序列出的顺序执行指令。IPC 上限 1,冒险停顿时更低。 乱序(out-of-order):CPU 一旦输入就绪就发射指令,不管程序顺序, 然后通过 ROB 按程序顺序提交结果。IPC 上限升到 4–6, 因为多条独立指令能跨多个 ALU 并行跑。
为啥排序后的数组在紧凑分支上有时比未排序快?
著名的 Stack Overflow 案例:对 arr[i] > 128 分支的循环。 排序后的数组上,前半部分分支始终走,后半始终不走 —— 预测器跑一遍后就学会。 未排序的数据上,结果 ~50/50 随机,预测帮不上忙。 深流水线里每次误测 15+ 周期。先排序带来 5–6 倍加速,真实可见,perf stat 里立刻就显出来。
IPC 量什么、什么限制它?
IPC = instructions per cycle,在样本上平均。现代核理论上限是 4–6(发射宽度)。 真实负载受以下因素限制(粗略递减):依赖链(循环带、指针追踪)、 分支误测(branchy 代码里)、cache miss(内存受限时)、结构冒险。 带 cache miss 的内存受限代码常常 IPC 远低于 1; 没依赖的紧凑算术循环能跑到 3–4。
什么是推测执行、2018 年之后什么让它变危险?
推测执行 = CPU 执行结果可能被丢掉的操作(因为分支误测、或将抛出异常)。 原本假设是「被丢掉的结果没有副作用」。Spectre 和 Meltdown(2018) 展示这是假的:推测期间改变的缓存状态在回滚后保留, 精心构造的攻击能通过触发攻击者控制地址的推测 load、测 cache 时序,读出受害者内存。 缓解措施按负载付出 5–30% 性能代价。
Code review 红旗
- 热内层循环里的 megamorphic 分发。对一个具体类型变化的异构集合 调用虚方法。间接分支预测器(BTB)容量小、跟不上。
- 未在上游排序的数据依赖分支。对未排序或未分区的输入值分支的循环 会大量误测。如果你知道数据有结构,排序或分桶一次,在后续每次扫描里都拿好处。
- 数值规约里的循环带依赖链。一个 accumulator 跑过 100 万个值, 把整个 sum 串行化了。用多个 accumulator,或者依赖编译器的
-ffast-math模式(连带它对 FP 的所有 caveat)。 - 数值内核里的函数指针 / 虚调用。每次调用都是一次间接分支。 函数体小的话,CPU 花在预测目标上的时间比执行还多。
- 单线程循环里每次迭代 load 的是上一次 store 的东西。store-load 转发覆盖不到逃出 store buffer 的依赖。 这表现为 IPC 卡在 0.5 附近,哪怕是简单算术。