ARM模拟x86内存模型难题:FEX项目解析TSO与弱排序差异

本文探讨在ARM处理器上高效模拟x86总存储顺序(TSO)内存模型的挑战。对比ARM弱内存模型与x86-TSO的根本差异,分析FEX项目在ARMv8.0-A上的传统方案及其性能代价,并介绍LRCPC扩展及Apple TSO模式如何优化这一过程。

为什么在 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

评论 0

0/500

评论需审核后展示,请文明发言

💬
还没有评论,来说两句

相关阅读