"Count the number of set bits in a 32-bit integer"的意思是“统计32位整数中设置为1的位数”。
"Count the number of set bits in a 32-bit integer"的意思是“统计32位整数中设置为1的位数”。
用8位二进制表示数字7,看起来像这样:
00000111
三个位被设置为1。
如何确定32位整数中设置的位数的算法?
这被称为“汉明重量”,“popcount”或“侧向加法”。
有些 CPU 有单个内置指令可以执行此操作,而其他 CPU 则有并行指令可以操作位向量。诸如 x86 的popcnt
(如果支持),将几乎肯定是单个整数最快的。其他一些架构可能会使用一个慢速指令,该指令通过微编码循环每周期测试一个 bit(需要引证 - 如果硬件 popcount 存在,通常是很快的)。
“最佳”算法实际上取决于你所在的 CPU 和你的使用模式。
你的编译器可能知道如何为你编译的特定 CPU 执行某些好的操作,例如C++20 的 std::popcount()
或 C++ 的std::bitset<32>::count()
,作为访问 builtin/intrinsic 函数的便携式方式(请参见这个问题的另一个答案)。但是,针对没有硬件 popcnt 的目标 CPU,你的编译器选择的回退可能不适合你的用例。或者你的语言(例如 C)可能不会公开任何可便携使用特定 CPU 的 popcount 的函数。
不需要(或无法受益于)任何硬件支持的便携式算法
如果你的 CPU 有一个大缓存并且你在一个紧密循环中执行大量这些操作,则预填充表查找方法可能非常快。但是,它可能会受到“缓存未命中”的影响,其中 CPU 必须从主存中获取某个表中的一些数据。(单独查找每个字节可以使表较小。)如果你要对一系列连续的数字进行 popcount,只有低字节会针对 256 个数字组发生变化,使其非常好。
如果你知道你的字节将主要为 0 或 1,则有针对这些场景的有效算法,例如在循环中使用位 hack 清除最低设置,直到其变为零。
我相信一个非常好的通用算法是以下算法,称为“并行”或“可变精度 SWAR 算法”。我用类似 C 的伪语言表达了这个算法,你可能需要调整它以适合某种语言(例如,在 C++ 中使用 uint32_t 和在 Java 中使用 >>>):
GCC10 和 clang 10.0 可以识别此模式/习惯用法,并在可用时将其编译为硬件 popcnt 或等效指令,让你拥有最好的两个世界。(https://godbolt.org/z/qGdh1dvKK)
int numberOfSetBits(uint32_t i) { // Java: use int, and use >>> instead of >>. Or use Integer.bitCount() // C or C++: use uint32_t i = i - ((i >> 1) & 0x55555555); // add pairs of bits i = (i & 0x33333333) + ((i >> 2) & 0x33333333); // quads i = (i + (i >> 4)) & 0x0F0F0F0F; // groups of 8 return (i * 0x01010101) >> 24; // horizontal sum of bytes }
对于JavaScript代码:转换成整数会提升性能,使用|0
:将第一行改为i = (i|0) - ((i >> 1) & 0x55555555);
该算法讨论中具有最佳平均复杂度,可以有效处理各种用法模式和值。在常规CPU上,所有整数操作包括乘法的时间都是恒定的,它的性能并不基于数据,因此无论输入多么“简单”,其性能都不会变得更快,但仍然非常不错。
参考文献:
- https://graphics.stanford.edu/~seander/bithacks.html
- https://catonmat.net/low-level-bit-hacks介绍的二进制技巧基础知识,例如通过减1来反转连续的零。
- https://en.wikipedia.org/wiki/Hamming_weight
- http://gurmeet.net/puzzles/fast-bit-counting-routines/
- http://aggregate.ee.engr.uky.edu/MAGIC/#Population%20Count%20(Ones%20Count)
这个SWAR二进制技巧如何工作:
i = i - ((i >> 1) & 0x55555555);
第一步是一个优化版的掩码操作,用于隔离奇/偶比特位,并将它们移位对齐,然后相加。这实际上在2比特的累加器中完成了16次单独的加法(SWAR = 在寄存器内的SIMD),类似于(i & 0x55555555) + ((i>>1) & 0x55555555)
。
下一步是将这16个2比特的累加器中的奇/偶八个再次相加,产生8个4比特的和。这一次不可能使用i-...优化,因此只是在移位之前/之后执行掩码操作。两次使用相同的0x33...
常量而不是0xccc...
常量是一个好事情,因为在编译ISA需要单独在寄存器中构造32位常量时,可以省去不必要的操作。
(i + (i >> 4)) & 0x0F0F0F0F
的最后一步将其扩大为4个8比特的累加器。在加法之后而不是之前执行掩码操作,因为对应输入比特的任何4个比特累加器中的最大值都是4
,即所有4比特都设置。4 + 4 = 8仍适用于4比特,所以在i + (i >> 4)
到目前为止,这只是使用SWAR技术进行相当正常的SIMD技术,并具有一些巧妙的优化。继续使用相同的模式进行两个步骤,可以将其扩大为2个16比特,然后是1个32比特的统计。但是,在具有快速硬件乘法的计算机上,有更高效的方法:
一旦我们有足够少的“元素”,乘以一个魔法常数可以将所有元素相加到顶部元素。 在这种情况下是字节元素。乘法是通过左移和加法完成的,因此乘以x * 0x01010101
的结果是x + (x<<8) + (x<<16) + (x<<24)
。我们的8位元素足够宽(且计数较小),因此这不会产生进入那个顶部8位的进位。
64位版本可以使用0x0101010101010101乘数在64位整数中做8x 8位元素,并使用>>56
提取高字节。因此,它不需要任何额外的步骤,只需要更宽的常量。这是GCC在x86系统上使用__builtin_popcountll
时的做法,当硬件popcnt
指令未启用时。如果您可以使用内置函数或内部函数,则应这样做,以使编译器有机会执行特定于目标的优化。
对于更宽的向量完全使用SIMD(例如统计整个数组)
这个按位Swar算法可以并行化为同时在多个向量元素中进行,而不是在单个整数寄存器中进行,对于在拥有SIMD但无可用popcount指令的CPU上进行加速非常有用(例如要在任何CPU上运行的x86-64代码,而不仅仅是在Nehalem或更高版本上)。
然而,使用矢量指令进行popcount的最佳方法通常是使用可变的Shuffle来对每个字节的4位进行表查找以进行并行处理。 (这4位将索引存储在矢量寄存器中的16个条目表中)。
在英特尔CPU上,硬件64位popcnt指令可以比SSSE3 PSHUFB位并行实现快大约2倍,但只有如果您的编译器做得恰到好处才有可能。否则,SSE可能会明显领先。最新版本的编译器已经意识到了在英特尔上的popcnt false dependency问题。
- https://github.com/WojciechMula/sse-popcount是SSSE3,AVX2,AVX512BW,AVX512VBMI或AVX512 VPOPCNT的最先进的x86 SIMD popcount。使用Harley-Seal跨向量推迟在元素内部进行popcount。(也是ARM NEON)
- 使用AVX-512或AVX-2在大数据上进行1位计数(人口计数)
- 相关:https://github.com/mklarqvist/positional-popcount - 多个8、16、32或64位整数的每个位位置的分开计数。 (同样,x86 SIMD,包括非常擅长此项任务的AVX-512,
vpternlogd
使Harley-Seal非常好。)