使以前的 NT 存储对其他线程中的后续内存加载可见

Posted

技术标签:

【中文标题】使以前的 NT 存储对其他线程中的后续内存加载可见【英文标题】:Make previous NT stores visible to subsequent memory loads in other threads 【发布时间】:2017-12-05 10:49:36 【问题描述】:

我想将数据存储在一个大数组中,并在循环中调用_mm256_stream_si256()。 据我了解,然后需要一个内存栅栏来使这些更改对其他线程可见。 _mm_sfence()的描述说

对所有存储到内存的指令执行序列化操作 在本指令之前发布的。保证每 按程序顺序在前面的存储指令是全局可见的 在按程序顺序跟随栅栏的任何 store 指令之前。

但是我最近存储的当前线程是否也对后续的 load 指令可见(在其他线程中)?还是我必须打电话给_mm_mfence()? (后者好像很慢)

更新:我之前看到过这个问题:when should I use _mm_sfence _mm_lfence and _mm_mfence。那里的答案通常集中在何时使用围栏。我的问题更具体,该问题的答案不太可能解决这个问题(目前不这样做)。

UPDATE2:在 cmets/answers 之后,让我们将“后续加载”定义为线程中的负载,该线程随后获取当前线程当前持有的锁。

【问题讨论】:

when should I use _mm_sfence _mm_lfence and _mm_mfence的可能重复 访问最近存储的数据破坏了_mm256_stream_si256 的全部目的,即当你知道你不会访问最近存储的数据时绕过缓存写入内存。 @VTT,通常不会立即访问。但这可能偶尔会发生,我希望程序在这种情况下是正确的。 “后续”是指稍后发生。 除非您通过使用同步生产者的东西来限制执行这些负载的时间,否则无法做到这一点线程与消费者。按照措辞,您要求sfence 使NT 存储在它执行的瞬间全局可见,以便在sfence 之后执行1 个时钟周期的其他内核上的加载将看到这些存储。 “后续”的合理定义是“在下一个获取该线程当前持有的锁的线程中”。 @PeterCordes,我现在似乎清楚了,谢谢! 【参考方案1】:

但是后续的加载指令也能看到我最近的商店吗?

这句话意义不大。加载是任何线程可以看到内存内容的唯一方式。不知道你为什么说“太”,因为没有别的了。 (非 CPU 系统设备的 DMA 读取除外。)

存储变得全局可见的定义是任何其他线程中的加载都会从中获取数据。这意味着存储已离开 CPU 的私有存储缓冲区,并且是包括所有 CPU 的数据缓存的一致性域。 (https://en.wikipedia.org/wiki/Cache_coherence)。

CPU 总是尝试尽快将存储从其存储缓冲区提交到全局可见的缓存/内存状态。您可以对障碍做的所有事情就是让这个线程等到发生这种情况后再进行以后的操作。这在具有流式存储的多线程程序中肯定是必要的,看起来这就是你所要做的。重新实际询问。但我认为重要的是要了解 NT 存储即使在没有同步的情况下也会很快可靠地对其他线程可见。

x86 上的互斥解锁有时是lock add,在这种情况下,这已经是 NT 商店的完整围栏。但是,如果您不能排除使用简单的 mov 存储的互斥锁实现,那么在 NT 存储之后、解锁之前的某个时间点,您至少需要 sfence


普通 x86 商店有 release memory-ordering semantics (C++11 std::memory_order_release)。 MOVNT 流存储有宽松的排序,但互斥/自旋锁函数,以及对 C++11 std::atomic 的编译器支持,基本上忽略了它们。 对于多线程代码,您必须自己隔离它们以避免破坏互斥锁/锁定库函数的同步行为,因为它们只同步正常的 x86 强排序加载和存储。

执行存储的线程中的加载仍将始终看到最近存储的值,即使来自movnt 存储。在单线程程序中永远不需要栅栏。乱序执行和内存重新排序的基本规则是它永远不会打破在单个线程中按程序顺序运行的错觉。编译时重新排序也是如此:由于对共享数据的并发读/写访问是 C++ 未定义行为,编译器只需保留单线程行为,除非您使用栅栏来限制编译时重新排序。


MOVNT + SFENCE 在生产者-消费者多线程等情况下很有用,或者在普通锁定的情况下,自旋锁的解锁只是一个释放存储。

生产者线程使用流存储写入一个大缓冲区,然后将“true”(或缓冲区的地址,或其他)存储到共享标志变量中。 (Jeff Preshing calls this a payload + guard variable)。

消费者线程在该同步变量上旋转,并在看到它变为 true 后开始读取缓冲区。

生产者必须在写入缓冲区之后,但在写入标志之前使用 sfence,以确保缓冲区中的所有存储在标志之前都是全局可见的。 (但请记住,NT 存储始终本地对当前线程立即可见。)

(使用锁定库函数,存储的标志是锁。其他试图获取锁的线程正在使用获取加载。)

std::atomic <bool> buffer_ready;

producer() 
    for(...) 
        _mm256_stream_si256(buffer);
    
    _mm_sfence();

    buffer_ready.store(true, std::memory_order_release);

asm 类似于

 vmovntdq  [buf], ymm0
 ...
 sfence
 mov  byte [buffer_ready], 1

如果没有sfence,一些movnt 存储可能会延迟到标志存储之后,这违反了正常非NT 存储的发布语义。

如果您知道自己在运行什么硬件,并且知道缓冲区总是很大,那么如果您知道消费者总是从从前到后(以与写入相同的顺序),因此到缓冲区末尾的存储可能无法在运行生产者线程的 CPU 核心中的存储缓冲区中仍在进行中消费者线程到达缓冲区的末尾。


(in comments) “后续”是指发生在以后的时间。

除非您通过使用使生产者线程与消费者线程同步的东西来限制执行这些负载的时间,否则无法实现这一点。按照措辞,您要求sfence 使NT 存储在它执行的瞬间全局可见,以便在sfence 之后执行1 个时钟周期的其他内核上的加载将看到这些存储。 “后续”的合理定义是“在下一个线程中获取该线程当前持有的锁”。


sfence 更强的栅栏也可以工作

x86 上的任何原子读取-修改-写入操作都需要一个 lock 前缀,这是一个完整的内存屏障(如 mfence)。

因此,例如,如果您在流式存储之后增加一个原子计数器,您就不需要sfence。不幸的是,在 C++ 中,std:atomic_mm_sfence() 彼此不了解,并且允许编译器按照 as-if 规则优化原子。因此,很难确定 locked RMW 指令会在生成的 asm 中准确出现在您需要的位置。

(基本上,if a certain ordering is possible in the C++ abstract machine, the compiler can emit asm that makes it always happen that way。例如,将两个连续的增量合并为一个 +=2,这样就没有线程可以观察到计数器是奇数。)

尽管如此,默认的 mo_seq_cst 可以防止大量编译时重新排序,并且当您仅针对 x86 时,将其用于读取-修改-写入操作并没有太大的缺点。不过,sfence 相当便宜,因此在一些流媒体商店和locked 操作之间尝试避免它可能不值得。

相关:pthreads v. SSE weak memory ordering。这个问题的提问者认为解锁锁总是会执行locked 操作,从而使sfence 变得多余。


C++ 编译器不会尝试在流式存储之后为您插入sfence,即使存在排序强于relaxedstd::atomic 操作。如果不非常保守,编译器很难可靠地做到这一点(例如,sfence 在每个带有 NT 存储的函数末尾,以防调用者使用原子)。

Intel 内在函数早于 C11 stdatomic 和 C++11 std::atomicstd::atomic 的实现假装弱序商店不存在,所以你必须自己用内在函数来围起来。

这似乎是一个不错的设计选择,因为您只想在特殊情况下使用 movnt 存储,因为它们的缓存驱逐行为。您不希望编译器在不需要的地方插入sfence,或者将movnti 用于std::memory_order_relaxed

【讨论】:

【参考方案2】:

但是我最近存储的当前线程是否可见 随后的加载指令也(在其他线程中)?还是我有 调用_mm_mfence()? (后者好像很慢)

答案是否定的。不保证在一个线程中看到以前的存储而不在其他线程中进行任何同步尝试。这是为什么?

    您的编译器可以重新排序指令 您的处理器可以重新排序指令(在某些平台上)

在 C++ 中,编译器需要发出顺序一致的代码,但仅适用于单线程执行。所以考虑下面的代码:

int x = 5;
int y = 7;
int z = x;

在这个程序中,编译器可以选择将x = 5放在y = 7之后,但不要放在后面,否则会不一致。 如果您然后考虑在其他线程中使用以下代码

int a = y;
int b = x;

同样的指令重新排序可以在这里发生,因为 a 和 b 是相互独立的。运行这些线程会产生什么结果?

a    b
7    5
7    ? - whatever was stored in x before the assignment of 5
...

即使我们在x = 5y = 7 之间设置内存屏障,我们也可以获得这个结果,因为在a = yb = x 之间也没有设置屏障,你永远不知道它们会以什么顺序被读取。

这只是您可以在 Jeff Preshing 的博文 Memory Ordering at Compile Time 中阅读的内容的粗略介绍

【讨论】:

在这个程序中,编译器可以选择将 x = 5 放在 y = 7 之后,但不要放在后面,因为它会不一致。 不,只要编译器的 asm 输出加载x 的旧值在 x=5 存储之前,它可以延迟 x=5 存储只要它想要(例如,将其从循环中删除并将 x 的值保存在寄存器中(或作为像mov dword [x],5这样的直接操作数,如果它真的是一个编译时常量),在返回之前只存储x的最终值)。 需要发出顺序一致的代码(用于单线程执行) 不是描述事物的好方法。函数返回时内存中的值必须与源代码所说的相匹配。 (在内联和过程间优化之后,例如优化掉地址不会逃逸编译单元的static 变量)。实现该结果的 asm 不必与 C++ 源代码的执行顺序有任何相似之处。 例如即使源说列优先,循环反转优化也可以按行优先顺序写入数组。编译器必须证明这是安全的(例如,任何可能具有指向相关内存的指针的非内联函数调用都必须看到正确的值,并且不改变函数本身的结果),但是循环反转是一些编译器如何“击败”SPECint 或 SPECfp 中的一些基准(我忘记了),使它们变得微不足道且毫无意义。 还要注意 x = 5; 是一个 C++ 赋值。它是否编译为函数中的 asm 存储指令 anywhere 取决于周围的代码。具有自动存储功能的局部变量通常可以保留在寄存器中,或者完全被优化掉。 你错了,编译器不能把int x = 5;放在int z = x;之后。它不会是一致的。关于您的其余评论 - 顺序一致性 [Leslie Lamport, 1979],任何执行的结果都与 1. 所有线程的操作以某种顺序执行 2. 每个线程的操作都按此顺序出现相同按照他们的程序指定的顺序。 - - 所以对于单线程,只要您保持与原始代码的一致性,您就可以重新排序。更详细的信息可以在 c++ 标准的 §1.10 中找到。

以上是关于使以前的 NT 存储对其他线程中的后续内存加载可见的主要内容,如果未能解决你的问题,请参考以下文章

细说Java多线程之内存可见性:学习笔记

java中volatilesynchronized

慕课网学习笔记Java共享变量的可见性和原子性

可见性原子性有序性

java线程-java多线程之可见性

Java 同步和内存可见性。 10 对其他线程可见吗?