x86_64平台上的原子双精度浮点数或SSE/AVX向量加载/存储

6

这里(以及一些SO问题)提到,C++不支持类似于无锁的std::atomic<double>,并且不能支持原子AVX/SSE向量,因为它是CPU相关的(尽管我所知道的CPU中,ARM、AArch64和x86_64都有向量)。

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


在我的平台上(GCC,x86-64),atomic<double>是无锁的,很可能在MSVC++上也是如此。我不明白为什么你认为你的链接显示了相反的情况。然而,std::atomic只提供整数类型的算术操作,因此对于atomic<double>,您只能执行基本操作,如加载/存储/交换。 - interjay
@interjay,在那个链接中没有 double 类型的常量,例如 ATOMIC_POINTER_LOCK_FREEATOMIC_LLONG_LOCK_FREE 等。 - Serge Rogatch
2
没有常量,但是 std::atomic<double>().is_lock_free() 可以(并且确实)返回 true - interjay
2个回答

12
C++不支持类似于无锁的std::atomic这样的东西。
实际上,在典型的C++实现中,C++11的std::atomic是无锁的,并且几乎可以暴露出你可以在x86上使用汇编进行无锁编程的所有功能(例如,加载、存储和CAS足以实现任何功能:为什么没有完全实现原子双精度)。然而,目前的编译器并不总是高效地编译atomic。
C++11的std::atomic没有Intel的事务内存扩展(TSX)的API(用于FP或整数)。TSX可能会成为一个改变游戏规则的因素,特别是对于FP / SIMD,因为它将消除在xmm和整数寄存器之间传递数据的所有开销。如果事务不中止,刚刚对双精度或向量加载/存储所做的任何操作都是原子的。
一些非x86硬件支持对浮点数/双精度数进行原子加法操作,而C++的p0020提案旨在为C++的std::atomic<float> / <double>添加fetch_addoperator+= / -=模板特化。
硬件使用LL/SC原子操作而不是x86风格的内存目标指令,例如ARM和大多数其他RISC CPU,可以在doublefloat上执行原子RMW操作,而无需CAS。但是,您仍然需要将数据从FP传输到整数寄存器,因为LL/SC通常仅适用于整数寄存器,就像x86的cmpxchg一样。然而,如果硬件通过仲裁LL/SC对来避免/减少活锁,那么在高争用情况下,它将比使用CAS循环更高效。如果您的算法设计得很少发生争用,那么在fetch_add和load + add + LL/SC CAS重试循环之间可能只有很小的代码大小差异。

x86自然对齐的加载和存储在8字节范围内是原子的,即使是x87或SSE。 (例如movsd xmm0,[some_variable]是原子的,即使在32位模式下)。 实际上,gcc使用x87 fild / fistp或SSE 8B加载/存储来实现32位代码中的std::atomic<int64_t>加载和存储。

具有讽刺意味的是,编译器(gcc7.1、clang4.0、ICC17、MSVC CL19)在64位代码(或具有SSE2的32位代码)中表现糟糕,并通过整数寄存器来传递数据,而不是直接执行movsd指令将数据加载/存储到xmm寄存器中(在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<struct>atomic<double>x86上唯一的选择是使用cmpxchg(或TSX)的重试循环

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

对于exchange(),gcc生成了最佳的汇编代码:

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总是进行位比较,所以你不需要担心IEEE语义中负零(-0.0)与正零(+0.0)相等的问题,或者NaN的无序性。然而,如果你尝试检查desired == expected并跳过CAS操作,这可能会成为一个问题。对于足够新的编译器来说,memcmp(&expected, &desired, sizeof(double)) == 0可能是在C++中表达FP值的位比较的好方法。只要确保避免误判;误判只会导致不必要的CAS操作。


硬件仲裁的锁定或[内存],1绝对比多个线程在锁定cmpxchg重试循环上自旋要好。每当一个核心获得对缓存行的访问但在cmpxchg操作中失败时,与总是在获取缓存行后成功的整数内存目标操作相比,吞吐量会浪费。

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

可能没有办法在C++中表达这个,让编译器在使用IEEE FP的目标上为您完成。所以如果您想要它,您可能需要自己使用类型转换到atomic<uint64_t>或其他方式,并检查FP字节序与整数字节序是否匹配等等。(或者只在x86上执行。大多数其他目标都使用LL/SC而不是内存目标锁定操作。)


目前还无法支持像原子AVX/SSE向量这样的功能,因为它取决于CPU。
是的,没有办法检测到128位或256位存储或加载是否在整个缓存一致性系统中都是原子的(参考链接1)。即使在L1D和执行单元之间存在原子传输的系统,当在窄协议下在缓存之间传输缓存行时,仍然可能出现8字节块之间的撕裂。一个真实的例子是:一个具有HyperTransport互连的多插槽Opteron K10似乎在单个插槽内具有原子的16字节加载/存储,但不同插槽上的线程可能会观察到撕裂现象(参考链接2)。
但是,如果您有一个共享的对齐的双精度数组,您应该能够在其上使用矢量加载/存储而不会出现“撕裂”问题。 (参见向量加载/存储和聚集/散射的每个元素的原子性? - 文档不清楚,但在实践中应该是安全的。)
我认为可以安全地假设对齐的32B加载/存储是通过非重叠的8B或更宽的加载/存储完成的,尽管英特尔并不保证这一点。 对于非对齐操作,即使当前的CPU可能不会在8B边界上的32B加载/存储中撕裂,也可能不能做出任何假设。
(更新:英特尔最终记录了AVX功能位对SSE/AVX加载和存储的128位原子性保证,而不是引入新的功能位。我不知道AMD是否也记录了同样的内容,但是除非多插槽Bulldozer系列存在与K10相同的8字节撕裂问题,否则应该是正确的。另请参阅https://rigtorp.se/isatomic/以进行16、32和64字节向量的实际加载/存储测试。)
(如果您需要在新记录的AVX原子性之前进行16字节的原子加载,您唯一的选择是使用lock cmpxchg16b,并将desired=expected。如果成功,它将用自身替换现有值。如果失败,则获取旧内容。(特例:此“加载”在只读内存上发生故障,因此要小心将指针传递给执行此操作的函数。)此外,与可以使缓存行处于共享状态且不是完全内存屏障的实际只读加载相比,性能当然非常糟糕。)
16B原子存储和RMW都可以使用“lock cmpxchg16b”来实现。这使得纯存储操作比常规向量存储操作更昂贵,特别是如果“cmpxchg16b”需要多次重试的话,但原子RMW本身就已经很昂贵了。
将向量数据移动到/从整数寄存器的额外指令并不是免费的,但与“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,然后对double进行原子RMW的xchgcmpxchg。 (在32位模式下,cmpxchg8b可以工作。)但是,你几乎肯定不会从编译器得到良好的汇编代码!
您可以原子方式更新一个16B对象,但可以单独原子读取8B的两个半部分。 (我认为在x86上关于内存排序,这是安全的:请看我的推理https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80835)。
然而,编译器没有提供任何清晰的方法来表达这一点。我使用了一个联合类型转换的方法实现了这个功能,适用于gcc/clang:如何使用c++11 CAS实现ABA计数器。但是gcc7及更高版本不会内联cmpxchg16b,因为他们重新考虑了16B对象是否真正应该被视为"无锁"(https://gcc.gnu.org/ml/gcc-patches/2017-01/msg02344.html)。

2
只是一个小参考,lock cmpxchg16b 在 MSVC++2017 中由 _InterlockedCompareExchange128 提供:https://learn.microsoft.com/en-us/cpp/intrinsics/interlockedcompareexchange128。到目前为止,我还没有找到 Intel 内部函数的对应项。 - Serge Rogatch

6
在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上没有自然的方法来实现原子浮点/双精度浮点操作。虽然大多数这些操作可以通过将浮点值的位表示加载到通用(即整数)寄存器中来实现,但这样做会严重降低性能,因此编译器作者选择不实现它。
如评论中所指出的,对于x86-64架构来说,在加载和存储时不需要使用LOCK前缀,因为它们总是原子操作。然而,英特尔SDM(第3卷,系统编程指南)只保证以下加载/存储是原子的:
- 读取或写入单个字节的指令。 - 读取或写入字(2字节)的指令,其地址对齐在2字节边界上。 - 读取或写入双字(4字节)的指令,其地址对齐在4字节边界上。 - 读取或写入四字(8字节)的指令,其地址对齐在8字节边界上。
特别地,不保证从/到更大的XMM和YMM向量寄存器的加载/存储的原子性。

2
有指令 cmpxchg8bcmpxchg16b,允许对 64/128 位进行 CAS 操作,从而允许在双精度/SSE 上进行通用原子操作。此外,RMW 指令不一定比 load/operation/store 序列更快。 - Margaret Bloom
你只需要在原子 RMW 操作时使用 lock,而不是在加载或存储时使用。 - Peter Cordes
@MSalters 是的,从技术上讲,不是寄存器是“浮点数”,而是指令。然而,在这个问题的背景下,我认为这并不重要,而且我不确定如何在不复杂化答案的情况下澄清这一点。 - avdgrinten
@PeterCordes好观点,我编辑了答案以说明哪些存储/加载操作是保证原子性的。 - avdgrinten
@MSalters:“FP”常用作非整数寄存器的简称,包括向量寄存器和x87寄存器。这在CPU架构中很常见,例如Intel Sandybridge具有160个入口的整数物理寄存器文件和144个入口的FP PRF。由于历史因素,还有其他例子,例如Linux内核将整个X/Y/ZMM + x87等状态称为“FPU”状态。(即使用XSAVE/XRSTOR保存/恢复而不是使用整数push/pop。)总之,精确术语通常太笨重,而本回答非常清晰。 :) - Peter Cordes
显示剩余2条评论

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