问题描述
有一个相对著名的技巧可以取消设置最右侧的单个位:
y = x & (x - 1) // 0b001011100 & 0b001011011 = 0b001011000 :)
我发现自己有一个紧密的循环来清除最右边的 n 位,但有没有更简单的代数技巧?
假设 n 相对较大(对于 64 位整数,n 必须
// x = 0b001011100 n=2
for (auto i=0; i<n; i++) x &= x - 1;
// x = 0b001010000
我已经翻了几次 TAOCP Vol4a,但找不到任何灵感。
也许有一些硬件支持?
解决方法
对于采用 BMI2 的 Intel x86 CPU,pext
和 pdep
速度很快。 AMD 的微编码 PEXT/PDEP 速度非常慢 (https://uops.info/) 所以要小心;其他选项在 AMD 上可能更快,甚至可能在循环中使用 blsi
,或者更好地对 popcount 进行二进制搜索(见下文)。
只有 Intel 有专门的硬件执行单元用于 pext/pdep 所做的掩码控制打包/解包,使其成为恒定时间:1 uop,3 周期延迟,只能在端口 1 上运行。
我不知道其他 ISA 具有类似的位打包硬件操作。
pdep
基础知识:pdep(-1ULL,a) == a
。从第一个操作数中取出低 popcnt(a) 位,并将它们存放在 a
已设置位的位置,将再次返回 a
。
但是,如果您的位源不是全 1,而是清除了低 N 位,则 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 带有一个简单的循环参考实现,表明它们对示例输入 a
和 n
产生相同的结果。
GCC 和 clang 都以显而易见的方式编译它,如下所示:
# GCC10.2 -O3 -march=skylake
unset_first_n_bits_bmi2(unsigned long,int):
mov rax,-1
shlx rax,rax,rsi
pdep rax,rdi
ret
(SHLX 是单 uop,1 个周期的延迟,与更新 FLAGS 的传统可变计数移位不同...除非 CL=0)
所以这从 a
-> 输出(只是 pdep)有 3 个周期的延迟
和 n
-> 输出(shlx、pdep)的 4 个周期延迟。
而且前端只有 3 uop。
一个半相关的 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
进行编译)。
调整这可能涉及选择一个可能的起点,并且可能使用最大初始步长而不是纯二分搜索。从尝试一些初始猜测中获得一些指令级并行性可能有助于缩短延迟瓶颈。