2017-02-11 74 views
6

标题可能看起来是无稽之谈,但让我解释一下。我正在研究一个方案有一天,当我遇到以下汇编代码:shufps比内存访问慢吗?

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 s仅用于选择xmm3中的四个浮标之一(例如,shufps xmm5, xmm3, 55h选择第二个浮标并将其置于xmm5中)。

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

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

虽然在任何实际的程序中我都不会明确地使用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; 
} 

在上述试验,我有两个洗牌方法shuffle1shuffle2做同样的事情。当MSVC -02编译,它产生下面的代码:

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 

shuffle1shuffle2我的机器上总是快至少30%。我没有注意shuffle2有两个更多的指令和shuffle1实际上使用eax而不是xmm0所以我想如果我添加一些垃圾算术运算,结果会有所不同。

所以我修改它们,如下所示:

__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%!

+0

虽然不太可能,它可能是手写汇编。 – tambre

+0

@ tambre是的,我想过这个,但我想不出有这样做的好理由。这是来自一个巨大的程序,可能有数以亿计的代码行。如果他们确实想要优化程序的某些部分,尽管复杂。他们为什么不确定它确实是最优化的,而不是相反的?因此,我责怪编译器:) – MegaStupidMonkeys

+0

也许对齐的内存访问在旧处理器上显着更快。所以编译器首选做一个16字节对齐的负载,而不是4个4字节的未对齐负载。另外,也许编译器不能使用诸如'eax'的寄存器作为浮点数据。 最后,请注意,比较内存加载和洗牌指令的速度并不聪明。这两种类型的指令可以并行运行,因为它们在CPU内部使用不同的执行单元。真正的表现是由这里的瓶颈所定义的...... – stgatilov

回答

2

如果没有更广泛的上下文,很难肯定地说,但是,当为较新的处理器进行优化时,必须考虑不同端口的使用情况。看到Agners在这里:http://www.agner.org/optimize/instruction_tables.pdf

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

  1. 这可能出现在代码的拉伸,其中缺货序调度恰好有更多端口5的(上的Haswell,例如)比端口2和3(再次使用的Haswell作为示例)可用。
  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] 
    

老实说,虽然,它只是看起来像写得不好的组装或产生不良的机器代码,所以我不会把过多考虑进去。我给的例子是不可能的。