全景:计算机是怎么跑起来的
在钻进任何细节之前,先建立一张完整的地图:比特从哪里来、「程序存在内存里」意味着什么、你的代码和电流之间隔着几层抽象、以及「快」到底怎么衡量。后面每一章都是把这张地图的某一块放大。
整台机器立在两个决定上:用两种状态而不是十种表示信息,以及把指令和数据放进同一个存储器。前者定了硬件形态,后者定了此后八十年的软件形态。
① 二进制是工程必然,不是数学偏好
- 晶体管本质是电控开关,只需可靠地处于通/断两态,制造偏差与噪声都不影响判断;把开关组合起来得到逻辑门,一种 NAND 就能搭出所有数字电路;
- 关键在于信号每过一级门都被掰回标准的 0 或 1,噪声不累积——这就是芯片能塞几百亿个晶体管还能工作的原因。
② 存储程序:软件从此诞生
- 1945 年那句「指令和数据以同样的方式存在同一个存储器里」之前,编程是重新接线;程序既然是数据就能被另一个程序读写,编译器、调试器、JIT 全建立在这条上;
- 由此定下经典结构:CPU、存储器、输入、输出,CPU 按取指 → 译码 → 执行永不停歇。代价是冯·诺依曼瓶颈——通道要同时供指令和数据,而 CPU 越来越快、内存跟不上;
- 开机是这条原理最极致的演出:复位后 PC 被硬件写死指向固件,固件 → bootloader → 内核 → 第一个用户进程,每一棒都把下一棒从「数据」变成「正在执行的程序」。
从 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 章)。
-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 定律在日常工程里的形态。
上手:把硬件行为量出来
这门课最容易学成背名词。先把五个实验在自己机器上跑一遍——分支预测、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 很友好」。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 章)。
这一页自下而上:从数据在机器里长什么样起步,逐层搭到指令集、微架构、存储层次与并行。前一章的产物是后一章的原料,顺着读最省力。
四段结构
- 02 数据的机器表示 → 03 指令集:地基。补码与浮点不过关,后面所有「为什么结果不对」都解释不了;
- 04 CPU 微架构:本页主线也最厚——流水线、分支预测、乱序全在里面,上手章那个分支预测实验在这里得到解释;
- 05 存储层次与 Cache → 06 虚拟内存:性能的真正大头,上手章另外三个实验的答案都在这两章;
- 07 I/O → 08 并行 → 09 显示子系统走到整机,10 用代码造一台机器把前面九章变成能跑的模拟器,11 是路线图。
数据的机器表示
全景图的第一站是「数据怎么表示」,现在把它放大:内存里只有比特,「类型」是人赋予比特的解释方式。这一章解决两个问题——整数和小数如何编码成比特(以及这些编码方式如何在你的代码里埋雷),硬件又如何对这些比特做算术。
十六进制不是「另一种进制」,而是二进制的速记法:一个 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(零扩展)并存的原因。
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 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)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 检查可能被删、结果可能改变。开它之前要知道自己在交易什么。0.1+0.2 与 0.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 内容是垃圾,且对端布局可能不同)。
union { int i; unsigned char b[4]; } u = {.i = 1}; printf("%d", u.b[0]); 输出 1 是小端。offsetof(struct A, x) 能打出每个成员的真实偏移——比背规则可靠。你写 a/b 和 a+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——真正值得手工消除的只有热路径上的变量除法。比特不是永恒的:宇宙射线能翻转内存里的一位,传输线上的噪声能污染一段报文。校验码是「用冗余比特换检错/纠错能力」的一门生意,三种主流方案对应三档投入。
三档方案
- 奇偶校验(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,是成本权衡,不是不需要。
指令集:软硬件的契约
上一章的比特光躺着没用,得有指令去搬运它、运算它——而指令本身也是一串比特。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 ret8d 04 37这三个字节里编码了:操作(lea 计算地址)、目的寄存器(eax)、两个源寄存器(rdi、rsi)。指令 = 操作码 + 操作数说明,所有 ISA 概莫能外。- 指令大致分五类:算术逻辑(add/and/shl)、数据搬运(load/store/mov)、控制流(jmp/branch/call/ret)、系统(syscall、特权指令)、SIMD(向量运算)。任何语言的任何程序,最终都摊平成这五类的序列。
寄存器:指令的工作台
- 运算不直接发生在内存上(RISC 中严格如此):数据先 load 进寄存器,算完再 store 回去。寄存器只有十几个,但访问不产生额外周期——它们就在 ALU 旁边。
- 编译器的一大核心工作就是寄存器分配:让最热的变量常驻寄存器。寄存器不够用时变量「溢出」到栈上,性能立降——这就是局部变量比全局变量快的底层原因之一。
gcc -S 和 objdump -d 默认输出 AT&T,本卡示例和 godbolt 默认是 Intel——加 -M intel/-masm=intel 统一之后再对照,否则会把 mov 的方向整个读反。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 契约,任何人可以照着造芯片——契约的所有权本身就是产业权力。
把「操作 + 操作数」压进比特是个编码问题,两大流派给出了相反的答案,而权衡的核心是:译码器的复杂度 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 从第一天就预留了扩展编码空间——设计的先见之明体现在二十年后。
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_64、aarch64 对比同一逻辑的字节——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,数据流就全分析错了。*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)采样时的栈展开质量直接受此影响。
八十年代最激烈的架构之争,结局出人意料:两派在微架构层殊途同归,差异只剩下契约表面。理解这段历史比背对比表格有价值得多。
当年的分歧
- 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 免费开放。
rep movsb(内存拷贝指令)在新 CPU 上突然变快,是因为微码把它重新实现了(ERMSB);某些「一条」指令特别慢,是因为它拆出了几十个 μops。指令数 ≠ 工作量,μops 才是。CPU 微架构:从时钟到乱序执行
契约之下,CPU 怎么真正把指令跑起来、并且一代比一代快?这一章顺着历史与逻辑的共同脉络走:单周期实现 → 流水线 → 冒险与转发 → 分支预测(跳不跳,以及跳去哪)→ 乱序执行 → 超标量与 SMT,终点是解剖一颗现代 CPU。
看懂 CPU 只需要三个电路概念。花十分钟建立它们,后面所有微架构图都会变得透明。
三个概念
- 组合逻辑:输出只由当前输入决定的电路(加法器、译码器、多路选择器 MUX)。信号穿过需要时间——门延迟,路径越长越慢。它没有记忆。
- 时序逻辑:加上触发器/寄存器——只在时钟边沿把输入「拍照」存下来的元件。有了它电路才有状态,才能记住「现在执行到哪了」。
- 时钟:全芯片统一的节拍器。每个时钟周期做同一件事:寄存器输出 → 穿过一片组合逻辑 → 结果在下一个边沿存进寄存器。周期必须长过最慢的那条组合路径(关键路径),否则存进去的是没算完的垃圾。
这就是主频的全部秘密
- 「5 GHz」的意思是:这颗芯片最长的寄存器到寄存器路径能在 0.2ns 内稳定。想提频只有三条路:让晶体管更快(工艺)、把长路径切短(加流水级,下一张卡)、砍掉复杂逻辑(简化设计)。
- CPU 从此可以理解为:一大坨组合逻辑,被时钟驱动的寄存器切成一段一段。微架构设计就是决定怎么切、切几段、每段放什么。
先造出「能跑但不快」的 CPU:每个时钟周期完整执行一条指令。所有教学 CPU(包括你在 nand2tetris 里搭的)都是它。
数据通路:指令流过的管道
PC → 指令存储器 → 译码/读寄存器堆 → ALU → 数据存储器 → 写回寄存器 ↑___________________ PC+4 或分支目标 ____________________|
- 每条指令都流经这条通路,不同指令用到不同的段:加法跳过数据存储器;load 用满全程;分支用 ALU 算比较、结果改 PC。MUX(多路选择器)+ 控制信号决定每一站的数据从哪来、到哪去。
- 控制器就是「看操作码、拉控制线」的组合逻辑:见到 load 就拉起「存储器读」「写回选存储器」;见到分支就拉起「PC 选分支目标」。教材说的「硬布线控制器」即此——一张真值表而已。
它为什么慢——以及两条出路
- 时钟周期必须容纳最慢的指令(load 要走完取指+译码+ALU+访存+写回全程)。加法明明一半时间就能完成,也被迫等一整拍——所有指令为最长路径买单。
- 出路一(历史):多周期——把一条指令拆成几拍,短指令少用几拍。控制器随之复杂化,「微程序控制」正是那个时代管理复杂控制的手段:控制信号不再由门电路拼出,而是存成控制存储器里的「微指令」逐拍读出。它作为设计方法已退场,但以 μcode 形式活到今天——x86 最复杂的指令仍由微码序列实现,Intel/AMD 靠微码更新修 CPU bug(Spectre 的部分缓解就是这么打上的)。
- 出路二(现代):流水线——不缩短单条指令的时间,而是让多条指令重叠。下一张卡。
上一张那台单周期 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 级。
流水线的美好假设是「指令之间互不相干」,现实是它们抢资源、传数据、还互相决定谁执行。三种冲突(冒险)各有解法,解法的思想比结论重要。
结构冒险:抢硬件
- 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 条指令一个分支,等不起。
- 解法是赌:猜一个方向继续取,猜错再冲掉重来——分支预测,下一张卡。它重要到值得单独一张。
上一卡三种冒险里控制冒险最贵——流水线越深,猜错一次作废的周期越多。所以现代 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 化的威力。
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 s | 1.8 万 | ≈ 0% |
| 两个交替 0,1,0,1… | 0.420 s | 4997 万 | ≈ 100% |
| 两个中随机 | 0.241 s | 2505 万 | 50% |
| 四个轮转 0,1,2,3… | 0.390 s | 4984 万 | ≈ 100% |
| 四个中随机 | 0.316 s | 3748 万 | 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 stat 的 branch-misses 把「方向错判」和「目标错判」合并计数。看到这一栏高就去排序数组、改 branchless,可能完全打错靶——先分清错的是哪一种(细分事件名各家不同,AMD 与 Intel 的 PMU 事件表不通用)。反过来也别把「间接调用慢」当教条:目标稳定时它没有额外开销,间接调用真正的代价往往不是那次跳转,而是它挡住了内联,连带后面所有跨函数优化都做不成。分支预测解决的是「取指不要停」,可指令进了流水线之后还会因为等数据而堵住。按程序顺序执行时,一条等内存的 load 会堵住后面所有不相干的指令。乱序执行的原则只有一句:谁的操作数先备齐谁先算,最后再假装是按顺序算的。
三个机制,一台机器
- 寄存器重命名:两条指令用了同一个寄存器名但数据无关(WAR/WAW,「名相关」),给它们各发一个物理寄存器别名,假冲突消失。ISA 的 16 个寄存器名背后是 200+ 个物理寄存器。剩下的 RAW(真数据依赖)无法消除——只能转发和等待。
- 保留站/发射队列(Tomasulo 的遗产):译码后的指令带着「我在等谁的结果」进队等待;每个执行结果广播到所有等待者,操作数凑齐的指令立即发射——数据流驱动取代程序顺序。
- 重排序缓冲 ROB:乱序算完 ≠ 乱序生效。所有指令按程序顺序进入 ROB,执行完打上「完成」标记,但只有排到队头才允许提交(真正改寄存器/内存)。于是异常发生时,未提交的后续指令全部一笔勾销——对外呈现的永远是「精确的、按序的」状态,契约得以维持。
深入理解
- 乱序的真正目标是容忍内存延迟:一条 miss 到 DRAM 的 load 要等几百周期,乱序窗口(现代 300~600+ 条指令)让 CPU 在等待期间继续挖掘后面的独立工作。窗口大小基本决定了「能藏住多深的延迟」——这是各家旗舰核心比拼 ROB 大小的原因。
- 推测(分支预测)+ 乱序组合起来意味着:CPU 时刻在执行可能根本不该执行的指令,只是结果不提交。「不提交」不等于「无痕迹」——推测执行会留下 Cache 痕迹,这正是 Spectre 的入口(第 08 章)。
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,但执行资源只有一份。
把前面所有零件装回一颗真实芯片,看看 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 倍功耗,混搭比全大核能效高得多(下表是同一颗芯片上的两种哲学)。手机十年前就这么干(ARM big.LITTLE),Intel 2021 年跟进,OS 调度器为此专门改造(联动 OS 页调度章)。
性能核 P-core 能效核 E-core 设计取向 宽、深、乱序,能冲高频 窄、浅,省电 面积 / 功耗 大 / 高 小 / 低(同面积可塞多个) 该跑什么 前台冲刺负载、单线程敏感任务 常驻后台、批量并行任务 放错核的代价 后台任务霸占大核 → 费电发热、逼着降频拖累全局 前台任务困在小核 → 直接卡顿
nproc 和 lscpu 的「CPU(s)」计的是逻辑 CPU(含 SMT),真核数 = Core(s) per socket × Socket(s),lscpu 里两行并排可对照。容量规划把逻辑核当物理核,算力会高估近一倍——「16 核」和「8 核 16 线程」不是一回事。存储层次与 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)
| 层 | 容量 | 相联度 | 行大小 | 归属 |
|---|---|---|---|---|
| L1d | 32 KB / 核 | 8 路 | 64 B | 私有 |
| L1i | 32 KB / 核 | — | 64 B | 私有 |
| L2 | 1 MB / 核 | 8 路 | 64 B | 私有 |
| L3 | 16 MB | 16 路 | 64 B | 全核共享 |
- 组数可以当场验算:组数 = 容量 ÷(行大小 × 路数)——32 KB ÷ (64 × 8) = 64 组,index 恰好 6 位。拿你自己机器的
getconf -a | grep -i cache代进去算一遍,「组」和「路」就再也不会混。 - 顺带看见「口径不统一」这个坑的实物:同一台机器上
getconf报LEVEL1_DCACHE_SIZE 32768(单核视角),lscpu报L1d cache: 64 KiB (2 instances)(所有实例之和),而产品页会宣传 L3「16 MB 大缓存」(全核共享总量)。三个数字都对,说的不是同一件事。
金字塔为什么可行:局部性
- 快的存储必然小(SRAM 一个 bit 要 6 个晶体管、还得离核近),大的必然慢——物理定律,没有两全。层次结构的赌注是程序行为的两条经验律:时间局部性(刚用过的很快再用——循环变量)、空间局部性(用了这个就会用旁边的——数组遍历、顺序取指)。
- 只要局部性成立,「小而快的层」就能拦截绝大多数访问,整个金字塔用 5% 的成本呈现出 95% 的快。局部性差的程序(随机指针追逐)则跌穿金字塔,直面 DRAM 的真实延迟——快慢差距可达百倍,这就是数据结构选择背后的硬件真相。
金字塔的上下两层用了两种物理原理迥异的存储电路,各自的「不得已」塑造了系统设计。
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 手里就有代码可取(只能预先固化)。层层易失、最终落到非易失,存储金字塔到此闭环。
| SRAM | DRAM | |
|---|---|---|
| 一个 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 的幂步长」是性能玄学事故的高发区。
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 少用 普遍选择
fsync、掉电丢数据的讨论说的是后者(DRAM → 磁盘那一层);CPU Cache 的脏行落在 DRAM 里,而 DRAM 本身就易失,谈不上持久性问题。两层各有一套「脏标记 + 何时往下写」,骨架相同、层次不同。这是全章的现金兑现点:同样的算法复杂度,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 效应):三列一起看才是完整的故事:耗时差 48 倍是结果,cache-misses 差 900 倍是直接原因,而 dTLB-load-misses 差 340 倍说明列遍历同时把 TLB 也打穿了(每跳 16 KB,几乎每次访问都换页)。课本只讲 Cache,告诉你 TLB 一起在受害。
耗时 cache-misses miss 率 dTLB-load-misses 行遍历 0.005 s 11 843 0.33% 50 478 列遍历 0.220 s 10 846 758 14.31% 17 084 757 倍数 慢 48× 多 约 900× — 多 约 340× - 倍数会因机器而异(内存带宽、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 不存在」。凡是拿循环计时的实验,先确认循环真的跑了。
-O2 删空循环、首轮缺页)。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 也成了需要调度的资源。
lscpu 的 L1d 一行给的是所有实例之和(如「64 KiB (2 instances)」= 每核 32KB);getconf 给的才是单核视角。口径不统一,跨 CPU 的对比结论就是错的。/sys/devices/system/cpu/cpu0/cache/index*/ 里看得一清二楚:每个目录的 level、size、ways_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 倍、带宽也打折,这就是「非一致内存访问」。所以多路机器上「线程跑在哪、内存分配在哪」要配对,否则通道堆再多也吃不满。
虚拟内存:地址的幻术
上一章 Cache 判命中比的是物理 Tag,可程序手里的指针全是虚拟地址——翻译这一步就横在访存旅程的最前面。每个进程都以为自己独占从 0 开始的完整地址空间,这是硬件(MMU/TLB)和操作系统合谋维持的幻术。本章讲硬件这一半:地址如何翻译、TLB 为何存在、L1 怎么和翻译并行赛跑、一次访存的完整旅程。策略那一半(置换、COW、mmap)在 OS 页的内存管理与虚拟内存两章。
让程序直接用物理地址,三个问题立刻爆炸;虚拟内存用「加一层翻译」同时解决三个。
三个问题 → 一个机制
- 保护:物理地址人人可算,进程 A 一个野指针就能改写进程 B 的数据、甚至内核。加了翻译层,A 的页表里根本没有映到 B 内存的条目——访问不到 = 绝对隔离,越权访问在翻译时被硬件拦下(段错误的来源)。
- 重定位:程序编译时不可能知道自己会被装到物理内存哪里。有了虚拟地址,每个程序都从固定布局开始(代码在低址、栈在高址),装哪儿由页表决定——编译器、链接器从此不关心物理内存。
- 超额分配:所有进程的虚拟空间总和远超物理内存也没关系——没访问的页不占物理页,冷页可以换出到磁盘。物理内存变成了「所有进程虚拟空间的缓存」——金字塔思想再次登场,DRAM 之下又垫了一层。
核心机制一句话
- 地址空间按 4KB 分页;虚拟页 → 物理页框的映射记在页表里;每次访存都由 MMU(内存管理单元,CPU 中专管地址翻译的硬件)自动查表翻译。程序手里的每个指针都是虚拟地址——包括你 printf 出来的
%p,两次运行还不一样(ASLR 随机化了布局,回忆第 03 章 PC 相对寻址)。
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 页)。
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)性能差的根源。
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 章),要多跑几轮才看得准。
把 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 页虚拟内存章。
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 上就现形。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 在等待期间 | 空转反复读状态 | 去干别的事 | 去干别的事 |
| 谁搬数据 | CPU 逐字节 | CPU 逐字节 | DMA 引擎,搬完才中断一次 |
| 每次事件的固定开销 | 极低 | 较高(陷入、冲流水线、污染 Cache) | 低(按批摊薄) |
| 适合的事件密度 | 极高(每秒百万级)或极低延迟要求 | 中低(事件稀疏) | 大块数据传输 |
| 典型 | DPDK / SPDK 独占核轮询 | 键盘、慢速串口 | 网卡、NVMe、GPU |
「设备拉一根线」的中断模型早已进化成一套可路由、可编程的消息系统。
从引脚到消息
- 经典模型:设备连到中断控制器的物理引脚,控制器按优先级仲裁后打断 CPU;CPU 拿着中断号查中断向量表跳到对应处理函数。线是稀缺的、共享线还要逐个问「是你吗」。
- 现代 MSI/MSI-X(消息信号中断):中断变成一次内存写——设备往特定地址写一个值,就算发了中断。不占引脚、一个设备可有上千个独立向量、还能指定送到哪个核——多队列网卡给每个 CPU 核配独立收包队列+独立中断,并行收包互不干扰(irqbalance/RSS 调优的原理)。
软硬件的接力(与 OS 页衔接)
- 硬件负责:保存最小现场(PC、标志)、切内核栈、跳向量入口——「中断隐指令」说的就是这段自动动作。
- 内核负责:上半部尽快应答硬件、登记工作;下半部(softirq 等)延后做重活——「关中断的时间必须极短」这条纪律贯穿所有 OS 的中断设计(细节在 OS 页中断卡)。
- 异常(除零、缺页)与中断走同一套向量机制,区别只在来源:异常是当前指令同步产生的,中断是外设异步插入的——所以异常可精确归因到指令,中断不行。
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 直通)也靠它隔离。
上一张 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 管的是「设备能碰哪些内存页」,管不了同组设备之间的横向通信,也管不了设备被当作侧信道使用。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 年后已反转。
认识真实机器里的几条主干道,以及正在改变数据中心的新趋势。
版图
- 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 共享数据免拷贝;用途二:内存池化——内存不再焊死在某台服务器上,机柜级共享、按需分配,直击数据中心「内存占成本一半且利用率低」的痛点。它是「总线演化」故事的最新一章:互连正在从「传数据」升级为「共享内存语义」。
并行与现代体系结构
前六章拼出了一颗完整的单核机器。可就在 2005 年前后,主频狂飙的时代撞上功耗墙,「让单核更快」这条路走到了尽头,性能增长的接力棒交给了并行与专用化。这一章从那堵墙讲起(以及它每天在你机器上的形态:睿频与降频),再看多核世界的新问题(一致性、内存模型)、数据并行的力量(SIMD/GPU)、专用化浪潮(DSA),以及推测执行时代的安全课(Spectre)。
2004 年 Intel 取消 4GHz Pentium 4,是体系结构史的分水岭。理解那堵墙,才能理解之后二十年的一切设计选择。
物理账本
动态功耗 P ∝ C × V² × f // 电容 × 电压平方 × 频率
提频需要提压 → 功耗随频率近似立方增长 → 散热撞死在 ~100W/cm²- 过去的免费午餐叫 Dennard 缩放:晶体管变小,电压同步降低,单位面积功耗不变——频率年年白涨。2005 年前后电压逼近物理下限(漏电流失控),缩放终结:晶体管还在变多(摩尔定律苟着),但不能全部同时全速跑(暗硅)。
- 同样的功耗预算,两个 3GHz 核的吞吐远超一个 4.5GHz 核——于是工业界集体转向:多核、大小核、SIMD、专用加速器,全是「把功耗花在刀刃上」的不同刀法。软件的免费午餐同时结束:单线程程序再也不会自动变快,并发编程从此是必修课(OS 页并发同步章的存在理由)。
上一张卡的功耗墙不是历史事件,它每天都在你的机器上生效: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/cpuinfo 的 cpu MHz 当实际频率——它在很多环境里是个静态值。本机 WSL2 里它无论空载满载都固定报 3792.624,而同一时刻的真实频率是 4.15~4.26 GHz;lscpu 干脆一行 MHz 都不给,/sys/devices/system/cpu/cpu0/cpufreq/ 目录也不存在(虚拟机里看不到宿主的调频)。要频率就用 cycles ÷ 时间自己算,这个办法在裸机、虚拟机、容器里一律成立。每个核都有私有 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 页原子操作卡)。
多线程靠「锁」协调,可锁本身也只是内存里的一个变量。用普通的读-改-写去抢锁,两个核会同时读到「没人占」、然后同时占上——锁没锁住。造出一把真锁,前提是有一条硬件保证「读-改-写中间绝不被插队」的指令。
两大流派
- x86:
lock前缀 + 读改写指令。lock add、lock xchg、lock cmpxchg让这条读-改-写在内存系统看来不可分割:早期真的锁住总线,现在是把目标 cache 行拉到独占态、改完才放手(上一张 MESI 卡)。其中 CAS(compare-and-swap,cmpxchg)最关键:比较内存与「期望值」,相等才写入新值并报告成功——一条指令完成一次「乐观更新」。 - ARM / RISC-V(弱序 RISC):LL/SC。
load-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 的理由。多线程程序员迟早撞见的鬼故事:两个核看到的「写入顺序」可以不一样。这不是 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,不要用裸变量传递信号」这条纪律的硬件依据就在这。
x=1; r1=y 与 y=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 内两条路径串行各走一遍 适合的负载 逻辑复杂、分支多、延迟敏感 规整、海量、算术密集
通用 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 专用单元)。体系结构不再是软件的透明地基,而是设计对话的另一方——这门课因此比十年前更值得学。
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 设计、每一个沙箱(浏览器、云多租户)都必须回答「推测执行泄露什么」——体系结构与安全从此再也分不开。
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 持续读取/dev/fb0 里 cat 一堆随机字节(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 —— 高刷新率是把预算砍半,不是白送的流畅CPU 逐像素往帧缓冲里写,是能用但很贵的做法。现代系统里绝大多数界面绘制由 GPU 完成,而窗口之间的叠加由合成器(compositor)完成——理解这两层的分工,就能理解「为什么某些动画流畅、某些卡」。
GPU 擅长什么
- 大规模并行的同类操作:几百万像素各自独立地做同一套计算,正好是 GPU 的形态(这与第 07、08 章讲的 SIMD 与并行是同一条思路,只是规模大了两个量级);
- 纹理采样与混合:缩放、旋转、透明度叠加在 GPU 上几乎是免费的,在 CPU 上是逐像素的乘加;
- 代价是「过去一趟」:数据要传到显存、命令要提交、结果要同步。小批量、频繁往返的用法会让 GPU 比 CPU 还慢——这就是「批处理(batching)」在图形编程里被反复强调的原因。
合成器做的事
- 每个窗口画到自己的一块缓冲区(离屏),合成器把它们按层级、透明度、变换叠加成最终一帧;
- 于是「移动窗口」「淡入淡出」「桌面切换」不需要应用重画——合成器拿现成的纹理做变换即可,这就是这类动画总是很流畅的原因;
- 反过来,只要动画涉及「内容本身变化」(文字重排、列表重新布局),就绕不开应用重画,流畅与否取决于应用自己的帧时间预算。
一条能迁移到任何平台的经验
- 「只改变换与透明度」的动画走合成器,几乎免费;「改布局」的动画要重排重画,很贵。
- 这条规律在 Web 前端叫「只动
transform与opacity」,在移动端叫「用 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 ← 没有余量的系统在负载抖动时立刻掉帧用代码造一台机器:写一个模拟器
前面各章讲的机制,最快的检验方式不是再读一遍,而是用普通高级语言把它们写出来跑起来:几百行 C 就是一个 RV32I 的取指-译码-执行循环,再给它挂上 Cache 模型和分支预测器——那些「看不见的硬件」于是变成可以逐条打印的计数器。而且模拟器里的数字是确定的:不受频率抖动、后台进程、编译器改写的干扰,你跑出来和这里写的一模一样。
把硬件机制变成手上功夫有两条路:一条造电路(nand2tetris、Verilog/Chisel),一条写模拟器(拿你平时用的语言,写一个执行指令的程序)。两条路检验的东西不同,本章推荐后者当第一站——它离你日常写的代码最近,改一行就能重跑。
模拟器有三档精度,别一上来就挑最难的
| 档次 | 保证什么 | 典型工具 | 速度 |
|---|---|---|---|
| 功能级 | 只保证 ISA 语义对:同样的程序算出同样的结果 | Spike、QEMU | 慢几十倍 |
| 周期近似 | 再挂上流水线、Cache、分支预测器模型,产出周期数与命中率 | gem5 | 慢几百到几千倍 |
| RTL | 连每根线每一拍的电平都对 | Verilator + Verilog | 慢几万倍 |
- 本章做的是第一档,外加第二档最有价值的两个零件:一个 Cache 模型、一个分支预测器。它们各只有几十行,却能把第 04、05 章的说法全部变成可以数出来的数字。
为什么模拟器里的数字比真机上的更适合学习
- 真机测量要跟三样东西搏斗:频率在动(第 08 章:同一程序连跑 12 次耗时差 18%)、后台进程在抢、编译器会把你要测的代码整段优化掉(第 01 章五个实验里踩了两次)。
- 模拟器里没有这些:miss 多少次、错判多少次是确定的,换台机器、换个编译器、隔一年再跑,数字一个不差。本章下面每个数字你都能一字不差地复现出来——这是真机实验给不了的东西。
- 更重要的是可观测性:真机的
perf只给你一个总数,模拟器里你可以问「是哪一条分支错的、错在第几次、这次访存落在哪一组」——想插什么计数器就插什么。
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;
}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 536 | 4 096 | 6.25% |
| 列遍历 | 64 组 × 8 路 | 65 536 | 65 536 | 100% |
| 列遍历 | 全相联(1 组 × 512 路) | 65 536 | 4 096 | 6.25% |
| 列遍历 | 直接映射(512 组 × 1 路) | 65 536 | 65 536 | 100% |
| 列遍历,每行补一个 int | 64 组 × 8 路 | 65 536 | 4 336 | 6.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、什么编译器,跑出来一模一样。
同样一行插桩,第 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 位预测器错判 | 错判率 |
|---|---|---|---|---|
| 随机 | 数据分支 if | 3 276 800 | 1 635 100 | 49.90% |
| 随机 | 内层循环回边 | 3 276 800 | 102 | 0.00% |
| 排序后 | 数据分支 if | 3 276 800 | 400 | 0.01% |
| 排序后 | 内层循环回边 | 3 276 800 | 102 | 0.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 次退出。教科书那句「连错两次才改主意」,就是这两个数。
(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 把一段客户机指令翻译成宿主指令、缓存起来反复执行),代价是失去「每条指令都能插桩」的能力。精度、速度、可观测性,三样里挑两样——这是模拟器领域的基本取舍。
四种工具,四个位置
| 工具 | 它是什么 | 什么时候用它 |
|---|---|---|
| Spike | RISC-V 官方功能模型,规范的可执行版本 | 当黄金参考做差分测试 |
| QEMU | 动态二进制翻译,快到能跑完整操作系统 | 跑真实软件、做交叉验证 |
| gem5 | 周期近似,可配流水线/Cache/预测器 | 做体系结构性能研究 |
| Verilator | 把 Verilog 编成 C++ 仿真 | 你已经在写真电路了 |
addi、add、blt 三条指令,RV32I 剩下的三十几条一条没验过。自己写的测试永远只覆盖自己想到的情况——要么跑官方测试集,要么做差分测试,没有第三条路。移位指令的 shamt 只取低 5 位、srai 与 srli 只差一个 bit、jalr 要把最低位清零,这些坑都不会在你的求和循环里露面。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 会议的获奖论文。