快速计算数组中零值字节的数量
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 在比较结果上累积计数,将其视为-1
或0
的向量。即使在全零或全非零的情况下,这也比这更有效。即使保持简单并在每次迭代中对 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)>>8;
会这样做。
@Jongware 你的代码在values[i] != 0
时会增加,所以我认为zeroCount += (values[i] == 0)
更正确
@LưuVĩnhPhúc:OP 认为“计数 non 零”同样是一个很好的解决方案。我提出的表达方式是为了避免显式比较(我知道仍然可能在程序集级别出现)。以上是关于快速计算数组中零值字节的数量的主要内容,如果未能解决你的问题,请参考以下文章