CPU 体系结构 入门

一颗 3 GHz 的核心并不是每秒跑 30 亿条指令,偏差还是双向的:紧凑循环每周期退休 4 条,走一遍链表是每 250 个周期 1 条。 6 个 section 把让这两件事同时成立的机器搭出来 ——循环、流水线、预测器、乱序发射、投机,以及速查表。这里每个数字都是从旁边那张图读出来的。

01

一个核心,一个循环

一个核心就是一台一直跑同一个循环的小状态机。后面 4 个 section 让这个循环更快,同时不改变它的含义。

干活的是 3 个结构:x86-64 上 16 个有名字的 64 位寄存器堆(ARM64 是 31 个)、一个 ALU,以及程序计数器。

一条 add rax, rbx 会按顺序访问它们全部。点播放,看这条指令一个结构一个结构地走,下面亮起的是它读的寄存器:

第 1 步 / 共 5 步 —— 取指

注意寄存器堆被碰了两次 —— 一次读操作数,一次写和 —— 而核心外面的东西一次都没碰。读一个寄存器是执行这条指令的一部分,不是一件另有延迟的事。

不是每条指令都是加法。下面这个梯子是一颗 Skylake 核心为每一类要付的周期数,滑块走过正在被计费的那一条:

add r, r —— 1 周期 = 0.33 ns —— 加法的 1 倍

这个跨度就是整页的故事。寄存器加法1 个周期 —— 3 GHz 下 0.33 ns —— 而一次穿透所有缓存的读取是 250 个周期,也就是 83 ns。两根横档之间差了 250 倍,再高的频率也补不上。

16 个寄存器不算多。把同时必须活着的值调多,看没地方坐的那些掉进下面那一行:

8 个活跃值 —— 8 个都在寄存器里,没有溢出

24 个活跃值时有 8 个溢出, 每个溢出的值每轮迭代要付一次写和一次读 —— 源码里根本没写的 16 次访存。这才是激进内联真正的收益。

那为什么不做一台寄存器更多的机器?寄存器的名字得写进指令里,而在 ARM64 上,给那 3 个寄存器字段的每一位,都是从操作码那儿拿走的:

5 位 × 3 —— 32 个寄存器,操作码 17 位 = 131,072 种操作

在 ARM64 选的 5 位上, 32 个寄存器吃掉 32 位里的 15 位,剩17 位 —— 131,072 种操作,绰绰有余。把字段推到 8 位,你得到 256 个寄存器和 256 种操作,那不叫指令集。

只有一个数字活到了日常对话中:频率。很诱人的算法是 3 GHz 每秒退休 30 亿条指令。把打到 DRAM 的读取调上去,看两根条分开:

3.0 GHz · 每千条 0 次未命中 → CPI 1.00 → 每秒 3.00 G 条指令

每千条指令 8 次未命中就把 CPI 抬到 3.00, 吞吐掉到每秒 10 亿条,是盒子上那个数的三分之一。另一个方向上也错:后面 3 个 section 讲核心怎么每周期退休不止一条。

02

同时干 5 件事

一条指令做完才开始下一条,会让 5 分之 4 的硬件闲着。流水线说:别等。

把循环切成阶段 —— 取指、译码、执行、访存、写回 —— 每个阶段配自己的硬件。一次一条指令每周期只用一个阶段,另外 4 个闲着。

滑块是两条指令启动之间的间隔。把它从 5 拖到 1, 看总周期数塌下去,而每条指令走到写回仍然要 5 个周期:

每 5 周期启动一条 —— 5 条指令 · 25 周期 · IPC 0.20

5 条指令一次一条要 25 个周期,重叠起来只要 9 个 —— 2.8 倍,没加硬件也没提频率。没有什么变短了,机器只是不再让阶段空着。单条指令的延迟还是 5,动的是吞吐。

一个周期一个周期地走,看对角线填满,然后第一次退休在第 5 个周期到来:

第 1 / 9 周期

因为这一趟的代价是 5 个周期的填充加上之后每条 1 个周期, n 条指令花 n + 4 而不是 5n。这是摊还论证,不是平均数:单条指令永远快不过 5 个周期,但随着 n 变大,整趟逼近每周期退休一条。

这只在每个阶段都有东西交给下一个时成立。下面第二条指令要用的寄存器,第一条还没写。把转发关掉,单步走过那些死周期:

转发关 —— 2 个气泡 —— 4 条指令用 10 周期

没有它,使用者必须等生产者写回 —— 假设寄存器堆前半周期写、后半周期读,那就是2 个死周期。转发把 ALU 的输出直接接回它的输入,这一趟从 10 个周期掉到 8 个。

有一种冒险它消不掉。load 的值要到访存阶段末尾才知道,所以紧跟其后的那条照样停顿。把使用者从 load 那儿拖远,看气泡消失:

相隔 1 个槽。横向拖动可以移动使用者;方向键一次移一个槽,Home 键放回原位
相隔 1 个槽 —— 1 个气泡

隔 1 个槽要付一个气泡,隔 2 个槽一分不花。这就是编译器把 load 往前提的全部原因 —— 调度器是在买一个槽,不是在对缓存耍聪明。任何一条不相干的指令都行,循环展开有用也是这个道理。

5 级流水线是 1990 年代的机器。现代核心跑 15 到 20 级,因为更短的阶段能用更高的频率去打。拖动深度,看时钟对上冲刷:

5 级
5 级 —— 1.19 GHz —— 一次冲刷 4 周期 = 3.36 ns

这个取舍不是人们常引用的那个。从 5 级到 20 级,频率差不多翻 3 倍,1.19 到 3.51 GHz,冲刷从 4 个周期涨到 19 个 —— 但那些周期更短,按时间只从 3.36 ns 到 5.42 ns。周期是衡量一次冲刷的错误单位。

也不会一直划算。Pentium 4 的 Prescott 跑 31 级、出货到 3.8 GHz, 每级多出来的锁存延迟吐回去了不少:过了 25,时钟曲线就平了,冲刷还在爬。

03

先猜,再验

每个 if、每条循环回边、每次虚调用都是分支,而下一条指令的地址在它落定之前是不知道的。深流水线等不起。

所以它不等:前端猜一个,继续取指。把分支推到流水线更深处才落定,数一数跟在它后面被取进来的有多少:

第 2 级落定 —— 冲刷 2 条 —— 第 4 周期重启

错误路径上的东西全部删掉,真正的目标从取指重来。落定越晚扔掉越多 —— §02 那个深度取舍,从另一头看。真机上重填是 17 个周期。

大约每 5 条指令就有 1 条是分支,这笔账一点都不留情。把猜错的那部分调上去,看这颗核心有多少不再属于你的程序:

错 2.0% —— CPI 1.07 —— 6.4% 的周期被冲刷

在真实代码能做到的 2% 上,CPI 是 1.07,6.4% 的周期被扔掉。到抛硬币的 50%,同一颗核心 CPI 2.70,63% 的周期花在它马上要丢掉的活上。准确率就是两台机器之间的差别。

机制是每个分支一个计数器。1 位说「照上次那样」; 2 位要错两次才肯改主意。单步走过这些结果, 看各自下面错的次数:

1 位错 8 / 32 · 2 位错 6 / 32

在跑 8 轮的循环上,1 位预测器 32 次里错 8 次,2 位错 6 次:多出来的那一位吸收了那次唯一的退出,而没忘掉循环。换成交替模式, 2 位错 16 次 —— 它在中间来回摆,一次跳都没预测出来。在不可预测的数据上是 32 里错 21,比 1 位还差一点。

最后这种就是那个著名的例子。把越过门槛的那些值排到后面去,看猜错消失:

0 / 32 已排好。横向拖动可以多排一些;方向键一次移一个位置
排好 0 个 —— 错 15 次 —— 383 周期(乱序时的 2.4 倍)

打乱时这个循环 32 轮里错 15 次,花 383 个周期;排好序是 2 次、162 个周期。同样的数据、同样的代码,2.4 倍的加速 —— 这就是热点过滤前先std::sort 能赚回来的原因。

接下来是第二层的问题:到底要排多少?过了 16,错误数就不动了,柱子还在重排 —— 16 正是所有小于 128 的值排到了大于它的值前面的位置,而预测器只看得见结果序列。分区就够,而且是 O(n)。

方向只是问题的一半。间接调用要的是一个目标, 而记目标的缓冲只记得上一个。给这个调用点加目标, 看命中率掉下去:

1 个目标 —— 猜对 100% —— 每次调用多 0.0 周期

1 个目标是免费的。4 个、以预测器学不会的顺序出现,4 次里对 1 次,每次多花 12.8 个周期 —— 比一个小虚方法的方法体还贵。这就是单态、多态、巨态,也是热点分发要被特化的原因。

真实的预测器还用最近若干分支的结果去索引那个计数器。挑一个重复模式,然后加全局历史位数,直到那根柱子掉到 0:

周期 6 · 0 位 —— 错 10 / 60

周期为 6 的模式在 4 位历史下是隐形的 —— 60 次里错 10 次 —— 有 5 位就完全可预测。阈值取决于模式本身而不是长度,所以 TAGE 同时保留好几种历史长度。这也正是 Spectre 训练的机制。

04

每周期不止一条

顺序流水线的上限是每周期一条。现代核心稳定跑到 3 到 4 条:谁的输入就绪就发谁,之后再按程序顺序提交。

前一半是堆硬件:多个执行端口,加上一个宽到喂得饱它们的前端。后一半是找到彼此不相依赖的指令。

把发射宽度拉宽,看互不相干的活更早做完,而串成一条链的版本纹丝不动:

宽度 1 —— 互不相干 15 周期(IPC 0.80)· 成链 48 周期(IPC 0.25)

12 个互不相干的运算从 15 个周期到 4 宽时的 6 个周期,然后就不再变好 —— 这颗核心只有 4 个端口,第 5 个发射槽没地方送东西。那条链在任何宽度下都是 48 个周期:宽度救不了一个长度为 1 的队列。

几乎每一次让人失望的优化都是这个形状。归约到一个累加器,就是一条一百万环的链。加累加器,看每条链变短,直到吞吐下限拦住它们:

1 个累加器 —— 4.00 M 周期 = 1.33 ms —— 是 1 个的 1.0 倍

1 个累加器是 400 万周期,3 GHz 下 1.33 ms;4 个是 100 万周期、0.33 ms —— 正好 4 倍,指令条数一样。过了 8 个,每条链已经比每周期 2 条的上限还短,下限说了算:第 9 个累加器和第 12 个都一分不赚。

第二种依赖不是真的:两条都写 rax 的指令撞的是一个名字,不是一个值。把重命名打开,看那段根本没必要的等待消失:

mov rax 等到第 20 周期 —— 依赖它的第 23 周期才完

没有它,mov rax, 7 要到第 20 个周期才能发射 —— 它在等一个除法的结果,而它马上就要覆盖掉 —— 依赖它的到第 23 周期才完。重命名给它一个新的物理寄存器,于是它在第 0 周期发射,依赖它的到第 3 周期就做完了。

这就引出那个显而易见的担心,而答案是机器的核心承诺:第 N 条指令退休之后的体系结构状态,和一台一次只跑一条的机器产生的完全一样。看执行乱序完成,而提交始终按序:

第 0 周期 —— 5 条里 0 条执行完 —— 5 条里 0 条在体系结构上可见

因为提交按序,没有东西会提前可见:最后三条在第 3 周期就算完了,却仍然排在除法后面,在第 22、23、24 周期退休。这就是重排序缓冲区买到的东西 —— 也是异常能精确投递到那一条指令上的原因。

它的大小决定了核心能往前看多远去找不相干的活,而往前看正是它重叠缓存未命中的方式。在一个每 8 条指令就打一次 DRAM 的循环上,把窗口拉宽,看它同时挂得住的未命中变多:

8 条目
8 条目 —— 1 次未命中同时在飞 —— 每次摊到 250.0 周期

8 个条目一次只挂得住1 次未命中, 每次要付满 250 个周期;80 个条目挂得住 10 次,每次 25 个。然后就到头了 —— 这颗核心只有 10 个填充缓冲,第 11 次未命中不管窗口多大都得等。Skylake 的 ROB 是 224,Golden Cove 是 512,都不是卡点。

这就回答了「为什么指针追逐慢」。链表天生一次只给核心一次未命中,所以 512 条目的机器也待在那个滑块最左端:每个节点 250 个周期。

内存还有一个陷阱,在源码里看不见。一次读取只有在写入完全包住它时,才能直接从写缓冲里得到答案。把读取在写入上滑过去,找到转发失败的那些位置:

读取起点 +0 字节
起点 +0 —— 转发成功 —— 5 周期

落在写入内部时读取花 5 个周期;跨过它的边界,硬件放弃,排到 L1 再重放 —— 同一条 C 语句 18 个周期,而且是静默失败。

05

回滚回滚不掉的东西

预测加乱序执行合起来意味着,一颗核心一直在做可能得扔掉的活。有 20 年时间,大家默认这是免费的。

就算一切按设计工作,它也不免费。从取到一条分支到它落定之间,一个 4 宽的前端会一直凭一个猜测往机器里灌指令。单步走过那 17 个周期的影子,看建立在这个猜测上的活堆起来,然后变成被丢掉的活:

已过 0 周期 —— 0 条 µop 建立在猜测上

4 宽时是 68 条 µop,6 宽是 102 条 —— 全都被译码、重命名、调度,很多还执行了,然后删掉。加宽让一次预测错误更贵,这就是宽度和预测器精度要一起设计的原因。

从体系结构上看,这次删除是彻底的: 没有寄存器、没有内存位置、没有标志位活下来。这就是「投机是安全的」那整套论证,而漏洞也在这儿 —— 缓存从来就不属于体系结构状态。

下面是 Spectre v1 的 gadget。边界检查已经被训练成跳;投机执行读过了数组末尾,并把那个它本不该看到的字节当索引用。选一个字节,然后走到最后一步:

这条分支已经连着跳了 64 次 —— 预测器就等着它跳

看最后一步。所有寄存器都恢复了, 那次读取在官方记录里从没发生过,程序的表现和检查失败时一模一样 —— 但探测数组里有一行还在缓存里, 是哪一行取决于那个秘密。没人给缓存写过回滚。

把它读出来是一次计时测量,而有负载时的计时测量是有噪声的。把噪声调大,直到攻击者报出的那一行不再是投机执行碰过的那一行, 再多平均几次直到它又成功:

最快的是第 11 行 —— 猜对了,比第二快的还快 0.84

噪声 0.80 时,单次测量报出的是第 9 行, 攻击直接错了。平均 16 次把噪声除以 4,第 11 行干干净净地回来了。这就是概念验证代码要循环几千次的原因,也是浏览器最先上线的缓解是限制高分辨率计时器精度的原因。

真正的缓解是结构性的,而且要花钱。内核页表隔离给用户态一张没有内核映射的页表,于是每次系统调用现在都要切地址空间。把系统调用频率调上去,看这次切换的代价:

每秒 35 k 次 · 有 PCID —— 吃掉一个核心的 1.1%

每秒 20 万次系统调用有 PCID 时吃掉一个核心的 6.0%,因为带标签的 TLB 熬过了切换;没有 PCID 就是20.0%,因为每次切换都清空 TLB。

retpoline 和 IBRS 还各有账单,新的侧信道一直在冒。对在共享硬件上跑不可信代码的人:你画的那条边界是体系结构上的,而下面那台机器不是。

06

速查表

3 个值得冷讲清楚的问题、1 处几乎总能赚回来的改动,以及 5 个红旗。

一颗核心的周期花到哪儿去了?

花进 3 个桶,而墙钟时间说不出是哪一个。把你负载真实的那两个比率设进去,读出划分 —— 退休了东西的周期、丢给猜错分支的周期, 以及等内存的周期:

IPC 4.00 —— 退休 100% · 白猜 0% · 内存 0%

干净的一轮每周期退休 4.00 条。普通情况 —— 2% 预测错、每千条 5 次 DRAM 未命中 —— 是 IPC 2.26:56% 在退休、15% 白猜、28% 等内存。

哪一处源码改动最能赚回自己?

打断依赖链。同样的算术、数据和指令条数 ——一条一百万环的链对上4 条四分之一长的链:

double s = 0;                  // 4.00 M
for (i = 0; i < n; i++) s += a[i];

double s0=0, s1=0, s2=0, s3=0; // 1.00 M
for (i = 0; i + 3 < n; i += 4) {
  s0 += a[i];    s1 += a[i+1];
  s2 += a[i+2];  s3 += a[i+3];
}
s = (s0 + s1) + (s2 + s3);

400 万周期对 100 万周期 —— 1.33 ms 对 0.33 ms。perf stat 十秒钟就能点出是哪个桶,而不给-ffast-math 编译器不会替你在浮点上做。

每种停顿有多大?

这一页上的每一种代价放在一根轴上,你手底下那一档拿寄存器加法当尺子量:

寄存器加法 —— 1 周期 = 0.33 ns —— 寄存器加法的 1 倍

要背的是第 3 档到最后一档:L2 命中 14 个周期,DRAM 读取 250。中间那些都差不多是一次 L2 未命中。

  • 热循环里的巨态调用。每次 12.8 个周期。
  • 未排序输入上的数据相关分支。上游分区一次就够。
  • 归约里只有一个累加器。4 倍延迟,静默失败。
  • 热路径上走链式结构。只挂得住 1 次未命中,数组能挂 10 次。
  • 和前面写入部分重叠的读取。5 个周期变成 18 个。