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<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_UNROLL
:0.994 秒,编译器未选择 SSE
UNROLL
:3.511 秒,使用movups
SSE_UNROLL
:3.315 秒,使用movups
SSE_UNROLL_ALIGNED
:3.276 秒,使用movaps
因此很明显,在这种情况下展开循环并没有帮助。即使确保我们使用更高效的 movaps
也无济于事。
但在编译为 64 位 (x64) 时,我得到了一个更奇怪的结果:
NO_UNROLL
:1.138 秒,编译器未选择 SSE
UNROLL
:1.409 秒,编译器未选择 SSE
SSE_UNROLL
: 1.420 秒,编译器仍然没有选择 SSE!
SSE_UNROLL_ALIGNED
:1.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 和循环展开的主要内容,如果未能解决你的问题,请参考以下文章