30 秒看懂
Matt Godbolt 以一个小循环为例,讲解 Intel Skylake 流水线如何将 x86 指令转化为微操作、寄存器重命名如何发掘并行性,以及加载和存储如何保持程序语义。他区分了有文档记载的事实和研究者推导出的模型。 在问答环节,他讨论了物理寄存器、指令集、性能计数器实验,以及 CPU 内部机制没有完全公开的原因。他还介绍了对分支预测器的一项发现及其安全影响。
- 1支持乱序执行的流水线前端负责取指和解码;后端执行操作数已就绪的操作,退役阶段则恢复程序顺序。
- 2两种融合宏融合将比较和分支等指令合并;微融合将内存操作与另一操作打包,直到后续阶段才分开。
- 3微操作缓存解码后的操作可以从缓存中持续送出,无须反复经过传统解码器。
- 4重命名消除假依赖架构寄存器名称映射到不断变化的物理寄存器,让彼此独立的循环迭代得以重叠。
- 5清零与移动消除重命名器可以识别清零惯用写法,或为物理寄存器建立别名,而无需发出执行操作。
- 6内存顺序存储和加载缓冲区跟踪推测性访问;加载可以从更早的存储转发数据,也可能需要等待歧义消除。
- 7研究者如何发现这些细节性能计数器和精心构造的依赖链有助于推断端口使用、重命名限制和分支行为。
要点建议
- 现代 CPU 对独立任务进行乱序执行,同时按程序顺序退役指令。
- 长度可变的 x86 指令需要复杂的解码,微操作缓存可减轻这一负担。
- 寄存器重命名消除了假依赖,还能省去某些清零和移动操作。
- 加载和存储需要依赖检查与推测执行,才能在外部表现出正确的顺序。
- 微基准测试、性能计数器和仔细的计时能揭示未公开的行为,但推导出的模型有其局限。
- 短小且对齐的循环和简单操作有助于提高性能,而开销高昂的整数除法值得仔细审视。
按章节阅读整理后的全文
现代 CPU 流水线
Godbolt 介绍乱序执行及其依赖的数据结构,解释为什么选择 Skylake 作为实验对象,以及讲解中哪些部分依赖推断。
主持人介绍 Matt Godbolt 是一名研究编译器、操作系统和芯片的 C++ 开发者,也是 Compiler Explorer 的维护者。他说自己喜欢逆向研究处理器,包括分支目标缓冲区;由于不便测试最新机器,他将重点讲解 Skylake。
传统教科书中的流水线依次取指、解码、执行和写回,让各阶段重叠以便每个周期都完成有效工作。现代处理器则包括前端、乱序后端和退役阶段。独立操作在输入和执行单元就绪时可以并行执行;退役阶段恢复程序顺序。
Skylake 前端包括取指和解码路径、微操作缓存、队列和重命名器。复杂的 x86 指令会拆成更简单的内部微操作。Godbolt 提醒,图中的许多内容由研究者社群推断而来,Intel 并未直接记录。
物理寄存器文件的槽位远多于程序能看到的架构寄存器。寄存器别名表将名称映射到当前物理值;重排序缓冲区跟踪尚未退役的操作,保留站则容纳等待执行的任务。分支缓冲区和内存顺序缓冲区有助于管理推测执行和内存依赖。

x86 取指与解码
一个计算平方和的小循环展示了变长指令、预解码、融合和微码。解码器的限制决定了操作进入流水线的速度。
为了具体说明各个阶段,我使用一个 C++ 循环:对数组中的整数求平方,再累加到总和中。它编译成六条指令,恰好占 16 字节。汇编只是文本表示;机器实际读取的是旁边的字节。
循环中的 x86 指令长度各不相同。整个指令集中的指令长度可以从 1 字节到 15 字节不等,前缀和数十年的兼容性要求使指令边界难以辨认。这种复杂性也是前端需要大量解码电路的原因之一。
经过修改的 Andreas Abel 的 uiCA 可以帮助我展示一条指令随时间经过流水线的过程。例子中,预解码、排队、发射、分派、执行和退役分别占据不同的周期。这些图是依据测量建立的模型,并非对 Intel 实现的直接观察。
取指器跟随预测的指令指针前进,而不是等待确定结果。它在对齐边界一次读取 16 字节,此时解码甚至还没有确定这些字节中是否包含分支。分支预测必须让取指器持续工作,跳入一个块的中间则可能浪费取指带宽。
预解码试图找出指令边界,以便并行解码多条指令。Godbolt 介绍了一种假设:硬件推测性地考虑可能的偏移量,并过滤相互重叠的候选项。他强调,幻灯片上的表示只是有根据的猜测,并非权威的内部格式。
预解码器还可以把常见的比较和分支指令对识别为一个内部单元,这称为宏融合。它每周期最多能处理五条指令,但有一些限制。改变长度的前缀可能带来性能损失,而识别为复杂指令的内容必须使用相应的解码器。

四个解码器按顺序接收部分解码的指令。宏融合合并两条架构指令;微融合则不同:内存读取和算术操作可以作为一个打包的微操作一起传递,之后再拆开执行。这个区别影响任务占用的队列和缓冲区空间。
微码序列器为需要大量操作的指令提供更复杂的序列,包括锁定操作、重复存储和整数除法。它可以发出操作序列,也可以包含控制逻辑。Godbolt 指出,他展示的单个操作是根据实验推断出来的,因此具体的内部工作并不总是清楚。
第一个解码器处理复杂指令,最多产生四个微操作;另外三个处理单个简单微操作,包括融合后的操作。一连串各需两个微操作的指令尤其不利,因为每条都需要第一个解码器,其他解码器却闲置。微操作缓存有助于避免反复执行这些解码工作。

微操作缓存与循环流检测器
缓存解码后的操作可以避免反复解码。Godbolt 解释缓存的限制,以及 Skylake 的一个缺陷为何促使 Intel 禁用循环流检测器。
微操作缓存与传统解码路径并列。解码后的操作经过时可以填充缓存;跳转到已缓存的代码时,前端可以切换为从缓存供给操作。这里并不是对每条指令都进行普通查找:前端从两条路径中的一条持续获取操作。
Skylake 的缓存有 32 组、每组 8 路,每条缓存行最多容纳 6 个微操作,某些操作占两个槽位。一个 32 字节的代码块最多由三路表示,而分支会结束一条缓存行。因此,短代码块有多个入口可能降低缓存效率;更新的设计有不同的限制。

将循环对齐到 16 字节边界有助于取指。Godbolt 说,文献记载缓存每周期最多能送出六个微操作,但他自己的测量达到四个。他把这一差异作为观察结果提出,没有假装已经解释清楚。
循环流检测器可以重复使用微操作队列中已有的循环,让前端不必反复取指和解码。它每周期最多送出四个微操作,还可能在内部展开短循环,以更有效地利用带宽。
但在 Skylake 上,Intel 因一个涉及短循环和高字节寄存器的严重缺陷禁用了循环流检测器。Godbolt 说,OCaml 社群成员发现了这个问题,随后微码更新关闭了该功能。这提醒我们,即使复杂精密的硬件也可能有出人意料的缺陷。
重命名与调度
重排序缓冲区、调度器和寄存器别名表跟踪任务并消除假依赖。一个循环展示了由此产生的重叠执行和几种重命名阶段的优化。
重命名器在重排序缓冲区中记录推测性工作,将架构寄存器映射到物理寄存器,并把操作送往执行单元。操作需要等待其依赖项和合适的单元就绪。Skylake 每周期能为四个微操作完成这一复杂的记录工作。
有些操作在这一阶段经历“解层”(unlamination),产生额外的微操作和重排序缓冲区条目。Skylake 的重排序缓冲区有 224 个条目,限制了同时进行的工作量;Godbolt 说更新的处理器大幅扩大了这一容量。缓冲区跟踪完成状态和异常,使退役能按顺序进行。
发射时还会选择执行端口。即使多个单元都能执行加法,端口也会在操作就绪之前选定,所以所选端口之后未必是最空闲的。研究者依据观察到的行为,逆向推导出一个近似的负载均衡算法。
经过微融合的内存与算术操作会拆成加载和依赖它的算术操作,送到执行单元,同时仍只占一个重排序缓冲区槽位。Godbolt 将其与会产生额外缓冲区条目的解层区分开来。调度器容纳待执行工作;重排序缓冲区则是按顺序完成工作的账本。
Godbolt 不确定已就绪的操作数值是直接存放在调度器条目中,还是在别处通过引用访问。结果似乎会广播给依赖它的操作,但表面上的寄存器读取次数又带来了更多疑问。他把这些图作为有用的模型,而不是声称知道确切电路。
重命名消除了重复使用架构名称产生的假依赖。寄存器别名表指出当前物理寄存器;指令完全覆盖一个寄存器时,空闲列表提供一个新的物理寄存器。读取使用旧映射,写入更新映射,因此同一名称的无关用途可以独立推进。

在这个例子中,加载操作将 EAX 写入一个新的物理寄存器,随后乘法读取该值并写入另一个物理寄存器。产生的微操作通过物理名称表示真实依赖。下一次循环迭代无须等待先前对 EAX 的无关使用完成就能开始。
在 Godbolt 举例的无重命名模型中,假依赖使工作串行化,每次循环约需十个周期。有了重命名,加载和指针递增可以重叠,模型中的吞吐量约为每次迭代一个半周期。他指出,实际代码可能会改用 SIMD。
常见的 `xor eax, eax` 将寄存器清零,编码也很紧凑。处理器还会把它识别为清零惯用写法:重命名器可以将结果映射到已知的零,而不必向执行单元发送操作,尽管该操作仍须跟踪以便退役。

寄存器之间的移动同样可以通过将两个架构名称映射到同一个物理值来消除。别名使判断何时可以释放物理槽位变得复杂。Godbolt 说,较早的设计中别名槽位数量有限,而后来的设计似乎处理得更灵活。
他说,在较新的处理器上,小幅递增有时可以在重命名机制中表示为偏移量,无须立即执行加法。偏移量的范围有限,最终必须实际算出结果。当移位操作使用这样的值时,可能会多出一个周期;他提出的解释只是关于时序的假设,并非已确认的电路实现。
执行与内存顺序
操作等待操作数和执行端口就绪。加载与存储缓冲区、转发和推测执行共同保持程序的内存语义。
至此,前端已将预测的指令地址转化为经过解码和重命名的操作。重排序缓冲区保存程序顺序的账本,待执行操作则存放在调度器中。在后端,操作数就绪且分配的执行端口可用时,工作才能真正运行。
执行单元本身也采用流水线,不同端口支持不同的操作和延迟。结果完成后,会写入物理寄存器文件,在重排序缓冲区中标记为完成,并提供给依赖它的工作。选择功能较专一的端口,可以为其他指令留出更灵活的端口。
内存顺序更复杂。存储缓冲区保存推测性存储,直到操作退役、可以安全提交;后续加载可能需要在这些值到达内存之前看到它们。因此,机器会同时跟踪地址和数据,而不是简单地延迟所有内存操作。
一次存储的地址和数据可能在不同时间就绪。如果地址较早确定,即使存储数据仍在计算,后续对不同地址的加载也可能继续执行。将两者分开创造了更多并行工作的机会。
加载缓冲区跟踪尚未完成的加载,处理器预测加载是否依赖更早的存储。预测错误可能需要代价高昂的恢复。如果已知匹配的较早存储,且中间没有尚未确定的存储,加载就可以通过存储到加载转发获取值,无须访问 L1。
如果更早的存储都不会影响某次加载,它就可以访问缓存;如果情况仍不明确,就可能需要等待。内存屏障和锁定操作会施加额外的顺序限制。Godbolt 强调,他的讲解有些部分经过简化,现有测量也无法确定某些细节。

执行还可能遇到特殊情况。Godbolt 描述了针对极小的非规格化浮点数的辅助处理:据他了解,当普通单元无法处理该值时,硬件可能需要清空流水线并使用微码序列。这可能造成严重的性能骤降。
写回、退役与实用建议
结果写回物理寄存器,指令按顺序退役。Godbolt 指出存储提交的某些细节仍不确定,并给出实用建议。
写回将结果放入物理寄存器,并标记操作完成。退役随后按程序顺序推进已完成的工作,处理机器异常,并在安全时释放旧的物理寄存器映射。已退役的存储仍留在缓冲区中,直到能够提交到内存。
Godbolt 对存储提交时读取所有权(read-for-ownership)及缓存交互发生的确切时点把握较小。他的实用建议是保持操作简单、谨慎对待整数除法、使用短小对齐的循环,并让重命名发挥作用。他补充说,与听众的讨论让他对之前关于是否需要更多寄存器的假设有了更复杂的认识。
关于寄存器、指令集和研究的问答
听众询问物理寄存器宽度、架构差异和微架构逆向工程的方法。Godbolt 讨论计数器、实验以及虚拟机的局限。
被问到物理寄存器宽度时,Godbolt 说寄存器文件按类型划分:标志、通用和向量寄存器分别拥有不同的寄存器池。他提到有文献认为全宽 AVX 寄存器会按需分配,同时承认自己没有亲自验证所有这些细节。
他还讨论了部分寄存器写入。在较早的设计中,写入较窄的部分可能需要将它与旧寄存器值的其余部分合并。据他了解,较新的处理器处理方式不同,但细节并未完全公开。
谈到 Intel 和 AMD 的差异时,Godbolt 说他主要研究 Intel,无法对后端的所有方面做出有把握的比较;但他知道两者的分支预测器不同。被问及指令集设计能否解释 ARM 的性能时,他认为现代解码和实现细节使问题难以简单回答。
人们如何发现这些内部机制?Godbolt 肯定了 Agner Fog 和其他研究者利用性能计数器与精确计时开展的实验。他自己的分支研究比较了向前和向后分支以及流水线早期重定向,以推断公开文档没有直接描述的行为。
重复操作和长依赖链可以揭示哪些端口执行某条指令,或资源何时耗尽。他提到了 Travis Downs 的 uarch-bench 和 Andreas Abel 的研究等相关工具与成果。虚拟机可能只提供太少的硬件计数器,因此许多实验需要物理硬件。
内部机制为何不公开
Godbolt 认为兼容性和知识产权可能是信息公开有限的原因。他介绍了一项有关分支预测器的发现,它可能用于沙箱隔离。
被问到 Intel 为什么不公开所有细节时,Godbolt 无法给出确定答案。他认为公开的行为可能变成兼容性承诺,知识产权和编写文档的成本也可能有影响。一些大客户可能获得比公开资料更多的信息。
他介绍了一项对 Skylake 分支预测器的研究:研究者发现某个地址位实际上将预测资源分开。研究者利用这一观察,为生成的代码及其监督程序构建独立的预测域,提出了一种应对特定推测执行安全问题的方法。Godbolt 指出其中的矛盾:一旦这些细节公开并被依赖,设计就更难改变。
同频道的其他文章
1:34:29EN
1:34:36EN → 简体中文
15:48EN → 简体中文
1:12:58EN → 简体中文
58:47EN → 简体中文
3:13EN → 简体中文