【问题标题】:How does memory reordering help processors and compilers?内存重新排序如何帮助处理器和编译器?
【发布时间】:2016-10-10 01:54:03
【问题描述】:

我研究了 Java 内存模型并看到了重新排序问题。一个简单的例子:

boolean first = false;
boolean second = false;

void setValues() {
    first = true;
    second = true;
}

void checkValues() {
    while(!second);
    assert first;
}

重新排序是非常不可预测和奇怪的。此外,它破坏了抽象。我认为处理器架构必须有充分的理由去做对程序员来说非常不方便的事情。 这些原因是什么?

有很多关于如何处理重新排序的信息,但我找不到任何关于为什么需要它的信息。到处都有人说“这是因为一些性能优势”。例如,在first 之前存储second 有哪些性能优势?

你能推荐一些关于这方面的文章、论文或书籍,或者自己解释一下吗?

【问题讨论】:

  • 走进一家咖啡馆,要一杯饮料和一个三明治。柜台后面的人把三明治递给你(就在他旁边),然后走到冰箱前拿你的饮料。你在乎他以“错误”的顺序给你吗?你宁愿他先做一个慢的,仅仅因为你是这样下命令的吗?
  • 有时它确实很重要。大热天你不会想要一杯热饮吧?所以你希望最后拿饮料。
  • 除了立即抛出异常之外,您的代码是否应该做其他事情?我猜你并不真正理解“重新排序”这个术语,存储的值永远不会改变,但它们的获取策略会。
  • 现代 CPU 是复杂的设备,如果指令之间没有数据依赖关系,可以同时执行多条指令。根据 CPU 的不同,将指令按照您在源代码中所做的顺序以外的特定顺序放置会使其运行得更快。见Out-of-order execution
  • @Jesper:编译时重新排序更重要的是允许对同一个共享变量的多个操作折叠在一起。例如内联后,多次调用函数的多次增量可以变成单个c.a += 4,即使事情发生在两者之间,编译器也无法证明没有其他线程可以观察到它们(通过引用)。查看我对答案的更新。

标签: java multithreading optimization compiler-optimization cpu-architecture


【解决方案1】:

TL;DR:它为编译器和硬件提供了更多空间来利用 as-if 规则,因为它不需要保留原始源代码的所有行为,只有单线程本身的结果。

将加载/存储的外部可观察(来自其他线程)排序作为优化必须保留的东西,为编译器提供了很大的空间来将事物合并到更少的操作中。对于硬件来说,延迟存储是一个大问题,但对于编译器来说,各种重新排序都会有所帮助。


(关于它为何对编译器有帮助的部分,请参阅部分内容)

为什么它有助于硬件

硬件在 CPU 内部重新排序较早的存储和较晚的负载 (StoreLoad reordering) 对于乱序执行至关重要。 (见下文)。

其他类型的重新排序(例如 StoreStore 重新排序,这是您的问题的主题)不是必需的,并且可以仅使用 StoreLoad 重新排序而不是其他三种重新排序来构建高性能 CPU。 (最好的例子是 tag:x86,其中每个商店都是一个 release-store, every load is an acquire-load。有关更多详细信息,请参阅 标签 wiki。)

有些人,比如 Linus Torvalds,认为与其他商店重新订购商店对硬件没有多大帮助,because hardware already has to track store-ordering to support out-of-order execution of a single thread。 (单个线程始终运行,就好像它自己的所有存储/加载都按程序顺序发生一样。)如果您好奇,请参阅 realworldtech 上该线程中的其他帖子。和/或如果你觉得 Linus 的侮辱和明智的技术论点的混合很有趣:P


对于 Java,问题在于,存在硬件提供这些排序保证的架构Weak memory ordering 是 ARM、PowerPC 和 MIPS 等 RISC ISA 的共同特征。 (但不是 SPARC-TSO)。该设计决策背后的原因与我链接的 realworldtech 线程中争论的原因相同:使硬件更简单,并让软件在需要时请求订购。

因此,Java 的架构师别无选择:为内存模型比 Java 标准更弱的架构实现 JVM 将需要在每个存储之后执行存储屏障指令,并且需要加载屏障每次加载之前。 (除非 JVM 的 JIT 编译器可以证明没有其他线程可以引用该变量。)一直运行屏障指令很慢。

Java 的强大内存模型将使 ARM(和其他 ISA)上的高效 JVM 变得不可能。证明不需要障碍几乎是不可能的,需要人工智能水平的全球程序理解。 (这远远超出了普通优化器的工作范围)。


为什么它可以帮助编译器

(另请参阅 Jeff Preshing 在 C++ compile-time reordering 上的出色博客文章。当您将 JIT 编译到本机代码作为流程的一部分时,这基本上适用于 Java。)

保持 Java 和 C/C++ 内存模型弱的另一个原因是允许更多优化。由于(弱内存模型)允许其他线程以任何顺序观察我们的存储和加载,因此即使代码涉及到内存的存储,也允许进行激进的转换。

例如在大卫的例子中:

c.a = 1;
c.b = 1;
c.a++;
c.b++;

// same observable effects as the much simpler
c.a = 2;
c.b = 2;

不要求其他线程能够观察中间状态。因此,编译器可以将其编译为c.a = 2; c.b = 2;,无论是在 Java 编译时还是在字节码被 JIT 编译为机器码时。

一种方法很常见,它增加了从另一个方法中多次调用的东西。如果没有这条规则,只有当编译器能够证明没有其他线程可以观察到差异时,才能将其转换为 c.a += 4

C++ 程序员有时会错误地认为,由于他们正在为 x86 编译,他们不需要std::atomic<int> 来获得共享变量的一些排序保证。 这是错误的,因为优化是根据语言记忆模型的 as-if 规则进行的,而不是目标硬件。


更多技术硬件说明:

为什么 StoreLoad 重新排序有助于提高性能:

一旦将存储提交到缓存中,它就会对运行在其他内核上的线程全局可见(通过缓存一致性协议)。到那时,回滚它为时已晚(另一个核心可能已经获得了该值的副本)。因此,除非确定商店不会出错,而且之前的任何指令都不会出错,否则它不会发生。商店的数据就准备好了。并且在之前的某个时间点没有出现分支错误预测,等等等等。也就是说,我们需要在退出存储指令之前排除所有错误推测的情况。

如果没有 StoreLoad 重新排序,每次加载都必须等待所有先前的存储退出(即完全完成执行,已将数据提交到缓存),然后才能从缓存中读取值以供以后依赖于加载的值。 (加载将值从缓存复制到寄存器的时刻是它对其他线程全局可见的时刻。)

由于您无法知道其他内核上发生了什么,我认为硬件不能通过推测它不是问题来隐藏启动加载的延迟,然后在事后检测错误推测。 (并将其视为分支错误预测:丢弃所有依赖于该负载完成的工作,然后重新发出它。)核心可能允许来自处于Exclusive or Modified 状态的高速缓存行的推测性早期加载,因为它们不能出现在其他内核中。 (如果在推测性加载之前退出最后一个存储之前,如果对该高速缓存行的高速缓存一致性请求来自另一个 CPU,则检测错误推测。)无论如何,这显然是大量的复杂性,其他任何事情都不需要。

请注意,我什至没有提到商店的缓存未命中。这会将存储的延迟从几个周期增加到数百个周期。


实际 CPU 的工作方式(允许 StoreLoad 重新排序时):

在我对Deoptimizing a program for the pipeline in Intel Sandybridge-family CPUs 的回答的早期部分,我包含了一些链接作为计算机体系结构简介的一部分。如果您发现这很难理解,这可能会有所帮助,或者更令人困惑。

CPU 通过在 store queue 中缓冲它们来避免 WAR and WAW pipeline hazards 用于存储,直到存储指令准备好退出。来自同一个内核的加载必须检查存储队列(以保留单个线程的按顺序执行的外观,否则在加载任何可能最近存储的内容之前您需要内存屏障指令!)。存储队列对其他线程是不可见的;存储仅在存储指令退出时才全局可见,但加载在执行后立即变为全局可见。 (并且可以在此之前使用预取到缓存中的值)。

另请参阅this answer 我写了解释存储缓冲区以及它们如何将执行与缓存未命中存储分离 提交,并允许存储的推测执行。 wikipedia's article on the classic RISC pipeline 也有一些用于更简单 CPU 的东西。存储缓冲区固有地创建 StoreLoad 重新排序(以及 store-forwarding 所以a core can see its own stores before they become globally visible,假设核心可以进行存储转发而不是停滞。)

因此商店可以乱序执行,但它们只会在商店队列中重新排序。由于指令必须退出以支持精确的异常,因此让硬件强制执行 StoreStore 排序似乎根本没有什么好处。

由于加载在执行时变得全局可见,因此强制 LoadLoad 排序可能需要在缓存中未命中的加载之后延迟加载。当然,实际上 CPU 会推测性地执行以下加载,并在发生内存顺序错误推测时检测它。这对于良好的性能几乎是必不可少的:乱序执行的很大一部分好处是继续做有用的工作,隐藏缓存未命中的延迟。


Linus 的一个论点是,弱排序 CPU 需要多线程代码才能使用大量内存屏障指令,因此它们需要便宜,多线程代码才能不烂。这只有在您有硬件跟踪加载和存储的依赖顺序时才有可能。

但是,如果您有依赖关系的硬件跟踪,您可以让硬件始终强制执行排序,因此软件不必运行那么多屏障指令。如果你有硬件支持来降低屏障的成本,为什么不像 x86 那样在每次加载/存储时都隐含它们。

他的另一个主要论点是内存排序是困难的,并且是错误的主要来源。在硬件上做对一次比每个软件项目都必须做对要好。 (这个论点之所以有效,是因为它可以在硬件中实现,而不会产生巨大的性能开销。)

【讨论】:

  • @Gilgamesz:我说的是 Java 架构师必须对 Java 的内存模型做出的设计决策。如果 Java 提供强大的内存模型而不需要显式的排序语义,那么 Java 将更容易编程,但这将使在弱排序硬件上实现高性能 JVM 成为不可能。 (以及严重限制编译时优化器)。
  • @Gilgamesz:1:是的,就像我在回答中所说的那样,就在你引用的那一点之后。 JVM 需要 AI 级别的智能来确定哪些操作实际上需要屏障,因此它必须在任何地方使用额外的屏障。
  • @Gilgamesz:2:是的,锁定为您提供了获取/释放语义。获取锁是获取障碍。但即使在需要锁的代码中,JVM 也不知道它也不依赖于隐式强排序。 (这很奇怪,但可能)。
  • @Gilgamesz:我刚刚进行了编辑。这是否有助于让未来的读者更清楚答案?我很难想象不知道我所知道的所有东西,或者有不同的思考方式是什么感觉。
  • @Gilgamesz:呵呵,我同意这一点,但对于人们不清楚的事情获得反馈总是很好的。如果我可以更清楚地解释它,那么我会的。其他时候,是需要其他知识来理解解释的问题,然后我只是链接到维基百科或其他东西。
【解决方案2】:

假设有以下代码:

a = 1;
b = 1;
a = a + 1;   // Not present in the register
b = b + 1;   // Not present in the register
a = a + 1;   // Not present in the register
b = b + 1;   // Not present in the register
// Here both a and b has value 3

使用内存重新排序的可能优化是

a = 1;
a = a + 1;   // Already in the register
a = a + 1;   // Already in the register
b = 1;
b = b + 1;   // Already in the register
b = b + 1;   // Already in the register
// Here both a and b has value 3

性能更好,因为数据存在于寄存器中。

请注意,有许多不同级别的优化,但这会让您了解为什么重新排序可以提高性能。

【讨论】:

  • 这是关于内存排序,而不是寄存器。 ab 应该是本地人吗?你是说在一台带有单个累加器寄存器的机器上,加载b 需要溢出a
  • 真正的优化是做一个设置a=3的store,因为重新排序后可以合并单独的a = a + 1。 (b 相同)。如果不允许重新排序,则另一个线程将永远无法观察到|a-b| > 1。但由于它可以合法地在 Java 内存模型中观察到这一点,优化器可以重新排列程序以使其更高效,同时仍然产生相同的外部可观察效果。
  • @PeterCordes 显然。事实上,我在最后添加了注释。但这可以让您了解重新排序如何影响性能。真正的优化会使问题难以阅读。
【解决方案3】:

在现代处理器芯片上,处理器执行寄存器到寄存器操作的速度通常比从主内存中读取的速度快一个数量级(或更多)。命中 L1 或 L2 高速缓存的操作比主内存快,比寄存器到寄存器慢。另一件需要注意的是,现代处理器芯片通常使用管道,它允许同时执行不同指令的不同部分。

考虑到这一点,操作的重新排序通常是为了避免管道(快)必须等待主内存上的操作(慢)完成的情况:

  • Davide 的示例说明了完全避免内存读取和写入的重新排序。 (至少,这是他的意图。实际上,重新排序是在本机指令级别完成的,而不是源代码或字节码级别。)

  • 在其他情况下,您可能会发现执行 a = a + 1b = b + 1 的指令交错;例如

    1) load a -> r1
    2) load b -> r2
    3) r1 + 1 -> r3
    4) r2 + 1 -> r4
    5) save r3 -> a
    6) save r4 -> b
    

    在管道架构中,这可能允许 2) 和 3) 同时发生,4) 和 5) 同时发生等等。

最后要注意的是,现代处理器芯片/指令集尽可能避免从主存读取和写入主存。实际上,写入指令通常会写入 L1 或 L2 高速缓存,并延迟(慢速)写入主存储器直到高速缓存行被刷新。这会导致另一种“内存异常”......在不同内核上运行的单独线程看不到内存更新,因为相应的写入尚未(尚未)被刷新。

Java 内存模型旨在允许编译器/处理器优化多线程应用程序的性能,如上所述。它清楚地表明,何时保证一个线程可以看到另一个线程所做的内存更改。在没有可见性保证的情况下,允许编译器/处理器重新排序等。这种重新排序会对整体性能产生很大影响。

【讨论】:

  • +1 调度内存 io 以避免冲突可能非常重要。不仅仅是降低套准压力。
  • SMP 系统是缓存一致的。一旦存储被提交到 L1 缓存,它就全局可见。发生 StoreLoad 重新排序是因为存储在将它们提交到缓存之前被缓冲在私有存储队列中,以启用乱序执行。甚至现代的有序 CPU 仍将支持一些存储缓冲以隐藏延迟。
【解决方案4】:

走进一家咖啡馆,要一杯饮料和一个三明治。柜台后面的人把三明治递给你(就在他旁边),然后走到冰箱前拿饮料。

你在乎他以“错误”的顺序给你吗?你宁愿他先做慢的,仅仅因为你是这样下命令的吗?

好吧,也许你确实在乎。也许你想把没吃的三明治塞进你的空饮料杯里(你付了钱,所以为什么不呢,如果你愿意的话)。您在取饮料时必须拿着三明治这一事实让您感到沮丧 - 毕竟,您本可以利用这段时间喝您的饮料,而且您最终不会打嗝,因为您很着急!

但是,如果您订购了一些东西而没有指定它们必须发生的顺序,就会发生这种情况。服务员不知道你不寻常的夹心杯填充习惯,所以在他们看来,点餐并不重要。

我们用自然语言构造来指定顺序(“请给我一杯饮料,然后给我一个三明治”)或不(“请给我一杯饮料和一个三明治”)。如果您不小心使用前者而不是后者,则会假定您只想要最终结果,并且可以为方便起见重新排序各个步骤。

同样,在 JMM 中,如果您不具体说明操作的顺序,则假定操作可以重新排序。

【讨论】:

  • 我喜欢这个类比的想法,但不幸的是,这个类比并不完美。乱序执行的黄金法则是:永远不要破坏单线程程序。即单线程似乎总是按程序顺序执行。在 Java 源代码级别相同;您无需执行任何操作来指定 a = 1 永远不会与 b = a 重新排序。 重新排序只会影响其他线程观察到的内容
猜你喜欢
  • 2017-06-25
  • 1970-01-01
  • 2011-10-15
  • 1970-01-01
  • 2022-01-15
  • 2017-11-02
  • 2021-09-15
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多