Here(在一些SO问题中)我看到C不支持无锁std::atomic<double>,也不支持原子AVX/SSE向量,因为它依赖于CPU(尽管现在我知道的CPU,ARM,AArch 64和x86_64都有向量)。但是,在x86_64中,是否存在对double s或vector上的原子操作的汇编级支持?如果是,支持哪些操作(比如加载、存储、加、减、乘)?MSVC2017在atomic<double>中实现了哪些无锁操作?
std::atomic<double>
double
atomic<double>
j1dl9f461#
C不支持无锁std::atomic<double>实际上,C11 std::atomic<double>在典型的C实现中是无锁的,并且在x86上使用float/double进行无锁编程时,几乎可以暴露出你在asm中可以做的所有事情(例如,float/double)。load、store和CAS足以实现任何东西:Why isn't atomic double fully implemented)的值。但是,当前的编译器并不总是能够高效地编译atomic<double>。C11 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通常仅适用于整数寄存器,如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):
float
std::atomic
fetch_add
operator+=
-=
std::atomic<float>
<double>
cmpxchg
movsd xmm0, [some_variable]
fild
fistp
std::atomic<int64_t>
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
字符串如果没有-mtune=intel,gcc喜欢对integer->xmm进行存储/重载。参见我报告的https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80820和相关的bug。即使对于-mtune=generic来说,这也是一个糟糕的选择。AMD在整数和向量regs之间的movq具有高延迟,但它在存储/重新加载方面也具有高延迟。使用默认的-mtune=generic,load()编译为:
-mtune=intel
-mtune=generic
movq
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)进行重试循环。
lock xadd [mem], eax
atomic<struct>
Atomic compare-and-swap (CAS)可用作任何原子RMW操作的无锁构建块,最大硬件支持的CAS宽度。在x86-64上,cmpxchg16b为16字节(在某些第一代AMD K8上不可用,因此对于gcc,您必须使用-mcx16或-march=whatever来启用它)。
cmpxchg16b
-mcx16
-march=whatever
gcc为exchange()提供了最好的asm:
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始终进行按位比较,因此您无需担心负零(-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浪费了吞吐量,而整数内存目标操作一旦获得缓存线就总是成功。
compare_exchange
-0.0
+0.0
desired == expected
memcmp(&expected, &desired, sizeof(double)) == 0
lock or [mem], 1
lock cmpxchg
IEEE浮点数的一些特殊情况可以用整数运算实现。例如,atomic<double>的绝对值可以用lock and [mem], rax来完成(其中RAX具有除符号位设置之外的所有位)。或者通过将1与符号位进行“或”运算来强制float / double为负。或者用XOR切换其符号。你甚至可以用lock add [mem], 1原子地增加它的星等1 ulp。(但前提是你能确定它不是无穷大。nextafter()是一个有趣的函数,这要归功于IEEE 754非常酷的设计,它具有偏置指数,使得从尾数到指数的进位实际上可以工作。
lock and [mem], rax
lock add [mem], 1
nextafter()
在C++中,可能没有办法让编译器在使用IEEE FP的目标上为您完成这一任务。所以如果你想要它,你可能需要自己用类型双关到atomic<uint64_t>或其他东西,并检查FP字节序是否匹配整数字节序等等。(或者只为x86做。大多数其他目标都有LL/SC而不是内存目的地锁定操作。
atomic<uint64_t>
还不能支持原子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 cmpxchg16b,desired=expected。如果成功,它将用自身替换现有值。如果失败了,那么你将得到旧的内容。(角箱:这种“加载”在只读内存上出错,所以要小心传递给执行此操作的函数的指针。)此外,与实际的只读加载相比,性能当然是可怕的,因为实际的只读加载可能会使该高速缓存线处于共享状态,并且不是完全的内存屏障。
lock cmpxchg16b
desired=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,然后xchg或cmpxchg用于double上的原子RMW。(在32位模式下,cmpxchg8b可以工作。)但是,你几乎肯定不会从编译器中获得很好的asm!
atomic<__m128d>
atomic<__m256d>
alignas(64) atomic<double> shared_buffer[1024];
movq rax, xmm0
xchg
cmpxchg8b
您可以原子地更新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)。
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卷,系统编程指南)仅保证以下加载/存储是原子的:
特别地,不保证从/到较大的XMM和YMM向量寄存器的加载/存储的原子性。
2条答案
按热度按时间j1dl9f461#
C不支持无锁
std::atomic<double>
实际上,C11
std::atomic<double>
在典型的C实现中是无锁的,并且在x86上使用float
/double
进行无锁编程时,几乎可以暴露出你在asm中可以做的所有事情(例如,float
/double
)。load、store和CAS足以实现任何东西:Why isn't atomic double fully implemented)的值。但是,当前的编译器并不总是能够高效地编译atomic<double>
。C11
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通常仅适用于整数寄存器,如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使用x87fild
/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):字符串
如果没有
-mtune=intel
,gcc喜欢对integer->xmm进行存储/重载。参见我报告的https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80820和相关的bug。即使对于-mtune=generic
来说,这也是一个糟糕的选择。AMD在整数和向量regs之间的movq
具有高延迟,但它在存储/重新加载方面也具有高延迟。使用默认的-mtune=generic
,load()
编译为:型
在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上,
cmpxchg16b
为16字节(在某些第一代AMD K8上不可用,因此对于gcc,您必须使用-mcx16
或-march=whatever
来启用它)。gcc为
exchange()
提供了最好的asm:型
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 cmpxchg16b
,desired=expected
。如果成功,它将用自身替换现有值。如果失败了,那么你将得到旧的内容。(角箱:这种“加载”在只读内存上出错,所以要小心传递给执行此操作的函数的指针。)此外,与实际的只读加载相比,性能当然是可怕的,因为实际的只读加载可能会使该高速缓存线处于共享状态,并且不是完全的内存屏障。16 B原子存储和RMW都可以以明显的方式使用
lock cmpxchg16b
。这使得纯存储比常规向量存储昂贵得多,特别是如果cmpxchg16b
必须重试多次,但原子RMW已经很昂贵了。将向量数据移动到整数寄存器/从整数寄存器移动向量数据的额外指令不是免费的,但与
lock cmpxchg16b
相比也不昂贵。型
在C++11中:
atomic<__m128d>
即使对于只读或只写操作(使用cmpxchg16b
)也会很慢,即使是最佳实现。atomic<__m256d>
甚至不能无锁。alignas(64) atomic<double> shared_buffer[1024];
理论上仍然允许对读取或写入它的代码进行自动向量化,只需要movq rax, xmm0
,然后xchg
或cmpxchg
用于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)。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卷,系统编程指南)仅保证以下加载/存储是原子的:
特别地,不保证从/到较大的XMM和YMM向量寄存器的加载/存储的原子性。