c++ - 在x86_64上加载/存储原子双浮点或SSE/AVX vector

标签 c++ assembly vectorization x86-64 stdatomic

Here(以及一些SO问题),我看到C++不支持诸如无锁std::atomic<double>之类的东西,但仍不支持诸如原子AVX/SSE vector 之类的东西,因为它依赖于CPU(尽管如今我所知道的CPU,ARM ,AArch64和x86_64具有 vector )。

但是,是否存在对double或x86_64中的 vector 的原子操作的程序集级支持?如果是这样,支持哪些操作(例如加载,存储,加,减,乘)? MSVC++ 2017哪些操作在atomic<double>中实现了无锁?

最佳答案

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



实际上,C++ 11 std::atomic<double>在典型的C++实现中是无锁的,并且确实公开了在asm中使用x86上的float/double进行无锁编程时几乎可以做的所有事情(例如,加载,存储和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和整数寄存器之间的数据跳动的所有开销。如果事务不会中止,那么您对double或vector加载/存储所做的一切都是原子发生的。

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

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

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

具有讽刺意味的是,编译器(gcc7.1,clang4.0,ICC17,MSVC CL19)在64位代码(或具有SSE2的32位代码)中做得不好,并通过整数寄存器反弹数据,而不仅仅是执行movsd加载/存储直接往返于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喜欢存储/重新加载integer-> xmm。请参阅https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80820和我报告的相关错误。即使对于-mtune=generic,这也是一个糟糕的选择。 AMD在整数和 vector regs之间的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)的重试循环。

Atomic compare-and-swap (CAS)可用作任何原子RMW操作的无锁构造块,直至硬件支持的最大CAS宽度。在x86-64上,这是 16个字节,带有cmpxchg16b (在某些第一代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总是进行按位比较,因此您无需担心IEEE语义中的负零(-0.0)与+0.0相等的比较,或者NaN是无序的。但是,如果您尝试检查desired == expected并跳过CAS操作,则可能会出现问题。对于足够新的编译器, memcmp(&expected, &desired, sizeof(double)) == 0 可能是表达C++中FP值按位比较的一种好方法。只要确保避免误报即可;假阴性只会导致不必要的CAS。

硬件仲裁的lock or [mem], 1绝对比在lock cmpxchg重试循环上旋转多个线程更好。与整数内存目标操作相比,每次内核访问缓存行但失败后,其cmpxchg都会浪费吞吐量,而整数内存目标操作只有在访问缓存行后才会成功。

可以使用整数操作来实现IEEE float的某些特殊情况。例如atomic<double>的绝对值可以通过lock and [mem], rax完成(其中RAX具有除符号位集以外的所有位)。或通过将1与符号位进行或运算来强制浮点数/ 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)。当通过窄协议(protocol)在高速缓存之间传输高速缓存行时,即使是在L1D与执行单元之间进行原子传输的系统,也可能在8B块之间撕裂。实际示例:a multi-socket Opteron K10 with HyperTransport interconnects在单个套接字中似乎具有16B原子加载/存储,但是不同套接字上的线程可以观察到撕裂。

但是,如果您有对齐的double共享数组,则应该能够在其上使用 vector 加载/存储,而不会在任何给定的double内部“撕裂”风险。

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

我认为可以肯定的是,对齐的32B加载/存储是通过不重叠的8B或更宽的加载/存储完成的,尽管Intel不能保证。对于不结盟的行动,假设任何事情可能都不安全。

如果您需要16B的原子负载,则唯一的选择是lock cmpxchg16bdesired=expected 。如果成功,它将用自身替换现有值。如果失败,则获得旧内容。 (正确的情况:此“负载”在只读存储器上出错,因此请注意传递给执行此功能的函数的指针。)而且,与实际的只读负载相比,其性能当然是可怕的,因为它可能会导致内存丢失。处于共享状态的高速缓存行,这并不是全部内存障碍。

16B原子存储和RMW都可以使用lock cmpxchg16b的明显方式。这使得纯存储比常规 vector 存储昂贵得多,尤其是如果cmpxchg16b必须重试多次,但是原子RMW已经很昂贵了。

相较于lock cmpxchg16b,将 vector 数据移入/移出整数reg的额外指令并非免费,但也不昂贵。
# 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上的原子RMW进行xchg,然后进行cmpxchgdouble即可。 (在32位模式下,cmpxchg8b可以工作。)但是,您几乎肯定不会从编译器那里获得好的asm!

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

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

关于c++ - 在x86_64上加载/存储原子双浮点或SSE/AVX vector ,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/45055402/

相关文章:

c++ - C++ 中 "if error then fail fast"的性能损失?

c++ - 如何使用输入子系统生成击键事件

linux - 如何在 nasm 中包含调试信息?

c++ - 使用 Eigen 的子矩阵和索引

Matlab - 两个以上输入的单例扩展

c++ - Trie 搜索正则表达式 : C++

c++ - 类型特征可以限制为不接受其他类型特征作为参数吗?

c - 这个集会怎么可能?

c# - 划分/移动 assembly 差异

python - Numpy 向量化