问题 快速计算两个数组之间的相等字节数


我写了这个函数 int compare_16bytes(__m128i lhs, __m128i rhs) 为了使用SSE指令比较两个16字节数:该函数返回执行比较后相等的字节数。

现在我想使用上面的函数来比较任意长度的两个字节数组:长度可能不是16字节的倍数,所以我需要处理这个问题。我怎样才能完成下面这个功能的实现?我怎样才能改进下面的功能?

int fast_compare(const char* s, const char* t, int length)
{
    int result = 0;

    const char* sPtr = s;
    const char* tPtr = t;

    while(...)
    {
        const __m128i* lhs = (const __m128i*)sPtr;
        const __m128i* rhs = (const __m128i*)tPtr;

        // compare the next 16 bytes of s and t
        result += compare_16bytes(*lhs,*rhs);

        sPtr += 16;
        tPtr += 16;
    }

    return result;
}

5176
2018-03-09 17:20


起源

使用for循环(长度/ 16次),如果剩余字节小于16,则将零填充为lhs和1到rhs。填充应该不同,以便它不会错误地将填充计数为相等。 - Oguz Meteer
while (length >= 16) { /* use your function */ length -= 16; } if (length) /* use a version that compares length (up to 15) bytes */; - pmg
仅供参考,这通常被称为 汉明距离  - 这可能有助于作为搜索词。 - Konrad Rudolph
C库包括类似的功能 memset() 可以处理任意数量的字节,但必须快速。对于速度,这些可以实现为内联函数,因此您可以在包含文件中找到它们的源。研究它们的实现方式可以帮助您解决这个问题。还要查看Agner Fog的asm库: agner.org/optimize/#asmlib - steveha
更好的方法是不使用你的 compare_16bytes 完全起作用并进行垂直比较/累积。然后在最后做一个减少。 (您还需要每255次迭代进行一次减少,以防止总和向量溢出。) - Mysticial


答案:


正如@Mysticial在上面的评论中所说,做垂直比较和求和,然后在主循环结束时水平求和:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <emmintrin.h>

// reference implementation
int fast_compare_ref(const char *s, const char *t, int length)
{
    int result = 0;
    int i;

    for (i = 0; i < length; ++i)
    {
        if (s[i] == t[i])
            result++;
    }
    return result;
}

// optimised implementation
int fast_compare(const char *s, const char *t, int length)
{
    int result = 0;
    int i;

    __m128i vsum = _mm_set1_epi32(0);
    for (i = 0; i < length - 15; i += 16)
    {
        __m128i vs, vt, v, vh, vl, vtemp;

        vs = _mm_loadu_si128((__m128i *)&s[i]); // load 16 chars from input
        vt = _mm_loadu_si128((__m128i *)&t[i]);
        v = _mm_cmpeq_epi8(vs, vt);             // compare
        vh = _mm_unpackhi_epi8(v, v);           // unpack compare result into 2 x 8 x 16 bit vectors
        vl = _mm_unpacklo_epi8(v, v);
        vtemp = _mm_madd_epi16(vh, vh);         // accumulate 16 bit vectors into 4 x 32 bit partial sums
        vsum = _mm_add_epi32(vsum, vtemp);
        vtemp = _mm_madd_epi16(vl, vl);
        vsum = _mm_add_epi32(vsum, vtemp);
    }

    // get sum of 4 x 32 bit partial sums
    vsum = _mm_add_epi32(vsum, _mm_srli_si128(vsum, 8));
    vsum = _mm_add_epi32(vsum, _mm_srli_si128(vsum, 4));
    result = _mm_cvtsi128_si32(vsum);

    // handle any residual bytes ( < 16)
    if (i < length)
    {
        result += fast_compare_ref(&s[i], &t[i], length - i);
    }

    return result;
}

// test harness
int main(void)
{
    const int n = 1000000;
    char *s = malloc(n);
    char *t = malloc(n);
    int i, result_ref, result;

    srand(time(NULL));

    for (i = 0; i < n; ++i)
    {
        s[i] = rand();
        t[i] = rand();
    }

    result_ref = fast_compare_ref(s, t, n);
    result = fast_compare(s, t, n);

    printf("result_ref = %d, result = %d\n", result_ref, result);;

    return 0;
}

编译并运行上面的测试工具:

$ gcc -Wall -O3 -msse3 fast_compare.c -o fast_compare
$ ./fast_compare
result_ref = 3955, result = 3955
$ ./fast_compare
result_ref = 3947, result = 3947
$ ./fast_compare
result_ref = 3945, result = 3945

请注意,在我们使用的上述SSE代码中有一个可能非显而易见的技巧 _mm_madd_epi16 打开并累积16位 0/-1 值为32位部分和。我们利用这个事实 -1*-1 = 1 (和 0*0 = 0 当然) - 我们在这里并没有真正做多,只需在一条指令中解包和求和。


更新:如下面的评论中所述,这个解决方案并不是最优的 - 我只采用了一个相当优化的16位解决方案,并添加了8位到16位解包,使其适用于8位数据。然而,对于8位数据,存在更有效的方法,例如,运用 psadbw/_mm_sad_epu8。我将把这个答案留给后人,对于那些可能想要用16位数据做这种事情的人,但实际上其中一个不需要解压缩输入数据的答案应该是接受的答案。


6
2018-03-09 21:50



大!它工作正常!而且,两个载体是否重要 s 和 t 是 对齐?对齐是什么? - enzom83
我用过 _mm_loadu_si128 在上面的例子中,这与对齐无关。如果你能保证 s 和 t 然后使用16字节对齐 _mm_load_si128 代替 _mm_loadu_si128 为了更好的性能,特别是在较旧的CPU上。 - Paul R
对于归零vsum,_mm_setzero_si128()可能比_mm_set1_epi32(0)更快。 - leecbaker
一个体面的编译器应该没有任何区别,但是,它可能并不是一个坏主意。 - Paul R
即使不展开也有更快的积累方式 psubb,只使用 psadbw / paddq。我把我的评论转化为答案。 - Peter Cordes


在16 x uint8元素中使用部分和可以提供更好的性能。
我把循环划分为内循环和外循环。
内循环求和uint8元素(每个uint8元素总和可达255“1”)。
小技巧:_mm_cmpeq_epi8将相等元素设置为0xFF,并且(char)0xFF = -1,因此可以从总和中减去结果(减去-1以添加1)。

这是我对fast_compare的优化版本:

int fast_compare2(const char *s, const char *t, int length)
{
    int result = 0;
    int inner_length = length;
    int i;
    int j = 0;

    //Points beginning of 4080 elements block.
    const char *s0 = s;
    const char *t0 = t;


    __m128i vsum = _mm_setzero_si128();

    //Outer loop sum result of 4080 sums.
    for (i = 0; i < length; i += 4080)
    {
        __m128i vsum_uint8 = _mm_setzero_si128(); //16 uint8 sum elements (each uint8 element can sum up to 255).
        __m128i vh, vl, vhl, vhl_lo, vhl_hi;

        //Points beginning of 4080 elements block.
        s0 = s + i;
        t0 = t + i;

        if (i + 4080 <= length)
        {
            inner_length = 4080;
        }
        else
        {
            inner_length = length - i;
        }

        //Inner loop - sum up to 4080 (compared) results.
        //Each uint8 element can sum up to 255. 16 uint8 elements can sum up to 255*16 = 4080 (compared) results.
        //////////////////////////////////////////////////////////////////////////
        for (j = 0; j < inner_length-15; j += 16)
        {
              __m128i vs, vt, v;

              vs = _mm_loadu_si128((__m128i *)&s0[j]); // load 16 chars from input
              vt = _mm_loadu_si128((__m128i *)&t0[j]);
              v = _mm_cmpeq_epi8(vs, vt);             // compare - set to 0xFF where equal, and 0 otherwise.

              //Consider this: (char)0xFF = (-1)
              vsum_uint8 = _mm_sub_epi8(vsum_uint8, v); //Subtract the comparison result - subtract (-1) where equal.
        }
        //////////////////////////////////////////////////////////////////////////

        vh = _mm_unpackhi_epi8(vsum_uint8, _mm_setzero_si128());        // unpack result into 2 x 8 x 16 bit vectors
        vl = _mm_unpacklo_epi8(vsum_uint8, _mm_setzero_si128());
        vhl = _mm_add_epi16(vh, vl);    //Sum high and low as uint16 elements.

        vhl_hi = _mm_unpackhi_epi16(vhl, _mm_setzero_si128());   //unpack sum of vh an vl into 2 x 4 x 32 bit vectors
        vhl_lo = _mm_unpacklo_epi16(vhl, _mm_setzero_si128());   //unpack sum of vh an vl into 2 x 4 x 32 bit vectors

        vsum = _mm_add_epi32(vsum, vhl_hi);
        vsum = _mm_add_epi32(vsum, vhl_lo);
    }

    // get sum of 4 x 32 bit partial sums
    vsum = _mm_add_epi32(vsum, _mm_srli_si128(vsum, 8));
    vsum = _mm_add_epi32(vsum, _mm_srli_si128(vsum, 4));
    result = _mm_cvtsi128_si32(vsum);

    // handle any residual bytes ( < 16)
    if (j < inner_length)
    {
        result += fast_compare_ref(&s0[j], &t0[j], inner_length - j);
    }

    return result;
}

3
2018-06-20 22:15



嘿,在评论保罗之前,我应该看看新的答案;我提出了同样的建议(psubb 在内环内)。这就是我的意思,除非你应该使用 psadbw 做水平和 vsum_uint8 (见我对保罗答案的评论)。 - Peter Cordes
我想过使用水平求和,但决定保持SSE2的兼容性。 - Rotem
你在说什么吗? phaddd?那不是我说的。 phaddd的 唯一的好处是代码大小 在当前的CPU上。另请参阅我对此问题的回答,该问题仅使用SSE2指令。 - Peter Cordes


大输入的最快方法是Rotem的答案,内循环是 pcmpeqb / psubb,在向量累加器的任何字节元素溢出之前断开到水平求和。使用无符号字节的hsum psadbw 反对全零向量。

如果没有展开/嵌套循环,最好的选择可能就是

pcmpeqb   -> vector of  0  or  0xFF  elements
psadbw    -> two 64bit sums of  (0*no_matches + 0xFF*matches)
paddq     -> accumulate the psadbw result in a vector accumulator

#outside the loop:
horizontal sum
divide the result by 255

如果循环中没有很多寄存器压力, psadbw 对矢量 0x7f 而不是全零。

  • psadbw(0x00, set1(0x7f)) => sum += 0x7f
  • psadbw(0xff, set1(0x7f)) => sum += 0x80

因此,而不是除以255(编译器应该在没有实际的情况下有效地执行 div),你只需要减去 n * 0x7f,哪里 n 是元素的数量。

另请注意 paddq 前Nehalem和Atom很慢,所以你可以使用 paddd (_mm_add_epi32)如果你不指望128 *计数永远溢出32位整数。

这与Paul R's相比非常好 pcmpeqb / 2x punpck / 2x pmaddwd / 2x paddw


2
2018-06-21 05:44





SSE中的整数比较产生全部为零或全部为1的字节。如果要计数,首先需要右移(不算术)比较结果7,然后添加到结果向量。 最后,您仍然需要通过对其元素求和来减少结果向量。这种减少必须在标量代码中完成,或者通过一系列添加/移位来完成。通常这部分不值得麻烦。


1
2018-03-09 17:59