为什么在 ARM 上模拟 x86 的内存模型如此困难
这是本站的第一篇专题文章,讨论一个影响所有 x86 模拟器的核心问题:如何在 ARM 处理器上高效地模拟 x86 总存储顺序内存模型(x86-TSO)。问题集中在 ARM 所采用的弱排序内存模型与 x86-TSO 之间的根本性差异上,其影响范围广泛。本文将梳理 FEX 项目遇到的所有相关问题,以及已解决和尚未解决的方案。
x86-TSO 与 ARM 弱内存模型
内存模型定义了系统中内存访问之间的排序规则,决定负载和存储在单线程与多线程环境中如何相互作用。目前硬件上主要存在两种模型:ARM 的放松(弱)一致性模型,以及 x86 的总存储顺序(TSO)模型。两者基本位于一个光谱的两端——ARM 最为宽松,允许显著的硬件优化;x86 最为严格,强制执行很强的连贯性。

需要注意的是一致性与原子性的区别:两者相关,但并不等同,也不能在所有场景下互相保证。
x86 的 TSO 模型对程序员非常友好:一次存储发生后,系统中所有其他处理器都能连贯地看到它;一次内存负载发生时,可以保证看到所有之前的存储。这符合直觉——写入内存的数据在写入时就变得可见。存储实际上决定了其他访问的可见顺序,模型因此得名。
ARM 的弱内存模型则不那么直观。默认情况下,ARM 的常规内存负载和存储在系统中各处理器之间并不完全连贯,这让 CPU 在多数情况下运行得更高效。存储指令执行时,对应的内存(缓存行)不会立即被其他处理器看到,从而节省宝贵的功耗与硬件成本。同样,从其他处理器写入的内存中加载数据时,也无法保证该负载会看到更新后的数据。旧版 ARM(ARMv7 及更早)使用内存屏障指令来强制排序,这对性能影响很大。
为绕过这一限制,ARM 引入了负载获取(load-acquire)和存储释放(store-release)指令,分别对应 C++ 中 std::atomic 的 memory_order_acquire 和 memory_order_release。严格来说,ARM 并不将这两类指令视为原子操作,但程序员通常将两者混为一谈,FEX 文档中的"原子加载/原子存储"也采用同一含义。这类指令的主要用途是强制内存排序:ARM 称之为"释放一致性顺序一致(RCsc)"模型。负载获取指令必须按顺序执行而不得重排,存储释放指令则需满足"屏障排序先于"语义,从而淘汰了旧架构中昂贵的内存屏障指令。
ARMv8.0-A 上的模拟方案及其代价
基于上述前提,FEX 在 ARMv8.0-A 上让所有 x86 内存负载映射到 ARM 的负载获取指令,x86 存储映射到存储释放指令。这使 FEX 获得了与 x86 相同的内存语义,尽管实际上比必要的更严格——因为当时不存在完全符合行为标准的中间方案。用这类指令模拟 TSO 的代价非常高,微基准测试可以清楚展示这一点:ARM CPU 并非为让这类相对罕见的获取/释放指令突然成为被执行指令的绝大多数而设计。
首先从一个对硬件友好的微基准开始:没有棘手的边缘案例,只是普通情况下的内存访问,以此作为最佳情况的基线。
图中每台机器的负载和存储列代表基线性能数——不是试图榨干每个系统的内存带宽,而是让每种类型的操作做同样的工作。从获取负载(acquire load)的结果可以看到,五个 CPU 的测试数据差异明显;AmpereOne CPU 的释放存储(release store)指令结果显著偏低,M1 的 Acquire/LRCPC 负载指令也比基线低得多。尤其是 AmpereOne 的结果,展示了这条传统路径能变得多糟——这些指令从未被设计用于这种场景。对每个负载使用获取-释放语义做 x86 模拟,实际上给 ARM CPU 施加了非常严格的限制:负载指令之间不再允许重排,当每秒有数以百万计的此类操作在飞行(in-flight)时,性能表现不会好。但在 ARMv8.0-A 上这是仅有的选择,尽管 Cortex-X4 和 Cortex-X925 的表现依然出色。
出路:LRCPC 与 Apple 的 TSO 模式
ARMv8.3 起强制实现的 LRCPC 扩展为 ISA 增加了一批新的负载指令,并在 RCsc 模型之上引入了新的"释放一致性处理器一致性(RCpc)"内存模型。这正是 x86 模拟一直需要的:该扩展围绕 x86 模拟的需求设计,并预期会在实现它的硬件上被大量使用。基准数据显示,几乎所有平台上 LRCPC 负载都与普通负载性能持平。FEX 检测到该扩展后,会完全停止使用 Acquire-Load 指令,转而使用 LRCPC-Load,内存性能问题由此基本得到解决——至少从这个微基准来看如此。
唯一的例外是 Apple M1。Apple 直接为 x86-TSO 内存模型增加了硬件支持:通过切换 CPU 功能,常规的 ARM 加载/存储指令会改变行为以符合 x86 的要求。Apple 在转向 ARM 生态时选择了这条路线,因为硬件需要高性能的 x86 模拟方案。因此在 Apple 硬件上,LRCPC-load 指令实际上是 acquire-load 指令的别名——Apple 的 x86 模拟器根本不使用这些指令,只使用常规加载/存储,因为其硬件已采用 x86 内存模型。
对其他平台而言,这种跨线程的 TSO 模式切换确实会带来一些性能影响。FEX 检测到 Asahi Linux 报告的 CPU 功能时,也会启用该模式并获得"免费"的性能提升。一个潜在的担忧是:在 x86 模拟与 ARM 原生代码之间切换时,ARM 代码会承担不必要的开销,因为此时所有访问都按 TSO 执行。这个担忧合理,但模拟环境下执行的 ARM 原生代码数量接近于零。作为开发者,你不会在意 1% 的内存访问慢 10%,而在意的是 99% 的访问慢 15%——这正是 AmpereOne 结果所展示的"理想"代价。
综合来看,TSO 模式是确保平台上高性能 x86 模拟的最佳途径,因为它保证每一条内存访问指令都按预期行为执行。官方 FEAT_LRCPC 扩展实际上有三个版本,每一版都在实现上逐步完善:
- FEAT_LRCPC — 为 GPR 操作数添加负载指令
- FEAT_LRCPC2 — 为 TSO 负载指令添加小偏移立即数
- FEAT_LRCPC3 — 添加基本向量化和基于指针的 TSO 负载与存储指令
即使拥有这三个扩展,边缘情况下的行为仍无法像硬件 TSO 切换那样被很好地模拟。预计随着时间推移会出现更多扩展版本,以解决本文后续讨论的额外问题。
未对齐访问:分锁问题
前面的讨论对 ARM 硬件是友好的,顺着底层硬件的调优要求来。但一个从始至终存在的问题是:x86 应用程序不在乎对齐。它们随心所欲地访问内存,跨越细粒度边界——游戏尤其如此。这个问题严重到业界有了专门术语:分锁(split-lock)。连 Linux 内核都会在检测到分锁时放慢游戏速度,导致许多玩家修改内核选项来避免减速。
从负载存储指令的角度出发,x86 为程序员提供了保证:如果一次负载或存储完全处于某个缓存行内部,那么这次访问是原子的,并且符合前述连贯性模型。但为了对硬件开发者保持友好,如果访问确实跨越缓存行,行为就无法保证——所以程序员需要小心,基本的负载存储本身并不是分锁。
问题在于,ARMv8.0 的获取/释放指令要求所谓的自然对齐:访问的数据大小必须与内存偏移匹配。以 8 字节访问为例,偏移必须是 0、8、16、24 等。违反这一要求意味着什么?对 ARM 而言,这是一条对齐错误的指令,硬件会验证对齐要求,不满足时 CPU 触发故障——通常导致崩溃,但 FEX 做了特殊处理。
在 FEX 的 JIT 中,会跟踪用于模拟 x86 负载存储的内存访问指令。当某次负载存储可能引发对齐故障时,代码中预先放置了所谓的补丁点——表现为负载存储之前或之后的一条 NOP 指令。当某个补丁点触发对齐故障时,FEX 捕获故障,将获取/释放指令修补为基本的等效负载存储,并在其外围包裹一条数据内存屏障(DMB),然后继续执行。
修补前与修补后的代码对比
也就是说,ARMv8.0-A 上每当对齐行为不匹配时,就会立即退回经典的内存屏障指令。之前的图表没有展示这种情况,需要引入新的数据。
这是一大堆数据。它展示了模拟 TSO 时硬件距离"最佳"路径有多远,但这不是这里关注的重点。值得注意的是,这个微基准没有显示出对齐与未对齐常规负载/存储之间的差异,因此取两者的平均值计算。图表中移除了 x86 CPU 和常规负载存储数据列,因为这些不是 FEX 的常见路径,这样能更有针对性地看出未对齐内存访问在模拟下的严重程度。
各平台表现分析
基于新的数据图,从左到右依次讨论各平台。
AmpereOne
对齐与未对齐负载的差异大致相当于噪声。这意味着尽管未对齐负载受到数据内存屏障的惩罚,CPU 直接消化了它——也可能因为基准被其他因素限制,毕竟该平台整体性能远低于其他平台。
存储方面则不太乐观,即使不考虑未对齐的情况,结果在图上几乎看不见。由于起点已经很低,下降难以察觉。这与该平台常规存储达到 28GB/s 形成鲜明对比。唯一合理的结论是:Ampere 针对服务器类工作负载优化,其行为不符合消费级硬件的预期。这是一个有趣的数据点,但用户通常不会在这种硬件上运行游戏。
Cortex-X4
这是高通 Snapdragon 8 Gen 3 内置的核心,测试中只选取了 SoC 的一个核心以免数据淹没图表。作为榜单上唯一的手机 SoC,该核心整体表现符合预期,趋势与下一代 Cortex 一致。
其主要特点是:对齐的负载和存储相当强,分别约 11.5GB/s 和 6.7GB/s;一旦需要处理未对齐负载存储,性能出现下降,遇到 DMB 指令后负载与存储大致持平,基准值约为正常值的 50%。这说明 CPU 可以保持相当数量的 LRCPC 释放访问处于在飞行状态,因此 DMB 指令遇到它们时更痛苦,但并未导致灾难性的性能损失。因对齐问题产生 50% 的性能下降,不算理想,但也不是世界末日。
Cortex-X925
接下来是 DGX Spark 及其 X925 核心。这不仅是 ARM 的新一代 CPU 核心,还运行在内存带宽大得多的系统上——273GB/s,而此前为 76.8GB/s。结果是数据与 X4 相似,只是图表尺度更高。有趣的是,未对齐访问的性能惩罚与 X4 大致相当,存储方面恢复得更快,可能得益于更快的内存。整体没有意外,属于代代一致的表现。
Oryon-3
该核心设计来自高通,已有强劲表现。最有趣的结果是:对齐的 LRCPC 负载指令性能与常规负载持平,这意味着在行为良好的应用程序中通常可以期待完整性能。存储方面为常规带宽的 68%,不算差。
该平台同样无法逃脱未对齐 LRCPC 释放访问的惩罚:负载方面与 Cortex-X925 约 70% 的性能损失相当,存储方面表现更差,仅保留 43% 的性能。不过该平台的对齐访问速度实际上快于 Cortex 产品。
一个值得注意的奇怪之处:该核心宣传拥有"完全连贯的 96KB、6 路 L1 缓存和 64B 连贯性颗粒",理论上未对齐访问的影响应该更小。先记住这一点。
Apple M1
这是需要重点讨论的平台——它向所有人证明了 ARM 方案不仅可行,而且可以更快。这正是 Apple 将 TSO 内存模型直接植入硬件的结果。该平台不使用 LRCPC 释放访问,而是启用 TSO 功能,此时对齐版本与未对齐版本性能基本一致,存储上可能约有 5% 的差距——与图中其他设备相比,这几乎不值一提。核心原因在于未对齐访问不再需要在代码中回补 DMB 指令,硬件可以直接处理。
对 FEX 而言,这就是在 ARM 上认真对待 x86 模拟的意义:发现问题,然后让问题消失。当然,启用 TSO 模式时确实存在性能代价——与前面的图表相比,只能获得常规存储 76% 的性能。但当所有其他操作都变得更快时,这种代价更容易接受。
FEAT_LSE2:未对齐访问的有限缓解
这里需要谈谈 ARM 推出的 FEAT_LSE2 扩展——所有测试平台都实现了它。如前所述,获取/LRCPC/释放内存访问要求自然对齐,否则会触发对齐故障。FEAT_LSE2 放松了这些指令(以及读-改-写原子操作)的对齐要求,其设计目标之一就是帮助 x86 模拟。
听起来不错,但实际情况并不乐观:该扩展只为 x86 模拟带来边际性能提升。它只允许 16 字节颗粒内的未对齐内存访问,任何跨越 16 字节颗粒的访问仍会收到对齐故障。而未对齐访问可以贯穿整个缓存行,整个问题的规模并未因此改变。所以这个扩展只是减少了问题的出现次数,并没有真正移动指针。既然已经谈到读-改-写原子,接下来就进入这部分。
读-改-写原子操作与原子性
与大多数现代指令集一样,x86 支持原子内存操作:这类指令对内存中的数据执行 ALU 操作,且不允许任何中间状态可见。x86 术语中,原子操作同时在原子且连贯的内存上进行;ARM 则允许选择"仅原子"或"原子且连贯"。原子性与连贯性之间存在实质差异。
此前关于 x86 内存模型的讨论只涉及存储对系统中其他处理器可见的连贯性,完全忽略了原子性要求。在 x86 世界中,负载或存储通常在未对齐时仍是原子的:如果存储 8 字节数据,另一个线程在竞争条件下加载这 8 字节,永远不会看到存储前与存储后数据混合的撕裂状态。ARM 的原子性保证显著更弱:未对齐存储指令下,ISA 规范对读取数据是否撕裂没有任何保证。值得庆幸的是,ARM 拥有"单拷贝原子性"规范;更好的消息是,前述 FEAT_LSE2 扩展将单拷贝原子性保证扩展到了 16 字节颗粒内的任何未对齐访问。缺点在于,x86 在完整缓存行范围内提供单拷贝原子性保证,因此该扩展仍然没能彻底解决问题,只是降低了问题出现的频率。
那么实际的原子指令在哪里?从 ARMv8.1-A 开始,ISA 获得了与 x86 原子指令相对应的指令。下表展示它们在 FEX JIT 中的直接映射关系:
| x86 | ARMv8.1-A |
|---|---|
| LOCK DEC | ldaddal |
| LOCK INC | ldaddal |
| LOCK NEG | ??? |
| LOCK NOT | ldeoral |
| LOCK ADC | ldaddal |
| LOCK ADD | ldaddal |
| LOCK AND | ldclral |
| LOCK OR | ldsetal |
| LOCK SBB | ldaddal |
| LOCK SUB | ldaddal |
| LOCK XADD | ldaddal |
| LOCK XOR | ldeoral |
| LOCK BTC | ldclralb |
| LOCK BTR | ldeoralb |





