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

标签 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 有什么性能优势?

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

最佳答案

TL;DR :它为编译器和硬件提供了更多的空间来利用 as-if 规则,不需要它保留原始源的所有行为,只保留单线程本身的结果。
将加载/存储的外部可观察(来自其他线程)排序作为优化必须保留的内容,为编译器提供了很大的空间将事物合并为更少的操作。对于硬件,延迟存储是最重要的,但对于编译器,各种重新排序都可以提供帮助。

(有关为什么它有助于编译器的部分,请参阅部分内容)
为什么它有助于硬件
硬件重新排序较早的存储与较晚的负载( StoreLoad reordering )在 CPU 内对于乱序执行至关重要。 (见下文)。
其他类型的重新排序(例如 StoreStore 重新排序,这是您问题的主题)不是必需的,并且高性能 CPU 可以仅使用 StoreLoad 重新排序来构建,而不是其他三种。 (主要示例是 tag: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 是 ARM、PowerPC 和 MIPS 等 RISC ISA 的共同特征。 (但不是 SPARC-TSO)。该设计决策背后的原因与我链接的真实世界技术线程中争论的原因相同:使硬件更简单,并让软件在需要时请求订购。
因此,Java 的架构师没有太多选择:为内存模型比 Java 标准弱的架构实现 JVM 需要在每次存储之后执行存储屏障指令,并且在每次加载之前需要加载屏障. (除非 JVM 的 JIT 编译器可以证明没有其他线程可以引用该变量。)一直运行屏障指令很慢。
强大的 Java 内存模型将使 ARM(和其他 ISA)上的高效 JVM 变得不可能。证明不需要障碍几乎是不可能的,需要人工智能水平的全局程序理解。 (这远远超出了普通优化器所做的)。

为什么它有助于编译器
(另请参阅 Jeff Preshing 在 C++ compile-time reordering 上的优秀博客文章。当您将 JIT 编译到 native 代码作为过程的一部分时,这基本上适用于 Java。)
保持 Java 和 C/C++ 内存模型弱的另一个原因是允许更多优化。由于允许其他线程(通过弱内存模型)以任何顺序观察我们的存储和加载,即使代码涉及到内存的存储,也允许激进的转换。
例如在像 Davide 的例子这样的情况下:

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 重新排序有助于提高性能:
一旦一个存储被提交到缓存中,它就会对其他内核上运行的线程全局可见(通过缓存一致性协议(protocol))。到那时,回滚它为时已晚(另一个核心可能已经获得了该值的副本)。因此,在确定商店不会出错之前不会发生这种情况,并且在此之前的任何指令也不会发生。并且商店的数据准备好了。并且在更早的某个时间点没有分支错误预测,等等。也就是说,我们需要在我们停用存储指令之前排除所有错误推测的情况。
如果没有 StoreLoad 重新排序,每次加载都必须等待所有前面的存储退出(即完全完成执行,已将数据提交到缓存),然后才能从缓存中读取值以供依赖加载值的后续指令使用。 (加载将值从缓存复制到寄存器的时刻是它对其他线程全局可见的时刻。)
由于您无法知道其他内核上发生了什么,我认为硬件无法通过推测它不是问题,然后在事后检测错误推测来隐藏启动加载时的这种延迟。 (并将其视为分支预测错误:丢弃依赖于该负载的所有工作,然后重新发布它。)内核可能能够允许来自处于 Exclusive or Modified 状态的缓存行的推测性早期加载,因为它们可以'不存在于其他内核中。 (如果在推测加载之前退出最后一个存储之前对该缓存行的缓存一致性请求来自另一个 CPU,则检测错误推测。)无论如何,这显然是大量的复杂性,其他任何事情都不需要。
请注意,我什至没有提到商店的缓存未命中。这将存储的延迟从几个周期增加到数百个周期。

实际 CPU 的工作方式(允许 StoreLoad 重新排序时):
在我对 Deoptimizing a program for the pipeline in Intel Sandybridge-family CPUs 的回答的早期部分中,我包含了一些链接作为计算机体系结构简要介绍的一部分。如果您发现这很难理解,这可能会有所帮助,或者更令人困惑。
CPU 通过将它们缓冲在 WAR and WAW pipeline hazards 中来避免 store queue 用于存储,直到存储指令准备好退出。来自同一内核的加载必须检查存储队列(以保留单个线程的有序执行的外观,否则在加载最近可能存储的任何内容之前您需要内存屏障指令!)。存储队列对其他线程不可见; store 仅在 store 指令退出时才变为全局可见,但加载一旦执行就变为全局可见。 (并且可以使用提前预取到缓存中的值)。
另见 this answer 我写了解释存储缓冲区以及它们如何将执行与缓存未命中存储 提交分离,并允许存储的推测执行。另外 wikipedia's article on the classic RISC pipeline 也有一些更简单的 CPU 的东西。存储缓冲区本身会创建 StoreLoad 重新排序(以及 存储转发,因此 a core can see its own stores before they become globally visible ,假设核心可以进行存储转发而不是停顿。)
因此,对于商店来说,乱序执行是可能的,但它们只会在商店队列中重新排序。由于指令必须退出以支持精确的异常,因此让硬件强制执行 StoreStore 排序似乎没有太大好处。
由于加载在执行时变得全局可见,因此强制 LoadLoad 排序可能需要在加载未命中缓存后延迟加载。当然,实际上 CPU 会推测性地执行以下加载,并在发生内存顺序错误推测时检测它。这对于良好的性能几乎是必不可少的:乱序执行的很大一部分好处是继续做有用的工作,隐藏缓存未命中的延迟。

Linus 的论点之一是弱排序 CPU 需要多线程代码才能使用大量内存屏障指令,因此它们需要便宜,多线程代码才能不烂。只有当你有硬件跟踪加载和存储的依赖顺序时,这才有可能。
但是如果你有硬件跟踪依赖关系,你可以让硬件一直强制执行排序,所以软件不必运行尽可能多的屏障指令。如果你有硬件支持来降低屏障的成本,为什么不像 x86 那样在每个加载/存储中隐含它们。
他的另一个主要论点是内存排序是困难的,并且是错误的主要来源。在硬件上做对一次比每个软件项目都必须做对要好。 (这个论点之所以有效,是因为它可以在没有巨大性能开销的硬件中使用。)

关于java - 内存重新排序如何帮助处理器和编译器?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/37725497/

相关文章:

java - 完成当前 Activity 后开始 Activity

java - 处理目录中的传入文件

java - @Transactional(propagation=Propagation.REQUIRES_NEW) 打开两个事务?

java - 检测方法的并发调用

optimization - 分配未初始化的 slice

c - 算法优化同时

java - 使用 HTML5 多文件输入将多张图像上传到 GAE Blobstore

java - Android 从抽屉导航上的 ActionLayout 获取开关

c++ - 在一个简单的例子中解释并行代码执行和进一步的性能提升

javascript - 重构 Javascript 以优化速度