第 1 章 存储层次与 CPU 微架构
本章解释"代码为什么快或慢"的硬件根因,是后续性能与并发章节的物理地基。
学习目标
读懂本章后你应当能够:推导 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 ns | 1× | 数百 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 ns | 100× | 约 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 字节。
| |
叠加一条硬约束:行的填充必须一次完成,中途不可用。因此行越大,首次访问该行的延迟、以及该行被驱逐时浪费的干净数据都越大。局部性失效的高频场景如下,共同点是访问模式不连续或工作集超容量:
| 场景 | 失效的局部性 | 表现 |
|---|---|---|
| 链表 / 树的指针追逐 | 空间局部性 | 每节点一次缺失,遍历退化为随机访存 |
| 二维数组按列遍历(行主序语言) | 空间局部性 | 每访问一个元素换一行,缺失率接近 100% |
| 工作集 > LLC 的随机访问 | 容量局部性 | 缺失率由工作集与容量之比决定 |
| 多线程写同一行的不同字段 | 空间局部性被"反向利用" | 见 1.5 节伪共享 |
警告
常见错误回答:“cache line 是 64 字节,因为计算机喜欢 2 的幂。“这是倒果为因。请务必能用"一次 DDR 突发 = 64 B"与"收益递减点"两个理由正面回答。
1.3 映射方式与三类缺失
缓存的本质是一张 (tag → 数据) 的表,映射方式决定它如何被索引。以 64 B 行、48 位物理地址为例:
| |
表 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 四态的含义
| 状态 | 全称 | 与内存是否一致 | 其他核是否有副本 | 读写约束 |
|---|---|---|---|---|
| M | Modified | 否(本核最新,内存陈旧) | 否(独占) | 可读可写 |
| E | Exclusive | 是 | 否(独占) | 可读,写时静默升 M |
| S | Shared | 是 | 可能有 | 可读,写前必须先升级 |
| I | Invalid | 无有效副本 | —— | 读写均缺失 |
一致性靠**总线嗅探(snooping)实现:所有缓存控制器监听总线/片上互联,看到地址匹配的事务就做出反应。这是分布式仲裁式实现,与用于大规模多路系统的目录式(directory)**协议相对。
| |
四个必须记住的转换要点: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。
| |
修复分两个层次,关键是先按 64 字节对齐、再填充:
| |
| |
面试必须能答的几点:@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-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.8.1 成因链条
两个侧信道漏洞(公开披露于 2018 年)的本质是回滚不彻底:ROB 能让架构状态(寄存器、内存)回到正确样子,但微架构状态(缓存内容、预测器状态、TLB 状态)不会回滚。把残留状态当侧信道读出来,就绕过了权限检查。
| |
表 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 的弱模型允许更多重排。
| |
注意
本章只回答"硬件为什么必须提供屏障":写缓冲、失效队列与乱序发射都会让访存顺序偏离程序顺序。屏障的四种类型(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/SMP | NUMA |
|---|---|---|
| 内存延迟 | 所有核一致(约 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 都在做远程访问。
| |
小心
把"绑定 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 是伪共享注解的原始依据。