为什么带有顺序一致性的std::atomic存储使用XCHG?

8
为什么 std::atomicstore
std::atomic<int> my_atomic;
my_atomic.store(1, std::memory_order_seq_cst);

当请求具有顺序一致性的存储时,执行xchg操作?


从技术角度来说,一个普通的存储器屏障与读写功能是否已足够呢?与以下代码等效:

_ReadWriteBarrier(); // Or `asm volatile("" ::: "memory");` for gcc/clang
my_atomic.store(1, std::memory_order_acquire);

我明确地谈论x86和x86_64。其中一个存储具有隐式获取栅。

@DanielLangr _ReadWriteBarrier()asm volatile("" ::: "memory")都是编译器栅栏,不会转换为任何栅栏指令。 - Leandros
@DanielLangr 这不是关于围栏的问题,而是关于为什么整个操作被实现为 xchg 而不是简单的 mov(假设目标正确对齐时也是原子性的)。 - Leandros
当一个存储器有隐式获取栅栏时,你需要同时使用释放和获取栅栏来实现顺序一致性。编译器屏障只能防止在编译器级别上重新排序,而不能在 CPU 级别上进行。 - Daniel Langr
1个回答

22
mov-store + mfencexchg都是在x86上实现顺序一致存储的有效方法。 在使用内存的xchg前缀时,隐含的lock使其成为完全内存屏障,就像在x86上的所有原子RMW操作一样。 (x86的内存排序规则基本上使得任何原子RMW的完全屏障效应成为唯一的选择:它既是一个加载又是一个存储,同时在全局顺序中粘合在一起。原子性要求不仅要将存储器排队到存储器缓冲区中以便被清除,而且还要保证在加载方面的加载排序不能重新排序。)

普通的mov指令不足以满足要求;它只有释放语义,没有顺序-释放语义。(与AArch64的stlr指令不同,后者确实执行了一个顺序-释放存储,不能和后来的ldar顺序获取加载重排。这个选择显然是受C++11默认内存排序seq_cst的影响。但AArch64的普通存储更弱;是弛弓而非释放。)

请参阅Jeff Preshing关于获取/释放语义的文章,注意常规的释放存储(例如mov或任何非锁定的x86内存目标指令,除了xchg之外)允许重新安排后续操作,包括获取加载(例如mov或任何x86内存源操作数)。例如,如果释放存储正在释放锁,则后续操作似乎可以出现在临界区内。


不同的CPU上,mfencexchg之间存在性能差异,在热和冷缓存、争用和非争用情况下可能会有所不同。此外,对于相同线程中连续执行多个操作的吞吐量以及允许周围代码与原子操作重叠执行的情况,mfencexchg也会有所不同。

请参见https://shipilev.net/blog/2014/on-the-fence-with-dependencies,了解mfencelock addl $0, -8(%rsp)(%rsp)之间的实际基准测试结果(当你没有存储要执行时,它们可作为完整的屏障使用)。

在Intel Skylake硬件上,mfence可以阻止独立的ALU指令乱序执行,但xchg不能。(请参见我在此SO答案底部的测试汇编代码和结果)。Intel的手册并不要求这么强,只有lfence被记录为能够实现这一点。但作为一个实现细节,在Skylake上,它对周围代码的乱序执行非常昂贵。
我没有测试其他CPU,而这可能是针对erratum SKL079的微代码修复的结果,SKL079 MOVNTDQA从WC内存中传递的指令可能会在MFENCE指令之前执行。该失效的存在基本上证明了SKL曾经能够在MFENCE之后执行指令。如果他们通过微代码使MFENCE更加强大,那么我不会感到惊讶,这种方法有点粗糙,但显著增加了周围代码的影响。
I've only tested the single-threaded case where the cache line is hot in L1d cache. (Not when it's cold in memory, or when it's in Modified state on another core.) xchg has to load the previous value, creating a "false" dependency on the old value that was in memory. But mfence forces the CPU to wait until previous stores commit to L1d, which also requires the cache line to arrive (and be in M state). So they're probably about equal in that respect, but Intel's mfence forces everything to wait, not just loads.
AMD的优化手册推荐使用xchg进行原子顺序存储。我认为Intel推荐使用mov+mfence,旧版gcc使用这种方式,但是Intel的编译器也在此处使用xchg
当我测试时,我在同一位置反复进行单线程循环时,Skylake的xchg的吞吐量比mov+mfence更好。请参阅Agner Fog的微体系结构指南和指令表获取一些详细信息,但他没有花费太多时间在锁定操作上。

请参考 Godbolt编译器探索器上的gcc/clang/ICC/MSVC输出,了解C++11 seq-cst my_atomic = 4; 当SSE2可用时,gcc使用mov+mfence。(使用-m32 -mno-sse2可以让gcc也使用xchg。)另外三个编译器都更喜欢默认调优下使用xchg,或者对于znver1(Ryzen)或skylake

Linux内核在__smp_store_mb()中使用xchg。
更新:最近的GCC(如GCC10)更改为像其他编译器一样对seq-cst存储使用xchg,即使SSE2可用于mfence。
另一个有趣的问题是如何编译atomic_thread_fence(mo_seq_cst);。显然的选择是mfence,但lock or dword [rsp], 0也是另一种有效的选择(当MFENCE不可用时,gcc -m32使用它)。堆栈底部通常已经在M状态下热缓存。缺点是如果本地存储了一个局部变量,则会引入延迟。(如果只是返回地址,则返回地址预测通常非常好,因此延迟ret读取它的能力并不是什么大问题。)因此,在某些情况下,lock or dword [rsp-4], 0可能值得考虑。(gcc曾考虑过它,但撤销了它,因为它使valgrind感到不满意。这是在知道即使在mfence可用时,它可能比mfence更好之前。)

所有编译器目前在可用时都使用mfence作为独立屏障。虽然C++11代码中很少出现,但需要进一步研究实际多线程代码中哪种方法最有效,因为这些线程之间存在实际工作且无锁通信。

但是多个来源推荐将lock add用作栈的屏障,而不是mfence,因此即使SSE2可用,Linux内核最近也切换到在x86上使用它来实现smp_mb()

请参阅https://groups.google.com/d/msg/fa.linux.kernel/hNOoIZc6I9E/pVO3hB5ABAAJ,其中包括有关HSW / BDW中一些勘误的讨论,涉及从WC内存加载movntdqa通过早期lock指令的情况。这与Skylake相反,后者mfence而不是lock指令存在问题。但与SKL不同的是,微码中没有修复程序。这可能是Linux仍然为其驱动程序的mb()使用mfence的原因,在某些情况下,如果任何内容使用NT加载从视频RAM或其他位置复制回来但不能在早期存储可见之后立即进行读取,则不能允许读取发生。
  • 在Linux 4.14中, smp_mb()使用mb()。如果可用,它使用mfence,否则使用lock addl $0, 0(%esp)

    __smp_store_mb(存储+内存屏障)使用xchg(在后续的内核中也不会更改)。

  • 在Linux 4.15中, smb_mb() 使用lock; addl $0,-4(%esp)%rsp,而不是使用mb()。(即使在64位系统中,内核也不使用red-zone,因此-4可能有助于避免本地变量的额外延迟)。

    驱动程序使用mb()来排序访问MMIO区域,但是当编译为单处理器系统时,smp_mb()会变成无操作。更改mb()更加危险,因为它很难测试(影响驱动程序),而且CPU存在与锁定vs.mfence相关的勘误。但是,mb()如果可用,使用mfence,否则使用lock addl $0, -4(%esp)。唯一的变化是-4

  • 在Linux 4.16中, 只删除#if defined(CONFIG_X86_PPRO_FENCE),该指令为比x86-TSO模型更弱的内存模型定义了一些内容,而现代硬件实现了x86-TSO模型。


x86和x86_64。当一个存储器有隐式获取栅栏时,您的意思是“释放”,我希望如此。因为只写原子操作不能是获取操作,所以my_atomic.store(1, std::memory_order_acquire);将无法编译。请参见Jeff Preshing关于获取/释放语义的文章
或者使用asm volatile("" ::: "memory"); 不,那只是一个编译器屏障;它防止所有编译时重排序穿过它,但不能防止运行时StoreLoad重排序,即将存储缓冲到后面,直到后面的加载之后才出现在全局顺序中。(StoreLoad是x86允许的唯一类型的运行时重排序。)
无论如何,在这里表达您想要的另一种方法是:
my_atomic.store(1, std::memory_order_release);        // mov
// with no operations in between, there's nothing for the release-store to be delayed past
std::atomic_thread_fence(std::memory_order_seq_cst);  // mfence

使用一个release fence是不够强大的(它和release-store都可能被延迟到稍后的load之后,这就意味着release fences无法防止稍后的loads提前发生)。但是,一个release-acquire fence可以解决这个问题,它可以防止稍后的loads提前发生,并且自身不能与release store重新排序。
相关:Jeff Preshing关于fences与release操作的文章
但请注意,根据C++11规则,seq-cst是特殊的:只有seq-cst操作保证具有所有线程都同意看到的单一全局/总序。因此,在C++抽象机上使用更弱的顺序+障碍物来模拟它们可能并不完全等价,即使在x86上也是如此。(在x86上,所有存储都具有所有核心都同意的单一总序。另请参见全局不可见加载指令:加载可以从存储缓冲区中获取其数据,因此我们无法真正说有一个总序用于加载+存储。)

谢谢您详尽的回答,这解决了我的所有疑惑!我读了Jeff Preshing的优秀文章,但还有一个问题困扰着我,我把隐式排序搞混了。 - Leandros

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接