SSE Intrinsics和循环展开

4

我正在尝试优化一些循环,我已经做到了,但我想知道是否只做了部分正确的事情。比如说,我有以下这个循环:

for(i=0;i<n;i++){
b[i] = a[i]*2;
}

将其展开三倍后,结果如下:
int unroll = (n/4)*4;
for(i=0;i<unroll;i+=4)
{
b[i] = a[i]*2;
b[i+1] = a[i+1]*2;
b[i+2] = a[i+2]*2;
b[i+3] = a[i+3]*2;
}
for(;i<n;i++)
{
b[i] = a[i]*2;
} 

现在是SSE的翻译等效项:

__m128 ai_v = _mm_loadu_ps(&a[i]);
__m128 two_v = _mm_set1_ps(2);
__m128 ai2_v = _mm_mul_ps(ai_v, two_v);
_mm_storeu_ps(&b[i], ai2_v);

或者是这样的吗:
__m128 ai_v = _mm_loadu_ps(&a[i]);
__m128 two_v = _mm_set1_ps(2);
__m128 ai2_v = _mm_mul_ps(ai_v, two_v);
_mm_storeu_ps(&b[i], ai2_v);

__m128 ai1_v = _mm_loadu_ps(&a[i+1]);
__m128 two1_v = _mm_set1_ps(2);
__m128 ai_1_2_v = _mm_mul_ps(ai1_v, two1_v);
_mm_storeu_ps(&b[i+1], ai_1_2_v);

__m128 ai2_v = _mm_loadu_ps(&a[i+2]);
__m128 two2_v = _mm_set1_ps(2);
__m128 ai_2_2_v = _mm_mul_ps(ai2_v, two2_v);
_mm_storeu_ps(&b[i+2], ai_2_2_v);

__m128 ai3_v = _mm_loadu_ps(&a[i+3]);
__m128 two3_v = _mm_set1_ps(2);
__m128 ai_3_2_v = _mm_mul_ps(ai3_v, two3_v);
_mm_storeu_ps(&b[i+3], ai_3_2_v);

对于以下代码段,我有一些疑惑:

for(;i<n;i++)
{
b[i] = a[i]*2;
}

这是做什么用的?如果循环不能被您选择的展开因子整除,它只是为了执行额外的部分吗?谢谢。


1
我假设您已经检查了生成的目标代码并验证了您的编译器没有使用适当的标志为您执行此操作?试图智胜优化器是毫无意义的。 - Cody Gray
1
你可能会喜欢看一下Boost.SIMD,它看起来非常整洁。 - Maxim Egorushkin
2
为什么要使用 _mm_mul_ps 来乘以2?为什么不使用 _mm_sll_epi32 或者只是一个单独的 _mm_add_ps(ai_v, ai_v)?不需要单独的 two2_v - phuclv
2
@LưuVĩnhPhúc:说得好,但我认为你的意思是 _mm_add_ps 而不是 ss。此外,整数移位对 FP 数据不会产生所需的结果。 - Peter Cordes
像往常一样,这是一个不明智的尝试手动优化。我用gcc进行了优化模式下的测试。当然,循环被展开了,并且使用了SSE特定的指令。 - SergeyA
显示剩余4条评论
2个回答

4
答案在第一个区块中:
    __m128 ai_v = _mm_loadu_ps(&a[i]);
    __m128 two_v = _mm_set1_ps(2);
    __m128 ai2_v = _mm_mul_ps(ai_v,two_v);
    _mm_storeu_ps(&b[i],ai2_v);

它已经一次性处理了四个变量。

以下是完整的程序,其中等效部分的代码已被注释掉:

#include <iostream>

int main()
{
    int i{0};
    float a[10] ={1,2,3,4,5,6,7,8,9,10};
    float b[10] ={0,0,0,0,0,0,0,0,0,0};

    int n = 10;
    int unroll = (n/4)*4;
    for (i=0; i<unroll; i+=4) {
        //b[i] = a[i]*2;
        //b[i+1] = a[i+1]*2;
        //b[i+2] = a[i+2]*2;
        //b[i+3] = a[i+3]*2;
        __m128 ai_v = _mm_loadu_ps(&a[i]);
        __m128 two_v = _mm_set1_ps(2);
        __m128 ai2_v = _mm_mul_ps(ai_v,two_v);
        _mm_storeu_ps(&b[i],ai2_v);
    }

    for (; i<n; i++) {
        b[i] = a[i]*2;
    }

    for (auto i : a) { std::cout << i << "\t"; }
    std::cout << "\n";
    for (auto i : b) { std::cout << i << "\t"; }
    std::cout << "\n";

    return 0;
}

关于效率问题:似乎在我的系统上,汇编生成了movups指令,而手动编写的代码可以使用movaps,这应该更快。

我使用下面的程序进行基准测试:

#include <iostream>
//#define NO_UNROLL
//#define UNROLL
//#define SSE_UNROLL
#define SSE_UNROLL_ALIGNED

int main()
{
    const size_t array_size = 100003;
#ifdef SSE_UNROLL_ALIGNED
    __declspec(align(16)) int i{0};
    __declspec(align(16)) float a[array_size] ={1,2,3,4,5,6,7,8,9,10};
    __declspec(align(16)) float b[array_size] ={0,0,0,0,0,0,0,0,0,0};
#endif
#ifndef SSE_UNROLL_ALIGNED
    int i{0};
    float a[array_size] ={1,2,3,4,5,6,7,8,9,10};
    float b[array_size] ={0,0,0,0,0,0,0,0,0,0};
#endif

    int n = array_size;
    int unroll = (n/4)*4;


    for (size_t j{0}; j < 100000; ++j) {
#ifdef NO_UNROLL
        for (i=0; i<n; i++) {
            b[i] = a[i]*2;
        }
#endif
#ifdef UNROLL
        for (i=0; i<unroll; i+=4) {
            b[i] = a[i]*2;
            b[i+1] = a[i+1]*2;
            b[i+2] = a[i+2]*2;
            b[i+3] = a[i+3]*2;
        }
#endif
#ifdef SSE_UNROLL
        for (i=0; i<unroll; i+=4) {
            __m128 ai_v = _mm_loadu_ps(&a[i]);
            __m128 two_v = _mm_set1_ps(2);
            __m128 ai2_v = _mm_mul_ps(ai_v,two_v);
            _mm_storeu_ps(&b[i],ai2_v);
        }
#endif
#ifdef SSE_UNROLL_ALIGNED
        for (i=0; i<unroll; i+=4) {
            __m128 ai_v = _mm_load_ps(&a[i]);
            __m128 two_v = _mm_set1_ps(2);
            __m128 ai2_v = _mm_mul_ps(ai_v,two_v);
            _mm_store_ps(&b[i],ai2_v);
        }
#endif
#ifndef NO_UNROLL
        for (; i<n; i++) {
            b[i] = a[i]*2;
        }
#endif
    }

    //for (auto i : a) { std::cout << i << "\t"; }
    //std::cout << "\n";
    //for (auto i : b) { std::cout << i << "\t"; }
    //std::cout << "\n";

    return 0;
}

我得到了以下结果(x86):
  • NO_UNROLL0.994 秒,编译器未选择SSE指令集
  • UNROLL3.511 秒,使用movups
  • SSE_UNROLL3.315 秒,使用movups
  • SSE_UNROLL_ALIGNED3.276 秒,使用movaps

因此,在这种情况下展开循环并没有帮助。即使确保我们使用更有效的movaps也没有多大帮助。

但是当编译为64位(x64)时,我得到了一个更奇怪的结果:

  • NO_UNROLL1.138 秒,编译器未选择SSE指令集
  • UNROLL1.409 秒,编译器未选择SSE指令集
  • SSE_UNROLL1.420 秒,编译器仍未选择SSE指令集!
  • SSE_UNROLL_ALIGNED1.476 秒,编译器仍未选择SSE指令集!

看来MSVC能够理解优化的建议并生成更好的汇编代码。尽管如此,它仍然比没有尝试任何手动优化慢。


最近,Peter Cordes 向我解释了一个事实,即使用 MOVUPS 处理对齐数据时不会产生惩罚。这可能是编译器使用它的原因,因为它更通用。除非针对一些非常老的架构进行调优,否则没有强制性的理由发出 MOVAPS。需要明确的是,仍然会对非对齐数据访问进行惩罚,因此请确保如果您希望数据尽可能快地运行,请对其进行对齐。这可以通过内部函数或编译器提示/属性来完成。 - Cody Gray
无论如何,你也值得一加,因为你实际上进行了基准测试。这是Kieran应该从这些答案中吸取的真正教训。 - Cody Gray

2

通常情况下,手动展开循环并尝试手动匹配SSE指令并不高效。编译器比你更擅长此项工作。例如,提供的示例将被自动编译为启用SSE的汇编代码:

foo:
.LFB0:
    .cfi_startproc
    testl   %edi, %edi
    jle .L7
    movl    %edi, %esi
    shrl    $2, %esi
    cmpl    $3, %edi
    leal    0(,%rsi,4), %eax
    jbe .L8
    testl   %eax, %eax
    je  .L8
    vmovdqa .LC0(%rip), %xmm1
    xorl    %edx, %edx
    xorl    %ecx, %ecx
    .p2align 4,,10
    .p2align 3
.L6:
    addl    $1, %ecx
    vpmulld a(%rdx), %xmm1, %xmm0
    vmovdqa %xmm0, b(%rdx)
    addq    $16, %rdx
    cmpl    %esi, %ecx
    jb  .L6
    cmpl    %eax, %edi
    je  .L7
    .p2align 4,,10
    .p2align 3
.L9:
    movslq  %eax, %rdx
    addl    $1, %eax
    movl    a(,%rdx,4), %ecx
    addl    %ecx, %ecx
    cmpl    %eax, %edi
    movl    %ecx, b(,%rdx,4)
    jg  .L9
.L7:
    rep
    ret
.L8:
    xorl    %eax, %eax
    jmp .L9
    .cfi_endproc

循环也可以展开,但这会使代码变得更长,我不想在此处粘贴。你可以相信我 - 编译器会展开循环。
结论:手动展开没有好处。

3
姑且说这有点言过其实。编译器当然可以展开循环并产生向量化代码,这是正确的。但同样正确的是,在某些情况下手动展开循环会比现有编译器效果更好(有时优势很大)。这里有一个例子(https://dev59.com/Y-o6XIcBkEYKwwoYIAof#25578124)。 - Jerry Coffin
1
@JerryCoffin,你遇到了一个边角案例。顺便提一下,那是在2014年,也就是两个gcc编译器的版本之前。以我的经验来看,任何花费在手动展开循环上的时间都应该被用在其他方面。成本效益总是更好的。 - SergeyA
1
那个特定的测试是几年前进行的。上周我用gcc 6的预发布做了一些相似的事情,并获得了非常相似的结果。成本/效益...好吧,如果没有我的展开,我上周正在处理的代码根本无法跟上所需的数据速率,所以成本将是授权更快的ARM核心并设计新的硬件。我很难相信这比我花费约15分钟展开循环更便宜。 - Jerry Coffin
2
像Jerry一样,我对“相信你的编译器,不要手动优化”的普遍建议感到不满。我更喜欢希望编译器能够正确处理,然后进行验证。在非速度关键代码上,它做得足够好。在关键代码上,你可能会惊喜地发现它已经很好了,或者你可能会失望并需要花费15分钟到一个小时来手动优化。告诫人们永远不要这样做是完全错误的。但同时,没有查看编译器正在执行什么操作就过早地进行优化也是错误的。 - Cody Gray
总之:在这种特定情况下,这个答案是好的,因为你实际上检查了编译器的操作。但不要过度概括你的建议。手动展开并不是从来没有任何好处。 - Cody Gray

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