MSVC和GCC之间的性能差异,用于高度优化的矩阵乘法代码

时间:2014-01-15 10:01:36

标签: c++ visual-c++ gcc assembly x86

我发现在MSVC(在Windows上)和GCC(在Linux上)为Ivy Bridge系统编译的代码之间的性能差异很大。代码执行密集矩阵乘法。我在GCC中获得了70%的峰值失误,而在MSVC中只有50%。我想我可能已经把他们两个内在函数如何转换的差异分开了。

__m256 breg0 = _mm256_loadu_ps(&b[8*i])
_mm256_add_ps(_mm256_mul_ps(arge0,breg0), tmp0)

GCC这样做

vmovups ymm9, YMMWORD PTR [rax-256]
vmulps  ymm9, ymm0, ymm9
vaddps  ymm8, ymm8, ymm9

MSVC执行此操作

vmulps   ymm1, ymm2, YMMWORD PTR [rax-256]
vaddps   ymm3, ymm1, ymm3

有人可以向我解释这两种解决方案是否以及为何能够在性能上产生如此大的差异?

尽管MSVC使用少一条指令,但它将负载与多线程联系在一起,这可能使它更加依赖(也许负载不能无序完成)?我的意思是Ivy Bridge可以在一个时钟周期内完成一个AVX加载,一个AVX多路复用和一个AVX加载,但这要求每个操作都是独立的。

也许问题出在其他地方?您可以在下面看到最里面循环的GCC和MSVC的完整汇编代码。您可以在此处查看循环的C ++代码Loop unrolling to achieve maximum throughput with Ivy Bridge and Haswell

g ++ -S -masm = intel matrix.cpp -O3 -mavx -fopenmp

.L4:
    vbroadcastss    ymm0, DWORD PTR [rcx+rdx*4]
    add rdx, 1
    add rax, 256
    vmovups ymm9, YMMWORD PTR [rax-256]
    vmulps  ymm9, ymm0, ymm9
    vaddps  ymm8, ymm8, ymm9
    vmovups ymm9, YMMWORD PTR [rax-224]
    vmulps  ymm9, ymm0, ymm9
    vaddps  ymm7, ymm7, ymm9
    vmovups ymm9, YMMWORD PTR [rax-192]
    vmulps  ymm9, ymm0, ymm9
    vaddps  ymm6, ymm6, ymm9
    vmovups ymm9, YMMWORD PTR [rax-160]
    vmulps  ymm9, ymm0, ymm9
    vaddps  ymm5, ymm5, ymm9
    vmovups ymm9, YMMWORD PTR [rax-128]
    vmulps  ymm9, ymm0, ymm9
    vaddps  ymm4, ymm4, ymm9
    vmovups ymm9, YMMWORD PTR [rax-96]
    vmulps  ymm9, ymm0, ymm9
    vaddps  ymm3, ymm3, ymm9
    vmovups ymm9, YMMWORD PTR [rax-64]
    vmulps  ymm9, ymm0, ymm9
    vaddps  ymm2, ymm2, ymm9
    vmovups ymm9, YMMWORD PTR [rax-32]
    cmp esi, edx
    vmulps  ymm0, ymm0, ymm9
    vaddps  ymm1, ymm1, ymm0
    jg  .L4

MSVC / FAc / O2 / openmp / arch:AVX ......

vbroadcastss ymm2, DWORD PTR [r10]    
lea  rax, QWORD PTR [rax+256]
lea  r10, QWORD PTR [r10+4] 
vmulps   ymm1, ymm2, YMMWORD PTR [rax-320]
vaddps   ymm3, ymm1, ymm3    
vmulps   ymm1, ymm2, YMMWORD PTR [rax-288]
vaddps   ymm4, ymm1, ymm4    
vmulps   ymm1, ymm2, YMMWORD PTR [rax-256]
vaddps   ymm5, ymm1, ymm5    
vmulps   ymm1, ymm2, YMMWORD PTR [rax-224]
vaddps   ymm6, ymm1, ymm6    
vmulps   ymm1, ymm2, YMMWORD PTR [rax-192]
vaddps   ymm7, ymm1, ymm7    
vmulps   ymm1, ymm2, YMMWORD PTR [rax-160]
vaddps   ymm8, ymm1, ymm8    
vmulps   ymm1, ymm2, YMMWORD PTR [rax-128]
vaddps   ymm9, ymm1, ymm9    
vmulps   ymm1, ymm2, YMMWORD PTR [rax-96]
vaddps   ymm10, ymm1, ymm10    
dec  rdx
jne  SHORT $LL3@AddDot4x4_

修改

我通过将总浮点运算计算为2.0*n^3来对代码进行基准测试,其中n是方阵的宽度,除以用omp_get_wtime()测量的时间。我重复循环几次。在下面的输出中,我重复了100次。

所有内核的Intel Xeon E5 1620(Ivy Bridge)turbo上MSVC2012的输出为3.7 GHz

maximum GFLOPS = 236.8 = (8-wide SIMD) * (1 AVX mult + 1 AVX add) * (4 cores) * 3.7 GHz

n   64,     0.02 ms, GFLOPs   0.001, GFLOPs/s   23.88, error 0.000e+000, efficiency/core   40.34%, efficiency  10.08%, mem 0.05 MB
n  128,     0.05 ms, GFLOPs   0.004, GFLOPs/s   84.54, error 0.000e+000, efficiency/core  142.81%, efficiency  35.70%, mem 0.19 MB
n  192,     0.17 ms, GFLOPs   0.014, GFLOPs/s   85.45, error 0.000e+000, efficiency/core  144.34%, efficiency  36.09%, mem 0.42 MB
n  256,     0.29 ms, GFLOPs   0.034, GFLOPs/s  114.48, error 0.000e+000, efficiency/core  193.37%, efficiency  48.34%, mem 0.75 MB
n  320,     0.59 ms, GFLOPs   0.066, GFLOPs/s  110.50, error 0.000e+000, efficiency/core  186.66%, efficiency  46.67%, mem 1.17 MB
n  384,     1.39 ms, GFLOPs   0.113, GFLOPs/s   81.39, error 0.000e+000, efficiency/core  137.48%, efficiency  34.37%, mem 1.69 MB
n  448,     3.27 ms, GFLOPs   0.180, GFLOPs/s   55.01, error 0.000e+000, efficiency/core   92.92%, efficiency  23.23%, mem 2.30 MB
n  512,     3.60 ms, GFLOPs   0.268, GFLOPs/s   74.63, error 0.000e+000, efficiency/core  126.07%, efficiency  31.52%, mem 3.00 MB
n  576,     3.93 ms, GFLOPs   0.382, GFLOPs/s   97.24, error 0.000e+000, efficiency/core  164.26%, efficiency  41.07%, mem 3.80 MB
n  640,     5.21 ms, GFLOPs   0.524, GFLOPs/s  100.60, error 0.000e+000, efficiency/core  169.93%, efficiency  42.48%, mem 4.69 MB
n  704,     6.73 ms, GFLOPs   0.698, GFLOPs/s  103.63, error 0.000e+000, efficiency/core  175.04%, efficiency  43.76%, mem 5.67 MB
n  768,     8.55 ms, GFLOPs   0.906, GFLOPs/s  105.95, error 0.000e+000, efficiency/core  178.98%, efficiency  44.74%, mem 6.75 MB
n  832,    10.89 ms, GFLOPs   1.152, GFLOPs/s  105.76, error 0.000e+000, efficiency/core  178.65%, efficiency  44.66%, mem 7.92 MB
n  896,    13.26 ms, GFLOPs   1.439, GFLOPs/s  108.48, error 0.000e+000, efficiency/core  183.25%, efficiency  45.81%, mem 9.19 MB
n  960,    16.36 ms, GFLOPs   1.769, GFLOPs/s  108.16, error 0.000e+000, efficiency/core  182.70%, efficiency  45.67%, mem 10.55 MB
n 1024,    17.74 ms, GFLOPs   2.147, GFLOPs/s  121.05, error 0.000e+000, efficiency/core  204.47%, efficiency  51.12%, mem 12.00 MB

3 个答案:

答案 0 :(得分:20)

由于我们已经涵盖了对齐问题,我猜是这样的:http://en.wikipedia.org/wiki/Out-of-order_execution

由于g ++发出独立的加载指令,因此您的处理器可以重新排序指令,以便预先获取所需的下一个数据,同时还可以添加和相乘。 MSVC向mul抛出指针使得load和mul绑定到同一条指令,因此更改指令的执行顺序对任何事都没有帮助。

编辑:Intel的服务器(S)今天所有的文档都少生气,所以这里的,为什么乱序执行更多的研究,(部分)的答案

首先,看起来你的评论是完全正确的,因为MSVC版本的乘法指令可以解码以分离可以由CPU的乱序引擎优化的μ-ops。这里有趣的部分是现代微码序列发生器是可编程的,因此实际行为依赖于硬件和固件。生成的程序集的差异似乎来自GCC和MSVC各自试图对抗不同的潜在瓶颈。 GCC版本试图为无序引擎提供余地(正如我们已经介绍过的那样)。然而,MSVC版本最终利用了一种称为“微操作融合”的功能。这是因为μ-op退休限制。管道的末端每个滴答只能退出3μ-ops。在特定情况下,微操作融合需要两个μ操作,必须在两个不同的执行单元(即存储器读取和算术)上完成,并将它们连接到单个μ-op,用于大多数管道。融合的μ-op仅在执行单元分配之前被分成两个实际的μ-op。执行后,ops再次融合,允许它们作为一个退役。

无序引擎只能看到融合的μ-op,因此它无法将负载op拉离乘法。这导致管道在等待下一个操作数完成其公共汽车的过程中挂起。

所有链接!!!: http://download-software.intel.com/sites/default/files/managed/71/2e/319433-017.pdf

http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf

http://www.agner.org/optimize/microarchitecture.pdf

http://www.agner.org/optimize/optimizing_assembly.pdf

http://www.agner.org/optimize/instruction_tables.ods (注意:Excel抱怨此电子表格部分损坏或粗略,因此请自行承担风险。但这似乎并不是恶意的,根据我的其余研究,Agner Fog非常棒。我选择后 - 在Excel恢复步骤中,我发现它有很多很棒的数据)

http://cs.nyu.edu/courses/fall13/CSCI-GA.3033-008/Microprocessor-Report-Sandy-Bridge-Spans-Generations-243901.pdf

http://www.syncfusion.com/Content/downloads/ebook/Assembly_Language_Succinctly.pdf


多次编辑: 哇,这里的讨论有一些有趣的更新。我想我误解了微操作融合实际影响了多少管道。也许从循环条件检查的差异中有更多的性能增益,其中未融合的指令允许GCC将比较和跳转与最后的矢量加载和算术步骤交错?

vmovups ymm9, YMMWORD PTR [rax-32]
cmp esi, edx
vmulps  ymm0, ymm0, ymm9
vaddps  ymm1, ymm1, ymm0
jg  .L4

答案 1 :(得分:6)

我可以确认在Visual Studio中使用GCC代码确实可以提高性能。我是通过converting the GCC object file in Linux to work in Visual Studio完成的。所有四个核心的效率从50%提高到60%(单核心的效率从60%提高到70%)。

Microsoft已从64位代码和broken their 64-bit dissembler so that code can't be resembled without modificationbut the 32-bit version still works)中删除了内联汇编。他们显然认为内在就足够了,但是这个案例表明他们错了。

也许融合说明应该是单独的内在函数?

但微软并不是唯一一个产生不太优化的内在代码的人。如果您将下面的代码放入http://gcc.godbolt.org/,您可以看到Clang,ICC和GCC的作用。 ICC的性能甚至比MSVC还差。它正在使用vinsertf128,但我不知道为什么。我不确定Clang正在做什么但它看起来更接近GCC只是以不同的顺序(和更多的代码)。

这解释了为什么Agner Fog在他的手册“Optimizing subroutines in assembly language”中写到了“使用内在函数的缺点”:

  

编译器可以修改代码或以较低效率实现它   比程序员想要的方式。可能有必要看一下   编译器生成的代码,以查看它是否在优化方式   程序员打算。

对于使用内在函数的情况,这是令人失望的。这意味着要么仍然要编写64位汇编代码soemtimes,要么找到一个编译器,它按照程序员的意图实现内在函数。在这种情况下,只有GCC似乎这样做(也许是Clang)。

#include <immintrin.h>
extern "C" void AddDot4x4_vec_block_8wide(const int n, const float *a, const float *b, float *c, const int stridea, const int strideb, const int stridec) {     
    const int vec_size = 8;
    __m256 tmp0, tmp1, tmp2, tmp3, tmp4, tmp5, tmp6, tmp7;
    tmp0 = _mm256_loadu_ps(&c[0*vec_size]);
    tmp1 = _mm256_loadu_ps(&c[1*vec_size]);
    tmp2 = _mm256_loadu_ps(&c[2*vec_size]);
    tmp3 = _mm256_loadu_ps(&c[3*vec_size]);
    tmp4 = _mm256_loadu_ps(&c[4*vec_size]);
    tmp5 = _mm256_loadu_ps(&c[5*vec_size]);
    tmp6 = _mm256_loadu_ps(&c[6*vec_size]);
    tmp7 = _mm256_loadu_ps(&c[7*vec_size]);

    for(int i=0; i<n; i++) {
        __m256 areg0 = _mm256_set1_ps(a[i]);

        __m256 breg0 = _mm256_loadu_ps(&b[vec_size*(8*i + 0)]);
        tmp0 = _mm256_add_ps(_mm256_mul_ps(areg0,breg0), tmp0);    
        __m256 breg1 = _mm256_loadu_ps(&b[vec_size*(8*i + 1)]);
        tmp1 = _mm256_add_ps(_mm256_mul_ps(areg0,breg1), tmp1);
        __m256 breg2 = _mm256_loadu_ps(&b[vec_size*(8*i + 2)]);
        tmp2 = _mm256_add_ps(_mm256_mul_ps(areg0,breg2), tmp2);    
        __m256 breg3 = _mm256_loadu_ps(&b[vec_size*(8*i + 3)]);
        tmp3 = _mm256_add_ps(_mm256_mul_ps(areg0,breg3), tmp3);   
        __m256 breg4 = _mm256_loadu_ps(&b[vec_size*(8*i + 4)]);
        tmp4 = _mm256_add_ps(_mm256_mul_ps(areg0,breg4), tmp4);    
        __m256 breg5 = _mm256_loadu_ps(&b[vec_size*(8*i + 5)]);
        tmp5 = _mm256_add_ps(_mm256_mul_ps(areg0,breg5), tmp5);    
        __m256 breg6 = _mm256_loadu_ps(&b[vec_size*(8*i + 6)]);
        tmp6 = _mm256_add_ps(_mm256_mul_ps(areg0,breg6), tmp6);    
        __m256 breg7 = _mm256_loadu_ps(&b[vec_size*(8*i + 7)]);
        tmp7 = _mm256_add_ps(_mm256_mul_ps(areg0,breg7), tmp7);    
    }
    _mm256_storeu_ps(&c[0*vec_size], tmp0);
    _mm256_storeu_ps(&c[1*vec_size], tmp1);
    _mm256_storeu_ps(&c[2*vec_size], tmp2);
    _mm256_storeu_ps(&c[3*vec_size], tmp3);
    _mm256_storeu_ps(&c[4*vec_size], tmp4);
    _mm256_storeu_ps(&c[5*vec_size], tmp5);
    _mm256_storeu_ps(&c[6*vec_size], tmp6);
    _mm256_storeu_ps(&c[7*vec_size], tmp7);
}

答案 2 :(得分:3)

MSVC完全按照你的要求行事。如果您希望发出vmovups指令,请使用_mm256_loadu_ps内在函数。