x86_64 上的原子双浮点数或 SSE/AVX 向量 load/store

Atomic double floating point or SSE/AVX vector load/store on x86_64

Here(以及一些 SO 问题)我看到 C++ 不支持无锁 std::atomic<double> 并且还不能支持原子 AVX/SSE向量,因为它是 CPU 依赖的(尽管现在我知道 CPUs,ARM、AArch64 和 x86_64 有向量)。

但是 double 上的原子操作或 x86_64 中的向量是否有汇编级支持?如果支持,支持哪些操作(例如加载、存储、加、减、乘)? MSVC++2017中哪些操作实现了无锁atomic<double>?

在 x86-64 上,原子操作是通过 LOCK 前缀实现的。 Intel Software Developer's Manual (Volume 2, Instruction Set Reference) 状态

The LOCK prefix can be prepended only to the following instructions and only to those forms of the instructions where the destination operand is a memory operand: ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, CMPXCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG.

这些指令都不对浮点寄存器(如 XMM、YMM 或 FPU 寄存器)进行操作。

这意味着在 x86-64 上没有自然的方式来实现原子 float/double 操作。虽然大多数这些操作可以通过将浮点值的位表示加载到通用(即整数)寄存器来实现,但这样做会严重降低性能,因此编译器作者选择不实现它。

正如 Peter Cordes 在评论中指出的那样,加载和存储不需要 LOCK 前缀,因为它们在 x86-64 上始终是原子的。然而,英特尔 SDM(第 3 卷,系统编程指南)仅保证以下 loads/stores 是原子的:

  • Instructions that read or write a single byte.
  • Instructions that read or write a word (2 bytes) whose address is aligned on a 2 byte boundary.
  • Instructions that read or write a doubleword (4 bytes) whose address is aligned on a 4 byte boundary.
  • Instructions that read or write a quadword (8 bytes) whose address is aligned on an 8 byte boundary.

特别是 loads/stores from/to 较大的 XMM 和 YMM 向量寄存器的原子性无法保证。

C++ doesn't support something like lock-free std::atomic<double>

实际上,C++11 std::atomic<double> 在典型的 C++ 实现上是无锁的,并且确实公开了您在 asm 中可以使用 float/[=17 进行无锁编程的几乎所有内容=] 在 x86 上(例如加载、存储和 CAS 足以实现任何东西:)。不过,当前的编译器并不总是能高效地编译 atomic<double>

C++11 std::atomic 没有 API 用于 Intel's transactional-memory extensions (TSX)(对于 FP 或整数)。 TSX 可能会改变游戏规则,尤其是对于 FP / SIMD,因为它会消除 xmm 和整数寄存器之间反弹数据的所有开销。如果交易没有中止,无论你刚刚用 double 或 vector loads/stores 做什么,都会自动发生。

一些非x86硬件支持float/double的原子添加,C++ p0020建议添加fetch_addoperator+=/-=模板C++ std::atomic<float> / <double> 的特化。

具有 LL/SC 原子而不是 x86 样式内存目标指令的硬件,例如 ARM 和大多数其他 RISC CPU,可以在 doublefloat 上执行原子 RMW 操作而无需一个 CAS,但你仍然需要从 FP 获取数据到整数寄存器,因为 LL/SC 通常只适用于整数寄存器,比如 x86 的 cmpxchg。但是,如果硬件将 LL/SC 对仲裁为 avoid/reduce 活锁,在非常高的争用情况下,它会比使用 CAS 循环更有效。如果您设计的算法很少发生争用,那么 fetch_add 的 LL/add/SC 重试循环与 load + add + LL/SC CAS 之间的代码大小可能只有很小的差异重试循环。


。 (例如 movsd xmm0, [some_variable] 是原子的,即使在 32 位模式下也是如此)。事实上,gcc 使用 x87 fild/fistp 或 SSE 8B loads/stores 来实现 std::atomic<int64_t> 加载和存储在 32 位代码中。

具有讽刺意味的是,编译器(gcc7.1、clang4.0、ICC17、MSVC CL19)在 64 位代码(或 SSE2 可用的 32 位代码)中做得不好,并且通过整数寄存器而不是仅仅通过整数寄存器来反弹数据直接 movsd loads/stores to/from xmm regs (see it on Godbolt):

#include <atomic>
std::atomic<double> ad;

void store(double x){
    ad.store(x, std::memory_order_release);
}
//  gcc7.1 -O3 -mtune=intel:
//    movq    rax, xmm0               # ALU xmm->integer
//    mov     QWORD PTR ad[rip], rax
//    ret

double load(){
    return ad.load(std::memory_order_acquire);
}
//    mov     rax, QWORD PTR ad[rip]
//    movq    xmm0, rax
//    ret

没有 -mtune=intel,gcc 喜欢 store/reload for integer->xmm。请参阅 https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80820 和我报告的相关错误。即使对于 -mtune=generic,这也是一个糟糕的选择。 AMD 在整数和向量 reg 之间的 movq 具有高延迟,但对于 store/reload 也具有高延迟。默认 -mtune=genericload() 编译为:

//    mov     rax, QWORD PTR ad[rip]
//    mov     QWORD PTR [rsp-8], rax   # store/reload integer->xmm
//    movsd   xmm0, QWORD PTR [rsp-8]
//    ret

在 xmm 和整数寄存器之间移动数据将我们带到下一个主题:


原子读取-修改-写入(如 fetch_add)是另一回事:直接支持整数 lock xadd [mem], eax(见 了解更多详情)。对于其他事情,例如 atomic<struct>atomic<double>x86 上的唯一选项是 cmpxchg(或 TSX).[=93 的重试循环=]

Atomic compare-and-swap (CAS) 可用作任何原子 RMW 操作的无锁构建块,最大硬件支持的 CAS 宽度。在 x86-64 上,这是 16 字节,cmpxchg16b(某些第一代 AMD K8 不可用,因此对于 gcc,您必须使用 -mcx16-march=whatever 启用它)。

gcc 为 exchange():

提供了最好的 asm
double exchange(double x) {
    return ad.exchange(x); // seq_cst
}
    movq    rax, xmm0
    xchg    rax, QWORD PTR ad[rip]
    movq    xmm0, rax
    ret
  // in 32-bit code, compiles to a cmpxchg8b retry loop


void atomic_add1() {
    // ad += 1.0;           // not supported
    // ad.fetch_or(-0.0);   // not supported
    // have to implement the CAS loop ourselves:

    double desired, expected = ad.load(std::memory_order_relaxed);
    do {
        desired = expected + 1.0;
    } while( !ad.compare_exchange_weak(expected, desired) );  // seq_cst
}

    mov     rax, QWORD PTR ad[rip]
    movsd   xmm1, QWORD PTR .LC0[rip]
    mov     QWORD PTR [rsp-8], rax    # useless store
    movq    xmm0, rax
    mov     rax, QWORD PTR [rsp-8]    # and reload
.L8:
    addsd   xmm0, xmm1
    movq    rdx, xmm0
    lock cmpxchg    QWORD PTR ad[rip], rdx
    je      .L5
    mov     QWORD PTR [rsp-8], rax
    movsd   xmm0, QWORD PTR [rsp-8]
    jmp     .L8
.L5:
    ret

compare_exchange 总是进行按位比较,因此您无需担心负零 (-0.0) 在 IEEE 语义中比较等于 +0.0,或者NaN 是无序的。不过,如果您尝试检查 desired == expected 并跳过 CAS 操作,这可能会成为一个问题。对于足够新的编译器, 可能是一种在 C++ 中表达 FP 值按位比较的好方法。只要确保避免误报即可;假阴性只会导致不需要的 CAS。


硬件仲裁 lock or [mem], 1 绝对比让多个线程在 lock cmpxchg 重试循环上旋转要好。每次核心访问高速缓存行但失败时,与整数内存目标操作相比,它的 cmpxchg 是浪费的吞吐量,一旦他们接触到高速缓存行,整数内存目标操作总是成功。

IEEE 浮点数的一些特殊情况可以用整数运算来实现。例如atomic<double> 的绝对值可以用 lock and [mem], rax 完成(其中 RAX 设置了除符号位之外的所有位)。或者通过将 1 与符号位进行或运算来强制 float / double 为负数。或者用 XOR 切换它的符号。您甚至可以使用 lock add [mem], 1 以原子方式将其幅度增加 1 ulp。 (但前提是您可以确定它不是无穷大... nextafter() 是一个有趣的函数,这要归功于 IEEE754 的非常酷的设计,它具有使从尾数到指数的进位真正起作用的偏置指数。 )

可能无法在 C++ 中表达这一点,让编译器在使用 IEEE FP 的目标上为您完成。因此,如果您想要它,您可能必须自己对 atomic<uint64_t> 或其他内容进行类型双关,并检查 FP 字节序是否与整数字节序相匹配,等等(或者只对 x86 执行此操作。大多数其他目标有 LL/SC 而不是内存目标锁定操作。)


can't yet support something like atomic AVX/SSE vector because it's CPU-dependent

正确。无法通过高速缓存一致性系统检测 128b 或 256b 存储或加载何时是原子的。 (https://gcc.gnu.org/bugzilla/show_bug.cgi?id=70490). Even a system with atomic transfers between L1D and execution units can get tearing between 8B chunks when transferring cache-lines between caches over a narrow protocol. Real example: a multi-socket Opteron K10 with HyperTransport interconnects 似乎在单个套接字内具有原子 16B loads/stores,但不同套接字上的线程可以观察到撕裂。

但是如果你有一个对齐的 double 的共享数组,你应该能够在它们上使用向量 loads/stores 而不会在任何给定的 [=17= 中出现 "tearing" 的风险].

Per-element atomicity of vector load/store and gather/scatter?

我认为可以安全地假设对齐的 32B load/store 是用非重叠的 8B 或更宽的 loads/stores 完成的,尽管英特尔不保证这一点。对于未对齐的操作,假设任何事情可能都不安全。

如果您需要 16B 原子负载,您唯一的选择是 lock cmpxchg16b,以及 desired=expected。如果成功,它将用自身替换现有值。如果失败,那么您将获得旧内容。 (极端情况:这个 "load" 在只读内存上出错,所以要小心你传递给执行此操作的函数的指针。)此外,与实际的只读加载相比,性能当然是可怕的将缓存行保留在共享状态,这不是完整的内存屏障。

16B 原子存储和 RMW 都可以使用 lock cmpxchg16b 显而易见的方式。这使得纯存储比常规向量存储昂贵得多,特别是如果 cmpxchg16b 必须重试多次,但原子 RMW 已经很昂贵了。

移动矢量数据的额外指令 to/from 整数 reg 不是免费的,但与 lock cmpxchg16b 相比也不昂贵。

# xmm0 -> rdx:rax, using SSE4
movq   rax, xmm0
pextrq rdx, xmm0, 1


# rdx:rax -> xmm0, again using SSE4
movq   xmm0, rax
pinsrq xmm0, rdx, 1

在 C++11 术语中:

atomic<__m128d> 即使是只读或只写操作(使用 cmpxchg16b)也会很慢,即使实现得最好。 atomic<__m256d> 甚至不能是无锁的。

alignas(64) atomic<double> shared_buffer[1024]; 理论上仍然允许对读取或写入它的代码进行自动矢量化,只需要 movq rax, xmm0 然后 xchgcmpxchg 原子 RMW在 double 上。 (在 32 位模式下,cmpxchg8b 可以工作。)不过,您几乎可以肯定 not 从编译器中获得好的 asm!


您可以自动更新一个 16B 对象,但是单独读取 8B 的一半。 (我 认为 这对于 x86 上的内存排序是安全的:请参阅我在 https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80835 的推理)。

但是,编译器没有提供任何简洁的方式来表达这一点。我破解了一个适用于 gcc/clang 的联合类型双关语:. But gcc7 and later won't inline cmpxchg16b, because they're re-considering whether 16B objects should really present themselves as "lock-free". (https://gcc.gnu.org/ml/gcc-patches/2017-01/msg02344.html).