二进制翻译的性能代价:Windows 11在ARM上运行x86应用的实测分析

3

PC市场是CPU设计师竞争最艰难的领域之一。PC细分市场的消费者期望在各种应用中实现高性能,并且众所周知地期望他们的设备能够突破有限且精心策划的使用场景。

Microsoft自家的Surface RT十多年前就因无法运行PC用户期望的程序在Windows上运行而显著失败。高通等公司最近的64位Arm核心速度足以满足PC性能的考量,但软件兼容性则是更严峻的障碍。

PC软件传统上是为x86-64构建的。让开发者提供64位Arm(aarch64)版本是一个缓慢且渐进的过程。有些程序可能永远无法获得aarch64移植,因为它们已不再活跃开发,且仅以二进制形式分发。

因此,Arm的PC市场机会取决于Microsoft确保x86-64二进制文件能够在aarch64主机上无缝运行的努力。

Windows 11最新的二进制翻译器Prism通过将x86-64指令转换为aarch64指令来实现这一点。二进制转换具有挑战性,因为x86-64指令有时无法以直接的方式映射到aarch64的指令。

转换还必须快速,以最小化程序启动延迟,避免在翻译过程中消耗过多的CPU时间。所有这些都意味着二进制转换相比运行等效的本地代码会带来性能损失。

在这里,我正在查看使用 Geekbench 7 时的这种惩罚表现。Geekbench 7 有 aarch64 和 x86-64 两个版本,提供了一个机会将带二进制转换的性能与原生 aarch64 性能进行比较。

John Poole(Primate Labs 创始人,Geekbench 的创始人)慷慨地提供了一把专业密钥,让我可以分析各个工作负载的特性。当然,单个基准测试套件无法反映广泛应用的行为,但我认为这是一个不错的起点。

至于硬件,我正在用 Snapdragon X2 Elite Extreme X2E-96-100 测试,安装在华硕 Zenbook A16 笔记本上,华硕和 Microsoft Azure 上的 Arm 实例也进行了测试。

Geekbench 7 指令特性

和 Geekbench 6 一样,Geekbench 7 也有多个工作负载,在可用时利用向量扩展。该套件偏向高 IPC、计算受限的工作负载。通过英特尔的软件开发模拟器(SDE)设置来展示 Haswell 的功能集,显示超过一半的工作负载使用 AVX 和 256 位矢量宽度。

我运行每个工作负载 200 次迭代,以确保绝大多数计数指令来自工作负载,而非 Geekbench 的测试框架。

经过200次迭代,每个工作负载执行约数万亿条指令。不同aarch64核心的性能计数器数据显示,执行Geekbench 7原生aarch64二进制时指令数大致相似,偶尔有例外。

高通核心在许多测试中报告的退休指令数高于Neoverse N1和N2,这表明硬件性能监控可能存在不准确性。

在二进制转换下执行 Geekbench 7 的 x86-64 版本会导致指令数量大幅增加,除了 PDF Viewer 之外。经验法则是,Geekbench 7 工作负载在二进制翻译时执行的 aarch64 指令大约是 x86-64 指令的两倍。

Microsoft暗示,二进制转换在所有aarch64 CPU中并不相同。不同CPU支持不同的指令集扩展,其中一些可能使Prism能更紧密地将x86-64指令映射到aarch64指令。

aarch64 CPU理论上还能提供超出ISA要求的保证,比如即使aarch64允许一个核心观察另一个核心的存储,仍能保持存储顺序。

Prism 专为高通 Snapdragon 处理器优化和调优。Prism 中的部分性能功能需要 Snapdragon X 系列硬件功能,但 Prism 适用于所有支持 Windows 11 on Arm 的 Windows 11 24H2 设备。

如果Prism是专门针对高通核心优化的,指令数量方面其实没什么区别。Ampere Altra的Neoverse N1是我测试过的最老核心,性能计数器显示执行指令数非常相似。

相似的指令数量当然会掩盖很大的差异。性能最终是关键,这既受核心架构,也受生成指令的性质影响。

评分影响

Geekbench 7的评分显示了二进制转换带来的严重惩罚。包括高通在内的每个核心都经历了几代的性能损失。然而,骁龙X2 Elite的核心基础性能远高于Neoverse N1和N2。

骁龙X2 Elite拥有6核心“性能”集群和两个6核“主核心集群。”性能“核心宽6,主频3.6 GHz。由于它们与其他芯片中的效率核心角色相同,我称它们为E核,以便简便。”Prime“核心宽9 GHz。它们追求最大性能,所以我称之为P核心。

高通的E核心即使承受二进制翻译惩罚,也能胜过Neoverse N1。P核心对抗Neoverse N2也是如此。即使存在二进制翻译惩罚,更宽的核心仍以极高时钟频率运行,这无可争议。

高通的核心在二进制转换中承受的代价也略低。很难判断这是高通Prism的专属优化,还是高通核心的吞吐量更高。二进制转换带来的指令数增加让我想起玩Claude的C编译器(CCC)。

CCC的情况涉及在关键路径上创建大量额外指令,而紊乱的CPU往往能吸收这些指令。最近的高性能核心通常大部分核心宽度未被利用,因此额外的核心宽度在一定程度上可以缓解指令数增加。

但我不确定这里是否如此,因为高通的4宽E核心的代价比5宽的Neoverse N2轻。

单个工作负载在二进制转换下会有不同程度的损失。导航是一个低IPC工作负载,受分支预测和后端内存延迟严重限制,在二进制转换下承担相对较低的代价。

在另一端,Video Player是一个矢量化良好的工作负载,利用AVX指令。该测试的分数差异非常巨大。Photo Editor和Photo Library处于类似情况。

在 Arm 的 Neoverse N1 和 N2 上,二进制翻译下的工作量特性基本保持不变。

核心吞吐量(IPC)

性能计数显示,高通核心通过利用未使用的核心宽度,减少了二进制转换开销。然而,IPC的提升相较于指令数量开销较小,这也解释了大幅度的二进制转换成本。

Snapdragon X2 Elite Extreme的P-Core在视频播放器工作负载上实现了巨大的IPC提升,但其他方面提升通常较为平淡。

高通的E-Core也处于类似情况,不过作为更窄的核心,它能更好地利用核心宽度。文本处理、HDR和视频播放器在二进制转换下,核心基本上面临4宽宽度的限制。

然而,高通P核心在文本处理和HDR中IPC的微弱提升表明,拓宽E核心可能效果有限,因为工作负载很快可能会遇到其他限制。

Neoverse N2 起始 IPC 较低,奇怪的是运行 x86-64 二进制转换时,IPC 提升不多。核心在原生工作负载的 IPC 表现绝对不错,但与高通最新高效优化的核心相比,表现不佳。

无论是否使用二进制转换,Neoverse N2 的工作负载都无法平均超过 3 IPC。

Neoverse N1是这套中最老的核心,毫不意外地遭遇了最艰难的挑战。Ampere Altra的N1带领了Arm在服务器市场的努力,成功了,因为它能提供足够的性能,同时密度优化优于同期的x86-64核心。

但那是多年前的事了。高通的E-Core也是4宽,远远领先于它。

高通E-Core相对较好的性能提醒我们,核心宽度相较于其他架构特性来说意义不大。高通赋予现代E-Core一个比Neoverse N1更大的乱序引擎。

这样做使核心能够保持更多指令的执行状态,减少短暂延迟对单个指令的影响。

自上而下分析

考虑调度阶段流水线吞吐量,对于原生且采用二进制转换的工作负载来说,情况大致相似,这并不令人意外,因为它们应该做相同的高层工作。

在高通的P-Core上,最大的区别是流水线槽位的使用率更高,并且前端受限度有所下降。

高通较窄的E核心在许多测试中占用了核心宽度的大部分,尤其是运行于二进制转换的x86-64版本。更窄的核心更容易供电,且因前端原因流水线槽位损失远少于宽P核。

Azure 的详细性能监控更难,因为平台不通过流水线槽的细粒度计入顶层计数器。不过,粗略地用周期级计费(STALL_FRONTEND 和 STALL_BACKEND)来概括,发现 Neoverse N2 因前端和后端原因失去的流水线槽位比高通的 E-Core 还多。

一方面,供给 5 宽流水线比 4 宽流水线更难。此外,高通拥有更大的核心私有缓存,以及 E-Core 集群可达 21 周期延迟的 12 MB L2。

缺少 Neoverse N2 的 1 MB L2 会使核心面临约 100 周期的 L3 延迟,这对任何核心来说都难以应对。二进制转换在这方面变化不大。

后端卡顿在Neoverse N1上更为严重,还要应对>100个周期的L2未命中延迟。N1受影响更严重,因为其体积较小且有序的引擎。

与高通的E-Core相比,可以看出Neoverse N1的严峻处境。高通的核心设计上能保持超过两倍的指令数量,尽管核心宽度和执行资源相似。

二进制转换带来的额外指令增加了对乱序资源的压力,而高通更强大的核心更有可能吸收这些开销。

翻译代码

Prism 将翻译后的代码存储在 C:\Windows\XtaCache 中。将生成代码写入磁盘有两个功能。首先,它让 Prism 避免了每次二进制文件启动时重新翻译代码的开销。

其次,它让 Windows 用户查看翻译后的代码,理解 x86-64 二进制文件如何能在 aarch64 主机上运行。这很重要,因为它让他们能够理解硬件的使用。

XtaCache 在后者问题上的效用有限,因为 Microsoft 忘记了记录他们的 .jc 缓存格式,但幸运的是其他人也介入了.不幸的是,翻阅翻译后的代码仍然是一项非常耗时的工作。

所以这里,我先举几个例子,让大家了解Prism的翻译过程。

Geekbench 7 的视频播放器工作负载突出,因为它在二进制转换下运行时的 IPC 更高,且执行指令数量大幅增加。用 VTune 在 Skylake 上分析视频播放器,显示最热的基本模块是一个 17 条指令的循环。

该循环穿越数组,并使用 AVX FMA3 指令执行融合乘加操作。

Prism 将此转换为 Neoverse N1 和 Snapdragon X2 Elite 上的 69 条指令序列。AVX 指令被映射到 NEON 指令上,而 NEON 指令的矢量宽度只有 128 位。

高通最新核心支持 SVE,但使用它可能影响不大,因为高通核心实现的 SVE 矢量长度为 128 位。此外,固定宽度的 x86-64 指令也无法很好地映射到 SVE 的可变矢量长度。

指令开销来自于使用多条NEON指令执行256位向量运算、复制x86-64更灵活的寻址模式,以及需要用单独指令执行内存访问,而不是用一条指令同时指定加载和数学运算。

例如,vbroadcastss ymm8, [r8+r10*4] 加载一个精度标量(32位)值,并将其广播到所有向量通道。Prism使用三条aarch64指令。一条加载该值,另一条通过128位NEON寄存器的通道复制,最后一条将值复制到另一个代表ymm8上128位的NEON寄存器。

融合乘法加法指令可获得更大的扩展。VFMADD231PS YMM7, YMM8, [RAX + R9*4 - 0x13c] 采用 x86-64 最复杂的寻址模式,具有基准、缩放索引和立即偏移。

Prism 用两条指令进行寻址计算,能够将索引缩放和加法合并到数组基址中,形成一个加法与扩展。然后,一条加载对(LDP)指令指定对一对 128 位 NEON 寄存器进行 256 位加载。

Prism 用一对 FMLA 指令执行 FMA 操作,但奇怪的是,将 YMM7 的高半部分置于栈上的 NEON 寄存器溢出。它对循环中所有八条 FMA 指令都重复此操作,使缓存带宽需求翻倍。

在下一次循环迭代前,溢出寄存器不会被修改,因此溢出和重新加载完全没有必要,且在已经带宽需求高的环路中可能有害。在环路正体末端,Prism忠实地将软件预取指令转换为aarch64等价物,包括额外的ALU指令以生成地址。

然后,环路计数器递增和控制流指令直接转换为aarch64等价物,当然跳转目标地址不同。

人工翻译器可以避免 Prism 不必要的寄存器溢出。他们也可以省略预取指令,因为线性遍历数组会形成可预测的访问模式,硬件预取器很可能会捕捉到。

他们还可以在某些地方省略一些指令,比如用一个寄存器同时存储 ymm8 的两半,因为广播负载的目的地从未被修改。不过 Prism 值得称赞的是,它简化了八个 FMA 操作中的六个地址生成,只发出一个减法,认识到已经生成了 rax + r9*4。

我还看了Geekbench 7的HDR工作负载,因为它的二进制翻译版本几乎没有IPC。Skylake上分析中最热门的模块是一个36条指令环路,带有标量AVX操作。

这应该会让翻译更容易,因为Prism就不用把AVX更宽的矢量宽度映射到NEON上。

Prism 将这些翻译翻译成了大约 60 条指令。当我从 Neoverse N1 虚拟机里拿到完整翻译时,似乎并没有在翻译缓存里找到。

Prism 通过尝试维护寄存器映射,使 x86 SSE 寄存器映射到等价编号的 aarch64 NEON 寄存器,从而引入了显著的指令开销。例如,在指令序列中,Geekbench 7 执行 FP 加法(vaddss xmm5, xxm4, [rdi+rbx*4])并将结果存储到内存(vmovss [rdi+rbx*4], xmm5)。

Prism 的 aarch64 翻译将 FP 加结果置于 s18,然后将其移至 v5 后存储,以便使用 v5 表示 xmm5。额外的开销来自堆栈中不可解释的存储,以及看似产生右移指令进位标志的额外指令。

许多 x86-64 指令设置标志,且并非所有指令在 aarch64 中都有对应的标志设置。Prism 似乎更倾向于保留标志生成的行为,而不是消耗计算力来观察旗帜是否被使用。

Prism 为高通骁龙 X2 Elite 生成的序列大致相同,但在加载和存储方面有所不同。高通的新核心支持 FEAT_LPCPC2,使它可以使用 ldapurb(即时偏移的加载获取)而非 ldaprb(加载获取)。

能够立即偏移简化了地址生成,省去了一些额外的指令。在这两种情况下,Prism 将加载转换为加载获取指令,而非 aarch64 的基本 LDR,以确保内存顺序。

结语

二进制转换是让 Windows 上的 Arm 在 PC 市场可行的关键。Prism 的翻译大多很简单。x86-64 指令的行为被等效的 AACH64 序列忠实复制,以至于你可以查看翻译后的代码,轻松看到哪些序列映射到 x86-64 指令。

我猜 Prism 这样做是为了避免过度消耗计算能力,因为它看起来可以通过线性通过每个块,内存中保持最小状态来实现。然而,如此直接的翻译会错过人类可能实现的优化。

在 aarch64 中复制 x86-64 寻址通常需要额外的指令。x86-64 编译器还利用了结合加载和数学运算的指令。这些代码必须在aarch64中拆分成两条指令,可能需要使用额外的临时寄存器来存储加载值。

在硬件方面,高性能核心常常能吸收未被使用核心宽度的死代码。有时Prism生成死代码时会出现这种情况,但Prism不像Claude的C编译器那样产生大量死代码。

复制地址生成或在寄存器间复制值以维护Prism寄存器映射的指令开销,很容易延长关键依赖链。乱序引擎对此无能为力。

解决二进制转换后果的最终方案,就是拥有一个性能高且几乎没有弱点的架构。高通似乎正朝着他们最新的Snapdragon X2 Elite实现这一目标。

一个9宽核心运行在5 GHz上,绝非小觑。大型指令缓存(E-Core 为128 KB,P-Core为192 KB)和充足的乱序资源对整体性能有利,关键地帮助减轻了二进制转换带来的指令开销。

我仍在尝试华硕Zenbook A16 UX3607OA笔记本的不同使用场景,但除了游戏等少数角落场景外,使用感觉出奇地正常。我猜随着更多应用获得AAC64端口,高性能核心意味着即使应用需要二进制转换,性能也会越来越好,Windows on Arm的情况会越来越好。

参与讨论