在 ARM 上同步 JIT/self-modifying 代码的缓存

Synchronizing caches for JIT/self-modifying code on ARM

根据我的理解,编写和稍后执行 JIT 或自修改代码的一般的、更抽象的过程类似于以下内容。

据我所知 this post about self-modifying code on x86,显然不需要手动缓存管理。我想象 clflushopt 是必要的,但是 x861 显然会在从具有新指令的位置加载时自动处理缓存失效,这样指令获取就永远不会过时。我的问题与 x86 无关,但我想将其包括在内以进行比较。

AArch64 中的情况稍微复杂一些,因为它区分了共享域和缓存操作的“可见性”。仅从 ARMv8/ARMv9 的官方文档,我首先想到了这个猜测。

但是 the documentation for DMB/DSB/ISB 表示“ISB 之后的指令是从 缓存 或内存中获取的”。这给我的印象是缓存控制操作确实是必要的。因此我的新猜测是这样的。

但我还是忍不住觉得连这样都不对。过了一会儿,我在 the documentation that I missed, and something pretty much the same on a paper 上找到了一些东西。他们都给出了一个看起来像这样的例子。

dc cvau, Xn ; Clean cache to PoU, so the newly written code will be visible
dsb ish     ; Wait for cleaning to finish
ic ivau, Xn ; Invalidate cache to PoU, so the newly written code will be fetched
dsb ish     ; Wait for invalidation to finish
isb sy      ; Make sure new instructions are fetched from cache or memory

对于一大块代码,这可能是一个清理循环,dsb ish,一个无效循环,dsb ish,然后是一个 isb sy。如果这不正确,请纠正我。无论如何,这个例子是有道理的,我想我唯一错过的是 dsb ish 单独不同步 I-cache 和 D-cache,必须手动清理和失效新数据。因此,我对此 post 的实际问题如下。


0 只有所有应该看到它的核心都会看到它。
1 至少,所有相当现代的都应该。

(免责声明:此答案是基于阅读规范和一些测试,而不是基于以前的经验。)

首先,这里有一个解释和示例代码 B2.2.5中的case(一个核写代码让另一个核执行) 体系结构参考手册(版本 G.b)。唯一的区别 从你展示的例子来看,最后的 isb 需要 在将 执行 新代码的线程中执行(我 猜测是你的“消费者”),缓存失效完成后。


我发现尝试理解像这样的抽象结构很有帮助 “内部共享域”,来自架构的“统一点” 更具体的参考。

让我们考虑一个具有多个内核的系统。他们的 L1d 缓存是 连贯,但它们的 L1i 缓存不需要与 L1d 统一,也不 彼此一致。不过L2缓存是统一的。

系统无法让 L1d 和 L1i 相互通信 直接地;它们之间的唯一路径是通过 L2。所以一旦我们有 将我们的新代码写入 L1d,我们必须将其写回到 L2 (dc cvau),然后 使 L1i (ic ivau) 无效,以便它从 L2 中的新代码重新填充。

在此设置中,PoU 是二级缓存,而这正是我们想要的 清理/失效到。

D4-2646 页对这些术语进行了一些解释。在 特别:

The PoU for an Inner Shareable shareability domain is the point by which the instruction and data caches and the translation table walks of all the PEs in that Inner Shareable shareability domain are guaranteed to see the same copy of a memory location.

在这里,Inner Shareable 域将包含所有核心 那可以 运行 我们程序的线程;事实上,它应该 包含与我们使用相同内核的所有内核 运行(第 B2-166 页)。 因为我们正在 dc cvauing 的记忆大概标有 内部可共享属性或更好,因为任何合理的 OS 应该 为我们做,它清理了域的 PoU,而不仅仅是 PoU 我们的核心(PE)。所以这正是我们想要的:一个缓存级别,所有 来自所有内核的指令缓存填充将会看到。

一致性点进一步下降;这是水平 系统上的一切 都可以看到,包括 DMA 硬件等。 这很可能是主内存,位于所有高速缓存之下。我们不需要 降到那个水平;它只会让一切都慢下来 受益。

希望对您的问题 1 有所帮助。


注意缓存清理和失效指令运行”在 background”,这样你就可以执行一长串它们 (就像在所有受影响的缓存行上循环)而不等待它们 一项一项地完成。 dsb ish最后用一次等待 他们都完成了。

一些关于 dsb 的评论,针对您的问题 #2 和 #3。它的 主要目的是作为屏障;它确保所有未决数据 我们核心内的访问(存储缓冲区等)被刷新到 L1d 缓存,以便所有其他内核都可以看到它们。这是那种 一般线程间内存排序所需的障碍。 (或为 大多数用途,较弱的 dmb 就足够了;它强制执行命令但是 实际上并没有等待所有东西都被刷新。)但它并没有 对缓存本身做任何其他事情,也不说什么 应该发生在 L1d 以外的数据上。所以就其本身而言,它不会 足以满足我们这里所需的任何地方。

据我所知,“等待缓存维护完成” effect 是 dsb ish 的一种额外功能。看起来正交 说明的主要目的,我不确定他们为什么不这样做 而是提供一个单独的 wcm 指令。但无论如何,也只是 dsb ish 具有此奖励功能; dsb ishst 没有。 D4-2658:“在所有情况下,本节中的文本指的是 DMB 或 DSB,这意味着 DMB 或 DSB,其所需的访问类型是 加载和存储”。

我 运行 在 Cortex A-72 上对此进行了一些测试。省略 dc cvauic ivau 通常会导致执行陈旧代码,即使 dsb ish 被执行也是如此。另一方面,在没有任何 dsb ish 的情况下执行 dc cvau ; ic ivau,我没有观察到任何失败;但这可能是运气或此实现的怪癖。


对于您的#4,我们一直在讨论的序列 (dc cvau ; dsb ish ; ci ivau ; dsb ish ; isb) 是为您 运行 编写它的同一核心上的代码。但实际上不应该 无论哪个线程执行 dc cvau ; dsb ish ; ci ivau ; dsb ish 序列,因为缓存维护指令导致所有内核 按照指示清理/失效;不只是这个。参见 table D4-6。 (但是如果 dc cvau 与 writer 在不同的线程中,也许 writer 必须事先完成一个 dsb ish,这样写入的数据才真正在 L1d 中而不是仍在 writer 的存储缓冲区中? 不确定。)

重要的部分是 isbci ivau完成后, L1i 缓存中的陈旧代码被清除,进一步的指令获取 任何核心都会看到新代码。但是,运行ner 核心可能 之前已经从L1i取回了旧代码,现在还在持有 它在内部(解码并在管道中,uop 缓存,推测性的 执行等)。 isb 刷新这些 CPU 内部机制, 确保要执行的所有进一步指令实际上已经 失效后从L1i缓存中取出。

因此,isb需要在要执行的线程中执行 运行新写的代码。而且你需要确保 所有缓存维护完全完成后完成; 也许通过让编写器线程通过条件变量通知它或者 之类的

我也测试过这个。如果所有缓存维护指令加上一个 isb 都由编写器完成,但 运行ner 没有 isb,那么它可以再次执行陈旧代码。我只能在测试中重现这一点,在该测试中,作者在 运行ner 同时执行的循环中修补了一条指令,这可能确保 运行ner 已经获取了它。这是合法的,前提是旧指令和新指令分别是 b运行ch 和 nop(参见 B2.2.5),这就是我所做的。 (但不保证运行适用于任意新旧指令。)

我尝试了一些其他测试来尝试 ar运行ge 它,以便指令在被修补之前不会真正执行,但 b运行ch 的目标应该已被预测采用,希望这能预取它;但在那种情况下我无法执行旧版本。


有一件事我不是很确定。典型的现代 OS 可能 好吧有 W^X,其中没有虚拟页面可以同时 writable 并执行table。如果在编写代码之后,您调用等价于 mprotect 使页面执行 table,那么 OS 很可能是 将负责所有缓存维护和同步 给你(但我想你自己做也没什么坏处)。

但另一种方法是使用别名:映射内存 writable 在一个虚拟地址,executable 在另一个。这 writer 在之前的地址写入,运行ner 跳转到 后者。在那种情况下,我 认为 你会 dc cvau writable 地址,和 ic ivau executable 一个,但我做不到 找到确认。但我对其进行了测试,无论将哪个别名传递给哪个缓存维护指令,它都能正常工作,而如果完全省略任何一条指令,它就会失败。所以看起来缓存维护是通过下面的物理地址来完成的。