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

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

boolean first = false; boolean second = false; void setValues() { first = true; second = true; } void checkValues() { while(!second); assert first; } 

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

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

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

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

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


(有关它为什么有助于编译器的部分,请参见部分内容)

为什么它有助于硬件

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

其他类型的重新排序(例如StoreStore重新排序,这是您的问题的主题)并不是必需的,高性能CPU可以仅使用StoreLoad重新排序而不是其他三种来构建。 (主要的例子是标签:x86,其中每个商店都是一个发布商店,每个负载都是一个获取负载 。有关更多详细信息,请参阅x86标签wiki。)

有些人,比如Linus Torvalds,认为与其他商店重新排序商店对硬件的帮助不大, 因为硬件已经必须跟踪商店订购以支持单个线程的无序执行 。 (单个线程总是运行,好像它自己的所有存储/加载按程序顺序发生。)如果你很好奇,请参阅realworldtech上该线程中的其他post。 和/或如果你发现Linus的侮辱和明智的技术争论很有趣:P


对于Java,问题在于, 存在硬件提供这些排序保证的架构 。 弱内存排序是RISC ISA的常见function,如ARM,PowerPC和MIPS。 (但不是SPARC-TSO)。 设计决策背后的原因与我所链接的真实世界的线程中的争论相同:使硬件更简单,并让软件在需要时请求订购。

因此,Java的架构师没有太多选择:对于内存模型比Java标准弱的架构实现JVM需要在每个存储之后进行存储屏障指令,并且在每次加载之前都需要加载屏障。 (除非JVM的JIT编译器能够certificate没有其他线程可以引用该变量。)始终运行屏障指令很慢。

Java的强大内存模型将使ARM(和其他ISA)上的高效JVM无法实现。 certificate不需要障碍几乎是不可能的,需要人工智能水平的全球计划理解。 (这超出了普通优化器的作用)。


为什么它有助于编译器

(另请参阅Jeff Preshing关于C ++编译时重新排序的优秀博客文章。当您将JIT编译包含到本机代码中时,这基本上适用于Java。)

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

例如在Davide的例子中:

 ca = 1; cb = 1; c.a++; c.b++; // same observable effects as the much simpler ca = 2; cb = 2; 

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

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

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


更多技术硬件说明:

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

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

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

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

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


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

在我的答案中,我将一些链接作为计算机体系结构简介的一部分包括在内, 以便在英特尔Sandybridge系列CPU中优化管道程序 。 如果你发现这很难理解,这可能会有所帮助,或者更令人困惑。

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

另请参阅维基百科关于经典RISC管道的文章 。

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

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


Linus的一个论点是,弱排序的CPU需要multithreading代码才能使用大量的内存屏障指令,因此对于multithreading代码来说,它们需要便宜而不要太糟糕。 只有当您有硬件跟踪加载和存储的依赖顺序时,才有可能。

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

他的另一个主要论点是内存排序是HARD,也是bug的主要来源。 在硬件中实现一次就比每个必须正确完成的软件项目更好。 (这个论点只有在没有巨大性能开销的硬件中才有效。)

想象一下,有以下代码:

 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 

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

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

在现代处理器芯片上,处理器通常可以执行寄存器以将从主存储器获取的操作更快地注册到一个数量级(或更多)。 命中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高速缓存,并延迟(慢)写入主存储器直到刷新高速缓存行。 这导致了一种不同类型的“内存exception”……其中运行在不同内核上的单独线程看不到内存更新,因为相应的写入尚未(尚未)被刷新。

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

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

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

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

但是如果您在没有指定必须发生的顺序的情况下订购一些东西,那就会发生什么。 服务器不知道你不寻常的夹心杯填充习惯,所以在他们看来,顺序无关紧要。

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

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

Interesting Posts