SSE微优化指令顺序

SSE micro-optimization instruction order

本文关键字:顺序 指令 优化 SSE      更新时间:2023-10-16

我注意到有时MSVC 2010根本不重新排序SSE指令。我认为我不必关心循环中的指令顺序,因为编译器可以最好地处理它,但事实似乎并非如此。

我该怎么想呢?什么决定了最好的指令顺序?我知道有些指令比其他指令有更高的延迟,有些指令可以在cpu级别上并行/异步运行。在上下文中哪些指标是相关的?我在哪里可以找到它们?

我知道我可以通过分析来避免这个问题,但是这样的分析器是昂贵的(VTune XE)和我想知道它背后的理论,而不仅仅是经验结果。

我还应该关心软件预取(_mm_prefetch)还是我可以假设cpu会比我做得更好?

假设我有以下函数。我应该穿插一些说明吗?我是否应该在流之前进行存储,按顺序进行所有加载,然后再进行计算等等?我是否需要考虑USWC与非USWC,以及临时与非临时?

            auto cur128     = reinterpret_cast<__m128i*>(cur);
            auto prev128    = reinterpret_cast<const __m128i*>(prev);
            auto dest128    = reinterpret_cast<__m128i*>(dest;
            auto end        = cur128 + count/16;
            while(cur128 != end)            
            {
                auto xmm0 = _mm_add_epi8(_mm_load_si128(cur128+0), _mm_load_si128(prev128+0));
                auto xmm1 = _mm_add_epi8(_mm_load_si128(cur128+1), _mm_load_si128(prev128+1));
                auto xmm2 = _mm_add_epi8(_mm_load_si128(cur128+2), _mm_load_si128(prev128+2));
                auto xmm3 = _mm_add_epi8(_mm_load_si128(cur128+3), _mm_load_si128(prev128+3));
                                    // dest128 is USWC memory
                _mm_stream_si128(dest128+0, xmm0);  
                _mm_stream_si128(dest128+1, xmm1);
                _mm_stream_si128(dest128+2, xmm2);;
                _mm_stream_si128(dest128+3, xmm3);
                                    // cur128 is temporal, and will be used next time, which is why I choose store over stream
                _mm_store_si128 (cur128+0, xmm0);               
                _mm_store_si128 (cur128+1, xmm1);                   
                _mm_store_si128 (cur128+2, xmm2);                   
                _mm_store_si128 (cur128+3, xmm3);
                cur128  += 4;
                dest128 += 4;
                prev128 += 4;
            }
           std::swap(cur, prev);

我同意每个人都认为测试和调整是最好的方法。但是有一些技巧可以帮助它。

首先,MSVC 重新排序SSE指令。你的例子可能太简单或者已经是最优的了。

一般来说,如果你有足够的寄存器这样做,完全交错往往会得到最好的结果。更进一步,展开循环以使用所有寄存器,但不要过多溢出。在您的示例中,循环完全由内存访问绑定,因此没有太多的空间可以做得更好。

在大多数情况下,没有必要使指令的顺序完美以达到最佳性能。只要它"足够接近",编译器或硬件的乱序执行都会为你修复它。

我用来确定代码是否最优的方法是关键路径和瓶颈分析。写完循环后,我查找哪些指令使用了哪些资源。使用这些信息,我可以计算性能的上限,然后将其与实际结果进行比较,看看我离最佳值有多近/多远。

例如,假设我有一个有100个加法和50个乘法的循环。在Intel和AMD (pre-推土机)上,每个内核每个周期可以维持一个SSE/AVX加法和一个SSE/AVX乘法。因为我的循环有100个添加,所以我知道我不能做得比100个循环更好。是的,乘法器有一半的时间是空闲的,但是加法器才是瓶颈。

现在我对循环计时每次迭代得到105个循环。这意味着我已经非常接近最优值了,不会有更多的增益了。但如果我得到250个循环,那就意味着循环出了问题,值得再修改一下。

关键路径分析遵循相同的思想。查找所有指令的延迟,并找到循环的关键路径的周期时间。如果你的实际表现非常接近它,你已经是最佳的了。

Agner Fog对当前处理器的内部细节有一个很好的参考:http://www.agner.org/optimize/microarchitecture.pdf

我刚刚使用VS2010 32位编译器构建了这个,我得到以下内容:

void F (void *cur, const void *prev, void *dest, int count)
{
00901000  push        ebp  
00901001  mov         ebp,esp  
00901003  and         esp,0FFFFFFF8h  
  __m128i *cur128     = reinterpret_cast<__m128i*>(cur);
00901006  mov         eax,220h  
0090100B  jmp         F+10h (901010h)  
0090100D  lea         ecx,[ecx]  
  const __m128i *prev128    = reinterpret_cast<const __m128i*>(prev);
  __m128i *dest128    = reinterpret_cast<__m128i*>(dest);
  __m128i *end        = cur128 + count/16;
  while(cur128 != end)            
  {
    auto xmm0 = _mm_add_epi8(_mm_load_si128(cur128+0), _mm_load_si128(prev128+0));
00901010  movdqa      xmm0,xmmword ptr [eax-220h]  
    auto xmm1 = _mm_add_epi8(_mm_load_si128(cur128+1), _mm_load_si128(prev128+1));
00901018  movdqa      xmm1,xmmword ptr [eax-210h]  
    auto xmm2 = _mm_add_epi8(_mm_load_si128(cur128+2), _mm_load_si128(prev128+2));
00901020  movdqa      xmm2,xmmword ptr [eax-200h]  
    auto xmm3 = _mm_add_epi8(_mm_load_si128(cur128+3), _mm_load_si128(prev128+3));
00901028  movdqa      xmm3,xmmword ptr [eax-1F0h]  
00901030  paddb       xmm0,xmmword ptr [eax-120h]  
00901038  paddb       xmm1,xmmword ptr [eax-110h]  
00901040  paddb       xmm2,xmmword ptr [eax-100h]  
00901048  paddb       xmm3,xmmword ptr [eax-0F0h]  
    // dest128 is USWC memory
    _mm_stream_si128(dest128+0, xmm0);  
00901050  movntdq     xmmword ptr [eax-20h],xmm0  
    _mm_stream_si128(dest128+1, xmm1);
00901055  movntdq     xmmword ptr [eax-10h],xmm1  
    _mm_stream_si128(dest128+2, xmm2);;
0090105A  movntdq     xmmword ptr [eax],xmm2  
    _mm_stream_si128(dest128+3, xmm3);
0090105E  movntdq     xmmword ptr [eax+10h],xmm3  
    // cur128 is temporal, and will be used next time, which is why I choose store over stream
    _mm_store_si128 (cur128+0, xmm0);               
00901063  movdqa      xmmword ptr [eax-220h],xmm0  
    _mm_store_si128 (cur128+1, xmm1);                   
0090106B  movdqa      xmmword ptr [eax-210h],xmm1  
    _mm_store_si128 (cur128+2, xmm2);                   
00901073  movdqa      xmmword ptr [eax-200h],xmm2  
    _mm_store_si128 (cur128+3, xmm3);
0090107B  movdqa      xmmword ptr [eax-1F0h],xmm3  
    cur128  += 4;
00901083  add         eax,40h  
00901086  lea         ecx,[eax-220h]  
0090108C  cmp         ecx,10h  
0090108F  jne         F+10h (901010h)  
    dest128 += 4;
    prev128 += 4;
  }
}

表示编译器正在重新排序指令,遵循"在写入寄存器后不要立即使用寄存器"的一般规则。它还将两次加载和一次添加转换为一次加载和一次内存添加。您没有理由不能自己编写这样的代码,并使用所有SIMD寄存器,而不是当前使用的四个寄存器。您可能希望将加载的字节总数与缓存行的大小相匹配。这将给硬件预取一个机会在你需要它之前填充下一个缓存行。

同样,预取,特别是在顺序读取内存的代码中,通常是不必要的。MMU最多可以一次预取4个流

你可能会发现英特尔架构优化参考手册的第5章到第7章非常有趣,它详细说明了英特尔认为你应该如何编写最佳的SSE代码,并且它详细说明了你正在询问的许多问题。

我还想推荐英特尔®架构代码分析器:

https://software.intel.com/en-us/articles/intel-architecture-code-analyzer

它是一个静态代码分析器,可以帮助找出/优化关键路径、延迟和吞吐量。它适用于Windows、Linux和MacOs(我只在Linux上试用过)。文档中有一个中等简单的例子来说明如何使用它(即,如何通过重新排序指令来避免延迟)。