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_add
和operator+=
/-=
模板特化添加到C++的std::atomic<float>
/<double>
的建议。具有LL/SC原子而不是x86风格的内存目标指令的硬件(例如ARM和大多数其他RISC CPU)可以在没有CAS的情况下对
double
和float
进行原子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=generic
,load()
编译为:// 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 cmpxchg16b
和desired=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
,然后进行cmpxchg
或double
即可。 (在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/