快速计算数组中零值字节的数量

Posted

技术标签:

【中文标题】快速计算数组中零值字节的数量【英文标题】:Quickly count number of zero-valued bytes in an array 【发布时间】:2014-01-04 22:49:36 【问题描述】:

有什么方法可以快速计算大型连续数组中零值字节的数量? (或者相反,非零字节的数量。)大,我的意思是 216 字节或更大。数组的位置和长度可以由任何字节对齐组成。

天真的方式:

int countZeroBytes(byte[] values, int length)

    int zeroCount = 0;
    for (int i = 0; i < length; ++i)
        if (!values[i])
            ++zeroCount;

    return zeroCount;

对于我的问题,我通常只维护zeroCount 并根据对values 的具体更改进行更新。但是,在对values 进行任意批量更改后,我希望有一种快速、通用的方法来重新计算zeroCount。我敢肯定有一个有点麻烦的方法可以更快地完成这项工作,但是唉,我只是一个新手。

编辑:有些人询问过零校验数据的性质,所以我将对其进行描述。 (不过,如果解决方案仍然是通用的,那就太好了。)

基本上,设想一个由voxels(例如Minecraft)组成的世界,程序生成的地形被分隔成立方,或者有效地索引为三维数组的内存页面。每个体素作为一个独特的字节进行飞行加权,对应于一种独特的材料(空气、石头、水等)。许多块仅包含空气或水,而其他块则包含大量 2-4 个体素的不同组合(泥土、沙子等),其中 2-10% 的体素是随机异常值。大量存在的体素往往沿每个轴高度聚集。

不过,在许多不相关的情况下,零字节计数方法似乎很有用。因此,需要一个通用的解决方案。

【问题讨论】:

寻找“人口计数”硬件指令和编译器内在函数(例如here for MSVC),并将它们应用于可用的最大字长。 可能有一些方法可以利用特定的架构(例如特定的操作码,或 x86 上的 SSE),但总的来说,我怀疑有什么方法可以更快地做到这一点。即使是查找表(例如 16 位块)也可能无济于事,因为它们只会破坏缓存。 最佳答案可能取决于您是否期望零很常见。如果不是这样,一次对大(例如 64 位)字进行按位“零”操作可能是最好的方法,允许您跳过没有零的大型运行。但是请注意别名违规... 也许strlen 的一些棘手实现可能会有所帮助...(通过uint32_t* 迭代而不是幼稚的uint8_t)。 没有将此作为答案自动发布,但您看过relevant code in bit twiddling hacks吗? 【参考方案1】:

这是How to count character occurrences using SIMD 和c=0 的一个特例,要计算匹配的字符(字节)值。请参阅问答,了解 char_count (char const* vector, size_t size, char c); 的经过良好优化的手动向量化 AVX2 实现,其内部循环比这更紧密,避免将每个 0/-1 匹配向量分别减少为标量。


这将是 O(n) 所以你能做的最好的就是减少常数。一种快速解决方法是删除分支。如果零是随机分布的,这将给出与我下面的 SSE 版本一样快的结果。这可能是由于 GCC 对这个循环进行了矢量化。但是,对于零的长时间运行或零的随机密度小于 1%,以下 SSE 版本仍然更快。

int countZeroBytes_fix(char* values, int length) 
    int zeroCount = 0;
    for(int i=0; i<length; i++) 
        zeroCount += values[i] == 0;
    
    return zeroCount;

我最初认为零的密度很重要。事实证明并非如此,至少在 SSE 中是这样。与密度无关,使用 SSE 的速度要快得多。

编辑:实际上,它确实取决于密度,它只是零的密度必须小于我的预期。 1/64 个零(1.5% 的零)是 1/4 中的一个零SSE 注册,因此分支预测不能很好地工作。但是,1/1024 个零(0.1% 的零)更快(参见时间表)。

如果数据有长时间的零运行,SIMD 会更快。

您可以将 16 个字节打包到 SSE 寄存器中。然后,您可以使用_mm_cmpeq_epi8 一次将所有 16 个字节与零进行比较。然后要处理零运行,您可以在结果上使用_mm_movemask_epi8,大多数情况下它将为零。在这种情况下,您可以获得高达 16 的加速(对于前半部分 1 和后半部分零,我获得了超过 12 倍的加速)。

这是 2^16 字节(重复 10000 次)以秒为单位的时间表。

                     1.5% zeros  50% zeros  0.1% zeros 1st half 1, 2nd half 0
countZeroBytes       0.8s        0.8s       0.8s        0.95s
countZeroBytes_fix   0.16s       0.16s      0.16s       0.16s
countZeroBytes_SSE   0.2s        0.15s      0.10s       0.07s

您可以在http://coliru.stacked-crooked.com/a/67a169ddb03d907a查看最后 1/2 个零的结果

#include <stdio.h>
#include <stdlib.h>
#include <emmintrin.h>                 // SSE2
#include <omp.h>

int countZeroBytes(char* values, int length) 
    int zeroCount = 0;
    for(int i=0; i<length; i++) 
        if (!values[i])
            ++zeroCount;
    
    return zeroCount;


int countZeroBytes_SSE(char* values, int length) 
    int zeroCount = 0;
    __m128i zero16 = _mm_set1_epi8(0);
    __m128i and16 = _mm_set1_epi8(1);
    for(int i=0; i<length; i+=16) 
        __m128i values16 = _mm_loadu_si128((__m128i*)&values[i]);
        __m128i cmp = _mm_cmpeq_epi8(values16, zero16);
        int mask = _mm_movemask_epi8(cmp);
        if(mask) 
            if(mask == 0xffff) zeroCount += 16;
            else 
                cmp = _mm_and_si128(and16, cmp); //change -1 values to 1
                //hortiontal sum of 16 bytes
                __m128i sum1 = _mm_sad_epu8(cmp,zero16);
                __m128i sum2 = _mm_shuffle_epi32(sum1,2);
                __m128i sum3 = _mm_add_epi16(sum1,sum2);
                zeroCount += _mm_cvtsi128_si32(sum3);
            
        
    
    return zeroCount;


int main() 
    const int n = 1<<16;
    const int repeat = 10000;
    char *values = (char*)_mm_malloc(n, 16);
    for(int i=0; i<n; i++) values[i] = rand()%64;  //1.5% zeros
    //for(int i=0; i<n/2; i++) values[i] = 1;
    //for(int i=n/2; i<n; i++) values[i] = 0;
    
    int zeroCount = 0;
    double dtime;
    dtime = omp_get_wtime();
    for(int i=0; i<repeat; i++) zeroCount = countZeroBytes(values,n);
    dtime = omp_get_wtime() - dtime;
    printf("zeroCount %d, time %f\n", zeroCount, dtime);
    dtime = omp_get_wtime();
    for(int i=0; i<repeat; i++) zeroCount = countZeroBytes_SSE(values,n);
    dtime = omp_get_wtime() - dtime;
    printf("zeroCount %d, time %f\n", zeroCount, dtime);       

【讨论】:

您只需要每 127 次迭代或其他东西进行水平求和(使用 SAD),以避免溢出。在此之前,您可以使用 PADDB 在比较结果上累积计数,将其视为-10 的向量。即使在全零或全非零的情况下,这也比这更有效。即使保持简单并在每次迭代中对 64 位计数器的向量求和也是相当不错的。 (即循环内的 PCMPEQB / PSADBW / PADDQ,加上一个或两个 MOVDQA)。【参考方案2】:

我提供了这个 OpenMP 实现,它可以利用每个处理器的本地缓存中的数组来实际并行读取它。

nzeros_total = 0;
#pragma omp parallel for reduction(+:nzeros_total)
    for (i=0;i<NDATA;i++)
    
        if (v[i]==0)
            nzeros_total++;
    

一个快速的基准测试,包括运行 1000 次 for 循环和一个幼稚的实现(与 OP 在问题中写的相同)与 OpenMP 实现,也运行 1000 次,这两种方法都花费了最好的时间,由 65536 个整数组成的数组,零值元素概率为 50%,在 QuadCore CPU 上使用 Windows 7,并使用 VStudio 2012 Ultimate 编译,得到以下数字:

               DEBUG               RELEASE
Naive method:  580 microseconds.   341 microseconds.
OpenMP method: 159 microseconds.    99 microseconds.

注意:我已经尝试过#pragma loop (hint_parallel(4)),但显然,这并没有导致原始版本的性能更好,所以我的猜测是编译器已经在应用这种优化,或者根本无法应用.此外,#pragma loop (no_vector) 并没有导致幼稚版本的性能变差。

【讨论】:

“在所有缓存中”假设是一个很大的假设,但如果它成立,这是一种简单但有效的技术。 如果您删除分支并执行nzeros_total += v[i] == 0,这可能会更快。 虽然这看起来很有效,但遗憾的是我目前没有多余的处理器可用。不过谢谢。 我已将此标记为最佳答案,因为它看起来既便携又高效。但是,如果有人要发明便携、快速、非平凡和非并行的东西,我会很乐意接受。 (除了惊讶!)【参考方案3】:

您也可以使用 POPCNT 指令返回设置的位数。这允许通过消除不必要的分支来进一步简化代码并加速它。以下是 AVX2 和 POPCNT 的示例:

#include <stdint.h>
#include <stdlib.h>
#include <stdio.h>
#include "immintrin.h"

int countZeroes(uint8_t* bytes, int length)

    const __m256i vZero = _mm256_setzero_si256();
    int count = 0;
    for (int n = 0; n < length; n += 32)
    
        __m256i v = _mm256_load_si256((const __m256i*)&bytes[n]);
        v = _mm256_cmpeq_epi8(v, vZero);
        int k = _mm256_movemask_epi8(v);
        count += _mm_popcnt_u32(k);
    
    return count;


#define SIZE 1024

int main()

    uint8_t bytes[SIZE] __attribute__((aligned(32)));

    for (int z = 0; z < SIZE; ++z)
        bytes[z] = z % 2;

    int n = countZeroes(bytes, SIZE);
    printf("%d\n", n);

    return 0;

【讨论】:

您可以通过将 cmpeq_epi8 结果用作 0 / -1 整数来收紧内部循环,将它们累加到向量累加器中,然后仅在末尾加起来:How to count character occurrences using SIMD。与 movemask + popcnt + scalar-add 相比,内部循环中的工作更少,但需要嵌套循环以避免大 length 溢出。【参考方案4】:

对于常见 0 的情况,一次检查 64 个字节会更快,并且只检查跨度非零的字节。如果零很少见,这将更昂贵。此代码假定大块可被 64 整除。这也假定 memcmp 尽可能高效。

int countZeroBytes(byte[] values, int length)

    static const byte zeros[64]=;

    int zeroCount = 0;
    for (int i = 0; i < length; i+=64)
    
        if (::memcmp(values+i, zeros, 64) == 0)
        
             zeroCount += 64;
        
        else
        
               for (int j=i; j < i+64; ++j)
               
                     if (!values[j])
                     
                          ++zeroCount;
                     
               
        
    

    return zeroCount;

【讨论】:

嗯,有时数据完全为零,有时很少。不过我喜欢的是,如果之前的 zeroCount 很高,我可以使用它,或者如果 zeroCount 超过某个阈值,甚至可以在固定时间间隔在这种方法和另一种方法之间交换。【参考方案5】:

蛮力计数零字节:使用向量比较指令,如果该字节为 0,则将向量的每个字节设置为 1,如果该字节不为零,则设置为 0。

执行 255 次以最多处理 255 x 64 字节(如果您有 512 位指令可用,或者如果您只有 128 位向量,则为 255 x 32 或 255 x 16 字节)。然后您只需将 255 个结果向量相加即可。由于比较后的每个字节的值都是 0 或 1,每个总和最多为 255,因此您现在有一个 64 / 32 / 16 字节的向量,低于大约 16,000 / 8,000 / 4,000 字节。

【讨论】:

【参考方案6】:

避免这种情况并将其换成查找和添加可能会更快:

char isCharZeroLUT[256] =  1 ; /* 1 0 0 ... */
int zeroCount = 0;
for (int i = 0; i < length; ++i) 
    zeroCount += isCharZeroLUT[values[i]];

不过,我还没有测量差异。还值得注意的是,一些编译器很乐意将足够简单的循环向量化。

【讨论】:

@Jongware:编译器可能(可能?)已经在做类似的事情,即避免条件分支。 @Jongware:可能。我不确定这是否可以通过一些位操作或使用内部条件来完成。它很有可能同时避免了条件和查找。 如果您的 C 实现具有(实现定义的)负值右移的通常定义,count -= ((unsigned char)values[i]-1)&gt;&gt;8; 会这样做。 @Jongware 你的代码在values[i] != 0 时会增加,所以我认为zeroCount += (values[i] == 0) 更正确 @LưuVĩnhPhúc:OP 认为“计数 non 零”同样是一个很好的解决方案。我提出的表达方式是为了避免显式比较(我知道仍然可能在程序集级别出现)。

以上是关于快速计算数组中零值字节的数量的主要内容,如果未能解决你的问题,请参考以下文章

有效地计算numpy数组中的零元素?

计算快速排序算法中组件明智比较的数量。

如何快速计算二进制列表中 0(s) 的数量? [复制]

计算多字节字符的数量

如何从数组中并行删除零值

有效计算 arm neon 中 16 字节缓冲区中不同值的数量