深入 Skylake 微架构

标题:Advanced Skylake Deep Dive

日期:2025/12/24

作者:Matt Godbolt

链接:https://www.youtube.com/watch?v=BVVNtG5dgks

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。

备注:相当大的文本量,第一次见到 gemini 排版乱飞,手动修了不少。(可我搞这个不是为了省时间吗,本末倒置啊)


大家好,欢迎来到本次技术演讲(Tech Talk),非常高兴大家都能出席。今天由我来向大家介绍 Matt Godbolt。Matt Godbolt 是一位 C++ 开发者,他热衷于探究编译器、操作系统和芯片的底层原理。白天,他和我们在座的许多人一样,为金融行业编写软件;到了晚上,他会开发复古计算机的模拟器,并维护 Compiler Explorer。Compiler Explorer 是它的官方名称,但大家都管它叫 Godbolt。所以理所当然地,当有人在 Jane Street 内部建立这个工具的实例时,他们将其命名为 Godbolt,这也使得 Matt 成为我们这里第一位已经拥有以他名字命名的内部域名的外部演讲者。请大家和我一起欢迎 Matt Godbolt。

谢谢你,Jasper。非常感谢邀请我来这里,这太棒了。

我很期待和大家聊聊我喜欢深究的这些东西。所以这次演讲的主题是“微架构:底层究竟发生了什么(Microarchitecture, What Happens Beneath)”,这可能不是我起过的最好的标题。是的,正如 Jasper 所说,你们之前听说过我,可能主要是因为 Compiler Explorer。我非常喜欢为老式计算机制作模拟器,这也是我过去一年在竞业禁止期内一直在做的事情。我的竞业禁止期今天正好结束,这也是我今天能站在这里给大家做演讲的原因。不过下周,我就要去 HRT(Hudson River Trading)工作了。所以我们马上就会成为邻居,我就会在街那头办公,我们算是友好的竞争对手。

我也非常喜欢尝试逆向工程,去探究你的 CPU 里面到底在搞什么鬼。众所周知,英特尔对计算机内部发生的事情提供的信息非常少。出于保护知识产权等显而易见的原因,这很合理。但是,有一小群极其专注的人正试图弄清楚里面到底发生了什么。这也是我在此领域的贡献,我曾试图弄清楚一些老芯片内部的 分支目标缓冲区(Branch Target Buffer, BTB) 是如何工作的。不幸的是,我们今天讨论的将是一些比较老的芯片,因为我无法接触到非常非常新的硬件——因为我已经处于竞业禁止期一年了,而且租用那些新机器极其昂贵。但无论如何,我以前做过一些研究。在这个领域让我小有成就感的一件事是,我的研究在《熔断(Meltdown)与幽灵(Spectre)》这篇论文中被引用了,这对于这类研究来说是一种很好的认可。

逆向工程与 CPU 流水线基础

你们可能以前在教科书上见过 CPU 流水线(Pipeline)。我知道我刚才和前排坐着的一些看起来“充满威胁性”的专家们聊过,他们对我们今天要讲的所有内容都有着非常深刻的理解。但对于在座的其他人,希望你们能原谅我先做一个关于 CPU 流水线是什么样的快速介绍和回顾。

在我成长的年代,CPU 流水线是长这样的:你获取(Fetch)一条指令,解码(Decode)它,执行(Execute)它,然后将其写回(Write back),按顺序进行。然后,你知道的,你可以把它做成像生产线一样,作为一个流水线,一步一步来。所以当上一条指令正在被解码时,下一条指令正在被获取;而在它之前的那条指令已经在执行了;再之前的那条则正在被写回。因此,尽管一条指令需要四个时钟周期才能跑完整个流程,但我们依然能在每个时钟周期完成一件有用的工作。在当时,这是一个巨大的进步。

然而,现代 CPU 意识到,如果我们可以做得比这种单步顺序执行更好,我们就可以将系统大致分为三个类别。

首先是我们有一个前端(Front end),里面有一个分支预测单元(Branch Prediction Unit)。我原本想和你们多聊聊分支预测的,实际上我为此做了一大堆逆向工程工作,但我没有时间了。所以我们只能在会后或者在酒吧里聊这个了。但不管怎样,这里有一个和以前一样的获取/解码器(Fetch/Decoder)。

接下来,我们有一个 重命名(Rename) 步骤,这就像是通往新世界的网关。

在这之后,中间的这部分就是后端(Back end)。事情可以在这里 乱序(Out-of-order) 发生,这听起来可能有点反直觉。但在程序中,我们经常编写这样的代码:其中包含许多不完全相互依赖的小部分。比如,你读取一个值,给它加上 10,然后存到另一个地方;你再读取另一个值,给它加上 20,再存到另一个地方。你会觉得:“这俩事完全可以同时进行嘛。”所以,如果你能一次性拾取足够多的指令,你实际上可以在每个时钟周期完成多个工作。这就是执行阶段的这个堆栈结构的作用,这也是乱序系统在做的事情。它在进行一些聪明的依赖关系追踪。只要执行单元空闲,并且输入的计算结果准备好了,一切就会立即发生。

最后,我们来到 退休(Retirement) 阶段,在这里,所有东西都会被按照程序编写的原始顺序重新排列好,这样你就不会注意到中间部分进行的那些“暗箱操作”了。

Skylake 微架构概览与核心数据结构

我们今天要专门讨论的是 Skylake,它已经很老了。大概是 2019 到 2020 年左右的产品。但话又说回来,我的笔记本电脑就是 Skylake 架构的。我的台式机是 Comet Lake 或者 Skylake X,实际上这台笔记本是 Comet Lake。所以它们足以代表现代 CPU 内部实际发生的事情,当然,在你们日常工作中使用的服务器级 CPU 中,这些东西已经发生了变化。如果我记得有哪些不同,我会尽量指出来。但同样,我无法在更新的机器上测试这些东西。

Skylake 上的乱序执行比那张漂亮的教科书图表要复杂得多。我们有一个看起来像这样的前端。正如前面所说,我们不讨论分支预测单元。我们有两条平行的线路:获取(Fetch)、预解码(Pre-decode)、指令队列(Instruction queue),然后是多个解码器(Decoders)。在它的下方,我们有一个微操作缓存(Micro-op cache)。在它的右侧,我们有一个微操作队列(Queue of micro-operations)和一个名字很有趣的 LSD(循环流检测器,Loop Stream Detector)。然后我们进入重命名器(Renamer)。我们稍后会讨论所有这些组件,我只是想先给大家一个我们要讲内容的全局视角。

关于这个我本来想说什么来着?我本来想说点什么的。我等下会想起来的。反正我们都会讲到的。总而言之,这就是前端。

到这个点的时候……噢,我想起来了,微操作(Micro-ops)。我在这里写了 micro-op。在英特尔(Intel)处理器上,特别指令被分解成了更小的操作,也就是微操作(Micro-operations)。与大多数 RISC 处理器不同——在 RISC 中你只是将两个数字相加,或者进行读取,或者进行分支——英特尔的一些指令简直疯狂。你可以进行非常复杂的地址计算,比如一个数字加上另一个数字乘以四,再加上某个偏移量,这就是你要读取的地址。然后你从那个地址读取数据,然后你要对它进行加法,最后还要把它写回去。 这在 x86 居然只是一条指令! 所以,前端必须处理这些疯狂的 x86 指令,它理所当然地需要将这些指令分解成后端能够处理的、合理的 RISC 架构风格的操作。但我们永远看不到这些内部操作,我们只能通过在外部进行的所有测量来推断它的存在。这就是微操作(micro-op)的概念。

是的,所以当我们到达这个点时,我们拥有了一个按程序顺序排列的微操作流。它们进入重命名器(Renamer),然后进入后端。后端相对更小、更简单,我们不会讲那么多后端的细节。如果我时间把控得好,最后大概只剩下五分钟来讲这个。但后端有一个调度器(Scheduler),有多个执行单元(Execution units)——不止图上画的这四个,实际上要多得多。还有一个写回(Write back)阶段。然后我们有退休(Retirement)阶段。在退休之后,我们还有一个存储缓冲区提交(Store buffer commit)阶段。同样的,这上面的每一步可能都经过了简化。但这主要是基于我们可以进行的测量得出的结论。这里说的“我们”,实际上并不只是指我。虽然我过去也为此做出过贡献,但我展示的这些,严重依赖于那个致力于逆向工程这些东西的社区人员的结果。这些东西你不一定能在英特尔的手册里找到,你必须自己去深挖。所以我在演讲最后提供了一些参考资料,如果你对此感兴趣,并且想知道如何自己去测量,尽情去探索吧。这真的是一个非常非常有趣且深不见底的兔子洞。

这里有一堆三字母缩写(TLA),也就是这边这一堆字母缩写,它们都是系统各个部分都能看到的共享数据结构。我们会分别讨论它们,但我想先提一下。

  • 物理寄存器文件(Physical Register File, PRF):虽然你在编写程序或者看汇编代码时,经常看到引用 EAXECXEDX 等等这 16 个我们可以访问的寄存器。16 个,多么奢侈的数字啊。我知道我们早些时候讨论过,我们可能需要更多的寄存器。但就在不久前,我们还只有 8 个寄存器,其中 2 个还被用于其他特定用途。但这 16 个架构寄存器,与 CPU 上实际可用的物理寄存器槽数量相比,根本不值一提。因此,在幕后,CPU 正在进行各种分配、重命名和映射,以利用芯片上额外的空间。所以物理寄存器文件包含了寄存器的所有实际值,而且可能有数百个。

  • 寄存器别名表(Register Alias Table, RAT):这是一个映射结构,它记录了当前的 EAX 目前映射在芯片上的哪个物理位置,以及其他类似信息。

  • 重排序缓冲区(Reorder Buffer, ROB):它本质上存储了所有流经系统的微操作的“账本”。它记录了引用的状态,比如:“这是我们知道一条指令已经发出的时间点,而在稍后,我们将按照它们进入这个重排序缓冲区的顺序来退休(Retire)它们。”

  • 保留站(Reservation Station, RS):在一些文献中也被称为调度器(Scheduler)。这就是我们实际存储那些尚未被处理的操作的地方,比如实际的操作本身。比如:“我需要把这个数字和那个数字相加”,或者“我需要把这个数字,和一个我们将要从它依赖的未完成操作那里读取到的数字相加。”它们就住在这个保留站里。

  • 分支顺序缓冲区(Branch Order Buffer, BOB):这是一个针对分支预测失败的检查点(Checkpointing)系统。再说一次,我很想多聊聊这个,但遗憾的是我们没有时间了。如果你坐下来想想,当它预测未来 20 个分支时它必须做什么,然后它把其中一个预测错了,而它又不想把所有的工作都扔掉,这就非常奇妙了。

  • 内存顺序缓冲区(Memory Order Buffer, MOB):它负责确保,即使我们对所有指令进行了乱序执行,对内存的加载(Loads)和存储(Stores)仍然是符合逻辑的。

前端流水线:获取(Fetch)与预解码(Pre-decode)

我们将从前端开始。这将占据演讲的大部分时间。我忘了开计时器了,所以我只能靠看你们身后的时钟来判断进度。

我将使用这个代码作为例子。很应景,我必须用 C++ 来写。虽然我们早些时候试图用 OCaml 来写这个,但它并没有生成相同的代码。你们可以自己发挥想象力。所以这是一个我自己编造的代码。它接收一个整数数组,将它们平方,然后求和。所以它只是在做一个滚动的整数平方和。这很符合我的目的,因为它正好编译成 6 条指令,总共 16 个字节的机器码。

/* 生成代码,仔细甄别 */
int sum_squares(const int* array, const int* end) {
    int sum = 0;
    while (array != end) {
        sum += (*array) * (*array);
        array++;
    }
    return sum;
}
/* 生成代码,仔细甄别 */

; 对应的汇编与机器码 (大致表示)
8B 07       mov eax, dword ptr [rdi]
0F AF C0    imul eax, eax
01 C2       add edx, eax
48 83 C7 04 add rdi, 4
48 39 FE    cmp rsi, rdi
75 F3       jne -13

我相信你们对这个应该相当熟悉了。但在右边,我们有文本表示形式,也就是汇编代码。在左边,是 CPU 实际读取的字节,这就是机器码。这以前经常把我搞糊涂,我经常把这两个词混用,这很蠢。你们其实不需要知道细节,但它的过程是:我们读取数组指针,我们把它平方,把它加到我们的运行总和中,我们把数组指针移动到下一个元素,我们把它和结束指针进行比较,如果还没到结尾,我们就跳回去。非常直接的逻辑,总共 16 个字节。

但你们会立刻注意到的是,这里的每一条指令的长度都不一样。我们有一条两字节的指令,一条三字节的,甚至这里还有一条四字节的指令。一般来说,x86 指令的长度可以是 1 到 15 个字节不等。它的设计并不十分合理,因为它是那种你从 20 世纪 70 年代的设计出发,为了保持向后兼容性而不断增量添加东西的结果。这意味着几乎所有的东西都可以追溯到这些古老的规则,并且有一些字节会说:“嘿,下一个字节现在要用不同的方式来解释了,除非今天是星期二而且月亮正在升起。”所以反汇编 x86 指令其实非常复杂。如果你使用过 ARM、MIPS、RISC-V 之类的指令集,感觉会好得多,所有的指令长度都是一样的,非常简单。但这也是为什么我们需要进行这种复杂的解码工作的原因。

我要快速展示给你们的另一件事是某种形式的指令追踪。这是通过一个经过大量修改的名叫 UICA 的工具得出的,这是在谷歌工作的 Andreas Abel 编写并开源的微代码分析器。这能让我们看到单个指令在流水线中的旅程。我们看到的是一条指令从左到右随时间推移所经历的所有阶段。这条指令花费了 16 个周期。在第一个周期这里,它正在被预解码;然后进入指令队列;接着被发出(Issued)、分派(Dispatched)、执行(Executed),然后在这个特定的例子中退休(Retired)。我只是想先给你们看个例子,后面我们还会展示几个这样的图,当我们遇到它们时我会做更多解释。但我不想当它弹出来时让你们感到惊讶。

第一阶段:获取(Fetching)

这可能是最简单的一个阶段了,尽管我将省略一大堆它实际做的非常复杂的事情。

我们拥有预测的指令指针(Predicted instruction pointer)。所以获取单元并不等待程序明确地跳转到某个地方。它始终被分支预测单元告知应该去哪里。这是我以前从未考虑过的一个非常有趣的方面。因为你可能会一次性读取 16 个字节,但在你解码它之前(这已经是流水线下游四五步之后的事情了),你甚至不知道里面是否有一个分支指令。到那个时候,你可能已经又拉取了两个 16 字节的内存块,然后你才发现:“哎呀,我走错方向了。”即使那是无条件分支也会这样。如果是条件分支,你甚至必须实际执行它才能计算出你是否会采取跳转。所以分支预测器承担着重任,而且总的来说,它做得很好。

获取单元每次在一个 16 字节的边界上提取 16 个字节。这意味着,如果你跳转到一个 16 字节边界的中间位置,那么你立刻就浪费了一点带宽,因为你错过了那些本可以在一个周期内提取出来的字节。

另外需要注意的是,我完全不会谈论缓存(Caches),因为那又需要讲上两个小时。但这显然必须与 TLB 协作,以计算出这些指令到底来自哪里,以及与 L1、L2、L3 缓存协作,实际将字节提取到 64 字节的缓存行中,然后再从中拉取 16 个字节。所以这里隐藏了大量的工作。但这个获取阶段的结果是产生某种由 16 字节块组成的管道流,大概率会附带某种标记它们的地址,这样我们就知道它们来自哪里,以便稍后进行检查。好了,简单的一个阶段讲完了。

第二阶段:预解码(Pre-decode)

因为 x86 指令集非常复杂,而且因为我们希望解锁这种并行性,使我们能够同时运行多条指令。如果我们在单个时钟周期内最多只能解码一条指令,那么我们在后续流水线中就会错失良机。因此,我们想要尝试做的是,在一个时钟周期内,尽可能多地从这 16 个字节的块中解码出指令。

所以它被分解成了几个阶段。这里的第一个阶段叫做 预解码(Pre-decode) 阶段,预解码器有一套相对“不可靠(flaky)”的启发式方法来判断这些字节可能意味着什么。说它们不可靠是因为,再次强调,我们唯一能想到的(在这里指的是 Agner Fog 等逆向人员推测的)解码方式是:既然我们根本不知道指令在这些字节中的具体位置,我们只知道它们不会相互重叠,而且它们当然依赖于前一条指令的成功解码。那么你怎么可能在一个周期内完成解码呢?

我们唯一能想到的就是,它只是在每个可能的偏移量上推测性地尝试解码一条指令,然后它有一个过滤步骤,它会说:“好吧,这些解码结果与前面的重叠了,所以这不可能是有效的解码。”同样,这有点像是一种启发式的黑客手段。它本身从来不会弄错,但它有时会非常保守地将一些指令标记为比实际更复杂的状态,留给流水线的后续阶段处理。我们稍后会讲到这个。

这 16 个字节碰巧代表了我之前展示的那个例程。预解码器将其标记,本质上就是将其分解并说:“这个是操作码(opcode)字节,这些是后续操作码字节等等”。顺便说一句,我现在指向屏幕这边的很多内容,都是我个人关于正在发生什么事情的猜测,这不是官方的规范。因为没有关于这里到底流转着什么信息的权威来源,但希望你们能谅解。

这里有趣的一点是,我们已经能够发现第一条指令 mov 的字节在哪里,第二条 imuladd edxadd rdi 在哪里。然后对于最后这一条,它是一个比较(cmp)和跳转(jump)。在这个流水线阶段,它发现了一个机会,因为尽管我们在汇编层面上将它们视为两条独立的指令,而且这是 ISA(指令集架构)所规定的,但它太常见了。你知道,每一个循环在结束时都会有一个比较,然后在不相等时跳转,等等。所以英特尔决定说:“如果我们在内部把这两条指令作为一个比较和跳转的整体操作来处理会怎样呢?”

他们本来可以制造一条新指令,让所有编译器作者都去生成这条新指令。或者,他们可以 直接在指令流中发现这种模式,并在这里直接将其替换。他们正是这么做的。这被称为宏指令融合(Macro instruction fusion)。至少在这个预解码步骤中,它被打上了这样的标记。所以在这个阶段,我们依然不是很清楚这些字节具体是什么,有时它使用的位掩码或者其他的什么规则可能会出错。如果我继续这样深挖下去,我会超时。

预解码器的限制是:它在一个周期内最多只能处理 5 条指令。它进行宏操作融合。至少目前(我说的目前是指 Skylake 时代,现在可能变了),只有对非内存(因为内存访问更复杂)进行的 cmptest,紧跟着任何分支指令,才会作为单个操作处理。或者对有限的几个指令如 addsubincdecand 加上一个分支指令,也会作为一个单一的 cmp/jcc 处理。

值得一提的是,你可能不要做这种事(虽然如果你写编译器你可能有这样的自由度):不要放置任何改变长度的前缀(Length-changing prefixes, LCP)。这些前缀通常用于古怪的 32 位模式,以使用更小的指针,但它们实际上会让预解码器陷入瘫痪。它会直接崩溃说:“哎呀,我不知道怎么处理了。”因为你可以仅仅通过放入乱七八糟的前缀来拼凑出一条极其复杂的指令。预解码器直接放弃,大概会有 3 个周期的惩罚,如果它看到 LCP,它就罢工了。

它还会做的另一件事是,它会注意到一条指令是简单的还是复杂的。因此,它会将该指令“引导”到适当的解码器。“引导”这个词可能不太准确,但我等下会解释我的意思。这些被部分解码的指令,同样只是字节块,外加推测某种内部序列号。(我一直在幻灯片上写 seq 序列号。我不认为它在内部使用内存地址来跟踪,因为显然如果你在一个循环中,你会一遍又一遍地看到相同的地址。它肯定有一种内部的全局序列号,用于比如:“嘿,这个分支预测错了。在这个序列号之后的所有东西现在都是垃圾数据了,我要清空它们。”所以我只是称之为序列号。)

解码器(Decoders)与微代码定序器(Micro-code Sequencer)

我们有部分解码的指令,它们被传递给解码器(Decoder)。一共有 4 个解码单元。它们按顺序被分配指令。处理的结果就是,(这也是我在这边自己发明的一种伪语法),生成了微操作(Micro-operations)。

在这个特定的例子中,每条 x86 指令对应一个微操作。所以左边的一条,就是右边生成的一个。比如有一个是通过 RDI 的 32 位加载(load),一个乘法(multiply),一个加法(add)等等。然后这个是我编造的一条指令,即前面融合后的那条宏操作,代表比较并跳转。诸如此类。

这里有两种“融合”。

一种是我们已经谈过的宏指令融合(Macrofusion)。宏(Macro)意味着大。我就是这样记住它们的。这意味着两个巨大的东西(x86指令)被融合成了内部的一个微操作。

另一方面, 微操作融合(Microfusion) 是应对英特尔搞出的这种愚蠢语法的。在右侧操作数中出现内存地址简直太常见了(比如 ADD RAX, [R14])。虽然在逻辑上这是两个操作:你要去计算 R14 是什么,从缓存中读取它,然后再回到算术逻辑单元(ALU)把它加到 RAX 上。这明明是两个截然不同的操作!但在实践中,大多数情况下,为什么我们不把微操作的格式做得宽一点,包含一个可选的内存标签呢?这样,对于我们有足够空间的多数指令,我们就能表示说:“这既是一个加法也是一个读取。”对于简单的情况,这完全可行。所以,一条微融合指令在后续实际上会被芯片上的两个不同部分作为两个微操作来执行,但 在解码这个时间点,我们可以将它打包成各种队列里的一个微操作。 这就是微操作融合和宏操作融合的区别。

这里还有一个 微代码定序器(Micro-code sequencer)。微代码定序器本质上是一个只读存储器(ROM),上面加了一个简单的解释器。因此,任何复杂到超过 4 个微操作的 x86 指令,都必须从这个 ROM 里获取微操作,而不是直接硬连线在电路代码中。这个 ROM 要么只是简单地“吐出”一堆微操作——“这里是一条复杂指令对应的 15 个微操作”;要么它内部实际上包含了逻辑。它可以真的像编程一样执行判断:“哦,当 ECX 不等于 0 时,一直发出这些微操作。”所以对于像锁(LOCK)操作这样复杂的事情,任何写过线程代码的人都见过,它显然需要超过 4 个微操作。或者像 REP STOS 这种重复的内存操作,本质上它就是微代码定序器在不断发出读写操作,直到达到拷贝的末尾。显然还有像 RDMSRCPUID 这样复杂的东西,它实际上就是运行了一段小程序去查询:“嘿,你是什么类型的 CPU?哦,我可以返回这个信息。”

这其实挺酷的。我曾经遇到一个在英特尔工作的人,他说:“哦,我的邻居就是写微代码的。”我当时就想,这工作得多疯狂啊,坐在那里就专门在流水线最深处写这些东西。微代码定序器处理的其他事情包括除法(Divide)指令。我知道除法非常复杂(肯定是超过 4 个微操作),但这对我来说真的是大开眼界,我才明白除法原来是这么工作的。它有各种加速电路,但也是它能短路执行的原因:“好吧,我们已经到了算不出更多位(bits)的地方了,我们可以停在这里了。”总之,我对除法有点执念,你们马上就会看到。

所以我们有 4 个解码器,但并非所有的解码器都是生来平等的。

第一个解码器(Decoder 0)才是真正的“大哥解码器”。 它可以输出多达 4 个微操作。所以任何可以拆分成 4 个微操作或更少的 x86 指令,都可以扔给第一个解码器。任何被预解码器标记为“复杂”的指令——即使它最后被证明只是个简单操作——也会被扔给第一个解码器,其他解码器连碰都不会碰它。任何需要切换到微代码定序器的指令,也必须通过第一个解码器。所以对于像除法这样的东西,第一步将会是“嘿,这里是启动代码,然后现在我们要开始从微代码定序器里读数据了。”

而解码器 2 到 4,它们每个只能产生单个微操作 ,所以它们只能处理简单的事情。但这实际上也包含了前面说过的融合微操作。那些带有内存操作的加法,在这个世界里依然被算作“一个”,尽管稍后它会被分解成两个。

大家都听懂了吗?我看到前排很多人点头,非常感谢。

所以,你在一个周期内可以解码 4 条指令,或者产生 5 个微操作。这意味着如果你真的需要输出大量微操作,你可以拥有一条产生 4 个微操作的指令,紧接着是一条产生 1 个微操作的指令,这可以在一个时钟周期内全部输出(第一条去 Decoder 0,第二条去 Decoder 1)。但在这种情况下,另外两个解码器就只能坐着无所事事地“玩大拇指”了。微操作输出的组合可能是 3-1-1,2-1-1-1,或者 1-1-1-1-1(这显然不可能,因为只有四个解码器,应该是 1-1-1-1,作者此处可能有口误)。幸运的是,大多数代码都符合这个世界(简单的指令为主),除非你又在做诸如除法之类蠢事。你可能制造出的最糟糕的情况是,你的指令序列恰好全都是需要 2 个微操作的指令。因为它们只能去第一个解码器处理,其余的解码器全都在闲置;然后下一个时钟周期你又生成两个。这就是最坏的情况。

尽管我刚才讲得滔滔不绝,听起来既有趣又复杂,而且英特尔甚至将此称为“传统解码流水线(Legacy decode pipeline)”——这可不是因为我们还有什么别的更好的选择能用,谢谢你啊英特尔——但在很大程度上,我们被即将要讨论的下一个东西给拯救了,那就是微操作缓存(Micro-op cache)循环流检测器(Loop stream detector, LSD)

我本来有一张关于微操作到底包含了什么信息的草图。我一直在脑海中试图构建我自己的模拟器,以便我可以演练事物需要去哪里的路径,但我们不需要讨论这个,因为我们没有时间。只是为了给你们看一些带有彩虹颜色的漂亮图表,这里展示了一些更复杂的指令,它们会被解码成 2 个、3 个和 4 个微操作。尽管这里面大多数依然停留在微融合(fused domain)的状态。

我想这个只产生 2 个,这个只有 1 个。我想这个是唯一一个实际产生 3 个微操作的。但你可以大致看到,PUSH RAX 就像是:我必须将 RAX 写到堆栈指针的位置,然后我必须更新堆栈指针。很合理吧?XCHG RBX, RAX(交换寄存器)可能就是 temp = RBX, RBX = RAX, RAX = temp。我不认为它会用异或(XOR)交换,或者谁知道呢。然后这里的这个指针加法(ADD [pointer])就是我们说的极其复杂的内存操作,我们必须读取内存,对它进行加法,然后再写回去。内存写回(Store)总是分为 2 个微操作,因为因为地址生成部分和数据的存储部分是分离的,我们稍后会讲到原因。

好吧。因为我必须要放一个除法(Divide)在这里。这是 Skylake 上的 32 位除法。它们现在已经改进了很多。我确信 Granite Rapids 处理器里的除法要快得多。但当我第一次勾勒这个图,让它打印出来的时候,我的反应是:“哦,天哪!” 64 位版本的除法甚至需要 100 个周期。这里的有趣之处在于:在第一个时钟周期,解码器 0 能够输出这三个前置微操作,它们流转下去;然后它切换到微代码定序器,这里有一个两周期的延迟;现在它就一直在执行各种神奇的内部代码操作了。需要注意的是,这些数据是基于对“端口压力(Port pressure)”的观察,通过经验极度推导得出的。所以我们并不 100% 确定内部个别操作具体在干什么,而且有时候这些指令完成的时间我也不能完全肯定是正确的。

微操作缓存(Micro-op Cache)与循环流检测器(LSD)

是的,我们被 微操作缓存(Micro-op cache) 拯救了。微操作缓存逻辑上位于我们刚刚讨论过的整个复杂解码流水线的侧面。我们随时处于两种模式之一:你要么在缓存微操作,要么就在从缓存中读取微操作。这和普通的 L1、L2、L3 缓存不同,并非每次读取都去问“你在吗?不在?那我只能走传统路线去辛苦解码了”。你只是处于其中一种模式。而决定这个状态切换的是 跳转(Jumps) 指令。

是的。在 缓存模式(Caching mode) 下,当我们在运行程序时,微指令转换引擎(Micro-instruction translation engine, MITE,英特尔就是喜欢给东西起复杂的名字,也就是之前说的传统解码流水线)执行完转换后,微操作会被推入并写入这个缓存。

或者,如果发生了一个跳转,而分支预测或者其他机制注意到,那个跳转的目标地址恰好存在于微操作缓存中,那么我们就直接切换到 从微操作缓存中流式传输数据 的模式。这是最理想的状态,你肯定希望代码能运行在这种状态下。

它的输出结果同样是微操作(Micro-operations)。微操作缓存绝对是你见过的最诡异的缓存,因为它的任务非常艰巨。回想一下常规缓存(比如 L1、L2),你拥有的是一一映射关系。这个字节在这个缓存行里,你只需要把它周围的一整条缓存行拉过来,塞进缓存,就完事了。但如果我们跳转到一个并不在缓存行边界对齐的地址上呢?我们要解码那条指令。但是我们没法倒车回去问:“那它前面的指令是什么?”你只能从你跳到的那一点开始缓存。所以它有许多非常奇怪的限制。

这在 Skylake 之后已经改变了很多,但 Skylake 的微操作缓存是 32 组(Sets)、8 路(Ways)的结构。每一路(缓存行)最多可以包含 6 个微操作。某些微操作会占用两个槽位。比如你有一个处理 64 位值的 MOV 指令,你需要两个槽位来存储那个 64 位值。它是 包含性(Inclusive) 的,与其对应的 L1i 缓存绑定,这是关于它如何实现的一个线索。

最诡异的地方在于,任何 32 字节的代码块,最多只能使用微操作缓存里的 3 路(Ways)。这闻起来就像是他们在这里遇到了一个真正的工程难题,因为从逻辑上讲,这非常类似于奔腾 Pro(Pentium Pro)时代的追踪缓存(Trace cache)。你们看着都太年轻了。但在当时,那个设计的问题在于,代码里有多少条执行路径,你的缓存里就需要有多少个条目。所以当代码路径变化时,缓存很快就会被挤爆。所以我认为他们试图通过这种限制来控制它:如果内存中的一小块代码逻辑需要超过 3 行微操作缓存,那我们可能就陷入了它即将垄断整个缓存的情况。与其这样,芯片宁愿把它扔掉,完全不缓存它。

值得一提的是,任何分支指令——甚至是一个没有触发跳转的分支(not-taken branch)——都会终结一个微操作缓存行,然后它会从下一个指令开始重新写入新的缓存行。所以它试图找到合理的边界。

从这个机制中得出的一条经验是:如果你在编写编译器生成代码,并且你的同一个 32 字节代码块有多个入口点,那么你可能没有像你想的那样高效地使用微操作缓存(DSB)。我知道在编译器中通常是这样写的:先执行一些设置代码,然后跳到循环末尾去执行循环结束条件的检查,然后再跳回到设置代码的正下方开始真正的循环。你就会意识到:“哦,我现在有两处入口了。”所以你必须考虑这一点。两个入口可能还可以接受。这也是我之前忘了说的,为什么我们通常会将循环对齐在 16 字节边界上——为了让获取(Fetch)系统在拾取循环时能获得对齐的好处。但是在更新的机器上,它更像是你可以在 64 字节中有 6 路(Ways),这是一种与 L1 缓存绑定得更紧密的诡异设计。

你可以每个周期从微操作缓存交付 4 到 6 个微操作。这意味着无论它们是复杂的、简单的还是其他任何指令,你都能从那里获得极其稳定的微操作流。文献上说可以输出 6 个,但我自己测试的时候最多只能测到 4 个,随便吧。

循环流检测器(Loop Stream Detector, LSD)

在微操作被送往后端的源头(无论是来自传统解码系统还是缓存)之间,有一个队列(Queue)。很显然,我们热爱队列。队列起到了缓冲的作用,如果重命名器(Renamer)或者执行单元发生了一点拥堵,这里就提供了一点喘息的空间。但这个队列本质上是一个拥有几百个条目的环形缓冲区(Circular buffer),我们将按程序顺序生成的微操作写入其中。

在这个下面,循环流检测器(LSD) 实际上做的是,它会说:“等一下,你刚刚跳转到了一个实际上已经在这个缓冲区里的位置。(它可能已经因为环形写入被移到了末尾,但我们还没覆盖它)。等等,如果我们直接冻结那部分内容,然后不断地、一遍又一遍地循环流出那一模一样的微操作序列,难道不是更好吗?

这就是循环流检测器在做的事。这意味着它甚至可以把整个前端都关闭(Turn down the whole front end)。不需要去微操作缓存了,不需要去解码了,这简直太棒了。

但它的容量相对较小,而且有诸多限制。它只能检测完全适合放进这个队列里的循环。它每个周期最高可交付 4 个微操作。但是,它一个周期内无法交付超过一次的循环迭代。所以如果你有一个非常非常小的循环,或者一个迭代指令数不是 4 的倍数的循环,那你可能会遇到这样的情况:比如循环有 5 个微操作,你在第一个周期得到 4 个,下一个周期得到 1 个;然后 4 个,再 1 个;4 个,再 1 个。

除非,它能够进行展开(Unroll)。LSD 实际上能在硬件层面找到它能装下的最长序列,在 Skylake 上最多可达 8 次展开。所以它实际上会在硬件中把这个循环展开,然后说:“现在,如果我把这 5 个微操作的迭代做 4 次展开,(我算术不太好,5 乘 4 等于 20,20 能被 4 整除,是的,我觉得没毛病,大概就是这样。)那么它就能完美适应端口输出,在每个时钟周期都能稳定输出 4 个微操作,这非常酷。”

听起来很棒,但为什么在我的幻灯片上把它放在括号里,并且还打了个星号(*)呢?

因为在 Skylake 上,这个功能被禁用了。根据 Debian 操作系统的修复报告,这是由于一个极其严重的 Bug 导致的。这时候我得拿出我的小抄了,抱歉你们得看一眼演讲者视图,因为我必须要读这个。是的,在 Debian 的修复说明中,它被描述为 “噩梦级别(Nightmare level)” 的 bug。

“使用高位 16 位寄存器(如 AH, BH, CH 等)和相应的宽寄存器混合使用的短循环,可能会导致不可预测的系统行为。”

我的天,这太可怕了。这要归功于那些发现它的人。我不知道具体是什么情况,在座的观众里可能有人能说得清楚。但是 OCaml 语言的运行时(Runtime)或 OCaml 编译器好像非常喜欢干这种事。比如在一个循环里,它会同时使用寄存器的高 8 位和低 8 位,可能是为了做标签(Tagging)还是什么。我正在看着 YouTube(指现场录像设备)因为你们俩是我最喜欢看的人。总之,这是由 OCaml 社区里的一些人发现并报告的。英特尔确定这个问题无法通过微代码逻辑来修复,所以他们直接发布了一个微代码补丁,把整个循环流检测器功能给关了。

哇。好吧。是的,是的,OCaml。我们做了这些诡异的事情,好让你们不用再体验这种痛了。(观众笑)

寄存器重命名(Renaming)与重排序缓冲区(ROB)

现在我们到了整个系统中最酷的部分。再说一次,对于那些在大学阶段学习过乱序执行课程的人来说,这可能只是家常便饭。但我还是要讲一遍,因为它非常有趣,而且在写这些幻灯片时,我才真正如梦初醒般地意识到这个过程有多么重要。

重命名器(Renamer) 可能是整个前端中任务最繁重的一个组件,因为它承担着大概三个截然不同的任务。

  1. 首先发生的事情是,现在微操作已经到了这个阶段。这是我们能够说出“这些操作绝对会发生”的第一个点。我的意思是,它们“绝对是以推测性的方式”会发生。如果稍后我们发现其实不该走这个分支,它们依然可能被撤销。但是,我们将把它们写入重排序缓冲区(Reorder Buffer, ROB),这只是记录“这条指令发生过了,或者即将发生”。

  2. 我们还会将其重命名(Rename)映射到物理寄存器(Physical registers)。所以我们拿架构层面的 EAXRDI 等等,然后利用芯片上巨大的物理资源阵列中的实际槽位来作为它们的载体。我马上会详细讲这个。

  3. 它还会提取出微操作所代表的具体操作,并将其下发给正确的执行单元去等待。微操作将坐在那些 调度器(Scheduler) 中,直到执行单元准备好接受一个新的操作(也许有个乘法器正忙得不可开交,前面排了一堆指令);或者也许这条指令依赖于前一条尚未完成的指令的结果,所以它会坐在那里等待它所有的依赖关系都准备就绪。

这就是我们从流水线扩展到各种底层数据结构的地方,然后乱序执行系统会在另一端把它们接管过去。令人惊叹的是,尽管重命名器要做这么多复杂的工作,它竟然每周期还能处理 4 个微操作。这让人感到震撼。也许这种事情在纯硬件电路上实现起来比听起来要容易得多吧?这种探索往往就是能让你得出这种结论。

我们做的第一件事是重排序缓冲区写入(ROB write)。这纯粹是为了我们在稍后保证按顺序完成(In-order completion)而设立的“账本”。

在这个节点,会发生一个被称为 解层叠(Unlamination,或分离) 的过程。此时,即便传入的微操作不是微融合(Micro-fused)而来的,如果芯片判定“这个操作实在太复杂,单个执行单元干不了”,它就会在这里将其拆分成多个微操作。某些极其复杂的寻址模式,如果本来是去加法器(Adder)处理的,有时就会触发这个情况。“哦,加法器处理不了这个复杂地址。”我承认在这个细节上我有点模糊。但解层叠是存在于这个阶段的,它可以生成额外的微操作。

Skylake 的重排序缓冲区(ROB)有 224 个条目(Entries)。这就是系统中可能同时“在飞(In-flight)”并被执行的指令数量的上限。对于更现代的处理器(我又看了一眼前排的那两位),比如 Granite Rapids 或更新架构,这个数字大概变成了 500 个左右。所以这是英特尔做了巨大改变和扩容的地方。

ROB 看起来大概是这样子的:我们有一些代表指令序列的序列号(Sequence number)。它需要追踪的信息包括:我们要写入哪里,以及发生了什么样的重命名(Rename)映射。稍后我们会讲为什么需要记录这个。奇怪的是,我们不仅需要记录源(Source),还需要记录旧版本(Old version)的寄存器状态,部分原因是为了支持撤销(Undo)(如果我们考虑分支预测错误的情况),部分原因是因为我们还没考虑到的一些其他机制。然后我们有一些指令的类型信息,还有一些诸如如果是分支指令,它的状态恢复点可能存储在哪里的信息。

这里重要的一点是,当我们在 ROB Write 阶段写入这个表格左半部分时;表格右半部分这里的执行状态(Execution)和异常(Exception)字段,要在指令实际完成后才会被写入。所以指令要去走完剩下的流水线,最后回到这里。所以在我在屏幕上展示的这个例子中,第一条指令(这个 Load)已经“完成”了。这意味着它已经走完了系统的执行流程,结束了它的工作,它可以在任何时候被退休单元(Retirement unit)处理掉并终结。你可以看到,我忘了我具体画了什么,哦,它在这里有一个 P22 的源。这就产生了一个隐含的依赖关系,其实这不那么重要。再次强调,我们其实不知道 ROB 在硬件里到底长什么样。这仅仅是我认为它为了实现功能而至少需要包含的信息集。

发出(Issuing)步骤,是我们实际将需要被计算的存活着的微操作移交给这些执行单元的地方。执行单元被分为两大块,上面附带了多个端口(Ports)。这术语有点怪。有一个 ALU 单元,包含了所有与算术和逻辑(Arithmetic and logic)相关的东西,你们懂的。还有一个内存系统(Memory system),只负责加载(Loads)和地址生成(Address generation)。它们拥有的条目数量也不同。它们大概是通过平衡内部各个区域的设计压力、复杂性,以及英特尔在实验室里测出的关于各类指令通常有多少同时存活在流水线里的实验数据来决定的。

“发出(Issuing)”阶段还会决定微操作要进入上述两块区域内的哪个具体端口。这意味着,它在被发出时被进一步细分了。所以,在这个节点,微操作最终要去哪就已经被决定了。即使这是一个加法操作,而芯片上有三个不同的加法器可以执行它,我们也会在“重命名(Rename)”这个时间点就决定好它将去哪个加法器。这其实有点目光短浅(myopic),因为等它真正准备好可以运行的时候,当初选定的那个加法器可能已经不是最优选择了,没准另一边有个加法器完全闲着。但这样提前分配还是有用的。

显然,这里我做了一个简化的假设。逆向工程社区已经以很高的置信度还原出了这里使用的分配算法。它其实跟你想象的一样简单:就是评估最近发往各个端口的操作数量的平衡。如果出现平局,它倾向于选择编号较高的端口号。它会试图平衡这 4 个发出的指令:第 1 个和第 3 个被发往分配的最佳位置,第 2 个和第 4 个被发往次佳位置。在英特尔的文档和代码里有记录,你可以去看做这同样事情的 Python 代码。

另外需要注意,同样是在这个时间点,与“解层叠(Unlamination)”不同(解层叠是指当我们发现一条指令稍后会变得过于复杂时,会在 ROB 中实际为其生成两个条目), 所有属于微融合(Microfused)的操作(比如加上一个来自内存 [RDI] 的值),在这个点会被正式拆分: 加载(Load)组件会被送往内存块里的 Load 单元,而依赖于该加载结果的加法(Add)组件则会被送往 ALU 块。解层叠和微融合的区别在于:微融合操作在 ROB 中依然只占用一个槽位,而解层叠的东西在 ROB 中实际上占据了更多的资源。当然,既然有 224 个槽位,这也不算是什么大问题。

大家都还跟得上吗?好的。

让我把这个图表放上来的初衷是想向大家展示每个部分到底存储了什么信息,因为很长一段时间里,我的脑海中经常把保留站(也就是调度器)写入的内容,和重排序缓冲区(ROB)的内容搞混。所以这其实是我在向我自己解释,以便我能讲给你们听,希望你们能谅解。

所以保留站(Scheduler)大概看起来像这样:我们有一堆微操作。它们现在已经完全没有任何特定的顺序了。每条指令可能包含三个需要的数据源。我们有一个即将写入的目标位置。这是一个加法操作。这依然是个猜测:因为我一直没能找到任何资料能告诉我,那些已知变量的“值”,到底是被直接存储在调度器的微操作记录里呢?还是只是存了一个引用指针?

在我的这个图表示意中,我选择的方式是:这条指令有两个 已解决的(Resolved) 源操作数。也就是说,生成这两个数字的先前指令已经执行完毕了。也许其中一个是操作码自带的立即数,另一个是读取寄存器得到的。这两者都完成了,所以我直接把实际的值写在了这里。

而对于另一些指令,我展示为它们正在“等待”:比如这条指令,它正在等待上一条指令(实际上是这个家伙)。他将要把结果写入物理寄存器 P24。所以我在这里等着他,当他完成计算时,他会告诉我结果是什么,然后我会把它填在这里。

我们不知道在硬件底层是否真的是这样发生的:即一条指令的完成,触发了一个广播(Broadcast)动作,从而使调度器中等待这些结果的槽位被写入。有一些间接的证据表明可能是这样的,因为 CPU 内部有一条广播总线。但这又引发了更多关于“如果你同时发出多条拥有两个已知变量源的指令”会发生什么的问题——因为这意味着我们必须在一个时钟周期内,去随机读取寄存器端口好多次(比如发出 4 条指令,每条 3 个源,那就要读 4 * 3 = 12 次)。这听起来极不现实。而且到目前为止,还没有人能找到(Skylake)在读取寄存器端口数量上有什么硬性限制。这与旧的 Sandy Bridge 架构截然不同,Sandy Bridge 对读取端口的数量限制极其严格。所以这里面肯定发生了一些我们无从知晓的魔法。

重命名机制(Renaming in detail)

好了,让我们来谈谈重命名。我们先讨论重命名运作的底层细节。

重命名的目的是什么?它将在后续流水线中解锁更多的乱序执行机会。为了做到这一点,我们将打破一切可以打破的依赖关系。

因为很多时候,当我们说 EAX 时,我们指的仅仅是“此刻暂时存在 EAX 里的值”。我们只是要对它做一些处理,然后我们就会抛弃它。随后我们会再往 EAX 里加载一个新的值,如此周而复始。这前后的两个状态其实是完全分离的,互不相干。

不需要依赖过去寄存器值的一个典型例子就是循环(Loop)。因为在每次循环迭代中,我们都会往 EAX 里读入一个新值,用它做些计算,存起来,然后在下一次迭代刚开始时重复这个过程。如果我们能打破这种伪依赖,这就意味着我们或许可以同时运行循环的两次甚至更多次迭代!我会给你们展示这是什么意思,对于那些正皱着眉头的听众,我准备了图片说明。

重命名的工作原理是:我们有一个映射表(RAT),它告诉我们每个架构寄存器当前到底在哪里。具体是哪个物理寄存器目前包含了最“现代”(最新)的 RAX 值。我依然使用我编造的 Pxx 语法。比如:RAX 当前映射到 P39 等等。

此外,我们还有一个物理寄存器的空闲列表(Free list)。这些代表着目前芯片上正在闲置、未被分配的物理槽位空间。太棒了。

  • 每次我们从一个架构寄存器中读取数据时,我们将使用它当前在映射表(RAT)中所对应的物理寄存器。

  • 每次我们覆盖写入一个架构寄存器时,相当于这就是一个全新的寄存器了。因此,我们将从空闲列表的前端取出一个新的物理寄存器,使用它进行写入;然后更新映射表,说:“嘿,现在的 RAX 在这里了。”

所以,看我们的第一条指令:我们读取 RDI 寄存器的值去获取内存,然后写入到 EAX 中。因此,这根本不关心 EAX 里面原本存的是什么鬼东西。所以 P39,谁在乎你啊,拜拜。我们要做的就是,我们把它重新命名为 P45(希望你看到了幻灯片上的动画,这代表从空闲列表弹出了一个新的)。请记住,这全都是在极速的硬件里发生的,非常惊人。而读取依然是使用 RDI 当前映射的 P22

然后下一个操作是 IMUL,也就是 EAX 乘以自己对吧?我在算 EAX 的平方,并把结果存回 EAX。我知道汇编操作码不一定长成三个操作数这样,但在乘法器内部是可以支持的,而且到这个阶段,它已经被重写成了这种非常优雅的 RISC 风格格式:EAX = EAX * EAX

所以当我们“读取” EAX 时,我们将使用物理寄存器 P45。而当我们“写入”时,我们将使用一个全新的寄存器,比如 P46。这确实就是发生的实际情况。如此这般,我们可以把接下来的指令全过一遍。

现在,我们得到了一个完全被重写的微操作集合。它们完全、且仅仅使用物理寄存器来表达。我们通过物理寄存器的名称,巧妙地编码了指令之间的值依赖关系。如果你在这个图表里顺着 P45 往下看,它在这里被创建,在这里被使用,然后它的使命就结束了。这极大地帮助了 CPU 的乱序执行。

这就是那个循环,或者准确地说是一次半的循环迭代看起来的样子。我画了一点五次是因为幻灯片只能装下这么多了。

如果我们在没有重命名系统的情况下去执行它:

一次循环迭代从这里的开头一直到差不多这里结束。你可以看到,下一次循环迭代连开始都无法开始!因为在上一轮迭代完成对 RDI 的加法(前进到下一个元素)之前,我根本无法进行第二次对 RDI 内存地址的读取,因为我必须等待它更新。而在我完成之前对 RDI 内存地址的读取之前,我又不能抢先给 RDI 加上 4,因为之前的读取可能还需要旧的 RDI 值。

(其实我为了让图表显示成这样,把原代码黑客式地改得很惨,然后我又手动把乱掉的连线修好了。但你们懂我要表达的意思即可。)

如果展示给你们全局视角,它看起来就像这样。如果你看这种乱序图表,眯起眼睛只看带有字母 R(表示 Ready/Retire)的地方,它主要代表吞吐量而不是延迟。完成一次迭代需要 10 个周期,这看起来也不算极其不合理。我的意思是,如果你真的要在生产环境写这个,你肯定会用 SIMD 向量指令来写,以获得单次迭代更高的收益。但总之,没有重命名就是 10 个周期一次迭代。

另一方面,如果我们进行了重命名:

在第 1 个时钟周期,我们已经将 5 条指令排入队列,并已经发出了其中 4 条!这其中的第一条(Load)和第四条(Add 4)在第 6 个周期就已经可以完成执行了。因为这条对 RDI 的加 4 操作,现在完全可以和那个读取内存的操作并行(Concurrently)运行,因为它们在物理层面已经不再相互依赖了!

依此类推。你可以看到,每次有一个字母 E(执行)出现时,它下面通常马上跟着一个 D(数据准备就绪)。这代表它执行完毕,结果就绪,并且能够立刻被下一条依赖它的指令使用。这也就是我刚才提到的关于 “结果广播(Broadcasting)理论” 的证据。当它完成时,它就像在吼叫:“在座有谁关心 P45 的结果吗?它的值现在是 27!”然后依赖它的指令说:“哦酷,我拿到了。”这样它就不用再白白浪费一个周期去等待了。

所以,你看这有多酷。如果我们在重命名的加持下放大视角,它变成了仅需 1.5 个周期就能跑完一次迭代!重命名真的产生了翻天覆地的差异。这简直太酷了。

重命名的魔法:零化、移动消除与加一技巧

好了,我已经铺垫得足够多了。让我们来谈谈我最喜欢的指令组合:

XOR EAX, EAX

你们肯定都见过它,对吧?有没有人想大声告诉我,到底为什么编译器要这么写?有人想喊出来吗?没人喊,好吧。

(观众反馈):它把值设为 0。

Matt: 没错,它把它设为 0。但是为什么不干脆写 MOV EAX, 0 呢?

(观众反馈):这是一条更短的指令。

Matt: 没错,它的字节长度更短。但伙计们,XOR EAX, EAX 的魔力远远不止于此。让我们过一遍。

首先,排除任何其他影响,一个数字和它自己进行异或(XOR)运算,结果永远是 0。而且因为 x86 糟糕的语法,这实际上意味着 EAX ^= EAX。所以它最终变成了 0,没问题。编译器极其喜欢这么做。

但最酷的是,CPU 也知道你极其喜欢这么做!

所以 CPU 看到这条指令就会说:“哦,你只是想把一个值设为 0 啊。那我根本就不需要做任何实际的运算工作。 我要做的只是在内部安排一个永远等于 0 的神奇物理寄存器,比如叫 P00。然后我只需要在重命名映射表(RAT)里改一下:好,现在的 RAX 指向 P00 这就完事了!”之后任何试图读取它的指令,都会直接拿到 0。

我们根本不需要去发出(Issue)这条指令。所以,尽管它被写入了重排序缓冲区(ROB),但它永远不会被发送到任何执行单元,它不占用后端的任何资源!这就像魔法一样。

我们还可以对一些置 1(One-ing)的操作做同样的事情。有些并行的置 1 模式会生成全 1 的状态,推测内部可能也有另一个表示全 1 的神奇物理寄存器。

移动消除(Move Elimination)

我们还能做移动消除。想一想,如果我执行 MOV RAX, RBX,我所做的实际上只是让两个架构寄存器名指向同一个物理寄存器罢了。所以我们只需要更新映射表(RAT)说:“嗯,RAX 现在映射到 P88 了,顺便一提,这也是目前 RBX 映射的地方。”这就不需要发出了,不产生任何微操作。这没有任何问题……等等,不占用资源不代表没问题,实际上这里确实有个隐藏的问题(Issue)。这带来了一个意想不到的复杂性。

(我以为我有一张幻灯片,哦,不,它在后面。)

因为我们现在允许同一个物理寄存器被多个架构寄存器别名指向。这意味着我们突然需要进行引用计数(Reference counting),以决定何时才能释放这个物理寄存器;或者我们需要另一个巧妙的机制,来判断什么时候所有人都用完了这个物理寄存器。

Skylake 似乎是这样做的:它只有 4 个槽位专门用来处理这种别名(Aliases)。一旦通过让两个寄存器指向同一个物理位置占用了一个槽位,直到这两个引用它的架构寄存器都被其他值覆盖重写之前,这个 P88 就被锁定(Locked)了,不能被释放。并且,那个别名处理槽位也不能被其他指令使用了。

如果你连续执行 4 次 MOV,它们全是“免费”的,不走执行单元。但在这 4 次之后,在寄存器之间移动数据突然就会变得极其昂贵(因为槽满了只能真去执行了),直到你把最初导致别名的源和目的寄存器全都覆写掉。

这在更新的架构上已经改善了。我认为从 Alder Lake(第 12 代)开始,他们采用了一种极其聪明的位掩码(Bitmask)技术来追踪这些东西。

在 Alder Lake 和之后的 CPU 上,他们还增加了一个新戏法。我知道我说过我主要只针对老架构,但这真的太酷了,不得不说。因为加法和减法实在是太直白了。如果重命名器看到一个加一操作(ADD RAX, 1),它会说:“等等,只是加 1?为什么我不直接在映射表里,把新的 RAX 继续映射到原先的 P88,然后只在旁边做个标记记住‘这个寄存器被加了 1’呢?这样多酷啊!”

同样的,小幅度的增量和加法操作完全不占用后端执行单元的资源,它们仅仅作为映射表(RAT)内务管理的一部分被记录下来!

当然,我们总要在某个时刻付出代价。有两个原因会导致我们“结账”。

  1. 范围限制:这个内部偏移量的范围只在 -1024 到 +1024 之间。所以它只有 11 位的范围。如果连续加法触及了这个极限,它就不得不真的发出一个微操作去后端算一下:“好吧,老老实实加一下吧,这样我们起码知道真实的值是多少了。”计算完成后,它又可以从这个新值开始进行不占用执行单元的重新命名映射。这很酷。

  2. 转嫁给后代的负担:更值得深思的是这真正意味着什么。当 P88(基础值)准备就绪时,任何依赖它的后续指令,现在突然被赋予了必须实际去完成这个被累积起来的加法的负担!

对于许多指令来说,这完全没问题。比如我是去读取 P88 的后续指令,旁边正好并行地跑着一个加法器,当结果传到我这儿时,那个需要加上的偏移量早就顺手被加上了。

但是,逻辑移位(Shift)指令却是个例外! 不管出于什么原因,移位操作每次都需要一个完整的周期来处理。这也是我们如何“发现”底层正在玩这种把戏的原因,因为在任何英特尔的公开文献中都没写过这个(因为根本不在那上面)。

如果你执行 MOV RAX, 10 然后做一次移位操作,你会测量出移位只需 1 个周期。

如果你执行 MOV RAX, 0,接着执行 INC RAX(加 1),然后再做一次移位操作,这个移位突然就需要 2 个周期了! 因为它必须为前面那个没跑完的加法“买单”,花 1 个周期把那个 1 补上,然后再移位。

我们不知道它为什么会这样。我们的猜测是:对于某些像可变移位(Variable shifts,即移位位数由另一个寄存器控制)这样的指令,因为巨大的桶形移位器(Barrel shifter)需要大量的设置时间,构建这样一个 64 位的任意移位硬件电路非常复杂。我们推测它可能需要在周期中非常早的时候拿到运算结果,所以它没有时间先去做前面欠下的加法,再去设置移位电路。而且英特尔硬件工程师可能为了省事,与其专门区分有可变位移的移位操作,他们直接一刀切地说:“凡是移位指令,只要遇到这种欠下的加法,全都强制多等一个周期”。或者,这也可能是个设计失误。我的意思是,既然我们见过 OCaml 的那个 bug,这种失误以前也不是没发生过。

这真的超级酷。这是一个非常有趣的现象。有一篇专门探讨这个的完整博客文章,我把它放在了最后的参考链接里,读起来极其引人入胜。

好的,我们演讲已经进行 50 多分钟了。我想我应该没有流失太多的听众,大多数人的眼睛还是睁开的,或者说还醒着,或者两者兼有。这是我们的中间小结,也就是我们在整体架构中所处的位置。这只是到达执行阶段的漫长铺垫:

  1. 预测的程序顺序(Predicted program order)给了我们指令或者指令指针序列。

  2. 它们被获取(Fetched)。

  3. 被解码(Decoded)成微操作(Micro-operations)。

  4. 微操作被重命名(Renamed)。

  5. 重排序缓冲区(ROB)按程序顺序被写入记录。

  6. 等待执行的微操作被写入调度器(Scheduler)。

棒极了。

后端执行:调度器(Scheduler)与执行端口(Execution Ports)

与此相比,后端看起来真的简单多了。但这正是隐藏着所有我们真正关心的东西的地方,也就是——实际完成工作的运算!前面的所有一切都只是前奏。

我已经说过, 保留站(Reservation station)调度器(Scheduler) 是英特尔(或者准确地说是 Agner Fog)可以互换使用的两个名字。所以这也是我很多信息的来源之处。然后我们有执行单元,和写回(Write back)。它看起来大致是这样的:

我们现在从一个整齐的流水线,进入了一个“只要时机到了随时可能被执行”的混沌大汤锅。我们有两个保留站(调度器),它们各自有大约 39 个条目,就坐在那里等待。它们等待的不是按照顺序,而是等待它们所需的所有操作数准备就绪,并且恰好有一个与它们标签匹配的执行单元处于空闲状态。此时,它们在这些队列里排队,顺序并不重要,随时准备开火。

我们还有重排序缓冲区(ROB)。这里展示的对重排序缓冲区的写入,仅仅是为了标记“这条指令现在完成了”。现在里面不再包含任何实际运算产生的数据值了。回想很久以前(比如 Sandy Bridge 之前的更早世代架构,它们的名字太蠢了我现在想不起来了,我真希望英特尔能起个好名字),早期的重排序缓冲区实际上是包含了物理寄存器数值的,它实质上兼作了物理寄存器存储的功能。所有运算结果都会被写回到 ROB 中。但这已经不是现在的情况了。物理寄存器现在单独放在一边,因为我们加入了向量(Vector)操作!如果我们在 ROB 里预留 512 位宽的槽位,那除非你永远只写 AVX-512 代码,否则这是一种巨大的资源浪费。(也许你们在座的有人真的天天写 AVX-512,我不知道,你们也不能告诉我。没准你们真的在用?)

总之,我们有保留站(RS),上面连接着不同的端口(Ports)。宏观上看,在每个时钟周期,调度器会说:“这些微操作里,哪一个准备好运行了?”然后它就会将这些指令下发给其中的一个端口。而这些端口内部,连接着许多实际的逻辑运算单元。你可能认为这里就是一个普通的加法器或乘法器之类的一对一映射,但实际上它们有不同的多用途组合。这些单元内部也是有自己的独立小流水线的。但是它们是固定长度周期的流水线,结果跑完后,比如 5 个周期后、3 个周期后,结果传出来,进入这条写回总线(Write back bus)

它(写回总线)会宣布:“是的,P47 现在变成了 127!”。这有可能被写入物理寄存器文件(好吧它肯定会被写入 PRF);同时它也会被那些一直苦苦等待这个结果的其他调度器条目“嗅探(Snoop)”到。一旦听到这个消息,那些依赖它的条目就会状态变为“准备运行”。然后这整个循环就会无休止地进行下去,永不停止。奇妙吧。

好的,让我们看看具体都有哪些执行端口。重申一下,这是 Skylake。在更现代的 Granite Rapids 之类的处理器上还有一大堆新增的端口。但在当时,你可以大致看出在这个极其努力干活的可怜芯片上,工程师的全部心血倾注在了哪里:

  • 有 3 个单元可以进行简单的标量整数和向量(Vector)计算。

  • 有 2 个专门负责移位(Shift)。

  • 1 个做排列组合(Permute),外加一些字符串操作之类。

顺便提一下,我这张幻灯片图表上的那些数字代表的是指令延迟(Latency)。也就是说,如果你用 x86 去跑字符串指令,那你可能走错路了(太慢了)。所以这边的执行端口负责处理所有算术相关的运算。但是这里面有一个奇怪的小子,它长得跟其他人不一样,那就是存储器计算单元(Store unit)。奇怪的是存储单元的数据处理部分竟然不在加载/存储(Load/Store)区域,而是活在 ALU 区域里。我们等下解释。

而在芯片的另一边这个区域(端口 2, 3 等),我们有一堆负责做功的加载端口(Load ports)。它们还能顺带生成用于写入操作的目标内存地址(Address generation for Store)。然后还有这里这一个非常孤独的“小不点”端口(端口 7),它只能做极其简单的地址计算。因此,我不知道这里发生了什么,也许硬件工程师在设计版图时复制粘贴了两次大端口,然后发现没地方了,于是随便改了改说:“我们在这放不下处理复杂地址的模块了,我们就让这个小家伙专门处理简单的地址计算吧。”

酷。我忘了我要继续说什么了。没关系,我想你们已经明白了:有很多功能模块连接到了不同的端口上。

是的。因此,在分配端口时,如果有平局,它通常会选择可用的最高编号端口。因为高级端口往往只能处理较少的特定任务;如果它能让一个向量移位指令去编号高的端口,它就相当于把具有更多功能的 0 号和 1 号端口释放出来,去应对后续可能即将到来的其他奇怪指令。

浮点辅助功能与退优化(Deoptimization)

执行阶段是我们能指望的最无聊的阶段,因为它做的事情就跟老 CPU 一样:就是死板地过流水线,偶尔抛个机器陷阱(Machine trap)。

这里有趣的一点是,这也是 浮点辅助功能(Floating point assists) 发生的地方。任何曾经撞到过 “非正规数(Denormals)” 导致的极其可怕的性能悬崖的人,可能会深有感触。(我看到台下有几个人在点头)。据我所知,这主要是因为:浮点计算单元无法在硬件层面直接处理极其微小、没有规格化的数字(比如不是 $1.x \times 10^{-y}$ 或 $2^{-y}$ 这种标准形式,而是小于 float min 的异常小数字)。遇到这种东西,必须要有特殊的分支逻辑来处理。但底层硬件搞不定它。然而,硬件又无法提前预判这个问题!

所以,只有当指令走到乘法器、加法器或除法器里面的时候,它才突然惊呼:“噢!见鬼!该死!我们该怎么办?这是一个我搞不定的数字!”

于是,它只能被迫清空(Flush)整个流水线,然后回到前端去找微代码定序器求助说:“老兄,你能给我生成一段专门处理这玩意的微代码序列吗,拜托了?”你想象一下,这就像是在最底层的硬件执行途中,做了一次动态的“退优化(Deoptimization)”,类似于 JIT 编译器里的那种退回慢速路径的做法。这太疯狂了。

此外,我们希望各个执行端口内部的延迟是尽量保持平衡的(回到上一张幻灯片,过去的情况是这样的)。我长话短说:他们试图让指令执行周期不要长短参差不齐。因为执行端口还会面临另一个问题,如果两个不同长度的指令在同一个时钟周期试图往写回总线上吐数据怎么办?比如一个 3 周期的指令,和一个比它早 2 个周期发出的 5 周期指令,它们偏偏在同一个时钟周期都要结束了!它们都想用同一根写回总线,这就不可避免地会导致其中一个被迫延迟。所以英特尔试图通过一些小技巧来避免这种冲突,比如让这边所有的延迟尽量都是 2 的倍数,然后做一些调度之类的事情。天啊,我们讲到哪里了?这就是我的意思。写回阶段就是结果被广播,被写入到持久的物理寄存器文件中(注意不是架构寄存器),然后在 ROB 中被标记为已完成。

内存访问:内存排序缓冲区(MOB)

地址生成(Address generation)和加载单元(Load unit)负责生成地址并执行加载。这听起来理所应当,但这到底意味着什么?

我们现在不得不引入一个名为 MOB(内存排序缓冲区,Memory Order Buffer) 的概念。这可能是整个 CPU 架构中最复杂的部分了。而我们只剩下大概 4 分钟来讲这个,所以毫无疑问这里会有一些简化。有些简化是因为我自己理解有限;有些简化是因为在这个领域,甚至还没有人能想出一种逆向测试方法来确切验证它到底是怎么运作的;还有一些可能是我的表达错误,我也不确定。

MOB 大致由三个部分组成:一个加载缓冲区(Load buffer),一个存储缓冲区(Store buffer),还有一些我们早前提过、但根本没时间详细讲的疯狂的预测器(Predictors)。这里到处都是预测。

存储缓冲区(Store Buffer) 保存着所有尚未写到真实内存的存储操作(Stores)。请记住,此时我们所做的一切都是 投机性(Speculative) 的!直到指令真正退休(Retire)那一刻之前,我们都没有绝对铁板钉钉的保证:也就是排在它前面的指令不会突然报告“我刚才除以零了”、“我分支预测错了”等等异常情况。所以在到达那个安全的退休单元之前,我们绝对不能让这些存储数据泄漏到真实的物理内存中。所以我们必须把它们扣押在这个缓冲区的“保持模式(Holding pattern)”里。

但是,如果我向内存的一个地址写入了一个值,紧接着我的下一条指令又要从这个同一地址读取数据,我理所当然地会期望能读到我刚才试图写入的那个最新数字。如果读不到旧值,那就太糟糕了。所以我必须解决这个问题。这个存储缓冲区有多重身份:一方面,它负责拦截那些还没资格去真实内存的数据;另一方面,它必须时刻应对后面排队的加载指令(Loads)发来的查询,因为那些加载指令可能恰好需要读取它手里还没提交的新鲜数据。

它大体上被分解为这两个部分:我们将 地址计算(Address calculation)数据准备(Data calculation) 分离开了。这就是为什么之前我们看到存储的“数据(Data)”单元会跑到 ALU 那边去的原因。因为你要存储的数据从哪里来?大概率是从算术逻辑单元(ALU)刚算出来的。而存储的地址则是由地址生成器产生的。

我们在这里这么做是因为,将它们分开后,(大多数情况下,或者至少有些情况下吧,我们就算有些情况下)你会比知道“要存什么数据”更早地知道“你要存到哪个地址去”。

打个比方,你要对一个数连续做 20 次平方根运算,所以你有一条长长的依赖链全是算平方根,最后你打算把最终结果存进内存地址 20。我可能在极其早的阶段就知道了我最终要存进地址 20。但我必须要苦苦等待 500 个周期,等那些该死的平方根全部算完,我才能拿到那个要存进去的数据。为什么我会如此关心这个提前知道的地址呢?因为如果我后面排队跟了一个读取指令(Load),而它恰巧不是去读地址 20 的,那我就可以放心地让那个读取指令提前越过我先去执行了!所以,为了让后面流水线的其他指令不再被无辜堵塞,我们迫切需要提前知道“地址”,这比“数据”本身更重要。

在存储缓冲区里,我们有地址,有数据,有操作的宽度大小,有它是否已完成计算,以及它是否引发了错误(Fault)。同样,如果发生了一个引发内存错误的存储操作,但它恰好处在一条最终被判定为不会走的预测路径上(比如还没执行到这里前面就除以零了,或者哪怕是遇到 Spectre 幽灵或 Meltdown 熔断这种漏洞级的情况),那么我们就不希望这个错误真正生效导致程序崩溃。所以要在它最后真正试图去退休(Retirement)的时候,我们才会核实它到底是不是一个真实的错误。

所以对于存储(Stores)操作:我们必须等待它的地址计算和数据操作这两者都完成。然后我们就坐在缓冲区里,一边等待命运的审判,一边时刻服务那些从后面发来试图读取我们身上数据的查询。最终,我们被实际提交。这发生在这条指令安全“退休”很久之后才会发生。

加载缓冲区(Load Buffer) 另一方面,存储着正在发生的加载操作,以及一些我刚才提到但没时间深入展开的预测状态信息。这里的机制被用于跟踪一些推测:它会根据一些智能启发式算法大胆猜测“这个读取操作估计永远不会和前面的存储地址发生冲突”。在这种推测下,它就会极其激进地让这个读取指令非常早就发出去执行。但这带来的隐患是,如果它猜错了——这代价极其惨痛,它必须立刻发起一整套状态清空动作,来撤销“本该读取前面刚刚存的数据,但却读了旧的错误数据”的后果。而且它根本没有优雅的回退机制,它只能粗暴地把后面跟着的所有运行结果全部作废。

所以在这个缓冲表里,我们有 ROB 的索引引用,操作的位宽,它访问的目标地址等信息。并且这里的排序同样重要。这里有一个属性叫做 存储颜色(Store color)。学术文献里使用了这个词,但他们根本没有深入解释它到底代表什么意思。如果你去看 RISC-V 对此的具体实现,它是用一个极其复杂的位掩码系统来表达的。但这本质上是它用来标记:“在这一连串已经发出的存储指令序列中,到底哪些先前的存储操作有可能影响到了当前的这次加载?”这样我就能把它们之间建立联系。我只有确信,所有排在我前面、可能干涉我数据的存储操作都被处理得清清楚楚之后,我才能安全地去进行这个读取操作。

所以说,加载(Loading)操作真的是复杂到令人发指!这也是为什么有时候它跑得慢的另一个原因。并不仅仅是因为我们在算“去 L1 缓存需要三四个周期”。天哪,它在背后还要做这么一大堆该死的事!

  1. 首先,我们要进行关于地址是否有歧义(Ambiguity)的预测(这超纲了略过)。

  2. 我们必须等待所需的读取地址被计算出来(希望这能快点)。

  3. 然后我们必须去查阅存储缓冲区(Store buffer)。如果我们在存储缓冲区里命中了,并且,在我们命中匹配到的那个存储条目,和任何尚未解决其地址的存储操作之间,没有插入其他的可疑操作。我们就知道:“哈哈!任何比这个加载更早的未提交存储操作,都不可能对我需要的数据产生除了我找到的这一个之外的其他影响了!”我们可以欢呼一声:“万岁!我们甚至连 L1 缓存都不用去访问了,这就是传说中的 L0 缓存!拿去,这是你要的数字!”

  4. 如果条件不满足,比如我们没有找到匹配(Miss)。但在我们前面的队列里,也没有任何依然地址不明的“定时炸弹”存储操作。我们就知道:现在排在我们前面的所有还在队列里的、没有冲刷到内存的写入操作,都和我们要读取的地址无关。那么我们现在就可以放心地发出这个读取指令,它终于会真正前往 L1 缓存并启动去读取物理内存的全套流程。最后读取完成,返回数据,然后发送给程序的下一步使用。

这堆乱七八糟的玩意儿居然真的能正常工作,简直是个奇迹。

相反,如果我们依然陷于那种模棱两可的状态:也就是前面依然存在还有没弄清去哪的存储操作,我们就只能一直痛苦地等待。我们只能坐在队列里干等,直到前面所有不明的存储操作全部被解析出明确的地址。这就是为什么我们要极其努力地去预测会不会发生冲突的原因,因为有的时候即使付出巨大代价去预测也是非常值得的。

(是的,这里有一个某种专利号,我相信你们不应该因为法律合规等种种原因去点击它。我们不提这个了。)

内存排序缓冲区(MOB)参与的另一件事是所有那些极其讨厌的锁(Locked)指令。你想要做内存屏障(Fences),比如存储屏障和加载屏障之类的操作。这些操作会强制让前面的流水线排干,然后才允许后续能发出读取请求的指令去执行等等。它或多或少涉及到了一个词,我要把它抛出来,那就是完全存储顺序(Total Store Order, TSO)。但老实说,在早些时候我和你们的一位专家交流后(Andrew 在哪里?他刚才还在这里),我对这个知识点的自信已经被彻底击碎了。(笑)

所以关于这个肯定还有更多的内幕。但这都是在这个阶段之后的事情了。

退休阶段(Retirement)与状态提交

最后我们来到了退休(Retirement)环节。现在刚好距离我开始讲话整整一个小时。幸运的是这是最轻松的一步。

大家经历了前面的一切,最终所有的工作都完成了。

那个重排序缓冲区(ROB),再一次扮演了流经系统的所有指令的终极“总账本”。退休系统所做的事情,就是尽其所能地快速抓取所有已标记为完成(Completed)的指令,然后宣布:“好了!这些案子都结了,它们所占用的所有资源现在可以被释放并回收到系统里去了。”这也是进行资源释放的最后关头。这也是前面被重命名占用的 那些物理寄存器最终被交还给系统的时刻。因为正如我们在操作码里看到的,我们要释放的,并不是我们刚刚写入的那个新物理寄存器;而是早在流水线最前端重命名阶段,我们要去替换掉的那个“老一代”寄存器。我们知道,当前指令产生的寄存器完全取代了现存的架构上的 EAX。它在重命名那一刻,把原先一直充当 EAXP88,换成了新的 P50。当这条指令最终安全退休时,那就意味着 P88 的历史使命彻底终结了,没有任何后方的分支或者撤销需要用到它了。所以这个时候,P88 就被重新链回到空闲列表中。这个过程之所以能如此简单,前提是你没有搞任何别名(Aliasing),并且那个时刻没有其他的微操作还在苦苦挂念着 P88

这也是异常(Exceptions)真正被处理的地方! 就像我们前面说的,如果只是在记录上打了个标记,这就是一切开始崩溃的时候。希望你们的代码里没有搞出太多由于机器层面的异常。这里说的异常不是指 C++ 层面的 throw Exception,我指的是诸如机器检查(Machine checks)、除以零(Divide by zero)以及段错误(Memory stuff)之类的真硬件异常。

存储操作(Stores)此时也在内存顺序缓冲区(MOB)中被正式晋升为 “资深/成熟状态(Senior)”。因为它们还要继续在那个缓冲区里待上一阵子,直到它们被彻底冲刷出真实的物理内存。成为“资深”状态的意思就是说:当初导致这个存储操作诞生的那条指令,它已经在退休环节宣告功德圆满完成了。所以这个存储操作现在已经被官方盖章许可,可以放心地去提交给真实的内存了。

退休系统每周期可以处理 4 个微操作,这很少会成为系统的性能瓶颈,尽管你能用某些非常极端的病态代码来证明它确实是上限。

再往后,就不算是一个单独的流水线阶段了。仅仅是存储缓冲区正在按照顺序,将那些已经变成“资深”状态的存储操作逐个退休。此时发生了一些关于完全存储顺序(TSO)的某种魔法,或许是与芯片上其他模块、或其他子系统的某种协同沟通。而且据我所知,如果我没弄错的话,这就是传说中的 读取所有权(Read For Ownership, RFO) 发生的时刻。当我们平时讨论并发编程里令人头疼的 缓存乒乓效应(Cache ping-ponging)伪共享(False sharing) 时,似乎就是发生在这个节点去抢缓存行的控制权。我对此真的很模糊。老实说,我曾拼命想弄明白它到底可能在整个流水线的哪个环节发生。也许演讲结束后我应该找你们这些专家聊聊,听听你们的高见,它看起来就是在这个时候发生的。

总结与建议

好了,这就是全部内容。其实挺直白的,对吧?(笑)

想象一下我之前还打算跟你们聊分支预测器来着。不管怎样,我们进入下一环节,总结。

这其实算不上是什么总结。整个这场演讲本身就是个总结。就像是在告诉大家:这台机器是被人类搭建起来的一座极其不可思议的奇迹建筑。它复杂得令人发指。

如果非要给什么建议的话,我感觉就像是“班门弄斧”。这里的人肯定都懂这些:

  • 用简单直接的方式做事。

  • 不要做除法!我是认真的,特别是整数除法。浮点除法我不管,随便你。

  • 尽量写精简且对齐的循环。

  • 放手让重命名器(Renamer)去干它的活吧! 同样地,这是我在和你们在座的顶尖专家交流前写下的建议。我之前一直没深刻思考过“到底要不要增加更多寄存器”这个问题,但这让我非常感兴趣。既然我自己写的短循环能跑得那么好,我还是坚持我的写法好了。重命名器在幕后做那些神奇的资源映射做得极其聪明,所以一般来说你不必过于担心架构寄存器不够用的问题。(这里的脚注是:具体详情请去咨询台下的大佬们)。

是的,我相信你们肯定有一堆问题。我在这里列出了一整页的参考资料,你们可以自己去深入研究。我也会确保你们能拿到这些幻灯片的。

非常感谢大家!

问答环节(Q&A)

好吧,我拿到这个方块麦克风了(指现场使用的可投掷麦克风 Catchbox)。有哪位勇敢的人想提问吗?我带了礼品!走之前你可以来领贴纸。你可以来拿一张贴纸,或者我这里有两个杯垫。其实并不怎么稀奇因为它们是纸板做的,但我试图用这个贿赂你们来跟我互动提问。有人要发言吗?来吧。哦,那里有一个问题。你好。

好,我要试着不砸到任何人把这个麦克风丢过去。抱歉,我记得我没有签什么“保证不造成人身伤害”的免责协议吧。(扔麦克风)

Q1: 那些物理寄存器……它们的位宽是完全相等的吗?

Matt: 这是一个非常好的问题。不,它们不是相等的。

物理寄存器文件(Physical Register File)是被分割成不同区块的。 我原以为我在某张幻灯片上列出了这些数字,也许我放了但刚才没讲。

针对不同类型的数据,存在不同数量的物理寄存器:

  1. 有几百个专门负责 标志位(Flags) 的物理寄存器。因为标志位很小(就几位),而且几乎每一条算术指令都会去影响和更新标志位,但后续指令中几乎没有几个真正关心这些标志位到底是什么。所以你极其需要频繁地对它们进行“重命名”,把它们隔离出去,以确保不会因为共用一个标志位寄存器而产生虚假的依赖阻塞。

  2. 对于 AVX 向量寄存器,似乎有一批全宽的 512 位物理寄存器。重命名器似乎非常吝啬,只有在明确知道必须用的时候才会动用这些 512 位寄存器,否则它就会试图降级使用 256 位宽的寄存器池以节省这部分芯片版图空间。再次声明,我这里有点含糊其辞,因为我个人没有针对这个做过全面的底层验证,但从文献阅读中可以得知它确实存在这种根据位宽做智能分配的机制。

  3. 显然,剩下的就是那些专门用于 64 位通用寄存器的几百个物理寄存器。它们至少是 64 位宽的,或者说绝对包含满足各个基本类型长度的基础结构。

另外需要注意的一点是,我之前在幻灯片上经常很随意地说 RAX,然后图上又标注 EAX 等等这些不同的名字。我们都知道在架构层面上,它们属于“同一个”寄存器。但这在以前(回到 Sandy Bridge 时代)的情况是这样的:如果你只对(比如)低 16 位的 AX 进行写入,那么寄存器分配器会给你分配一个全新的物理寄存器来做这部分工作。但是,它必须在底层硬塞一个操作码,执行一个“与/或(AND/OR)”指令,把你刚写的新结果跟原先那个旧寄存器里保留的高位部分拼接(Merge)起来!所以在那个时代,部分寄存器写入是代价极其高昂的合并操作。

但这在现代似乎不再是问题了。它现在似乎只会单纯地处理这部分,中间就这么发生了神奇的变化。如果你向低 16 位写入数据,你就不必再付出那些合并的额外工作了。

但无论如何,是的,通用物理寄存器它们都是 64 位宽的。好问题。

好的。你想把它传给你旁边的提问者吗,以免我乱扔。太棒了。

Q2: 您对不同设计之间的差异有多少直观的感受?比如这些英特尔 CPU 与 AMD 之间,甚至是其他高性能架构 CPU 之间相比?

Matt: 老实说,不是很多。我童年时代接触的都是纯纯的 ARM 架构,但这显然是非常非常古老且毫无复杂性可言的了。之后我的所有研究精力几乎全倾注在英特尔 CPU 上了。

话虽如此,我确实知道 AMD 在某些地方是非常不同的,比如它的分支预测器(Branch predictor)。那是我自己亲自进行过逆向研究的领域,那完全是两种截然不同的设计理念。这属于其中一个例子。但对于我们今天讨论的诸如前端解码、重命名这种核心细节方面的区别,我确实不清楚。也许在座的其他人会知道。所以很抱歉,这是一个“无可奉告”式的回答。

前排这边有一个问题。

Q3: 我听过关于 ISA(指令集架构)是否重要的两种观点。比如,ARM 之所以快,到底是因为抛开了 ISA 包袱的其他周边设计,还是单纯因为它的 ISA 本身就好?您的看法是什么?

Matt: 我觉得,如果回到过去那段辉煌岁月,当 ARM 还真的是一个纯粹的 RISC 处理器的时候;你知道,那个时候指令极其直白,我只需看一眼那串十六进制的指令代码就能在脑海里把它反汇编出来。我认为在那时,面对同期的 x86 对手,ARM 的简单 ISA 绝对是它占据优势的关键所在。

但那艘船早就开走了。如今看看 ARM 增加的那些花里胡哨、眼花缭乱的新功能吧。 我认为他们现在在前端所拥有的那些解码步骤、预测系统、流水线架构以及乱序执行等等复杂度,绝对和 x86 不相上下。 所以我并不认为现在的底层 ISA 还能带来多大的根本性差异。至于这些加减运算最终是如何通过前端各种曲折的路径最后抵达 ALU 的,这更多是前端硬件实现细节的区别,而不是本质架构优势的体现。

可能唯一能拿出来争辩的论点是: x86 ISA 糟糕的变长设计,无形中充当了“穷人的指令压缩技术”。 这使得它的代码密度更高,所以在 L1 指令缓存(iCache)的利用率上,x86 或许能稍微占点优势。但是你懂的,我们刚才也都看到了有些单条 x86 指令长达 15 个字节的情况。你看着这种东西肯定会想:“我一点也不觉得这比定长指令好。”所以是的,很抱歉又给了你一个看似什么都没回答的答案。

前排还有一个问题。我想请你小心地把它递过去。这就对了,完美。

Q4: 这些东西到底是怎么被人发现的?人们到底做了什么样疯狂的实验才能把这一切逆向推导出来?

Matt: 这是一个必须对前辈致以崇高敬意的问题。

这一切的祖师爷(OG)是 Agner Fog。他是一个丹麦人,现在已经退休了,我上周才知道,他实际上是一位退休的丹麦人类学家!这简直完全符合你脑海中对这种神秘大神的想象。他似乎曾经是一位社会人类学家,或者是一位专注于科技的人类学家。这也解释了“我为什么会干出这种极其离谱的硬核逆向工程”。总之,他做了大量的基础实验来逆向工程推导这些底层的机制。

简短的回答是:利用性能计数器(Performance counters)。

CPU 内部其实自带了大量的性能监控硬件计数器。尽管英特尔根本不希望你知道它们记录的到底是什么底层原理,但这些计数器的名字却起得极具启示性。你可以利用它们,通过极其诡异的使用方式来推断发生了什么。

就拿我自己在分支预测方面的逆向研究来说,这就起源于典型的“网上有人信口开河,我必须去打他脸”的心态(就是那张有人在网上乱说话必须被纠正的 XKCD 梗图)。有人在网上发表了极其笼统的言论,说“向后跳转的分支永远会被默认预测为‘执行跳转’”。我就想:“真的吗?‘永远’?那如果你代码逻辑确实没有发生跳转呢?如果它是一个向后的条件分支呢?”

总之长话短说,结果就是无条件分支和条件分支确实机制不同之类的。但这些实验的手段,是利用了英特尔文档里偶然提到的两个隐晦指标:“早期重新转向(Early re-steers)”和它不愿细说的其他参数。它只会说:“哦,这是流水线因为早期被改变方向而触发的计数”,然后你会说“好的。”还有一个是“晚期重新转向(Late re-steers)”,以及别的东西。

最后得出我的推论(这些推论得到了我收集到的数据和结论的强力支持),就像我在演讲开头说的那样:你一次读取 16 字节的一整块内存,前端的获取单元根本不知道里面到底有没有分支指令。分支预测器(BPU)和获取器只会死板地喊:“读 16 字节,再读 16 字节,再读 16 字节。”但是,如果在此后的“解码阶段(Decode stage)”,一旦它非常早地侦测到了有一条分支指令在里面,它就能向后给前面的流水线发一条消息说:“哦对了,你刚才去获取的路径估计错了。因为如果你之前根本没预见到这个点该有分支,那根据大概率你现在应该立刻转向另一条分支方向了。”这就叫“早期重新转向”。所以它只承受了 3 个周期的惩罚,然后就立刻开始纠正到了正确的轨道上。

通过不断地修改这些跳转的前后方向,精确计时,并观察指令之间的距离如何影响时钟周期(这就是那张带颜色方块的堆叠图的来历),你就能管中窥豹,推断出内部到底发生了什么。

而对于更为复杂的东西,比如微操作(Micro-ops)之类,它同样有计数器来统计“这个指令到底在流水线的每个不同阶段被执行了多少次”。你可以编写一段极其刻意控制的测试代码,比如只让你去一遍遍地执行加法,你会看到:“哦,端口 0、1 和 7 上的计数器都在增长,所以是这三个端口能执行这个操作。”以此类推,你去构建极长的依赖关系链,然后在这些长链之后做一些手脚,观察哪些其它的计数器也随之暴涨:“哦,这说明这里我们撞到了重命名器的瓶颈,它无法再进行重命名了,因为等待队伍实在排得太长了”,诸如此类的实验。

这背后有一大堆这种极其变态的实验。在这个领域,Travis Downs 可能拥有最完善的一套工作成果。你可以去看他的博客。他还写了一个叫做 Uarch Bench(微架构基准测试)的工具。这本质上就是一套专门针对芯片底层微架构特征探伤的测试套件。Agner Fog 也有他自己写的一套。我自己也分支(fork)并在维护了一份专门用来测试分支目标缓冲区(BTB)的版本,我刚把它修好能重新编译了,它之前坏得一塌糊涂。

然后就是 Andreas Abel 以及在他那篇论文里和他合作的另一个人(我想那应该是他的硕士论文)。他们花了大量的时间试图反推重命名器背后的分配算法模型,哪个端口在什么时间分配给谁,耗时多长。他们发现了大量以前从未有人注意到过的特性,这极其不可思议。

我非常热爱这种能对日常随身携带的、一直好奇“这玩意到底是怎么工作”的东西进行解剖实验的乐趣,你可以亲手去寻找答案。

所以简短的回答就是:各种诡异的硬件计数器和极其极其精密的计时测试。

让人难过的是(我相信我们刚才也聊到过),这些测试中绝大部分只在你有权限接触到真正的裸机物理硬件时才有效。很显然我自己有一台笔记本电脑和一台台式机。但我曾试图去租借一台使用 Granite Rapids 架构的服务器。你知道租这种极其先进的机器是非常昂贵的。结果我登录到我的亚马逊云端账户,当它跑起来的时候,我试图用 sudo 运行命令去读计数器,结果发现:“为什么这上面只显示有三个硬件性能计数器可用?一台这种级别的 CPU 绝对不可能只有三个计数器!”然后我反应过来了:“哦,这是虚拟机,对吧?它没有给我暴露底层的硬件计数器接口。”所以我没法在那台极其昂贵的机器上进行任何我想要的测量。这就跟试图在受限环境运行你们的 magic trace 工具一样:你必须拥有一台真实的、货真价实的实体计算机才能跑它并得出真实的答案。

还有其他问题吗?顺便说一句,谢谢你们的提问。

好的,旁边有一位,然后你后面也有一位。

Q5: 您有最喜欢的 x86 指令吗?

Matt: 我有最喜欢的吗?哦,其实没有。我是个极其简单的人。其实我挺喜欢… 有一条处理并行比较结果运算的指令,我总是记不住它的全名,但每次我看到它的时候我都忍不住笑,因为它的名字实在太夸张了。我想不起来是哪条了,就像是什么 PCLMULQDQ 或者之类的东西吗?不。这是一个非常好的问题,我本应该为你准备一个极其聪明机智的答案的,但我确实不知道。

提问者:我们接受最讨厌的指令作为答案。

最讨厌的吗?最讨厌的指令。天啊,我真希望我现在能有个极其精辟有趣的答案,但你把我问住了。他们把那条指令怎么处理来着……他们为了二进码十进数(BCD 码)的调整,曾经有过一条专门的调整指令。但后来他们在全面进入 64 位时代的时候,甚至都懒得把它移植过去。当时我就想:“拜托,你们的系统里到处都塞满了因为后向兼容留下的各种陈芝麻烂谷子的破包袱,结果你们偏偏就把这条指令给扔下了?”它有个很奇怪的名字,我现在想不起来了,大概是那个吧。

好,先生。

Q6: 我们可以利用这些知识来让我们的代码跑得更快,而英特尔肯定也极其希望他们的 CPU 在测试里看起来非常快。那为什么要把这些至关重要的底层信息全部隐藏起来、搞得神秘莫测无法描述呢?他们为什么不直接大大方方地把所有细节全部公布告诉我们?

Matt: 我不知道。有可能在这个大楼里,真的存在某些能够获得这方面绝密资料的人,而我不具备这样的渠道和权限。当我在谷歌(Google)工作时,我至少了解到这样一个“潜规则”:当你的公司每年从英特尔那里采购的芯片数量达到某个巨大的天文数字时,他们对你的服务态度会有一个极为陡峭的升级。

待遇好到什么程度呢?低一点的是“我们可以派遣一名专门的技术专家飞到你们公司,手把手帮你们调试代码瓶颈”。高到离谱的待遇则是“哦,我们可以按照你们定制规格的流水线,完全为你们单独代工生产一颗专属于你们的特制版芯片!”

然后他们内部就会分发一些叫做“粉皮书(Pink books)”和“黄皮书(Yellow books)”之类的绝密资料。这是对不同机密级别的芯片底层技术文档的代号。确实,在爆发 Spectre(幽灵)和 Meltdown(熔断)危机的那个时期,我和一些同行进行过完全不留任何记录的私下交谈。他们说:“唉,那本粉色文档里面明明就讲过这方面的预测机制……”我听得目瞪口呆:“你在说什么?什么粉色文档?哇,难道你没看过那些书吗?”我回答说:“没有。”他们立刻惊恐万状:“哦见鬼,我们根本不该提这个!我的天哪,即使只是让你知道这种秘密文档的存在都不行。”

所以,我认为对于金字塔尖极少数的超级大客户来说,他们绝对是掌握着这类核心机密信息的。

但回到你真正的问题本身:他们不完全对外公开的普遍原因,我认为是他们极度害怕对底层的工作方式做出任何官方的承诺。

因为,你知道 海勒姆定律(Hyrum’s Law) 吗?那就是:“只要你的系统具有任何可被依赖的行为,哪怕那根本不是你设计的官方特性,最终一定会有一大堆用户死死地依赖上它。”这也正是为什么直到今天,现代的芯片上依然不得不保留着 20 世纪 70 年代的古老指令的根源(尽管我那位处理二进码十进数的可怜老朋友已经被抛弃了)。所以他们绝不想因为把底层细节说得太明白,而把自己未来的技术演进空间给逼进死胡同里。

此外,这背后理所应当还有一层涉及极其敏感的知识产权(Intellectual property)保护层面的考量,即便它很快就会被民间的专家强行逆向工程出来。就像分支预测器这个东西,这是我认为极其特别的一个点。因为我确实知道 AMD 在这方面的设计与英特尔截然不同。

当时那群深入逆向了 Skylake 分支预测器的顶尖研究人员,他们发现了一个让人极其不可思议的奇特现象(我原本想为了这个单独再加几张幻灯片,可惜没时间了):

分支预测器在内部要对指令地址进行极其复杂的混淆(Smooshing)、哈希(Hashing)和各种扰动,以此来为当前正在经历的这条特定分支路径提取出一个独一无二的“数字指纹”。这样它才能在预测表里去查找:“嘿,上次在这个特定的上下文中遇到这条分支指令时,我们是跳转了还是没跳转?”这就是分支预测器的核心理念。

但这些研究人员震惊地发现:这套极其复杂的哈希算法里,竟然没有把分支地址的第 5 位(bit 5)混合进去!这一位竟然是直接映射的(Direct map)!

这意味着,如果分支地址的第 5 位是 0(清零),你就等于被完全隔离分配到了整个预测系统极其庞大的另外半壁江山里;如果它是 1(设置),你就用另外一半!这一位在哈希运算中“漏水”了!

更有趣的是,这帮黑客大佬竟然利用这个天大的破绽,为那个大名鼎鼎的类似 Spectre/Meltdown 的漏洞攻击,手动打造了一个完美的硬件级安全沙箱(Sandbox)

做法是这样的:如果你写了一个 JIT 编译器,你可以在你生成的每一段可能被外部触发执行的代码里,利用极其精密的对齐和指令填充手段,强行确保所有的分支跳转指令的执行地址,它的第 5 位永远是 1!而你在系统底层的 Supervisor 监督代码里,在所有极其敏感的内存越界检查逻辑中,确保那些检查代码分支地址的第 5 位永远是 0!(观众震惊大笑)。

砰!就这样,你在同一颗物理核心里,硬生生地创造出了两个由于底层哈希漏洞而绝对无法互通的、平行的分支预测域(Prediction domains)! 在这个时候,因为外部的恶意代码无论如何去疯狂训练它的那半边预测器,都死活影响不到你底层防护代码的另一半预测结果,你突然就对 Spectre 和 Meltdown 攻击免疫了!这特么有多酷!

我当时的反应就是:“对啊,如果他们(英特尔)早点把这个细节在官方手册里告诉我们,我们早八百年就把这招用在编译器安全增强上了好吗?”但随之而来的问题就是,一旦公开了这个,英特尔工程师就会崩溃:“哦见鬼,现在所有的顶级安全厂商都在利用我们这个哈希错误,我们在以后的十代芯片里都别想去动这一层的代码了,哪怕它是个极其弱智的漏洞!”所以老实说,我严重怀疑英特尔的设计师自己在被破解之前,根本都不知道他们自己的硬件里第 5 位就这么大喇喇地被泄露出来了。

所以我认为这算是某种情况。我不觉得这适用于汉隆剃刀原理(Hanlon’s Razor)——就是“永远不要用恶意去解释那些单纯是由愚蠢造成的事情”。我不认为这单纯是愚蠢。因为我认为负责设计这些东西的这群人是极其极其聪明的。对于必须对外暴露什么,必须隐藏什么,这肯定是经过了无数次极其严密的反复推敲和权衡才做出的极其小心的决定。我们显然都处在一个对知识产权极其极其敏感的行业大环境里。所以,在“这东西确实能让极少部分开发者如虎添翼”与“世界上绝大多数凡人根本不需要知道这种东西,但它如果被底朝天曝光,极有可能瞬间导致我们在商业竞争中被对手致命打击并抹平优势”之间找到一个微妙的平衡线,这肯定是我能给出的最合理的推测。

更何况,光是把这堆犹如天书般的庞杂体系完完整整地写进开发文档里,就已经是一场足以让人发疯的极其痛苦的灾难了。

还有其他问题吗?

好的,后面有人向我挥手了,看来时间到了。

所以,再次感谢大家邀请我来。我在这里度过了极其美好的一段时光。非常感谢。

(全场鼓掌)