shufps比内存访问慢吗?

时间:2017-02-11 10:44:05

标签: c++ assembly sse simd

标题可能看起来很废话,但让我解释一下。前几天,当我遇到以下汇编代码时,我正在研究一个程序:

movaps  xmm3, xmmword ptr [rbp-30h]
lea     rdx, [rdi+1320h]
movaps  xmm5, xmm3
movaps  xmm6, xmm3
movaps  xmm0, xmm3
movss   dword ptr [rdx], xmm3
shufps  xmm5, xmm3, 55h
shufps  xmm6, xmm3, 0AAh
shufps  xmm0, xmm3, 0FFh
movaps  xmm4, xmm3
movss   dword ptr [rdx+4], xmm5
movss   dword ptr [rdx+8], xmm6
movss   dword ptr [rdx+0Ch], xmm0
mulss   xmm4, xmm3

似乎主要只是将[rbp-30h]的四个浮点数复制到[rdx]。这些shufps仅用于选择xmm3中的四个浮点值之一(例如shufps xmm5, xmm3, 55h选择第二个浮点数并将其放在xmm5中)。

这让我想知道编译器是否这样做是因为shufps实际上比内存访问更快(类似movss xmm0, dword ptr [rbp-30h]movss dword ptr [rdx], xmm0)。

所以我写了一些测试来比较这两种方法,发现shufps总是慢于多次内存访问。现在我想,使用shufps可能与性能无关。它可能只是在那里混淆代码,因此反编译器不能轻易生成干净的代码(尝试使用IDA专业版,它确实过于复杂)。

虽然我可能永远不会在任何实际程序中明确地使用shufps(例如使用_mm_shuffle_ps),因为编译器很可能比我聪明,我仍然想知道为什么编译程序编译程序生成了这样的代码。它既不快也不小。这毫无意义。

无论如何,我会提供我在下面写的测试。

#include <Windows.h>
#include <iostream>

using namespace std;

__declspec(noinline) DWORD profile_routine(void (*routine)(void *), void *arg, int iterations = 1)
{
    DWORD startTime = GetTickCount();
    while (iterations--)
    {
        routine(arg);
    }
    DWORD timeElapsed = GetTickCount() - startTime;
    return timeElapsed;
}


struct Struct
{
    float x, y, z, w;
};

__declspec(noinline) Struct shuffle1(float *arr)
{
    float x = arr[3];
    float y = arr[2];
    float z = arr[0];
    float w = arr[1];

    return {x, y, z, w};
}


#define SS0     (0x00)
#define SS1     (0x55)
#define SS2     (0xAA)
#define SS3     (0xFF)
__declspec(noinline) Struct shuffle2(float *arr)
{
    Struct r;
    __m128 packed = *reinterpret_cast<__m128 *>(arr);

    __m128 x = _mm_shuffle_ps(packed, packed, SS3);
    __m128 y = _mm_shuffle_ps(packed, packed, SS2);
    __m128 z = _mm_shuffle_ps(packed, packed, SS0);
    __m128 w = _mm_shuffle_ps(packed, packed, SS1);

    _mm_store_ss(&r.x, x);
    _mm_store_ss(&r.y, y);
    _mm_store_ss(&r.z, z);
    _mm_store_ss(&r.w, w);

    return r;
}



void profile_shuffle_r1(void *arg)
{
    float *arr = static_cast<float *>(arg);
    Struct q = shuffle1(arr);
    arr[0] += q.w;
    arr[1] += q.z;
    arr[2] += q.y;
    arr[3] += q.x;
}
void profile_shuffle_r2(void *arg)
{
    float *arr = static_cast<float *>(arg);
    Struct q = shuffle2(arr);
    arr[0] += q.w;
    arr[1] += q.z;
    arr[2] += q.y;
    arr[3] += q.x;
}

int main(int argc, char **argv)
{
    int n = argc + 3;
    float arr1[4], arr2[4];
    for (int i = 0; i < 4; i++)
    {
        arr1[i] = static_cast<float>(n + i);
        arr2[i] = static_cast<float>(n + i);
    }

    int iterations = 20000000;
    DWORD time1 = profile_routine(profile_shuffle_r1, arr1, iterations);
    cout << "time1 = " << time1 << endl;
    DWORD time2 = profile_routine(profile_shuffle_r2, arr2, iterations);
    cout << "time2 = " << time2 << endl;

    return 0;
}

在上面的测试中,我有两个shuffle方法shuffle1shuffle2执行相同的操作。使用MSVC -O2编译时,它会生成以下代码:

shuffle1:
 mov         eax,dword ptr [rdx+0Ch]  
 mov         dword ptr [rcx],eax  
 mov         eax,dword ptr [rdx+8]  
 mov         dword ptr [rcx+4],eax  
 mov         eax,dword ptr [rdx]  
 mov         dword ptr [rcx+8],eax  
 mov         eax,dword ptr [rdx+4]  
 mov         dword ptr [rcx+0Ch],eax  
 mov         rax,rcx  
 ret  
shuffle2:
 movaps      xmm2,xmmword ptr [rdx]  
 mov         rax,rcx  
 movaps      xmm0,xmm2  
 shufps      xmm0,xmm2,0FFh  
 movss       dword ptr [rcx],xmm0  
 movaps      xmm0,xmm2  
 shufps      xmm0,xmm2,0AAh  
 movss       dword ptr [rcx+4],xmm0  
 movss       dword ptr [rcx+8],xmm2  
 shufps      xmm2,xmm2,55h  
 movss       dword ptr [rcx+0Ch],xmm2  
 ret  

shuffle1始终比我机器上的shuffle2快至少30%。我注意到shuffle2还有两条指令而shuffle1实际上使用eax而不是xmm0所以我想如果我添加一些垃圾算术运算,结果会有所不同。< / p>

所以我将它们修改为以下内容:

__declspec(noinline) Struct shuffle1(float *arr)
{
    float x0 = arr[3];
    float y0 = arr[2];
    float z0 = arr[0];
    float w0 = arr[1];

    float x = x0 + y0 + z0;
    float y = y0 + z0 + w0;
    float z = z0 + w0 + x0;
    float w = w0 + x0 + y0;

    return {x, y, z, w};
}


#define SS0     (0x00)
#define SS1     (0x55)
#define SS2     (0xAA)
#define SS3     (0xFF)
__declspec(noinline) Struct shuffle2(float *arr)
{
    Struct r;
    __m128 packed = *reinterpret_cast<__m128 *>(arr);

    __m128 x0 = _mm_shuffle_ps(packed, packed, SS3);
    __m128 y0 = _mm_shuffle_ps(packed, packed, SS2);
    __m128 z0 = _mm_shuffle_ps(packed, packed, SS0);
    __m128 w0 = _mm_shuffle_ps(packed, packed, SS1);

    __m128 yz = _mm_add_ss(y0, z0);
    __m128 x = _mm_add_ss(x0, yz);
    __m128 y = _mm_add_ss(w0, yz);

    __m128 wx = _mm_add_ss(w0, x0);
    __m128 z = _mm_add_ss(z0, wx);
    __m128 w = _mm_add_ss(y0, wx);

    _mm_store_ss(&r.x, x);
    _mm_store_ss(&r.y, y);
    _mm_store_ss(&r.z, z);
    _mm_store_ss(&r.w, w);

    return r;
}

现在程序集看起来更加公平,因为它们具有相同数量的指令,并且都需要使用xmm寄存器。

shuffle1:
 movss       xmm5,dword ptr [rdx+8]  
 mov         rax,rcx  
 movss       xmm3,dword ptr [rdx+0Ch]  
 movaps      xmm0,xmm5  
 movss       xmm2,dword ptr [rdx]  
 addss       xmm0,xmm3  
 movss       xmm4,dword ptr [rdx+4]  
 movaps      xmm1,xmm2  
 addss       xmm1,xmm5  
 addss       xmm0,xmm2  
 addss       xmm1,xmm4  
 movss       dword ptr [rcx],xmm0  
 movaps      xmm0,xmm4  
 addss       xmm0,xmm2  
 addss       xmm4,xmm3  
 movss       dword ptr [rcx+4],xmm1  
 addss       xmm0,xmm3  
 addss       xmm4,xmm5  
 movss       dword ptr [rcx+8],xmm0  
 movss       dword ptr [rcx+0Ch],xmm4  
 ret  
shuffle2:
 movaps      xmm4,xmmword ptr [rdx]  
 mov         rax,rcx  
 movaps      xmm3,xmm4  
 movaps      xmm5,xmm4  
 shufps      xmm5,xmm4,0AAh  
 movaps      xmm2,xmm4  
 shufps      xmm2,xmm4,0FFh  
 movaps      xmm0,xmm5  
 addss       xmm0,xmm3  
 shufps      xmm4,xmm4,55h  
 movaps      xmm1,xmm4  
 addss       xmm1,xmm2  
 addss       xmm2,xmm0  
 addss       xmm4,xmm0  
 addss       xmm3,xmm1  
 addss       xmm5,xmm1  
 movss       dword ptr [rcx],xmm2  
 movss       dword ptr [rcx+4],xmm4  
 movss       dword ptr [rcx+8],xmm3  
 movss       dword ptr [rcx+0Ch],xmm5  
 ret  

但这并不重要。 shuffle1仍然快30%!

2 个答案:

答案 0 :(得分:3)

如果没有更广泛的背景,很难肯定地说,但是......在针对较新的处理器进行优化时,您必须考虑使用不同的端口。请参阅此处的Agners:http://www.agner.org/optimize/instruction_tables.pdf

在这种情况下,虽然看起来似乎不太可能,但如果我们假设程序集实际上是优化的,那么有一些可能会向我跳出来。

  1. 这可能出现在一段代码中,其中无序调度程序恰好有更多的端口5(例如,在Haswell上),而不是端口2和3(再次使用Haswell作为示例)。< / LI>
  2. 与#1类似,但在超线程时可能会观察到相同的效果。此代码可能不会从兄弟超线程中窃取读取操作。
  3. 最后,具体到这种优化和我使用类似的东西。假设您有一个运行时接近100%可预测的分支,但不是在编译期间。让我们假设,假设在分支之后有一个读取通常是缓存未命中。你想尽快阅读。如果您不使用读端口,则无序调度程序将提前读取并开始执行该读取。这可以使shufps指令基本上“自由”执行。这是一个例子:

      MOV ecx, [some computed, mostly constant at run-time global]
     label loop:
      ADD rdi, 16
      ADD rbp, 16
      CALL shuffle
      SUB ecx, 1
      JNE loop
    
    MOV rax, [rdi]
    
    ;do a read that could be "predicted" properly
    MOV rbx, [rax]
    
  4. 老实说,它只是看起来很糟糕的汇编或生成的机器代码很差,所以我不会多考虑它。我给出的例子不太可能。

答案 1 :(得分:1)

您不会显示后面的代码是否实际使用了将每个元素广播到向量的所有4个位置的结果。 (例如0x55_MM_SHUFFLE(1,1,1,1))。如果以后已经需要...ps指令来使用它,那么无论如何都需要改组,所以没有理由也进行标量加载。

如果不这样做,唯一可见的副作用就是存储到内存中,这仅仅是使用内在函数的人工程序员和/或编译器无法实现的荒谬的优化 >。就像在您的测试功能的MSVC输出示例中一样。

请记住,某些编译器(例如ICC和MSVC)并没有真正优化内在函数,因此,如果您编写3x _mm_shuffle_ps会得到3x shufps,因此,这个错误的决定可能是由于使用内在函数而不是编译器的人。


但是,另一方面,Clang确实可以积极优化洗牌内在函数。 clangshuffle函数的两者优化为一个movaps负载,一个shufps(或pshufd)负载,以及一个movups存储区。这对于大多数CPU来说是最佳选择,只需最少的指令和操作即可完成工作。

(gcc会自动向量化shuffle1,但不会shuffle2。MSVC在所有情况下都会失败,仅对shuffle1使用标量)

((如果您只需要...ss指令的xmm寄存器底部的每个标量浮点,则可以使用将您的存储向量创建为其中之一的shuffle,因为它的低元素不同于不过,您应该movaps首先进行复制,或使用pshufd,以避免破坏带有原始low元素的reg。)

如果专门针对具有慢movups存储(例如Intel pre-Nehalem)的CPU进行调优,并且未知结果是一致的,那么您仍将使用shufps,但存储movlpsmovhps的结果。如果您使用-mtune=core2进行编译,这就是gcc所做的事情。

您显然知道输入向量是对齐的,因此使用movaps加载它仍然很有意义。 K8会将movaps分成两个8字节的加载单元,但是大多数其他x86-64 CPU可以作为单个uop进行16字节对齐的加载。 (奔腾M / Core 1是最后一个主流的Intel CPU,它们可以像这样拆分128位矢量运算,并且不支持64位模式。)

vbroadcastss需要AVX,因此如果没有AVX,则要将内存中的双字广播到XMM寄存器中,则必须使用需要端口5 ALU uop的shuffle指令。 ({vbroadcastss xmm0, [rsi+4]在Intel CPU上解码为纯负载uop,不需要ALU uop,因此每个时钟吞吐量有2个而不是1个。)

像Merom和K8这样的旧CPU具有仅64位宽的慢速随机播放单元,因此shufps相当慢,因为它是粒度小于64位的完整128位随机播放。您可能会考虑对movsd进行2倍的movqpshuflw加载,这是快速的,因为它只会混排低64位。但是只有当您专门针对旧CPU进行调优时。


 // for gcc, I used __attribute__((ms_abi)) to target the Windows x64 calling convention
Struct shuffle3(float *arr)
{
    Struct r;
    __m128 packed = _mm_load_ps(arr);

    __m128 xyzw = _mm_shuffle_ps(packed, packed, _MM_SHUFFLE(1,0,2,3));
    _mm_storeu_ps(&r.x, xyzw);
    return r;
}

shuffle1shuffle3都使用gcc和clang(on the Godbolt compiler explorer)编译成相同的代码,因为它们会自动矢量化标量分配。唯一的区别是对movups使用了shuffle1加载,因为没有什么保证在那里的16字节对齐。 (如果我们向编译器保证了纯C标量版本的对齐指针,那么它将完全相同。)

# MSVC compiles shuffle3 like this as well

# from gcc9.1 -O3    (default baseline x86-64, tune=generic)
shuffle3(float*):
        movaps  xmm0, XMMWORD PTR [rdx]        # MSVC still uses movups even for _mm_load_ps
        mov     rax, rcx                       # return the retval pointer
        shufps  xmm0, xmm0, 75
        movups  XMMWORD PTR [rcx], xmm0        # store to the hidden retval pointer
        ret

使用-mtune=core2,gcc仍会自动矢量化shuffle1。它使用未对齐的拆分负载,因为我们不保证编译器对齐内存。

对于shuffle3,它确实使用movaps,但仍将_mm_storeu_ps分成movlps + movhps。 (这是调整选项可能具有的有趣效果之一。它们不允许编译器使用新指令,而只是更改现有指令的选择。)

# gcc9.1 -O3 -mtune=core2        # auto-vectorizing shuffle1
shuffle1(float*):
        movq    xmm0, QWORD PTR [rdx]
        mov     rax, rcx
        movhps  xmm0, QWORD PTR [rdx+8]
        shufps  xmm0, xmm0, 75
        movlps  QWORD PTR [rcx], xmm0          # store in 2 halves
        movhps  QWORD PTR [rcx+8], xmm0
        ret

MSVC没有调整选项,并且不会自动向量化shuffle1