Link

了解你的硬件

当我们编写游戏引擎或任何其他类型的高性能系统时,了解硬件的工作原理对于能够进行良好的高性能设计至关重要。计算机是复杂的设备,并且随着时间的推移变得越来越复杂。CPU 的工作方式在几十年中不断发展,这需要改变为其编写软件的方式。本文并不试图完全深入,而是作为现代硬件工作方式的概述和起点。如果您正在从事高性能代码工作,了解这一点对于达到最佳性能非常重要,您可以在您要定位的任何控制台的开发者站点或硬件供应商的文档网站上找到这些事物的具体细节。

CPU 基础知识

计算机的核心是 CPU(中央处理器)。它是运行您的程序和操作系统的东西。现代 CPU 是工程奇迹,是高度复杂的设备,具有大量的功能和技巧来快速运行。通过回顾 CPU 随时间演变的时间线,我们可以了解这些功能是如何实现的,并大致了解现代 CPU 的疯狂程度以及它们在运行代码时所关注的内容。

CPU 核心是一组逻辑门和电路,它们执行一个操作,然后循环并继续下一个操作。最简单的 CPU 几乎可以用几百行 Verilog(硬件设计语言)来表达,请参阅这个 “小型 Riscv CPU” 小型 cpu 源代码 。核心由一组协同工作以执行程序的部分组成。

有 ALU(算术逻辑单元),它实现加法、减法和比较数字等数学运算。寄存器组是存储处理器“工作内存”的地方。您可以将其视为绝对最高性能的内存,但它非常小,通常只够存储几个数字。CPU 中的操作主要在寄存器上进行,并直接连接到 ALU 以及 CPU 核心的其他部分。为了存储更多数据,使用了 RAM(随机存取存储器),也称为内存。CPU 可以将数据从内存加载到寄存器中,也可以将数据从寄存器存储到内存中。程序的常见流程是它将加载内存,使用 ALU 对其执行一些数学运算,然后存储结果。CPU 核心由解码器控制,也称为前端或许多其他名称。这是 CPU 的一部分,控制核心的主要执行。当运行指令时,CPU 的这部分将读取指令的二进制代码,然后触发 CPU 核心的不同部分来完成它们的工作。这部分通常有一个特殊的寄存器,通常称为 PC(程序计数器),它存储要从内存加载的下一条指令的地址。如果程序使用分支或跳转,则可以直接寻址和修改此程序计数器。

现代 CPU 也将拥有多个这样的核心,以便能够一次完成更多工作。一组指令通常称为执行的“线程”,每个 CPU 核心都独立于其他核心。有一些方法可以使用原子指令在核心之间同步信息,也有一些方法可以跨核心同步内存。有关更深入的信息,请查看另一篇文章 游戏引擎的多线程处理

流水线

在一个时钟周期内可以完成的工作量取决于指令必须通过的逻辑门和导线的数量。可以设计一个每个时钟执行 1 条指令的 CPU,但这会使这种 CPU 的时钟频率非常慢。为了改进这一点,CPU 设计人员将指令执行分成不同的“阶段”,每个阶段执行指令的一部分,并且一次只需要核心的一个部分。

例如,执行指令的各个阶段大致遵循以下模式

  • 取指:从内存中获取要执行的下一条指令
  • 解码:读取指令并设置信号以控制下一步将执行的操作
  • 执行:执行所需的操作。这可能需要多个时钟周期。CPU 支持多种操作,其中一些最常见的操作是
    • 数学运算 - 告诉 ALU 使用寄存器进行数学运算
    • 比较 - 比较寄存器
    • 加载/存储 - 在寄存器和内存之间移动数据
    • 控制流 - 将 PC 更改为代码中的特定指令
  • 写回:将操作结果推送到寄存器或将其存储在内存中

完成所有这些之后,它再次从取指阶段开始以继续执行。这些阶段在 CPU 设计之间差异很大,并且其中一些阶段可能根据指令花费更多或更少的时间。有关更多详细信息,请参阅 “维基百科:指令流水线”

让我们看一个非常简单的将两个数字相加并存储结果的示例。

add a b      =  F D E W 
add a c      =  - - - - F D E W
mov a [mem]  =  - - - - - - - - F D W 

两次加法各需要 4 个时钟周期,mov 将结果存储在内存中,但因为它不进行数学运算,它可以跳过执行步骤,因此运行需要 11 个时钟周期。

您可能会看到我们在每个时钟激活 CPU 的不同部分,并且可以重叠不同指令的阶段。这称为流水线。在上面的示例中,我们可以在 ALU 工作时开始解码和加载下一条指令,这将像这样结束。

add a b      =  F D E W 
add a c      =  - F D E W
mov a [mem]  =  - - F D X W 

我们必须在内存指令中添加一个 X 阶段,以等待数学运算的结果,通过流水线指令,我们可以在每个时钟使用更多的 CPU,并完成更多总工作。在此示例中,我们已从 11 个时钟减少到 6 个时钟。我们还必须找到一种方法在第二次运算之前使用 a 寄存器,以便更快地在第二次加法中使用它。流水线由于需要跟踪一组关于什么可以重叠以及如何重叠的规则,因此给 CPU 增加了相当大的复杂性,但它也带来了巨大的性能提升。一些旧的 CPU(如 Gameboy CPU)由于复杂性要求,几乎没有任何流水线。该 CPU 仅重叠了下一条指令的内存加载和当前指令的内存加载,但其他阶段都没有流水线化,因此每条指令至少需要 4 个时钟周期。在像 Ryzen 这样的现代 CPU 中,我们看到了流水线中超过 15 个阶段,并且 CPU 甚至会重新排序指令的执行以提高效率。

内存缓存

在 Gameboy 时代,CPU 的时钟频率与 RAM 的时钟频率相似,因此您可以在 1 条指令的时间内(几乎在几个时钟周期内)读取和写入 RAM。但在现代 CPU 中,情况不再如此,因为 CPU 在加载或存储 RAM 中的单个值所花费的时间内可以执行数百条指令。

为了缓解这个问题,CPU 设计人员添加了更小的内存,这些内存更靠近 CPU 的执行单元,目标是这些内存就像二级但速度更快的 RAM。这些就是所谓的缓存。虽然现代 PC 可能有 32 GB 的 RAM,但它很可能有一个 32 KB 的 L1(一级)缓存,它小 100 万倍。CPU 内存系统很智能,会在使用时将内存从 RAM 加载到缓存中,并使用智能算法将最常用的内存保存在缓存中。它知道一个值何时已在缓存中并且可以快速获取,以及何时需要进入慢速系统 RAM。CPU 中也有多个缓存,其中一些缓存直接位于其中一个核心旁边,另一些缓存是所有核心的全局缓存。这形成了加载数据的完整层次结构。每个级别的大小和速度都在缩放。随着尺寸变大,其速度下降。这是一种有意的权衡,旨在解决物理学对 CPU 设计人员施加的限制。

跟踪您的内存使用情况和缓存的工作方式通常是您在编写高速代码时可以提高性能的最重要的事情。在游戏和渲染引擎中,工作集通常不适合 32 KB 的 L1 缓存,也可能不适合 L3 可以容纳的数兆字节。为了获得最佳性能,您需要尽可能最好地利用加载到缓存中的内存,并尽量使 CPU 更容易预测,因为如果内存访问是可预测的,CPU 将在内存使用之前开始将内存加载到缓存中。内存加载通常一次加载 64 字节(称为“缓存行”),因此如果您只访问对象中的 1 个变量而没有其他内容,您将浪费周围的所有内存,使其成为内存系统中的空流量。找到一种方法来最大化从缓存加载的 64 字节的价值可以带来巨大的收益。CPU 内存预测器通常尝试找到线性迭代模式,因此如果您的代码对数组中的对象执行操作而没有间接寻址,则 CPU 很可能会识别出该模式并为您加速。有关编程如何映射到缓存使用情况的演示,请观看有关缓存效果的演讲 “Meeting Cpp:CPU 缓存效果”

乱序执行

所有这些缓存行为都给指令加载所需的时间带来了很大的随机性。如果 mov 指令在 L1 中,则可能需要 10 个周期,但如果必须访问 RAM,则可能需要 200 个周期。在那段时间里,CPU 需要完全停止执行该指令,直到内存到达。这种情况发生在 PS3 CPU 上,并且是其主要减速源之一,即使该 CPU 具有较快的时钟速度。为了解决这个问题,CPU 开始乱序执行指令。这样,即使其中一条指令必须等待一段时间,CPU 的流水线也可以继续进行有用的工作。一旦乱序执行开始成为现实,CPU 还可以通过为不同阶段配备多个执行单元并自行提取并行性来提高性能。现代 CPU 可以将数百条指令排队并安排它们执行。

例如,单个 CPU 核心可以有 3 个独立的 ALU。一个 ALU 正在运行诸如除法之类的长时间操作,而其他 ALU 正在进行快速的 1 周期加法。但是,这样做时,我们遇到了一个问题,即某些指令在不同的时间结束,并且某些指令也相互依赖。这在 CPU 内部通过排队系统处理,其中一次读取多条指令,然后在解码后将它们放入挂起队列,直到满足它们的依赖关系。由于指令使用的寄存器数量很少,不同排队指令所需的寄存器会不断相互冲突,因此硬件设计人员向 CPU 添加了一个“寄存器重命名器”,该重命名器将代码中使用的少量寄存器连接到 CPU 必须具有的更多寄存器,以改善指令重叠。一旦指令满足其依赖关系,它就会被添加到 CPU 的不同执行单元中,例如仅执行加法/减法的 ALU,或也执行除法的 ALU,或多个内存加载器之一。

虽然指令以不同的时钟周期数运行,并且它们也乱序完成,但 CPU 会跟踪一个输出列表,该列表将完全保留代码顺序来执行内存写入和原子操作,以便它按编写方式工作。

有关一些信息,请参阅此维基百科页面 “维基百科:乱序执行”

分支预测

在 CPU 内部具有如此深度的流水线和乱序执行的情况下,我们在分支方面遇到了问题。当 CPU 遇到分支但数据仍然未知分支到哪里时会发生什么?我们可以停止整个过程,直到数据到达,但这将意味着使 CPU 完全停止许多时钟周期,如果分支数据依赖于可能需要 200 个周期才能到达的 RAM 加载。现代 CPU 决定仅决定分支方向,并继续执行。如果他们决定错误,他们将刷新半完成的指令和挂起的内存写入,然后重新开始。如果他们猜对了,一切都很好,什么也不做。这称为分支预测。它试图预测未来,以便对程序流程的走向做出有根据的猜测。为此,它在 CPU 内部保留存储空间,记录给定分支上次是否被采用,甚至是否存在在采用和未采用之间翻转的模式。现代分支预测器的复杂性非常高,它们的具体工作细节通常是严密保护的商业秘密。在像 i7 或 Ryzen 这样的设备上,分支预测器平均超过 95% 的时间都能正确预测。他们挣扎的地方是当分支本质上是随机的时,对于可预测和稳定的分支,预测器几乎总是会正确命中。如果分支是随机的并且不断被错误预测,则 CPU 会因不良预测而停顿并不得不不断重置,这通常是可能显着减慢软件速度的事情。在某些情况下,值得运行分支的双方并使用选择指令或无分支混合,因为错误预测的惩罚高于计算。“Cpp 中的无分支编程” “CPPCON:Cpp 中的无分支编程” 可以是一个很好的视频,它讨论了如何以无分支方式进行编程以提高性能。另一篇很棒的文章解释了 CPU 中看到的不同类型的分支预测器,是这篇 “Danluu:分支预测”

SIMD

以上所有内容都侧重于更快地执行指令的核心。但是,如果我们想每条指令执行更多工作怎么办?这就是 SIMD(单指令,多数据)发挥作用的地方。由于需要每条指令完成更多工作,CPU 开始添加新的指令,这些指令将一次执行多个操作。例如,通常 CPU 一次添加一个数字,但是如果您使用 AVX512 指令集(在现代服务器 i7 和上一代 Ryzen 上),它们将在一个指令中执行 16 次加法,每条指令提供 16 倍的工作量。SIMD 通常具有相当大的限制,因为它们是非常复杂的指令(因为每条指令执行多项操作),并且编译器通常很难处理它们。自 SIMD 指令首次发明以来,自动向量化一直是前沿研究,即使在今天,试图依赖自动向量化来最大化给定代码段的性能也是不可靠的。为了获得最大性能,程序员通常需要使用内在函数(直接在代码中使用 SIMD 指令)来完成他们的算法。还有一些语言(如 ISPC)“ISPC”,可让您直接编写向量化代码。多年来,SIMD 指令集一直在发展。我们首先从 MMX 和 SSE 指令集开始,它们是 4 路的,因此它们一次运行 4 个操作。这对于许多多媒体和图形工作非常有用,因为您经常处理本机 vec4。例如,DXMath 库(类似于 GLM,但用于 directX)大量使用 SSE 指令来加速游戏开发的向量和矩阵数学,它使用 vec4 和 mat4x4。随着时间的推移,我们然后转向 AVX 指令集,它是 8 路的。现在,向量不适合图形数学中使用的典型 vec3 和 vec4,因此使用它们更加困难。此外,AVX 与 SSE 不兼容,因此如果您想使用新指令和更宽的执行,则需要重建代码。AVX 2 在几年后发布,它仍然是 8 倍,但它有一些更有用的操作。几年前,我们获得了 AVX512,它提供 16 路执行,以及一组非常高级的操作。英特尔认为游戏玩家不需要它,因此在他们上一代 i7 上移除了它。在撰写本文时,只有服务器级英特尔 CPU 和 Ryzen CPU 支持它,这意味着它完全不适合用于游戏开发,因为根据 Steam 的数据,只有不到 30% 的玩家拥有支持 AVX512 的 CPU。在 ARM 平台上,您可以找到 NEON 支持,它是 4 路的,但比 x86 PC SSE 等效项更好,并且在用于数据中心的尖端 ARM 核心上,您可以找到 SVE,它是一种向量指令集,允许可变长度向量,以便未来的 CPU 可以具有更宽的 SIMD 单元,并且当前代码仍然可以工作。由于有如此多不兼容的 SIMD 功能集,手动创建内在函数可能是一项重要的开发工作,因为开发人员通常需要至少编写 3 次代码。一个用于标量普通代码,另一个用于 PC 和大型控制台的 AVX,另一个用于 ARM(任天堂 Switch 和手机)的 NEON。甚至 PC 也可能有多个功能级别。关于内在函数的一个很好的视频是 “性能峰会:SIMD 编程的艺术”。如果您不想使用内在函数并且更喜欢库,xSimd“Github xsimd” 可能是一个不错的选择,它使用模板来抽象多种类型的内在函数。

材质

这里谈到的很多内容都考虑到了称为面向数据编程的编程风格。在这种编程风格中,开发人员通常在编写代码时会考虑到缓存和分支预测器等因素,通常将他们的数据放入数组中并分批处理。讨论它的主要书籍是 “面向数据设计书籍”,以及演讲 “CPPCon:面向数据设计和 C++”“CPPCon:OOP 已死,面向数据设计万岁” 是关于它的经典演讲。

有关特定硬件详细信息的信息,一个很好的信息来源是 “Agner fog 优化手册”。第一本是必读的,微架构手册谈到了这里解释的许多主题。另一个来源是 Wikichip,它提供了有关不同处理器详细信息的良好信息。《现代硬件算法》数字图书 “现代硬件算法” 也是一本很棒的读物,它深入探讨了许多这些概念,特别关注围绕它们进行编程以及高性能算法的代码示例。