使用Intel SSE2进行无溢出的无符号字节求和缩减

15

我正在尝试在Intel i3处理器上对32个元素(每个1字节数据)进行总和缩减。我做了这个:

s=0; 
for (i=0; i<32; i++)
{
    s = s + a[i];
}  

然而,由于我的应用程序是实时应用程序,需要更少的时间,所以它花费了更多的时间。 请注意,最终的总和可能会超过255。

是否有一种方法可以使用低级别的SIMD SSE2指令来实现这个功能?不幸的是,我从未使用过SSE。我尝试搜索此目的的SSE2函数,但也不可用。对于如此小的问题,SSE是否保证减少计算时间?

有什么建议吗?

注意:我已经使用OpenCL和CUDA实现了类似的算法,当问题规模较大时效果很好,但是对于小型问题,开销成本更高。不确定它在SSE上的运行方式如何。


是的,最终的总和可能大于255。 - gpuguy
3个回答

11
你可以使用 PSADBW 来计算字节的水平总和,而不会发生溢出。例如:
pxor    xmm0, xmm0
psadbw  xmm0, [a + 0]     ; sum in 2x 64-bit chunks
pxor    xmm1, xmm1
psadbw  xmm1, [a + 16]
paddw   xmm0, xmm1        ; accumulate vertically
pshufd  xmm1, xmm0, 2     ; bring down the high half
paddw   xmm0, xmm1   ; low word in xmm0 is the total sum
; movd  eax, xmm0    ; higher bytes are zero so efficient dword extract is fine

内部版本:

#include <immintrin.h>
#include <stdint.h>

// use loadu instead of load if 16-byte alignment of a[] isn't guaranteed
unsigned sum_32x8(const uint8_t a[32])
{
    __m128i zero = _mm_setzero_si128();
    __m128i sum0 = _mm_sad_epu8( zero,
                        _mm_load_si128(reinterpret_cast<const __m128i*>(a)));
    __m128i sum1 = _mm_sad_epu8( zero,
                        _mm_load_si128(reinterpret_cast<const __m128i*>(&a[16])));
    __m128i sum2 = _mm_add_epi32(sum0, sum1);
    __m128i totalsum = _mm_add_epi32(sum2, _mm_shuffle_epi32(sum2, 2));
    return _mm_cvtsi128_si32(totalsum);
}

这段文字的意思是:这个程序可以被轻松地编译成相同的汇编代码,你可以在Godbolt上看到。

reinterpret_cast<const __m128i*>是必要的,因为在AVX-512之前的Intel指令集中,整数向量的加载/存储需要__m128i*指针参数,而不是更方便的void*。有些人喜欢使用更紧凑的C风格转换,如_mm_loadu_si128((const __m128*)&a[16])作为样式选择。

16、32和64位SIMD元素大小并不重要;在所有机器上,16和32位同样有效,并且32位将避免溢出,即使您将其用于求和更大的数组。(paddq在一些旧CPU上(如Core 2)速度较慢;请参见https://agner.org/optimize/https://uops.info/)。提取为32位肯定比_mm_extract_epi16pextrw)更有效。


请问您能否为上述内容编写Intel® C++编译器内在等效项? - gpuguy
使用同样的方法来处理 int8_t (而非 uint8_t) :将范围移动到无符号数上(使用异或 0x80),然后从总数中减去 16 * 0x80。请参见 这个 Agner Fog 向量类库的内联汇编代码示例。同样的想法也适用于 AVX2 ymm 向量)。 - Peter Cordes

5

以下内容可能有点啰嗦,但它应该比标量代码快至少2倍:

uint16_t sum_32(const uint8_t a[32])
{
    const __m128i vk0 = _mm_set1_epi8(0);   // constant vector of all 0s for use with _mm_unpacklo_epi8/_mm_unpackhi_epi8
    __m128i v = _mm_load_si128(a);          // load first vector of 8 bit values
    __m128i vl = _mm_unpacklo_epi8(v, vk0); // unpack to two vectors of 16 bit values
    __m128i vh = _mm_unpackhi_epi8(v, vk0);
    __m128i vsum = _mm_add_epi16(vl, vh);
    v = _mm_load_si128(&a[16]);             // load second vector of 8 bit values
    vl = _mm_unpacklo_epi8(v, vk0);         // unpack to two vectors of 16 bit values
    vh = _mm_unpackhi_epi8(v, vk0);
    vsum = _mm_add_epi16(vsum, vl);
    vsum = _mm_add_epi16(vsum, vh);
    // horizontal sum
    vsum = _mm_add_epi16(vsum, _mm_srli_si128(vsum, 8));
    vsum = _mm_add_epi16(vsum, _mm_srli_si128(vsum, 4));
    vsum = _mm_add_epi16(vsum, _mm_srli_si128(vsum, 2));
    return _mm_extract_epi16(vsum, 0);
}

请注意,a[]需要16字节对齐。
您可以使用_mm_hadd_epi16来改进上述代码。

我该如何确保a[]是16字节对齐的?在SSE中是否有类似于CUDA中的__align__(16)的东西? - gpuguy
这取决于您使用的编译器和操作系统 - 例如,对于使用非动态分配的gcc,请使用__attribute__ ((aligned(16))) - 对于Linux上的动态分配,请使用memalign()posix_memalign() - Paul R
我将不得不对此进行负投票;虽然它可以工作,但 psadbw 才是正确的答案。对于有符号的 int8_t,您可以使用 xor 将范围移动到无符号,以将 0x80 添加到每个字节,并从结果中减去 16 * 0x80。(例如,参见 Agner Fog 的向量类库的 此补丁 中的内部函数。相同的思路也适用于 AVX2 ymm 向量)。但是这里的 OP 似乎已经是无符号的了,所以 psadbw 是一个巨大的胜利。 - Peter Cordes
1
@PeterCordes:嗯,我确实说过这有点冗长。;-) 但是,哈罗德的答案是更好的解决方案(当然我已经点赞了)。我应该删除这个回答,因为它并没有真正提供任何有用的信息。 - Paul R

0

使用SSE指令找到数组所有元素的和还有另一种方法。代码使用以下SSE结构。

  • __m256寄存器
  • _mm256_store_ps(float *a, __m256 b)
  • _mm256_add_ps(__m256 a, __m256 b)

该代码适用于任何大小的浮点数数组。

float sse_array_sum(float *a, int size)
{
    /*
     *   sum += a[i] (for all i in domain)
     */

    float *sse_sum, sum=0;
    if(size >= 8)
    {
        // sse_sum[8]
        posix_memalign((void **)&sse_sum, 32, 8*sizeof(float));

        __m256 temp_sum;
        __m256* ptr_a = (__m256*)a;
        int itrs = size/8-1;

        // sse_sum[0:7] = a[0:7]
        temp_sum = *ptr_a;
        a += 8;
        ptr_a++;

        for(int i=0; i<itrs; i++, ptr_a++, a+=8)
            temp_sum = _mm256_add_ps(temp_sum, *ptr_a);

        _mm256_store_ps(sse_sum, temp_sum);
        for(int i=0; i<8; i++)  sum += sse_sum[i];
    }

    // if size is not divisible by 8
    int rmd_itrs = size%8;
    // Note: a is pointing to remainder elements
    for(int i=0; i<rmd_itrs; i++)   sum += a[i];

    return sum;
}


float seq_array_sum(float *a, int size)
{
    /*
     *  sum += a[i] (for all i)
     */

    float sum = 0;
    for(int i=0; i<size; i++)   sum += a[i];
    return sum;
}

基准测试:

大小 = 64000000
对于所有在域中的i,a[i] = 3141592.65358

顺序版本时间:194毫秒
SSE版本时间:49毫秒

机器规格:

每个核心线程数:2
每个插槽核心数:2
插槽数:1
CPU MHz: 1700.072
操作系统:Ubuntu


1
首先,这个问题是关于对uint8_t[]求和的。更重要的是,动态分配临时存储肯定不是一个好的选择。像正常人一样使用一个__m256 sum临时变量即可。(或者更好的方法是使用多个累加器来隐藏FP延迟。如果你想使用本地的“数组”,编译器通常会优化掉它们并将它们全部保留在寄存器中)。而且绝对不要在内部循环中使用_mm256_store_ps(),虽然那也可能被优化掉。 - Peter Cordes
1
此外,如果 a 没有按照 32 对齐,你的代码就不安全了;你会对一个 __m256* 进行解引用,而不是使用 _mm256_loadu_ps - Peter Cordes
1
你需要的是一个AVX __m256版本的SSE浮点向量缩减。请注意它内部循环的简单性。对于最后的水平求和,请参见最快的SSE向量和(或其他缩减)方法。将其存储到alignas(32) float tmp[8]中是一种选择,但您可以通过使用洗牌来做得更好。此外,像这个问答中所示,展开主循环并使用多个累加器来隐藏FP延迟。 - Peter Cordes
感谢反馈。你说得对。下次我会注意以下两点:1)回答问题时具体明确;2)获取正确的背景信息。其实我刚开始使用SSE指令,想着这可能会帮到别人。 - dwijesh522
1
仅供参考:__m256 需要 AVX。一般来说,把所有的 x86 SIMD 都称为 "SSE" 并不完全错误,但通常人们至少会区分 SSE* vs. AVX1 / AVX2 / FMA vs. AVX512。无论如何,感谢你的帮助,但不幸的是,这个答案并没有展示出好的解决方法,即使你在 SSE reduction of float vector 上发布它回答正确的问题,我也会给它点个踩。 - Peter Cordes
1
当你第一次学习某个东西时,这是很正常的。不要因为你的第一次尝试过于复杂和不安全(仅适用于对齐的a,并且会泄漏动态分配的内存 - 就像我说的,只需使用本地数组或使用洗牌来进行hsum)而感到难过。但是这并不改变它不是其他初学者或任何人的好示例,因此也不是一个好的Stack Overflow答案的事实。也许https://codereview.stackexchange.com/是发布此类尝试以获取有关如何正确执行操作的反馈的好地方。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接