这里(以及一些SO问题)提到,C++不支持类似于无锁的std::atomic<double>
,并且不能支持原子AVX/SSE向量,因为它是CPU相关的(尽管我所知道的CPU中,ARM、AArch64和x86_64都有向量)。
但在x86_64上是否有关于double
或向量的汇编级别的原子操作支持?如果有,支持哪些操作(例如加载、存储、加、减、乘等)? MSVC++2017在atomic<double>
中实现了哪些操作是无锁的?
这里(以及一些SO问题)提到,C++不支持类似于无锁的std::atomic<double>
,并且不能支持原子AVX/SSE向量,因为它是CPU相关的(尽管我所知道的CPU中,ARM、AArch64和x86_64都有向量)。
但在x86_64上是否有关于double
或向量的汇编级别的原子操作支持?如果有,支持哪些操作(例如加载、存储、加、减、乘等)? MSVC++2017在atomic<double>
中实现了哪些操作是无锁的?
std::atomic<float>
/ <double>
添加fetch_add
和operator+=
/ -=
模板特化。double
和float
上执行原子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=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)的重试循环。
原子比较并交换(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而不是内存目标锁定操作。)
lock cmpxchg16b
,并将desired=expected
。如果成功,它将用自身替换现有值。如果失败,则获取旧内容。(特例:此“加载”在只读内存上发生故障,因此要小心将指针传递给执行此操作的函数。)此外,与可以使缓存行处于共享状态且不是完全内存屏障的实际只读加载相比,性能当然非常糟糕。)# 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
,然后对double
进行原子RMW的xchg
或cmpxchg
。 (在32位模式下,cmpxchg8b
可以工作。)但是,你几乎肯定不会从编译器得到良好的汇编代码!
cmpxchg16b
,因为他们重新考虑了16B对象是否真正应该被视为"无锁"(https://gcc.gnu.org/ml/gcc-patches/2017-01/msg02344.html)。lock cmpxchg16b
在 MSVC++2017 中由 _InterlockedCompareExchange128
提供:https://learn.microsoft.com/en-us/cpp/intrinsics/interlockedcompareexchange128。到目前为止,我还没有找到 Intel 内部函数的对应项。 - Serge Rogatchcmpxchg8b
、cmpxchg16b
,允许对 64/128 位进行 CAS 操作,从而允许在双精度/SSE 上进行通用原子操作。此外,RMW 指令不一定比 load/operation/store 序列更快。 - Margaret Bloomlock
,而不是在加载或存储时使用。 - Peter CordesXSAVE
/XRSTOR
保存/恢复而不是使用整数push/pop。)总之,精确术语通常太笨重,而本回答非常清晰。 :) - Peter Cordes
atomic<double>
是无锁的,很可能在MSVC++上也是如此。我不明白为什么你认为你的链接显示了相反的情况。然而,std::atomic
只提供整数类型的算术操作,因此对于atomic<double>
,您只能执行基本操作,如加载/存储/交换。 - interjaydouble
类型的常量,例如ATOMIC_POINTER_LOCK_FREE
、ATOMIC_LLONG_LOCK_FREE
等。 - Serge Rogatchstd::atomic<double>().is_lock_free()
可以(并且确实)返回true
。 - interjay