PS3 模拟现在在 ARM 上运行很快
摘要
RPCS3 的 ARM 移植版现在运行速度快了 60%,功耗降低了 25%,这是因为修复了一个忙等待定时器 bug、用 ARM ISB 替换了 x86 pause,并重写了 LLVM 代码生成。这些改进源于由一台廉价 Android 掌上测试设备推动的低层 ARM 优化。
<p><a href="https://lobste.rs/s/ykzv1a/ps3_emulation_is_fast_on_arm_now">评论</a></p>
查看缓存全文
缓存时间:
2026/08/07 20:27
TL;DR:RPCS3 的 ARM 移植版性能提升了 60%,功耗降低了 25%,这得益于修复了一个忙等待计时器 bug、用 ARM ISB 替代 x86 pause,以及为数十条 SPU 指令重新调整了 LLVM 代码生成。
## 通往更快 ARM 上 PS3 模拟之路
六个月前,RPCS3 在 ARM 上运行得还如同龟速。如今,这款模拟器能以 60% 更高的性能模拟 PlayStation 3,同时功耗降低 25%。这一变化并非来自什么神秘的改写,而是源于阅读 17,000 页的 ARM 架构参考手册、找到容易摘的果子,并修复 LLVM 为 ARM 生成代码的方式。
这项工作由一台 Ayn Odin 2 驱动——这是一款安卓掌机,基本上就是一台加装了控制器的智能手机。目标并不是把它当作安卓设备使用,而是安装 Linux,把它当作一台廉价的 ARM 测试机,用于上游 RPCS3 的开发。
## 忙等待 Bug
第一个主要问题出现在一个只有四行的小函数上:`busy_wait`。
忙等待的存在是为了提高多线程代码的性能。如果一个线程反复检查另一个线程是否改变了某个变量,忙等待能让监控线程尽可能快地看到变化。但如果没有节流,它还会产生额外热量、消耗更多电力,并增加缓存和内存等共享资源的压力。
过去,在 RPCS3 中某个特定位置加入忙等待,在特定情况下性能提升了三倍。所以这个函数很重要。但在 ARM 上,它的速度远低于预期。
### x86 上忙等待的工作原理
在 x86 上,代码读取硬件计时器,加上 3000 得到目标等待时间,然后循环直到硬件计时器超过该目标。循环内部调用 `pause` 指令,它会节流硬件并节省电力。
3000 这个值在大多数 x86 设备上大约相当于 1 微秒的等待,因为硬件计时器通常运行在 2 到 4 GHz。这并不是精确的等待,只是一种简单的省电方法。
移植到 ARM 时,采用了直接对应的翻译:
- x86 硬件计时器读取 → ARM 硬件计时器读取
- x86 `pause` 指令 → ARM `yield` 指令
但测试设备上的 ARM 硬件计时器只有 19 MHz,比预期慢了约 150 倍。每次忙等待调用不是等待 1 微秒,而是等待 150 微秒。
修复方法是根据实际计时器频率缩放等待时间。ARM 硬件计时器差异极大,通常在 10 MHz 到 1 GHz 之间,因此没有哪个固定常量能适用。经过这种缩放后,平均性能提升了 25%,功耗降低了 10%。一位在 ARM MacBook 上使用模拟器的用户,在《Skylanders》中从 5–10 FPS 提升到了锁定的 30 FPS。
## 为什么 `yield` 不是 `pause` 的正确替代
忙等待还有第二个问题。ARM 的 `yield` 指令看起来是 x86 `pause` 的自然替代,但它并不像你想象的那样工作。
根据 ARM 手册,`yield` 是一条专为多线程设计的提示指令。它可以向硬件表明当前线程正在做类似自旋锁的事情,可以被换出。这听起来完美。
但在手册的另一部分,行为描述更加具体:`yield` 仅在硬件支持 SMT 时才会让出当前线程。几乎所有的消费级 ARM 机器都使用传统的 SMP 核心,而不是 SMT。在 SMP 系统上,手册说 `yield` 可以降低侦听总线(snoop bus)的优先级,但如果忙等待循环没有读取内存,这也无济于事。
ARM 发布过一篇博客文章,解释如何将 x86 `pause` 风格的忙等待移植到 ARM。它指出 `yield` 在没有 SMT 的系统上完全是一个空操作。推荐的替代品是指令同步屏障指令 `ISB`。ISB 会重新启动指令取指,比 `pause` 多消耗一点电力,但在这个循环中仍然比 `yield` 更省电。
手册中关于 `yield` 的描述也许可以调整一下,以避免这种困惑。许多大公司都遇到过同样的问题;搜索提及 `yield` 或 `ISB` 的 pull request,会发现各种市值数万亿美元的公司提交的修复。
使用 ISB 后,CPU 每几纳秒才检查一次等待条件,而不是每纳秒检查多次。
## 重编译器:LLVM 的 ARM 代码生成
在 x86 上,SPU 重编译器通常是 PS3 模拟中负担最重的部分。RPCS3 使用 LLVM 将 PlayStation 3 游戏代码翻译为 x86 或 ARM:PS3 汇编 → LLVM IR → 宿主机的原生代码。这与 Unleash Recompiled 等游戏的工作方式类似,只不过 Unleash 翻译到 C,再通过 LLVM 的 clang 编译。
使用 LLVM 意味着 RPCS3 可以利用 LLVM 的优化,但 LLVM 并非完美无缺。在某些情况下,无需任何干预,ARM 的代码生成甚至比 x86 更好。
### ARM 优于 x86 的指令
- **abs db**:计算两个数字之间的绝对差。例如,3 和 7 的绝对差是 4。x86 需要三条指令的序列,因为它的绝对差指令还会同时进行水平求和。ARM 有一条与 PS3 行为完全匹配的单条指令。
- **count b**:统计每个字节中置位的位数。x86 只有在 AVX-512 下才能用一条指令完成,否则需要一长串可怕的指令。ARM 有一条单指令。
- **CLZ**:统计每个字节中直到遇到置位位为止的前导零个数。同样的情况:ARM 一条指令,x86 需要 AVX-512 才能做到单指令。
- **CLGT**:一条无符号比较指令。x86 在没有 AVX-512 时需要四条指令,有 AVX-512 时需要两条(因为比较写到掩码寄存器,必须移回)。ARM 一条指令搞定。
- **SELB**:一条选择指令。ARM 的 BSL 是单指令。x86 需要三条位运算指令;有了 AVX-512,VPTERNLOG 可以一条指令完成。
所以 ARM 在某些情况下可以更优化,但这个差距通常在带有 AVX-512 的 x86 机器上被拉平。
## 修复糟糕的 ARM 代码生成
有数十条指令在 ARM 上的代码生成比 x86 更差。修复工作覆盖面很广。
### SHUFB:重头戏
SHUFB 是最复杂也最常见的 PS3 指令之一。首个糟糕的代码生成实际上源于 ARM 移植版的一个偷懒做法:不是重写 SHUFB 模拟以使用 ARM 的 shuffle,而是直接模拟了 x86 的 PSHUFB。这样能用,但效率不高。
未优化的版本需要 10 条 ARM 指令:
1. 对索引的低四位做 XOR,因为 SHUFB 使用大端字节序,而 ARM TBL 使用小端。
2. 与十六进制 8F 做 AND,以屏蔽除低四位和最高位之外的所有位。
3. 将索引送入 TBL 两次,因为 TBL 在索引的低四位之外有任何位被置位时会清零结果;屏蔽后仍保留位 7 置位,以便能生成特殊值。
4. 根据位 4,通过移位、比较和选择来合并两次 TBL 的结果。
5. 使用另一个 TBL 生成三个特殊常量(零、十六进制 FF、十六进制 80)。
6. 将生成的常量 OR 进 shuffle 结果。
优化使用 TBX 而不是 TBL。TBX 在给定越界索引时,会从目标寄存器中取值,而不是填零。这样就省掉了最后一步 OR。TBL 和 TBX 还可以接受多个输入寄存器,因此一条带两个输入寄存器的 TBX 就能替代整个五条指令的合并序列。
这样 SHUFB 降到了五条指令。如果使用 BCAX 指令(同时执行 AND 和 XOR)甚至可以降到四条。BCAX 属于 SHA-3 加密扩展,但它充满了可用于通用代码的位运算指令。LLVM 只有在输入不是常量时才会模式匹配 BCAX,而这里的情况并非如此。已经提交了一个 LLVM issue。
即使只有五条指令,循环中还可以进一步优化:如果 SHUFB 在循环内使用,所有特殊情况的设置都可以提升到循环外,循环内只剩一条 TBX2 指令。
仅 SHUFB 优化一项就带来了 8% 的性能提升。但一半的游戏拒绝启动。使用带两个输入向量的 TBX/TBL 要求两个输入向量在寄存器中相邻,这对 LLVM 的寄存器分配器施加了很大约束。在 RPCS3 的 LLVM 配置下,它会放弃并崩溃。
解决办法:捕捉 LLVM 在编译带双源 TBX/TBL 的块时的崩溃,然后用单源版本重试。这是个丑陋的方案,但当 10,000 个重编译块成功使用双源版本,只有三个需要回退时,它保住了 8% 的速度提升,同时保持了兼容性。
## ARM 并非简单指令集
人们经常把 ARM 描述为比 x86 更简单的指令集。那是个古老的说法,出自 1980 年代原始 ARM 芯片的时代。现代 ARM 是一套庞大的架构。仅手册就有 17,000 页。
举个例子,有三条 ARM 指令具有不同的编码,但在 ARM 最新的消费级核心上行为完全相同。ARM 指令 `US dot` 的行为与 `VPDPBUS D` 完全一致。助记符更简单并不代表指令集更简单。现代 ARM 与现代 x86 的共同点,比它与 80 年代 ARM 机器的共同点更多。
一线希望在于:许多 x86 优化可以直接移植到 ARM。你不需要完全重写,也不需要全新的模拟器来利用 ARM 硬件。通常只是把方块放进方孔里而已。
## 点积优化
### SUMB
SPU 指令 `sum B` 将两个向量水平求和并打包结果。x86 和 ARM 都有点积指令,先垂直乘法字节,再水平求和。通过使用乘数为 1 的点积指令,SUMB 可以在两种架构上以相同方式模拟。ARM 移植版非常简单:在 x86 使用 `VPDPBUS D` 的地方换成点积指令即可。
### 收集位(GBB、GBH、GB)
收集位系列指令从每个字节中取出最低有效位,将它们打包在一起,并清零寄存器其余部分。
这里的点积技巧使用 2 的幂而不是乘以 1。每个通道乘以不同的 2 的幂,屏蔽掉除 LSB 之外的所有内容,然后点积实际上就收集了这些位。由于每个点积结果包含四位,需要将结果 shuffle 到正确的通道,然后用成对加法指令进行水平求和。
大多数收集位用法都跟在比较指令之后。比较产生 -1(所有位都置位)或 0。通过乘以负的 2 的幂,可以跳过隔离最低有效位的步骤,再省一条指令。
ARM 的 i8mm 扩展比 x86 更进一步。`UMMLA` 和 `SMMLA` 指令将两个输入向量的每一半拆分为字节,并以一种完美映射 GBH 和 GBB 常见用法的方式乘累加。利用符号技巧,GBH 和 GBB 只需要两条指令,而 GB 已经有两条指令的解决方案。
## 乘法
SPU 整数乘法都是 16 位 × 16 位,产生 32 位结果。直接模拟会屏蔽位并使用 32 位乘法。ARM 有加宽乘法指令,可以执行 16 位 × 16 位并产生 32 位结果,但它们期望的输入打包方式与 SPU 不同。
不过,它们仍然能省指令。例如,SPU `MPY`(有符号乘法)通常需要先移位以将低 16 位符号扩展为 32 位,然后再相乘。使用 ARM 加宽乘法,可以用 `XTN` 将每个通道的低 16 位打包到向量的低 64 位,对两个输入都这样做,然后使用 `SMLAL` 进行加宽乘法。这样成本从五条指令降到了三条。
类似的节省也适用于其他 SPU 乘法指令。SVE2 版本的加宽乘法更有用,但 SVE 指令集有些注意事项需要单独处理。
## 浮点及其他 LLVM 问题
### FCGT
FCGT 是一条浮点比较指令。在 PS3 的 SPU 上,NaN 和无穷大不存在,因此模拟必须处理这一点。LLVM 为此生成的 ARM 代码很奇怪。除了为本来应该在那里的 BSL 指令使用内联汇编之外,似乎没有任何办法能把代码整理成合理的形式。这样把 15 条指令换成了 7 条。内联汇编只是权宜之计,因为它会抑制 LLVM 的进一步优化;已经提交了一个上游 LLVM issue。
### FSM
FSM 是收集位系列的反向操作。它接收打包的位,并将它们扩展为全零或全一的字节、16 位或 32 位元素。x86 用三条指令完成:广播打包值,与常量 AND 以屏蔽相应位,然后比较。ARM 的 LLVM 输出则是一场灾难:它把向量操作标量化,使用 SBFX 一次抓取一位,扩展,插入向量,然后重复。
解决办法是以更惯用的方式编写 LLVM IR,基本匹配 x86 代码的做法。这样产生了两条 ARM 指令的解决方案,使用 `CM TST`,它结合了 AND 和比较。这比 x86 版本还要快。
### SHL 和 ROTM
SPU 移位指令会在移位前屏蔽掉一定数量的位。例如,左移字会屏蔽除低六位之外的所有内容;如果位 5 被置位,结果为零。x86 通过一条 AND 指令做同样的屏蔽,然后进行原生移位。这是两条指令。
ARM 的原生移位以同样的方式工作,只有一个例外:`USHL` 在移位计数为正时左移,为负时右移。这对模拟 PS3 SHL 没有影响。然而 LLVM 在 ARM 上把同样的代码编译成了四条指令,因为 LLVM IR 中的移位将部分实现留为未定义,在高位产生“毒值”而不是零。优化器本应识别出目标机器上的原生移位会移入零,但在 ARM 上它们没有做到。
解决办法是使用 `USH` 的内在函数,每条移位指令节省两条指令。ROTM 仍然比 x86 多一条指令,因为移位计数必须取反才能进行循环右移。
## 骁龙 8 Gen 2 的五种不同核心
Odin 2 搭载的是骁龙 8 Gen 2,这颗芯片的独特之处在于拥有四种不同的核心类型:
- **Cortex-X3**:最大最快的核心,以牺牲面积和功耗为代价提供高性能。
- **两个 Cortex-A715**:性能、功耗和尺寸均衡。
- **两个 Cortex-A710**:基本上就是上一代的 A715,因为 X3 和 A715 放弃了对 32 位 ARM 指令的支持。
- **三个 Cortex-A510**:慢速、低功耗的小核心。
如果把聚集的 A510 和非聚集的 A510 分开计算,这台八核机器实际上有五种不同的核心类型。
A510 非常适合 PS3 模拟。其中两个共享一个 128 位向量单元,因此即使每条 PS3 指令都用一条宿主 ARM 指令模拟,它们仍可能比原版 PS3 慢得多。第三个 A510 独占一个 64 位向量单元,所以 128 位指令以半速运行。
A715 和 A710 同源,都围绕 A78 结晶。它们还有一个共同的不寻常行为:每时钟周期能执行的向量加载数量多于向量操作数量。
典型 CPU 每时钟周期的向量操作至少与向量加载一样多:
- 某颗随机 Intel CPU:每时钟 2 次 256 位加载和 3 次 256 位向量操作。
- 一颗较新的 Intel CPU:每时钟 3 次 256 位加载和 3 次 256 位向量操作。
- 最新的 AMD 核心:每时钟 2 次 512 位加载和 4 次 512 位向量操作。
- Cortex-X3
相似文章
GitHub Trending (daily)
SharpEmu 是一款实验性的 PlayStation 5 模拟器,使用 C# 开发,支持 Windows、Linux 和 macOS。目前处于早期阶段,能够加载游戏可执行文件并执行原生 CPU 指令,注重准确性和教育目的。
Hacker News Top
一位开发者将 Pokémon Emerald 移植到 Raspberry Pi Pico 2 (RP2350) 微控制器上原生运行,通过将 GBA 游戏重新编译为 Cortex-M33,并在第二个核心上重新实现 PPU,实现了 60 fps 的 HDMI 输出。
Reddit r/LocalLLaMA
mistral.rs v0.9.0 在x86和ARM上实现了比llama.cpp快达1.8倍的CPU解码速度,优化覆盖所有CPU规格,并保证基准测试完全可重现。
Hacker News Top
这篇博客文章探讨了如何通过在非 RISC-V 机器上使用提前重编译器来加速 RISC-V 执行,该重编译器通过尾调用连接基本块,并利用 Clang 的 preserve_none 调用约定,作为 Axiom 的 OpenVM 项目的一部分。
Hacker News Top
实测显示,在 Snapdragon X Elite 上运行的 Windows Server 2025 ARM64 虚拟机,因性能更平稳且二进制更干净,在延迟敏感型服务器角色中优于 Intel i9 上的 x64 虚拟机。