统计整数数组各比特位置位次数的高效算法与位运算技巧
我有大量32位(32b)数值,需要统计全量数据中每个第n位为真(置1)的出现总次数。由于该操作是整个仿真程序的性能瓶颈,我需要尽可能快的实现方案。我编写了一个针对8位数值的朴素C++实现用于举例说明问题:
#include <iostream> #include <vector> #include <cstdint> std::vector<uint32_t> vertical_popcount(std::vector<uint8_t>& data) { std::vector<uint32_t> result({0, 0, 0, 0, 0, 0, 0, 0}); for (auto i = 0; i < data.size(); i++) { result[0] += (data[i] & 0b10000000) > 0; result[1] += (data[i] & 0b01000000) > 0; result[2] += (data[i] & 0b00100000) > 0; result[3] += (data[i] & 0b00010000) > 0; result[4] += (data[i] & 0b00001000) > 0; result[5] += (data[i] & 0b00000100) > 0; result[6] += (data[i] & 0b00000010) > 0; result[7] += (data[i] & 0b00000001) > 0; } return result; } int main() { std::vector<uint8_t> data({0b00000001, 0b00000100, 0b00000101}); auto result = vertical_popcount(data); std::cout << "occurrence of bits: " << result[0] << ", " << result[1] << ", " << result[2] << ", " << result[3] << ", " << result[4] << ", " << result[5] << ", " << result[6] << ", " << result[7] << "\n"; return 0; }
是否存在可以实现相同功能、且运行速度大幅提升的算法?
你的朴素实现性能差的核心原因是存在冗余的比较操作,且没有利用现代CPU的位操作能力和数据级并行特性,从易到难有几档优化,性能可以提升3~30倍不等:
第一档:无分支标量优化(性能提升2~4倍)
首先去掉所有>0的判断逻辑:位与操作得到的非零值不需要比较,直接把对应位右移到最低位,和1做与操作就能直接得到0/1的计数值,编译器可以直接把这部分逻辑编译成无分支的指令序列,完全消除分支预测开销。
优化后的8位数据处理代码如下:
#include <cstdint> #include <vector> std::vector<uint32_t> vertical_popcount_scalar(const std::vector<uint8_t>& data) { uint32_t cnt[8] = {0}; const size_t n = data.size(); const uint8_t* ptr = data.data(); for (size_t i = 0; i < n; i++) { const uint8_t x = ptr[i]; cnt[0] += (x >> 7) & 1; cnt[1] += (x >> 6) & 1; cnt[2] += (x >> 5) & 1; cnt[3] += (x >> 4) & 1; cnt[4] += (x >> 3) & 1; cnt[5] += (x >> 2) & 1; cnt[6] += (x >> 1) & 1; cnt[7] += x & 1; } return {cnt, cnt + 8}; }
如果要处理32位数据,只需要把cnt数组长度改成32,循环内依次移位取位即可,逻辑完全一致。打开O2/O3优化时,这段代码的效率已经比朴素实现高很多。
第二档:分块popcount批量统计(性能提升8~15倍)
利用CPU原生的popcount指令(x86下的popcnt,ARM下的cnt),每64个元素分块处理:把64个元素的同一比特位拼成一个64位整数,直接用一次popcount指令得到这64个元素中该位的置1总数,不需要逐元素累加。
32位数据的分块实现示例:
#include <cstdint> #include <vector> #include <immintrin.h> // 包含硬件popcnt指令声明 std::vector<uint64_t> vertical_popcount_block(const uint32_t* data, size_t n) { uint64_t cnt[32] = {0}; size_t i = 0; // 每次处理64个32位元素 for (; i + 63 < n; i += 64) { for (int b = 0; b < 32; b++) { uint64_t bit_block = 0; for (int k = 0; k < 64; k++) { bit_block |= static_cast<uint64_t>((data[i + k] >> b) & 1) << k; } cnt[b] += _mm_popcnt_u64(bit_block); } } // 处理剩余不足64个的尾部元素 for (; i < n; i++) { const uint32_t x = data[i]; for (int b = 0; b < 32; b++) { cnt[b] += (x >> b) & 1; } } return {cnt, cnt + 32}; }
编译器会自动把内层拼bit_block的循环优化成高效的位提取序列,配合硬件popcnt指令,吞吐量比标量版本高一个量级。
第三档:SIMD向量化(性能提升20~30倍)
如果你的CPU支持AVX2/AVX-512指令集,可以直接用512位宽的寄存器一次处理16个32位整数,通过SIMD移位、掩码、按字节累加指令批量统计每一位的计数,不需要逐位循环。这一档优化可以把性能拉到内存带宽上限,也就是数据从内存读到CPU的速度有多快,统计速度就有多快,是目前量产CPU上的最优实现方案。
- 编译时一定要打开O2/O3优化,以及对应平台的指令集选项(比如x86下GCC/Clang加
-march=native,MSVC下加/arch:AVX2),否则所有优化都不会生效。 - 尽量直接访问数组的底层指针,避免在热循环中调用std::vector的[]运算符做冗余的边界检查(开O2后多数情况会被优化掉,但手动写更稳妥)。
- 计数值根据数据总量选择合适的位宽:如果数据量不超过42亿,用uint32_t存计数即可,超过的话换uint64_t,避免溢出。
内容的提问来源于stack exchange,提问作者toothgoblin

