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

Make previous NT stores visible to subsequent memory loads in other threads

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

Perform a serializing operation on all store-to-memory instructions that were issued prior to this instruction. Guarantees that every store instruction that precedes, in program order, is globally visible before any store instruction which follows the fence in program order.

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

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

UPDATE2:在 comments/answers 之后,让我们将 "subsequent loads" 定义为随后获取当前线程当前持有的锁的线程中的负载。

But will my recent stores be visible to subsequent load instructions too?

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

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

CPUs 总是尝试尽快将存储从其存储缓冲区提交到全局可见的 cache/memory 状态。对于障碍,你所能做的就是让 this 线程在执行后续操作之前等待,直到发生这种情况。 这在具有流式存储的多线程程序中当然是必要的,看起来像这就是你真正要问的。但我认为重要的是要了解 NT 存储即使在没有同步的情况下也能很快可靠地对其他线程可见。

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


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

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


MOVNT + SFENCE 在生产者-消费者多线程或使用正常锁定的情况下非常有用,其中自旋锁的解锁只是一个释放存储。

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

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

生产者必须在写入缓冲区之后但在写入标志之前使用 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 存储的发布语义。

如果您知道 运行 使用的是什么硬件,并且您知道缓冲区 总是 大,您可能会跳过 sfence 如果你知道消费者总是从前到后读取缓冲区(按照写入的相同顺序),那么缓冲区末尾的存储可能仍然在当消费者线程到达缓冲区末尾时,CPU 运行 生产者线程的核心。


by "subsequent" I mean happening later in time.

除非您通过使用使生产者线程与消费者同步的东西来限制何时可以执行这些负载,否则无法实现这一点。如前所述,您要求 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,即使有 std::atomic 次序强于 relaxed 的操作。编译器很难在不非常保守的情况下可靠地做到这一点(例如 sfence 在每个带有 NT 存储的函数的末尾,以防调用者使用原子)。

英特尔内部函数早于 C11 stdatomic 和 C++11 std::atomicstd::atomic 的实现假装弱序商店不存在,所以你必须自己用内在函数来保护它们。

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

But will my recent stores of the current thread be visible to subsequent load instructions too (in the other threads)? Or do I have to call _mm_mfence()? (The latter seems to be slow)

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

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

在 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 的博客中阅读到的内容的粗略介绍 post Memory Ordering at Compile Time