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

时间:2017-07-12 10:40:44

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

Here(以及一些SO问题)我看到C ++不支持无锁std::atomic<double>这样的东西,但还不能支持像原子AVX / SSE这样的东西矢量,因为它依赖于CPU(虽然现在我知道CPU,ARM,AArch64和x86_64都有矢量)。

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

2 个答案:

答案 0 :(得分:6)

  

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

之类的东西

实际上,C ++ 11 std::atomic<double>在典型的C ++实现上是无锁的,并且几乎可以通过float / double公开你在asm中可以做的无锁编程在x86上(例如,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和整数寄存器之间弹跳数据的所有开销。如果交易没有中止,那么你用双重或矢量加载/存储做的任何事情都会以原子方式发生。

某些非x86硬件支持float / double的原子添加,而C ++ p0020是向C +添加fetch_addoperator+= / -=模板特化的提案+ std::atomic<float> / <double>

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

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位代码(或SSIC2可用的32位)中表现不佳,并且通过整数寄存器而不是仅执行{ {1}}直接加载/存储到xmm regs(see it on Godbolt):

movsd

没有#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 ,gcc喜欢存储/重新加载整数 - > xmm。请参阅https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80820以及我报告的相关错误。即使-mtune=intel,这也是一个糟糕的选择。 AMD在整数和向量寄存器之间具有-mtune=generic的高延迟,但它对于存储/重新加载也具有高延迟。使用默认movq-mtune=generic编译为:

load()

在xmm和整数寄存器之间移动数据使我们进入下一个主题:

原子读取 - 修改 - 写入(如// mov rax, QWORD PTR ad[rip] // mov QWORD PTR [rsp-8], rax # store/reload integer->xmm // movsd xmm0, QWORD PTR [rsp-8] // ret )是另一个故事:对fetch_add等内容的整数有直接支持(请参阅Can num++ be atomic for 'int num'?更多细节)。对于其他内容,例如lock xadd [mem], eaxatomic<struct> x86上的唯一选项是使用atomic<double>(或TSX)的重试循环。

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

gcc为-mcx16提供了最好的asm:

-march=whatever

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 )在IEEE语义中与compare_exchange进行比较,或者NaN是无序的。如果您尝试检查-0.0并跳过CAS操作,则可能会出现问题。对于足够新的编译器,memcmp(&expected, &desired, sizeof(double)) == 0可能是表达C ++中FP值的按位比较的好方法。只要确保你避免误报;假阴性只会导致不需要的CAS。

硬件仲裁+0.0肯定比让多个线程在desired == expected重试循环上旋转更好。每次核心访问高速缓存行但失败时,lock or [mem], 1与整数内存 - 目标操作相比,浪费了吞吐量,而整数内存目标操作一旦获得高速缓存行就会成功。

IEEE浮点数的一些特殊情况可以使用整数运算来实现。例如lock cmpxchg的绝对值可以用cmpxchg完成(其中RAX具有除符号位设置之外的所有位)。或者通过将1加入符号位来强制浮点/双精度为负。或者用XOR切换其标志。您甚至可以用atomic<double>原子地将其幅度增加1 ulp。 (但是,只有你可以确定它不是无限的开始...... nextafter()是一个有趣的功能,这要归功于具有偏向指数的IEEE754的非常酷的设计,这使得实际上从尾数进入指数的工作。)

在C ++中可能没有办法表达这一点,这会让编译器在使用IEEE FP的目标上为你做这件事。因此,如果你想要它,你可能必须自己使用类型惩罚lock and [mem], rax或其他东西,并检查FP endianness是否匹配整数字节序等等。(或者只是为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加载/存储,但不同套接字上的线程可以观察到撕裂。

但是如果你有一个对齐lock add [mem], 1的共享数组,你应该可以在它们上面使用向量加载/存储而不会有&#34;撕裂&#34;在任何给定的atomic<uint64_t>内。

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

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

如果您需要16B原子负载,则唯一的选择是doubledouble 。如果成功,它会将现有值替换为自身。如果失败,那么你得到旧的内容。 (转角情况:这个&#34;加载&#34;只读内存上的错误,所以要小心你传递给执行此操作的函数的指针。)此外,与实际只读相比,性能当然是可怕的负载可以使缓存行处于共享状态,并且不存在完全的内存障碍。

16B原子商店和RMW都可以以明显的方式使用lock cmpxchg16b。这使得纯存储比常规矢量存储更昂贵,特别是如果desired=expected必须重试多次,但原子RMW已经很昂贵。

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

cmpxchg16b

在C ++ 11术语中:

即使对于只读或只写操作(使用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 也会很慢,即使以最佳方式实现也是如此。 atomic<__m128d>甚至无法锁定。

理论上,

cmpxchg16b仍允许对读取或写入代码的代码进行自动矢量化,只需atomic<__m256d>然后alignas(64) atomic<double> shared_buffer[1024];movq rax, xmm0即可获得原子RMW xchg。 (在32位模式下,cmpxchg可以正常工作。)但是,你几乎可以肯定从编译器获得好的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后来又赢了内联double,因为他们正在重新考虑16B对象是否应该真正表现为&#34;无锁定&#34;。 (https://gcc.gnu.org/ml/gcc-patches/2017-01/msg02344.html)。

答案 1 :(得分:5)

在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向量寄存器加载/存储的原子性。