内存重新排序如何帮助处理器和编译器?
How does memory reordering help processors and compilers?
我研究了 Java 内存模型并发现了重排序问题。一个简单的例子:
boolean first = false;
boolean second = false;
void setValues() {
first = true;
second = true;
}
void checkValues() {
while(!second);
assert first;
}
重新排序是非常不可预测和奇怪的。此外,它破坏了抽象。我想处理器架构一定有充分的理由来做一些对程序员来说非常不方便的事情。 这些原因是什么?
有很多关于如何处理重新排序的信息,但我找不到任何关于为什么需要它的信息。到处都有人说 "it is because of some performance benefit" 之类的话。例如,在 first
之前存储 second
有哪些性能优势?
能否推荐一些关于这方面的文章、论文或书籍,或者自己解释一下?
假设有如下代码:
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
性能更好,因为数据存在于寄存器中。
请注意,有许多不同级别的优化,但这会让您了解为什么重新排序可以提高性能。
走进一家咖啡馆,要了一杯饮料和一个三明治。柜台后面的人把三明治递给你(就在他旁边),然后走到冰箱拿饮料。
你在乎他是按"wrong"顺序给你的吗?你宁愿他先做慢的,仅仅因为你是这样下订单的吗?
好吧,也许你确实在乎。也许你想把没吃完的三明治塞进你的空饮料杯里(你付了钱,如果你愿意,为什么不呢)。取饮料时必须拿着三明治,这让您感到沮丧 - 毕竟,您本可以利用这段时间来喝饮料,而且您不会打嗝,因为您很赶时间!
但是,如果您在没有指定它们必须发生的顺序的情况下订购一些东西,就会发生这种情况。服务器不知道您不寻常的三明治杯馅习惯,因此在他们看来顺序无关紧要。
我们有自然语言的构造来指定排序 ("Please give me a drink, then give me a sandwich") 或不指定排序 ("Please give me a drink and a sandwich")。如果你不小心使用了前者而不是后者,它会假设你只是想要最终结果,为了方便起见,各个步骤可以重新排序。
同样,在 JMM 中,如果您没有具体说明操作的顺序,则假定操作可以重新排序。
在现代处理器芯片上,处理器执行寄存器到寄存器操作的速度通常比从主存储器中读取快一个数量级(或更多)。命中 L1 或 L2 缓存的操作比主内存快,但比寄存器到寄存器慢。另一件需要注意的事情是现代处理器芯片通常使用 管道 允许同时执行不同指令的不同部分。
考虑到这一点,操作的重新排序通常是为了避免管道(快)必须等待主内存(慢)上的操作完成的情况:
Davide 的示例说明了完全避免内存读写的重新排序。 (至少,这是他的意图。实际上,重新排序是在本机指令级别完成的,而不是源代码或字节码级别。)
在其他情况下,您可能会发现执行 a = a + 1
和 b = 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 缓存并延迟(缓慢)写入主内存直到缓存行被刷新是很常见的。这导致了一种不同的 "memory anomaly" ...其中不同核心上的单独线程 运行 看不到内存更新,因为相应的写入尚未(尚未)被刷新。
Java 内存模型旨在允许编译器/处理器优化多线程应用程序的性能,如上所述。它明确了何时保证一个线程可以看到另一个线程所做的内存更改。在没有可见性保证的情况下,允许编译器/处理器重新排序等。这种重新排序会对整体性能产生很大影响。
TL;DR:它通过不要求 as-if 规则为编译器和硬件提供更多空间来利用它保留原始源的所有行为,仅保留单个线程本身的结果。
将 loads/stores 的外部可观察(来自其他线程)排序作为优化必须保留的东西排除在外,为编译器提供了很大的空间来将事物合并为更少的操作。对于硬件,延迟存储很重要,但对于编译器,各种重新排序都有帮助。
(请参阅部分内容了解它为何对编译器有帮助)
为什么它对硬件有帮助
硬件在 CPU 内重新排序较早的存储和较晚的加载 (StoreLoad reordering) 对于乱序执行至关重要。 (见下文)。
其他类型的重新排序(例如 StoreStore 重新排序,这是您问题的主题)不是必需的,高性能 CPU 可以仅使用 StoreLoad 重新排序而不是其他三种重新排序来构建。 (最好的例子是 tag:x86,其中每个商店都是一个 release-store, every load is an acquire-load. See the x86 标签 wiki 以获取更多详细信息。)
有些人,例如 Linus Torvalds,认为与其他商店重新订购商店对硬件没有多大帮助,because hardware already has to track store-ordering to support out-of-order execution of a single thread。 (单个线程总是 运行s 就好像它自己的所有 stores/loads 都按程序顺序发生一样。)如果您好奇的话,请参阅 realworldtech 上该线程中的其他 posts。 And/or 如果你觉得 Linus 的侮辱和明智的技术论证很有趣:P
对于 Java,问题是 存在硬件 不 提供这些顺序保证 的架构。 Weak memory ordering 是 ARM、PowerPC 和 MIPS 等 RISC ISA 的共同特征。 (但不是 SPARC-TSO)。该设计决策背后的原因与我链接的 realworldtech 线程中争论的原因相同:使硬件更简单,并在需要时让软件请求订购。
所以 Java 的架构师没有太多选择:为内存模型比 Java 标准弱的架构实现 JVM 需要存储-每次存储之后的屏障指令,以及每次加载之前的加载屏障。 (除非 JVM 的 JIT 编译器可以证明没有其他线程可以引用该变量。)运行 屏障指令总是很慢。
Java 的强大内存模型将使 ARM(和其他 ISA)上的高效 JVM 变得不可能。证明不需要障碍几乎是不可能的,需要 AI 水平的全球程序理解。 (这远远超出了普通优化器所做的)。
为什么它对编译器有帮助
(另请参阅 Jeff Preshing 在 C++ compile-time reordering 上的精彩博客 post。这基本上适用于 Java,当您将 JIT 编译为本机代码作为过程的一部分时。)
保持 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;
不要求其他线程能够观察到中间状态。因此,编译器可以在 Java 编译时或在字节码被 JIT 编译为机器代码时将其编译为 c.a = 2; c.b = 2;
。
从另一个方法多次调用递增某些内容的方法是很常见的。如果没有这条规则,只有在编译器可以证明没有其他线程可以观察到差异的情况下,才能将其变成 c.a += 4
。
C++ 程序员有时会错误地认为,由于他们是为 x86 编译的,所以他们不需要 std::atomic<int>
来获得共享变量的某些顺序保证。 这是错误的,因为优化是基于语言内存模型的假设规则,而不是目标硬件。
更多技术硬件解释:
为什么 StoreLoad 重新排序有助于提高性能:
一旦存储被提交到缓存中,它就会对其他内核上的线程运行全局可见(通过缓存一致性协议)。到那时,再将其回滚为时已晚(另一个核心可能已经获得了该值的副本)。所以它不会发生,直到确定商店不会出错,之前的任何指令也不会出错。并且商店的数据已准备就绪。并且在之前的某个时间点没有分支错误预测,等等。也就是说,我们需要在退出存储指令之前排除所有错误推测的情况。
如果没有 StoreLoad 重新排序,每次加载都必须等待所有前面的存储退出(即完全执行完毕,将数据提交到缓存),然后它们才能从缓存中读取值以供以后依赖的指令使用在加载的值上。 (加载将值从缓存复制到寄存器的时刻是它对其他线程全局可见的时刻。)
由于您不知道其他内核上发生了什么,我认为硬件无法通过推测这不是问题然后在事后检测错误推测来隐藏启动加载的延迟。 (并将其视为分支错误预测:丢弃所有依赖于该负载的已完成工作,然后重新发布它。)核心可能能够允许来自处于 Exclusive or Modified 状态的缓存行的推测性早期负载,因为它们不能出现在其他核心中。 (如果该缓存行的缓存一致性请求来自另一个CPU,则检测错误推测,然后在推测加载之前退出最后一个存储。)无论如何,这显然是不需要的大量复杂性其他的。
请注意,我什至没有提到商店的缓存未命中。这将存储的延迟从几个周期增加到数百个周期。
CPU 的实际工作方式(允许 StoreLoad 重新排序时):
我在 的回答的开头部分包含了一些链接作为计算机体系结构简要介绍的一部分。如果您发现这很难理解,那可能会有所帮助,或者更容易混淆。
CPUs 避免 WAR and WAW pipeline hazards for stores by buffering them in a store queue,直到存储指令准备好退出。来自同一核心的加载必须检查存储队列(以保持单个线程按顺序执行的外观,否则在加载最近可能存储的任何内容之前,您需要内存屏障指令!)。存储队列对其他线程不可见;存储只有在存储指令退出时才变得全局可见,但是加载一旦执行就会变得全局可见。 (并且可以使用提前预取到缓存中的值)。
另见 I wrote explaining store buffers and how they decouple execution from cache-miss store commit, and allow speculative execution of stores. Also wikipedia's article on the classic RISC pipeline has some stuff for simpler CPUs. A store-buffer inherently creates StoreLoad reordering (and also store-forwarding so ,假设核心可以进行存储转发而不是停止。)
所以商店可能会乱序执行,但它们只会在商店队列中重新排序。由于指令必须退出以支持精确的异常,因此让硬件强制执行 StoreStore 排序似乎没有太大好处。
由于加载在执行时变得全局可见,因此强制执行 LoadLoad 排序可能需要在加载未命中缓存后延迟加载。当然,实际上 CPU 会推测性地执行以下加载,并在发生时检测内存顺序错误推测。这对于良好的性能几乎是必不可少的:乱序执行的很大一部分好处是继续做有用的工作,隐藏缓存未命中的延迟。
Linus 的一个论点是,弱序 CPUs 需要多线程代码使用大量内存屏障指令,因此它们需要很便宜才能让多线程代码不糟透了。这只有在您有硬件跟踪加载和存储的依赖顺序时才有可能。
但是,如果您有依赖项的硬件跟踪,您就可以让硬件始终强制执行排序,这样软件就不必 运行 那么多的障碍指令。如果你有硬件支持来降低障碍,为什么不像 x86 那样让它们隐含在每个加载/存储中。
他的另一个主要论点是内存排序很困难,并且是错误的主要来源。在硬件中一次把它做对比每个软件项目都必须做对要好。 (这个论点之所以有效,是因为它可以在没有巨大性能开销的情况下在硬件中实现。)
我研究了 Java 内存模型并发现了重排序问题。一个简单的例子:
boolean first = false;
boolean second = false;
void setValues() {
first = true;
second = true;
}
void checkValues() {
while(!second);
assert first;
}
重新排序是非常不可预测和奇怪的。此外,它破坏了抽象。我想处理器架构一定有充分的理由来做一些对程序员来说非常不方便的事情。 这些原因是什么?
有很多关于如何处理重新排序的信息,但我找不到任何关于为什么需要它的信息。到处都有人说 "it is because of some performance benefit" 之类的话。例如,在 first
之前存储 second
有哪些性能优势?
能否推荐一些关于这方面的文章、论文或书籍,或者自己解释一下?
假设有如下代码:
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
性能更好,因为数据存在于寄存器中。
请注意,有许多不同级别的优化,但这会让您了解为什么重新排序可以提高性能。
走进一家咖啡馆,要了一杯饮料和一个三明治。柜台后面的人把三明治递给你(就在他旁边),然后走到冰箱拿饮料。
你在乎他是按"wrong"顺序给你的吗?你宁愿他先做慢的,仅仅因为你是这样下订单的吗?
好吧,也许你确实在乎。也许你想把没吃完的三明治塞进你的空饮料杯里(你付了钱,如果你愿意,为什么不呢)。取饮料时必须拿着三明治,这让您感到沮丧 - 毕竟,您本可以利用这段时间来喝饮料,而且您不会打嗝,因为您很赶时间!
但是,如果您在没有指定它们必须发生的顺序的情况下订购一些东西,就会发生这种情况。服务器不知道您不寻常的三明治杯馅习惯,因此在他们看来顺序无关紧要。
我们有自然语言的构造来指定排序 ("Please give me a drink, then give me a sandwich") 或不指定排序 ("Please give me a drink and a sandwich")。如果你不小心使用了前者而不是后者,它会假设你只是想要最终结果,为了方便起见,各个步骤可以重新排序。
同样,在 JMM 中,如果您没有具体说明操作的顺序,则假定操作可以重新排序。
在现代处理器芯片上,处理器执行寄存器到寄存器操作的速度通常比从主存储器中读取快一个数量级(或更多)。命中 L1 或 L2 缓存的操作比主内存快,但比寄存器到寄存器慢。另一件需要注意的事情是现代处理器芯片通常使用 管道 允许同时执行不同指令的不同部分。
考虑到这一点,操作的重新排序通常是为了避免管道(快)必须等待主内存(慢)上的操作完成的情况:
Davide 的示例说明了完全避免内存读写的重新排序。 (至少,这是他的意图。实际上,重新排序是在本机指令级别完成的,而不是源代码或字节码级别。)
在其他情况下,您可能会发现执行
a = a + 1
和b = 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 缓存并延迟(缓慢)写入主内存直到缓存行被刷新是很常见的。这导致了一种不同的 "memory anomaly" ...其中不同核心上的单独线程 运行 看不到内存更新,因为相应的写入尚未(尚未)被刷新。
Java 内存模型旨在允许编译器/处理器优化多线程应用程序的性能,如上所述。它明确了何时保证一个线程可以看到另一个线程所做的内存更改。在没有可见性保证的情况下,允许编译器/处理器重新排序等。这种重新排序会对整体性能产生很大影响。
TL;DR:它通过不要求 as-if 规则为编译器和硬件提供更多空间来利用它保留原始源的所有行为,仅保留单个线程本身的结果。
将 loads/stores 的外部可观察(来自其他线程)排序作为优化必须保留的东西排除在外,为编译器提供了很大的空间来将事物合并为更少的操作。对于硬件,延迟存储很重要,但对于编译器,各种重新排序都有帮助。
(请参阅部分内容了解它为何对编译器有帮助)
为什么它对硬件有帮助
硬件在 CPU 内重新排序较早的存储和较晚的加载 (StoreLoad reordering) 对于乱序执行至关重要。 (见下文)。
其他类型的重新排序(例如 StoreStore 重新排序,这是您问题的主题)不是必需的,高性能 CPU 可以仅使用 StoreLoad 重新排序而不是其他三种重新排序来构建。 (最好的例子是 tag:x86,其中每个商店都是一个 release-store, every load is an acquire-load. See the x86 标签 wiki 以获取更多详细信息。)
有些人,例如 Linus Torvalds,认为与其他商店重新订购商店对硬件没有多大帮助,because hardware already has to track store-ordering to support out-of-order execution of a single thread。 (单个线程总是 运行s 就好像它自己的所有 stores/loads 都按程序顺序发生一样。)如果您好奇的话,请参阅 realworldtech 上该线程中的其他 posts。 And/or 如果你觉得 Linus 的侮辱和明智的技术论证很有趣:P
对于 Java,问题是 存在硬件 不 提供这些顺序保证 的架构。 Weak memory ordering 是 ARM、PowerPC 和 MIPS 等 RISC ISA 的共同特征。 (但不是 SPARC-TSO)。该设计决策背后的原因与我链接的 realworldtech 线程中争论的原因相同:使硬件更简单,并在需要时让软件请求订购。
所以 Java 的架构师没有太多选择:为内存模型比 Java 标准弱的架构实现 JVM 需要存储-每次存储之后的屏障指令,以及每次加载之前的加载屏障。 (除非 JVM 的 JIT 编译器可以证明没有其他线程可以引用该变量。)运行 屏障指令总是很慢。
Java 的强大内存模型将使 ARM(和其他 ISA)上的高效 JVM 变得不可能。证明不需要障碍几乎是不可能的,需要 AI 水平的全球程序理解。 (这远远超出了普通优化器所做的)。
为什么它对编译器有帮助
(另请参阅 Jeff Preshing 在 C++ compile-time reordering 上的精彩博客 post。这基本上适用于 Java,当您将 JIT 编译为本机代码作为过程的一部分时。)
保持 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;
不要求其他线程能够观察到中间状态。因此,编译器可以在 Java 编译时或在字节码被 JIT 编译为机器代码时将其编译为 c.a = 2; c.b = 2;
。
从另一个方法多次调用递增某些内容的方法是很常见的。如果没有这条规则,只有在编译器可以证明没有其他线程可以观察到差异的情况下,才能将其变成 c.a += 4
。
C++ 程序员有时会错误地认为,由于他们是为 x86 编译的,所以他们不需要 std::atomic<int>
来获得共享变量的某些顺序保证。 这是错误的,因为优化是基于语言内存模型的假设规则,而不是目标硬件。
更多技术硬件解释:
为什么 StoreLoad 重新排序有助于提高性能:
一旦存储被提交到缓存中,它就会对其他内核上的线程运行全局可见(通过缓存一致性协议)。到那时,再将其回滚为时已晚(另一个核心可能已经获得了该值的副本)。所以它不会发生,直到确定商店不会出错,之前的任何指令也不会出错。并且商店的数据已准备就绪。并且在之前的某个时间点没有分支错误预测,等等。也就是说,我们需要在退出存储指令之前排除所有错误推测的情况。
如果没有 StoreLoad 重新排序,每次加载都必须等待所有前面的存储退出(即完全执行完毕,将数据提交到缓存),然后它们才能从缓存中读取值以供以后依赖的指令使用在加载的值上。 (加载将值从缓存复制到寄存器的时刻是它对其他线程全局可见的时刻。)
由于您不知道其他内核上发生了什么,我认为硬件无法通过推测这不是问题然后在事后检测错误推测来隐藏启动加载的延迟。 (并将其视为分支错误预测:丢弃所有依赖于该负载的已完成工作,然后重新发布它。)核心可能能够允许来自处于 Exclusive or Modified 状态的缓存行的推测性早期负载,因为它们不能出现在其他核心中。 (如果该缓存行的缓存一致性请求来自另一个CPU,则检测错误推测,然后在推测加载之前退出最后一个存储。)无论如何,这显然是不需要的大量复杂性其他的。
请注意,我什至没有提到商店的缓存未命中。这将存储的延迟从几个周期增加到数百个周期。
CPU 的实际工作方式(允许 StoreLoad 重新排序时):
我在
CPUs 避免 WAR and WAW pipeline hazards for stores by buffering them in a store queue,直到存储指令准备好退出。来自同一核心的加载必须检查存储队列(以保持单个线程按顺序执行的外观,否则在加载最近可能存储的任何内容之前,您需要内存屏障指令!)。存储队列对其他线程不可见;存储只有在存储指令退出时才变得全局可见,但是加载一旦执行就会变得全局可见。 (并且可以使用提前预取到缓存中的值)。
另见
所以商店可能会乱序执行,但它们只会在商店队列中重新排序。由于指令必须退出以支持精确的异常,因此让硬件强制执行 StoreStore 排序似乎没有太大好处。
由于加载在执行时变得全局可见,因此强制执行 LoadLoad 排序可能需要在加载未命中缓存后延迟加载。当然,实际上 CPU 会推测性地执行以下加载,并在发生时检测内存顺序错误推测。这对于良好的性能几乎是必不可少的:乱序执行的很大一部分好处是继续做有用的工作,隐藏缓存未命中的延迟。
Linus 的一个论点是,弱序 CPUs 需要多线程代码使用大量内存屏障指令,因此它们需要很便宜才能让多线程代码不糟透了。这只有在您有硬件跟踪加载和存储的依赖顺序时才有可能。
但是,如果您有依赖项的硬件跟踪,您就可以让硬件始终强制执行排序,这样软件就不必 运行 那么多的障碍指令。如果你有硬件支持来降低障碍,为什么不像 x86 那样让它们隐含在每个加载/存储中。
他的另一个主要论点是内存排序很困难,并且是错误的主要来源。在硬件中一次把它做对比每个软件项目都必须做对要好。 (这个论点之所以有效,是因为它可以在没有巨大性能开销的情况下在硬件中实现。)