Why does a std::atomic store with sequential consistency use XCHG?

mov-store + mfence and xchg are both valid ways to implement a sequential-consistency store on x86. The implicit lock prefix on an xchg with memory makes it a full memory barrier, like all atomic RMW operations on x86.

(x86’s memory-ordering rules essentially make that full-barrier effect the only option for any atomic RMW: it’s both a load and a store at the same time, stuck together in the global order. Atomicity requires that the load and store aren’t separated by just queuing the store into the store buffer so it has to be drained, and load-load ordering of the load side requires that it not reorder.)

Plain mov is not sufficient; it only has release semantics, not sequential-release. (Unlike AArch64’s stlr instruction, which does do a sequential-release store that can’t reorder with later ldar sequential-acquire loads. This choice is obviously motivated by C++11 having seq_cst as the default memory ordering. But AArch64’s normal store is much weaker; relaxed not release.)

See Jeff Preshing’s article on acquire / release semantics, and note that regular release stores (like mov or any non-locked x86 memory-destination instruction other than xchg) allows reordering with later operations, including acquire loads (like mov or any x86 memory-source operand). e.g. If the release-store is releasing a lock, it’s ok for later stuff to appear to happen inside the critical section.

There are performance differences between mfence and xchg on different CPUs, and maybe in the hot vs. cold cache and contended vs. uncontended cases. And/or for throughput of many operations back to back in the same thread vs. for one on its own, and for allowing surrounding code to overlap execution with the atomic operation.

See https://shipilev.net/blog/2014/on-the-fence-with-dependencies for actual benchmarks of mfence vs. lock addl $0, -8(%rsp) vs. (%rsp) as a full barrier (when you don’t already have a store to do).

On Intel Skylake hardware, mfence blocks out-of-order execution of independent ALU instructions, but xchg doesn’t. (See my test asm + results in the bottom of this SO answer). Intel’s manuals don’t require it to be that strong; only lfence is documented to do that. But as an implementation detail, it’s very expensive for out-of-order execution of surrounding code on Skylake.

I haven’t tested other CPUs, and this may be a result of a microcode fix for erratum SKL079, SKL079 MOVNTDQA From WC Memory May Pass Earlier MFENCE Instructions. The existence of the erratum basically proves that SKL used to be able to execute instructions after MFENCE. I wouldn’t be surprised if they fixed it by making MFENCE stronger in microcode, kind of a blunt instrument approach that significantly increases the impact on surrounding code.

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’s optimization manual recommends xchg for atomic seq-cst stores. I thought Intel recommended mov + mfence, which older gcc uses, but Intel’s compiler also uses xchg here.

When I tested, I got better throughput on Skylake for xchg than for mov+mfence in a single-threaded loop on the same location repeatedly. See Agner Fog’s microarch guide and instruction tables for some details, but he doesn’t spend much time on locked operations.

See gcc/clang/ICC/MSVC output on the Godbolt compiler explorer for a C++11 seq-cst my_atomic = 4; gcc uses mov + mfence when SSE2 is available. (use -m32 -mno-sse2 to get gcc to use xchg too). The other 3 compilers all prefer xchg with default tuning, or for znver1 (Ryzen) or skylake.

The Linux kernel uses xchg for __smp_store_mb().

Update: recent GCC (like GCC10) changed to using xchg for seq-cst stores like other compilers do, even when SSE2 for mfence is available.

Another interesting question is how to compile atomic_thread_fence(mo_seq_cst);. The obvious option is mfence, but lock or dword [rsp], 0 is another valid option (and used by gcc -m32 when MFENCE isn’t available). The bottom of the stack is usually already hot in cache in M state. The downside is introducing latency if a local was stored there. (If it’s just a return address, return-address prediction is usually very good so delaying ret‘s ability to read it is not much of a problem.) So lock or dword [rsp-4], 0 could be worth considering in some cases. (gcc did consider it, but reverted it because it makes valgrind unhappy. This was before it was known that it might be better than mfence even when mfence was available.)

All compilers currently use mfence for a stand-alone barrier when it’s available. Those are rare in C++11 code, but more research is needed on what’s actually most efficient for real multi-threaded code that has real work going on inside the threads that are communicating locklessly.

But multiple source recommend using lock add to the stack as a barrier instead of mfence, so the Linux kernel recently switched to using it for the smp_mb() implementation on x86, even when SSE2 is available.

See https://groups.google.com/d/msg/fa.linux.kernel/hNOoIZc6I9E/pVO3hB5ABAAJ for some discussion, including a mention of some errata for HSW/BDW about movntdqa loads from WC memory passing earlier locked instructions. (Opposite of Skylake, where it was mfence instead of locked instructions that were a problem. But unlike SKL, there’s no fix in microcode. This may be why Linux still uses mfence for its mb() for drivers, in case anything ever uses NT loads to copy back from video RAM or something but can’t let the reads happen until after an earlier store is visible.)

  • In Linux 4.14, smp_mb() uses mb(). That uses mfence is used if available, otherwise lock addl $0, 0(%esp).

    __smp_store_mb (store + memory barrier) uses xchg (and that doesn’t change in later kernels).

  • In Linux 4.15, smb_mb() uses lock; addl $0,-4(%esp) or %rsp, instead of using mb(). (The kernel doesn’t use a red-zone even in 64-bit, so the -4 may help avoid extra latency for local vars).

    mb() is used by drivers to order access to MMIO regions, but smp_mb() turns into a no-op when compiled for a uniprocessor system. Changing mb() is riskier because it’s harder to test (affects drivers), and CPUs have errata related to lock vs. mfence. But anyway, mb() uses mfence if available, else lock addl $0, -4(%esp). The only change is the -4.

  • In Linux 4.16, no change except removing the #if defined(CONFIG_X86_PPRO_FENCE) which defined stuff for a more weakly-ordered memory model than the x86-TSO model that modern hardware implements.

x86 & x86_64. Where a store has an implicit acquire fence

You mean release, I hope. my_atomic.store(1, std::memory_order_acquire); won’t compile, because write-only atomic operations can’t be acquire operations. See also Jeff Preshing’s article on acquire/release semantics.

Or asm volatile("" ::: "memory");

No, that’s a compiler barrier only; it prevents all compile-time reordering across it, but doesn’t prevent runtime StoreLoad reordering, i.e. the store being buffered until later, and not appearing in the global order until after a later load. (StoreLoad is the only kind of runtime reordering x86 allows.)

Anyway, another way to express what you want here is:

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

Using a release fence would not be strong enough (it and the release-store could both be delayed past a later load, which is the same thing as saying that release fences don’t keep later loads from happening early). A release-acquire fence would do the trick, though, keeping later loads from happening early and not itself being able to reorder with the release store.

Related: Jeff Preshing’s article on fences being different from release operations.

But note that seq-cst is special according to C++11 rules: only seq-cst operations are guaranteed to have a single global / total order which all threads agree on seeing. So emulating them with weaker order + fences might not be exactly equivalent in general on the C++ abstract machine, even if it is on x86. (On x86, all store have a single total order which all cores agree on. See also Globally Invisible load instructions: Loads can take their data from the store buffer, so we can’t really say that there’s a total order for loads + stores.)

Leave a Comment