【问题标题】:Counting 1 bits (population count) on large data using AVX-512 or AVX-2使用 AVX-512 或 AVX-2 对大数据计数 1 位(人口计数)
【发布时间】:2018-04-28 22:04:34
【问题描述】:

我有一大块内存,比如 256 KiB 或更长。我想计算整个块中 1 的位数,或者换句话说:将所有字节的“人口计数”值相加。

我知道 AVX-512 有一个 VPOPCNTDQ instruction 计算 512 位向量内每个连续 64 位中 1 位的数量,而 IIANM 应该可以在每个周期发出其中一个(如果适当SIMD 向量寄存器可用)——但我没有任何编写 SIMD 代码的经验(我更像是一个 GPU 专家)。另外,我不能 100% 确定编译器是否支持 AVX-512 目标。

在大多数 CPU 上,仍然不(完全)支持 AVX-512;但 AVX-2 已广泛使用。我无法找到类似于 VPOPCNTDQ 的小于 512 位的矢量化指令,所以即使理论上我也不确定如何使用支持 AVX-2 的 CPU 快速计算位数;也许这样的东西存在,我只是不知何故错过了它?

无论如何,我很欣赏一个简短的 C/C++ 函数 - 使用一些内部包装库或内联汇编 - 用于两个指令集中的每一个。签名是

uint64_t count_bits(void* ptr, size_t size);

注意事项:

【问题讨论】:

  • 据我所知,目前还没有任何硅胶支持vpopcntdq。 AVX2 和 SSE 都没有类似的指令,尽管存在标量 popcnt。更多想法请参考[本文]()0x80.pl/articles/sse-popcount.html
  • @fuz:Knight's Mill 应该已经有了,而且那些从去年就已经出来了。
  • 哦,是的,我完全忘记了这些。不过,你不会在野外看到。
  • 我可以确认 article 中的 avx2-lookup 方法对于大小在 64 KB - 512 MB 范围内的缓冲区是最有效的。您甚至可以通过在多个线程中对数组进行分区然后将所有本地 popcount 相加来做得更好。
  • avx2-lookup 即使在单核上也是最好的。

标签: assembly avx2 avx512 bitcount population-count


【解决方案1】:

AVX-2

@HadiBreis 的评论链接到 article 关于 SSSE3 的快速人口计数,作者 Wojciech Muła;文章链接到this GitHub repository;和存储库 has 以下 AVX-2 实现。它基于向量化查找指令,并使用 16 值查找表来计算半字节的位数。

#   include <immintrin.h>
#   include <x86intrin.h>

std::uint64_t popcnt_AVX2_lookup(const uint8_t* data, const size_t n) {

    size_t i = 0;

    const __m256i lookup = _mm256_setr_epi8(
        /* 0 */ 0, /* 1 */ 1, /* 2 */ 1, /* 3 */ 2,
        /* 4 */ 1, /* 5 */ 2, /* 6 */ 2, /* 7 */ 3,
        /* 8 */ 1, /* 9 */ 2, /* a */ 2, /* b */ 3,
        /* c */ 2, /* d */ 3, /* e */ 3, /* f */ 4,

        /* 0 */ 0, /* 1 */ 1, /* 2 */ 1, /* 3 */ 2,
        /* 4 */ 1, /* 5 */ 2, /* 6 */ 2, /* 7 */ 3,
        /* 8 */ 1, /* 9 */ 2, /* a */ 2, /* b */ 3,
        /* c */ 2, /* d */ 3, /* e */ 3, /* f */ 4
    );

    const __m256i low_mask = _mm256_set1_epi8(0x0f);

    __m256i acc = _mm256_setzero_si256();

#define ITER { \
        const __m256i vec = _mm256_loadu_si256(reinterpret_cast<const __m256i*>(data + i)); \
        const __m256i lo  = _mm256_and_si256(vec, low_mask); \
        const __m256i hi  = _mm256_and_si256(_mm256_srli_epi16(vec, 4), low_mask); \
        const __m256i popcnt1 = _mm256_shuffle_epi8(lookup, lo); \
        const __m256i popcnt2 = _mm256_shuffle_epi8(lookup, hi); \
        local = _mm256_add_epi8(local, popcnt1); \
        local = _mm256_add_epi8(local, popcnt2); \
        i += 32; \
    }

    while (i + 8*32 <= n) {
        __m256i local = _mm256_setzero_si256();
        ITER ITER ITER ITER
        ITER ITER ITER ITER
        acc = _mm256_add_epi64(acc, _mm256_sad_epu8(local, _mm256_setzero_si256()));
    }

    __m256i local = _mm256_setzero_si256();

    while (i + 32 <= n) {
        ITER;
    }

    acc = _mm256_add_epi64(acc, _mm256_sad_epu8(local, _mm256_setzero_si256()));

#undef ITER

    uint64_t result = 0;

    result += static_cast<uint64_t>(_mm256_extract_epi64(acc, 0));
    result += static_cast<uint64_t>(_mm256_extract_epi64(acc, 1));
    result += static_cast<uint64_t>(_mm256_extract_epi64(acc, 2));
    result += static_cast<uint64_t>(_mm256_extract_epi64(acc, 3));

    for (/**/; i < n; i++) {
        result += lookup8bit[data[i]];
    }

    return result;
}

AVX-512

同一存储库还具有基于 VPOPCNT 的 AVX-512 实现。在列出它的代码之前,这里是简化且更易读的伪代码:

  • 对于每个连续的 64 字节序列:

    • 将序列加载到 64x8 = 512 位的 SIMD 寄存器中
    • 在该寄存器上执行 8 次 64 位的并行填充计数
    • 将 8 个人口计数结果并行添加到包含 8 个和的“累加器”寄存器中
  • 累加器中的8个值相加

  • 如果尾部小于 64 字节,则以更简单的方式计算其中的位数

  • 返回主和加上尾和

现在是真正的交易:

#   include <immintrin.h>
#   include <x86intrin.h>

uint64_t avx512_vpopcnt(const uint8_t* data, const size_t size) {
    
    const size_t chunks = size / 64;

    uint8_t* ptr = const_cast<uint8_t*>(data);
    const uint8_t* end = ptr + size;

    // count using AVX512 registers
    __m512i accumulator = _mm512_setzero_si512();
    for (size_t i=0; i < chunks; i++, ptr += 64) {
        
        // Note: a short chain of dependencies, likely unrolling will be needed.
        const __m512i v = _mm512_loadu_si512((const __m512i*)ptr);
        const __m512i p = _mm512_popcnt_epi64(v);

        accumulator = _mm512_add_epi64(accumulator, p);
    }

    // horizontal sum of a register
    uint64_t tmp[8] __attribute__((aligned(64)));
    _mm512_store_si512((__m512i*)tmp, accumulator);

    uint64_t total = 0;
    for (size_t i=0; i < 8; i++) {
        total += tmp[i];
    }

    // popcount the tail
    while (ptr + 8 < end) {
        total += _mm_popcnt_u64(*reinterpret_cast<const uint64_t*>(ptr));
        ptr += 8;
    }

    while (ptr < end) {
        total += lookup8bit[*ptr++];
    }

    return total;
}

lookup8bit 是用于字节而非位的 popcnt 查找表,定义为 here编辑:正如评论者所说,最后使用 8 位查找表不是一个好主意,可以改进。

【讨论】:

  • 我没有试验avx512-harley-sealavx512-vpopcnt,所以我不知道它们是否更快。
  • 我希望 avx512-vpopcnt 对于大缓冲区大小更快,但我们需要一个 Ice LakeKnights Mill 处理器来进行实验。
  • @HadiBrais AFAIK,Harley-Seal 版本仅适用于没有vpopcnt 但有 AVX512BW 的 AVX512 CPU。即 Skylake-AVX512 但不是 KNL。在Large (0,1) matrix multiplication using bitwise AND and popcount instead of actual int or float multiplies? 上查看我的回答。如果 IceLake 只在一个端口上运行 VPOPCNT,那么混合方法可能会使两个向量 ALU 都保持忙碌,但假设 VPOPCNT 是单微指令,它肯定会赢。 Harley-Seal 需要 30x VPTERNLOGD + 1 个向量 popcnt 用于 16x ZMM 向量。
  • @PeterCordes:您不会使用另一个端口将之前的计数添加到运行总和中吗?
  • 我认为任何带有vpopcntq 的 Intel CPU 都很有可能将其作为单个 uop 运行,否则几乎不值得。 (而且它不是一大组其他指令的一部分,因此不想花费晶体管的 CPU 可以省略该功能位)。如果 AMD 构建具有 256b 执行单元的 AVX512 CPU,它可能是 2 uop,但 vpternlogd 也是如此。 Haswell 运行 vdivps ymm 作为 3 微指令,我猜是在一个半角分隔线 + 一个合并微指令上。因此,英特尔不太可能将其作为 2 微指令运行。即使是 2,Harley-Seal 对于很多向量来说仍然是一个微小的胜利,否则就是一个更大的胜利。
【解决方案2】:

Wojciech Muła's big-array popcnt functions 看起来是最优的,除了标量清理循环。 (有关主循环的详细信息,请参阅@einpoklum 的答案)。

最后只使用几次的 256 条目 LUT 可能会缓存未命中,即使缓存很热,也不是超过 1 个字节的最佳选择。我相信所有 AVX2 CPU 都有硬件 popcnt,我们可以轻松地隔离最后最多 8 个尚未计算的字节,以便为单个 popcnt 设置我们。

与 SIMD 算法一样,在缓冲区的 last 字节处进行全角加载通常效果很好。但与向量寄存器不同的是,全整数寄存器的可变计数移位很便宜(尤其是 BMI2)。 Popcnt 不关心位的位置,因此我们可以只使用移位而不需要构造 AND 掩码或其他任何东西。

// untested
// ptr points at the first byte that hasn't been counted yet
uint64_t final_bytes = reinterpret_cast<const uint64_t*>(end)[-1] >> (8*(end-ptr));
total += _mm_popcnt_u64( final_bytes );
// Careful, this could read outside a small buffer.

或者更好的是,使用更复杂的逻辑来避免页面交叉。例如,这可以避免页面开头的 6 字节缓冲区的页面交叉。

【讨论】:

  • 我明白了转移负载的技巧,当然。但是我为什么要尽量避免页面交叉呢?我的意思是,如果我的数据跨页,我为什么不应该跨页?
  • @einpoklum:您不希望一次加载跨越页面边界。在 Skylake 上,它实际上只花费与缓存行拆分大致相同的成本,但在早期的 CPU 上,它会额外花费约 100 个周期。如果您的缓冲区只有 6 个字节长,则与缓冲区的 end 对齐的 8 字节负载将跨入没有数据的前一页,可能会出现段错误。 (正在进行更新以讨论 Harley-Seal,并说明哪个版本可能在哪个 CPU 上是最佳的。我也会澄清那个页面交叉点。)
猜你喜欢
  • 1970-01-01
  • 2022-06-14
  • 2017-10-16
  • 1970-01-01
  • 1970-01-01
  • 2017-12-23
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多