RPCS3:为什么 PS3 模拟这么快

标题:Why is PS3 emulation so fast: RPCS3 optimizations explained

日期:2024/04/13

作者:Whatcookie

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

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


我为 PlayStation 3 版本的《尼尔》(Nier)创建了一个补丁,使其能以任意帧率运行。在制作这个补丁的过程中,我发现游戏代码中存在一个奇怪的模式。游戏喜欢先将一个向量寄存器清零,然后再将这个清零的寄存器转换为浮点数。但问题是,与所有其他数字不同,二进制中“零”的表示方式对于浮点数和整数来说是完全相同的。换句话说,这里的第二条指令基本上什么也没做。这个模式在《尼尔》的代码中出现了 2434 次。

幸运的是,当我们将 PlayStation 3 的 PowerPC 代码转换到 x86 平台时,我们能够优化掉这个浮点转换。但我无法向你展示 RPCS3 中优化此模式的代码,因为 RPCS3 并没有直接优化这个模式。我们并不是将 PowerPC 代码直接翻译成 x86,而是将其翻译成一种叫做 LLVM-IR 的中间语言。开源的 LLVM 项目懂得如何接收 LLVM-IR 并为许多不同的计算机架构输出代码。由于 LLVM 项目收到了大量的贡献,像“对常量值进行浮点转换”这类简单的优化就不再需要 RPCS3 团队重新实现了。

这就是为什么当你在 RPCS3 中首次启动游戏时,在“编译 PPU 模块”这一步会花费很长时间。RPCS3 内的一个分析器会找出二进制文件中的所有 PowerPC 代码,将其翻译成 LLVM-IR,然后 LLVM 会将其转换成经过良好优化的 x86 代码。在游戏启动前完成这一步,有助于减轻在运行时因即时翻译代码而通常会发生的任何卡顿或延迟。

但是,在 PS3 上运行 PowerPC 代码的核心,即 PPU(Processing a a PowerPC Unit),基本上是 PS3 中最无趣的部分。这是一张 PS3 CPU 的芯片裸片图。这个巨大的结构就是 PPU。它支持运行现代操作系统所需的一切,并且可以运行通用代码。而这 8 个重复的结构是 SPU 核心。它们是为了最大化吞吐量而构建的,代价是牺牲了可编程性。在芯片裸片上本身有 8 个,但为了提高良率,其中一个被禁用了,另一个则被操作系统保留。所以,在模拟 PS3 时,我们只需要关心其中的 6 个。

让我们来看看 SPU 的手册。我看不懂这个。让我们看一份官方手册吧。有很多特性使 SPU 与众不同,但我们来谈谈它们如何处理浮点数。首先,手册提到,“游戏应用程序的大部分代码库都假定使用一种不同于通用处理器上普遍实现的 IEEE 754 格式的单精度浮点格式。”这到底是什么意思?

原来,SPU 继承了 PlayStation 2 那种奇葩的浮点格式。在这种格式中,无穷大(infinity)和非数值(NaN)的值是不被支持的,而是被解释为一个具有非常大指数的数字。在 IEEE 754 标准下,任何指数位全为 1 的数字都会被解释为 NaN 或无穷大,但在这种扩展范围浮点格式中,它们被视为一个极大的数。

这给模拟器带来了问题,因为我们需要执行额外的指令来确保与 PlayStation 3 软件的兼容性。这就是当我们没有正确模拟扩展范围浮点格式时,《忍者龙剑传 سی格玛 2》(Ninja Gaiden Sigma 2)看起来的样子。你可以看到大部分几何体都丢失了。

我们如何处理这个问题?一种在 PlayStation 2 和 PlayStation 3 模拟器中都常见的方法是,将输入值从在 IEEE 处理器上会被解释为 NaN 或 inf 的值,钳位(clamp)到 Fmax(浮点最大值)。然而,这并不完全准确,但速度相当快。即便如此,即使是快速的方法也会给我们的实现增加几条指令。这看起来是怎样的呢?这是生成钳位指令的代码,而这大致是最终生成的 x86 汇编代码。两条指令的开销并不算多,但考虑到 PlayStation 3 在处理浮点数方面的速度是 PlayStation 2 的 40 倍,你仍然希望有更快的方法。

于是,我确实想出了一个更快的方法。通过使用 VRANGEPS 指令,我们只需要一条指令就能同时进行正负值的钳位。这条指令的行为由一个常量值控制。通过使用正确的常量,我们可以取输入的绝对值,然后取该绝对值与 Fmax 的较小值,最后将原始输入的符号位复制到结果值上。至关重要的一点是,当任一输入为 NaN 时,该指令的行为也有所不同。如果任一输入为 NaN,它将使用第二个输入值作为结果。由于我们的第二个输入是 Fmax,这种行为正是我们想要的。不幸的是,VRANGEPS 指令只能在支持 AVX512 指令集的 CPU 上使用,而大多数人仍然没有能执行 AVX512 指令的 CPU。

那么,使用更精确的浮点模拟后,《忍者龙剑传》看起来是什么样呢?在不精确的浮点模拟下,游戏丢失了部分几何体,因为游戏正在 SPU 上执行尽可能多的几何处理。许多游戏为此使用了一个由索尼开发的名为 SPU Edge Geometry 的库。如果我们放大红叶(Momiji)的脸部,你可以看到游戏实际上正在剔除(culling)那些在游戏原生 720p 分辨率下太小而无法渲染的三角形。如果我们切换到 PC 版的相同画面,你可以看到所有这些都变得清晰了。

PlayStation 3 版本还有一个区别。如果你像个傻子一样晃动手柄,她的胸部就会像这样晃动。出于某种原因,他们在 PC 版本中没有包含这个功能。搞什么鬼?

现在让我们来看一些具体的 SPU 指令。FCEQ 或浮点比较相等(Floating Compare Equal)是一条非常直接的指令。取两个数,如果它们相等,则结果全部填充为 1;如果不相等,则结果全部填充为 0。在与 x86 中的等效指令比较时,有一个问题,那就是它如何处理 NaN 值。NaN 不能等于 NaN,因此有两种行为可供选择。无序比较(unordered comparison)在任一操作数为 NaN 时会返回全 1 的结果,而有序比较(ordered comparison)在任一操作数为 NaN 时绝不会返回全 1 的结果。这里的简单解决方案是同时执行一个有序浮点比较和一个整数比较,然后将它们的结果进行逻辑或(OR)运算。我们这样做是因为浮点数的相等比较本质上是一个整数比较,只是对 NaN 和 0 有特殊处理,所以通过组合结果,我们可以得到与 PlayStation 3 行为一致的准确结果。

比较指令之后通常会跟着下一条指令:selb。这条指令接收三个输入。根据第三个输入的内容,指令会从第一个或第二个输入中选择一个比特位。这个过程会对 SPU 寄存器中的每一位重复 128 次。由于像 FCEQ 这样的比较指令会将结果填充为全 1 或全 0,这可以被用来选择不仅仅是比特位,而是整个 32 位浮点数。

但如果 SPU 寄存器是 128 位的,我们为什么在谈论 32 位数呢?因为 SPU 实现了一种叫做 SIMD(Single Instruction, Multiple Data,单指令多数据)的技术。这意味着像 FCEQ 这样的指令实际上会执行四次浮点比较,并将结果打包进一个 128 位的寄存器中。这比执行四条独立的指令更高效,但牺牲了可编程性。SIMD 指令并非什么稀奇的东西,所有现代处理器都支持它们。例如,SSE、AVX 和 AVX512 就是你可能听说过的一些 x86 SIMD 指令集。然而,SPU 的突出之处在于它不支持标量指令(scalar instructions),即那些只对单个输入进行操作的指令。

回到 selb,我们如何模拟它?有一个需要三条指令的简单解决方案。我们需要对其中一个输入进行逻辑与(AND)操作,对另一个输入的反码进行逻辑与操作,然后将这两个操作的结果进行逻辑或(OR)运算。有了 AVX512,实际上有一条指令可以让你在一步之内完成所有这些操作。VPTERNLOGD 指令可以在一次操作中对最多三个输入执行任意组合的位运算。但即使没有 AVX512,也有一种快速的方法可以在 90% 的情况下执行 selb。x86 中的 VPBLEND 系列指令会根据第三个向量的最高有效位,从前两个输入中选择整个 32 位通道。因为我们知道在比较指令之后,提供给 selb 的选择掩码(selection mask)将永远是全 1 或全 0,所以在这种情况下 VPBLEND 指令是等效的。

让我们再花一点时间看看 FCGT 或浮点比较大于(Floating Compare Greater Than)指令。就像 FCEQ 一样,我们必须执行一些额外的指令来匹配 PlayStation 3 的行为。对于这条指令,有一个特殊的优化案例,即当其中一个输入是正数时,能够将我们的模拟简化为单个整数比较。妙处在于,LLVM 能够识别出“整数大于比较”后跟一个“选择”操作的模式,并将其简化为一个更简单的整数 min-max 指令,这意味着我们只需要一条指令就能模拟两条 PlayStation 3 的指令。真不错。

接下来,让我们谈谈我认为最具标志性的 SPU 指令:shufbshufb 或字节乱序(Shuffle Bytes)是一条功能极其强大且使用频率非常高的指令。由于它如此常用,因此确保其实现经过高度优化至关重要。x86 程序员可能熟悉 SSE 指令 PSHUFBPSHUFB 的意思是“压缩字节乱序”(Packed Shuffle Bytes)。由于 PSHUFBshufb 简单,我们将反过来先解释 PSHUFB

首先,想象一个包含 16 个数字的数组,并从 15 向下到 0 反向编号。然后,如果我们有另一个包含 16 个数字的数组,我们可以将这些值看作是索引,它们将从我们的另一个数组中选择结果。所以,一个值为 0 的索引将从那边选择结果 9。这就是对向量的每个元素重复此过程后的样子。你会注意到,这种情况下有一个 0 被写入了结果中。PSHUFB 有一个特殊情况,即如果索引的最高有效位被设置,它会向结果写入 0。

那么,shufb 有何不同呢?首先,这次我们的输入数组是按相反顺序编号的,从 0 到 15。其次,我们实际上有第二组数字,编号从 16 到 31,因为 shufb 接收两个 128 位的输入向量,而不是一个。就像 PSHUFB 一样,当最高有效位被设置时,shufb 也有特殊情况。但 shufb 总共有三种特殊情况,允许在需要时将一些特殊的常量写入结果中。

这需要模拟的行为可真不少。我们该怎么做呢?我们将从获取输入索引开始,将它们向右位移四位。然后,我们需要用这些位移后的索引,通过 PSHUFB 指令来索引一个特殊的常量。现在我们将得到一个填充了特殊常量和其他位置为零的数组。我们先保留这个向量,稍后再用。

接下来,我们需要将十六进制值 0xF 与原始未位移的索引进行异或(XOR)操作。这样做的目的是反转我们索引 shufb 索引的顺序,以便 PSHUFB 能够匹配其行为。现在我们需要用反转后的索引执行两次 PSHUFB 指令。这必须做两次,因为 PSHUFB 不能像 shufb 那样接收两个输入向量。

最后,我们需要将两个 PSHUFB 的结果与我们之前得到的特殊情况合并。我们需要将 shufb 的索引向左位移三位,然后利用 x86 的 blend 指令从我们的两个 PSHUFB 结果中进行选择。最后,我们需要将我们从一开始得到的特殊情况逻辑或(OR)到我们的结果中。由于 PSHUFBshufb 会写入结果的三种特殊情况下都会写入零,所以这些特殊情况可以被干净地添加到结果中。

这就是最终生成的 x86 汇编代码的样子。这里我们需要大约九条指令。请注意,我没有将加载任何常量的指令计算在内。在大多数情况下,LLVM 能够找到一些额外的优化来减少这里所需的指令数量。例如,如果 shufb 使用相同的索引被调用了两次,它会识别出它已经为该索引集计算过特殊情况,并会省略再次计算。或者,如果 shufb 在一个循环中被调用,LLVM 会将计算特殊情况的操作提升到循环之外,这样它就不会在每次迭代中重新计算。

尽管 LLVM 能够自行添加如此多的优化,我们仍然为 shufb 准备了一些我们自己的额外优化。代码行数并不是评判代码的好标准,但看看这个实现的规模有多大。我之前展示的那些对于一个准确的实现来说已经足够了,但所有这些额外的代码都是为了让你的视频游戏运行得更快。

  • 我们有针对输入来自专门与 shufb 配对使用的特殊索引生成指令时的优化。

  • 我们有针对索引是常量值时的优化,这使得 LLVM 能够进一步简化代码。

  • 我们有针对 LLVM 的“已知位分析”(known bits analysis)能够确定索引不包含任何特殊情况时的优化。

  • 我们有针对输入向量最近被字节交换过(byteswapped)的优化,这使我们能够对未交换的数据进行乱序,并跳过反转索引的步骤。

  • 最后,我们有一个特殊的 AVX512 路径。这是我一生中写过的最引以为豪的一段代码。它不仅非常烧脑,而且速度快得惊人。这需要一点时间来解释,所以请坐稳了。

这次我们先来看看这段代码生成的汇编结果。如果不计算加载常量,这里我们只需要 5 条指令。这一切的关键是一条 GF2P8AFFINEQB 指令。这个指令的名字真绕口,我经常看到刚接触 x86 编程的新手特别关注这条指令,并对其长得离谱的名字感到惊愕。然而,这条指令深受那些花时间学习如何使用它的经验丰富的程序员的喜爱。

这条指令是 GFNI 指令集的一部分。这些指令操作于一种被称为伽罗瓦域(Galois field)或有限域(finite field)的东西。伽罗瓦域以法国数学家埃瓦里斯特·伽罗瓦(Evariste Galois)的名字命名。这与 PlayStation 3 模拟其实没太大关系,但哇,这家伙在 20 岁时因决斗而死之前,为数学做出了许多贡献。你 20 岁时完成了什么?你打算在 20 岁时完成什么?

总之,呃,如果没有数学背景,对这条指令的描述是相当难以理解的。这条指令是为了加速密码学而设计的,其描述也适合该领域的受众。但这条指令因其在非密码学用途上的潜力而变得如此出名,以至于连英特尔都发布了关于如何将其用于非密码学目的的文档。我不会抄袭他们写的内容,我只会引用文档中的一小部分,然后用一些例子来跟进。

英特尔说:“GF(2) 域只是一个只有两个元素(0 和 1)的伽罗瓦域。GF(2) 中两个值的加法等同于模 2 加法,或者说是一次异或(exclusive OR)运算。GF(2) 中两个值的乘法等同于模 2 乘法,或者说是逻辑与(AND)操作。其他操作也类似地定义。”

所以这里其实并不需要数学背景。从现在开始,我们可以简单地将伽罗瓦域中的加法看作是异或运算。

这是英特尔给出的一个转换输入字节的例子。对于每个结果位,我们有一个输入字节、一个常量位和一个源字节。对于第一个输入的每一位,我们从源字节的相同位置取一位。如果那里有位,就设置它。当我们取完所有这些位后,我们将它们水平地进行异或运算,同时也与常量位进行异或。通过在第一个输入的每个字节中只设置一位,我们可以将这条指令看作是一种比特乱序指令,与 PSHUFBshufb 的工作方式不无相似之处。各种其他的可能性也随之出现,比如模拟一个奇数比特位的符号扩展,或者可能是一个 8 位移位指令——那种 x86 SIMD 中所缺失的东西。

让我们回到我们的 shufb LLVM 代码。这 16 个字节在中间是镜像对称的,因为每组 8 个字节在独立的通道中操作。这个字节将从我们的输入中选择次高有效位。这 7 个位将从我们的输入中选择第三高有效位。最后,我们将每个结果与 0x7F 进行异或。我们剩下的就是 shufb 指令的特殊情况。我们需要取我们的结果和 0 之间的最小值,因为如果索引的次高有效位没有被设置,常量应该是 0。

我为这段代码的工作方式感到非常自豪。我已经等了好几年想详细谈论它的工作原理了。老实说,我受够了人们抨击 GF2P8AFFINEQB,所以我正式宣布,从现在起,我将向任何说这条指令坏话的人发起决斗。没错,如果你说了什么蠢话,我会找到你,然后我们决斗至死。

总之,RPCS3 也有一些 GF2P8AFFINEQB 指令的其他用途,但这个视频已经够长了。不过,我好像在这里留下了一些注释,所以也许你可以在没有我帮助的情况下弄清楚它是如何工作的。

好了,别分心了。我们快要讲完 shufb 了。关于 AVX512 路径,我只想再谈最后一部分。这个 vperm2b 函数看起来相当不起眼。如果你运行的是支持 AVX512 的 AMD 处理器,它只会输出 vperm2b,这是一条接收两个输入向量的指令,有点像 shufb,只是没有任何特殊情况。很酷。但在英特尔上,我们 invece 用一个由两条指令组成的序列来模拟 vperm2b。为什么?因为 vperm2b 在英特尔上是由三个微操作(micro-ops)实现的。你可以想象它看起来有点像我们用 PSHUFB 模拟 shufb 时那样,进行了两次乱序然后合并结果。但在英特尔上有更快的方式来处理这个问题,稍微快一点。通过将我们的第二个源向量插入到第一个源向量的高 128 位中,vpermb 的行为实际上变得与 vperm2b 等效。是的,发出所有这些 LLVM IR 代码,就能神奇地编译成仅仅两条指令。

尽管文档表明这会更快,但我当时不确定这个优化是否真的会更快,所以我做了基准测试。在我的 Tiger Lake 笔记本电脑上,AVX2 路径的吞吐量是每四个周期模拟一个 shufb。旧的 AVX512 路径的速度是每三个周期一个 shufb。而最新的 AVX512 路径的速度是每 2.3 个周期一个 shufb。相当不错。

所以,我可以花更多时间谈论具体的 PS3 指令,但我想现在你已经理解了 RPCS3 团队为了让它们快速运行所付出的努力类型。那么让我们换个话题。不同的指令集对性能有什么样的影响?这是在针对具有 SSE 4.1 指令的处理器时,《战神 3》(God of War 3)的表现。使用 AVX2 指令,游戏性能提高了约 30%,这主要归功于融合乘加(Fuse Multiply Add)指令的加入。当针对 AVX512 指令时,性能又获得了 20% 的提升。

所以,在寻找新 CPU 时,你应该买一个支持 AVX512 的,对吗?嗯,差不多是这样。根据游戏的不同,最快的 CPU 可能是不支持 AVX512 的。是的,最新的英特尔 CPU 不支持 AVX512。尽管是英特尔创造了这个指令集,并在几年前的 CPU 上搭载了它。为什么呢?因为英特尔最新的 CPU 上有他们称之为“能效核”(efficiency cores)的东西。不幸的是,由于能效核不支持 AVX512,更大的性能核也无法启用 AVX512 支持。

英特尔正在为此研究一个他们称之为 AVX10 的解决方案。AVX10 本质上是 AVX512,但只有 256 位长的向量。所以你可以使用所有新的 AVX512 指令,而无需实现 512 位长的向量。问题是,AVX10 距离在消费级产品中推出还有好几年。AMD 最近向开源的 GCC 编译器提交了一些补丁,其中包含他们即将推出的 Zen5 CPU 的细节。Zen5 的一个新特性是它可以在一个周期内执行 512 位宽的 AVX512 指令。我之前就这个主题写过一整篇博客文章,目的是解释为什么 AVX512 对 RPCS3 有用,但这与指令有多宽无关。RPCS3 确实使用了一些宽的 AVX512 指令,但由于它们占所执行代码的比例不到 1%,将其速度加倍并不会产生太大影响。然而,Zen5 GCC 补丁中详述的其他新特性对 RPCS3 来说是件大事,比如翻倍的 L1 和 L2 带宽。

让我们回到讨论 PlayStation 3 硬件。SPU 编程起来非常痛苦。部分问题在于 SPU 只执行 128 位的 SIMD 指令。但在可编程性方面,还有一个更大的问题。SPU 无法直接访问主内存。他们为什么要这样做?让我们看看传统机器是如何从内存中加载的。首先,我们执行一条加载指令,它会生成一个地址。接下来,我们需要将该地址从虚拟地址转换为物理地址。用于转换地址的映射表保存在内存的某个地方,但每次都去查找会太慢,所以地址转换被缓存到一个叫做转译后备缓冲器(Translation Lookaside Buffer, TLB)的地方。一旦地址被转换,处理器会检查该地址是否存在于缓存(caches)中。典型的现代处理器有三级缓存。最后,如果所有级别的缓存都不包含我们的地址,处理器会向内存控制器发出请求,加载包含我们地址的缓存行。考虑到加载和存储指令是多么常见,这是一个极其复杂的过程。

在 SPU 上,加载和存储指令非常简单。一旦地址生成,处理器就加载该地址处的数据。没有虚拟到物理的转换,也没有缓存需要检查。每个 SPU 只能直接访问 256KB 的本地存储,它由 SRAM 构成,类似于其他 CPU 上的缓存,但没有硬件管理驱逐和填充的复杂性。为了实际执行真正的工作,仍然需要一种与系统其余部分通信的方式。为了实现这一点,SPU 利用了一种叫做 DMA(Direct Memory Access,直接内存访问)的技术。DMA 控制器接收来自程序的请求,并在主内存和 SPU 之间移动内存块。最大的痛点在于,所有这些都由程序员管理。

让我们谈谈最后一个主题。我将再次以《尼尔》为例。这款游戏的代码中隐藏着不止一些古怪的东西。游戏喜欢在主线程上休眠 10 微秒。在 Linux 上,这没什么问题,我们休眠 10 微秒。在 Windows 上,这就有点问题了,因为我们可以休眠的最短时间是 500 微秒。解决方案是忙等待(busy wait)。保持线程活跃,并在用户空间中等待计时器倒计时。问题是这会消耗电量,并且不允许操作系统在等待计时器时将线程重新调度到别处。

存在一些指令,允许处理器高效地等待计时器,而不会导致功耗爆炸。历史上,这些指令只对操作系统可用,普通程序无法执行它们。然而,最近 AMD 和英特尔已经添加了允许你在用户空间高效等待计时器的指令。问题是,这是一个罕见的情况,即 AMD 和英特尔为同一目的实现了不同的指令,所以这次我们需要为每个品牌编写不同的路径。好消息是,使用这些指令带来了可测量的性能和功耗改进。

是时候结束这个视频了。我谈论了很多我觉得激动人心的东西,但对于我触及的每一个主题,大约都有十个其他主题被我省略了。我为 RPCS3 项目做出贡献,所以我想谈论的内容也与我想要从事的工作类型相符。但模拟器中还有许多其他我没有参与的部分,比如 GPU 模拟,它充满了与模拟器其余部分同样的天才构想。如果你好奇,我会在描述中留下一些涵盖 RPCS3 额外细节的链接。

我想涵盖一些非常技术性的主题,但又不想花时间解释像“比特是什么”这样的基础知识。所以我希望我找到了一个很好的平衡点。如果你喜欢这个视频,希望你能点赞和订阅,因为我确实打算再制作几个视频,可能与 RPCS3 有关,也可能无关。我目前正处于待业状态,所以如果你能把视频分享给尽可能多的、你认为会喜欢它的人,我将不胜感激。我不打算成为一名全职 YouTuber,但我现在也没什么别的事可做,所以我确实打算至少再做几个视频。

好了,就这样,各位技术宅们,回见!