本章解释"代码为什么快或慢"的硬件根因,是后续性能与并发章节的物理地基。

学习目标

读懂本章后你应当能够:推导 AMAT 并判断该优化命中率还是缺失惩罚;从地址位段划分推出冲突缺失,说明组相联如何压缩冲突概率;完整推导 MESI 四态转换并解释伪共享的硬件成因;判断代码触发的是结构、数据还是控制冒险,说明三种解法的边界;说清 Spectre/Meltdown 的成因链条与三种缓解手段各堵住哪一环;用 NUMA 的本地/远程延迟差解释绑核的收益来源。

前置知识检查

你需要知道在哪一节补齐
虚拟地址到物理地址的翻译、TLB见第 8 章 8.2 节;本章只用"地址先经 TLB 翻译"这一结论
原子指令、锁的语义与 Java 内存模型见第 6 章 6.1 节、第 30 章 30.2 节;本章 1.9 节只讲硬件为什么必须提供屏障

1.1 存储层次金字塔:数量级从哪里来

性能问题的根源可概括为一句话:核心的执行速度与内存的供给速度差了两个数量级以上,存储层次的全部设计就是为了填这个洞。下表是本章必须记住的数量级。

表 1-1 存储层次的容量、延迟与带宽量级

层级典型容量典型延迟相对寄存器典型带宽口径由谁管理
寄存器数百字节 ~ 数 KB< 1 ns1×数百 GB/s编译器
L1 数据缓存32 ~ 64 KB / 核约 1 ns(4 ~ 5 周期)1×约 100 GB/s / 核硬件
L2 缓存256 KB ~ 2 MB / 核约 4 ns(12 ~ 20 周期)4×约 50 GB/s / 核硬件
L3 缓存8 ~ 64 MB / 路(共享)约 15 ns(30 ~ 50 周期)15×约 20 GB/s / 核硬件
主存 DRAM数十 GB ~ 数 TB约 100 ns100×约 10 GB/s / 通道硬件 + 操作系统(见第 8 章)
SSD(NVMe)数百 GB ~ 数 TB约 100 μs(4 KB 随机读)10⁵×顺序约 3 ~ 7 GB/s操作系统 + 设备
HDD数 TB ~ 数十 TB约 5 ~ 10 ms(4 KB 随机读)10⁷×顺序约 150 ~ 250 MB/s操作系统 + 设备

延迟为什么是数量级式跳变?其一,每一级都是下一级的缓存:寄存器只装编译器静态分配的活跃变量,L1 要装一个循环的工作集,主存要装整个进程,容量每放大一档就必须容忍更多"来不及",只能靠更长的查找路径换更大的命中范围。其二,物理距离:L1 的路径在核内几毫米量级,主存要出封装走 PCB 到 DIMM 往返,按电信号在铜中约 0.2 m/ns 估算,仅"走一趟"就是几十纳秒。其三,单位容量成本:SRAM 单元约 6 个晶体管、DRAM 单元 1 管 1 电容、HDD 靠机械寻道,价格差决定了我们只能买很小的高层与很大的低层。

设命中延迟 T_c、命中率 h、内存延迟 T_m,则 AMAT = T_c + (1 − h) × T_m。取 T_c = 1 ns、T_m = 100 ns:h = 99.0% 时 AMAT = 2.00 ns(命中延迟的 2 倍);h = 99.9% 时 AMAT = 1.10 ns;h = 95.0% 时 AMAT = 6.00 ns。可见缺失率从 1% 降到 0.1% 使 AMAT 改善 45%,而把内存延迟减半(100 → 50 ns)在 h = 95% 时只把 6.00 ns 降到 3.50 ns,且工程上几乎不可能。优化缓存的第一优先级是减少缺失次数,不是降低单次缺失延迟。

重要

面试官问"L1 为什么不做得更大",标准答案是访问时间随容量超线性增长:更大的 SRAM 阵列意味着更长的字线与位线、更复杂的译码与组选择,周期数从 4 周期涨到 6 ~ 8 周期;而 L1 必须在访存指令后的固定周期内交付数据,命中率带来的收益会被流水线停顿频率吃掉。L2/L3 能变大,正是因为它被允许有更长的延迟。

注意

面试官会追问的下一层:L3 是共享还是私有? 主流服务器是"每核私有 L1/L2 + 片上共享或分片式 LLC"。共享让一个核写入的数据能被另一个核在 L3 命中,这是 MESI 高效工作的前提;而分片式 LLC 使跨切片访问明显慢于本地切片,是 1.10 节 NUMA 效应的片上版本。

1.2 局部性原理:64 字节是怎么定下来的

局部性(locality)不是经验规律,而是程序结构的统计后果:**时间局部性(temporal locality)**来自循环体、计数器、栈顶局部变量与热对象;**空间局部性(spatial locality)来自数组顺序遍历、结构体字段连续布局与顺序执行的指令流。硬件正是押注这两条性质,才以缓存行(cache line)**而非"字"为单位装载数据,x86-64 与主流 ARM 的行大小都是 64 字节。

1
2
3
4
5
6
7
行大小 L 的三方权衡(S = 容量,A = 相联度):
  行数 = S / (A × L) ⟹ L 越大,行数越少,tag 存储与比较开销越小
  随机访问时单行浪费的期望字节 ≈ L / 2;填充一行需 L / 总线宽度 个突发周期
  L = 32 B:行数多、tag 开销大,一次缺失搬运的数据可能填不满流水线
  L = 64 B:恰等于一次 DDR 突发(8 × 8 B),一次突发正好填满一行
  L = 128 B:空间局部性收益递减,随机访问下无用数据与带宽浪费翻倍
★ 决定 64 的是"一次内存突发的粒度 × 空间局部性收益递减点",而非"2 的幂"。

叠加一条硬约束:行的填充必须一次完成,中途不可用。因此行越大,首次访问该行的延迟、以及该行被驱逐时浪费的干净数据都越大。局部性失效的高频场景如下,共同点是访问模式不连续或工作集超容量:

场景失效的局部性表现
链表 / 树的指针追逐空间局部性每节点一次缺失,遍历退化为随机访存
二维数组按列遍历(行主序语言)空间局部性每访问一个元素换一行,缺失率接近 100%
工作集 > LLC 的随机访问容量局部性缺失率由工作集与容量之比决定
多线程写同一行的不同字段空间局部性被"反向利用"见 1.5 节伪共享

警告

常见错误回答:“cache line 是 64 字节,因为计算机喜欢 2 的幂。“这是倒果为因。请务必能用"一次 DDR 突发 = 64 B"与"收益递减点"两个理由正面回答。

1.3 映射方式与三类缺失

缓存的本质是一张 (tag → 数据) 的表,映射方式决定它如何被索引。以 64 B 行、48 位物理地址为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
组相联缓存(S 组、A 路)的地址位段划分:
 ┌──────────── tag ────────────┬──── set index ────┬──── block offset ────┐
 │       高位(用于比较)        │    log2(S) 位     │   log2(64) = 6 位     │
 └─────────────────────────────┴───────────────────┴──────────────────────┘
 offset:选中行内第几字节,6 位 = 64 B 的由来,且对齐访问只需低 6 位
 index :选中哪一组;全相联时 index 宽度为 0,直接映射时 A = 1
 tag   :tag 相等且有效位 V = 1 才算命中

 例:L1d = 32 KB、8 路、64 B 行 ⟹ S = 32768 / (8 × 64) = 64 组,index 6 位,
     tag = 48 − 6 − 6 = 36 位。地址相差 4096 B 的两个块(4096 = 64 × 64)组号
     相同,落到同一 8 路组的第 9 个块就会挤掉前面某个 —— 这就是冲突缺失。

表 1-2 三种映射方式的对比

映射方式查找方式命中判定缺失类型硬件代价典型用途
直接映射(direct-mapped)index 唯一确定一路比较 1 个 tag冲突缺失最严重最小,无需组选择早期 L1、部分 TLB
全相联(fully associative)与所有路并行比较比较 A 个 tag无冲突缺失最大,A 个比较器 + 编码器TLB(32 ~ 64 项)、victim cache
组相联(set-associative)先选组,再组内并行比较比较 A 个 tag冲突缺失按 A 缩小中等,A 路并行现代 L1/L2/L3(4 ~ 16 路)

三类缺失(3C)——面试官问"缓存为什么没命中"时必须能区分并给出用例:**强制缺失(compulsory miss)**是该地址首次访问,只有预取能减少,冷启动性能差即源于此;**容量缺失(capacity miss)**是工作集大于容量,注意工作集不等于数据集,一个 O(n log n) 排序的工作集可能远小于 n;**冲突缺失(conflict miss)**是工作集装得下但多个热地址映射到同一组,是唯一"纯由映射策略造成"的浪费,因此被单独挑出来计数。

三者的计数器口径严格互斥,由此得到面试可用的两次重放对照法:用全相联缓存重跑一遍,冲突缺失归零;再用无限大缓存重跑,容量缺失归零。

1.3.1 写策略与写分配

读缺失只有一个动作(取行),写缺失有两个正交决策:**写直达(write-through)**同时写缓存与下级,**写回(write-back)**只写缓存并置脏位、驱逐时才写下级;写分配(write-allocate)缺失时先取行再写,非写分配直接写下级。现代 CPU 一律选写回 + 写分配,因为写分配把"部分写"变成"整行写”,整行写才可能被合并成一次突发;写回把同一行的多次写压缩成一次下级流量。代价是一致性变复杂:现在存在多个"最新副本”,必须靠 MESI 找出谁是权威。

1.4 MESI:多核如何保证缓存一致

这是本章第一号必考题。先把问题定义说准:缓存一致性问题不是"内存里到底存了什么",而是"同一地址的所有副本何时对所有核呈现同一个值"。它只约束单个地址的可见性,不约束多个地址之间的顺序——后者是内存一致性模型,由 1.9 节的屏障处理。

表 1-3 MESI 四态的含义

状态全称与内存是否一致其他核是否有副本读写约束
MModified否(本核最新,内存陈旧)否(独占)可读可写
EExclusive是否(独占)可读,写时静默升 M
SShared是可能有可读,写前必须先升级
IInvalid无有效副本——读写均缺失

一致性靠**总线嗅探(snooping)实现:所有缓存控制器监听总线/片上互联,看到地址匹配的事务就做出反应。这是分布式仲裁式实现,与用于大规模多路系统的目录式(directory)**协议相对。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
MESI 状态机(事件 → 动作 → 次态)

                     PrRd / 命中(本核独有)
        ┌──────────────────────────────────────────┐
        ▼                                          │
   ┌─────────┐  PrWr → 发 BusRdX,其他核置 I   ┌─────────┐
   │    I    │ ─────────────────────────────▶ │    M    │
   │  无效    │                                │  已修改  │
   └─────────┘                                └─────────┘
       ▲   │ PrRd → 发 BusRd(他核有副本 → S;无副本 → E)
       │   ▼                                        │ PrWr 命中
   ┌─────────┐   PrRd 命中   ┌─────────┐  ── PrWr 命中 ──┐
   │    S    │ ◀─────────── │    E    │  静默升级,不发事务 │
   │  共享    │ ── PrWr ───▶ │  独占    │                  │
   └─────────┘ 发 BusUpgr,   └─────────┘                  │
        ▲       他核置 I           │ 收到他核 BusRd         │
        │                          ▼ 置 S 并直供/写回       │
        └──────────────────────────┘ ◀─────────────────────┘
  收到他核 BusRd 且本核为 M:必须先写回或直供,再置 S
  收到他核 BusRdX / BusUpgr:M、E、S 一律置 I

四个必须记住的转换要点:I → E 只在"本核发起读且无其他核持有副本"时发生,这个确认过程本身有延迟,是 L3 缺失路径的主要组成部分;E → M 是静默的,所以单线程反复写刚读入的变量几乎不产生一致性流量;S → M 必须先发 BusUpgr,若目标核为 M 则它必须先写回,这几十纳秒正是"两个线程交替 RMW 同一变量"的性能杀手;M 态的行被驱逐时才有资格写回内存,所以内存中的数据可能长期陈旧。

重要

面试的标准三段答法:①问题本质是同一地址的多副本可见性;②MESI 用四态加总线嗅探,写操作前必须先取得独占权(RFO,read-for-ownership),从而把并发写串行化;③代价是共享行的写会引发状态迁移与总线流量,因此要避免伪共享。答到"写前必须独占"就算过关,能画出状态机是加分。

注意

面试官会追问的下一层:四态够用吗? 不够。真实的 MOESI 增加 O(Owned) 态,表示"本核持有脏数据且其他核可共享副本",于是 M 态核能在不写回内存的前提下直接直供其他核,把 M → S(含写回) 拆成 M → O 加共享,显著减少写回流量;MESIF 则用 F 态标记"唯一且干净的转发者"。能主动提到这两个变体是强加分。

1.5 伪共享:源码看不出来的性能杀手

**伪共享(false sharing)**的定义要精确到硬件事件:两个或多个核频繁写同一缓存行内的不同变量。数据上毫无共享,但硬件以行为单位维护一致性,于是每次写都把对方副本置 I,下一次访问变成缺失加 RFO。

1
2
3
4
5
6
7
  核0: PrWr(a) → 发 BusUpgr → 核1 的 X 置 I → 核0 的 X 变 M
  核1: PrWr(b) → 缺失! 发 BusRdX → 核0 写回并置 I → 核1 的 X 变 M
  核0: PrWr(a) → 缺失! 发 BusRdX → 核1 写回并置 I → 核0 的 X 变 M
  ... 每次写都是缺失 + 总线 RFO + 可能的写回,真实数据流为零

  量级:L1 命中写约 1 ns,跨核 RFO 常在几十纳秒,叠加写回后伪共享可把紧循环
        吞吐降低数倍到十倍以上。★ 读-读共享不触发(S 态可共存),只有写才触发。

修复分两个层次,关键是先按 64 字节对齐、再填充:

1
2
3
4
5
6
7
8
9
import jdk.internal.vm.annotation.Contended; // JDK 9+ 需 --add-exports

public final class PaddedCounter {
    // 为什么用 @Contended:JVM 会在该字段前后插入 padding,使它和相邻字段不再共享缓存行。
    // 不加注解时两个线程各自 ++ 的两个 long 极可能相邻、落进同一个 64 B 行,
    // 于是循环里每次自增都退化成一次跨核 RFO —— 源码里看不到共享,硬件事件却全是共享。
    @Contended
    volatile long value;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include <stddef.h>

// 为什么手动 padding:C 没有语言级的伪共享注解,只能靠对齐把两个热变量推到不同行。
// 必须先 aligned(64) 再填充;只 padding 不对齐,元素仍可能跨在两行之间。
// 代价是每个计数器独占 64 B,只有确认该变量被多核高频写时才值得。
struct padded_counter {
    volatile long value;
    char pad[64 - sizeof(long)];
} __attribute__((aligned(64)));

struct padded_counter counters[NR_CPUS];   // 每核一个,跨核不再共享行

面试必须能答的几点:@Contended 默认只对 JDK 内部类生效,用户类在 JDK 8 需要 -XX:-RestrictContended,JDK 9+ 注解移入 jdk.internal.vm.annotation 并需要 --add-exports;反向识别是"线程数增加而吞吐不增反降",配合 perf stat 的 cache-misses 或 perf c2c 定位争用行;不要过度 padding,每计数器 64 B 会放大内存与缓存压力。

小心

把伪共享答成"两个线程访问同一变量导致的数据竞争"会直接把题目答偏。数据竞争是正确性问题(需要同步原语),伪共享是性能问题(逻辑完全正确,只是慢)。面试官一定会追问这个区分。

1.6 CPU 流水线:五级流水与三类冒险

流水线把指令执行切成若干阶段,让不同指令占据不同阶段并行推进。经典五级划分与对应硬件就是本节骨架:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
  IF 取指 / ID 译码取寄存器 / EX 执行算地址 / MEM 访存(仅 load-store)/ WB 写回

  理想流水(稳态 CPI = 1):
    周期:     1     2     3     4     5     6     7     8
    I1:      IF    ID    EX    MEM   WB
    I2:            IF    ID    EX    MEM   WB
    I3:                  IF    ID    EX    MEM   WB
    I4:                        IF    ID    EX    MEM   WB
    I5:                              IF    ID    EX    MEM   WB
    → 5 条指令 9 个周期完成,相对非流水的 25 周期加速 5 倍 = 流水级数 k

  load-use 冒险(数据冒险的转发边界):
    I1: LOAD  r1, [r2]      # r1 要到 MEM 末尾才有效
    I2: ADD   r3, r1, r4    # EX 级就需要 r1,但此刻 I1 还在 MEM

    周期:      1     2     3      4      5     6
    I1:       IF    ID    EX    MEM    WB
    I2:             IF    ID    EX←?                 ← 数据还没到
    I2(实际):       IF    ID   STALL  STALL  EX    MEM   WB

  → L1 命中时至少停顿 1 周期;L1 缺失则可达数百周期,交由乱序执行掩盖。
  → 编译调度把第 3 条无关指令插进 I1 与 I2 之间,即可藏起这段停顿。

表 1-4 三类流水线冒险

冒险触发条件硬件解法编译器解法主要残留代价
结构冒险(structural)两条指令同周期争用同一部件(取指与访存抢端口)分离 I/D-cache、增加端口、多功能单元调度避免连续访存端口冲突导致的发射停顿
数据冒险(data)后一条指令要用的寄存器尚未写回(RAW 为主)转发(forwarding)、停顿、乱序执行插入无关指令、寄存器重命名load-use 延迟通常无法完全转发
控制冒险(control)取指时还不知道分支是否跳转分支预测 + 推测执行、延迟槽循环展开、条件移动(cmov)、predication误判时清空流水线,约 5 ns 且与深度成正比

1.6.1 数据冒险的三种解法与取舍

转发(forwarding/bypass)把 EX/MEM 级的结果直接旁路回 EX 级输入,不等它写回寄存器堆,成立条件是生产者的结果已算完,1.6 节开头的 load-use 时序就是它失效的经典例外。三种解法的适用边界:转发要求结果产生级不晚于消费者的需要级,对 load-use 与跨长延迟单元(除法、浮点)失效;停顿总能正确工作,但直接抬升 CPI,缺失时可停顿数百周期;编译调度要求存在可填充的无关指令且编译器能看到足够指令窗口,受寄存器数量限制填不满,且分支迫使调度保守、运行时缺失概率编译期无法预知。

提示

面试话术:数据冒险的标准答案不是"加气泡",而是"优先转发,转发不了才停顿,停顿尽量由编译器调度和乱序执行掩盖"。这三句话的层级关系能立刻把回答从背概念拉到机制层。

1.7 分支预测:控制冒险的代价与解法

控制冒险的根因是:取指阶段必须在上一周期分支结果出来之前就知道下一条取哪里,而在深流水线里分支方向与地址至少要到 EX 级才能确定。

表 1-5 分支预测器的层次与能力

类型判据能处理的模式失效场景
静态预测固定启发式(后向跳转预测跳、前向预测不跳)循环的绝大多数迭代所有数据相关的分支
一位动态预测记录上次方向简单二次循环循环退出时的两次误判
两位饱和计数器(bimodal)需连续两次反向才翻转单层循环规律性强但周期长的模式
两级自适应(correlating)用全局历史寄存器 GHR 索引模式历史表 PHT相邻分支方向彼此关联历史长度不足时抓不住长周期模式
锦标赛(tournament)两个预测器并用选择计数器仲裁混合负载选择器本身需要预热
TAGE用几何递增的多个历史长度分别索引多张 PHT,取命中历史最长者的预测超长周期模式、复杂相关性首次遇到的模式;表格规模限制历史长度

一条核心认识:预测器实际是在用历史模式识别重复出现的分支序列;历史越长覆盖的模式越长,但表格越大。TAGE 用几何递增的多个历史长度做多级索引,以近似对数级的存储代价覆盖很长的历史,因此成为当前主流。误判代价 = 分支解析时刻之前已发射的指令数 × CPI_ideal(被推测执行但方向弄错的指令全部冲刷,重新从正确地址取指):五级流水分支在 EX 级解析,IF/ID 中最多 2 条指令作废,约 2 ~ 3 周期 ≈ 1 ns;深流水(15 ~ 20 级)分支可能很晚解析、取指端已推进十几条,约 15 ~ 20 周期 ≈ 5 ns(按 3 ~ 4 GHz 折算)。

预测失败的代价与流水线深度成正比,而不是与"分支多少"成正比。 这既是"深流水 + 高主频"必须配强预测器的原因,也解释了为何分支密集且不可预测的代码(解释器主循环、JSON 解析器)在深流水 CPU 上远差于预期。

警告

常见错误回答:“预测失败就是多执行了几条指令。“错。失败的指令不会提交(乱序核里被 ROB 丢弃),真正的代价是流水线冲刷与重新取指期间执行单元、取指和访存带宽的空转,以及已被冲刷指令占用的预测器状态。答"多执行了指令"会被追问"那为什么还要清空流水线”。

1.8 乱序执行、推测执行与 Spectre/Meltdown

顺序五级流水的问题是:一次 L1 缺失(约 100 ns ≈ 300 周期)的装载会阻塞后面所有不相关指令。乱序执行(out-of-order execution)的做法是把程序顺序与执行顺序解耦:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  取指 ─▶ 译码 ─▶ 重命名 ─▶ 发射队列 ─▶ 各执行单元 ─▶ 提交
                    │         (保留站)                 │
             寄存器重命名表 RAT                    ROB(重排序缓冲)
             (架构寄存器 → 物理寄存器)         按程序顺序排队,顺序提交

  1) 寄存器重命名:WAW/WAR 冒险在重命名阶段即消失。物理寄存器远多于架构寄存器
     (16 个架构整数寄存器对应 200+ 物理寄存器),每次写分配新物理寄存器,旧名
     不再被引用即可释放 ⟹ 只有真正的 RAW 限制并行度,这就是 ILP 的上限来源。
  2) 发射队列/保留站:操作数就绪即可发射,不按程序顺序。
  3) ROB:按程序顺序提交,保证精确异常——异常发生时比它年轻的指令全部丢弃,
     架构状态停在异常点之前。ROB 大小决定乱序窗口与能掩盖的缺失延迟长度。
  推测执行 = 分支预测 + 乱序执行 + ROB:预测后先执行,错了按 ROB 回滚架构状态。

1.8.1 成因链条

两个侧信道漏洞(公开披露于 2018 年)的本质是回滚不彻底:ROB 能让架构状态(寄存器、内存)回到正确样子,但微架构状态(缓存内容、预测器状态、TLB 状态)不会回滚。把残留状态当侧信道读出来,就绕过了权限检查。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
成因链条(四步,缺一不可)

  ① 处理器推测执行了本不该执行的指令
       Meltdown:越权读内核地址。权限检查在提交阶段才生效,而乱序核在数据到达
                 cache 后已把它前递给后续指令。
       Spectre :通过训练预测器,让受害者(或本进程)推测执行到一段以秘密数据
                 为索引的访存代码。
  ② 被推测执行的指令用秘密值作地址访存:idx = secret_byte * 4096;
  ③ 该访存在缓存中留下痕迹(秘密值决定哪一行被装入)
  ④ 攻击者用 Flush+Reload / Prime+Probe 等缓存计时侧信道测出哪一行在缓存里,
     逐字节还原秘密

  ★ 漏洞不在"分支预测错了",而在于"缓存状态不可回滚"——
    任何"只回滚架构状态"的硬件都天然存在这个侧信道。

表 1-6 缓解手段各自堵住哪一环

缓解手段原理堵住哪一环代价
KPTI(内核页表隔离)用户态运行时把内核映射从页表移除①:内核地址在用户态根本无法被推测访问系统调用/中断需切换页表并冲刷部分 TLB,有可观性能损失
微码更新 + IBRS/IBPB限制间接分支的推测目标,或在切换时清空预测器状态①:预测器无法被跨权限/跨进程训练间接分支密集负载性能下降明显
lfence / array_index_nospec在越界检查与访存之间插入序列化点②:带秘密值的访存不会在检查确定前发射需逐处修补代码,序列化本身有开销
缓存隔离(CAT/Intel RDT)、地址随机化、禁用高精度计时器削弱 probe 数组与秘密地址的可分辨性,或抬高计时噪声③④:侧信道分辨率下降、计时不可靠隔离资源降低缓存利用率,计时器精度下降影响正常性能分析

重要

面试官问"Spectre 和 Meltdown 的区别”,要按利用路径答而非漏洞编号:Meltdown 利用"权限检查被推测执行绕过",受害者是任意用户进程,影响面限于部分实现;Spectre 利用"分支预测被投毒",通常需要在受害者进程内先运行诱导的 gadget,影响几乎全部推测执行处理器,也更难彻底修。Spectre 至今没有"关一个开关就修好"的方案,只能靠软件插入屏障——这是它比 Meltdown 更麻烦的根因。

1.9 指令级并行与内存屏障的硬件位置

乱序执行的目标是榨取指令级并行(instruction-level parallelism, ILP),其上限由三件事决定,面试按此顺序展开:真实数据依赖(RAW)的数量由程序语义决定,硬件无法消除,是根本上限;乱序窗口大小(ROB 条目数、发射队列深度、物理寄存器数量)决定能跨越多少缺失,收益在前几百条指令内累积最快、之后递减;执行资源宽度(每周期发射数、ALU/AGU/FP 单元个数)不足时窗口再大也无处发射。典型 x86 核稳态 IPC 可达 3 ~ 4,而存在长依赖链或缺失时会跌到 1 以下——这是微架构与性能优化之间的直接桥梁,见第 3 章 3.2 节的 Amdahl 定律与 3.3 节的吞吐/延迟分析。

1.9.1 为什么硬件需要屏障

以上所有机制都为"更快",但它们会破坏程序员通常默认的假设:程序顺序即内存可见顺序。破坏有两个独立来源:编译器重排序(受语言内存模型约束)与硬件重排序——写缓冲(store buffer)让写延迟可见、失效队列(invalidation queue)让嗅探响应延迟处理、乱序发射让读越过更早的写。x86 的 TSO(Total Store Order)只允许"写→读"重排,ARM/PowerPC 的弱模型允许更多重排。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
x86-TSO 下的经典反例(StoreLoad 重排)—— Dekker 算法为什么必须加屏障
  初始:X = 0, Y = 0
  核0                          核1
  ──────────────────────       ──────────────────────
  X = 1;                       Y = 1;
  r1 = Y;   // 可能读到 0       r2 = X;   // 可能读到 0

  原因:核0 的写 X=1 还躺在 store buffer 里,读 Y 先完成;核1 同理。
  ⟹ 屏障(mfence / lock 前缀 / sfence / lfence / dmb)的作用是划定一条分界线:
     "此前的访存对本核与所有其他核的可见顺序"。
     软件可见性原语(volatile、synchronized、Lock)最终都编译成这些指令。

注意

本章只回答"硬件为什么必须提供屏障":写缓冲、失效队列与乱序发射都会让访存顺序偏离程序顺序。屏障的四种类型(LoadLoad/StoreStore/LoadStore/StoreLoad)、JMM 的 happens-before、volatile 与 synchronized 的语义,见第 30 章 30.2 与 30.3 节;原子指令与锁的取舍见第 6 章 6.1 与 6.2 节。

1.10 SMP 与 NUMA:内存不总是等距的

最后一个必须记住的硬件事实:主存访问延迟不是常数。SMP / UMA(Symmetric Multiprocessing / Uniform Memory Access)下所有核到内存延迟相同,靠共享前端总线时总线很快成为瓶颈;NUMA(Non-Uniform Memory Access)下每个路自带内存控制器与本地 DIMM,访问本地节点内存快、访问其他节点要跨片上互联(UPI、Infinity Fabric),延迟明显更高。现代多路服务器全部是 NUMA。

表 1-7 UMA 与 NUMA 的对比

维度UMA/SMPNUMA
内存延迟所有核一致(约 100 ns)本地约 100 ns,远程约 150 ~ 250 ns(约为本地 1.5 ~ 2.5 倍,随拓扑跳数变化)
聚合带宽受共享总线/控制器限制随节点数近似线性扩展
扩展性数十核后遭遇瓶颈可扩展到多路、数百核
主要风险总线争用远程访问比例高、内存分配不均衡
观测手段——numactl --hardware、/sys/devices/system/node/node*/numastat、lstopo

NUMA 的性能原则只有一句:让线程尽量访问它所在节点的内存。这解释了绑核提升性能的完整逻辑:其一,避免线程在节点间迁移——默认调度器为负载均衡可能把热线程迁到远端 CPU,此后它的全部工作集都要跨节点访问(见第 5 章 5.4 节);其二,迁移后 L1/L2 中的数据留在原核,本地缓存变冷;其三,必须配合首次触碰(first-touch)分配策略——Linux 默认在第一次写某页时把它分配到触发者的本地节点,因此"主线程预分配大数组、再交给 worker 线程使用"会让所有页落在主线程节点,所有 worker 都在做远程访问。

1
2
3
4
5
6
7
8
# 为什么这样写:先绑核再跑,保证进程 CPU 固定在一个 NUMA 节点内,
# 从而让 first-touch 把页分配到该节点,所有后续访问都是本地内存。
numactl --cpunodebind=0 --membind=0 ./service

# 为什么用 interleave 而不是 membind:当单节点内存带宽喂不饱全部线程时
# (带宽受限而非延迟受限的负载),让页在所有节点交错分布,用带宽换延迟。
# 这是"延迟敏感绑核、带宽敏感交错"的取舍。
numactl --interleave=all ./bandwidth_bound_service

小心

把"绑定 CPU"答成"减少上下文切换"是不完整的。绑核的第一收益是内存亲和性与缓存/TLB 热度,减少调度迁移只是附带效果(见第 5 章 5.4 节)。反过来,盲目绑核也可能变慢:它使负载无法均衡,单节点出现热点或线程等待时没有其他核可用。

本章小结

关键结论一句话表述常见错误
存储层次是数量级台阶每级都是下级的缓存,容量换延迟是唯一杠杆说"缓存越大越快",忽略访问时间随容量超线性增长
AMAT 由缺失率主导AMAT = T_c + (1 − h) × T_m,缺失率的小改善收益远大于命中路径优化只优化命中路径,不测缺失率
64 字节是权衡结果一次内存突发粒度 × 空间局部性收益递减点说"因为 2 的幂"
三类缺失口径互斥全相联消掉冲突缺失、无限容量消掉容量缺失、冷访问必是强制缺失把冲突缺失也算成容量不足
MESI 的核心是写前独占写必须先取得 RFO,从而把并发写串行化只背四态名称,说不出其他核副本何时失效
伪共享是性能问题逻辑正确、只是慢;只有写才触发答成数据竞争(正确性问题)
数据冒险有优先级层级先转发,转发不了才停顿,停顿尽量被调度与乱序掩盖张口就答"加气泡"
误判代价与流水线深度成正比五级约 2 ~ 3 周期,深流水约 15 ~ 20 周期,量级约 5 ns说成"多执行了几条指令"
推测执行漏洞在回滚不彻底缓存等微架构状态不回滚,形成侧信道;屏障的存在源于硬件重排序说成"分支预测器有 bug",或认为屏障只是为了编译器不重排
NUMA 下绑核的本质是亲和性减少远程内存访问 + 保持缓存/TLB 热度只答"减少上下文切换"

自测题

Q1(计算)L1 命中延迟 1 ns、L2 命中 4 ns、主存 100 ns。L1 缺失率 5%,其中 40% 命中 L2。求 AMAT;若改善布局使 L1 缺失率降至 2%、L2 命中率不变,AMAT 变为多少?

答案与解析

分层展开(L1 缺失后先查 L2,L2 也缺失才到主存):AMAT = 0.95 × 1 + 0.02 × 5 + 0.03 × 105 = 4.20 ns。改善后 AMAT = 0.98 × 1 + 0.008 × 5 + 0.012 × 105 = 2.28 ns。

错在哪:最常见的错误是把 (1 − 0.05) × 100 直接当缺失代价,漏掉 L2 命中的那部分只需多花 4 ns;另一个错误是以为"L2 命中率不变"意味着 L2 绝对命中次数不变——它随 L1 缺失数一起下降了 60%。面试官会怎么追问:①"缺失率只降 3 个百分点,为什么 AMAT 几乎减半?"——主存项 3.15 ns 占 AMAT 的 75%,缺失率的绝对变化虽小,乘上 100 ns 的惩罚就是主要收益。②"缺失率降到 0 时 AMAT 下限是多少?"——1.0 ns,即命中延迟本身,这就是优化天花板。

Q2(推导)某缓存 32 KB、8 路、64 B 行,程序按 4096 字节步长遍历大数组。会发生什么缺失?给出两种修复方案。

答案与解析

组数 S = 32768 / (8 × 64) = 64,组号来自地址第 6 ~ 11 位。步长 4096 B 意味着每次访问间隔 4096 / 64 = 64 行,恰好等于组数,每 4096 字节回绕到同一组;8 路组只能容纳 8 个这样的地址,第 9 个就挤掉第 1 个。工作集远小于 32 KB,但访问全落在同一个 8 路组里,触发严重冲突缺失,命中率接近 0。修复:① 破坏步长与组数的整数关系,把元素间距改为 4096 + 64 或加一个 64 B 偏移,使相邻访问落到不同组;② 分块(tiling/blocking)遍历,让块内访问不冲突、块间依次推进;③ 数据结构层面做 padding,把冲突结构彻底打散。

错在哪:答"容量缺失"最典型——32 KB 装几个元素绰绰有余,问题在映射而非容量;答"强制缺失"则把首次访问与重复回绕混为一谈。面试官会怎么追问:①"怎么用实验区分冲突缺失与容量缺失?"——在全相联缓存模型上重放同一程序,冲突缺失归零即确认是冲突;或把容量翻倍再测,缺失率几乎不变说明不是容量问题。②"为什么步长是 2 的幂时最严重?"——组索引取自地址中间位,2 的幂步长只改变高位而中间位恒定,索引不变。

Q3(分析)两个线程各自循环自增 volatile long counter[i](i 不同),压测显示 8 线程吞吐低于 1 线程。给出根因、验证方法与修复,并说明为什么读操作不会引发同样问题。

答案与解析

根因:两个 long 在数组里相邻,共处同一 64 B 缓存行。每次写都要通过 BusUpgr/BusRdX 取得独占权并把对方副本置 I,对方下一次写立刻缺失。源码访问的是不同变量,硬件事件却全在同一行争抢——这就是伪共享。

验证与修复:perf stat -e cache-misses,cache-references 对比 1 线程与 8 线程(缺失数随线程数陡增),perf c2c 可直接定位跨核争用行,最直接的是 A/B 实验——加 64 B padding 后吞吐立刻回升即确认因果。修复用 C 的 aligned(64) 结构体填充或 Java 的 @Contended,关键是先对齐再填充。

为什么读不会:共享只读行在各核中都处于 S 态,S 态可共存,嗅探一次即可,后续读全部本地命中,没有一致性流量。只有写需要独占权(S → M 需 BusUpgr,I → M 需 BusRdX),才会反复互相失效。

错在哪:把根因答成"多线程竞争同一变量需要加锁",会被立刻追问"我加了 padding 就好了,跟锁有什么关系";正确说法是"逻辑上无共享,物理上共享了缓存行"。面试官会怎么追问:①"@Contended 在 JDK 8 和 JDK 17 用法一样吗?"——不一样,JDK 8 需 -XX:-RestrictContended,JDK 9+ 注解移入 jdk.internal.vm.annotation 且需 --add-exports。②"还有什么比 padding 更省内存的办法?"——把每核计数聚合成局部变量(ThreadLocal 或每核缓冲),只在最终阶段合并。

Q4(判断)“分支预测失败的主要代价是多执行了几条本不该执行的指令,因此减少分支数量一定能提升性能。“判断并说明理由,另举一个"减少分支反而变慢"的场景。

答案与解析

前半句错。被推测执行的指令不会提交:乱序核按程序顺序在 ROB 提交,方向判错后比它年轻的指令全部丢弃,架构状态回到正确位置。真正的代价是流水线冲刷期间执行单元、取指与访存带宽的空转,加上重新取指的延迟,量级为"流水线深度 × 周期时间”:五级约 2 ~ 3 周期(约 1 ns),深流水约 15 ~ 20 周期(约 5 ns)。清空流水线才是代价,“多执行了指令"不是。

后半句也错,因为减少分支本身有成本,收益取决于该分支是否可预测:① 分支可预测时几乎免费,100 次迭代只错 1 次,摊到每次迭代接近零;展开成无分支代码反而增大代码体积、挤占 I-cache 与 μop cache。② cmov 消除了控制依赖却引入数据依赖,两个操作数都必须先算完,当预测很准而计算很贵时更慢。③ 无分支算术技巧需额外指令,省下的是潜在误判,付出的是确定的多条指令。错在哪:把"误判的代价"与"分支的代价"混为一谈,或把"减少分支"当成无条件优化,忽略"预测准确率"这个决定性变量。

面试官会怎么追问:①"怎么判断该不该消除一个分支?"——先测 perf stat 的 branch-misses / branch-instructions,只有误判率高且位于热路径时才考虑,并且必须 A/B 实测;②"为什么不把预测器做成无限大?"——表格规模、索引与训练延迟都随历史长度增长,且预测器必须在 1 个周期内给出答案,面积与功耗同样是约束。

Q5(设计)2 路 NUMA 服务器上的服务,压测吞吐只有预期一半,numastat 显示远程访问占比很高。给出诊断步骤与三类修复方案及各自适用边界。

答案与解析

诊断:① numactl --hardware 或 lstopo 看清节点数、每节点 CPU/内存与节点距离;② numastat -p <pid> 看 numa_hit 与 numa_miss/other_node 占比,这是最直接的证据;③ 采样 /proc/<pid>/task/*/stat 的 CPU 字段或 perf sched,确认热点线程是否在节点间漂移;④ 检查是否存在"主线程预分配 + 线程池使用"的错配模式。

三类修复与边界:① 绑核绑内存(numactl --cpunodebind=N --membind=N),适用延迟敏感、工作集能装进单节点内存的场景;边界是单节点内存不足时不可用,节点内出现热点时失去均衡能力。② 交错分配(--interleave=all),适用带宽敏感的顺序扫描与流式处理,用延迟换聚合带宽;边界是延迟敏感负载变慢,因为一半访问变远程。③ 代码层保证 first-touch 亲和,让实际使用数组的线程各自初始化自己那段(或 mbind/set_mempolicy 显式指定策略),这是最根本的修法;边界是需改代码,且对象池、共享缓存这类跨线程结构难以完全本地化。

错在哪:答"加机器"或"打开透明大页"都错了病因——问题是内存与计算不在同一节点;另一个常见错误是只绑核不绑内存:线程不再迁移,但已分配的页仍在远端,远程访问比例不降。面试官会怎么追问:①"绑核后性能反而下降,可能是什么原因?"——失去跨核负载均衡出现单节点热点,或该负载是带宽敏感型而被绑核限制了可用带宽。②"为什么 Linux 默认用 first-touch?"——内核对"哪个线程最终会用这个页"没有先知信息,first-touch 是零成本且对"谁分配谁使用"这一最常见模式的最优猜测。

面试高频问题与陷阱

面试问法考查角度陷阱 / 加分点对应小节
为什么要有存储层次?延迟为什么是数量级递增而非线性增长?是否理解"每级都是下级的缓存"与容量-延迟-成本三角陷阱:答"因为快的贵"就停住,说不出容量放大导致查找路径变长、延迟超线性增长。加分:推导 AMAT = T + (1 − h) × M,指出缺失率的小改善收益远大于命中延迟优化1.1
缓存行为什么是 64 字节?改成 128 字节会怎样?行大小是空间局部性收益与带宽浪费的权衡陷阱:答"因为 2 的幂"或"越大命中率越高”。加分:指出 64 B 对应一次 DDR 突发长度,行越大则随机访问下无用数据搬运与首次访问延迟越高1.2
你说缓存没命中,那到底是哪种没命中?怎么证明?三类缺失的区分与实验方法陷阱:只背 3C 名字,说不清口径互斥关系,也拿不出测量方法。加分:说出"全相联重放消掉冲突缺失、无限容量重放消掉容量缺失"的对照法1.3
多核之间怎么保证缓存一致?说说 MESI。一致性协议的状态机与嗅探陷阱:只背四个状态名,说不清"写之前必须先拿到独占权(RFO)",也画不出转换。加分:主动提 MOESI 的 O 态用于减少脏行写回、MESIF 的 F 态用于指定唯一转发者1.4
两个线程写同一结构体的不同字段,会有什么问题?伪共享的识别与量化陷阱:答成"数据竞争,需要加锁”,把性能问题当正确性问题。加分:说出"只有写才触发、读-读共享不触发",给出 @Contended/padding 的用法与对齐要求1.5
你的代码里 load 之后紧跟一条用它的指令会发生什么?分支预测失败又贵在哪里?数据冒险的转发边界与控制冒险的代价构成陷阱:前者只答"停顿一个周期"却说不清转发为何失效(数据要到 MEM 末尾才有效);后者答"多执行了几条指令"。加分:说明编译器调度与乱序执行各能掩盖到什么程度,以及误判代价与流水线深度成正比(五级约 1 ns、深流水约 5 ns)1.6 / 1.7
乱序执行是怎么做到既乱序又不违反程序语义的?寄存器重命名与 ROB 的分工陷阱:把"乱序"理解成"完全无序",说不出 ROB 顺序提交保证精确异常。加分:说清 WAW/WAR 在重命名阶段被消除、只有 RAW 限制并行度1.8
Spectre 和 Meltdown 的成因是什么?为什么修不干净?推测执行与微架构侧信道陷阱:答"分支预测器有 bug"。加分:指出根因是"架构状态可回滚、缓存等微架构状态不可回滚",并逐一说明 KPTI、IBRS、lfence 各堵住链条的哪一环1.8
为什么把线程绑到固定 CPU 上通常能提升性能?NUMA 与缓存亲和的收益来源陷阱:只答"减少上下文切换",说不出内存亲和性这个主因。加分:说明 first-touch 导致的"分配者与使用者不在同一节点"的经典错配,以及绑核在带宽敏感负载上可能变慢1.10

延伸阅读

  • Hennessy & Patterson, Computer Architecture: A Quantitative Approach:第 2 章(存储层次与 AMAT)、附录 B(缓存与一致性协议的状态机推导);Bryant & O’Hallaron, CSAPP 第 6 章"存储器层次结构"(局部性度量与缓存友好的矩阵乘法推导)。
  • Intel 64 and IA-32 Architectures Optimization Reference Manual(分支预测器、ROB 与流水线深度);Intel SDM Vol. 3A 内存类型与内存顺序小节,是 1.9 节的权威依据。
  • ARM Architecture Reference Manual 的内存模型与屏障指令(dmb/dsb/isb)章节:对照 x86-TSO 理解弱内存模型。
  • Linux 内核文档 Documentation/admin-guide/numa_memory_policy.rst、numactl(8)、numastat(8)、lstopo(1),以及 Documentation/admin-guide/hw-vuln/:1.10 与 1.8 节的观测与缓解依据;OpenJDK 关于 jdk.internal.vm.annotation.Contended 的说明与 JEP 142 是伪共享注解的原始依据。