内存重新排序如何帮助处理器和编译器?

时间:2016-06-09 12:04:42

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

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

boolean first = false;
boolean second = false;

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

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

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

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

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

4 个答案:

答案 0 :(得分:11)

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

将外部可观察(来自其他线程)的加载/存储排序作为优化必须保留的内容,使编译器有很大的空间将事物合并到更少的操作中。对于硬件而言,延迟商店是最重要的,但对于编译器而言,各种重新排序都可以提供帮助。

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

为什么它有助于硬件

硬件重新排序早期存储以及CPU内部的后续加载(StoreLoad reordering)对于无序执行至关重要。 (见下文)。

其他类型的重新排序(例如StoreStore重新排序,这是您的问题的主题)并不重要,只能使用StoreLoad重新排序来构建高性能CPU,而不是其他三种。 (主要示例是标记:x86,其中每个商店都是release-store, every load is an acquire-load。有关详细信息,请参阅标记维基。)

有些人,比如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是RISC ISA的常见功能,如ARM,PowerPC和MIPS。 (但不是SPARC-TSO)。设计决策背后的原因与我所链接的真实世界的线程中所讨论的相同:使硬件更简单,并让软件在需要时请求订购。

因此,Java的架构师没有多少选择:对于内存模型比Java标准弱的架构实现JVM需要在每个商店之后进行商店屏障指令,以及每次装载前的负载障碍。 (除非JVM的JIT编译器能够证明没有其他线程可以引用该变量。)运行屏障指令一直很慢。

Java的强大内存模型将无法在ARM(和其他ISA)上实现高效的JVM。证明不需要障碍几乎是不可能的,需要AI级别的全球程序理解。 (这超出了普通优化器的作用)。

为什么它有助于编译器

(另请参阅Jeff Preshing在C++ compile-time reordering上的优秀博客文章。当您将JIT编译包含在本机代码中时,这基本上适用于Java。)

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

e.g。在像Davide的例子中那样:

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

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

没有要求其他线程能够观察到中间状态。因此编译器可以在Java编译时或者在字节码被JIT编译为机器代码时将其编译为c.a = 2; c.b = 2;

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

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

更多技术硬件说明:

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

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

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

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

请注意,我甚至没有提到商店的缓存缺失。这会将商店的延迟从几个周期增加到数百个周期。

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

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

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

另见wikipedia's article on the classic RISC pipeline

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

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

Linus&#39;参数是弱排序的CPU需要多线程代码才能使用大量的内存屏障指令,因此它们需要对多线程代码来说是便宜的而不是很糟糕。只有当你有硬件跟踪加载和存储的依赖顺序时,才有可能。

但是如果你有依赖关系的硬件跟踪,你可以让硬件一直强制执行,因此软件不必运行尽可能多的屏障指令。如果你有硬件支持来减少障碍,为什么不在每个加载/存储上隐含它们,就像x86那样。

他的另一个主要论点是内存排序很难,也是错误的主要来源。在硬件中实现一次就比每个必须正确完成的软件项目更好。 (这个论点之所以有效,是因为它可以在没有巨大性能开销的情况下在硬件中使用。)

答案 1 :(得分:5)

想象一下有以下代码:

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

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

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

答案 2 :(得分:3)

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

考虑到这一点,重新排序操作通常完成,以避免管道(快速)必须等待主存储器上的操作(慢)完成:

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

  • 在其他情况下,您可能会发现执行a = a + 1b = b + 1的说明是交错的; e.g。

    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 Memory Model旨在允许编译器/处理器优化多线程应用程序的性能,如上所述。当一个线程保证看到另一个线程所做的内存更改时,它就清楚了。在没有可见性保证的情况下,允许编译器/处理器重新排序等。这种重新排序可以在整体表现上产生很大的不同。

答案 3 :(得分:0)

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

你是否在乎他是按照“错误”的顺序给你的?你宁愿他先做慢速的,只是因为那是你给订单的方式吗?

好吧,也许你会照顾。也许你想把未吃的三明治塞进你的空杯子里(你付了钱,所以为什么不这样,如果你愿意的话)。当你拿着饮料时你必须拿着三明治这一事实让你感到沮丧 - 毕竟你可以利用那个时间来喝饮料,而你最终也不会打嗝,因为你很匆忙!

但是,如果你订购了一些东西而没有指明它们必须发生的顺序,那就会发生这种情况。服务器没有意识到你不寻常的夹心杯填充习惯,所以在他们看来,排序无关紧要。

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

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