【问题标题】:Atomic double floating point or SSE/AVX vector load/store on x86_64x86_64 上的原子双浮点或 SSE/AVX 矢量加载/存储
【发布时间】:2017-12-16 17:55:58
【问题描述】:

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

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

【问题讨论】:

  • atomic<double> is 在我的平台(GCC,x86-64)上是无锁的,可能在 MSVC++ 上也是如此。我不明白为什么您认为您的链接显示其他内容。但是,std::atomic 仅提供整数类型的算术运算,因此使用atomic<double> 您只能进行基本操作,如加载/存储/交换。
  • 没有常量,但std::atomic<double>().is_lock_free() 可以(并且确实)返回true

标签: c++ assembly vectorization x86-64 stdatomic


【解决方案1】:

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

LOCK 前缀只能添加到以下指令并且只能添加到那些形式的指令 其中目标操作数是内存操作数:ADD、ADC、AND、BTC、BTR、BTS、CMPXCHG、CMPXCH8B、 CMPXCHG16B、DEC、INC、NEG、NOT、OR、SBB、SUB、XOR、XADD 和 XCHG。

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

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

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

  • 读取或写入单个字节的指令。
  • 读取或写入地址在 2 字节边界上对齐的字(2 字节)的指令。
  • 读取或写入地址在 4 字节边界上对齐的双字(4 字节)的指令。
  • 读取或写入地址在 8 字节边界上对齐的四字(8 字节)的指令。

特别是,不能保证从/到较大 XMM 和 YMM 向量寄存器的加载/存储的原子性。

【讨论】:

  • “将浮点值的位表示加载到整数寄存器中”。我可能弄错了,但是 SSE/AVX 寄存器既不是 FP 也不是整数,而是 SSE/AVX 指令。并且 SSE/AVX 加载/存储操作是按位的,所以既不是 FP 也不是整数。
  • 有指令 cmpxchg8b, cmpxchg16b 允许 CASsing 64/128 位,从而允许对双精度/SSE 进行通用原子操作。此外,RMW 指令不一定比加载/操作/存储序列快。
  • 您不需要lock 进行加载或存储,仅用于原子 RMW。
  • @MSalters 是的,从技术上讲,寄存器不是“浮点”,而是指令。但是,我认为这在这个问题的背景下并不重要,我不确定如何在不使答案复杂化的情况下澄清这一点。
  • @PeterCordes 好点,我编辑了答案以说明哪些存储/加载保证是原子的。
【解决方案2】:

C++ 不支持无锁std::atomic<double>

实际上,C++11 std::atomic<double> 在典型的 C++ 实现上是无锁的,并且确实公开了您在 asm 中可以使用 x86 上的float/double 进行无锁编程的几乎所有事情(例如加载, store 和 CAS 足以实现任何东西:Why isn't atomic double fully implemented)。但是,当前的编译器并不总是有效地编译 atomic<double>

C++11 std::atomic 没有Intel's transactional-memory extensions (TSX) 的 API(用于 FP 或整数)。 TSX 可能会改变游戏规则,尤其是对于 FP / SIMD,因为它将消除 xmm 和整数寄存器之间弹跳数据的所有开销。如果事务没有中止,那么您刚刚对双重或向量加载/存储所做的任何事情都会以原子方式发生。

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

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


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

具有讽刺意味的是,编译器(gcc7.1、clang4.0、ICC17、MSVC CL19)在 64 位代码(或 SSE2 可用的 32 位代码)中表现不佳,并且通过整数寄存器而不是仅仅执行 @ 987654356@ 直接向/从 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 喜欢存储/重新加载整数->xmm。请参阅https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80820 和我报告的相关错误。即使对于-mtune=generic,这也是一个糟糕的选择。 AMD 在整数和向量寄存器之间对movq 有很高的延迟,但它对于存储/重新加载也有很高的延迟。使用默认的-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 之类的整数(有关详细信息,请参阅Can num++ be atomic for 'int num'?)。对于其他事情,例如 atomic&lt;struct&gt;atomic&lt;double&gt;x86 上的唯一选项是使用 cmpxchg(或 TSX)的重试循环

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

gcc 为exchange() 提供了最好的汇编:

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 操作,这可能是一个问题。对于足够新的编译器,memcmp(&amp;expected, &amp;desired, sizeof(double)) == 0 可能是在 C++ 中表达 FP 值的按位比较的好方法。只要确保避免误报;假阴性只会导致不必要的 CAS。


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

IEEE 浮点数的一些特殊情况可以通过整数运算来实现。例如atomic&lt;double&gt; 的绝对值可以用 lock and [mem], rax 完成(其中 RAX 设置了符号位以外的所有位)。或者通过将 1 与符号位进行或运算来强制浮点数/双精度数为负。或者用 XOR 切换它的符号。您甚至可以使用lock add [mem], 1 原子地将其幅度增加 1 ulp。 (但前提是您可以确定它不是从无穷大开始...nextafter() 是一个有趣的功能,这要归功于 IEEE754 的非常酷的设计,它带有偏差指数,使得从尾数到指数的进位实际上可以工作。)

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


尚不能支持原子 AVX/SSE 向量之类的东西,因为它依赖于 CPU

正确。无法通过缓存一致性系统检测 128b 或 256b 存储或加载何时是原子的。 (https://gcc.gnu.org/bugzilla/show_bug.cgi?id=70490)。即使是在 L1D 和执行单元之间进行原子传输的系统,在通过窄协议在缓存之间传输缓存线时也会在 8B 块之间撕裂。真实示例:a multi-socket Opteron K10 with HyperTransport interconnects 似乎在单个套接字中具有原子 16B 加载/存储,但不同套接字上的线程可以观察到撕裂。

但是,如果您有一个对齐的doubles 共享数组,您应该能够在它们上使用矢量加载/存储,而不会在任何给定的double 内“撕裂”。

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

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

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

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

lock cmpxchg16b相比,将矢量数据移入/移出整数regs的额外指令不是免费的,但也不昂贵。

# 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&lt;__m128d&gt; 即使对于只读或只写操作(使用cmpxchg16b)也会很慢,即使实现最佳。 atomic&lt;__m256d&gt; 甚至不能无锁。

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


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

但是,编译器没有提供任何简洁的方式来表达这一点。我破解了一个适用于 gcc/clang 的联合类型双关语:How can I implement ABA counter with c++11 CAS?。但是 gcc7 和更高版本不会内联 cmpxchg16b,因为他们正在重新考虑 16B 对象是否真的应该将自己呈现为“无锁”。 (https://gcc.gnu.org/ml/gcc-patches/2017-01/msg02344.html)。

【讨论】:

猜你喜欢
  • 1970-01-01
  • 2012-08-08
  • 2013-11-12
  • 2017-08-31
  • 1970-01-01
  • 1970-01-01
  • 2017-06-10
  • 2015-05-15
  • 1970-01-01
相关资源
最近更新 更多