如何取消设置 N 个最右边的设置位

How to unset N right-most set bits

取消设置最右边的单个位有一个相对著名的技巧:

y = x & (x - 1) // 0b001011100 & 0b001011011 = 0b001011000 :)

我发现自己有一个紧密的循环来清除最右边的 n 个位,但是有更简单的代数技巧吗?

假设 n 相对较大(对于 64 位整数,n 必须小于 64,但通常在 20-30 的数量级)。

// x = 0b001011100 n=2
for (auto i=0; i<n; i++) x &= x - 1;
// x = 0b001010000

翻了好几遍我的TAOCP Vol4a,还是找不到灵感。

也许有一些硬件支持?

对于带 BMI2 的 Intel x86 CPU,pext and pdep are fast. AMD before Zen3 has very slow microcoded PEXT/PDEP (https://uops.info/) 所以要小心;其他选项在 AMD 上可能更快,甚至可能 blsi 在一个循环中,或者更好地对 popcount 进行二进制搜索(见下文)。
只有 Intel 有专门的硬件执行单元用于 pext/pdep 做的掩码控制 pack/unpack,使其成为恒定时间:1 uop,3 个周期延迟,只能在端口 1 上 运行。

我不知道其他 ISA 有类似的位打包硬件操作。


pdep 基础知识pdep(-1ULL, a) == a。从第一个操作数中取出低 popcnt(a) 位,并将它们存放在 a 已设置位的位置,将再次返回 a

但是,如果您的位源清除了低 N 位而不是全 1,则 a 中的前 N ​​个设置位将获取 0 而不是 1。这正是您想要的.

uint64_t unset_first_n_bits_bmi2(uint64_t a, int n){
    return _pdep_u64(-1ULL << n, a);
}

-1ULL << n 适用于 C 中的 n=0..63。x86 asm 标量移位指令屏蔽了它们的计数(有效地 &63),因此 可能 较大 n 的 C 未定义行为会发生什么。如果您关心,请在源代码中使用 n&63,这样行为在 C 中定义明确,并且它仍然可以编译为直接使用计数的移位指令。

On Godbolt 带有一个简单的循环引用实现,表明它们对示例输入 an.

产生相同的结果

GCC 和 clang 都以显而易见的方式编译它,如下所示:

# GCC10.2 -O3 -march=skylake
unset_first_n_bits_bmi2(unsigned long, int):
        mov     rax, -1
        shlx    rax, rax, rsi
        pdep    rax, rax, rdi
        ret

(SHLX 是单 uop,1 个周期延迟,与更新 FLAGS 的传统可变计数移位不同...除非 CL=0)

所以这从 a->output (just pdep)
有 3 个周期延迟 和来自 n->output (shlx, pdep).

的 4 个周期延迟

而且前端只有 3 微码。


半相关 BMI2 技巧:

pext(a,a) 将打包底部的位 ,与 (1ULL<<popcnt(a)) - 1 类似,但如果所有位都已设置则不会溢出。

用 AND 掩码清除它的低 N 位,然后用 pdep 扩展就可以了。但这是创建具有 N 个零以上的足够位的位源的一种过于复杂且昂贵的方法,这对于 pdep 实际上很重要。感谢@harold 在此答案的第一个版本中发现了这一点。


没有快速 PDEP:也许二进制搜索正确的 popcount

@Nate 关于二进制搜索要清除多少低位的建议 可能是 pdep 的一个很好的替代方案。

popcount(x>>c) == popcount(x) - N 时停止以找出要清除多少低位,最好使用 c 的无分支更新。 (例如 c = foo ? a : b 通常编译为 cmov)。

完成搜索后,x & (-1ULL<<c) 会使用该计数,或者只是 tmp << c 将您已有的 x>>c 结果移回。直接使用右移比生成一个新掩码并在每次迭代中使用它更便宜。

高性能 popcount 在现代 CPU 上相对广泛可用。 (尽管 不是 x86-64 的基线;您仍然需要使用 -mpopcnt-march=native 进行编译。

调整它可能涉及选择一个可能的起点,并且可能使用最大初始步长而不是纯二进制搜索。通过尝试一些初始猜测获得一些指令级并行性可能有助于缩短延迟瓶颈。