SSE Intrinsics 和循环展开

Posted

技术标签:

【中文标题】SSE Intrinsics 和循环展开【英文标题】:SSE Intrinsics and loop unrolling 【发布时间】:2016-06-25 14:15:19 【问题描述】:

我正在尝试优化一些循环并且我已经做到了,但我想知道我是否只做了部分正确。例如说我有这个循环:

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

将其展开 3 倍,得到:

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;

这是做什么的?是否只是做额外的部分,例如,如果循环不能被您选择展开它的因素整除?谢谢。

【问题讨论】:

您询问的最后一部分实际上涉及其余部分。如果 n 是整数,则n/4 将丢弃余数。所以for(;i&lt;n;i++) 做了最后一点。 我假设您已经检查了生成的目标代码并验证了您的编译器还没有为您执行此操作,即使使用了适当的标志?试图超越优化器毫无意义。 你可能想看看Boost.SIMD,它看起来很整洁。 为什么要用_mm_mul_ps乘以2?为什么不使用_mm_sll_epi32 或只使用一个_mm_add_ps(ai_v, ai_v)?不需要单独的two2_v @LưuVĩnhPhúc:说得好,但我认为您的意思是_mm_add_ps,而不是ss。此外,整数移位不会在 FP 数据上给出所需的结果。 【参考方案1】:

答案是第一块:

    __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 i0;
    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 i0;
    __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 i0;
    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 j0; 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_UNROLL: 1.420 秒,编译器仍然没有选择 SSE! SSE_UNROLL_ALIGNED1.476 秒,编译器仍然没有选择 SSE!

似乎 MSVC 看穿了提案并生成了更好的程序集,尽管仍然比我们根本没有尝试任何手动优化时要慢。

【讨论】:

最近,Peter Cordes 告诉我,在使用 MOVUPS 对齐数据时,您不会付出任何代价。这可能就是编译器使用它的原因,因为它更通用。没有令人信服的理由发出 MOVAPS,除非可能是在调整一些非常古老的架构时。需要明确的是,未对齐的数据访问仍然会受到惩罚,因此如果您希望数据尽可能快,请确保对齐数据。这可以通过内在函数或编译器提示/属性来完成 无论如何,也为您+1 以实际对其进行基准测试。这是 Kieran 应该从这些答案中吸取的真正教训。【参考方案2】:

像往常一样,展开循环并尝试手动匹配 SSE 指令效率不高。编译器可以比你做得更好。例如,提供的示例会自动编译成支持 SSE 的 ASM:

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

循环也可以展开,它只会产生更长的代码,我不想在这里粘贴。你可以相信我——编译器会展开循环。

结论

手动展开对你没有好处。

【讨论】:

这是夸大其词,委婉地说。编译器当然可以并且可以展开循环,并且它们也可以生成向量代码,这是真的。然而,在某些情况下,手动展开的循环击败了当前的编译器,这也是事实(有时差距很大)。这是one example。 @JerryCoffin,你遇到了麻烦。 2014年,顺便说一句。 2代之前的gcc编译器。根据我的经验,花在手动循环展开上的任何时间都应该花在其他方面。成本效益总是会更好。 那个特殊的测试是几年前的事了。上周我对 gcc 6 的预发布做了一些非常相似的事情,并且得到了非常相似的结果。成本/收益...好吧,如果没有展开,我上周编写的代码根本无法跟上所需的数据速率,因此成本将是许可更快的 ARM 内核并设计新硬件。我很难相信这会比我花 15 分钟(左右)展开循环的成本更低。 像 Jerry 一样,我对“相信你的编译器,不要手动优化”的一般建议感到愤怒。我更喜欢希望编译器能正确处理,然后检查验证。在非速度关键代码上,它做得足够好。在关键代码上,您可能会发现自己惊喜不已,或者您可能会感到失望,需要花 15 分钟到一个小时卷起袖子进行手动优化。告诫人们永远不要这样做是完全错误的。但与此同时,过早优化而不查看编译器在做什么同样是错误的。 总之:这个答案在这种特殊情况下很好,因为您实际上已经检查过编译器做了什么。但不要过度概括你的建议。手动展开从不没有任何好处。

以上是关于SSE Intrinsics 和循环展开的主要内容,如果未能解决你的问题,请参考以下文章

优化编译器如何决定何时展开循环以及展开循环的程度?

为啥 clang 无法展开循环(即 gcc 展开)?

循环展开与循环平铺

如何在 C++ 中展开嵌套的 for 循环?

C/C++ 中的自展开宏循环

使用循环展开计算正数、负数和零数的最有效方法