Arm 的弱序内存模型和屏障要求

标题:Arm’s Weakly-Ordered Memory Model and Barrier Requirements

日期:2021/06/01

作者:Ash Wilding

链接:https://www.youtube.com/watch?v=2I8OHacills

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

备注:最近跑 Arm 弱序测试,有些场景就是无法复现,来补习一下。幻灯片在这里


好的,我们开始吧。大家好,感谢今天来听我的演讲。我将和大家探讨 ARM64 的弱顺序内存模型以及正确使用屏障(barrier)的必要性。我要讲的内容相当多,不确定在 30 分钟内能讲完多少。所以,我们看看能进行到哪里。如果有人愿意多留一会儿,我会留下来继续讲,直到讲完为止。我们拭目以待。

那么,我先搞清楚怎么用这个软件。我需要点击一下就可以,还是要按那个按钮?好的,可以了。

那么,我是谁?

我的名字是 Ash Wilding。我来自位于剑桥的亚马逊网络服务(Amazon Web Services)EC2 内核与操作系统部门。在此之前,我曾是 ARM 的一名高级工程经理,担任架构与开源软件支持部门的团队负责人。所以,我的背景是帮助工程团队设计符合架构的硬件系统,开发符合架构的系统软件组件,以及在基于 ARM 的硬件平台上应用特定的开源软件组件。比如 TFA、Linux 内核,团队将来某个时候也会做 Zen。显然,现在 ARM 与 Zen 有了互动,比如今天在场的 Bertrand 和 Rahul。

为什么选择这个主题?

这其实不是一个 Zen 的特定主题,但我认为它可能非常有趣。无论对于新手还是经验丰富的工程师来说,屏障都是他们在构建基于 ARM 的系统时遇到的最大障碍之一。这些屏障有时被视为令人生畏的东西。你知道,人们为了安全起见,倾向于使用比实际需要更严格、侵入性更强的屏障。这不仅会影响性能,实际上还可能隐藏更深层次的潜在错误。你的软件或硬件中可能存在一个潜伏多年的 bug,只是碰巧没有被触发。然后,当你迁移到新版本的软件或新版本的硬件时,这个 bug 突然就显现出来了。这类问题调试起来非常棘手,因为你所有的假设——比如“我这个稳定运行的系统已经用了这么久”——都被打破了。你可能会想肯定是哪里变了。但实际上,那个所谓的“稳定”系统本身就存在细微的缺陷,只是从未暴露而已。

这意味着,选择能够保证正确行为的、最宽松且侵入性最小的屏障,是一项非常有用的技能。我们正看到 ARM 架构越来越多地参与到各种项目中。许多人来自具有更强顺序内存模型的背景,比如,大部分工作都是在 x86 架构上完成的。所以我希望,无论你是否是 ARM 架构的新手,这次演讲都能让你感兴趣,或许能揭示一些在使用弱顺序内存模型时需要注意的潜在陷阱和事项。

ARM 的弱顺序内存模型

那么,ARM 的弱顺序内存模型到底意味着什么?简单来说,它不要求程序顺序中对普通内存(normal memory)的非依赖加载(load)和存储(store)操作,必须以相同的顺序被内存系统观察到。

这句话有点绕口,我们来分解一下。当我们谈论非依赖的加载或存储时,可以指两种不同的情况。我在屏幕上放了一些汇编代码。我知道在座的很多人可能从未见过汇编,尤其是 ARM 汇编。别担心,我会讲得非常简单,并在需要时进行解释。但大多数情况下,你不需要知道代码具体在做什么。

屏幕上显示的代码是两个加载操作。你可以这样理解:括号里的 X1 就像是对指针的解引用。所以这里的意思是,将地址 X1 处的值加载到寄存器 X0 中。但你可以看到,第二个加载操作是根据我们刚刚从 X0 加载的值来计算地址的。它取 X0 的值加上 4,形成我们加载到 X2 的地址。

/* 生成代码,仔细甄别 */

LDR X0, [X1]
LDR X2, [X0, #4]

这些就是我们所说的寄存器依赖(register dependency)。这是因为第二个加载依赖于第一个加载,这意味着这两个加载操作不能相互重排序。好的,所以它们是依赖的。你不用担心这里,CPU 会自动处理好,你无需担心重排序的问题。


类似地,这里有一个存储操作。我试着用一张彩图来形象地说明发生了什么。这是一个 64 位的存储,也就是 8 个字节。图中蓝色的每个小方块代表这次存储写入的 8 个字节中的一个。

/* 生成代码,仔细甄别 */

STR X0, [X1]  // 8-byte store (blue)

现在,如果我后面还有两个存储操作,你不用关心左边的汇编代码,只需看这些彩色方块。第二个存储是一个 2 字节的存储,写入到黄色方块的位置;第三个存储是一个 4 字节的存储,写入到橙色方块的位置。

/* 生成代码,仔细甄别 */

STURH W2, [X1, #2] // 2-byte store (yellow)
STUR W3, [X1, #4]  // 4-byte store (orange)

这里有几点要注意。首先,这里没有像前一页那样的寄存器依赖。我特意构造了这个序列以避免任何寄存器依赖。但你会注意到,黄色和橙色的存储都与蓝色的存储有重叠。这意味着它们必须在蓝色存储之后被观察到。然而,黄色和橙色的存储本身并不重叠,所以它们之间可以相互重排序。好的,我们称之为地址依赖(address dependency)

地址依赖是指两个加载、两个存储,或者一个加载和一个存储操作访问了重叠的地址。这会产生地址依赖,并强制它们按顺序被观察到。所以在这里,黄色和橙色的存储可以相互重排序,但它们都必须在蓝色存储之后被观察到。

我这里还有另一个存储操作,这是一个单字节的存储,它与橙色方块重叠。所以这个绿色的存储必须在橙色存储之后被观察到。然而,它可以被重排序到黄色存储之前。所以,实际上,这两个存储(橙色和绿色)就像一个整体,它们要么都在黄色存储之后发生,要么都在黄色存储之前发生。它们甚至可以被稍微分开排序。比如,我可以有橙色、然后黄色、然后绿色的顺序,只要绿色在橙色之后,橙色在蓝色之后即可。这样我们就得到了复杂的依赖链。如果你把它想象成重叠的彩色方块,你就能很容易地看出这些依赖关系在哪里。

/* 生成代码,仔细甄别 */

STURB W4, [X1, #5] // 1-byte store (green)

总结一下,如果我有一个加载操作,这是一个 8 字节的加载,并且这个加载操作精确地覆盖了我们之前讨论的所有 8 个方块。它需要观察到正确的最终值。所以,它会看到两个蓝色、两个橙色、一个绿色、一个橙色和两个黄色。这实际上使得前面的所有存储操作看起来都是按正确顺序发生的。

这些就是地址依赖。除了寄存器依赖和地址依赖,所有其他的约束都不存在了。这是唯二能导致两个不同加载或存储操作之间产生依赖的因素。除此之外,你必须假设 CPU 可以并且将会对非依赖的普通内存访问进行重排序。

普通内存(Normal memory)是 ARM 定义的存放所有代码和数据的地方。它基本上就是 DRAM,任何不是 MMIO (Memory-Mapped I/O) 的东西。设备内存(Device memory),也就是我们对 MMIO 的称呼,工作方式有点不同。它实际上有一个“禁止重排序”的标志,但这可能并不像你直觉中想的那样工作。我们稍后会回到那个例子。

一个实际例子:轮询式邮箱

我认为理解重排序最简单的方法是看一个真实世界的例子。这里我们有一个轮询式邮箱(polled mailbox)。这张幻灯片上信息量很大,我现在会逐一讲解,但它都在这里。幻灯片的 PDF 版本可以从你进入这个聊天室的链接中找到。所以如果你想看更多细节,可以稍后查看幻灯片。

重点是,我们有两个 CPU。一个 CPU(CPU0)将一些数据写入一个邮箱,然后设置一个标志(flag)表示“数据已就绪”。另一个 CPU(CPU1)会持续轮询那个标志,直到它不等于零。每次它等于零时,CPU1 就会跳转回去再次加载标志。这意味着只有当它读到标志等于 1 时,它才会去读取邮箱中的数据,也就是最后那个加载操作。

CPU0 (Producer):

/* 生成代码,仔细甄别 */

// Write data to mailbox
STR X0, [X1]
// Set flag to indicate data is ready
STR W2, [X3]

CPU1 (Consumer):

/* 生成代码,仔细甄别 */

1:
// Poll the flag
LDR W0, [X3]
// If flag is zero, loop
CBNZ W0, 2f
B 1b
2:
// Read data from mailbox
LDR X4, [X1]

每当你遇到这种情况,即在不同主控(master)之间共享数据时,你必须开始问自己:我需要屏障吗?

如果我们思考一下,在 CPU0 的这两个存储操作之间,我需要屏障吗?好吧,如果这两个访问被重排序了会发生什么?那将意味着我会在写入数据之前就设置了标志。这意味着 CPU1 可能会读到标志已设置,并在邮箱数据实际写入之前就去读取邮箱。所以我认为这里需要一个屏障。

然后你问自己,好吧,到底有没有什么能阻止这两者被重排序?有寄存器依赖吗?有地址依赖吗?嗯,这里有四个完全不同的寄存器,而且你可以看到 X1X3 是不同的地址。所以,没有寄存器依赖,也没有地址依赖。因此它们可以相互重排序。这意味着我需要在这里加入某种东西——一个屏障——来阻止这两个访问被重排序。

好的,那我完成了吗?这是我唯一需要做的吗?这张幻灯片上还有什么可能出错的地方?

我们来看 CPU1 的代码序列。你可能会想,我这里不需要屏障,对吧?我会一直轮询那个标志,只有当我看到标志等于 1 时,我才会去读取邮箱。你可能听说过一些关于推测执行(speculation)路径下的值会被丢弃的说法。但这只适用于当推测路径上有依赖的加载,并且我们最终意识到我们推测错了值,并用那个错误的值构成了地址的情况。

在这种情况下,你可以看到,那个分支(branch)根本无法阻止 CPU 推测执行越过它。再次强调,唯一能阻止这些操作被重排序的是寄存器依赖或地址依赖。所以问问你自己,这里有寄存器依赖吗?没有。好的。你可能会想,嗯,它们都写 W0,但这不重要。那实际上不是一个寄存器依赖。也绝对没有地址依赖。所以,实际上,我在这里也需要一个屏障,以防处理器要么推测执行越过分支,要么简单地将第二个加载重排序到第一个加载之前。

什么是屏障?

好了,我一直在说屏障,我们需要这个屏障。屏障到底是什么?

屏障是一条汇编指令,它允许软件手动强制执行顺序。你基本上是在告诉 CPU:“在这些条件满足之前,你不能做这件事。”

这通常在以下情况是必需的:

  1. 当你在不同的执行线程之间共享数据时,顺便说一句,即使这些线程是在同一个 CPU 上并发执行的。所以,即使你任何时候只有一个线程在单个 CPU 上执行,你仍然需要屏障。我们很快会看到为什么。

  2. 当你与其他观察者(observer)共享数据时。这可能是其他 CPU,也可能是其他的总线主控、外设和设备。

  3. 当我们改变本地 CPU 的配置时,也需要屏障。比如,如果我正在更改系统寄存器,或者更改虚拟内存布局,这些都需要屏障。

很重要的一点是,这些屏障与 编译器屏障(compiler barriers) 不同。编译器屏障关系到代码生成过程中的序列点。它们对 CPU 流水线没有影响,因为当你的二进制文件生成后,所有的编译器屏障都已经被处理掉了。它只是在代码生成步骤中起作用。我们这里讨论的是对 CPU 流水线的影响。所以你需要在指令流中加入明确的指令来实际影响 CPU 流水线。

一个自然的后续问题是:编译器能自动为我插入这些屏障吗?答案是不能。例如,C11 原子操作(Atomics)在底层会使用这些屏障,但你仍然需要在你的代码中插入 C11 原子操作。关键在于,CPU 和编译器都没有足够的上下文来了解哪些访问在逻辑上是相互依赖的。除了那些寄存器依赖和地址依赖之外,它不够智能去推断出这两个事物是相关的。

DMB - 数据内存屏障

好了,那什么是屏障呢?这里有一个实际屏障的例子:数据内存屏障或 DMB(Data Memory Barrier)。

DMB 的意思是,在程序顺序中,DMB 之前的显式数据访问必须在 DMB 之后的显式数据访问之前被观察到。

这里有一小段代码。我们有一个显式的存储,一个 DMB,一个 ADD 指令和一个 LDR。这个 ADD 指令与任何加载和存储都没有寄存器依赖,并且它不是一个显式的数据访问——它不是加载,不是存储,也不是像数据缓存清理或数据缓存无效化这样的数据缓存维护操作。因此,这个 ADD 可以被重排序到 DMB 之前。但 LDR 不能。

/* 生成代码,仔细甄别 */

STR X0, [X1]
DMB ish
ADD X2, X2, #1 // Can be reordered before DMB
LDR X3, [X4] // Cannot be reordered before DMB

所以,回到我们那个有红色大屏障的例子,你现在可以看到,这就是我们放置 DMB 的地方。我们会在这里放一个数据内存屏障。

那个 ish 是一个限定符(qualifier),它允许我们限制屏障的效果。这就是我演讲标题中所说的“最小侵入性屏障”(minimally intrusive barriers)的意思。你可以在这里放上你想要的最具侵入性的屏障,并且能得到正确的行为,但这显然会对性能产生影响。在实践中,我们想要做的是使用在所有实现上仍然能保证正确行为的、侵入性最小的屏障。

让我们看一些限定符。限定符允许我们限制屏障的作用域,使其侵入性更小,并减少对性能的影响。这里我们可以做两件事:

  1. 指定屏障适用的观察者群体。这是一个有点棘手的概念,我可以讲很久,但今天时间有限。我在幻灯片右侧放了一个小例子。你可以看到那里的可共享域(shareability domains)。再次强调,这只是一个例子。你不一定只有一个内部可共享域(inner shareable)、一个外部可共享域(outer shareable)和一个全系统(full system)。你可以在你的外部可共享域中有多个内部可共享域,也可以在你的全系统中有多个外部可共享域。

    • 通常我们说,一个 内部可共享域(Inner Shareable Domain, ISH) 通常是一组 CPU。如果你在运行像 Linux 内核这样的东西,Linux 内核以 SMP 模式运行的那些 CPU,就在你的内部可共享域中。这实际上是 Linux 的一个要求。如果你去看启动要求,你不能跨多个内部可共享域启动 Linux。所以,一个内部可共享域是你可以运行 SMP 的一组 CPU。你可以想象一个大的复杂系统,它有多个内部可共享域。你可能在上面运行不同的操作系统,可能在一个计算岛上运行 Linux 内核,在另一个计算岛上运行一个实时操作系统(RTOS)等等。

    • 你的 外部可共享域(Outer Shareable Domain, OSH) 是它的一个超集。它会包含一个或多个内部可共享域,并且还包括与这些 CPU 相关的设备和外设。这里举个例子,可能是一个 GPU、一个 DMA 控制器和你的 GIC(通用中断控制器)或其他中断生成器。

    • 默认情况下,屏障将应用于全系统(Full System, SY)。但我们可以用 OSH 将其范围缩小到外部可共享域,用 ISH 缩小到内部可共享域,或者如果我说这个 CPU、这个主控是唯一会使用这些数据的,我可以用 NSH(Non-shareable)。这是当你只在同一个 CPU 的线程之间共享数据时使用的。

  2. 除此之外,我们还可以指定我们关心的访问类型

    • 默认是没有限定符,这意味着屏障前的任何操作都必须在屏障后的任何操作之前被观察到。

    • 但我可以用 ST 来表示我只关心屏障前的存储和屏障后的存储。

    • 或者我可以用 LD 来表示我只关心屏障前的加载和屏障后的任何操作。

再次回到这里,如果我放一个 DMB SY,那意味着全系统,任意操作到任意操作。这会起作用,是好的。但我们可以做得更好。再次强调这个理念:缩小屏障的范围,使其侵入性最小。

所以我可以把它设为 OSH(外部可共享),这会起作用。我也可以把它设为 ISH(内部可共享),这也会起作用。我能一直缩小到 NSH(不可共享)吗?嗯,在这个例子里不行。我们这里有两个独立的 CPU 在共享那个轮询邮箱。如果你去看幻灯片右下角的细节,我提到过它们在同一个内部可共享域中。所以 NSH 是行不通的。

所以我们知道 ISH 是我们这里能用的最小的可共享域。但我仍然可以做得更好,因为我可以看到,对于 CPU0,这里是一个存储,这里是另一个存储;而对于 CPU1,我们关心的是一个加载和一个加载。这意味着我实际上可以在 CPU0 使用 DMB ISHST,表示内部可共享的存储到存储。而在 CPU1,我可以使用 DMB ISHLD,表示内部可共享的加载到任意。

好的,所以这些就是保证轮询邮箱例子正确行为的、侵入性最小的屏障。

更多真实世界例子

好的,喝口水。我意识到我已经以非常快的速度讲了大约 20 分钟了。希望我讲的还算清楚,没有让大家跟不上。如果你有问题,请在聊天框里提出来。我会在最后尽力回答,之后我也会多留一会儿。如果需要,我可以更详细地讲解这些内容。

现在让我们来看一些其他真实世界的例子。

1. 一致性 DMA (Coherent DMA)

好的。在这个例子中,我没有另一个 CPU,而是有一个 DMA 引擎,它将执行 DMA 传输。这是一个设备(device)。你实际上会看到,CPU0 的代码序列和我们之前看到的非常相似。我写入 DMA 缓冲区,然后写入一个寄存器来启动传输,这看起来非常像写入邮箱然后写入标志。所以,我们之前思考的完全相同的规则也适用于这里。

我想让这两件事能够相互重排序吗?不,因为我不想在实际写入 DMA 缓冲区之前就让 DMA 引擎开始传输。它们可以被重排序吗?是的,因为没有寄存器或地址依赖。

所以,我需要一个屏障。我需要哪个屏障?嗯,我会像之前一样使用 DMB,但这次我需要一个 OSHST,而不是 ISHST。也就是外部可共享的存储到存储。这是因为 DMA 是一个设备,所以它隐含地在那个外部可共享域中。如果你还记得我们早先看到的例子,CPU 在内部可共享域,而那些橙色的外设在外部可共享域。所以我确实需要在这里提升我的可共享域。然后,这就是保证正确行为的最小侵入性屏障。

2. 非一致性 DMA (Non-coherent DMA)

让我们对那个例子做一个小小的改动。如果我有一个非一致性 DMA。好的。这里的非一致性意味着它不是缓存一致的(cache coherent)。所以在之前的例子里,我不需要担心 DMA 缓冲区的可缓存性。即使我的缓存里有脏数据(dirty data),我们说的是那个 DMA 引擎可以窥探(snoop)我的缓存并取走脏数据。一切都好。

非一致性 DMA 有点不同,因为现在我需要像之前一样进行存储。这个代码片段里唯一改变的就是这个 DC CVAC。除此之外,代码片段和前一张幻- 7 -灯片完全相同。这个 DC CVAC 的作用是,将我的缓存行中的脏数据清理(clean)到内存系统中足够远的地方,以便 DMA 引擎能看到它。在这个例子里,这仅仅意味着把它推送到 DRAM。

/* 生成代码,仔细甄别 */

STR X0, [X1]      // Store to DMA buffer
DC CVAC, X1       // Data Cache Clean by VA to PoC
STR W2, [X3]      // Write to device to start DMA

所以我们需要问自己,我需要在这两个操作之间加一个屏障吗?首先,让我们问问自己,如果它们被重排序了会出什么问题?嗯,我会先执行数据缓存清理,这会清理掉我的一些缓存行。然后我再写入 DMA 缓冲区,这会在我的缓存中分配脏数据行。我不想这样。所以我需要一个屏障,对吗?

实际上,不需要。

这是一个小小的陷阱问题。原因又回到了我们早先谈到的地址依赖。这两个都是我们所谓的 D-side(数据侧) 访问。我们有一个数据侧的存储,也就是 STR 指令。我们还有一个数据侧的缓存维护指令,也就是 DC。它们都是数据侧的操作,并且有地址依赖,因为它们都作用于 X1。这意味着我实际上不需要在它们之间加屏障。

CPU 内部的不同观察者

好的,那我所说的 D-side 是什么?这里事情可能会变得有点棘手。

不同的观察者所做的访问之间,没有强制的顺序。请记住,一个观察者是任何可以产生加载或存储的东西。一个 ARM CPU 不是一个观察者,也不是一个主控。它实际上是三个。你可以在左边的图表中看到。

  • D-side(数据侧):负责数据侧的加载和存储,以及数据缓存维护操作。

  • I-side(指令侧):负责指令获取和指令缓存维护操作。

  • TLB-side:你有时会看到人们称之为 TLB 侧或者页表遍历器(page table walker)等等。它们都指的是同一件事。这用于进行页表遍历和翻译后备缓冲器(TLB)的维护。

同一个 CPU 由这些不同的观察者组成,而它们之间没有强制的顺序,这意味着当我们在修改页表或修改程序内存时,必须格外小心。这就是我们现在要看的例子。

修改程序内存的例子

这里有几条小指令。我认为通过看图来理解这些总是更容易一些。我们把之前的图放在下面。让我们快速地通过动画过一遍代码,然后看看我们需要哪些屏障。

首先,解释一下情况。假设我有一个旧的操作码(opcode)。内存中可能有一些来自之前加载的应用程序的指令。也许我正在加载一个新模块,或者这是一个 JIT(即时编译)场景,我需要写入一个新的操作码。改变程序内存有不同的定义,但所有这些都满足条件。

所以,我有一些旧指令,不管它们是什么。我们假设它是黄色的星星。它现在在我的 iCache(指令缓存)里,这意味着如果我要跳转到这段代码,我会取到那个旧的指令,取到那个黄色的星星。我不想这样。

  1. 当我执行第一个存储操作时,这是在写入我真正想要执行的新指令。这会导致一个脏缓存行被分配,也就是绿色的星星,进入我的 L1 dCache(数据缓存)。

    • 好的,这意味着如果我现在跳转到我的代码,这个黄色的星星还在这里。所以我还是会取到黄色的星星并跳转到它,这不是我想要的。

  2. 我这里有两条指令。第一条是 DCCVAC。它做的是数据缓存清理到合一点(point of unification)。合一点是内存系统中足够远的一个点,使得我的 D-side、I-side 和 TLB-side 都能看到内存的同一份拷贝。你可以看到在这个例子中,合一点是 L2 缓存。这个 L2 统一缓存将是内存系统中的一个点,对于这个特定的 CPU 核心,所有三个观察者都能看到同一份内存拷贝。

    • 所以,当我执行 DCCVAC 时,我正在把那个绿色的星星推送到 L2 统一缓存中。

  3. 当我执行 ICIVAU 时,那是一个指令缓存无效化到合一点。所以它会把那个操作码一直无效化到合一点。换句话说,在这个例子里,它会把 L1 iCache 里的旧操作码清除掉。

  4. 最后,这意味着如果我现在跳转到代码,我会在 L1 iCache 中未命中(miss),然后我会从 L2 统一缓存中获取指令。那就是正确的操作码,也是我真正想要执行的代码。

好的,这是对代码作用的解释。现在让我们问问自己,哪里需要屏障。

  • 在新指令的存储和 DCCVAC 之间:我需要屏障吗?你现在可能从之前的例子中记得,这两个都是 D-side 访问,并且作用于同一个地址。因此,我不需要屏障。

  • DCCVACICIVAU 之间:我需要屏障吗?让我们先问问自己,如果这两件事被重排序了会出什么问题。如果我的 iCache 开始无效化黄色的星星,问题在于,推测行为,包括推测性的缓存行填充,可能在任何时候因为任何原因或没有原因而发生。你基本上必须假设它们会在最糟糕的时候发生。对我们来说,最糟糕的时候就是,一旦我把那个黄色的星星从 L1 iCache 中移除,L1 iCache 立刻决定推测性地再次获取它。因为我们的绿色星星此时仍在 L1 dCache 中(因为 DCCVAC 还没有完成),我实际上会重新获取我刚刚从 L1 iCache 中无效化的那个黄色星星。这可不好。我需要确保,在那个 ICIVAU 开始之前,L2 统一缓存中已经有了我们之前步骤中写入的正确的绿色星星数据。这样我就知道,万一 L1 iCache 决定推测执行,它会获取到那个新的数据。

好的,很公平。所以我要用我的 DMB,对吧?

那是不够的。

原因在于,如果你还记得 DMB 实际上做什么,它作用于显式的数据访问。这也适用于显式的数据缓存维护操作,但我们这里有一个 iCache 指令。所以 DMB 没有效果。这意味着我们需要工具箱里的一个新工具。

那个新工具是 DSB数据同步屏障(Data Synchronization Barrier)。它和 DMB 非常相似,但做得更严格一些。它实际上会阻止任何后续指令的体系结构性执行(architectural execution)。不管它是数据处理指令还是实际的数据加载或存储。在 DSB 之前的事情完成之前,DSB 之后的任何东西都不允许被体系结构性地执行。这显然意味着它被认为是非常昂贵的,因为你基本上是在暂停流水线(stalling the pipeline)。所以,你应该只在绝对必要时才使用 DSB

回到我们的例子。如果我放一个 DSB ish,它将强制数据缓存清理在 iCache 无效化开始之前完成。

  • ICIVAU 和我的分支指令之间:我需要屏障吗?是的,我需要,原因和我之前提到的一样。我不想让推测行为导致推测性的缓存行填充,让我推测性地跳转到可能仍然存在的旧操作码。所以我在这里需要另一个 DSB 来强制 iCache 无效化完成。

好的。我完成了吗?就这样了吗?我需要做的就这些吗?

我还没完。 在这个序列中还有别的事情可能出错。

记住 DSB 做什么。我刚才说它阻止体系结构性执行。但这并不能阻止推测行为实际越过这个点,并已经开始获取和解码来自旧上下文的陈旧指令。DSB 确实会确保 ICIVAU 已经完成,但我们可能仍然已经获取并解码了那之后的指令。我们在这里需要做的是,实际告诉处理器放弃它之前做的任何事情,重新获取并重新解码它。 因为我们现在已经改变了程序内存的内容。我需要确保它确实获取到那里实际存在的新操作码。

所以,这是另一个屏障。我们不能在这里放 DMBDSB,那没有意义。我需要的是一个 ISB

这个 ISB指令同步屏障(Instruction Synchronization Barrier),它实际上会让处理器放弃所有正在执行中的指令,同步上下文——任何对处理器状态的待定更改现在都可见了——然后它会重新获取并重新解码那些它刚刚放弃的指令。所以,万一我们推测执行越过了 DSB 并开始获取和解码那些指令,记住,它们不被允许体系结构性地退役(retire)。这就是关键。DSB 阻止了它们被体系结构性地退役。所以它不可能已经提交了它。但我们可能仍然已经获取并解码了那个旧的操作码。这就是症结所在。这就是为什么我需要那个 ISB 以及为什么 DSB 还不够的原因。


(主持人提示时间已到,但观众希望继续,所以演讲继续)


好的。我们现在已经看到了为什么我们需要这里的这些屏障。你可以看到这里有不少屏障,我觉得屏障比实际的指令还多,这挺有趣的。

回到我们之前看到的例子,我们的轮询邮箱,我们说,我需要 DMB ISHSTDMB ISHLD 作为这里的最小侵入性屏障。这是否意味着我可以在这里用 DSB?是的,你可以,但说真的,别这么做DMB 就够了。记住,DSB 对你的性能非常非常糟糕。如果你真的非常害怕弱顺序内存模型,你可以在几乎每条指令后面都放一个 DSB,它会工作的,对吧?你不会有任何弱顺序内存模型的问题,但你的性能会一落千丈。

所以,每当你使用 DSB 时,你应该认真问问自己,我真的需要在这里用 DSB 吗?你可能有一个 bug,你搞不清楚,你某个地方有一个 DMB,你把 DMB 换成 DSB,问题就消失了。你很可能只是掩盖了真正的问题。你让它消失了,你绕过了它,但你没有真正解决它。所以,每当你使用 DSB 时,认真问自己,我是不是做错了什么?我使用它是否有真正的好理由?

问: 性能影响有多大?例如,使用 DMB ISHSTDMB ISH 之间的区别。
答: 这很难说。它非常依赖于上下文。它将取决于你的代码,在屏障前后观察到的是什么样的数据,你正在与哪些其他观察者共享数据,以及你的代码正在进行的访问的种类和模式。这听起来可能有点像推脱的回答,但我的意思是,基本的答案是你必须去分析(profile)它。但再次强调,最佳实践是,如果你知道你在这里放这个屏障的原因是我关心一个存储之前和一个存储之后,那就放 DMB ISHST,你就知道你会得到最好的性能。影响可能是微不足道的,但你也在为未来做准备,因为特别是当你开始转向越来越大的系统时,比如内部可共享域中可能有数百个核心,你真的希望屏障前的每个加载和存储都被那数百个核心全部观察到,才允许 DMB 之后的每个访问吗?或者你不想把范围缩小一点吗?所以,这真的会取决于你的系统、你的硬件系统、你的代码、你能做的访问。所以是的,这确实非常非常难以预测。这就是为什么,再次强调,最好的策略就是努力思考,并总是使用侵入性最小的那个,然后你就为未来做好了准备,无论如何。

更多有趣的例子

好的,让我们开始思考一些稍微更有趣的例子。

门铃中断 (Doorbell IRQ)

幻灯片上现在是一个门铃中断。这里的想法是,CPU0 会做一些事情,也许是写入一个邮箱。然后,它不是写入一个让 CPU1 在循环中一遍又一遍轮询的标志,而是直接写入这个外设。它可能像一个门铃外设之类的东西。这会导致对 CPU1 的一次中断。当 CPU1 接收到那个中断时,它会去读取邮箱。

代码再次看起来和我们之前看到的非常相似。你会经常注意到这一点。即使你没有一个明确的邮箱-标志场景,你也经常会遇到同样的情况。比如,你正在写入一个结构体中的一些字段,其中一个字段是 initialized = true。其他 CPU 等,它们不是在轮询 initialized == true,它们也不会在 initialized == true 时被中断。但它们可能想做一些事。它可能是一个 vCPU。这是几周前 Julian 和我讨论的真实例子。另一个 CPU 可能想对这个 vCPU 做些什么,但前提是它已经被初始化了。因为如果它被初始化了,它就可以假设其余的状态是有效的。所以你没有那种明确的邮箱-标志。但真的,如果你从一个更通用或抽象的意义上思考,你其实是有的。

所以这是另一个例子,这个门铃中断。我写入我的邮箱,我写入我的外设以引发中断,然后在 CPU1,我在我的中断处理程序中,我只是做一个加载。

再次,这两件事,我希望它们能够相互重排序吗?不,因为我不想在实际将数据写入邮箱之前就生成门铃中断。那可不好。

它们可以相互重排序吗?嗯,没有地址依赖,没有寄存器依赖。这里需要注意的一件重要事情是,第一个对邮箱的存储,是对普通内存(normal memory)的写入。这是 DRAM 中的某个缓冲区。但第二个存储是一个 MMIO 写入。这是一个外设,我正在写入它的寄存器。写入那个寄存器的一个副作用是它会断言一个中断请求(IRQ)。所以这实际上被映射为设备内存(device memory)。需要立刻说明的是,普通内存和设备内存之间从没有任何顺序要求。所以,对于“它们能被重排序吗?”这个问题的答案是:是的。你可能会问自己,我不能做点什么,比如让它们之间有地址依赖吗?但如果你让它们之间有地址依赖,你就会把同一块内存同时别名为普通内存和设备内存。ARM 架构中有一大堆关于你这样做会发生什么 неприятных вещей 的警告。

所以,在这种情况下,我需要一个屏障。我需要哪个屏障? DMB ISHST

嗯,这可能会让你感到惊讶,取决于你对我一直在讲的一些事情观察得有多敏锐。因为我说过,当你把某个东西映射为设备内存时,它隐含地在外部可共享域中。所以我在这里不需要一个 DMB OSHST 吗?事实上,你早先看到了 DMA 缓冲区的例子,对吧?那个 DMA 引擎。我用了一个 DMB OSHST,因为我在对外部可共享域做事。那么为什么这里不适用呢?

嗯,这就是我们得到那个嵌套的、超集的可共享域概念的地方。我实际上不关心在外部可共享域中的中断生成器是否在写入 IRQ 门铃之前观察到对邮箱的写入。实际上值得注意的是,IRQ 本身,那个 IRQ 生成器,并不会观察到对它自己寄存器的写入。那是另一个我们可以下次再深入讨论的话题,但它不会观察到对它自己寄存器的写入。

我们在这里真正关心的是,CPU1 已经观察到了对缓冲区的写入,也就是对邮箱的写入。因为 CPU1 在我们的内部可共享域中,而那是包含中断生成器的更广泛的外部可共享域的一个子集,我实际上可以在这里用一个内部可共享的 ST (DMB ISHST) 来确保 CPU1 在观察到产生中断的对 IRQ 生成器的写入之前,已经观察到了那个存储。这有点奇怪,但现在让我们看一个稍微复杂一点的。

非一致性互连 (Non-coherent Interconnect)

我这里有什么?这看起来稍微有点吓人。我像之前一样有我的缓存一致性互连。我可能在这个系统上还有一堆其他的东西没有画出来。我试着简化它以适应幻灯片。所以,我有了我的缓存一致性互连,也许我还有一个其他的互连挂在它上面。你可以想象你有一个主计算复合体,那里有你的高性能 CPU,大的网状结构协同工作,处理你的数据平面、数据处理等。但现在许多系统在同一个 SoC 中还有其他的子系统。所以你可能有一个非一致性的互连挂在上面,带有一些控制平面的东西。在这种情况下,也许我们有一个小的微控制器(MCU)坐落在那个非一致性互连上。

和之前类似。我有一些缓冲区,一些邮箱。在这种情况下,它会在 SRAM 中,因为这是我想与 MCU 共享的消息。所以它在这里。我还会像之前一样生成一个 IRQ。

所以你可能会想,也许唯一真正改变的是 MCU 现在在外部可共享域中。它不会在我的内部可共享域中。我不可能在这两个东西之间以 SMP 模式运行 Linux,当 MCU 甚至都不是缓存一致的时候。它在这个下游的互连上。也许我可以只用一个 DMB OSHST 来代替我们前一张幻灯片上的 ISHST

那是不够的。

有趣。为什么那不够呢?嗯,我需要一个 DSB。原因在于,DMB 只在可以被指定的观察者群体实际观察到的内存访问之间强制执行顺序

再看一下这个系统的拓扑结构。想象一下 CPU0 的写入所走的路径。它会从这里下来,穿过缓存一致性互连,然后进入 IRQ 生成器。这意味着它从未到过这里(指向非一致性互连)。MCU 读取或写入的所有东西都是流经这个非一致性互连的访问。所以,如果访问从未到达下游的互连,它就从未观察到它。这意味着在这个例子里,DMB 是不够的。DMB 并不能强制那个 MCU 实际观察到对邮箱的写入。即使我用 DMB SY,也就是全系统,也没用,因为 MCU 无法观察到那个访问。

这意味着我需要一个 DSB。记住 DSB 做什么?它基本上是在暂停我的流水线,直到观察者观察到它。所以,我需要做一个 DSB。我做 DSB 的原因是因为它保证了对邮箱的原始存储已经完成(completed)。所以,当我越过这个 DSB 时,我知道数据已经在 SRAM 中了。顺便说一下,我把这个缓冲区映射为不可缓存的,所以我不用担心任何缓存一致性的东西。我不需要把它清理到 SRAM。我知道当我完成那个 DSB 后,它就在 SRAM 里了。然后我就可以对 IRQ 生成器进行写入,我知道那时 MCU 就能从 SRAM 中读取那个数据了。

多个设备端口

好的,和那个类似的例子,但因为不同的原因而有点不同。这张图和我们早先讨论 ARM CPU 由不同观察者组成时用的那张一样。我们有 D-side、TLB-side、I-side,我只是稍微扩展了一下,展示了这个系统上的两个不同端口。

  • AXI 从端口(slave port):这差不多是你会用来把这个 CPU 连接到总线上的标准端口。所以它会连接到你的缓存一致性互连。也许你系统下游某个地方挂着一些设备。也许这里的这个设备是一个 UART,比如某种调试日志基础设施。当我向它写入时,它就像追踪之类的。

  • ACP(加速器一致性端口):这个的想法是,你可以把一个非一致性的主控连接到 ACP,它会让那个主控变得缓存一致。所以你可以拿一个像非一致性 DMA 这样的东西,放在 ACP 上,它所有的访问都通过 CPU 的 D-side,这实际上让它变得缓存一致,因为 D-side 是缓存一致的。这是个不错的小技巧。你可以把一些特定于 CPU 的、非常靠近 CPU 的外设放在这里,它们现在就和那个 CPU 缓存一致了。

和之前类似的事情,假设我要对设备 0 进行写入,然后我要对设备 1 进行写入。可能,再次,如果这是一个某种调试基础设施,也许,我有非常重要的事情需要审计这个系统上发生了什么。如果我写入设备 0,它会在某个有序的日志中记录一些消息。也许设备 1 是一个 DMA,它要开始做一些拷贝和传输。我需要确保当那个时间戳被设备 0 打印出来时,那绝对发生在设备 1 开始发出任何总线事务之前。所以我真的想确保这个写入先被观察到。

思考我们之前看到的,问问自己,如果这两件事被重排序了我会不会烦恼?是的,我会。我刚才一直在说,保持它们的顺序至关重要。它们可以被重排序吗?嗯,我把这些东西映射为 Device-nGnR。那个 nR 意味着非重排序(non-reordering)。所以它们不能被重排序。

嗯,让我们暂时假设它们可以被重排序,我们一会儿会看到为什么。好的,所以我需要一个屏障。那我需要哪个屏障呢?好吧,也许我只需要一个 DMB,因为它们都是 D-side 访问,都是存储,都在外部可共享域。

但实际上和之前一样,我们在这里需要一个 DSB。我需要 DSB 的原因,虽然和我们一分钟前看到的 MCU 的例子非常非常相似,但略有不同。对于设备,任何两个访问,如果它们要么都是对一个非重排序设备类型的,要么是对任何设备类型但它们之间有一个 DMB,它们只被要求在它们是对 ARM 架构所谓的“单个外设或实现定义大小的内存块”时才按顺序被观察到。

这通常是令人困惑的 ARM 架构术语。它在实践中真正意味着的是,危险检测(hazard checking)只在每个端口(per port)上进行。所以在幻灯片上的例子中,设备 0 和设备 1 在不同的端口上,这意味着即使它们都是 nR 类型,它们之间也没有顺序强制。即使你在它们之间放一个 DMB,它们之间也没有顺序,因为在两个设备类型之间的 DMB 基本上只是让它假装它们都是 nR。所以规则是一样的。因为它对 nR 类型来说不够好,所以在这里也不够好。这意味着我需要一个 DSB

课后练习

好的,我已经超时 20 分钟了。所以我快速地给你们看一个东西,我称之为小小的课后练习。你可以看到这里有一些代码,它将做一个页表修改

在 ARM 架构中,我们要做一个叫做“先断后立(break before make)”的事情。我不在会上解释为什么。如果你去 ARM ARM(架构参考手册)里搜索“break before make”,你会找到一个小小的解释。基本上,它的意思是,我必须用一个无效的条目(比如全零)覆盖翻译表条目(页表条目)。然后才做我真正想做的写入。如果我正在改变映射,我不能直接修改一个页表条目。这里有一些条件适用。如果你在改变它的输出地址,对类型的某些改变等等。我需要先用全零完全覆盖那个条目,使之无效。做一堆事情。然后最后才做我的新映射的写入,也就是最后的存储操作。这个序列就是“先断后立”。

在我们看这个之前,你马上会注意到的事情之一是,我有 D-side 访问,我有 TLB-side 访问(我们还没见过),我还有 I-side 访问。所以这是同一个核心的所有三个观察者。这增加了一点复杂性。

我说过这是一个课后练习。所以这里有一些关于你可能或可能不需要屏障的地方的线索。基本上是在每条指令之间和最后。在可用的 PDF 文稿中(同样,在你来到这个演讲的页面上有链接),你可以找到这个,这里是倒置的,但它有答案。但我建议你自己先试一试。希望你已经从我这次演讲中得到了一些小线索,并且能够做对,然后把你的答案和幻灯片上的答案比较一下。

如果你想看更多关于这个的信息,再次,如果你在 ARM 架构手册中搜索“break before make”,你实际上会找到一些很好的讲解。ARM 架构参考手册中还有一个完整的附录叫做“屏障检验测试(Barrier Litmus Tests)”。如果你在做任何类型的底层 ARM 开发,并且会和屏障打交道,通读整个屏障检验测试附录。它和 ARM-ARM 的其余部分非常不同。如果有人试过读 ARM-ARM,你会知道那不是那种你晚上会带到床上读的书。它写得非常非常特定,可能很难解析。但实际上,屏障检验测试部分读起来非常愉快。它有很多很好的实例。它做了一个很好的教学,它会说,“好吧,我们有这个问题。所以我会用这些屏障。实际上,这是错的。这是为什么错了。”然后它会给你真正的解决方案,这非常好。所以,是的,非常非常推荐那里。

那就是我今天准备的所有材料了。希望这很有趣。

问答环节

问: Bertrand 提问,你知道目前 Zen 代码里有多少是错的吗?
答: 我不知道。我甚至还没开始看。我知道 Julian 一直在做一些工作,看各种东西。他最近在邮件列表上发的一些东西就是关于他发现的潜在的屏障问题。是的,这很棘手,特别是当你的代码库最初主要是为一个不需要过多担心这个问题的架构编写的时候。试图找到所有那些可能遗漏了屏障但还没人注意到的地方,而且一切看起来都还好。再次强调,你的代码里可能有真正的 bug,只是在你现有的硬件上没有显现出来。然后最后,你换到了一个新的硬件系统。然后两件事之一会发生:要么那个硬件系统是完全符合架构的,实际上你的代码里有一个 bug,最后这个 bug 在这个新硬件平台上显现出来了。在这种情况下,很容易回头想,“嗯,我们有所有这些一直工作的平台,只有你的新平台坏了。因此,你的新平台有问题。”实际上,不,是代码里的 bug。反之亦然,对吧?你的硬件平台可能是有问题的。你的代码是好的,并且已经正常工作了好几年。然后你换到一个新版本的硬件。你在上面运行同样的一段软件,现在它坏了。很容易去怪罪不同的东西。所以,是的,这是一个棘手的问题。而且是一个大问题。我认为随着我们看到更大、更复杂的硬件系统使用像 Xen 这样的东西,我们会看到越来越多这样的问题显现出来。

问: Christopher 提问,一个 CPU 模拟器,比如 QEMU,能有一个模糊测试模式来压力测试这些屏障条件吗?
答: QEMU 本身,可能不行。屏障的一个问题是时序(timing)很重要。任何时序不准确的东西,你可能无法让问题显现出来。话虽如此,ARM 开发了一个内存模型浏览器(memory model explorer)。它在 ARM 开发者网站上可以找到。我现在忘了它的名字了。内核也有类似的东西。但它基本上就是做那个,就是能够探索你的内存模型是否如你所想的那样行为,以及你是否可能遗漏了屏障。免责声明:我自己从没用过。我只是被指引过它的方向。所以我知道它存在,并且在那里可用。但我对它了解不多。(Ash 在聊天中分享了链接,工具名为 DIY7)是的,就是这个链接。所以,我绝对建议读一下那个页面,看看那个工具。据说它相当不错。

结束语

好的。酷。是的。感谢大家今天的时间。希望这很有趣。希望你们玩得开心,学到了一些新东西。看看幻灯片。是的。期待在邮件列表上与你们互动。如果你们有问题,随时在邮件列表上找我。如果你们在 Zen 代码库里发现了任何东西,特别是围绕 ARM 的东西,你觉得,“嗯,我这里需要一个屏障吗?”或者你看着现有的代码觉得这看起来不对?请 ping 我。我总是很乐意看一看。把这些问题讨论清楚,看看我们能否得出正确的结论,是很有趣的。

谢谢大家。谢谢。