以原子方式清除无符号整数的最低非零位

时间:2018-07-15 08:52:56

标签: c++ x86 arm x86-64 atomic

问题:
我正在寻找一种以线程安全的方式清除无符号原子(如std::atomic_uint64_t)的最低非零位的最佳方法,而无需使用额外的互斥锁等。另外,我还需要知道清除了哪一点。

示例: 可以说,如果存储的当前值为0b0110,我想知道最低的非零位是位1(0索引),并将变量设置为0b0100

我想到的最好的版本是this

#include <atomic>
#include <cstdint>

inline uint64_t with_lowest_non_zero_cleared(std::uint64_t v){
    return v-1 & v;
}
inline uint64_t only_keep_lowest_non_zero(std::uint64_t v){
    return v & ~with_lowest_non_zero_cleared(v);
}

uint64_t pop_lowest_non_zero(std::atomic_uint64_t& foo)
{
    auto expected = foo.load(std::memory_order_relaxed);
    while(!foo.compare_exchange_weak(expected,with_lowest_non_zero_cleared(expected), std::memory_order_seq_cst, std::memory_order_relaxed))
    {;}
    return only_keep_lowest_non_zero(expected);
}

有更好的解决方案吗?

注意:

  • 它不必是最低的非零位-我也很希望解决方案能够清除最高位或设置最高/最低“零位”(如果有区别的话)
  • 我非常希望使用标准的c ++(17)版本,但会接受对clang和msvc使用内在函数的答案
  • 它应该是可移植的(对于x64和AArch64,使用clang或msvc进行编译),但是当使用clang进行编译时,我对最新的intel和AMD x64体系结构的性能最感兴趣。
  • 编辑:更新必须是原子性的,必须保证全局进度,但是就像上面的解决方案一样,它不必是免费的等待算法(当然,如果您能向我展示,我将非常高兴一)。

2 个答案:

答案 0 :(得分:7)

x86上的原子清​​除最低位没有直接的硬件支持BMI1 blsr仅在内存源形式下可用,而在内存目的地形式下不可用; lock blsr [shared_var]不存在。 (当您使用(var-1) & (var)进行编译时,编译器知道如何针对本地var优化blsr-march=haswell中,或者启用假定BMI1支持的代码生成。)即使您可以假定BMI1支持< sup> 1 ,它不会让您做任何根本不同的事情。

在x86上可以做的最好的事情是您在问题中建议的lock cmpxchg重试循环。与在变量的旧版本中找到正确的位然后使用lock btr相比,这是一个更好的选择,尤其是如果在位扫描之间设置了较低的位以清除错误的位是正确性问题时,和lock btr。而且,如果其他线程已经清除了所需的位,您仍然需要重试循环。

CAS重试循环在实践中还不错:重试非常罕见

(除非您的程序对共享变量的争用非常高,即使在lock add那里没有尝试,只是硬件仲裁来访问缓存行的情况下,性能也会有问题。如果是这样,您应该可能需要重新设计您的无锁算法和/或考虑使用某种操作系统辅助的睡眠/唤醒方式,而不是让大量内核花费大量CPU时间在同一缓存行上。无锁在低竞争情况下非常有用。)< / p>

CPU在丢失要获取expected的负载和运行lock cmpxchg之间的高速缓存行的窗口很小,只有几个指令指示该值。 通常情况下,它将第一次成功完成,因为当cmpxchg运行时,高速缓存行仍将存在于L1d高速缓存中。高速缓存行到达时,(如果有希望)CPU前方有足够的距离为其执行RFO,它将(希望)已经处于MESI Exclusive状态。

您可以检测cmpxchg重试循环,以查看实际程序中实际获得了多少争用。 (例如,通过在循环内增加局部变量,并在成功后使用原子松弛+=到共享计数器中,或使用线程局部计数器)。

请记住,您的真实代码(希望)在此位掩码上的原子操作之间完成了大量工作,因此,它与微基准测试有很大不同,在微基准测试中,所有线程将所有时间都花在了该缓存行上。

  

编辑:更新必须是原子性的,并且必须确保全局进度,但是就像上面的解决方案一样,它不必是无等待算法(当然,我会如果您能给我看一个,我会非常高兴。

从技术上讲,CAS重试循环(即使在LL / SC机器上编译,也请参见下文)是无锁的:您仅需在其他线程取得进展时重试即可。。< / p>

CAS如果失败,则使高速缓存行保持不变。在x86上,它弄脏了缓存行(MESI M状态),但是x86 cmpxchg不能检测到ABA,它仅进行比较,因此,另一个加载了相同expected的线程将成功。希望在LL / SC机器上,一个内核上的SC故障不会导致其他内核上的SSC出现奇怪的故障,否则它可能会死锁。您可以假设CPU架构师会想到这一点。

它不是无休止的,因为理论上单个线程必须重试无数次。


您的代码使用gcc8.1 -O3 -march=haswell编译到此asm(from the Godbolt compiler explorer

# gcc's code-gen for x86 with BMI1 looks optimal to me.  No wasted instructions!
# presumably you'll get something similar when inlining.
pop_lowest_non_zero(std::atomic<unsigned long>&):
    mov     rax, QWORD PTR [rdi]
.L2:
    blsr    rdx, rax                      # Bit Lowest Set Reset
    lock cmpxchg    QWORD PTR [rdi], rdx
    jne     .L2                           # fall through on success: cmpxchg sets ZF on equal like regular cmp
    blsi    rax, rax                      # Bit Lowest Set Isolate
    ret

没有BMI1,blsr和blsi分别成为两条指令。鉴于lock指令的成本,这与性能几乎无关。

不幸的是,

clang和MSVC有点笨拙,在无争用的快速路径上有一个采用的分支。 (然后clang通过剥离第一个迭代来膨胀该函数。IDK如果这有助于分支预测或其他事情,或者纯粹是错过了优化。我们可以使用unlikely()使clang生成不带分支的快速路径宏How do the likely() and unlikely() macros in the Linux kernel work and what is their benefit?


脚注1:

除非您要为一组已知的计算机构建二进制文件,否则不能假设BMI1仍然可用。因此,编译器将需要执行lea rdx, [rax-1] / and rdx, rax之类的事情,而不是blsr rdx, rax。这对于该功能而言是微不足道的。考虑到乱序执行吞吐量对周围代码的影响,即使在无竞争的情况下,大部分成本也是原子操作,即使对于L1d高速缓存无争用情况也是如此。 (例如,在Skylake(http://agner.org/optimize/上为lock cmpxchg花费10 uops,而用blsr而不是其他2条指令保存1 uop。假设前端是瓶颈,而不是内存或其他东西)完全的内存屏障也会影响周围代码的加载/存储,但幸运的是不会影响独立ALU指令的无序执行。


非x86:LL/SC machines

大多数非x86机器都通过负载链接/存储条件重试循环执行其所有原子操作。不幸的是,C ++ 11不允许您创建自定义的LL / SC操作(例如,在LL / SC内使用(x-1) & x,而不仅仅是使用{{1} }),但CAS机器(如x86)无法提供LL / SC提供的ABA检测。因此,尚不清楚如何设计C ++类,该类可以在x86上有效地编译,还可以直接在ARM和其他LL / SC ISA上直接编译为LL / SC重试循环。 (请参见this discussion。)

因此,如果C ++没有提供所需的操作(例如fetch_addcompare_exchange_weak),则只需使用fetch_or编写代码。

从当前的编译器实际得到的是使用LL / SC实现的fetch_and,并且您的算术运算与此分开。实际上,asm会在互斥负载获取(compare_exchange_weak之前执行宽松的负载,而不是仅仅基于ldaxr结果进行计算。并且还有额外的分支来检查上次尝试中的ldaxr是否与新的加载结果匹配,然后再尝试存储。

在重要的情况下,LL / SC窗口可能比加载和存储之间有2条相关的ALU指令短。 CPU具有预先准备好的期望值,与负载结果无关。 (这取决于上一个的加载结果。)Clang的代码生成将expected / sub放入循环内,但取决于上一次迭代的加载,因此订单执行后,他们仍可以提前准备好。

但是,如果这确实是最有效的处理方式,则编译器也应将其用于and。他们没有,因为可能不是。 LL / SC重试很少,就像x86上的CAS重试一样。我不知道在竞争非常激烈的情况下它是否会有所作为。也许可以,但是编译器不会在编译fetch_add时放慢为此优化的快速路径。

我重命名了您的函数,并重新设置了fetch_add的格式,以提高可读性,因为一行太长了,没有用while()进行换行。

此版本可使用clang编译为比原始版本更好的asm 。我还修复了您的函数名称,以便它实际上可以编译;您的问题不匹配。我选择了与x86的BMI指令名称相似的完全不同的名称,因为它们简洁地描述了操作。

unlikely()

AArch64的C #include <atomic> #include <cstdint> #ifdef __GNUC__ #define unlikely(expr) __builtin_expect(!!(expr), 0) #define likely(expr) __builtin_expect(!!(expr), 1) #else #define unlikely(expr) (expr) #define likely(expr) (expr) #endif inline uint64_t clear_lowest_set(std::uint64_t v){ return v-1 & v; } inline uint64_t isolate_lowest_set(std::uint64_t v){ //return v & ~clear_lowest_set(v); return (-v) & v; // MSVC optimizes this better for ARM when used separately. // The other way (in terms of clear_lowest_set) does still CSE to 2 instructions // when the clear_lowest_set result is already available } uint64_t pop_lowest_non_zero(std::atomic_uint64_t& foo) { auto expected = foo.load(std::memory_order_relaxed); while(unlikely(!foo.compare_exchange_weak( expected, clear_lowest_set(expected), std::memory_order_seq_cst, std::memory_order_relaxed))) {} return isolate_lowest_set(expected); } (Godbolt上的-O3)产生了一些奇怪的代码。这是来自clang6.0,但是较旧的版本也很奇怪,在寄存器中创建一个0/1整数然后进行测试,而不是仅仅跳到正确的位置。

-target aarch64-linux-android -stdlib=libc++ -mcpu=cortex-a57

所有这些疯狂的分支可能不是一个真正的性能问题,但是很明显,这可能会效率更高(即使不教c如何直接使用LL / SC而不是模拟CAS)。 报告为clang / LLVM错误38173](https://bugs.llvm.org/show_bug.cgi?id=38173

似乎MSVC不知道AArch64的发布存储区是顺序发布的(不仅仅是像x86这样的常规版本),因为它在pop_lowest_non_zero(std::__1::atomic<unsigned long long>&): // @pop_lowest_non_zero(std::__1::atomic<unsigned long long>&) ldr x9, [x0] @ the relaxed load ldaxr x8, [x0] @ the CAS load (a=acquire, x=exclusive: pairs with a later stxr) cmp x8, x9 @ compare part of the CAS b.ne .LBB0_3 sub x10, x9, #1 and x10, x10, x9 @ clear_lowest( relaxed load result) stlxr w11, x10, [x0] @ the CAS store (sequential-release) cbnz w11, .LBB0_4 @ if(SC failed) goto retry loop # else fall through and eventually reach the epilogue # this looks insane. w10 = 0|1, then branch if bit[0] != 0. Always taken, right? orr w10, wzr, #0x1 tbnz w10, #0, .LBB0_5 @ test-bit not-zero will always jump to the epilogue b .LBB0_6 # never reached .LBB0_3: clrex @ clear the ldrx/stxr transaction .LBB0_4: # This block is pointless; just should b to .LBB0_6 mov w10, wzr tbz w10, #0, .LBB0_6 # go to the retry loop, below the ret (not shown here) .LBB0_5: # isolate_lowest_set and return neg x8, x9 and x0, x9, x8 ret .LBB0_6: @ the retry loop. Only reached if the compare or SC failed. ... 之后仍在使用dmb ish指令(完整的内存屏障) 。它具有更少的浪费指令,但是却显示出x86偏差:stlxrcompare_exchange_weak一样具有可能无用的重试循环,它将在LL上以旧的预期/期望再次尝试CAS / SC故障。这通常是因为另一个线程修改了该行,因此预期会不匹配。 (Godbolt在任何较旧的版本中都没有针对AArch64的MSVC,因此也许是全新的支持。这也许可以解释compare_exchange_strong

dmb
用于AArch64的

gcc6.3也会产生奇怪的代码,将 ## MSVC Pre 2018 -Ox |pop_lowest_non_zero| PROC ldr x10,[x0] # x10 = expected = foo.load(relaxed) |$LL2@pop_lowest| @ L2 # top of the while() loop sub x8,x10,#1 and x11,x8,x10 # clear_lowest(relaxed load result) |$LN76@pop_lowest| @ LN76 ldaxr x8,[x0] cmp x8,x10 # the compare part of CAS bne |$LN77@pop_lowest| stlxr w9,x11,[x0] cbnz w9,|$LN76@pop_lowest| # retry just the CAS on LL/SC fail; this looks like compare_exchange_strong # fall through on LL/SC success |$LN77@pop_lowest| @ LN77 dmb ish # full memory barrier: slow cmp x8,x10 # compare again, because apparently MSVC wants to share the `dmb` instruction between the CAS-fail and CAS-success paths. beq |$LN75@pop_lowest| # if(expected matches) goto epilogue mov x10,x8 # else update expected b |$LL2@pop_lowest| # and goto the top of the while loop |$LN75@pop_lowest| @ LN75 # function epilogue neg x8,x10 and x0,x8,x10 ret 存储到堆栈中。 (Godbolt没有更新的AArch64 gcc)。

我对AArch64编译器不满意! IDK为什么他们不生成像这样的干净高效的东西,在快速路径上没有分支,只有少量的脱机代码可以设置以返回CAS重试。

expected

pop_lowest_non_zero ## hand written based on clang # clang could emit this even without turning CAS into LL/SC directly ldr x9, [x0] @ x9 = expected = foo.load(relaxed) .Lcas_retry: ldaxr x8, [x0] @ x8 = the CAS load (a=acquire, x=exclusive: pairs with a later stxr) cmp x8, x9 @ compare part of the CAS b.ne .Lcas_fail sub x10, x9, #1 and x10, x10, x9 @ clear_lowest (relaxed load result) stlxr w11, x10, [x0] @ the CAS store (sequential-release) cbnz w11, .Lllsc_fail # LL/SC success: isolate_lowest_set and return .Lepilogue: neg x8, x9 and x0, x9, x8 ret .Lcas_fail: clrex @ clear the ldrx/stxr transaction .Lllsc_fail: mov x9, x8 @ update expected b .Lcas_retry @ instead of duplicating the loop, jump back to the existing one with x9 = expected 比较:

Clang确实为.fetch_add编写了不错的代码:

fetch_add()

我们希望拥有 mov x8, x0 .LBB1_1: ldxr x0, [x8] # relaxed exclusive load: I used mo_release add x9, x0, #1 stlxr w10, x9, [x8] cbnz w10, .LBB1_1 # retry if LL/SC failed ret / add #1而不是sub x9, x8, #1,所以我们只得到LL / SC重试循环。这样可以节省代码大小,但不会明显更快。在大多数情况下,甚至可能没有明显提高的速度,特别是作为整个程序的一部分,而该程序没有用到疯狂的数量。


替代方法:您甚至需要此位图操作吗?您可以将其拆分以减少争用吗?

可以使用原子计数器代替位图,并在需要时将其映射到位图吗?需要位图的操作可以使用and x9, x9, x8将计数器映射到位图(仅适用于非零计数器)。或者,也许uint64_t(~0ULL) << (64-counter)(即x86 tmp=1ULL << counter; tmp ^= tmp-1; / xor eax,eax(rax = 1位置0..63处的设置位)/ blsmsk rax, rax(rax =设置到该位置的所有位),对于mask = 0,仍然需要特殊情况,因为连续的位掩码有65种可能的状态:最高(或最低)位在64个位置之一,或者根本没有设置位。

或者如果位图有某种模式,x86 BMI2 pdep可以将连续的位分散到该模式中。再次使用bts rax, rdi获取N个连续的设置位,用于计数器<64。


也许它必须是一个位掩码,但是不必是一个位掩码?

不知道您的用例是什么,但是这种想法可能有用:

您是否需要所有线程都必须竞争的单个原子位图?也许您可以将其分为多个块,每个块位于单独的缓存行中。 (但是,这样就不可能以原子方式对整个地图进行快照。)但是,如果每个线程都有一个首选块,并且在尝试继续寻找另一个块中的插槽之前始终尝试这样做,则通常情况下您可以减少争用。 / p>

在asm中(或在C ++中有可怕的联合黑客攻击),您可以尝试通过找到要更新的64位整数的正确字节,然后仅在其上(1ULL << counter) - 1来减少cmpxchg故障,而不会减少争用。这实际上对这种情况没有帮助,因为看到相同整数的两个线程都将尝试清除同一位。但是,这可以减少在此操作与设置qword中其他位的操作之间的重试。当然,只有当qword的其他字节已更改而lock cmpxchg成功时,不是正确性问题时,此方法才有效。

答案 1 :(得分:0)

基本上,问题是,我是否最好在compare_exchange_weak上循环,直到找到/得到想要的东西,还是应该只使用mutex?好的,至少我现在明白了这个问题,所以让我们来探讨一下。

所以,外面的每个人都可能在尖叫哪个更快,哪个更快?好吧,我个人并不太在乎,除非事实证明这是一个问题,但是如果在乎,那么您应该对它进行基准测试。您可以获得最出色的Wandbox的一阶近似值。

但是还有另一个更微妙的问题:锁定。第一个解决方案是无锁的,但是有一个繁忙的循环。第二个需要一个锁,它可能会有副作用。

繁忙循环可能非常无害。它只会占用一个内核,不太可能运行很长时间,但是如果有人怀疑,则在真实条件下运行时,人们会希望分析代码。

另一方面,锁可能不是那么无害,因为它可能导致优先级倒置,这可能导致优先级较低的线程抢占了优先级较高的线程。在任何以不同优先级运行协作线程的应用程序中,这可能是个问题,但对我来说尤其如此,因为我编写了实时音频代码。在Mac上让我很伤心,这就是我所知道的一切。

因此,我希望至少可以告诉您一些您想知道的内容。对于您的代表,我不应该试图告诉您如何“编写代码”。

参考:https://en.m.wikipedia.org/wiki/Priority_inversion