计算机组成与体系结构 从入门到精通的交互式讲解

全景:计算机是怎么跑起来的

在钻进任何细节之前,先建立一张完整的地图:比特从哪里来、「程序存在内存里」意味着什么、你的代码和电流之间隔着几层抽象、以及「快」到底怎么衡量。后面每一章都是把这张地图的某一块放大。

整台机器立在两个决定上:用两种状态而不是十种表示信息,以及把指令和数据放进同一个存储器。前者定了硬件形态,后者定了此后八十年的软件形态。

① 二进制是工程必然,不是数学偏好

  • 晶体管本质是电控开关,只需可靠地处于通/断两态,制造偏差与噪声都不影响判断;把开关组合起来得到逻辑门,一种 NAND 就能搭出所有数字电路
  • 关键在于信号每过一级门都被掰回标准的 0 或 1,噪声不累积——这就是芯片能塞几百亿个晶体管还能工作的原因。

② 存储程序:软件从此诞生

  • 1945 年那句「指令和数据以同样的方式存在同一个存储器里」之前,编程是重新接线;程序既然是数据就能被另一个程序读写,编译器、调试器、JIT 全建立在这条上;
  • 由此定下经典结构:CPU、存储器、输入、输出,CPU 按取指 → 译码 → 执行永不停歇。代价是冯·诺依曼瓶颈——通道要同时供指令和数据,而 CPU 越来越快、内存跟不上;
  • 开机是这条原理最极致的演出:复位后 PC 被硬件写死指向固件,固件 → bootloader → 内核 → 第一个用户进程,每一棒都把下一棒从「数据」变成「正在执行的程序」
别把「哈佛结构」当成被淘汰的失败者:现代 CPU 的 L1 Cache 几乎都是指令/数据分开的哈佛式,主存层面才是统一编址的冯·诺依曼式。两种结构按层次并存,不是新旧更替。
检验理解的一问:为什么 JIT 在 iOS 上受限?因为 iOS 禁止申请「可写又可执行」的内存页——正是对「程序即数据」的限制。

a = b + c; 到电子流动,中间隔着一摞接口清晰的层。这门课最重要的思想不是某个算法,而是分层抽象:每层只依赖下一层的契约。把这条语句送下去走一遍,就是全书主线。

这一摞层,以及那条分界线

高级语言      Python / C / Rust        ← 人的思维
    ↓ 编译 / 解释
汇编语言      mov, add, jmp            ← 指令的助记符
    ↓ 汇编器
机器码 (ISA)  0x48 0x01 0xd8           ← 软硬件的契约 ★
    ↓ 微架构实现
微架构        流水线、Cache、乱序引擎   ← 厂商的自由发挥
    ↓ 数字电路 / 物理
门电路 → 晶体管  NAND、MOSFET 开关      ← 布尔逻辑与电子

ISA 是软硬件的分界线:写明的部分永不轻易改动,没写的部分(流水线几级、Cache 多大)厂商随便换——所以 2005 年编译的 x86 程序能跑在 2025 年的 CPU 上。但性能问题会穿透抽象层,这正是学这门课的理由。

这条语句走过的站

  • 编译期:翻成三条指令(02 章数据怎么表示、03 章指令怎么编码)再变成机器码字节;
  • 取指:地址先过虚拟内存翻译(06 章)、再查 Cache(05 章),未命中才访问 DRAM;
  • 执行:ALU 做加法,而它会和前后几十条指令乱序、并行地跑(04 章);写回时多核同碰一块内存还有缓存一致性(08 章)。
两个词常在层与层之间打滑:x86-64、ARMv8 是 ISA,Skylake、Zen 4 是微架构——Intel 和 AMD 的 CPU 能跑同样的程序是因为共享 ISA 契约,微架构实现毫无相似之处。另外语句和指令的一一对应只在 -O0 成立:开 -O2 后变量常驻寄存器、中间存取消失,是常量时整条语句还会被折叠掉。
卡在某个算法里出不来时,回到这条语句问一句:我学的东西在这趟旅程的哪一站?编译期可本机重放:gcc -S a.c 得汇编,objdump -d a.o 得机器码。

「这台机器快不快」是个没法直接回答的问题——先分清你在问延迟(一件事多快做完)还是吞吐(单位时间做多少件事),两者常常互相牺牲。

基本量

CPU 时间 = 指令数 × CPI × 时钟周期
         = 指令数 × CPI / 主频

CPI = 平均每条指令花的时钟周期数(越小越好,乱序 CPU 可 < 1)
IPC = 1/CPI,每周期执行的指令数(现代高性能核 IPC 可达 4~6)
  • 这个乘积说明性能是三方合作:编译器影响指令数,微架构影响 CPI/IPC,工艺和电路影响主频。只盯主频是九十年代的思维——如今两颗同主频的 CPU 性能可以差一倍,差距全在 IPC。
  • 跨指令集比「每秒执行多少条指令」毫无意义:一条 x86 指令可能顶三条 RISC-V 指令的活。比性能只能比真实负载的完成时间,这就是 SPEC、Geekbench 这类基准测试存在的原因(以及它们被厂商「应试优化」的原因)。

Amdahl 定律:优化的天花板

加速比 S = 1 / ( (1−f) + f/s )    // f=可优化部分占比, s=该部分的加速倍数

例: 程序 80% 可并行,用无穷多核 → S = 1/0.2 = 最多 5 倍
  • 直觉版本:你没法优化的那部分,决定了你的上限。串行部分占 20%,哪怕并行部分加速无穷倍,整体也只有 5 倍。
  • 它不只关于并行:做任何性能优化前先测量「这段代码占总时间的比例」——占 3% 的函数优化到零,整体也只快 3%。先 profile 再动手,是 Amdahl 定律在日常工程里的形态。
Amdahl 定律隐含「问题规模固定」的假设。现实中机器变大时人们往往把问题也做大(更高分辨率、更多数据),串行部分的占比随之被稀释——这是 Gustafson 定律的视角,也是上千核的超算依然有用的原因。别拿 Amdahl 一票否决大规模并行。
延迟与吞吐的取舍无处不在:流水线提高吞吐但不缩短单条指令的延迟,批量 I/O 提高吞吐但增加单个请求的延迟。看到「XX 更快」先问是哪一个。

上手:把硬件行为量出来

这门课最容易学成背名词。先把五个实验在自己机器上跑一遍——分支预测、Cache 行、行列遍历、false sharing、依赖链——每个都只有十几行代码,但都能让「看不见的硬件」吐出可对照的数字。之后每一章讲的机制,你都已经先摸到过它的影子。

整页只需要两件工具:一个看编译器生成了什么指令,一个数硬件真实花了多少周期。前者在浏览器里,后者一条命令。

godbolt.org:看指令

  • 浏览器里写 C/C++/Rust,右侧实时出汇编,可切换编译器版本、优化等级、目标架构(x86 / ARM64 / RISC-V)。学 02 章时看 x / 8 被编成移位、x / 10 被编成「乘一个魔数再移位」;学 03 章时对比同一函数在三种 ISA 下的样子。

perf stat:数周期

$ perf stat -e instructions,cycles,branches,branch-misses,cache-misses ./a.out

       428 405 329      instructions      #  1.82  insn per cycle
       235 243 356      cycles
       131 623 330      branches
         1 627 336      branch-misses     #  1.24% of all branches
         2 845 966      cache-misses
  • WSL2 里 perf 可用(部分计数器受限),macOS 用 Instruments,Windows 原生用 VTune。工具不重要,「拿数字验证直觉」的习惯才重要。
  • 用法上只有一条纪律:先写下预测,再看输出。猜「这个循环 IPC 大概多少、cache-miss 率大概多少」,然后对照。猜错的地方就是你对硬件的误解所在——这比读十页讲解有效。
cache-misses 统计的通常是最后一级缓存的未命中,被 L1/L2 挡住的不算——所以它的数值往往比你想象的小得多,别据此以为「我的程序 Cache 很友好」
读 perf 输出只看比率,不看绝对数branch-misses 要看占 branches 的百分比,cache-misses 要看占 cache-references 的百分比。

五个实验的共同点:算法一行没改,只动数据布局或写法,性能差几倍到几十倍。数字取自本机(Zen 4,L1d 32 KB / 行 64 B),你机器上的倍数会不同但方向一定相同。

① 排一下序就快一倍 ② 用 stride 量出「行」

3.3 万个随机数扫 2000 遍:if (a[i] < 128) sum += a[i];
              耗时     branch-misses      IPC
未排序      0.053 s   1 627 336 (1.24%)   1.82
排序后      0.026 s     179 393 (0.13%)   3.66

扫 64 MB 数组,每隔 stride 字节访问一次:
stride   访问次数     总耗时    每次访问
    32   2 097 152    1.7 ms   0.83 ns
    64   1 048 576    1.7 ms   1.63 ns  ← 总耗时与 stride=32 完全相同
  • 数据一个字节没变、指令一条没少,只是顺序变了:分支变得可预测,流水线不再被冲刷(04 章)。必踩的坑是编译器会把它编成条件传送(cmov)让效应消失,要显式 -fno-if-conversion
  • ②stride 32 的访问次数是 64 的两倍而总耗时一样,因为取回的 cache 行数相同——决定耗时的是行数不是访问次数(05 章)。

③ 列遍历 ④ 假共享 ⑤ 依赖链

4096² int 数组,关向量化:行遍历 0.005 s / 列遍历 0.220 s(慢 48×)
                          cache-misses 多 ~900×,dTLB-misses 多 ~340×
4 线程各加自己的计数器:同一 cache 行 0.042 s / 各占独立行 0.009 s(快 5×)
4 亿次浮点加:1 个累加器 0.269 s / 4 个独立累加器 0.067 s(恰好快 4.0×)
  • ③耗时差 48 倍是结果,cache-misses 差 900 倍是原因,而 dTLB 差 340 倍说明列遍历把 TLB 也一起打穿了(06 章);
  • ④四个线程逻辑上毫无共享,却因挤在同一条 64 B 行里被一致性协议来回收发所有权(08 章);
  • ⑤浮点加有延迟但完全流水,串成一条链时每次都等上一次结果;拆成 4 条独立链就能同时在流水线里,上限 ≈ 延迟 ÷ 发射间隔(04 章)。
所有计时实验都要先热身一轮再计时,否则测到的是缺页开销而不是 Cache 效应。另外别把本机的倍数当成普适常数——结论是「哪个方向更快」,不是「快几倍」。
把五个实验的「预测值 vs 实际值」记在一个文件里,是性能工程师的标准训练。对不上就深挖:是我理解错了机制,还是测法有问题?

这一页自下而上:从数据在机器里长什么样起步,逐层搭到指令集、微架构、存储层次与并行。前一章的产物是后一章的原料,顺着读最省力。

四段结构

  • 02 数据的机器表示 → 03 指令集:地基。补码与浮点不过关,后面所有「为什么结果不对」都解释不了
  • 04 CPU 微架构:本页主线也最厚——流水线、分支预测、乱序全在里面,上手章那个分支预测实验在这里得到解释;
  • 05 存储层次与 Cache → 06 虚拟内存:性能的真正大头,上手章另外三个实验的答案都在这两章;
  • 07 I/O → 08 并行 → 09 显示子系统走到整机,10 用代码造一台机器把前面九章变成能跑的模拟器,11 是路线图。
最常见的弯路是把这门课当成纯记忆课——背下五级流水线的名字、MESI 四个字母、Cache 三种映射方式,然后发现对写代码毫无帮助。每学一个机制就去 01 章那几个实验里把它测出来。
每读完一章,回上手章那几个实验重跑一次:现在你能预测那些数字了吗?能预测才算学会。第一遍可以跳过带「进阶」徽章的卡,它们是回头深挖用的。

数据的机器表示

全景图的第一站是「数据怎么表示」,现在把它放大:内存里只有比特,「类型」是人赋予比特的解释方式。这一章解决两个问题——整数和小数如何编码成比特(以及这些编码方式如何在你的代码里埋雷),硬件又如何对这些比特做算术。

十六进制不是「另一种进制」,而是二进制的速记法:一个 hex 位恰好等于 4 个 bit,这让它成为程序员读内存的母语。

读法与换算

0xDEADBEEF = 1101 1110 1010 1101 1011 1110 1110 1111
               D    E    A    D    B    E    E    F   // 逐 4 位对照,无需计算

常见数字的直觉: 0xFF=255(一字节全1)  0x400=1024  0x1000=4096(一页)
  • 为什么不用十进制读内存:255 和 256 看不出任何结构,0xFF 和 0x100 一眼看出「进位边界」。地址、掩码、颜色值、权限位都天然是 2 的幂结构。

位运算:对比特的直接操作

x & 0xFF        // 取低 8 位(掩码:AND 留下想要的位)
x | (1 << 3)    // 把第 3 位置 1
x & ~(1 << 3)   // 把第 3 位清 0
x ^ (1 << 3)    // 把第 3 位翻转
x & (x - 1)     // 消掉最低位的 1(判 2 的幂、数 1 个数的基础)
(x + 7) & ~7    // 向上对齐到 8 的倍数(内存分配器天天在用)
  • 这些不是竞赛技巧,而是系统代码的日常:权限标志(O_RDWR | O_CREAT)、页表项、网络协议头的字段拆装,全是位运算。
两个高频语法坑:^ 是异或不是乘方——10^2 得 8;x & 1 == 0 不是判偶数——C 里 == 优先级高于 &,它实际是 x & (1==0),恒为 0。位运算混入比较时永远加括号(较新的 gcc 开 -Wall 对这两处都有专门警告)。
移位即乘除:x << 3 是 ×8,x >> 3 是 ÷8(无符号)。不必手写——编译器看到 x*8 自动生成移位。在 godbolt 输入 x/10 会看到更神奇的东西:编译器用乘法+移位替代了昂贵的除法指令(这一招叫 magic number)。

补码不是「负数的某种存法」,而是一个精巧的数学决定:让同一个加法电路不加区分地处理正负数。理解它靠推导,不靠背口诀。

从里程表推导

  • 想象 3 位数的里程表,只能显示 000~999。从 000 往回倒 1 格显示 999——所以在这个「模 1000」的世界里,999 的行为和 −1 一模一样:任何数加 999,效果等于减 1(进位溢出被丢掉)。
  • 二进制同理:8 位寄存器是模 256 的世界,11111111(255)的行为等于 −1,11111110(254)等于 −2。补码就是「用 2ⁿ − |x| 代表 −x」:[−x] = 2ⁿ − x,如此而已。
  • 于是减法免费获得:a − b = a + (2ⁿ − b),硬件里就是「b 逐位取反再 +1,然后照常加」。一套加法器通吃加减、通吃正负,这是补码淘汰原码/反码的根本原因。

随之而来的性质(都能从定义推出)

  • 最高位天然充当符号位(不需要特殊电路对待它);0 只有一种表示。
  • n 位范围 −2ⁿ⁻¹ ~ 2ⁿ⁻¹−1,负端多一个:−128 存在而 +128 不存在(8 位)。所以 abs(INT_MIN) 溢出——这是真实世界的 bug 来源。
  • 符号扩展:8 位的 0xFF(−1)扩到 32 位要填成 0xFFFFFFFF 才还是 −1——「负数补 1、正数补 0」,这就是指令集里 movsx(符号扩展)和 movzx(零扩展)并存的原因。
别用原码思维把最高位当「独立的符号标志」:补码的最高位是带权 −2ⁿ⁻¹ 的普通一位,照常参与运算。按位权直接读:1000 1011 = −128+8+2+1 = −117——比「取反加一」的口诀更快,也不会在「负零」这类原码才有的问题上犯迷糊。
检验理解:为什么 (x >> 31) | 1 能取出 int 的符号?为什么无符号数比较和有符号数比较需要不同的跳转指令(ja vs jg)?——比特相同,解释不同:有无符号不是数据的属性,而是指令看待数据的方式

整数编码的规则简单,但它与语言规则交织后产生了真实世界最常见的一类 bug。这张卡全部是实战。

溢出

int mid = (lo + hi) / 2;          // 经典 bug:lo+hi 可能溢出 int
int mid = lo + (hi - lo) / 2;     // 修复:JDK 的二分搜索曾栽在上一行
  • 硬件层面溢出只是「进位丢掉、结果回绕」,CPU 会如实设置溢出标志;但 C/C++ 把有符号溢出定义为未定义行为(UB),编译器会按「不可能溢出」优化,产生比回绕更诡异的结果(如死循环、检查被删除)。无符号溢出则是良定义的回绕。
  • 怎么防:Rust 在 debug 模式下溢出直接 panic;C 用 __builtin_add_overflow;安全关键代码先判再算。

有无符号混用

std::vector<int> v;
for (int i = 0; i < v.size() - 1; i++)   // v 为空时: size()-1 = 巨大无符号数
    ...                                   // 循环失控 → 越界崩溃
  • C 的转换规则:有符号与无符号相遇,有符号被转成无符号。−1 变成 4294967295,比较、减法全部失真。size_t 是无符号的,这让上面的坑遍布 C++ 代码。
  • 移位的暗雷:负数右移是实现定义(多数编译器算术移位);移位数 ≥ 位宽是 UB(1 << 32 在 x86 上实际只移 0 位,因为硬件只取移位数低 5 位)。
char 是否有符号由平台决定(x86 有符号,ARM 传统上无符号)。char c = 0x80; if (c == 0x80) 在两种平台上结果不同——跨平台代码请写明 int8_t / uint8_t。
让工具站岗:gcc -fsanitize=undefined 编译后运行,有符号溢出会当场报 runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'-Wextra(含 -Wsign-compare)在编译期抓有无符号混用比较。这两个开关比肉眼审查可靠得多。

IEEE 754 用「二进制科学计数法」在 64 位里塞下从 10⁻³⁰⁸ 到 10³⁰⁸ 的动态范围,代价是几乎所有十进制小数都只能近似存储

格式

值 = (−1)^S × 1.M × 2^(E − bias)

float  (32位): S 1位 | E 8位(bias=127)  | M 23位   ≈ 7 位十进制精度
double (64位): S 1位 | E 11位(bias=1023)| M 52位   ≈ 16 位十进制精度
  • 尾数首位恒为 1(规格化),所以不存,白赚一位精度——这是隐藏位
  • 阶码用移码(真实指数 + bias)存储,好处是非负浮点数的比特模式可以直接当无符号整数比大小;负数一侧顺序相反,需先翻转才能比——排序浮点数仍可以用整数比较器。

0.1 的真相

0.1 (十进制) = 0.000110011001100...(二进制, 无限循环)
存进 double  = 0.1000000000000000055511151231257827...
所以: 0.1 + 0.2 == 0.3   → false
      0.1 + 0.2          → 0.30000000000000004
  • 和 1/3 在十进制里写不尽是同一回事:分母含 2 以外因子的分数,二进制写不尽。0.5、0.25、0.125 精确,0.1、0.2、0.3 全部不精确。
  • 钱为什么不能用 float:分单位的累加误差会攒出真实的账目差。正确做法是整数存最小单位(以分计)或十进制类型(Java BigDecimal、Python decimal、SQL NUMERIC)。
反方向的坑:别以为「整数存进浮点总是安全」。float 的尾数只有 24 位(含隐藏位),从 2²⁴ = 16777216 起就存不下所有整数——(float)16777217 就等于 16777216;double 的界限是 2⁵³。JavaScript 的 Number 就是 double,后端发来的 64 位 ID 超过 2⁵³ 会在前端悄悄失真——真实事故的常客。
每个浮点数其实是精确的——不精确的是「十进制 → 二进制」的转换。printf("%.20f", 0.1) 打出的那串数字就是 0.1 存进 double 后的精确值。想看比特:printf("%a", 0.1) 输出十六进制浮点表示 0x1.999999999999ap-4

知道 0.1 不精确只是开始;写出正确的浮点代码需要一套纪律。

特殊值:编码里预留的暗门

E 全 0, M = 0   → ±0          // 正负零,== 判相等但 1/x 符号不同
E 全 0, M ≠ 0   → 非规格化数   // 填补 0 附近的空隙,运算极慢(软件辅助)
E 全 1, M = 0   → ±∞          // 1.0/0.0 = inf(不崩溃!)
E 全 1, M ≠ 0   → NaN         // 0.0/0.0、sqrt(-1);NaN != NaN 恒真
  • NaN 会传染:任何与 NaN 的运算结果还是 NaN,一路污染到输出。判断方法只有 isnan(x) 或利用 x != x
  • 浮点除零不崩溃(得到 inf),整数除零才崩溃——很多人的直觉恰好相反。

比较与误差纪律

fabs(a - b) < 1e-9              // 绝对误差:量级大时失效
fabs(a - b) < eps * max(|a|,|b|) // 相对误差:通用但 0 附近失效
  • 浮点加法不满足结合律(big + small) − big 可能把 small 整个吃掉(大数吃小数)。求和顺序影响结果——这是并行归约结果不可复现的根源;高精度求和用 Kahan 补偿算法。
  • 误差会在相近数相减时灾难性放大(catastrophic cancellation):sqrt(x+1)−sqrt(x) 在 x 很大时几乎全是噪声,改写成 1/(sqrt(x+1)+sqrt(x)) 则稳定——数值稳定性的入门直觉。
编译器优化选项 -ffast-math 允许假装浮点满足结合律来加速(向量化求和),代价是放弃 IEEE 语义:NaN 检查可能被删、结果可能改变。开它之前要知道自己在交易什么。
想知道两个 double「差几格」:把比特按 int64_t 取出相减,就是 ULP 距离(同号时有效)——0.1+0.20.3 恰好差 1 ULP。googletest 的 EXPECT_DOUBLE_EQ 就按「相差 ≤ 4 ULP」判等——比自己拍一个 1e-9 的阈值科学。

一个 int 占 4 个字节,那么哪个字节放在低地址?不同阵营给了相反答案,而你的代码迟早会撞上这个分歧。

字节序

int x = 0x12345678;  地址从低到高:
小端 (x86, ARM默认):  78 56 34 12    // 低位字节在低地址
大端 (网络字节序):    12 34 56 78    // 高位字节在低地址,像人的书写
  • 小端的好处:取一个数的低 8 位/低 16 位,地址不变(指针强转截断天然正确);大端的好处是十六进制 dump 顺着读。没有对错,只有约定。
  • 什么时候必须在意:数据跨机器边界时——网络协议(TCP/IP 头一律大端,所以有 htonl/ntohl)、二进制文件格式、直接 memcpy 结构体到 socket。同一台机器内永远感知不到。

对齐与 struct padding

struct A { char c; int x; };      // sizeof = 8, 不是 5!
内存布局: [c][pad][pad][pad][x x x x]   // int 要求地址是 4 的倍数

struct B { int x; char c; };      // sizeof 仍是 8: 尾部补齐到对齐边界
struct C { char a; int x; char b; }  // 12 字节; 调序成 {x,a,b} → 8 字节
  • 为什么要对齐:让一个 int 落在 4 的倍数地址上,保证它不跨越硬件的自然访问边界。x86 容忍非对齐访问(跨 Cache 行时变慢),ARM 的原子指令遇到非对齐直接异常。
  • 实战影响:结构体按成员从大到小排列可以消掉 padding;序列化时不要直接 memcpy 结构体(padding 内容是垃圾,且对端布局可能不同)。
字节序只翻转「多字节标量内部」的字节:字符串、字节数组在大小端机器上的内存布局一模一样(每个 char 自成一体),数组元素之间的顺序也不变。把「小端」理解成「内存里什么都倒着放」,读 hexdump 时就会把字符串也倒过来读。
验证一切的三行代码:union { int i; unsigned char b[4]; } u = {.i = 1}; printf("%d", u.b[0]); 输出 1 是小端。offsetof(struct A, x) 能打出每个成员的真实偏移——比背规则可靠。

你写 a/ba+b 时代价可以差二十倍。这张卡讲清硬件算术的三档速度从何而来——它直接影响你和编译器写代码的方式。

加法:1 个周期(64 位约 0.2~0.3 纳秒,即三十~五十亿分之一秒)

  • 朴素方案「行波进位」——每一位等低位的进位传上来——64 位要串行等 64 级门延迟,太慢。
  • 超前进位(CLA)的洞察:第 i 位是否产生进位,其实可以直接从输入算出来,不必等。定义「生成 G=AB、传播 P=A⊕B」,每一位的进位都展开成只含输入的表达式,并行计算。代价是电路面积换时间——硬件设计的永恒交易。

乘法:约 3 个周期

  • 本质是「移位相加」的并行化:把 64 个部分积用压缩树(Wallace tree)并行归约。现代 CPU 的 64 位乘法延迟约 3 周期,已经很便宜。
  • Booth 编码的洞察值得一记:乘数里一串连续的 1(如 0111111)等于「高一位的 1 减最低位的 1」,一串加法变成一加一减——减少部分积数量,还顺带自动处理了补码符号。

除法:20~40+ 个周期,且难以流水

  • 除法本质是「试商」的串行循环——每一步依赖上一步的余数,无法像乘法那样并行展开。这是三种运算里唯一的本质串行。
  • 所以编译器不遗余力地消除除法:除以常数 8 变移位;除以常数 10 变「乘 magic number 再移位」;除以变量则没救。热点循环里的整数除法值得手动消除(比如用累减、查表或换成乘法)。
「位运算比乘法快,所以手写 x<<3 代替 x*8」是过时优化:编译器自动做强度削减(x*8 生成 lea/移位,x/10 生成 magic number 乘法),乘法本身也只有约 3 周期。手写移位只伤可读性,还容易在负数、优先级上引入新 bug——真正值得手工消除的只有热路径上的变量除法
浮点也遵循同样档位:加/乘约 3~5 周期且完全流水(每周期都能发射一条新的),除法和 sqrt 十几到几十周期且占住执行单元。矩阵库把一切写成乘加(FMA:一条指令算 a×b+c)不是巧合。

比特不是永恒的:宇宙射线能翻转内存里的一位,传输线上的噪声能污染一段报文。校验码是「用冗余比特换检错/纠错能力」的一门生意,三种主流方案对应三档投入。

三档方案

  • 奇偶校验(1 bit 冗余):让 1 的个数恒为偶数,任何 1 位翻转立刻暴露。只能发现、不能定位、两位同错则漏网——用在串口、老式内存这类低成本场景。
  • 海明码/ECC(log n 级冗余):多组奇偶位交叉覆盖,出错时各组的「报警模式」恰好拼出出错位置——能定位就能纠正(翻回来即可)。服务器内存的 ECC 就是它的扩展版(SECDED:纠 1 位、检 2 位)。
  • CRC(固定几十 bit 冗余):把整段报文当多项式做模 2 除法,余数作校验值。对突发错误(连续多位损坏,正是传输线的典型错法)检测能力极强,硬件实现只是移位加异或——以太网帧、ZIP、PNG、磁盘扇区全在用。

深入理解

  • 统一的度量是码距(合法码字间最少差几位):码距 d 能检 d−1 位错、纠 ⌊(d−1)/2⌋ 位错。奇偶码 d=2,海明码 d=3——所有校验码都是在「冗余量 ↔ 码距」之间讨价还价。
  • 这不是遥远的理论:Google 的统计显示 DRAM 每 GB 每年发生可观测位翻转的概率不可忽略;大规模集群里没有 ECC 的机器会静默地算错数(silent data corruption)。你的笔记本没有 ECC,是成本权衡,不是不需要。
「TCP 有校验和,所以传输不会错」想多了:TCP 校验和只有 16 位、算法是简单的反码求和,检错能力有限,大流量长链路下坏数据漏网并非没有——这正是下载站在 TCP 之上还要给 SHA-256 的原因。「有校验」只是降低概率,不是消灭错误。
校验码防「意外损坏」,防不了「恶意篡改」——攻击者改完数据可以重算 CRC。防篡改需要密码学哈希(SHA-256)或 MAC(消息认证码)。分清 checksum 和 hash 的适用场景是工程常识。

指令集:软硬件的契约

上一章的比特光躺着没用,得有指令去搬运它、运算它——而指令本身也是一串比特。ISA 就规定了这套指令:CPU 对软件承诺的一切——有哪些指令、哪些寄存器、内存怎么访问。这一章从「机器码长什么样」讲到「为什么 CISC/RISC 之争以殊途同归收场」。配合汇编页的 x86-64/ARM64/RISC-V 深入可获得完整图景。

CPU 不认识 C,也不认识汇编——它只消费字节。汇编只是给这些字节起的助记名,一一对应、毫无魔法。

亲眼看一次

int add(int a, int b) { return a + b; }

$ gcc -O1 -fcf-protection=none -c add.c && objdump -d -M intel add.o
0000000000000000 <add>:
   0:  8d 04 37     lea    eax,[rdi+rsi]     // 3 个字节就是这个函数的全部
   3:  c3           ret
  • 8d 04 37 这三个字节里编码了:操作(lea 计算地址)、目的寄存器(eax)、两个源寄存器(rdi、rsi)。指令 = 操作码 + 操作数说明,所有 ISA 概莫能外。
  • 指令大致分五类:算术逻辑(add/and/shl)、数据搬运(load/store/mov)、控制流(jmp/branch/call/ret)、系统(syscall、特权指令)、SIMD(向量运算)。任何语言的任何程序,最终都摊平成这五类的序列。

寄存器:指令的工作台

  • 运算不直接发生在内存上(RISC 中严格如此):数据先 load 进寄存器,算完再 store 回去。寄存器只有十几个,但访问不产生额外周期——它们就在 ALU 旁边。
  • 编译器的一大核心工作就是寄存器分配:让最热的变量常驻寄存器。寄存器不够用时变量「溢出」到栈上,性能立降——这就是局部变量比全局变量快的底层原因之一。
x86 汇编有两套语法:AT&T(源在前、目的在后,寄存器带 %)和 Intel(目的在前)。gcc -Sobjdump -d 默认输出 AT&T,本卡示例和 godbolt 默认是 Intel——加 -M intel/-masm=intel 统一之后再对照,否则会把 mov 的方向整个读反。
不需要会写汇编,但要会读个大概——调试器崩在没有源码的地方、性能分析定位到某条热指令时,读汇编是唯一的出路。godbolt 上把优化开到 -O2 读自己熟悉的函数,是最好的练习。

ISA 的本质是一份法律文件:写明的部分(指令行为、寄存器、内存模型)硬件厂商世代遵守;没写的部分(怎么实现)随便改。这一刀切出了整个计算机工业的分工。

契约的两侧

  • 契约内(架构状态):通用寄存器、PC、标志位、每条指令的语义、异常行为。程序只依赖这些,所以 2005 年的 exe 在 2025 年的 CPU 上行为分毫不差。
  • 契约外(微架构):流水线深度、Cache 大小、乱序窗口、分支预测器。Intel 从 Pentium 到今天换了十几代微架构,ISA 契约始终向后兼容——这是 x86 帝国的护城河,也是它背着几十年历史包袱的原因。

契约思维解释的现象

  • 为什么 Apple 从 x86 迁到 ARM 需要 Rosetta 2(二进制翻译):换契约必须翻译或重编译。而 macOS 应用商店里的 app 多数「直接就能跑」,因为分发的是中间格式或双架构包。
  • 为什么 CPU 漏洞分两种修法:ISA 层的 bug(指令行为错)必须换硬件或禁用指令;微架构层的问题(Spectre 类)可以用微码更新缓解——因为微码在契约之下。
  • 为什么 RISC-V 令人兴奋:它是第一个开放、免版税、模块化的主流 ISA 契约,任何人可以照着造芯片——契约的所有权本身就是产业权力。
「永远能跑」的方向是老程序跑在新 CPU;反过来不成立——用了 AVX2 的新程序在不支持它的老 CPU 上会以非法指令(SIGILL)崩溃。所以发行二进制要选指令基线(如 x86-64-v2/v3 这类微架构级别),「契约也有版本」是分发软件时躲不开的现实。
「用户可见 / 不可见」寄存器的划分就是契约边界:通用寄存器、PSW 在契约内;IR、MAR、MDR 这类教材寄存器全是微架构私产,换代随便扔。判断任何硬件特性「能不能依赖」,就看它写没写进 ISA 手册。

把「操作 + 操作数」压进比特是个编码问题,两大流派给出了相反的答案,而权衡的核心是:译码器的复杂度 vs 代码密度

两派

RISC-V: 所有指令恒 32 位,字段位置固定
        [funct7|rs2|rs1|funct3|rd|opcode]   // 寄存器号永远在同一位置
x86:    1~15 字节可变长
        [前缀...][操作码][ModRM][SIB][位移][立即数]  // 每段都可有可无
  • 定长:取指时不用解析就知道下一条在哪,可以并行译码任意多条;代价是简单操作也占 32 位,代码体积大(RISC-V 用 C 扩展的 16 位压缩指令补救)。
  • 变长:常用指令编得短(ret 只占 1 字节),代码密度高、省指令 Cache;代价是找到第二条指令之前必须先译完第一条的长度——x86 译码器为此付出巨大的晶体管和功耗,是它在低功耗场景吃亏的真实原因之一。

编码空间是稀缺资源

  • 操作码位数有限,设计者用前缀码思想分配:高频指令给短码,低频指令用长码逐级扩展(教材里的「扩展操作码」就是这件事)——和 Huffman 编码同一个直觉。
  • ISA 会长满补丁:x86 的前缀字节层层叠加(REX、VEX、EVEX),每次扩展(64 位、AVX、AVX-512)都要在旧编码的缝隙里找地方。对比之下 RISC-V 从第一天就预留了扩展编码空间——设计的先见之明体现在二十年后。
「变长 = CISC、定长 = RISC」并不严格:ARM 的 Thumb-2 和 RISC-V 的 C 扩展都是 16/32 位混合的变长编码(addi a0,a0,1 压缩后只占 2 字节),代码密度的压力把 RISC 也推向了变长。真正的分界是 x86 那种 1~15 字节「任意变长、判长本身就要译码」,与 RISC 两档变长「开头几个固定位一读就知长度」(RISC-V 看最低两位,Thumb-2 看首个半字的高位)的差别。
定长(RISC-V 基础指令、ARM64)任意变长(x86)
指令长度恒 32 位1~15 字节
找下一条指令不用译码就知道在哪必须先译出当前指令的长度
并行译码容易——想译几条译几条,且付出巨大晶体管与功耗
代码密度低(简单操作也占 32 位)ret 只占 1 字节)
补救手段16 位压缩扩展(RV 的 C、Thumb-2)μop 缓存(译码结果缓存起来复用)
编码不用背,亲手汇编一条看:echo 'add a0,a1,a2' | llvm-mc --triple=riscv64 --show-encoding,再把 triple 换成 x86_64aarch64 对比同一逻辑的字节——x86 的 ret 只占 1 字节(c3),ARM/RISC-V 每条恒 4 字节,两派的取舍一眼可见。

一条指令要说明「操作数在哪」,办法从「就在指令里」到「地址还要再算一下」排成一个谱系。每种寻址方式都对应高级语言里一个具体的东西——这样记才记得住。

谱系(每种都标了它服务的语言结构)

立即数     add rax, 42        // 常量 42
寄存器     add rax, rbx       // 局部变量(已分配到寄存器)
直接/绝对  mov rax, [0x601040] // 全局变量(地址链接期定死)
寄存器间接 mov rax, [rbx]      // 指针解引用 *p
基址+偏移  mov rax, [rbp-16]   // 栈上局部变量、结构体成员 p->field
基址+变址×比例 mov rax, [rbx+rcx*8]  // 数组下标 a[i](i 乘元素大小)
PC 相对    lea rax, [rip+0x2f]  // 位置无关代码:共享库的全局访问

深入理解

  • PC 相对寻址是现代安全机制的地基:代码里只有「距此处 +0x2f」而没有绝对地址,整个程序才能被加载到随机基址(ASLR)而不需修补——共享库 .so/.dll 的位置无关代码全靠它。
  • x86 的 [base + index*scale + disp] 一条指令算完数组寻址,还催生了怪用法:lea rax,[rax+rax*4] 是免费的 ×5(用地址计算单元做算术,不占 ALU)。而 RISC-V 干脆不提供比例变址——地址自己用移位加法算——两派对「一条指令该做多少事」的分歧在此暴露无遗。
  • 间接寻址每多一层就多一次访存。链表比数组慢的根本原因之一就是每个节点都要一次「寄存器间接 + 不可预测的地址」,Cache 与预取全部失效(第 05 章细说)。
lea 长着访存的样子却从不访存:方括号在 lea 里是「算出这个地址」,在 mov 里才是「取这个地址的内容」。lea rax,[rbx+rcx*4] 只做乘加算术、不碰内存——读汇编时把它当 load,数据流就全分析错了。
每种寻址方式都能被一行 C 触发:全局变量 → PC 相对,*p → 寄存器间接,p->f → 基址+偏移,a[i] → 基址+变址×比例。把这四行写进 godbolt 开 -O1,谱系逐一现形——比背表格牢固得多。

函数调用是 ISA 之上最重要的软件协议:参数放哪、返回地址存哪、谁负责恢复寄存器——约好了,分别编译的代码才能互相调用

调用的机械过程(x86-64 System V 为例)

caller:                          callee:
  mov edi, 42     // 参数1→rdi      push rbp        // 序言:存旧帧基
  call foo        // 压返回地址      mov  rbp, rsp   // 立新帧
                                   sub  rsp, 32    // 给局部变量留空间
                                   ...
                                   leave; ret      // 尾声:撤帧,弹地址跳回
  • 栈向低地址生长;每层调用压一个栈帧:返回地址、保存的寄存器、局部变量。递归深度过深 → 栈耗尽 → stack overflow,此名即由此来。
  • 调用约定的分工:前 6 个整型参数走寄存器(rdi/rsi/rdx/rcx/r8/r9),返回值在 rax;寄存器分「调用者保存」(callee 可随便用)和「被调用者保存」(callee 用前必须存、返回前必须还)。约定是 per-平台的:Windows x64 与 Linux 不同,跨约定调用必然崩。

深入理解

  • 返回地址躺在栈上、紧挨着局部数组——缓冲区溢出攻击的原理一句话就能讲完:写穿数组、覆盖返回地址、ret 跳向攻击者指定的位置。栈金丝雀、NX(不可执行位,标记数据页不可当代码运行)、ASLR、shadow stack(影子栈)全是围绕这一个弱点的层层设防。
  • 为什么调试器能打印调用栈:顺着每帧保存的旧 rbp 链一路往上爬(frame pointer chain)。开 -fomit-frame-pointer 优化后这条链消失,只能靠调试信息重建——性能工具(perf)采样时的栈展开质量直接受此影响。
「参数总是压栈传递」是 32 位时代的课本记忆:x86-64 下前 6 个整型参数走 rdi/rsi/rdx/rcx/r8/r9,常见调用的汇编里根本没有参数入栈;浮点参数另走 xmm 寄存器,调 printf 这类变参函数前还要把「用了几个向量寄存器」写进 al。带着旧记忆读 64 位汇编,会满世界找不存在的 push。
尾调用优化(TCO)在这张图上一目了然:如果调用是函数的最后一件事,何必新开一帧再回来?直接 jmp 过去复用当前帧——递归变循环,栈不再增长。函数式语言依赖它,C 编译器 -O2 时也会做。

八十年代最激烈的架构之争,结局出人意料:两派在微架构层殊途同归,差异只剩下契约表面。理解这段历史比背对比表格有价值得多。

当年的分歧

  • CISC(x86、VAX)的逻辑:内存又贵又慢,指令应该「浓缩」——一条指令干一串事(VAX 甚至有多项式求值指令),代码密度至上。
  • RISC(MIPS、SPARC,源于 Patterson/Hennessy 的观察)的逻辑:测量显示编译器根本不用那些复杂指令,80% 的执行是 20% 的简单指令。不如只留简单指令:定长编码、只有 load/store 碰内存、大寄存器堆——一切为流水线让路。

终局:表面 CISC,内芯 RISC

  • Pentium Pro(1995)起,x86 CPU 在译码级把每条 CISC 指令拆成 1~4 个 RISC 风格的微操作(μops),后端流水线、乱序引擎处理的全是 μops。从此「x86 是 CISC」只在译码器里成立。
  • 反方向同样发生:ARM 加入了越来越多复杂指令(一条指令载入多寄存器、加密轮函数),RISC-V 靠扩展堆功能。纯粹主义两边都放弃了
  • 真正剩下的差异:译码功耗与并行度。变长的 x86 译码又费电又难并行(这是 Apple M 系列每周期能译 8+ 条而 x86 长期 4~6 条的原因之一);以及契约的商业属性——x86 双寡头授权、ARM 收费授权、RISC-V 免费开放。
RISC 的 R(Reduced)精简的是每条指令的复杂度——单周期可流水、只有 load/store 碰内存——而不是指令的数量:ARMv8 加上 SIMD、加密等扩展后指令数以千计,手册比早年 x86 还厚。照字面把 RISC 理解成「指令很少」,解释不了任何一个现代 RISC ISA。
μop 视角能解释很多现象:x86 的 rep movsb(内存拷贝指令)在新 CPU 上突然变快,是因为微码把它重新实现了(ERMSB);某些「一条」指令特别慢,是因为它拆出了几十个 μops。指令数 ≠ 工作量,μops 才是。

CPU 微架构:从时钟到乱序执行

契约之下,CPU 怎么真正把指令跑起来、并且一代比一代快?这一章顺着历史与逻辑的共同脉络走:单周期实现 → 流水线 → 冒险与转发 → 分支预测(跳不跳,以及跳去哪)→ 乱序执行 → 超标量与 SMT,终点是解剖一颗现代 CPU。

看懂 CPU 只需要三个电路概念。花十分钟建立它们,后面所有微架构图都会变得透明。

三个概念

  • 组合逻辑:输出只由当前输入决定的电路(加法器、译码器、多路选择器 MUX)。信号穿过需要时间——门延迟,路径越长越慢。它没有记忆。
  • 时序逻辑:加上触发器/寄存器——只在时钟边沿把输入「拍照」存下来的元件。有了它电路才有状态,才能记住「现在执行到哪了」。
  • 时钟:全芯片统一的节拍器。每个时钟周期做同一件事:寄存器输出 → 穿过一片组合逻辑 → 结果在下一个边沿存进寄存器。周期必须长过最慢的那条组合路径(关键路径),否则存进去的是没算完的垃圾。

这就是主频的全部秘密

  • 「5 GHz」的意思是:这颗芯片最长的寄存器到寄存器路径能在 0.2ns 内稳定。想提频只有三条路:让晶体管更快(工艺)、把长路径切短(加流水级,下一张卡)、砍掉复杂逻辑(简化设计)。
  • CPU 从此可以理解为:一大坨组合逻辑,被时钟驱动的寄存器切成一段一段。微架构设计就是决定怎么切、切几段、每段放什么。
别把「时钟边沿」当成计算发生的时刻——边沿只做「存」这一个动作,计算发生在两个边沿之间、信号穿过组合逻辑的传播过程中。由此推论:提频的敌人是最慢的那一条路径而非平均路径,99% 的路径再快,只要有一条超长路径,整颗芯片的频率就被它按住。
判别口诀:组合逻辑管「算」、寄存器管「存」、时钟管「拍」。任何微架构图都能拆成「寄存器 → 组合逻辑 → 寄存器」的三明治——看图先找寄存器在哪,两排寄存器之间夹的那片逻辑就是一段关键路径候选。

先造出「能跑但不快」的 CPU:每个时钟周期完整执行一条指令。所有教学 CPU(包括你在 nand2tetris 里搭的)都是它。

数据通路:指令流过的管道

PC → 指令存储器 → 译码/读寄存器堆 → ALU → 数据存储器 → 写回寄存器
 ↑___________________ PC+4 或分支目标 ____________________|
  • 每条指令都流经这条通路,不同指令用到不同的段:加法跳过数据存储器;load 用满全程;分支用 ALU 算比较、结果改 PC。MUX(多路选择器)+ 控制信号决定每一站的数据从哪来、到哪去。
  • 控制器就是「看操作码、拉控制线」的组合逻辑:见到 load 就拉起「存储器读」「写回选存储器」;见到分支就拉起「PC 选分支目标」。教材说的「硬布线控制器」即此——一张真值表而已。

它为什么慢——以及两条出路

  • 时钟周期必须容纳最慢的指令(load 要走完取指+译码+ALU+访存+写回全程)。加法明明一半时间就能完成,也被迫等一整拍——所有指令为最长路径买单。
  • 出路一(历史):多周期——把一条指令拆成几拍,短指令少用几拍。控制器随之复杂化,「微程序控制」正是那个时代管理复杂控制的手段:控制信号不再由门电路拼出,而是存成控制存储器里的「微指令」逐拍读出。它作为设计方法已退场,但以 μcode 形式活到今天——x86 最复杂的指令仍由微码序列实现,Intel/AMD 靠微码更新修 CPU bug(Spectre 的部分缓解就是这么打上的)。
  • 出路二(现代):流水线——不缩短单条指令的时间,而是让多条指令重叠。下一张卡。
「CPI=1」听起来很美,别据此以为单周期快——CPI 只数拍数,不管一拍有多长。单周期的一拍要装下 load 的全程(取指+译码+ALU+访存+写回),同样工艺下它的主频只有五段流水线的几分之一,三个因子乘出来反而最慢。
把性能铁律记牢:CPU 时间 = 指令数 × CPI × 时钟周期。单周期赢在 CPI=1、输在周期被最慢指令拉满;多周期和流水线的所有花样都是在这三个因子间腾挪。分析任何微架构改动,先问它动了哪个因子、有没有把另一个因子搞坏——屡试不爽。

上一张那台单周期 CPU 的时钟周期被最慢的那条指令绑死,想再快只剩一条路:让多条指令同时待在机器里。洗衣机 30 分钟、烘干机 30 分钟:洗完再烘,两小时洗两批;洗第二批的同时烘第一批,两小时洗四批。流水线不让任何一件事变快,却让整体吞吐翻倍——这是全书性价比最高的一个思想。

经典五段

       周期:  1    2    3    4    5    6    7
指令1:       IF → ID → EX → MEM → WB
指令2:            IF → ID → EX  → MEM → WB
指令3:                 IF → ID  → EX  → MEM → WB

IF 取指 | ID 译码读寄存器 | EX 执行 | MEM 访存 | WB 写回
  • 把单周期的长通路切成五段,段间插寄存器:关键路径变成五分之一,主频立刻能提五倍;稳态下每周期完成一条指令(CPI→1),尽管每条指令仍要走 5 拍。
  • 延迟没变(一条指令还是 5 拍),吞吐变了(每拍出一条)——回忆第 00 章:这是典型的「牺牲的延迟为零、吞吐纯赚」的设计,所以从 1980 年代起没有任何严肃 CPU 不用流水线。

为什么不切 50 段

  • 段间寄存器本身有开销(写入时间),切得越细开销占比越大;更致命的是流水线越深,指令间的依赖和分支带来的「断流」代价越大(下两张卡)。Pentium 4 曾冲到 31 级流水追求高主频,结果分支预测一错要冲掉 31 级——功耗爆炸、性能倒挂,成为工业史上著名的反面教材。现代设计稳定在 12~20 级。
别把五段流水读成「同时执行 5 条指令 = 5 倍算力」——任何时刻仍只有一条指令在 EX 段真正做算术(单发射),重叠的是不同阶段,执行单元并没有复制。「加深」(切更多段、提频)和「加宽」(一拍发多条,超标量那张卡)是两个独立的维度,混着说就乱了。
一句话判据:延迟不变(甚至因段间寄存器略增)、吞吐 ×k——满足它的就是流水线思想。CPU 五段、网卡收发、工厂产线、CI 流水线全是同一道数学题;反过来,凡是承诺「单个请求更快」的优化就一定不是流水线在起作用。

流水线的美好假设是「指令之间互不相干」,现实是它们抢资源、传数据、还互相决定谁执行。三种冲突(冒险)各有解法,解法的思想比结论重要。

结构冒险:抢硬件

  • IF 要读内存取指、MEM 也要读写内存——同一拍抢同一个存储器。解法简单粗暴:把资源复制一份——指令和数据分开的 L1 Cache(哈佛式 L1)就是这么来的。

数据冒险:等结果

add x1, x2, x3    // x1 在第5拍才写回
sub x4, x1, x5    // 但它第3拍就要读 x1 → 读到旧值!
  • 关键观察:结果其实第 3 拍末尾(EX 出口)就算出来了,只是还没写回寄存器堆。那就别绕寄存器堆——直接从 ALU 出口拉一根线到 ALU 入口:转发(forwarding)。多数数据冒险由此零代价化解。
  • 唯一转发救不了的:load-use——load 的数据 MEM 段末尾才到,下一条指令的 EX 等不到,必须硬插一个气泡(stall)。所以编译器会把 load 和使用它的指令隔开(指令调度),你在 -O2 的汇编里看到的「怪异顺序」多半是这个。

控制冒险:不知道下一条是谁

  • 分支要到 EX 段才知道跳不跳,可 IF 每一拍都得取新指令——取哪条?等答案就是每个分支浪费两三拍;程序平均每 5~7 条指令一个分支,等不起。
  • 解法是:猜一个方向继续取,猜错再冲掉重来——分支预测,下一张卡。它重要到值得单独一张。
别把「气泡 stall」和「冲刷 flush」混为一谈:stall 是——指令都是对的,只是暂停推进一拍;flush 是作废——猜错路径上已经取进来的指令整批清掉、全部白干。数据冒险靠转发+stall,控制冒险猜错靠 flush,两者代价差一个量级(1 拍 vs 十几拍),这正是分支预测值得单独一张卡的原因。
教材里的吞吐率/加速比公式(k 段 n 条指令,S = kn/(k+n−1))描述的是无冒险的理想流水线。真实 CPU 的效率恰恰由「冒险处理得多好」决定——这也是为什么现代微架构的复杂度几乎全花在冒险的三个解法(转发网络、预测器、乱序引擎)上。

上一卡三种冒险里控制冒险最贵——流水线越深,猜错一次作废的周期越多。所以现代 CPU 干脆不等着算清楚:现代 CPU 每次遇到分支都先赌一个方向、沿着赌的路径继续全速执行,赌对了白赚,赌错了把几十条推测执行的指令全部作废。预测准确率 95%+ 是现代性能的支柱之一。

预测器的进化

  • 2 位饱和计数器:每个分支记一个 4 状态计数(强跳/弱跳/弱不跳/强不跳),跳则加、不跳则减。妙处是连错两次才改主意:循环末尾那一次「不跳」不会毁掉「这个分支基本总跳」的判断。
  • 历史模式:很多分支的行为取决于「前几个分支怎么走的」(如交替跳/不跳)。把最近分支结果串成位串作为索引,同一条分支在不同「上下文」里可以有不同预测——两级自适应预测。
  • 现代 TAGE 类预测器:多张表用不同长度的历史,长历史命中优先——本质是自动学习「这个分支依赖多远的过去」。数万比特的状态、95~99% 的准确率,是 CPU 里最像机器学习的部件。

写代码时它真实存在

// 对随机数组: if (a[i] < 128) sum += a[i];
未排序: 预测器五五开瞎猜 → 每次错猜罚十几个周期 → 慢
排序后: 前半全不跳后半全跳 → 预测几乎全对 → 快       // 著名的 StackOverflow 问题
  • 规避手段:热路径消除难预测分支——用条件传送(cmov)、位技巧、查表替代 if(branchless 编程);把可预测性还给硬件(排序、分离冷热路径)。perf stat 的 branch-misses 一栏直接告诉你赌输了多少。

数字比论证有用(AMD Zen 4,3.3 万元素扫 2000 遍)

                    耗时     branch-misses      IPC
未排序(随机数据)   0.053 s   1 627 336 (1.24%)   1.82
排序后               0.026 s     179 393 (0.13%)   3.66
                    ↑ 快约 2 倍   ↑ 少约 9 倍      ↑ 翻倍
  • 三个数字要一起看:耗时只是结果,branch-misses 是原因,IPC 才是机制——预测对了流水线不断流,同样的指令数在一半的周期里跑完(1.82 → 3.66)。这就是「分支预测是现代性能支柱」的可测量形态。
  • 但那个流传的「快 5~6 倍」已经过时了。那是 2012 年提问时的硬件;本机只差约 2 倍——现代 TAGE 类预测器连相当随机的数据也能学出些规律(随机数据上仍只错 1.24%)。方向恒定、倍数缩小:预测器越强,这个经典演示的效果就越弱。自己跑一遍看你的机器是多少。
  • 测这个实验有个必踩的坑:编译器会把 if (a[i] < 128) sum += a[i] 编成条件传送(cmov),分支根本不存在、效应随之消失。要看到分支效应必须显式关掉:gcc -O2 -fno-if-conversion -fno-tree-loop-if-convert,并用 objdump -d | grep cmov 确认真的没有 cmov。这恰好从反面证明了 branchless 化的威力。
预测错误的代价 = 流水线深度级别的冲刷(现代约 15~20 周期)。但不要闻分支色变:可预测的分支几乎免费(预测对了无代价),只有「数据驱动的随机分支」才值得 branchless 化——先测量,再优化。
亲眼看这个赌局:对同一段 if (a[i] < 128) sum += a[i]; 分别用随机数组和排序后数组计时,差距可达数倍。测不出来先 gcc -S 看汇编——现代编译器常把这种 if 编译成 cmov(branchless),分支消失、效应随之消失,这恰好从反面证明了 branchless 化的威力。

上一张卡只回答了分支的一半问题——跳不跳。另一半同样得在取指阶段猜出来:跳去哪。直接跳转的目标写死在指令里,而间接跳转(函数指针、虚函数、switch 跳转表、解释器的字节码分发)的目标随数据变,靠的是另一套预测器。

两个部件:BTB 与返回地址栈

  • BTB(分支目标缓冲):取指阶段手里只有 PC,指令还没译码,CPU 甚至不知道这条是不是分支——BTB 就是「按 PC 索引、告诉你上次这条分支跳去了哪」的表。直接分支的目标恒定,一学就准;间接分支学不学得会,全看目标序列有没有规律。
  • 返回地址栈(RAS)ret 也是间接跳转(目标在栈上),但它有完美的结构规律——call 时把返回地址压进一个十几层的小硬件栈,ret 时弹出来当预测,准确率接近满分。代价是call/ret 必须配对:手写汇编用 jmp 冒充返回、协程切换栈、setjmp/longjmp 跳出深层调用,都会把这个栈弄错位,之后连续多次返回全部错判。

同一个调用点,五种目标序列

5000 万次间接调用,四个候选函数,只改「每次调哪个」的序列(AMD Zen 4,gcc -O2):

目标序列耗时branch-misses占间接调用
永远同一个0.063 s1.8 万≈ 0%
两个交替 0,1,0,1…0.420 s4997 万≈ 100%
两个中随机0.241 s2505 万50%
四个轮转 0,1,2,3…0.390 s4984 万≈ 100%
四个中随机0.316 s3748 万75%
  • 最反直觉的是那两行「轮转」:完全规则的 0,1,2,3,0,1,2,3… 反而比随机更慢、错判率顶到 100%。
  • 五行数字与一个极简模型逐条吻合:「预测这次的目标 = 上次的目标」。永远同一个 → 全中;两个交替 → 每次都错;两个随机 → 对一半;四个轮转 → 每次都错;四个随机 → 错四分之三。五个比例(0 / 100 / 50 / 100 / 75)没有一个是估出来的,都是这个模型的直接推论。
  • 为什么这么规则的轮转学不会?因为这段循环里除了循环回边没有任何别的条件分支,全局分支历史是一串常量。现代间接预测器靠「历史 + PC」区分同一条间接跳转的不同上下文,历史退化成常数时,四次调用在预测器眼里完全一样。规律藏在数据里而不是控制流里,预测器就看不见。
  • 错判一次多花多少:永远同一个是 1.26 ns/次,两个交替是 8.4 ns/次——每次错判约 7 ns,本机 4.2 GHz 下约 30 个周期,比上一张卡说的方向错判(15~20 周期)更贵。合理:目标错判要等地址算出来才发现,发现得更晚,冲掉的指令更多。
perf statbranch-misses 把「方向错判」和「目标错判」合并计数。看到这一栏高就去排序数组、改 branchless,可能完全打错靶——先分清错的是哪一种(细分事件名各家不同,AMD 与 Intel 的 PMU 事件表不通用)。反过来也别把「间接调用慢」当教条:目标稳定时它没有额外开销,间接调用真正的代价往往不是那次跳转,而是它挡住了内联,连带后面所有跨函数优化都做不成。
这张卡的工程含义只有一句:虚函数和函数指针本身几乎免费,贵的是「同一个调用点的目标随数据乱跳」——里单一目标那一行错判率 0.01%,和直接调用没有可测差别。所以优化手段全是「制造单态性」:JIT 的内联缓存、C++ 的去虚拟化、把同类对象排在一起批量处理(ECS、列式布局)。解释器主循环用 computed goto(每个字节码末尾各自跳向下一个)比一个大 switch 快,也是同一个道理——把一个「什么目标都可能」的跳转点,拆成几百个各自有规律的跳转点,BTB 才有机会分别学会。

分支预测解决的是「取指不要停」,可指令进了流水线之后还会因为等数据而堵住。按程序顺序执行时,一条等内存的 load 会堵住后面所有不相干的指令。乱序执行的原则只有一句:谁的操作数先备齐谁先算,最后再假装是按顺序算的

三个机制,一台机器

  • 寄存器重命名:两条指令用了同一个寄存器名但数据无关(WAR/WAW,「名相关」),给它们各发一个物理寄存器别名,假冲突消失。ISA 的 16 个寄存器名背后是 200+ 个物理寄存器。剩下的 RAW(真数据依赖)无法消除——只能转发和等待。
  • 保留站/发射队列(Tomasulo 的遗产):译码后的指令带着「我在等谁的结果」进队等待;每个执行结果广播到所有等待者,操作数凑齐的指令立即发射——数据流驱动取代程序顺序。
  • 重排序缓冲 ROB:乱序算完 ≠ 乱序生效。所有指令按程序顺序进入 ROB,执行完打上「完成」标记,但只有排到队头才允许提交(真正改寄存器/内存)。于是异常发生时,未提交的后续指令全部一笔勾销——对外呈现的永远是「精确的、按序的」状态,契约得以维持。

深入理解

  • 乱序的真正目标是容忍内存延迟:一条 miss 到 DRAM 的 load 要等几百周期,乱序窗口(现代 300~600+ 条指令)让 CPU 在等待期间继续挖掘后面的独立工作。窗口大小基本决定了「能藏住多深的延迟」——这是各家旗舰核心比拼 ROB 大小的原因。
  • 推测(分支预测)+ 乱序组合起来意味着:CPU 时刻在执行可能根本不该执行的指令,只是结果不提交。「不提交」不等于「无痕迹」——推测执行会留下 Cache 痕迹,这正是 Spectre 的入口(第 08 章)。
别担心乱序会把单线程程序的结果算乱——ROB 按序提交保证对外语义与程序顺序完全一致,调试器里看到的永远是按序状态。乱序真正「漏出来」的只有两处:多核之间看到的内存访问顺序(要靠内存屏障约束)和推测执行留下的微架构侧信道——单线程代码不需要为乱序加任何屏障。
乱序引擎的本事能亲手量出来:同样次数的浮点加法,s += a[i] 串成一条依赖链,改成 4 个独立累加器最后再合并,耗时立刻降为几分之一——依赖链一断,乱序引擎就能把多个加法同时挂上执行单元。高性能库的循环里堆满累加器变量,就是在给乱序引擎喂并行度。(4 亿次浮点加,关掉向量化):1 个累加器 0.269 s,4 个累加器 0.067 s——恰好快 4.0 倍。这个倍数不是玄学:浮点加法有几个周期的延迟但完全流水,串成一条依赖链时每次加法都要等上一次的结果,执行单元大部分时间在空转;拆成 4 条独立链,4 次加法就能同时在流水线里。倍数上限 ≈ 加法延迟 ÷ 发射间隔,所以再加到 8 个累加器收益就趋于饱和——自己试试第 8 个还有没有提升。

流水线让 CPI 逼近 1,要突破 1 就得每拍发射多条指令——超标量。而当一个线程喂不饱这么宽的机器时,就让两个线程共享它——SMT(Simultaneous Multi-Threading,同时多线程,即 Intel 说的超线程)。

超标量:把机器加宽

  • 取指、译码、发射、执行、提交全部并行化:现代大核每拍可译码 4~8 条、发射 6~12 个 μop 到十来个执行端口(几个 ALU、几个访存单元、几个向量单元)。
  • 宽度的收益递减:程序里天然的指令级并行(ILP)有限——真依赖链、分支、访存把可并行的指令隔开。这就是为什么没有 20 发射的 CPU:不是造不出,是喂不饱
  • VLIW(超长指令字)是另一条路:让编译器提前打包并行指令,硬件只管照单执行。通用市场上败了(Itanium——编译器猜不准运行时行为),但在 DSP(数字信号处理器)/AI 加速器里活得很好(负载规则、编译期可知)。

SMT/超线程:两个线程填一台机器

  • 一个线程停顿(cache miss、依赖链)时,它占的执行端口全在空转。SMT 给一个物理核配两套架构状态(寄存器、PC),两个线程的指令混在同一个乱序引擎里争抢执行端口——空隙被另一个线程填上。
  • 收益视负载而定:两个访存密集线程 +20~30% 吞吐;两个算力密集线程互相踩踏,可能负收益。所以 HPC 集群常关超线程,Web 服务器常开。
  • 它是「一核当两核报」的营销来源:OS 看到 2 个逻辑 CPU,但执行资源只有一份。
别把「8 宽译码 / 6 发射」读成 IPC=6~8——那是硬件上限,不是均值。真实程序的 IPC 常在 1~2 之间徘徊:依赖链、cache miss、难预测分支把可并行的指令隔开,宽度只在指令流「顺」的片段里兑现。同理,SMT 的两个逻辑核加起来也远到不了两个物理核的吞吐。
Flynn 分类法按「指令流 × 数据流」各单/多把并行分成四象限(SISD 单指令单数据 / SIMD 单指令多数据 / MISD / MIMD 多指令多数据),如今读法是:单核乱序超标量 = 榨干 SISD 的 ILP;向量指令与 GPU = SIMD(第 08 章);多核 = MIMD;SMT 是在 SISD 硬件上模拟 MIMD 接口。

把前面所有零件装回一颗真实芯片,看看 2020 年代的高性能核长什么样、指标差距从哪来。

前端(按序)→ 后端(乱序)→ 提交(按序)

前端: 分支预测 → 取指 → 译码(x86: 变长切分+μop转换, 带μop缓存)
         ↓ 每拍 4~8 μops
后端: 重命名 → 发射队列 → 执行端口×10~20(ALU/AGU/向量/访存)
         ↓ 乱序完成
提交: ROB 按序退休(每拍 4~8 条) → 架构状态更新
  • 旗舰核的数量级(2020s):ROB 300~600 条、物理寄存器 300+、L1 32~192KB、L2 1~3MB、乱序窗口能藏住一次 DRAM miss 的大部分延迟。
  • IPC 差距的来源:Apple M 系列宽前端(8 译码)+ 巨大 ROB + 巨大 L1,同频性能领先——「宽而稳」路线;传统 x86 走「窄一点但频率冲 5~6 GHz」路线。两者本质是在功耗曲线上选了不同的点:频率的功耗代价是超线性的(P∝CV²f,提频还得提压),宽度的代价是面积。

大小核:一颗芯片上的两种哲学

  • 性能核(宽、深、乱序、耗电)跑冲刺负载,能效核(窄、浅、省电)跑常驻后台——因为性能的最后 20% 要花 3 倍功耗,混搭比全大核能效高得多(下表是同一颗芯片上的两种哲学)
    性能核 P-core能效核 E-core
    设计取向宽、深、乱序,能冲高频窄、浅,省电
    面积 / 功耗大 / 高小 / 低(同面积可塞多个)
    该跑什么前台冲刺负载、单线程敏感任务常驻后台、批量并行任务
    放错核的代价后台任务霸占大核 → 费电发热、逼着降频拖累全局前台任务困在小核 → 直接卡顿
    。手机十年前就这么干(ARM big.LITTLE),Intel 2021 年跟进,OS 调度器为此专门改造(联动 OS 页调度章)。
看规格别被逻辑核骗了:nproclscpu 的「CPU(s)」计的是逻辑 CPU(含 SMT),真核数 = Core(s) per socket × Socket(s),lscpu 里两行并排可对照。容量规划把逻辑核当物理核,算力会高估近一倍——「16 核」和「8 核 16 线程」不是一回事。
想看真实数据:uops.info 收录了每条 x86 指令在各代微架构上的延迟/吞吐;Agner Fog 的优化手册是微架构细节的圣经;AnandTech/Chips and Cheese 的架构深潜文章把每代新核的 ROB、端口、缓存参数扒得一清二楚。

存储层次与 Cache

上一章那颗乱序超标量核一拍能算四条指令,可 DRAM 一次访问要几百拍——CPU 越快,一碰内存就越是干等。这道速度鸿沟是现代体系结构的头号矛盾。这一章从鸿沟本身讲起:SRAM(静态存储,用来做 Cache)与 DRAM(动态存储,用来做主存)的物理本质、Cache 的组织与策略,落脚在「怎么写出 Cache 友好的代码」——这是本课对日常编程最值钱的回馈。

先看数字。把 CPU 的一个周期想象成 1 秒,各级存储的延迟会变成这样:

延迟的数量级(换算成人类时间)

寄存器      ~0.3ns    「1 秒」     手边的东西
L1 Cache    ~1ns      「4 秒」     桌上翻笔记
L2 Cache    ~4ns      「15 秒」    书架上找书
L3 Cache    ~15ns     「1 分钟」   隔壁房间拿资料
DRAM 主存   ~80ns     「5 分钟」   楼下便利店
NVMe SSD    ~50µs     「2 天」     跨城快递
机械磁盘    ~5ms      「6 个月」   海运
网络往返    ~0.5ms+   「2 周起」   国际邮件
  • 结论自己浮现:miss 到下一层的代价是数量级的。程序快慢往往不取决于算了多少,而取决于数据在金字塔哪一层。

本机真实参数(getconf -a / lscpu,AMD Ryzen 7 8845H)

容量相联度行大小归属
L1d32 KB / 核8 路64 B私有
L1i32 KB / 核64 B私有
L21 MB / 核8 路64 B私有
L316 MB16 路64 B全核共享
  • 组数可以当场验算:组数 = 容量 ÷(行大小 × 路数)——32 KB ÷ (64 × 8) = 64 组,index 恰好 6 位。拿你自己机器的 getconf -a | grep -i cache 代进去算一遍,「组」和「路」就再也不会混。
  • 顺带看见「口径不统一」这个坑的实物:同一台机器上 getconfLEVEL1_DCACHE_SIZE 32768(单核视角),lscpuL1d cache: 64 KiB (2 instances)所有实例之和),而产品页会宣传 L3「16 MB 大缓存」(全核共享总量)。三个数字都对,说的不是同一件事。

金字塔为什么可行:局部性

  • 快的存储必然小(SRAM 一个 bit 要 6 个晶体管、还得离核近),大的必然慢——物理定律,没有两全。层次结构的赌注是程序行为的两条经验律:时间局部性(刚用过的很快再用——循环变量)、空间局部性(用了这个就会用旁边的——数组遍历、顺序取指)。
  • 只要局部性成立,「小而快的层」就能拦截绝大多数访问,整个金字塔用 5% 的成本呈现出 95% 的快。局部性差的程序(随机指针追逐)则跌穿金字塔,直面 DRAM 的真实延迟——快慢差距可达百倍,这就是数据结构选择背后的硬件真相。
这张表比的是延迟,别把「SSD 比 DRAM 慢上千倍」推广到一切场景——带宽是另一个维度:NVMe 顺序读带宽与 DRAM 只差一个数量级左右,远小于延迟的千倍差。所以「随机小 IO 改成批量顺序 IO」能救回大部分性能:延迟墙绕不过去,带宽墙可以靠批量摊薄。
这张延迟表值得记住数量级(Jeff Dean 的「每个程序员都该知道的延迟数字」)。做系统设计时它是直觉的来源:为什么加缓存、为什么批量化、为什么顺序读远快于随机读——全是这张表的推论。

金字塔的上下两层用了两种物理原理迥异的存储电路,各自的「不得已」塑造了系统设计。

SRAM:静态,即「通电就稳」

  • 一个 bit = 6 个晶体管锁成的双稳态(两个反相器咬住对方)。读写快(亚纳秒)、不需要刷新;代价是面积大、贵——所以只用来做 Cache 和寄存器堆,容量以 KB/MB 计。

DRAM:动态,即「会漏电、要续命」

  • 一个 bit = 1 个晶体管 + 1 个电容,有电荷=1。密度是 SRAM 的 6 倍以上,便宜——所以主存用它,容量以 GB 计。
  • 电容漏电,几十毫秒就丢数据 → 必须周期性刷新(逐行读出再写回),内存控制器自动做,代价是几个百分点的带宽和读写间隙。「动态」之名即由此来。
  • 读取是破坏性的:读一行会放掉整行电荷,读完必须写回。这行数据读出后暂存在行缓冲(row buffer)里——连续访问同一行(行命中)快,跳到别的行要先关旧行再开新行(行冲突)慢好几倍。DRAM 也有「局部性」,顺序访问快于随机访问在 DRAM 层面又应验一次。

深入理解

  • DDR 的含义:时钟上升沿和下降沿都传数据(Double Data Rate),DDR4/DDR5 逐代提升的是接口带宽;而核心延迟(~50ns)二十年几乎没变——电容充放电的物理极限。带宽在涨、延迟不动,这决定了优化方向:硬件靠预取和并行掩延迟,软件靠顺序化和批量化吃带宽。

非易失存储:断电不忘的底层

  • SRAM 和 DRAM 都断电即失忆(易失性),所以金字塔底还得有一个非易失层兜底,和上面拼速度的两位分工完全不同——它换来的是「掉电还在」。
  • Flash/SSD:按块擦写、读快写慢,负责数据的长期落盘;主板上那颗存固件的 ROM/Flash 则保证开机瞬间 CPU 手里就有代码可取(只能预先固化)。层层易失、最终落到非易失,存储金字塔到此闭环。
选内存条别被 CL 数字吓到:CL 是周期数不是纳秒,实际延迟 = CL × 周期时间。DDR5 的 CL 普遍比 DDR4 大一倍左右,但周期也短了一半,绝对延迟基本没变——这正是「带宽逐代翻倍、延迟原地踏步」在参数表上的样子。
记一对数字就能推出全部性质:SRAM 一个 bit 用 6 管(双稳态,通电就稳)→ 面积大、贵、快 → 做 Cache;DRAM 一个 bit 用 1 管 1 容(电荷会漏)→ 密、便宜、要刷新 → 做主存。名字里的 Static/Dynamic 说的是「数据怎么保持」,不是速度。
SRAMDRAM
一个 bit 的电路6 个晶体管锁成双稳态1 个晶体管 + 1 个电容
要不要刷新不要(通电就稳)(电容漏电,几十 ms 就丢)
密度 / 成本低 / 贵高(约 6 倍以上)/ 便宜
读取是否破坏数据不破坏破坏——读完必须写回
典型延迟亚纳秒约 50 ns(二十年几乎没变)
用在哪寄存器堆、各级 Cache(KB~MB)主存(GB)

既然 SRAM 快而贵、DRAM 慢而密,夹在中间的 Cache 就得用最少的 SRAM 接住最多的访问——第一个要解决的是查找。Cache 要用硬件在 1~4 个周期内回答「这个地址的数据我有没有」。组织方式就是围绕这个查找问题的三次逼近。

基本单位:Cache 行(64 字节)

  • Cache 不缓存单个字节,而是按64 字节的行整块搬运——赌空间局部性:你要了第 1 个字节,大概率马上要第 2~64 个。「碰一个字节 = 搬 64 字节」是本章后面所有优化的公理

放哪、怎么找:三种方案

地址切三段:  [ Tag | 索引 index | 行内偏移 offset ]
                      ↓              ↓ log2(64)=6 位
              选中某一组         定位行内字节
组内比较 Tag → 相等即命中
组织方式一个地址能放几个坑查找代价冲突 miss用在哪
直接映射1(index 直接定行)最低(比一次 Tag)——两个热点撞坑就互相踢早期 Cache、某些 L1i
全相联任意(哪儿都能放)最高——并行比较所有 Tag最低只有 TLB 这种几十项的小结构
组相联路数(4~16)中(并行比较组内几个 Tag)接近全相联现实答案:L1 典型 8 路、L3 典型 16 路
  • 直接映射:每个地址只有一个坑(index 直接定行)。查找快、电路省,但两个热点地址若撞坑会互相踢——冲突 miss。
  • 全相联:任何行进任何坑,冲突最少,但每次查找要并行比较所有 Tag——只有 TLB 这种几十项的小结构用得起。
  • 组相联(现实答案):index 选组、组内 4~16 路全相联。冲突率接近全相联、成本接近直接映射。现代 L1 典型 8 路、L3 典型 16 路。

深入理解

  • 「index 用地址中间位而不是高位」有讲究:高位做 index 会让相邻的行落进同一组(顺序遍历自己跟自己冲突),中间位让连续地址均匀铺满各组。
  • 典型实例:以 4096 步长访问的循环(比如遍历矩阵某一列、且行宽恰为 4KB)会让所有访问的 index 相同——整个 Cache 只有一组在工作,等效容量骤降为几 KB。「2 的幂步长」是性能玄学事故的高发区。
「8 路组相联」常被读反:8 是每组的坑数(路,way),不是组数。组数 = 容量 ÷(行大小 × 路数)——如 32KB L1、64B 行、8 路 → 64 组,index 恰好 6 位。拿 getconf 的输出代入这条公式验算一遍,组和路就再也不会混。
亲眼看这个:getconf -a | grep -i cache 直接打印各级容量、相联度和 LINESIZE(64)。行大小还能亲手测出来,本机(AMD Zen 4,L1d 32 KB / 行 64 B)扫 64 MB 数组的结果:
stride   访问次数     总耗时    每次访问
    1   67 108 864   23.2 ms   0.34 ns
    8    8 388 608    3.0 ms   0.36 ns   ← 每次访问的成本几乎没变
   32    2 097 152    1.7 ms   0.83 ns
   64    1 048 576    1.7 ms   1.63 ns   ← 总耗时与 stride=32 完全相同
最后两行是全表的重点:stride 32 的访问次数是 stride 64 的两倍,总耗时却一模一样——因为两者要取回的cache 行数完全相同。这就是「碰一个字节 = 搬 64 字节」最干净的证据:决定耗时的是行数,不是访问次数。也因此 stride ≤ 8 时每次访问只要 0.34 ns(一次行填充被 8~64 次访问摊薄),而 stride 跨过 64 B 后每次访问都要一条新行,单次成本涨到 1.63 ns 以上。

组满了要腾地方(替换),CPU 写数据了要不要立刻同步下层(写策略)——两个策略问题,答案都由「代价与概率」决定。

替换:踢谁

  • 理想答案是「踢最久之后才会用的」(Belady 最优)——但那需要预知未来。可实现的近似是 LRU(踢最久没用过的):赌时间局部性,最近没用的以后多半也不用。
  • 真实硬件连精确 LRU 都嫌贵(16 路要维护全序),用伪 LRU(树状近似)或动态插入策略(DRRIP 等,抗扫描污染:一次性流过的大数组不该把常驻热数据踢光)。方向是一致的:用最少的记账位数逼近「保热踢冷」

写:何时告诉下层

  • 写直达(write-through):每次写同步写穿到下层。数据永远一致、掉电不怕,但写带宽被放大——配写缓冲吸收突发。
  • 写回(write-back):只写 Cache、标记「脏」位,被替换时才写回下层。同一行写一百次只回写一次,省带宽——现代 CPU Cache 的普遍选择,代价是「内存里的值可能是旧的」,多核一致性因此复杂化(第 08 章 MESI 的 M 态就是「脏」的多核版本)。
  • 写 miss 时:写回配「写分配」(先把行搬上来再写),写直达配「非写分配」(直接写下层)——搭配逻辑:会反复写的才值得搬上来。
    写直达 write-through写回 write-back
    何时写下层每次写都穿透被替换时才写(脏位标记)
    写带宽被放大(要配写缓冲吸收突发)省(同一行写一百次只回写一次)
    下层数据是否最新永远最新可能是旧的
    多核一致性简单复杂(MESI 的 M 态就是「脏」的多核版)
    写 miss 搭配非写分配(直接写下层)写分配(先把行搬上来)
    现代 CPU少用普遍选择
别把 CPU Cache 的「写回」和 OS 页缓存的「延迟写盘」混成一件事:fsync、掉电丢数据的讨论说的是后者(DRAM → 磁盘那一层);CPU Cache 的脏行落在 DRAM 里,而 DRAM 本身就易失,谈不上持久性问题。两层各有一套「脏标记 + 何时往下写」,骨架相同、层次不同。
同样的两难在软件世界处处重演:Redis 的 LRU/LFU 淘汰、页面置换算法(OS 页)、数据库 buffer pool 的刷脏策略、甚至浏览器缓存——「替换与写回」是缓存问题的通用骨架,硬件只是它跑得最快的实例。

这是全章的现金兑现点:同样的算法复杂度,Cache 友好与否可以差 10 倍。四个模式覆盖了大部分实战场景。

① 按内存布局的方向遍历

// C 的二维数组按行存储
for (i...) for (j...) sum += a[i][j];   // 行遍历: 顺着内存走,每行 64B 用满 → 快
for (j...) for (i...) sum += a[i][j];   // 列遍历: 每次跳 N×4 字节,行行 miss → 慢很多
  • (4096×4096 int,64 MB,关掉向量化以隔离纯 Cache 效应):
    耗时cache-missesmiss 率dTLB-load-misses
    行遍历0.005 s11 8430.33%50 478
    列遍历0.220 s10 846 75814.31%17 084 757
    倍数48×约 900×约 340×
    三列一起看才是完整的故事:耗时差 48 倍是结果,cache-misses 差 900 倍是直接原因,而 dTLB-load-misses 差 340 倍说明列遍历同时把 TLB 也打穿了(每跳 16 KB,几乎每次访问都换页)。课本只讲 Cache,告诉你 TLB 一起在受害。
  • 倍数会因机器而异(内存带宽、Cache 大小、TLB 项数都不同),但方向恒定、且三个指标必然同向恶化。自己跑一遍:perf stat -e cache-misses,cache-references,dTLB-load-misses ./prog

② 数据结构决定局部性

  • 数组顺序扫 = 空间局部性满分 + 硬件预取器全速(它识别顺序模式、提前搬后面的行);链表/树的指针追逐 = 每步一次不可预取的随机访问。N 不大时,O(n) 的数组常胜 O(log n) 的指针结构——复杂度分析假设访存等价,现实里差两个数量级。
  • AoS(结构体数组,struct 的数组)vs SoA(数组结构体,把各字段拆成平行数组):只用到 x 字段却遍历 struct{x,y,z,w} 数组,每 64B 行里 3/4 是无用数据;拆成四个平行数组(SoA),带宽利用率 ×4 且可向量化——游戏引擎 ECS(实体-组件-系统架构)、列式数据库(比如分析型数仓)都是这一个想法。

③ 分块(tiling):让工作集塞进 Cache

  • 大矩阵乘法直接三重循环,B 矩阵每一列都要重新从 DRAM 读;把矩阵切成能装进 L1/L2 的小块、块内算完再换块,同一块数据被复用几十次——高性能 BLAS(基础线性代数子程序库)快出朴素实现 10 倍以上的第一功臣。

④ 多线程:false sharing 假共享

int counter[4];              // 4 个线程各自累加 counter[tid]
// 看似无共享,实则 4 个 int 挤在同一个 64B 行里:
// 每次写都让其他核的该行失效 → 行在四个核间弹跳 → 比单线程还慢
struct alignas(64) { int v; } counter[4];   // 每人独占一行 → 线性加速
  • (4 线程各自 +1 两千万次,先用 &P[0]^&P[1] >> 6 == 0 确认真在同一行):
    同一 cache 行(false sharing)  0.042 s
    各占独立一行(64 B 对齐)      0.009 s   → 快约 5 倍
    四个线程各改各的变量、逻辑上毫无共享,却因为挤在同一条 64 B 行里而被一致性协议来回收发所有权——这是「性能问题可以完全不在算法里」最典型的一个例子
  • 测它有个必踩的坑:累加器必须自己是 volatile。否则 -O2 看出「循环两千万次自增」有闭式解,一步算完,两个版本都是 0.000 s——第一次跑就是这样,差点当成「false sharing 不存在」。凡是拿循环计时的实验,先确认循环真的跑了。
亲别把「Cache 友好」升级成教条:把结构体拆成 SoA、交换循环顺序都会牺牲可读性,只在 profile 点名了这段热点之后才值得做——冷路径上一次几千元素的遍历,行序列序的差别根本不会出现在火焰图里。真要动手计时验证本卡结论,先看 01 章那两个假象坑(-O2 删空循环、首轮缺页)。
全部可用 perf 验证:perf stat -e cache-misses,cache-references ./a.out。先写朴素版记下 miss 数,再改写对比——数字会替这张卡说话。

一层 Cache 填不平百倍的速度鸿沟,现代 CPU 用三级阶梯过渡,每级的设计目标不同。

三级的分工(典型参数,2020s)

L1i/L1d  32~192KB/核   4~5 周期    指令/数据分开(消结构冒险)
L2       1~3MB/核      12~16 周期  私有,统一
L3       数十MB 共享    40~60 周期  全核共享,切片分布
DRAM     数十GB         ~300 周期
  • L1 拼延迟(必须跟上流水线节拍,所以小);L3 拼命中率(拦住去 DRAM 的漏网之鱼,所以大而慢);L2 是缓冲带。
  • 平均访问时间 = 命中时间 + miss 率 × miss 代价,逐级嵌套。改善性能的三个着力点一目了然:降 miss 率(容量/相联度/软件局部性)、降 miss 代价(多级、预取)、降命中时间(小而近)。

深入理解

  • 包含策略:inclusive L3(L1/L2 的内容 L3 必有副本,方便一致性过滤——踢掉 L3 行可保证全芯片无副本)vs non-inclusive/exclusive(不浪费容量,AMD 常用)。这决定「有效总容量」怎么算。
  • 硬件预取器常驻 L1/L2:识别顺序流、跨步流,提前发起搬运。它是顺序遍历快的第二重原因(第一重是行本身),也是「伪随机访问模式偶尔莫名变快」的幕后黑手。写性能敏感代码时,「让访问模式可被预取器识别」与「减少访问量」同等重要。
  • 共享 L3 是多租户干扰的来源:邻居进程刷大数组会把你的热数据踢出去(noisy neighbor)。服务器 CPU 提供按核划分 L3 的机制(Intel CAT)——Cache 也成了需要调度的资源。
读缓存参数要对齐口径:产品页吹的「16MB 大缓存」是全核共享的 L3 总量lscpu 的 L1d 一行给的是所有实例之和(如「64 KiB (2 instances)」= 每核 32KB);getconf 给的才是单核视角。口径不统一,跨 CPU 的对比结论就是错的。
三级分工在 /sys/devices/system/cpu/cpu0/cache/index*/ 里看得一清二楚:每个目录的 levelsizeways_of_associativity 给出参数,shared_cpu_list 暴露归属——L1/L2 通常只列本核(及其 SMT 兄弟逻辑核),L3 列出所有核。教材说的「私有 vs 共享」就写在这个文件里。

延迟难降,带宽可堆——「并行开多家店」的思想从 1960 年代用到今天,只是名字一直在换。

同一个思想的三代形态

  • 多体交叉(教材经典):把主存分成多个独立「体」,连续地址轮流落在不同体上(低位交叉),顺序访问时各体流水错峰工作,带宽 ×体数。若高位选体则连续地址挤在一个体里,白分——「怎么切地址」决定并行度。
  • 现代 DRAM 的内部并行:一根内存条上有多个 bank/bank group,内存控制器把请求交织派发,掩盖每个 bank 的行操作延迟——低位交叉活在每根内存条里。
  • 多通道(channel):CPU 引出 2/4/8 个独立内存通道,双通道≈带宽×2。装两根内存条快于一根大条的原因就在这——这是普通人最常接触的「多体并行」。服务器/HBM(高带宽内存)把通道堆到极致:GPU 的 HBM 上千 bit 位宽,为的就是喂饱几千个核。

带宽也会成为墙

  • Roofline 模型的直觉:程序的算术强度(每搬 1 字节做几次运算)决定它撞哪堵墙——强度低(如向量点积、大多数 AI 推理)撞带宽墙,堆再多核也没用;强度高(矩阵乘)才可能吃满算力。判断「该优化计算还是访存」,先算这个比值。
  • NUMA:带宽和延迟还分远近——多路服务器上每颗 CPU(socket)直连自己那部分内存,访问本地内存快,跨 socket 走互连访问远端内存延迟高约 1.5~2 倍、带宽也打折,这就是「非一致内存访问」。所以多路机器上「线程跑在哪、内存分配在哪」要配对,否则通道堆再多也吃不满。
DDR 标号里的数字是 MT/s(每秒传输次数)不是 MHz:上升、下降沿各传一次,DDR5-6400 的实际时钟只有 3200MHz。算理论带宽用 MT/s × 每次传输字节数(单通道 8B),要是把 MT/s 当成 MHz 再「×2」,带宽会凭空翻倍。
带宽可以自己测:顺序扫一个远大于 L3 的数组并计时,字节数 ÷ 时间就是带宽。两个典型发现:单线程往往吃不满标称带宽(一个核同时挂起的 miss 数有限),多开线程带宽明显上涨、然后停在一个平台——那个平台就是你机器的带宽墙。

虚拟内存:地址的幻术

上一章 Cache 判命中比的是物理 Tag,可程序手里的指针全是虚拟地址——翻译这一步就横在访存旅程的最前面。每个进程都以为自己独占从 0 开始的完整地址空间,这是硬件(MMU/TLB)和操作系统合谋维持的幻术。本章讲硬件这一半:地址如何翻译、TLB 为何存在、L1 怎么和翻译并行赛跑、一次访存的完整旅程。策略那一半(置换、COW、mmap)在 OS 页的内存管理与虚拟内存两章。

让程序直接用物理地址,三个问题立刻爆炸;虚拟内存用「加一层翻译」同时解决三个。

三个问题 → 一个机制

  • 保护:物理地址人人可算,进程 A 一个野指针就能改写进程 B 的数据、甚至内核。加了翻译层,A 的页表里根本没有映到 B 内存的条目——访问不到 = 绝对隔离,越权访问在翻译时被硬件拦下(段错误的来源)。
  • 重定位:程序编译时不可能知道自己会被装到物理内存哪里。有了虚拟地址,每个程序都从固定布局开始(代码在低址、栈在高址),装哪儿由页表决定——编译器、链接器从此不关心物理内存。
  • 超额分配:所有进程的虚拟空间总和远超物理内存也没关系——没访问的页不占物理页,冷页可以换出到磁盘。物理内存变成了「所有进程虚拟空间的缓存」——金字塔思想再次登场,DRAM 之下又垫了一层。

核心机制一句话

  • 地址空间按 4KB 分页;虚拟页 → 物理页框的映射记在页表里;每次访存都由 MMU(内存管理单元,CPU 中专管地址翻译的硬件)自动查表翻译。程序手里的每个指针都是虚拟地址——包括你 printf 出来的 %p,两次运行还不一样(ASLR 随机化了布局,回忆第 03 章 PC 相对寻址)。
别把「虚拟内存」等同于「交换空间」——Windows 设置里「虚拟内存 = 页面文件」是历史叫法带偏的。虚拟内存首先是地址翻译机制,保护与重定位全靠它;换出到磁盘只是它顺带解锁的能力之一,没配 swap 的机器上虚拟内存照样全程工作。
幻术亲眼可见:连跑两次 cat /proc/self/maps,堆和栈的虚拟地址每次都不同(ASLR),但每个进程的布局骨架一模一样——「人人都以为自己独占一台标准机器」,这就是翻译层制造的效果。

页表面临一个尺寸悖论:48 位地址空间 ÷ 4KB = 680 亿个页,平铺的页表本身就要 512GB。解法是

x86-64 的四级页表

虚拟地址 48 位: [ 9位 | 9位 | 9位 | 9位 | 12位页内偏移 ]
                  ↓     ↓     ↓     ↓
                PML4 → PDPT → PD → PT → 物理页框
每级 512 项×8B = 恰好一个 4KB 页
  • 树的妙处:没用到的子树整棵不存在。普通进程只用几百 MB 地址空间,页表只需几十 KB——稀疏地址空间的存储代价趋近于零。这也是 64 位机器敢给每个进程 256TB 虚拟空间的底气。
  • 页表项(PTE)不只存页框号,还带一排权限位:present(在不在内存)、R/W(可写吗)、U/S(用户态可访问吗)、NX(可执行吗)、dirty/accessed(写过/访问过——给 OS 的置换算法当线索)。只读段、W^X、写时复制,全是在这几个 bit 上做文章

深入理解

  • 翻译不是免费的:四级页表意味着一次访存要先做 4 次访存查表——不加缓存的话内存性能直接除以五。这就是 TLB 存在的理由(下一张卡)。
  • 每个进程一棵页表树,「切换进程」的本质动作之一就是把页表基址寄存器(x86 的 CR3)指向另一棵树——地址空间瞬间整体切换。这也解释了为什么线程比进程切换便宜:同进程的线程共享同一棵树,CR3 不用动(联动 OS 页)。
页表放在主存里,不在 MMU 里——MMU 只握着一个基址寄存器(x86 的 CR3),页表行走就是一连串普通的内存读(也经过 Cache)。以为「查页表是芯片内部瞬间完成的」,就解释不了 TLB miss 为什么要付几十周期、TLB 为什么必须存在。
页表不是抽象概念,它占的内存看得见:grep VmPTE /proc/self/status 是当前进程页表的开销(普通进程只有几十 KB——树的稀疏性);grep PageTables /proc/meminfo 是全机总账。

TLB(转译后备缓冲)缓存「虚拟页号 → 物理页框」的最终结果,让 99%+ 的翻译在 1 周期内完成,四级查表沦为罕见的慢路径。

参数与代价

  • 典型规模:L1 TLB 64~128 项(全相联/高相联度),L2 TLB 1~3K 项。覆盖范围 = 项数 × 页大小:128 项 × 4KB 只罩得住 512KB——工作集超过它就开始 TLB miss,每次 miss 由硬件页表行走器做多次访存补课(几十周期)。
  • 所以有大页(huge page):2MB/1GB 的页让每个 TLB 项覆盖 512/26 万倍的地址范围。数据库、JVM 大堆、虚拟化场景收益立竿见影——这是「调大页」这个运维操作背后的全部原理。

深入理解

  • TLB 是「进程幻术」的最贵成本中心:切换地址空间时,旧进程的 TLB 条目全部作废——传统做法整表冲刷,切回来再一条条 miss 回暖。现代硬件给每个条目打上地址空间标签(ASID/PCID),切换时不冲刷、按标签区分——上下文切换成本因此大降(OS 页「态切换的真实成本」的硬件注脚)。
  • 多核下更麻烦:一个核改了页表(如 munmap),其他核的 TLB 里可能还留着旧条目——必须发核间中断请大家一起清(TLB shootdown),是多核频繁改映射(频繁 mmap/munmap)性能差的根源。
TLB miss 不是缺页:前者由硬件页表行走器静默补齐、几十周期、程序毫无感知;后者才陷入内核、微秒起步。「miss 了就要 OS 介入」是把两级慢路径混成了一级——绝大多数 TLB miss 根本轮不到操作系统出场。
大页不是玄学:cat /sys/kernel/mm/transparent_hugepage/enabled 看策略、grep AnonHugePages /proc/meminfo 看用量——Linux 的透明大页就是在悄悄帮你扩大 TLB 覆盖。TLB shootdown 也有专门计数:grep TLB /proc/interrupts,每核一列。

上一张卡说 TLB 把翻译压到 1 个周期——可 L1 命中本身也才 4 个周期,翻译要是老老实实排在查 Cache 前面,L1 的延迟就凭空多出一大截。现代 L1 的解法是让这两件事同时开跑

用什么地址索引:三种方案

方案选组用比 Tag 用好处麻烦
VIVT虚拟地址虚拟地址完全不用等翻译,最快换进程要冲刷;同一物理页的两个虚拟地址会存成两份副本
PIPT物理地址物理地址没有别名问题,最简单正确必须等翻译完成才能开查——延迟串行叠加
VIPT虚拟地址物理地址翻译与选组并行,翻译的延迟被藏掉有容量约束(见下)
  • 现代 CPU 的分工很整齐:L1 用 VIPT(它跟流水线抢每一个周期),L2/L3 用 PIPT(它们本来就要几十个周期,翻译早就完成了,多等一拍无所谓)。

VIPT 的隐藏约束:解释了一个你可能一直觉得奇怪的参数

  • 能这么并行的前提是:用来选组的位必须全部落在页内偏移里——页内偏移那 12 位翻译前后原样不变,用它选组,虚拟地址和物理地址选出来的是同一组。写成公式就是 index 位数 + 行内偏移位数 ≤ 12,等价于每一路的容量 ≤ 页大小
  • 代进本机参数(getconf -a:L1d 32 KB、8 路、行 64 B):每路 = 32 KB ÷ 8 = 4 KB,恰好一个页;组数 64 → index 6 位 + 偏移 6 位 = 12 位,顶格用满、一位不剩
  • 于是「L1d 为什么十几年一直卡在 32 KB」有了硬答案:4 KB 页 + VIPT 之下,想扩容只能加路数,而路数一多比较器和功耗又扛不住。Apple 换了个前提——16 KB 页,每路就能到 16 KB,L1d 一口气做到 128 KB 还保持 VIPT。这是「参数不是拍脑袋定的」最好的一个例子:第 05 章那张真实参数表里的每个数字背后,都压着这类约束。

两个都叫「别名」,完全是两回事

  • Cache 别名:如果每路容量超过页大小,index 就用到了要翻译的位,同一个物理行可能落进不同的组、存成两份副本,改了一份另一份还是旧的。硬件要么额外查重,要么由 OS 做页着色(分配物理页时凑齐那几位,让虚实的那几位一致)。
  • 4K 别名:跟上面毫无关系,发生在 store 与 load 之间。硬件为了快,先只比地址的低 12 位来判断「这条 load 会不会撞上前面那条 store」——低 12 位相同就先按「有依赖」处理、拖住 load,等物理地址算出来才发现是虚惊一场。(一条 store 写 buf+0、一条 load 读 buf+off,1 亿轮):off=4096 约 0.30 ns/轮,off=4160(错开一条 cache 行)约 0.26 ns/轮方向稳定,但幅度只有一成多——Zen 4 上这个惩罚远比教科书里 Intel 的例子温和,而且它和频率抖动同一量级(第 08 章),要多跑几轮才看得准。
别把这套约束推广到 L2/L3:它们是 PIPT,进来时虚拟地址早已翻译完毕,容量和相联度不受页大小约束(本机 L2 就是 1 MB、L3 16 MB)。「L1 为什么那么小」的答案在这张卡,「L3 为什么那么慢」的答案在第 05 章——两个问题的成因完全不同,别用一句「越大越慢」糊过去。
VIPT 不是「虚拟地址进了 Cache」——比 Tag 用的仍然是物理地址,命中判断依旧是物理的,只是「选哪一组」这一步提前用了翻译前后不变的低位。一句话记法:index 抢跑,Tag 不抢跑。也正因为 Tag 是物理的,换进程时 L1 不需要冲刷(对照 VIVT 那一行),这是它同时拿下速度与正确性的关键。

把 04、05 两章拼起来:CPU 执行 mov rax, [rdi] 时,硬件世界发生的全部事情。这是本课最值得反复走一遍的流程。

快慢分岔的完整决策树

虚拟地址
 ├─ TLB 命中(99%+) ──────────→ 物理地址   // ~1 周期
 └─ TLB miss → 页表行走(4次访存,查表本身也走Cache)
      ├─ PTE present ─────────→ 物理地址,填TLB  // 几十周期
      └─ PTE 缺失/无权限 → 缺页异常 → 陷入内核(OS 接管)
            ├─ 合法但没装载: 分配页/从磁盘读入 → 重执行  // 微秒~毫秒
            └─ 非法访问: SIGSEGV → 进程崩溃

物理地址 → L1 命中(~4周期) / L2(~14) / L3(~50) / DRAM(~300)
  • 同一条指令的耗时从 1 周期到几毫秒,跨越六个数量级——全看数据落在哪条分支。性能工程的大部分工作就是把访问从慢分支挪到快分支。
  • 注意软硬分工的界线:TLB 与页表行走全是硬件自动的;只有缺页才惊动操作系统——机制(翻译)在硬件,策略(缺了怎么办、换谁出去)在 OS。这条界线也是本页与 OS 页的分界:置换算法、工作集、COW、mmap 的精彩内容在 OS 页虚拟内存章。
别把「缺页异常」当程序出错的信号——malloc 大块内存后首次触碰每一页都会缺页,这是按需分配的正常路径;决策树里只有「非法访问」那一支才是 SIGSEGV。真正的性能红灯是海量缺页的频率,不是缺页这件事本身的存在。
感受这棵决策树:顺序遍历 1GB 数组 vs 随机遍历同一数组,perf stat 看 dTLB-load-misses 和 cache-misses——随机版两项暴涨,耗时差可达 10 倍。数字比任何讲解都有说服力。

I/O 与互连

前五章都在 CPU–内存这个核心圈里打转;这一章走出去,看 CPU 怎么跟磁盘、网卡、显卡这些外设打交道。它们之间隔着巨大的速度差和一个根本问题:谁等谁?这一章从「CPU 怎么跟设备说话」讲到「数据怎么绕开 CPU 自己流动」(以及怎么给这条绕道也套上地址翻译),再看总线四十年的演化如何把「共享一条大路」变成「人人专线直达」。

设备在 CPU 眼里是一组寄存器:状态寄存器(忙不忙)、控制寄存器(写命令)、数据寄存器(收发数据)。剩下的问题只是:这些寄存器怎么编址?

两种编址,一个胜者

  • 内存映射 I/O(MMIO,现代主流):把设备寄存器映射进物理地址空间的保留区段。读写设备 = 普通的 load/store,无需专用指令,C 代码里一个 volatile 指针就能驱动硬件——嵌入式开发的日常。
  • 独立编址(port I/O,x86 遗产):专用 in/out 指令访问独立的 64K 端口空间。地址空间不占内存的份额,但指令专用、只有 x86 有——如今仅剩键盘控制器等古董设备在用。

MMIO 的陷阱:它不是内存

  • 读设备寄存器可能有副作用(读一次数据寄存器 = 取走一个字节),写可能触发动作。因此这些地址绝不能被 Cache、绝不能被乱序/合并——页表项里专门有位标记「不可缓存」,驱动代码必须用 volatile 禁止编译器优化掉「看似重复」的读写,必要时插内存屏障保序。「为什么驱动代码到处是 volatile 和 barrier」的答案就在这。
volatile 只约束编译器(别删、别合并、别重排这些访问),管不住 CPU 层面的乱序与写合并——真正保序还要靠页表里的「不可缓存」属性和屏障指令。x86 对这类页给的内存类型较强,常显得「volatile 就够了」,同样的驱动代码搬到弱序的 ARM 上就现形。
两种编址在 Linux 上各有一张账本:cat /proc/iomem 列出物理地址空间里的 RAM 与 MMIO 区段,cat /proc/ioports 列出 x86 独立端口空间(keyboard、rtc 这些古董还在册)——普通用户看到的地址列被隐去为 0,但条目名单已足够看清两个空间的版图。

设备比 CPU 慢几个数量级,「怎么知道设备好了」有三个答案,按 CPU 的解放程度递进——但最优解取决于场景,快设备反而让轮询复活了。

三种方式

  • 轮询:CPU 循环读状态寄存器直到就绪。实现最简单,但慢设备会把 CPU 空转烧光——键盘每秒几次按键,轮询等于全职保安盯一扇几乎不开的门。
  • 中断:设备就绪时主动拉信号,CPU 暂停当前程序跳进处理函数。CPU 平时干正事,「门铃响了才去开门」——中低速、事件稀疏的设备的标准答案。
  • DMA(直接内存访问):连「搬数据」本身也外包——CPU 只说「把这 4KB 从设备搬到内存地址 X」,DMA 引擎自己占用总线搬运,完成后发一个中断汇报。CPU 的参与从「每字节」降到「每批次」。

现代反转:中断太贵,轮询回归

  • 中断不是免费的:陷入内核、冲刷流水线、污染 Cache——每次微秒级。当设备快到每秒百万事件(10G 网卡、NVMe),中断风暴会把 CPU 打瘫
  • 于是高速路径反过来拥抱轮询:Linux 网络栈的 NAPI 在流量大时切到轮询批量收包、稀疏时切回中断;DPDK/SPDK 干脆全程用户态轮询独占核心。判断标准是事件密度:门铃响个不停时,站在门口反而是效率最高的。
「DMA 解放 CPU」不是零成本:DMA 引擎和 CPU 争的是同一条内存带宽,而且每次传输的准备工作(分配缓冲、建立映射、填描述符)仍由 CPU 完成——数据量小时准备开销反而超过直接搬运,所以读写几个字节的设备寄存器从不劳驾 DMA。
完整的现代收包路径把三者串成一条链:网卡 DMA 把包直接写进内存环形缓冲 → 攒一批后发一次中断 → 内核切到 NAPI 轮询批量处理。三种机制不是替代关系,而是按速率分层组合。
轮询中断DMA
CPU 在等待期间空转反复读状态去干别的事去干别的事
谁搬数据CPU 逐字节CPU 逐字节DMA 引擎,搬完才中断一次
每次事件的固定开销极低较高(陷入、冲流水线、污染 Cache)低(按批摊薄)
适合的事件密度极高(每秒百万级)或极低延迟要求中低(事件稀疏)大块数据传输
典型DPDK / SPDK 独占核轮询键盘、慢速串口网卡、NVMe、GPU

「设备拉一根线」的中断模型早已进化成一套可路由、可编程的消息系统。

从引脚到消息

  • 经典模型:设备连到中断控制器的物理引脚,控制器按优先级仲裁后打断 CPU;CPU 拿着中断号查中断向量表跳到对应处理函数。线是稀缺的、共享线还要逐个问「是你吗」。
  • 现代 MSI/MSI-X(消息信号中断):中断变成一次内存写——设备往特定地址写一个值,就算发了中断。不占引脚、一个设备可有上千个独立向量、还能指定送到哪个核——多队列网卡给每个 CPU 核配独立收包队列+独立中断,并行收包互不干扰(irqbalance/RSS 调优的原理)。

软硬件的接力(与 OS 页衔接)

  • 硬件负责:保存最小现场(PC、标志)、切内核栈、跳向量入口——「中断隐指令」说的就是这段自动动作。
  • 内核负责:上半部尽快应答硬件、登记工作;下半部(softirq 等)延后做重活——「关中断的时间必须极短」这条纪律贯穿所有 OS 的中断设计(细节在 OS 页中断卡)。
  • 异常(除零、缺页)与中断走同一套向量机制,区别只在来源:异常是当前指令同步产生的,中断是外设异步插入的——所以异常可精确归因到指令,中断不行。
中断不能「拦腰打断一条指令」——CPU 只在指令边界响应中断,保存的现场永远是整数条指令的状态(x86 的 rep 串操作这类长指令会把进度记在寄存器里,同样精确可恢复)。「随时被打断」的粒度是指令,不是指令的中间。
这张卡在 cat /proc/interrupts 里全是实物:每行一个中断源、每核一列计数,行内直接标着触发方式(IO-APIC 还是 MSI/MSI-X)——多队列设备每个队列独占一行、各自路由到不同的核,「中断是可路由的消息」看得见。

「CPU 不搬数据」贯彻到底,就是现代高性能 I/O 的形态:设备直读直写内存,CPU 只管下单和收货。

NVMe:DMA 设计的教科书

内核在内存里维护: 提交队列 SQ + 完成队列 CQ(都在主存,SSD 靠 DMA 读写)
① CPU 把读写命令填进 SQ, 写一下设备的 doorbell 寄存器(唯一的 MMIO)
② SSD 自己 DMA 取命令 → 执行 → 数据直接 DMA 进目标内存
③ SSD 把完成状态 DMA 写进 CQ → 发中断(或被轮询发现)
  • 每核可配独立队列对、队列深度上万——对比一次只能一个请求的老 SATA 协议,NVMe 的性能优势一半来自闪存、一半来自这套为 DMA 和多核而生的队列协议。io_uring 把同样的「共享内存双队列」思想搬进了系统调用接口(OS 页)——好设计会传染。

深入理解

  • 缓存一致性问题:设备 DMA 写内存绕过了 CPU Cache,若 Cache 里有旧副本,CPU 会读到陈货。x86 平台由硬件自动嗅探解决(DMA 一致性),很多 ARM/嵌入式平台要驱动手动刷 Cache——跨平台驱动 bug 的经典来源。
  • 安全问题:能 DMA = 能读写任意物理内存,恶意外设(如雷电接口设备)可以直接拖走整机内存。IOMMU(I/O 内存管理单元)应运而生:给设备也做一层「页表」,设备只能访问驱动明确映射给它的区域——虚拟机直通设备(GPU 直通)也靠它隔离。
设备不经过 CPU 的 MMU——交给 DMA 引擎的必须是物理地址(或 IOMMU 翻译的总线地址),而驱动手里的指针全是虚拟地址。以为「把指针递给设备就行」,会撞上地址转换和缓冲区物理连续性(不连续就得靠 scatter-gather 列表)两堵墙——DMA 缓冲区要用专门接口分配,道理在此。
记忆钩子「doorbell 是唯一的 MMIO」:整条 NVMe 提交路径里 CPU 只写一次门铃寄存器,命令、数据、完成状态全走 DMA。判断一个 I/O 协议现代不现代,就数 CPU 必须亲手触碰设备寄存器的次数——次数越少,设备自治程度越高。

上一张 DMA 卡里,设备绕开 CPU 直接读写内存——它发出的是物理地址。也就是说,一块能发 DMA 的设备(包括你插上去的雷电扩展坞)理论上可以读写整台机器的内存。给设备也配一个 MMU,既是这个洞的补丁,也是虚拟机能直通显卡的前提。

设备侧的那一套页表

  • IOMMU(Intel VT-d / AMD-Vi / ARM SMMU)坐在设备与内存控制器之间:设备发出的地址叫 IOVA(I/O 虚拟地址),先查 IOMMU 的页表翻译成物理地址,才真正落到内存上。和第 06 章 CPU 侧的 MMU 是同一套思想——页表 + 一个叫 IOTLB 的缓存,只是服务对象从进程换成了设备。
  • 驱动的动作随之变成:先建立映射拿到 IOVA、把 IOVA 交给设备,用完再解除映射。设备只碰得到被显式映射过的那几页,其余内存对它根本不存在——这就是「设备也进沙箱」。

三个用途,正好对应第 06 章虚拟内存的三个理由

  • 保护:固件有 bug 的网卡、恶意的外接设备,都不能再随手读走整个内存(这类攻击有个专名:DMA 攻击)。
  • 重定位与直通:虚拟机里的驱动直接编程物理设备,设备发出的是 guest 眼中的物理地址,由 IOMMU 翻译成宿主的真实物理地址——没有 IOMMU 就没有 GPU/网卡直通
  • 扩展寻址:只能寻址 4 GB 以下的老设备,可以由 IOMMU 把任意高位物理页映射到它够得着的窗口,省掉「先拷到低端内存再 DMA」的中转缓冲。

代价与边界

  • IOTLB 也会 miss,建立/解除映射本身也不便宜。高频小包的网络设备上,动态映射能吃掉可观的 CPU——所以高性能路线要么预映射一大块反复使用(注册缓冲区),要么干脆让宿主自己的设备走恒等映射。
  • 隔离的最小单位是 IOMMU 分组(group),不是单个设备:同组内的设备之间可能直接通信、绕过 IOMMU,所以直通必须整组一起交出去。虚拟机直通失败最常见的原因就是「想要的那块卡和别的设备挤在同一组里」。
iommu=pt(passthrough)不等于「关掉 IOMMU」:它只是给宿主自己的设备装上恒等映射、省掉翻译开销,分组与直通能力仍在。也别指望它防住一切——IOMMU 管的是「设备能碰哪些内存页」,管不了同组设备之间的横向通信,也管不了设备被当作侧信道使用。
Linux 上一眼可见:dmesg | grep -iE 'DMAR|AMD-Vi|IOMMU' 看有没有启用(分别是 Intel/AMD 的关键字),ls /sys/kernel/iommu_groups/ 数出分组数,每个组里的 devices/ 列出成员。想直通某块卡之前先看它的组里还有谁——这一步能省下大半天的排查。

「总线」的字面意思——一组大家分时共享的公共线——在高速时代已经死了。理解它为什么死,比背它的分类更有价值。

共享并行总线的两宗罪

  • 共享 = 排队:同一时刻只能一对设备通信,设备越多、争用越凶,还需要仲裁机制裁决谁先用(当年链式查询/独立请求那些方案解决的就是这个排队问题——如今问题本身被消灭了)。
  • 并行 = 齐步走:64 根数据线的信号必须同时到达,频率一高,线间的长度差异和串扰让「同时」不可能维持——共享式多驱动的并行总线(PCI/FSB 一类)频率天花板在几百 MHz 就到了(点对点、严格等长布线的 DDR 内存接口是例外,能跑到数千 MT/s)。

现代答案:串行 + 点对点 + 交换

  • 串行差分对把时钟嵌进数据流,单对线可跑 16~64 GT/s——没有对齐问题,速度靠提升单线速率和堆 lane(通道):PCIe x1/x4/x16 就是 1/4/16 对收发线的并联。
  • 点对点 + 交换芯片取代共享:每个设备独享到交换节点的链路,多对设备同时全速通信,仲裁问题消解为交换机的路由问题——和以太网从同轴总线进化到交换机是同一个故事。
  • USB、SATA、以太网、片间互连(NVLink、Infinity Fabric)全走了同一条路:高速世界里,串行点对点是唯一答案。「并行口比串行口快」的直觉在 2005 年后已反转。
GT/s 不是 GB/s:「PCIe 4.0 16GT/s」说的是每条 lane 每秒 160 亿次传输、每次 1 位,扣掉编码开销(3.0 起为 128b/130b)后每 lane 约 2GB/s 单向——把 GT/s 直接当整卡的字节带宽读,会差出一个数量级。
带宽公式仍然通用:带宽 = 每传输位宽 × 传输率 × lane 数。PCIe 4.0 x16 ≈ 32GB/s、PCIe 5.0 翻倍——显卡、NVMe 盘标的「x4/x16」直接代入即可算出上限,判断「盘到底被什么卡住了」时常用。

认识真实机器里的几条主干道,以及正在改变数据中心的新趋势。

版图

  • PCIe:机箱内的主干——显卡、NVMe、网卡都挂它。分层协议(物理/链路/事务层),支持 DMA、MSI-X、IOMMU,代际翻倍(3.0 约 1GB/s/lane → 6.0 约 8GB/s/lane)。CPU 与芯片组之间、芯片组与设备之间是一棵 PCIe 树——lspci -tv 能看到你机器的这棵树。
  • USB:机箱外的万金油——即插即用、热插拔、供电。USB4 融合了雷电,本质是「把 PCIe/DP 隧道化跑在 USB 线上」——线缆世界也在走「一条高速串行链路承载多种协议」的路。
  • NVMe over PCIe / 网络化存储:NVMe-oF 把 NVMe 队列协议跑在 RDMA/TCP 上,存储彻底脱离机箱——协议与介质解耦的范例。

CXL:正在发生的变革

  • 建立在 PCIe 物理层上的缓存一致互连:设备可以和 CPU 一致地共享内存(CXL.cache/CXL.mem)。用途一:加速器与 CPU 共享数据免拷贝;用途二:内存池化——内存不再焊死在某台服务器上,机柜级共享、按需分配,直击数据中心「内存占成本一半且利用率低」的痛点。它是「总线演化」故事的最新一章:互连正在从「传数据」升级为「共享内存语义」。
Type-C 只是接口形状:同一个口里跑的可能是 USB 2.0(480Mb/s)也可能是 USB4/雷电(40Gb/s 以上),相差两个数量级,还受线缆自身规格限制。「口长一样 = 一样快」是最常见的选购出错——认协议标注,别认口形。
记一个锚点推全表:PCIe 3.0 ≈ 1GB/s/lane(单向),此后每代翻倍(4.0≈2、5.0≈4、6.0≈8),乘上 lane 数就是链路上限——看到「PCIe 5.0 x4 的 SSD」立刻知道天花板约 16GB/s,不用查表。

并行与现代体系结构

前六章拼出了一颗完整的单核机器。可就在 2005 年前后,主频狂飙的时代撞上功耗墙,「让单核更快」这条路走到了尽头,性能增长的接力棒交给了并行与专用化。这一章从那堵墙讲起(以及它每天在你机器上的形态:睿频与降频),再看多核世界的新问题(一致性、内存模型)、数据并行的力量(SIMD/GPU)、专用化浪潮(DSA),以及推测执行时代的安全课(Spectre)。

2004 年 Intel 取消 4GHz Pentium 4,是体系结构史的分水岭。理解那堵墙,才能理解之后二十年的一切设计选择。

物理账本

动态功耗 P ∝ C × V² × f    // 电容 × 电压平方 × 频率
提频需要提压 → 功耗随频率近似立方增长 → 散热撞死在 ~100W/cm²
  • 过去的免费午餐叫 Dennard 缩放:晶体管变小,电压同步降低,单位面积功耗不变——频率年年白涨。2005 年前后电压逼近物理下限(漏电流失控),缩放终结:晶体管还在变多(摩尔定律苟着),但不能全部同时全速跑(暗硅)。
  • 同样的功耗预算,两个 3GHz 核的吞吐远超一个 4.5GHz 核——于是工业界集体转向:多核、大小核、SIMD、专用加速器,全是「把功耗花在刀刃上」的不同刀法。软件的免费午餐同时结束:单线程程序再也不会自动变快,并发编程从此是必修课(OS 页并发同步章的存在理由)。
「摩尔定律终结」和「Dennard 缩放终结」常被混为一谈:2005 年前后死掉的是后者——「频率白涨」的时代;晶体管密度的增长只是放缓、至今仍在继续。分清这两条曲线,才能看懂芯片的答案为什么是「更多核、更多专用单元」而不是原地踏步。
这堵墙也解释了手机芯片的设计语言:性能核冲刺、能效核常驻、NPU(神经网络处理单元)跑 AI、ISP(图像信号处理器)处理图像——每类任务用能效最高的那块硅。「通用 CPU 包打天下」让位于「异构协作」,正是功耗墙的长影。

上一张卡的功耗墙不是历史事件,它每天都在你的机器上生效:CPU 的频率是一个随负载、温度、供电实时变动的量,参数表上「最高 5.1 GHz」说的是一个核短时冲刺的值,不是你的程序跑起来时的值。

频率是从预算里挤出来的

  • DVFS(动态电压频率调节):负载轻就同时降压降频。因为 P ∝ C×V²×f,降频还能顺带降压,省下的功耗是超线性的——这就是手机待机时能把频率压到几百 MHz 的原因。
  • 睿频(Turbo/Boost)是反向操作:只要功耗、温度、电流三条预算还有余量就往上冲。所以同一颗芯片有一整条频率曲线——单核冲刺够得着标称峰值,全核持续负载被功耗预算压低一档,散热跟不上再被温度墙压一档。笔记本插不插电、垫不垫散热,是两套完全不同的预算。
  • 宽向量指令(AVX-512 一类)功耗密度特别高,某些处理器为它单独降一档频率——「改用向量指令反而更慢」是真实存在的现象(按公开资料写,本机未验证)。

同一个程序连跑 12 次

4 线程、完全相同的计算,连跑 12 次:
  耗时      0.967 ~ 1.141 s     ← 相差 18%
  周期数    16.19G ~ 16.47G     ← 只差 1.7%
  实际频率  3.82 ~ 4.25 GHz     ← 抖的全在这里
  • 结论直接摆在脸上:「秒」不是硬件的量具,它是「周期数 ÷ 当时的频率」。同一段代码干的活一模一样、周期数几乎是常量,耗时却抖了 18%——抖的不是程序,是频率。
  • 线程数怎么影响频率(同一份工作负载):1 线程 4.258 GHz、2 线程 4.243、4 线程 4.150。本机在 WSL2 里只看得到 4 个逻辑核,真正全核满载时跌幅会更大。
  • 怎么读出实际频率:perf stat 输出里 cycles 那行末尾的 GHz,就是 cycles ÷ task-clock 算出来的——这是「刚才真的跑了多快」的唯一可靠读数。
别拿 /proc/cpuinfocpu MHz 当实际频率——它在很多环境里是个静态值。本机 WSL2 里它无论空载满载都固定报 3792.624,而同一时刻的真实频率是 4.15~4.26 GHzlscpu 干脆一行 MHz 都不给,/sys/devices/system/cpu/cpu0/cpufreq/ 目录也不存在(虚拟机里看不到宿主的调频)。要频率就用 cycles ÷ 时间自己算,这个办法在裸机、虚拟机、容器里一律成立。
由此得出计时实验的三条纪律,第 01 章那五个实验都该这么读:① 多跑几轮取最小值(最小值最接近「没被别人打扰、频率也冲上去了」的那一次);② 优先报与频率无关的量——指令数、周期数、IPC、miss 率,它们跨机器仍然可比;③ 跨机器只比倍数和方向,别比秒数。本页每个数字后面都跟着一句「自己跑一遍」,理由就在这里。

每个核都有私有 Cache,同一变量就会有多份副本——一个核改了,其他核的副本瞬间过期。一致性协议的任务:让「多副本」对软件不可见

MESI:每行一个状态机

M odified   独占且已改(脏) —— 全芯片唯一真本
E xclusive  独占且干净 —— 只有我有,可静默改成 M
S hared     多核共享只读 —— 想写?先广播让别人失效
I nvalid    无效 —— 读写都得重新获取
状态别的核有副本吗和内存一致吗我能直接写吗
M odified没有(全芯片唯一)不一致(我改过,是脏的)
E xclusive没有一致能(静默转成 M)
S hared可能有一致不能——先广播让别人失效
I nvalid不能(读写都要先重新获取)
  • 核间靠嗅探/目录互相监听:写一个共享行之前,必须先发「使无效」消息收回所有副本(写失效策略)——保证任意时刻要么多读者、要么单写者,和读写锁同构。
  • 硬件保证的是单地址的一致:你永远不会读到「一半新一半旧」的缓存行。它保证多个地址间的顺序——那是内存模型的事(下一张卡),两者常被混为一谈。

对性能的直接影响

  • 写共享数据的代价是核间通信:一行在多核间被轮流写,就在 M/I 状态间弹跳(cache line ping-pong),每次弹跳几十纳秒。第 05 章的 false sharing 正是无辜变量共乘一行引发的弹跳;「每核一份、最后汇总」(per-CPU 计数、分片锁)是通用解法。
  • 原子指令(lock add、CAS 比较并交换)的成本本质也是一致性流量:先把行拉到独占态再改——「原子操作慢」慢在总线仲裁,不慢在运算(联动 OS 页原子操作卡)。
「硬件都保证缓存一致了,为什么还要锁?」——MESI 只保证你读到的是单个地址的最新副本,不保证「读-改-写」期间没人插队(原子性),更不保证多个地址的可见顺序(内存模型)。一致性、原子性、顺序性是三层独立的保证,锁和 atomic 买的是后两层。
cache line ping-pong 亲手可测:两个线程各自累加同一 cache 行内相邻的两个计数器,对比各自累加隔开 64 字节的两个计数器——前者慢数倍。一致性流量看不见,计时看得见。

多线程靠「锁」协调,可锁本身也只是内存里的一个变量。用普通的读-改-写去抢锁,两个核会同时读到「没人占」、然后同时占上——锁没锁住。造出一把真锁,前提是有一条硬件保证「读-改-写中间绝不被插队」的指令。

两大流派

  • x86:lock 前缀 + 读改写指令lock addlock xchglock cmpxchg 让这条读-改-写在内存系统看来不可分割:早期真的锁住总线,现在是把目标 cache 行拉到独占态、改完才放手(上一张 MESI 卡)。其中 CAS(compare-and-swap,cmpxchg最关键:比较内存与「期望值」,相等才写入新值并报告成功——一条指令完成一次「乐观更新」。
  • ARM / RISC-V(弱序 RISC):LL/SCload-linked 读一个地址时给它挂上监视,store-conditional 只有在这期间没有别的核碰过它才写成功,否则失败、让你重试。它不锁任何东西,靠「监视 + 失败重试」达成原子性——更契合弱序内存模型,也更好扩展到多核。

CAS 是无锁编程的原子

  • 用一个 CAS 循环(读旧值 → 基于它算新值 → CAS 写回,失败就整段重来)可以不用锁实现计数器、栈、队列——这就是 lock-free。好处是没有「持锁线程被调度器换出、结果所有人干等」的问题(联动 OS 页)。
  • 代价是它极难写对:ABA 问题(值从 A 变 B 又变回 A,CAS 只看值、以为从没变过,酿成 bug)、活锁、以及和内存序的纠缠。所以日常请用现成的 std::atomic 或锁;手写 lock-free 数据结构是专家运动,不是常规操作。

深入理解

  • 原子操作的成本不在运算,在一致性流量:要原子地改一个变量,必先把它所在的 cache 行拉到独占态、令其他核的副本全部失效。所以高竞争下一个小小的原子计数器会在核间疯狂弹跳(cache line ping-pong,上一张 MESI 卡),比直觉慢一个数量级——「每核一份、最后汇总」(分片计数、per-CPU 变量)是通用解药。
  • 原子性 ≠ 顺序性,这是最常见的混淆:一条原子指令只保证「这一个地址的读改写不可分割」,完全不保证它和周围别的访存谁先谁后可见——那是内存模型的事(下一张卡)。C++ 的 atomic 把两件事打包(RMW 操作 + memory_order 参数),但它们是两个正交的保证,分清了才不会用错。
counter++ 一条语句所以是原子的」——它是读、改、写三步,两个线程对普通变量各自增一百万次,结果几乎必然小于两百万。对齐的单条 load 或 store 在多数 ISA 上确实原子,但「改」从来不在其中——这正是必须有 lock 前缀/LL-SC 的理由。
一句话戳穿层次:软件的 mutex 并不是硬件直接给的,而是拿一条原子指令自己盖的——通常先用一次 CAS 尝试抢占,抢不到再陷入内核睡眠(Linux 的 futex 就是这个「快路径 CAS + 慢路径系统调用」的组合)。往下追,一切同步原语的地基,都是这一条「读改写不可分割」的硬件指令。

多线程程序员迟早撞见的鬼故事:两个核看到的「写入顺序」可以不一样。这不是 bug,是体系结构明码标价的性能交易。

鬼故事的机制

核A:  data = 42;        核B:  while (!flag);
      flag = 1;               print(data);    // 可能打出 0 !
  • 为什么:核 A 的写先进写缓冲(store buffer)再择机刷出,两条写到达「全局可见」的顺序可能颠倒;核 B 侧的乱序执行也可能把读提前。单线程内硬件保证「看起来按序」,跨线程的可见顺序没人默认担保
  • 各 ISA 担保的强度不同:x86-TSO 较强(只有「写后读」会乱),ARM/RISC-V 弱序(读读、读写、写写都可乱)——所以并发 bug 常在 ARM 上必现、x86 上潜伏,迁移到 Apple Silicon/服务器 ARM 时集中爆雷。

把顺序买回来

  • 硬件提供屏障指令(fence/dmb):屏障前的访存全局可见之后,才允许屏障后的访存进行。
  • 程序员的正确姿势不是手插屏障,而是用语言级原子类型:C/C++ 的 atomic 带 acquire/release 语义——release 写(发布 flag)与 acquire 读(看见 flag)配对,编译器自动在正确位置插屏障,data 的写入保证先于 flag 可见。「用锁和 atomic,不要用裸变量传递信号」这条纪律的硬件依据就在这。
volatile(C/C++)只禁编译器优化,不插硬件屏障,不能用于线程同步——它是给 MMIO(第 07 章)准备的。Java 的 volatile 语义完全不同(带 acquire/release)。同名不同物,跨语言讨论时先对齐定义。
这个鬼故事在 x86 上就能亲手抓到:两线程分别执行 x=1; r1=yy=1; r2=x(初值全 0),反复跑几十万轮,能观测到大量 r1==r2==0——那一刻两条写都还睡在各自的 store buffer 里。「写后读乱序」从此是你亲眼见过的数据。

当同一个操作要施加到一百万个数据上,为每个数据取指译码一次是巨大浪费。数据并行的思想:一份控制,N 份执行

SIMD:CPU 里的向量车道

  • x86 的 SSE/AVX(128/256/512 位)、ARM 的 NEON/SVE:一条指令对 4~16 个 float 同时运算。图像、音频、字符串处理(memchr 用 SIMD 一次扫 32 字节)、AI 推理的 CPU 路径全靠它。
  • 用法优先级:先让编译器自动向量化(规整循环 + -O3,godbolt 验证);不行再用 intrinsics 手写。数据布局决定成败——第 05 章的 SoA 布局正是为了让数据「一行行喂进向量车道」。

GPU:把整颗芯片押给吞吐

  • 设计哲学与 CPU 相反:砍掉分支预测、乱序、大 Cache(那些为延迟服务的一切),换成几千个简单核 + 超宽显存带宽,用海量线程轮转来掩盖访存延迟——一批线程等内存时,另一批顶上,执行单元永不空闲。
  • SIMT(单指令多线程)执行模型:32 个线程为一组(warp,线程束)踏同一条指令;组内分支分歧则两条路径串行各走一遍——GPU 代码「怕 if」的原因。适合的负载一句话:规整、海量、算术密集(图形、矩阵乘、深度学习);指针追逐和复杂分支请留在 CPU。
    CPU(含 SIMD)GPU
    为什么服务而设计延迟——让单条依赖链尽快跑完吞吐——让总完成量最大
    核的性质少而宽(乱序、大 Cache、分支预测)多而简(砍掉这些,换执行单元)
    怎么掩盖访存延迟乱序窗口 + 多级 Cache海量线程轮转(一批等内存,另一批顶上)
    分支分歧预测器处理,代价可控同一 warp 内两条路径串行各走一遍
    适合的负载逻辑复杂、分支多、延迟敏感规整、海量、算术密集
「GPU 有几千个核,所以比 8 核 CPU 快几百倍」——营销所称的 CUDA core 对标的不是 CPU 的核,而是一条 SIMD 车道;按同一口径,8 核 AVX2 CPU 也有 8×8=64 条 float 车道。跨架构比较请看 FLOPS 和内存带宽,数「核」没有意义。
判断「该上 GPU 吗」用第 05 章的算术强度:数据搬到显存的 PCIe 传输是固定税,运算强度不够高时,搬运时间就吃掉全部加速——「GPU 版更慢」的案例十有八九栽在这。

通用 CPU 每焦耳的运算次数逼近极限后,性能增长的第三条路是为特定领域定制硅片——Hennessy 与 Patterson 称之为「体系结构的新黄金时代」。

为什么专用能赢几个数量级

  • 通用 CPU 执行一次加法,90%+ 的能量花在「取指、译码、调度、预测」这些管理开销上,真正的运算只占零头。专用架构把管理开销摊薄:TPU 的脉动阵列让数据在数万个乘加单元间直接流动,一次取指驱动上万次运算——能效比 CPU 高两个数量级,原理不神秘,就是砍掉灵活性、摊薄开销
  • 代表:AI 的 TPU(张量处理单元)/NPU、视频编解码的固定单元(手机录 4K 不烫手的原因)、网络的 DPU(数据处理单元)、加密的 AES-NI(x86 的 AES 加密指令扩展)。判断某负载会不会「硬化」:足够普遍 + 算法足够稳定 + 数据并行度高。

随之改变的芯片形态

  • Chiplet:大芯片良率随面积指数下跌,于是拆成多个小 die 封装互连(AMD 的 CCD、Apple 的 UltraFusion)——摩尔定律放缓后,「先进封装」接棒扛增长。
  • 软硬协同设计成为常态:算法迁就硬件(AI 模型量化到 INT8/FP8 迎合张量核)、硬件迎合算法(Transformer 专用单元)。体系结构不再是软件的透明地基,而是设计对话的另一方——这门课因此比十年前更值得学。
加速器标称的「XX TOPS」是特定低精度(多为 INT8)下乘加阵列的峰值吞吐:和 GPU 的 FP32 FLOPS 不同精度、不可直比;实际负载受带宽与算子覆盖限制,常只发挥出峰值的一小部分。拿 TOPS 排座次之前,先对齐精度、再问利用率。
专用指令的收益一分钟可测:openssl speed -evp aes-256-cbc 对比 OPENSSL_ia32cap="~0x200000000000000" openssl speed -evp aes-256-cbc(屏蔽 AES-NI、退回纯软件实现),吞吐差出数倍——「把算法硬化进硅片」的现金价值。

2018 年初公开的这对漏洞是体系结构史上最深刻的一课:性能优化本身成了攻击面——而且漏洞不在某家的实现 bug 里,而在二十年来所有高性能 CPU 共同的设计假设里。

攻击的两块积木

  • 积木一(第 04 章):分支预测错误时,推测执行的指令被回滚——架构状态完美还原,但推测期间的访存已经改变了 Cache 的冷热,而 Cache 状态不回滚。
  • 积木二(第 05 章):命中与 miss 的时间差可以精确测量——Cache 的冷热是一条侧信道
  • 组合(Spectre v1):训练分支预测器让越界检查被预测为通过 → CPU 推测执行 array2[secret*4096](用越权读到的秘密值当下标)→ 回滚后逐项测量 array2 哪一行变热 → 秘密值泄露。没有任何一条指令「真正」违规执行过,秘密却出来了。

深远的影响

  • Meltdown(推测窗口内越权读内核内存)靠 OS 把内核页表彻底分离(KPTI)修补,代价是系统调用变贵;Spectre 类至今只能逐变种缓解(微码补丁、编译器插 lfence、浏览器降低计时器精度、站点隔离进程模型)——因为推测执行没法关掉,关掉等于回到 1995 年的性能
  • 它改写了安全的定义:ISA 契约只规定了架构状态的行为,而微架构状态(Cache、预测器、缓冲区)同样承载信息。之后的每一代 CPU 设计、每一个沙箱(浏览器、云多租户)都必须回答「推测执行泄露什么」——体系结构与安全从此再也分不开。
「Meltdown/Spectre 是 Intel 的 bug,换家就免疫」——Meltdown 确实主要打中 Intel(及部分 ARM 核),但 Spectre 打的是「分支预测 + 推测执行」这个高性能 CPU 的共同设计:AMD、ARM、Apple Silicon 乃至高性能 RISC-V 实现无一幸免,只是各家的变种清单和缓解组合不同。
你的机器为这对漏洞付的税列得明明白白:grep -r . /sys/devices/system/cpu/vulnerabilities/——每个变种一行,标着 Mitigation(retpoline、IBPB 等)还是 Not affected。这份逐条缓解的清单本身,就是「漏洞在设计假设里、没有一键修复」的最好证据。

显示子系统:从帧缓冲到屏幕

前面八章的数据最终要变成人能看见的东西。这一章补上这条末端链路:一帧画面在内存里长什么样、它以多大的带宽被搬到屏幕、撕裂与卡顿从哪来、以及 GPU 在其中承担了什么——这是「界面为什么会卡」在硬件层面的答案。

屏幕上的每一帧,在内存(或显存)里就是一块连续的字节:帧缓冲(framebuffer)。显示控制器按固定节奏把它一行行读出去送给显示接口——这个过程叫扫描输出(scanout),它是持续不断的,哪怕画面完全静止。

算一笔确定性的账

  • 一个像素常见是 32 位(RGBA 各 8 位,其中 8 位常常不用,纯粹为了对齐);
  • 1920 × 1080 一帧 = 2,073,600 像素 × 4 字节 ≈ 7.9 MiB
  • 60 Hz 下扫描输出的带宽 = 7.9 MiB × 60 ≈ 475 MiB/s(约 3.98 Gbit/s);
  • 换成 4K@120Hz:3840 × 2160 × 4 × 120 ≈ 3.8 GiB/s这两个数字只取决于算术,谁算都一样——它解释了为什么高分辨率高刷新率的设备对内存带宽敏感,也解释了显示接口(HDMI / DisplayPort)为什么要一代代提速。

三个相关概念

  • stride(行距):一行实际占的字节数常常大于「宽 × 每像素字节」,因为要对齐到 64 / 256 字节边界。逐行拷贝时用 stride 而不是宽度,是图形代码里最经典的一条
  • 色深与色域:8 bit/通道是主流,HDR 内容用 10 bit(每像素 30 位),带宽随之上升;
  • 消隐期(blanking):扫描输出在每帧之间有一小段不传像素的间隙,这段时间就是「换缓冲区」的安全窗口——下一卡的 VSync 正是踩在这里。

它和前面几章的连接

  • 帧缓冲是大块连续内存的顺序访问——第 04、05 章讲的 cache 与预取在这里完全适用,逐行访问快、逐列访问慢的规律一模一样;
  • 显示控制器是一个持续占用内存带宽的 DMA 设备:它和 CPU 抢的是同一条内存总线,这在集成显卡的机器上尤其明显。
// 直接往帧缓冲写像素(Linux 的 /dev/fb0,最裸的一条路)
int fd = open("/dev/fb0", O_RDWR);
struct fb_var_screeninfo v;
ioctl(fd, FBIOGET_VSCREENINFO, &v);          // 取分辨率与像素格式

size_t stride = v.xres_virtual * v.bits_per_pixel / 8;   // 行距,不等于 xres*4
size_t size   = stride * v.yres_virtual;
uint8_t *fb = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

// 把第 y 行第 x 列涂成红色(注意用 stride 定位行首)
uint32_t *px = (uint32_t *)(fb + y * stride) + x;
*px = 0x00FF0000;

// 带宽账:1920*1080*4 字节 = 7.91 MiB/帧;60 Hz → 约 475 MiB/s 持续读取
「分辨率越高越清晰」在带宽面前有代价。把 4K@120Hz 的账算出来(约 3.8 GiB/s)就明白:这条流量要么占用系统内存带宽(集成显卡),要么占用显存带宽(独立显卡),而且它是持续的,与画面动不动无关。这也是笔记本外接 4K 屏后续航明显变差、以及低端设备接高分屏会整体变卡的物理原因——不是「渲染变慢了」,是这条固定开销吃掉了带宽预算
想直观感受扫描输出的存在,在 Linux 上试试往 /dev/fb0cat 一堆随机字节(cat /dev/urandom > /dev/fb0,在纯文本控制台下),屏幕会立刻变成雪花——因为显示控制器根本不关心那块内存的语义,它只是不停地把字节当像素送出去。这个实验一秒钟就能建立起「帧缓冲就是一块内存」的直觉。

画面问题有三种完全不同的成因,对应三种不同的修法。把它们分清楚,是排查「界面卡」的第一步——很多人把三者统称为「卡」,于是永远修不对。

三种现象

  • ① 撕裂(tearing):屏幕上半部分是新一帧、下半部分是旧一帧,中间一条明显的错位线。成因是扫描输出正读到一半时,帧缓冲的内容被改了
  • ② 卡顿 / 掉帧(stutter):某一帧算得太久,错过了扫描输出的窗口,于是同一帧被显示了两次。60 Hz 下每帧预算约 16.7 ms,超一点点就掉一整帧
  • ③ 输入延迟(latency):画面流畅但操作「跟手感差」。成因是缓冲层数太多——每多一层缓冲,就多一帧的延迟。

双缓冲与 VSync

  • 双缓冲:画在后台缓冲区,画完再整体切换。切换动作(page flip)只是改一个「当前显示哪块内存」的指针,代价极低;
  • VSync(垂直同步):把切换动作对齐到消隐期,于是撕裂被彻底消除。代价是渲染要等待——如果一帧只超时一点点,帧率会从 60 直接掉到 30(因为必须等下一个窗口);
  • 三缓冲:多一块后台缓冲,让渲染不必等待切换完成,缓解「掉一半帧率」的问题,代价是多一帧延迟;
  • 可变刷新率(VRR / FreeSync / G-Sync):反过来让显示器等 GPU——帧准备好了就扫描输出。这是今天最优的方案,同时解决撕裂与半帧率问题。

判据:怎么区分是哪一种

  • 有横向错位线 → 撕裂,开 VSync 或 VRR;
  • 周期性一顿一顿、帧时间图上有尖峰 → 掉帧,要去找那一帧里干了什么重活(这就是第 03、06 章的性能分析方法);
  • 帧率数字很高但手感发飘 → 延迟,减少缓冲层数、关掉「预渲染队列」。三者的修法互不相干,甚至互相冲突(开 VSync 降撕裂但加延迟)。
// 帧时间才是真相,平均帧率会骗人
// 60 帧里 59 帧用 5 ms、1 帧用 200 ms:
//   平均帧率 = 60 / (59*0.005 + 0.2) ≈ 121 FPS   ← 看起来很棒
//   实际体验 = 每秒一次明显卡顿                    ← 用户只记得这个

// 所以监控要看分位数,不看平均
p50 帧时间 = 5 ms      // 一半的帧比这快
p99 帧时间 = 200 ms    // 百分之一的帧有这么慢 ← 卡顿就藏在这里

// 60 Hz 的预算表(每帧 16.7 ms)
//   输入采样 + 逻辑 + 渲染 + 提交 全部要塞进 16.7 ms
//   120 Hz 则只有 8.3 ms —— 高刷新率是把预算砍半,不是白送的流畅
「关掉 VSync 帧率更高」是个常见的误判。关掉之后计数器上的数字确实更大,但那些多出来的帧根本没有被完整显示过——它们只贡献了撕裂。真正有意义的指标是「显示器实际呈现了多少不同的画面」,上限就是刷新率。唯一合理的关 VSync 理由是降低输入延迟(竞技游戏),而今天更好的答案是 VRR:既不撕裂也不多等。
**衡量流畅度要用帧时间的分位数,不要用平均帧率**——这条规则在图形、游戏、前端(Web 的 INP 指标同理)完全通用。平均值会把「一秒卡一次」平滑掉,而人眼对不均匀极其敏感:稳定的 40 FPS 看起来比在 30~90 之间跳的画面观感稳定得多。

CPU 逐像素往帧缓冲里写,是能用但很贵的做法。现代系统里绝大多数界面绘制由 GPU 完成,而窗口之间的叠加由合成器(compositor)完成——理解这两层的分工,就能理解「为什么某些动画流畅、某些卡」。

GPU 擅长什么

  • 大规模并行的同类操作:几百万像素各自独立地做同一套计算,正好是 GPU 的形态(这与第 07、08 章讲的 SIMD 与并行是同一条思路,只是规模大了两个量级);
  • 纹理采样与混合:缩放、旋转、透明度叠加在 GPU 上几乎是免费的,在 CPU 上是逐像素的乘加;
  • 代价是「过去一趟」:数据要传到显存、命令要提交、结果要同步。小批量、频繁往返的用法会让 GPU 比 CPU 还慢——这就是「批处理(batching)」在图形编程里被反复强调的原因。

合成器做的事

  • 每个窗口画到自己的一块缓冲区(离屏),合成器把它们按层级、透明度、变换叠加成最终一帧
  • 于是「移动窗口」「淡入淡出」「桌面切换」不需要应用重画——合成器拿现成的纹理做变换即可,这就是这类动画总是很流畅的原因;
  • 反过来,只要动画涉及「内容本身变化」(文字重排、列表重新布局),就绕不开应用重画,流畅与否取决于应用自己的帧时间预算。

一条能迁移到任何平台的经验

  • 「只改变换与透明度」的动画走合成器,几乎免费;「改布局」的动画要重排重画,很贵。
  • 这条规律在 Web 前端叫「只动 transformopacity」,在移动端叫「用 layer 动画不用 layout 动画」,在游戏里叫「用矩阵变换不改顶点」——三种说法,同一个硬件事实
  • 判据也一样:动画期间有没有触发「重新计算位置」这一步。有就贵,没有就便宜。
// 同一个「让方块横向移动」的动画,两种实现的硬件路径

// ① 改位置:每帧都要重新计算布局 → CPU 重排 → 重画 → 上传纹理 → 合成
box.x += 2;                    // 布局属性

// ② 改变换:布局与内容都没变 → 合成器直接拿旧纹理做位移 → 合成
box.transform = translate(dx, 0);  // 变换属性

// 帧预算的分配(60 Hz / 16.7 ms 一帧)
//   ① 输入处理     ~1 ms
//   ② 应用逻辑     ~2 ms
//   ③ 布局与绘制   ~6 ms   ← 路径 ① 每帧都要付
//   ④ 提交与合成   ~2 ms   ← 路径 ② 只付这一段
//   剩余余量       ~5 ms   ← 没有余量的系统在负载抖动时立刻掉帧
把一切都丢给 GPU 不会自动变快。三个常见的反例:① 层数过多——每个独立图层都占显存并要参与合成,几百个图层会把带宽吃光;② 频繁的 CPU↔GPU 同步——每帧读回像素(截图、取色)会强制流水线停顿,代价极高;③ 大面积的模糊/毛玻璃——它需要多次采样整块区域,在集成显卡上经常是掉帧的头号元凶。GPU 快是因为并行,不是因为「硬件加速」这四个字
「界面卡」的排查顺序建议固定成三步:① 先看是不是撕裂/掉帧/延迟中的哪一种(上一卡的判据);② 再看每帧的时间花在布局、绘制还是合成(各平台都有对应的分析工具);③ 最后才去优化具体算法。跳过前两步直接优化代码,十次有八次优化错了地方——这与第 03 章讲的「先测量再优化」是同一条纪律。

用代码造一台机器:写一个模拟器

前面各章讲的机制,最快的检验方式不是再读一遍,而是用普通高级语言把它们写出来跑起来:几百行 C 就是一个 RV32I 的取指-译码-执行循环,再给它挂上 Cache 模型和分支预测器——那些「看不见的硬件」于是变成可以逐条打印的计数器。而且模拟器里的数字是确定的:不受频率抖动、后台进程、编译器改写的干扰,你跑出来和这里写的一模一样。

把硬件机制变成手上功夫有两条路:一条造电路(nand2tetris、Verilog/Chisel),一条写模拟器(拿你平时用的语言,写一个执行指令的程序)。两条路检验的东西不同,本章推荐后者当第一站——它离你日常写的代码最近,改一行就能重跑。

模拟器有三档精度,别一上来就挑最难的

档次保证什么典型工具速度
功能级只保证 ISA 语义对:同样的程序算出同样的结果Spike、QEMU慢几十倍
周期近似再挂上流水线、Cache、分支预测器模型,产出周期数与命中率gem5慢几百到几千倍
RTL连每根线每一拍的电平都对Verilator + Verilog慢几万倍
  • 本章做的是第一档,外加第二档最有价值的两个零件:一个 Cache 模型、一个分支预测器。它们各只有几十行,却能把第 04、05 章的说法全部变成可以数出来的数字。

为什么模拟器里的数字比真机上的更适合学习

  • 真机测量要跟三样东西搏斗:频率在动(第 08 章:同一程序连跑 12 次耗时差 18%)、后台进程在抢、编译器会把你要测的代码整段优化掉(第 01 章五个实验里踩了两次)。
  • 模拟器里没有这些:miss 多少次、错判多少次是确定的,换台机器、换个编译器、隔一年再跑,数字一个不差。本章下面每个数字你都能一字不差地复现出来——这是真机实验给不了的东西
  • 更重要的是可观测性:真机的 perf 只给你一个总数,模拟器里你可以问「是哪一条分支错的、错在第几次、这次访存落在哪一组」——想插什么计数器就插什么。
别把目标定成「跑通 Linux」——那要特权级、页表、设备、中断、串口,是另一个量级的工程,绝大多数人卡在这里放弃。先让一个求和循环算对,再一样一样往上加。下面每张卡都是一个可以单独完成、单独验证的小台阶。
选型两句话就够:语言用你最熟的(C/Rust/Go 快,Python 慢十倍但足够看机制);目标 ISA 选 RV32I——基础整数指令只有 40 来条、编码规整(字段位置几乎不随指令变)、官方有测试集,比 x86 简单一个数量级。x86 的变长编码光是「切出这条指令有多长」就够写几百行。

CPU 那个「永不停歇的循环」(第 00 章)翻译成高级语言,就是下面这段:从数组取一个 32 位数、按位切出字段、switch 分派、改寄存器和 PC。第 04 章画的数据通路,在这里是三行注释。

三个必踩的坑(都是静默出错,不会报错)

  • x0 必须作废:RV32I 规定 0 号寄存器恒为零,而 li t0, 0 这类伪指令、比较结果都会往它上面写。漏了 reg[0] = 0 这一行,程序不会崩,只会算出莫名其妙的结果。
  • 立即数要符号扩展(int32_t)insn >> 20 里那个强制转换是关键——有符号数的右移是算术右移,高位补符号位,正好就是符号扩展。写成无符号右移,所有负偏移全错。
  • 分支和跳转的目标是相对「本条指令」的 PC,不是相对 PC+4。写反了循环会少跑或多跑一圈,而且往往还能「跑完」,只是结果不对。

怎么产出可以喂给它的机器码

  • 不需要交叉编译器,一套 binutils 就够(Ubuntu 上包名 binutils-riscv64-linux-gnu)。三步:as -march=rv32i -mabi=ilp32 汇编 → ld -m elf32lriscv -Ttext=0x1000 链接 → objcopy -O binary 拍平成裸镜像。
  • 本卡的代码就是这么跑通的:一段 7 条指令的求和汇编(sum += i 循环到 100),模拟器输出 a0=5050,执行了 303 条指令——3 条初始化 + 100 圈 × 3 条,一条不多一条不少。指令数能对上,说明控制流也对了,这比只看结果强得多。
uint32_t reg[32], pc;          /* 32 个寄存器 + PC */
uint8_t  mem[1 << 20];         /* 一整块内存,就是个数组 */

int step(void) {
  uint32_t insn = ld32(pc);                            /* ① 取指 */
  uint32_t op = insn & 0x7f, rd = insn >> 7 & 31;      /* ② 译码 = 切字段 */
  uint32_t f3 = insn >> 12 & 7;
  uint32_t rs1 = insn >> 15 & 31, rs2 = insn >> 20 & 31;
  int32_t immI = (int32_t)insn >> 20;                  /* 算术右移 = 符号扩展 */
  /* immS / immB / immJ 的拼法略:把编码里散落的位按规则拼回去 */
  uint32_t a = reg[rs1], b = reg[rs2], next = pc + 4;
  switch (op) {                                        /* ③ 执行 */
    case 0x13: reg[rd] = a + immI; break;                          /* addi */
    case 0x33: reg[rd] = (insn >> 30 & 1) ? a - b : a + b; break;  /* add / sub */
    case 0x03: reg[rd] = ld32(a + immI); break;                    /* lw */
    case 0x23: st32(a + immS, b); break;                           /* sw */
    case 0x63:                                                     /* beq/bne/blt */
      if (f3 == 0 ? a == b : f3 == 1 ? a != b : (int32_t)a < (int32_t)b)
        next = pc + immB;
      break;
    case 0x6f: reg[rd] = pc + 4; next = pc + immJ; break;          /* jal */
    case 0x37: reg[rd] = insn & 0xfffff000; break;                  /* lui */
    case 0x73: return 0;                                           /* ecall:停机 */
  }
  reg[0] = 0;      /* 写 x0 一律作废——漏了这行会静默算错 */
  pc = next;
  return 1;
}
别直接把 ELF 文件 fread 进内存当机器码——ELF 有头部和多个段,头几十字节根本不是指令,模拟器会一头撞进「未知指令」。要么老老实实解析 ELF,要么像这里一样用 objcopy -O binary 拍平。拍平之后还有一个约定要对齐:链接时的 -Ttext 必须等于模拟器里 PC 的初值(这里都是 0x1000),否则所有跳转目标全部偏移。
调试的省力办法是objdump -d 当标准答案:模拟器读到的每个 32 位数,反汇编会告诉你它应该是哪条指令、跳去哪里。译码写错时,把「我解出来的 op/rd/imm」和这一行并排一看就现形。再进一步就是每步打印 pc 和变化的寄存器,和真硬件的执行轨迹逐行比对(下一张卡的差分测试)。

模拟器有个真机给不了的能力:你可以在「每一次访存」这个位置插一行代码。挂上一个几十行的 Cache 模型,第 05 章所有说法立刻从「据说」变成「数出来是多少」。

模型比想象中简单

  • 三个数组就够:tag[组][路]valid[组][路]lru[组][路](存一个时间戳)。给定地址:行号 = 地址 ÷ 行大小、组号 = 行号 % 组数、tag = 行号 ÷ 组数——正是第 05 章那张「地址切三段」的图,写成两个除法。
  • 组内挨个比 tag,中了就更新时间戳;没中就挑时间戳最小的换出去。只统计命中率的话,Cache 模型里不需要存一个字节的真实数据——这也解释了 Cache 的本质:它是一张「谁在里面」的账本。

256×256 的 int 矩阵(256 KB),模拟 32 KB / 8 路 / 64 B

访问方式Cache 配置访存次数未命中miss 率
行遍历64 组 × 8 路65 5364 0966.25%
列遍历64 组 × 8 路65 53665 536100%
列遍历全相联(1 组 × 512 路)65 5364 0966.25%
列遍历直接映射(512 组 × 1 路)65 53665 536100%
列遍历,每行补一个 int64 组 × 8 路65 5364 3366.62%
  • 6.25% 就是 1/16:一条 64 B 的行装 16 个 int,第一次未命中把后面 15 个一起带上来。第 05 章「碰一个字节 = 搬 64 字节」的精确形态。
  • 列遍历那 100% 不是容量不够——同样 32 KB、只把相联度换成全相联,miss 立刻掉回 4096。差别全在冲突:列步长 1024 B = 16 条行,组号每次 +16 再模 64,整个 Cache 只有 4 个组在干活,8 路 × 4 组 = 32 条行,装不下一列需要的 256 条。第 05 章那句「2 的幂步长是性能玄学事故的高发区」,这就是它的实物。
  • 最后一行是解药:每行多留一个 int(步长 1024 → 1028 B),组号不再原地打转,miss 从 6.5 万掉到 4336——一个 int 的填充换来 15 倍。数组填充(padding)这个老技巧的全部原理就在这三个数字里。
  • 这五行数字与机器无关:不管你用什么 CPU、什么编译器,跑出来一模一样。
别拿模拟出来的 miss 数去预测真机耗时。模型里「一条访存指令 = 一次访问」,真机上还有取指、页表行走(第 06 章)、硬件预取器、写合并、多个 miss 并行处理——模拟值是个下界,而且预取器往往能把顺序访问的 miss 代价几乎抹平。它教的是机制与方向(谁更差、差在冲突还是容量),不是性能预测。
把这个模型当实验台,十分钟能问出一堆问题的答案:把路数从 8 改成 4、2、1,看列遍历以外的负载什么时候开始塌;把行大小从 64 改成 32 或 128,看 6.25% 怎么变成 12.5% 或 3.1%;给矩阵换个尺寸(255、257),看冲突怎么消失。每改一个参数就重跑一次,比读十页教材管用——这也正是芯片公司做 Cache 设计的方式,只是他们的负载是几十个真实程序。

同样一行插桩,第 04 章那场赌局也能数出来。2 位饱和计数器的全部实现只有四行:一张按 PC 索引的表,每项 0~3;≥2 猜跳;跳了加一、没跳减一

四行的预测器

  • idx = (pc >> 2) & 1023:用 PC 索引一张 1024 项的表(右移 2 位是因为指令四字节对齐,低两位恒为零)。
  • guess = bp[idx] >= 2:状态 0~3 分别是强不跳 / 弱不跳 / 弱跳 / 强跳。
  • 猜错就计数;然后 taken ? bp[idx]++ : bp[idx]--(夹在 0~3 之间)。
  • 再加一个「每个分支点单独计数」的小表,你就有了一个 perf 给不了的 profiler:能精确到哪条分支错了几次。

第 01 章那个经典实验,这次数得清清楚楚

程序是 if (a[i] >= 128) sum += a[i]; 扫 32768 字节数组、跑 100 遍(数组用固定 LCG 生成,所以人人可复现):

数据分支点执行次数2 位预测器错判错判率
随机数据分支 if3 276 8001 635 10049.90%
随机内层循环回边3 276 8001020.00%
排序后数据分支 if3 276 8004000.01%
排序后内层循环回边3 276 8001020.00%
  • 同一份代码、同一条指令,只换了数据,错判从 163 万次降到 400 次。这就是第 01 章那个实验的机理——真机的 perf 只会告诉你「branch-misses 少了 9 倍」,模拟器直接指出是哪一条分支、错了几次。
  • 数据分支的 49.90% 说明:2 位计数器对随机数据毫无办法,等于瞎猜。对照第 04 章的—现代 TAGE 类预测器在同样随机的数据上只错 1.24%——这两个数字之间就是三十年预测器进化的距离
  • 1 位与 2 位的差别要在循环上才看得出来:上一张卡的行遍历里,内层循环回边执行 65 536 次,2 位错 261 次、1 位错 514 次——正好是「每次退出内层循环,1 位错两次(出去一次、回来一次),2 位只错一次」,256 次退出。教科书那句「连错两次才改主意」,就是这两个数。
别把模拟出的准确率当成真机的准确率——你写的是两位状态的玩具,真机是几万比特、多张表、按历史长度竞争的 TAGE。模拟器给你的是机制的方向(这条分支可预测还是不可预测、换数据有没有救),不是绝对值。拿 49.90% 去解释真机上只有 2 倍的差距,就会得出错误结论——这一条第 01 章已经栽过一次。
再加一个 GShare(把索引换成 (pc >> 2) ^ 全局历史,一行的事)会很有意思:它在这个程序上没有任何好处,甚至略差——随机数据 74.90%(2 位是 75.05%),行遍历 99.57%(2 位是 99.60%)。原因很实在:随机就是随机,历史里没有可学的规律;而历史反倒把同一条分支摊到多个表项里,学得更慢。想看到历史真正发挥作用,得造一个「跳-不跳-跳-不跳」这类有模式的分支来喂它。

写完第一版,两个问题会立刻找上门:它真的对吗?为什么这么慢?两个问题的答案都能量化。

先说「对吗」:三级验证

  • 自检程序:跑一个你能手算出答案的程序(求和到 100 得 5050),并且核对指令条数——结果对而条数不对,说明控制流走了另一条路,只是恰好殊途同归。
  • 官方测试集:RISC-V 的 riscv-tests 每条指令一组用例,失败时把用例编号留在寄存器里,直接告诉你哪条指令实现错了。
  • 差分测试(最强):同一个程序在你的模拟器和一个黄金模型(Spike)上同时跑,每执行一条指令就比对全部 32 个寄存器和 PC,第一处不一致就是 bug 现场。写这个对比框架的成本,远低于你为一个静默错误熬的夜。

再说「为什么慢」:本机数字

  • 跑那个 2130 万条指令的程序,模拟器用 0.06~0.09 秒,约 230~360 MIPS。用 perf 看宿主这一侧:执行了 13.37 亿条 x86 指令——每模拟一条 RV32I 指令要花约 63 条宿主指令,宿主 IPC 4.06。「功能级模拟慢几十倍」这句话,具体形态就是这个 63。
  • 开销的大头是那个 switch:它被编成一张跳转表,也就是每模拟一条指令都要走一次间接跳转——正是第 04 章 BTB 卡讲的那种分支。有意思的是本机 perf 显示这条 dispatch 只错 0.90%:因为这个测试程序的 opcode 序列高度规则,宿主的预测器把它学会了。换成 opcode 杂乱的真实负载,它就会变成解释器最大的一笔开销。
  • 解释器界的标准解法是 computed goto:每条指令执行完,直接跳向下一条指令的处理代码,把一个「什么目标都可能」的跳转点拆成几百个各自有规律的跳转点——第 04 章那张卡的结论在这里直接兑现(本机未做 A/B 对照,别把网上流传的倍数当定论,自己测)。
  • 再快就得换路线:动态二进制翻译(QEMU 把一段客户机指令翻译成宿主指令、缓存起来反复执行),代价是失去「每条指令都能插桩」的能力。精度、速度、可观测性,三样里挑两样——这是模拟器领域的基本取舍。

四种工具,四个位置

工具它是什么什么时候用它
SpikeRISC-V 官方功能模型,规范的可执行版本当黄金参考做差分测试
QEMU动态二进制翻译,快到能跑完整操作系统跑真实软件、做交叉验证
gem5周期近似,可配流水线/Cache/预测器做体系结构性能研究
Verilator把 Verilog 编成 C++ 仿真你已经在写真电路了
最容易骗自己的是「跑通了就以为对了」:那个求和循环只用到 addiaddblt 三条指令,RV32I 剩下的三十几条一条没验过。自己写的测试永远只覆盖自己想到的情况——要么跑官方测试集,要么做差分测试,没有第三条路。移位指令的 shamt 只取低 5 位、sraisrli 只差一个 bit、jalr 要把最低位清零,这些坑都不会在你的求和循环里露面。
想接着做下去,路线正好是本页的倒序复习:加 M 扩展(乘除)→ 加 CSR 与陷入,让 ecall 变成真的系统调用 → 加 Sv32 页表,亲手写一遍第 06 章的页表行走和 TLB → 加一个五级流水线模型,把第 04 章的冒险与转发写成代码,看着 CPI 从 1 慢慢逼近理想值。每一步都对应本页的一章,停在哪一步都算数

从这里到精通:路线图

从比特到多核,这张地图到这里铺完了。但体系结构是一门造过才算懂的课——最后这一章给出收尾路线:难度递进的动手项目、按阶段的书单,以及一条自测标准。

这一页给了你地图,但精通只能来自亲手走过。按顺序,每一步都有明确的产出物。

动手项目(难度递进)

  • ① nand2tetris:从 NAND 门搭到能跑俄罗斯方块的完整计算机。做完它,第 00、02~04 章从「懂了」变成「造过」。(约 2~4 周业余时间,前 6 章硬件部分对应本页)
  • ② 写一个 RISC-V 模拟器:几百行 C/Rust 实现 RV32I 的取指-译码-执行循环,跑通官方测试集。ISA、寻址、调用约定从此刻进肌肉。第 10 章已经把这条路铺到能跑——核心循环、Cache 模型、分支预测器、验证办法都在那一章,接着往上加 M 扩展、CSR、页表、流水线模型即可。(配合汇编页 RISC-V 章)
  • ③ 性能实验集:用 perf 复现本页每个可测现象——分支预测(排序 vs 未排序)、Cache 行遍历、false sharing、TLB miss、内存带宽上限。每个实验写下「预测值 vs 值」,对不上就深挖——这是性能工程师的日常训练。
  • ④ 进阶选修:给 QEMU/gem5 加个简单设备模型;用 Verilog 在 FPGA 上跑一个五段流水线核(如「一生一芯」计划)。

书与资料(按阶段)

  • 入门起步:CS:APP(深入理解计算机系统)——程序员视角的体系结构,配套 lab(bomb lab、cache lab)质量极高,本页多章与之呼应。
  • 系统学习:Patterson & Hennessy《计算机组成与设计》(RISC-V 版)——单周期到流水线的标准教材。
  • 登堂入室:Hennessy & Patterson《计算机体系结构:量化研究方法》——乱序、多核、DSA 的研究生级正典。
  • 保持更新:Agner Fog 优化手册、uops.info、Chips and Cheese 架构分析、ISCA/MICRO 会议的获奖论文。
书单是难度阶梯,不是并列推荐:跳过 CS:APP 直接啃《量化研究方法》,大概率在前两章被公式挡住——它默认你已具备本科体系结构的全部基础。另一个极端「读完三本再动手」同样走不远:每读完一段就做对应的动手项目,「造过」才能沉淀成「懂了」。
一三个项目里挑一个做完,比把这页重读五遍管用得多:亲手写过模拟器的人,对流水线阻塞的理解和只看过流水线图的人不在一个层级——因为模拟器会强迫你回答「这一拍到底谁在等谁」这种图上永远不画的问题。