【问题标题】:What is the efficient way to count set bits at a position or lower?计算某个位置或更低位置的设置位的有效方法是什么?
【发布时间】:2016-03-28 05:18:57
【问题描述】:

给定std::bitset<64> bits,设置任意数量的位和位位置X (0-63)

什么是最有效的方法来计算位置 X 或更低的位或如果 X 的位未设置则返回 0

注意:如果设置了该位,则返回将始终至少为 1

蛮力方式很慢:

int countupto(std::bitset<64> bits, int X)
{
  if (!bits[X]) return 0;
  int total=1;
  for (int i=0; i < X; ++i)
  {
    total+=bits[i];
  }
  return total;
}

bitsetcount() 方法将为您提供所有位的 popcount,但 bitset 不支持范围

注意:这不是 How to count the number of set bits in a 32-bit integer? 的重复,因为它询问所有位而不是范围 0 到 X

【问题讨论】:

  • 64 是事实还是例子?更一般地说:你的位总是适合整数吗?
  • @5gon12eder 它们适合 long long(64 位)
  • 那我觉得Jerry Coffin 的回答会是你最好的选择。 (或与此相关的任何其他答案。)

标签: c++ algorithm performance bit-manipulation


【解决方案1】:

这个 C++ 让 g++ 发出 very good x86 ASM (godbolt compiler explorer)。我希望它也能在其他 64 位架构上高效编译(如果有一个硬件 popcount 供std::bitset::count 使用,否则这将始终是缓慢的部分;例如,一定要使用g++ -march=nehalem 或更高版本,或者-mpopcnt if如果您可以将代码限制为仅在支持该 x86 指令的 CPU 上运行,则您不想启用任何其他功能):

#include <bitset>

int popcount_subset(std::bitset<64> A, int pos) {
  int high_bits_to_eliminate = 63 - pos;
  A <<= (high_bits_to_eliminate & 63);  // puts A[pos] at A[63].

  return (A[63]? ~0ULL : 0) & A.count();  // most efficient way: great code with gcc and clang
  // see the godbolt link for some #ifdefs with other ways to do the check, like
    // return A[BSET_SIZE-1] ? A.count() : 0;
}

这在 32 位架构上可能不是最佳选择,因此如果您需要构建 32 位架构,请比较其他替代方案。

这适用于其他大小的 bitset,只要您对硬编码的 63s 进行一些操作,并将移位计数的 &amp; 63 掩码更改为更通用的范围检查。为了使用奇怪大小的位集获得最佳性能,请为目标机器的size &lt;= register width 专门创建一个模板函数。在这种情况下,将位集提取为具有适当宽度的unsigned 类型,然后移动到寄存器的顶部而不是位集的顶部。

您希望这也会为bitset&lt;32&gt; 生成理想的代码,但事实并非如此。 gcc/clang 在 x86-64 上仍然使用 64 位寄存器。

对于大型位集,移动整个内容比仅计算包含pos 的单词下方的单词并在该单词上使用它要慢。 (如果您可以假设 SSSE3 而不是 popcnt insn 硬件支持或 32 位目标,这就是矢量化 popcount 真正在 x86 上大放异彩的地方。AVX2 256bit pshufb 是进行批量 popcounts 的最快方法,但我认为没有 AVX2 64 位 popcnt 非常接近于 128 位 pshufb 实现。有关更多讨论,请参阅 cmets。)

如果你有一个 64 位元素的数组,并且想分别计算每个元素中某个位置以下的位数,那么你绝对应该使用 SIMD。该算法的移位部分矢量化,而不仅仅是 popcnt 部分。在基于pshufb 的popcnt 之后,针对全零寄存器使用psadbw 对64 位块中的字节进行水平求和,该popcnt 分别为每个字节中的位生成计数。 SSE/AVX 没有 64 位算术右移,但您可以使用不同的技术来混合每个元素的高位。


我是如何想到这个的:

您希望编译器输出的 asm 指令将:

  1. 从 64 位值中删除不需要的位
  2. 测试所需的最高位。
  3. popcount 吧。
  4. 根据测试结果返回 0 或 popcount。 (无分支或有分支的实现都有优势。如果分支是可预测的,那么无分支的实现往往会更慢。)

1 的明显方法是生成一个掩码 ((1&lt;&lt;(pos+1)) -1) 和&amp; 它。更有效的方法是左移63-pos,将您想要打包的位留在寄存器的顶部。

这还有一个有趣的副作用,就是将要测试的位作为寄存器的最高位。测试符号位,而不是任何其他任意位,需要的指令略少。算术右移可以将符号位广播到寄存器的其余部分,从而实现比通常更高效的无分支代码。


popcount 是一个被广泛讨论的问题,但实际上是难题中更棘手的部分。在 x86 上,它有非常有效的硬件支持,但仅限于最近的硬件。在 Intel CPU 上,popcnt 指令仅适用于 Nehalem 和更新版本。我忘记了 AMD 何时添加支持。

因此,为了安全地使用它,您需要使用不使用 popcnt 的回退进行 CPU 调度。或者,制作依赖/不依赖某些 CPU 功能的单独二进制文件。

没有popcnt 指令的popcount 可以通过几种方式完成。一个使用 SSSE3 pshufb 来实现 4 位 LUT。不过,这在整个阵列上使用时最有效,而不是一次使用单个 64b。标量 bithacks 在这里可能是最好的,并且不需要 SSSE3(因此将与具有 64 位但没有 pshufb 的古老 AMD CPU 兼容。)


比特广播:

(A[63]? ~0ULL : 0) 要求编译器将高位广播到所有其他位位置,允许将其用作与掩码为零(或不为零)popcount 结果。请注意,即使对于较大的 bitset 大小,它仍然只屏蔽 popcnt 的输出,而不是 bitset 本身,所以 ~0ULL 很好,我使用 ULL 来确保从未要求编译器仅将 bit 广播到寄存器的低 32b(例如,在 Windows 上使用 UL)。

这个广播可以通过算术右移 63 位来完成,它在高位的副本中移动。

clang 从原始版本生成此代码。在 Glenn 对 4 的不同实现提出了一些建议之后,我意识到我可以通过编写更像我想要的 ASM 的源代码来引导 gcc 走向 clang 的最佳解决方案。更直接地请求算术右移的明显((int64_t)something) &gt;&gt; 63 不会是严格可移植的,因为带符号的右移是implementation-defined as either arithmetic or logical。该标准不提供任何可移植的算术右移运算符。 (不过,它不是undefined behaviour。)无论如何,幸运的是编译器足够聪明:gcc 一旦你给出足够的提示就会看到最好的方法。

这个源代码在 x86-64 和 ARM64 上使用 gcc 和 clang 编写了很棒的代码。两者都只是对 popcnt 的输入使用算术右移(因此移位可以与 popcnt 并行运行)。它还可以在 32 位 x86 上使用 gcc 编译,因为屏蔽只发生在 32 位变量上(在添加多个 popcnt 结果之后)。在 32 位(当 bitset 大于寄存器时),剩下的功能是讨厌的。


带有 gcc 的原始三元运算符版本

使用 gcc 5.3.0 -O3 -march=nehalem -mtune=haswell 编译(旧的 gcc,如 4.9.2,也仍然会发出这个):

; the original ternary-operator version.  See below for the optimal version we can coax gcc into emitting.
popcount_subset(std::bitset<64ul>, int):
    ; input bitset in rdi, input count in esi (SysV ABI)
    mov     ecx, esi    ; x86 variable-count shift requires the count in cl
    xor     edx, edx    ; edx=0 
    xor     eax, eax    ; gcc's workaround for popcnt's false dependency on the old value of dest, on Intel
    not     ecx         ; two's complement bithack for 63-pos (in the low bits of the register)
    sal     rdi, cl     ; rdi << ((63-pos) & 63);  same insn as shl (arithmetic == logical left shift)
    popcnt  rdx, rdi
    test    rdi, rdi    ; sets SF if the high bit is set.
    cmovs   rax, rdx    ; conditional-move on the sign flag
    ret

有关 gcc 使用 -x == ~x + 1 二进制补码标识的背景信息,请参阅 How to prove that the C statement -x, ~x+1, and ~(x-1) yield the same results?。 (和Which 2's complement integer operations can be used without zeroing high bits in the inputs, if only the low part of the result is wanted? 切线提到shl 掩盖了移位计数,所以我们只需要ecx 的低6 位来保存63 - pos。主要是链接它,因为我最近写了它并且任何仍在阅读本段的人可能觉得很有趣。)

内联时,其中一些指令会消失。 (例如 gcc 会首先在 ecx 中生成计数。)

使用 Glenn 的乘法而不是三元运算符 想法(由 USE_mul 启用),gcc 可以

    shr     rdi, 63
    imul    eax, edi

最后而不是xor / test / cmovs


Haswell perf analysis, using microarch data from Agner Fog(倍增版):

  • mov r,r:1 个融合域 uop,0 延迟,没有执行单元
  • xor-zeroing:1 个融合域微指令,没有执行单元
  • not:p0/p1/p5/p6 1 uop,1c 延迟,每 0.25c 吞吐量 1 个
  • shl(又名sal),在cl 中计数:p0/p6 为 3 微指令:2c 延迟,每 2c 吞吐量 1 个。 (奇怪的是,Agner Fog 的数据表明 IvyBridge 只需要 2 微秒。)
  • popcnt:p1 1 uop,3c 延迟,每 1c 吞吐量 1 个
  • shr r,imm:p0/p6 1 uop,1c 延迟。每 0.5c 吞吐量 1 个。
  • imul r,r:p1 1uop,3c 延迟。
  • 不包括ret

总计:

  • 9 个融合域 uop,可以在 2.25 个周期内发出(理论上;uop 缓存线效应通常会稍微成为前端的瓶颈)。
  • p0/p6 为 4 微秒(移位)。 p1 2 微秒。 1 个任意 ALU 端口微指令。可以每 2c 执行一个(饱和移位端口),因此前端是最严重的瓶颈。

延迟:从 bitset 准备好到结果为:shl(2) -> popcnt(3) -> imul(3) 的关键路径。总共 8 个周期。或从pos 准备就绪时开始的 9c,因为 not 对它来说是额外的 1c 延迟。

最佳bitbroadcast 版本shr 替换为sar(性能相同),将imul 替换为and(1c 延迟而不是3c,在任何端口上运行)。所以唯一的性能变化是将关键路径延迟减少到 6 个周期。吞吐量仍然是前端的瓶颈。 and 能够在任何端口上运行并没有什么不同,除非您将其与端口 1 上的瓶颈代码混合(而不是查看仅运行 this 代码的吞吐量紧环)。

cmov(三元运算符)版本:11 个融合域微指令(前端:每 2.75c 一个)。执行单元:仍以每 2c 一个的速度在移位端口 (p0/p6) 上遇到瓶颈。 延迟:从 bitset 到 result 7c,从 pos 到 result 8c。 (cmov 是 2c 延迟,对于任何 p0/p1/p5/p6 都是 2 微秒。)


Clang 有一些不同的技巧:代替test/cmovs,它通过使用算术右移生成一个全1或全零掩码将符号位广播到寄存器的所有位置。我喜欢它:使用and 而不是cmov 在英特尔上更有效。不过,它仍然具有数据依赖性,并为分支的两侧工作(这通常是 cmov 的主要缺点)。更新:使用正确的源代码,gcc 也会使用此方法。

clang 3.7 -O3 -Wall -march=nehalem -mtune=haswell

popcount_subset(std::bitset<64ul>, int):
    mov     ecx, 63
    sub     ecx, esi      ; larger code size, but faster on CPUs without mov-elimination
    shl     rdi, cl       ; rdi << ((63-pos) & 63)
    popcnt  rax, rdi      ; doesn't start a fresh dep chain before this, like gcc does
    sar     rdi, 63       ; broadcast the sign bit
    and     eax, edi      ; eax = 0 or its previous value
    ret

sar / and 替换 xor / test / cmovcmov 是 Intel CPU 上的 2-uop 指令,所以这非常好。 (对于三元运算符版本)。

当使用多源版本或“bitbroadcast”源版本时,Clang 仍然使用 sar / and 技巧,而不是实际的 imul。所以那些帮助 gcc 而不会伤害 clang。 (sar/and 绝对比 shr/imul 好:关键路径上的延迟减少了 2c。)pow_of_two_sub 版本确实伤害了 clang(请参阅第一个神螺栓链接:从这个答案中省略以避免混乱的想法没有平移出)。

mov ecx, 63/sub ecx, esi 在 CPU 上实际上更快,没有 mov-elimination for reg,reg 移动(零延迟和无执行端口,通过寄存器重命名处理)。这包括 Intel 之前的 IvyBridge,但不包括更新的 Intel 和 AMD CPU。

Clang 的 mov imm / sub 方法仅将 pos 的一个延迟周期放在关键路径上(超出 bitset->result 延迟),而不是两个用于 CPU 上的 mov ecx, esi / not ecx其中mov r,r 的延迟为 1c。


使用 BMI2(Haswell 及更高版本),最佳 ASM 版本可以将 mov 保存到 ecx。其他一切都一样,因为shlx 将其移位​​计数输入寄存器屏蔽为操作数大小,就像shl 一样。

x86 移位指令具有疯狂的 CISC 语义,如果移位计数为零,则标志不受影响。因此,可变计数移位指令对标志的旧值具有(潜在的)依赖性。 “正常”x86 shl r, cl 在 Haswell 上解码为 3 uop,但 BMI2 shlx r, r, r 只有 1。所以 gcc 仍然发出 sal-march=haswell 太糟糕了,而不是使用 shlx(它确实使用在其他一些情况下)。

// hand-tuned BMI2 version using the NOT trick and the bitbroadcast
popcount_subset(std::bitset<64ul>, int):
    not     esi           ; The low 6 bits hold 63-pos.  gcc's two-s complement trick
    xor     eax, eax      ; break false dependency on Intel.  maybe not needed when inlined.
    shlx    rdi, rdi, rsi ; rdi << ((63-pos) & 63)
    popcnt  rax, rdi
    sar     rdi, 63       ; broadcast the sign bit: rdi=0 or -1
    and     eax, edi      ; eax = 0 or its previous value
    ret

英特尔 Haswell 的性能分析:6 个融合域微指令(前端:每 1.5c 一个)。执行单位:2 p0/p6 shift uops。 1 p1 微指令。 2 个任意端口微指令:(总执行端口限制中每 1.25c 一个)。关键路径延迟:shlx(1) -> popcnt(3) -> and(1) = 5c bitset->result。 (或来自pos->result 的 6c)。

请注意,在内联时,人工(或智能编译器)可以避免使用 xor eax, eax。它只是因为popcnt's false dependency on the output register (on Intel) 而存在,我们需要eax 中的输出(调用者最近可能已将其用于长的dep 链)。使用-mtune=bdver2 或其他东西,gcc 不会将用于popcnt 输出的寄存器归零。

当内联时,我们可以使用至少早在popcnt 的源reg 就已经准备好的输出寄存器来避免这个问题。当以后不需要源时,编译器将就地执行popcnt rdi,rdi,但这里不是这种情况。相反,我们可以选择另一个在源之前已经准备好的寄存器。 popcnt 的输入依赖于63-pos,我们可以破坏它,所以popcnt rsi,rdi 对rsi 的依赖不能延迟它。或者如果我们在寄存器中有63,我们可以popcnt rsi,rdi/sarx rax, rsi, reg_63/and eax, esi。或者 BMI2 3 操作数移位指令也让我们不会破坏输入,以防以后需要它们。


这是非常轻量级的,循环开销和设置输入操作数/存储结果将成为主要因素。 (而63-pos 可以使用编译时常量进行优化,或者优化到变量计数的来源。)


Intel 编译器有趣地自责,并没有利用 A[63] 是符号位这一事实。 shl/bt rdi, 63/jc。它甚至以一种非常愚蠢的方式设置分支。它可以将eax归零,然后根据shl设置的符号标志跳过popcnt。

最佳分支实现,从 Godbolt 上 -O3 -march=corei7 的 ICC13 输出开始:

   // hand-tuned, not compiler output
        mov       ecx, esi    ; ICC uses neg/add/mov :/
        not       ecx
        xor       eax, eax    ; breaks the false dep, or is the return value in the taken-branch case
        shl       rdi, cl
        jns    .bit_not_set
        popcnt    rax, rdi
.bit_not_set:
        ret

这非常理想:A[pos] == true 案例有一个未采用的分支。不过,它并没有比无分支方法节省太多。

如果A[pos] == false 的情况更常见:跳过ret 指令,跳转到popcnt / ret。 (或者在内联之后:跳转到执行popcnt 的最后一个块并跳回)。

【讨论】:

  • high_bits_to_eliminate &amp; 63 不是多余的吗?
  • @GlennTeitelbaum:不,因为编译器不知道pos 的范围是[0..63]。在没有godbolt的情况下尝试一下,看看asm会发生什么。它在(uint64_t) pos &gt; 63U 上测试和分支。它类似于stackoverflow.com/questions/776508/…,其中源代码中的屏蔽与 x86 指令的工作方式一致,允许编译器使用它 检查或未定义的行为。 std::bitset::operator&lt;&lt; 看起来它会使计数饱和,当您移出所有位时会产生零结果。
  • 显然是ARM's shift instructions saturate the count,因此您可能会在 ARM 上通过不屏蔽获得更高效的代码。 (但随后使用超出范围的pos 调用该函数会导致未定义行为。blog.llvm.org/2011/05/what-every-c-programmer-should-know.html 提到了变化。)
  • 在没有可预测性的情况下,您对将 return A[63] ? A.count() : 0; 更改为 return A[63] * A.count(); 有何想法
  • @GlennTeitelbaum:有趣的是,令我惊讶的是,使用 gcc for x86-64 确实可以生成更好的代码。 xor/test/cmov 替换为 shr imm/imul r32,r32imul 是 1 uop,3 个周期延迟,因此延迟稍差,吞吐量稍好。两种方式在 x86-64 上都是无分支的,但只有 mul 版本在 ARM64 上是无分支的(不包括对 popcount 的函数调用)。 clang 以任何一种方式生成相同的代码,因为它通过乘以 0 或 1 值来查看。
【解决方案2】:

我的第一反应是测试指定的位,并立即返回 0,它是明确的。

如果您超过了这一点,请创建一个设置了该位(以及不太重要的位)的位掩码,并使用原始输入创建and。然后使用count()成员函数获取结果中设置的位数。

至于创建蒙版:可以将1左移N位,然后减1。

【讨论】:

  • 嗯,对于 0:(1&lt;&lt;0)-1==0 但如果设置了 1,我正在寻找它,这会检查下面的所有位,但不检查。然后我们可以添加 1。离开(bits[X]) ? bitset&lt;64&gt;((1UL &lt;&lt; x) - 1)).count() +1 : 0
  • @GlennTeitelbaum:我想我应该已经很清楚了,但我考虑的是基于 1 的位编号,所以对于最低有效位,它将是 (1所有 位,在这种情况下,您需要一种在减法之前至少可以容纳一个额外位的类型。
  • @JerryCoffin 在后一种情况下,您可以返回原始的count :)
  • @CompuChip:可以,但如果可能的话,我希望避免出现任何特殊情况。
  • std::bitset 基于 0,我不知道如何从 long long 获得额外的位
【解决方案3】:

假设unsigned longunsigned long long 大到足以容纳64 位,您可以调用bits.to_unlong()(或bits.to_ullong())以整数形式获取位集数据,屏蔽X 以上的位(@987654325 @) 然后计算您链接到的问题的答案中给出的那些位。

【讨论】:

    【解决方案4】:

    在位和位掩码之间转换很容易,所以这样的事情应该可以工作:

    int popcnt(bitset<64> bs, int x) {
        // Early out when bit not set
        if (!bs[x]) return 0;
        // Otherwise, make mask from `x`, mask and count bits
        return (bs & bitset<64>((1UL << x) - 1)).count() + 1;
    }
    

    这里的假设是bitset::count 被有效地实现(使用popcnt 内在函数或有效的后备);这不能保证,但 STL 人员倾向于优化这类事情。

    【讨论】:

    • 不确定您是否可以在long long 中进行 64 次换档
    • @GlennTeitelbaum:好点,移到添加一个并且只屏蔽低位。
    【解决方案5】:

    我编辑了一个我以前见过的问题,它会检查一个数字中设置的位数是奇数还是偶数。它适用于 C,但将它按摩到 C++ 中应该不会太难。解决方案的关键在于 while 循环中的内容。在纸上尝试一下,以了解它如何挑选出 LSB,然后将其从 x 中删除。其余的代码是直截了当的。代码在 O(n) 中运行,其中 n 是 x 中设置的位数。这比我认为只有在第一次看到这个问题时才有可能实现的线性时间要好得多。

    #include <stdio.h>
    
    int
    count(long x, int pos)
    {
        /* if bit at location pos is not set, return 0 */
        if (!((x >> pos) & 1))
        {
            return 0;
        }
    
        /* prepare x by removing set bits after position pos */
        long tmp = x;
        tmp = tmp >> (pos + 1);
        tmp = tmp << (pos + 1);
        x ^= tmp;
    
        /* increment count every time the first set bit of x is removed (from the right) */
        int y;
        int count = 0;
        while (x != 0)
        {
            y = x & ~(x - 1);
            x ^= y;
            count++;
        }
        return count;
    }
    
    int
    main(void)
    {
        /* run tests */
        long num = 0b1010111;
        printf("%d\n", count(num, 0)); /* prints: 1 */
        printf("%d\n", count(num, 1)); /* prints: 2 */
        printf("%d\n", count(num, 2)); /* prints: 3 */
        printf("%d\n", count(num, 3)); /* prints: 0 */
        printf("%d\n", count(num, 4)); /* prints: 4 */
        printf("%d\n", count(num, 5)); /* prints: 0 */
        printf("%d\n", count(num, 6)); /* prints: 5 */
    }
    

    【讨论】:

      猜你喜欢
      • 2010-10-19
      • 1970-01-01
      • 2012-08-23
      • 1970-01-01
      • 1970-01-01
      • 2014-01-09
      • 1970-01-01
      • 1970-01-01
      • 2020-02-14
      相关资源
      最近更新 更多