assembly x86_64上的原子双浮点或SSE/AVX向量加载/存储

hsvhsicv  于 2023-08-06  发布在  其他
关注(0)|答案(2)|浏览(81)

Here(在一些SO问题中)我看到C不支持无锁std::atomic<double>,也不支持原子AVX/SSE向量,因为它依赖于CPU(尽管现在我知道的CPU,ARM,AArch 64和x86_64都有向量)。
但是,在x86_64中,是否存在对double s或vector上的原子操作的汇编级支持?如果是,支持哪些操作(比如加载、存储、加、减、乘)?MSVC
2017在atomic<double>中实现了哪些无锁操作?

j1dl9f46

j1dl9f461#

C不支持无锁std::atomic<double>
实际上,C
11 std::atomic<double>在典型的C实现中是无锁的,并且在x86上使用float/double进行无锁编程时,几乎可以暴露出你在asm中可以做的所有事情(例如,float/double)。load、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和整数寄存器之间的所有跳跃数据开销。如果事务没有中止,那么无论您刚刚对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通常仅适用于整数寄存器,如x86的cmpxchg。然而,如果硬件仲裁LL/SC对以避免/减少活锁,则在非常高的争用情况下,它将比CAS环路显著更有效。如果你设计的算法很少发生争用,那么fetch_add的LL/add/SC重试循环与fetch_add的LL/add/SC重试循环之间的代码大小差异可能很小。加载+添加+ 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加载/存储来实现32位代码中的std::atomic<int64_t>加载和存储。
具有讽刺意味的是,编译器(gcc7.1,clang4.0,ICC 17,MSVC CL 19)在64位代码(或32位,SSE 2可用)中做得很糟糕,并通过整数寄存器反弹数据,而不是直接从xmm regs加载/存储movsd(请参阅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和相关的bug。即使对于-mtune=generic来说,这也是一个糟糕的选择。AMD在整数和向量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上,cmpxchg16b16字节(在某些第一代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操作,则可能会出现问题。对于足够新的编译器,memcmp(&expected, &desired, sizeof(double)) == 0可能是在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()是一个有趣的函数,这要归功于IEEE 754非常酷的设计,它具有偏置指数,使得从尾数到指数的进位实际上可以工作。

在C++中,可能没有办法让编译器在使用IEEE FP的目标上为您完成这一任务。所以如果你想要它,你可能需要自己用类型双关到atomic<uint64_t>或其他东西,并检查FP字节序是否匹配整数字节序等等。(或者只为x86做。大多数其他目标都有LL/SC而不是内存目的地锁定操作。

还不能支持原子AVX/SSE向量,因为它依赖于CPU

正确。没有办法检测128 b或256 b存储或加载在整个缓存一致性系统中何时是原子的。(https://gcc.gnu.org/bugzilla/show_bug.cgi?id=70490)。即使是在L1 D和执行单元之间具有原子传输的系统,当通过窄协议在高速缓存之间传输高速缓存行时,也可以在8B块之间进行撕裂。真实的例子:具有HyperTransport互连的多插槽OpteronK 10看起来在单个插槽内具有原子16 B加载/存储,但是不同插槽上的线程可以观察到撕裂。
但是如果你有一个对齐的double的共享数组,你应该能够在它们上面使用向量加载/存储,而不会有在任何给定的double内部“撕裂”的风险。(参见 * Per-element atomicity of vector load/store and gather/scatter? * -文档不清楚,但在实践中应该是安全的。
我认为可以安全地假设对齐的32 B加载/存储是用不重叠的8B或更宽的加载/存储来完成的,尽管英特尔并不保证这一点。对于未对齐的操作,假设任何事情都可能不安全,即使当前的CPU可能不会在8B边界上的32 B加载/存储的8B单元内撕裂。
(**更新:英特尔最终记录了AVX功能位保证SSE/AVX加载和存储的128位原子性,**追溯而不是引入新功能位。IDK如果AMD也记录了同样的事情,但除非多插槽推土机家庭有相同的8B撕裂问题的K10,这应该是真的。有关16、32和64字节向量的向量加载/存储的实际测试,请参见https://rigtorp.se/isatomic/。)

如果你需要一个16 B的原子负载(在新记录的AVX原子性之前),你唯一的选择是lock cmpxchg16bdesired=expected。如果成功,它将用自身替换现有值。如果失败了,那么你将得到旧的内容。(角箱:这种“加载”在只读内存上出错,所以要小心传递给执行此操作的函数的指针。)此外,与实际的只读加载相比,性能当然是可怕的,因为实际的只读加载可能会使该高速缓存线处于共享状态,并且不是完全的内存屏障。

16 B原子存储和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,然后xchgcmpxchg用于double上的原子RMW。(在32位模式下,cmpxchg8b可以工作。)但是,你几乎肯定不会从编译器中获得很好的asm!

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

然而,编译器没有提供任何清晰的方式来表达这一点。我破解了一个适用于gcc/clang的union类型双关:如何用C++11 CAS实现阿坝计数器?但是gcc 7和更高版本不会内联cmpxchg16b,因为他们正在重新考虑16 B对象是否真的应该将自己呈现为“无锁”。(https://gcc.gnu.org/ml/gcc-patches/2017-01/msg02344.html)。

wmomyfyw

wmomyfyw2#

在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在评论中指出的那样,加载和存储不需要LOCK前缀,因为这些在x86-64上总是原子的。但是,英特尔SDM(第3卷,系统编程指南)仅保证以下加载/存储是原子的:

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

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

相关问题