为什么矢量化循环没有性能改进

Pou*_*uya 33 c performance simd icc

我正在调查矢量化对程序性能的影响.在这方面,我写了以下代码:

#include <stdio.h>
#include <sys/time.h>
#include <stdlib.h>

#define LEN 10000000

int main(){

    struct timeval stTime, endTime;

    double* a = (double*)malloc(LEN*sizeof(*a));
    double* b = (double*)malloc(LEN*sizeof(*b));
    double* c = (double*)malloc(LEN*sizeof(*c));

    int k;
    for(k = 0; k < LEN; k++){
        a[k] = rand();
        b[k] = rand();
    }

    gettimeofday(&stTime, NULL);

    for(k = 0; k < LEN; k++)
        c[k] = a[k] * b[k];

    gettimeofday(&endTime, NULL);

    FILE* fh = fopen("dump", "w");
    for(k = 0; k < LEN; k++)
        fprintf(fh, "c[%d] = %f\t", k, c[k]);
    fclose(fh);

    double timeE = (double)(endTime.tv_usec + endTime.tv_sec*1000000 - stTime.tv_usec - stTime.tv_sec*1000000);

    printf("Time elapsed: %f\n", timeE);

    return 0;
}
Run Code Online (Sandbox Code Playgroud)

在这段代码中,我只是初始化和乘以两个向量.结果保存在矢量中c.我最感兴趣的是矢量化跟随循环的效果:

for(k = 0; k < LEN; k++)
    c[k] = a[k] * b[k];
Run Code Online (Sandbox Code Playgroud)

我使用以下两个命令编译代码:

1) icc -O2 TestSMID.c -o TestSMID -no-vec -no-simd
2) icc -O2 TestSMID.c -o TestSMID -vec-report2
Run Code Online (Sandbox Code Playgroud)

我希望看到性能提升,因为第二个命令成功地将循环向量化.但是,我的研究表明,当循环被矢量化时,性能没有提高.

我可能在这里遗漏了一些东西,因为我对这个话题并不是很熟悉.所以,如果我的代码有问题,请告诉我.

在此先感谢您的帮助.

PS:我使用的是Mac OSX,因此不需要对齐数据,因为所有分配的内存都是16字节对齐的.

编辑:我想首先感谢大家的意见和答案.我想到了@Mysticial提出的答案,这里还有一些要点.首先,正如@Vinska所提到的,c[k]=a[k]*b[k]不只需要一个周期.除了循环索引增量和确保k小于的比较之外LEN,还有其他事情要执行操作.看一下编译器生成的汇编代码,可以看出简单的乘法需要多于一个循环.矢量化版本看起来像:

L_B1.9:                         # Preds L_B1.8
        movq      %r13, %rax                                    #25.5
        andq      $15, %rax                                     #25.5
        testl     %eax, %eax                                    #25.5
        je        L_B1.12       # Prob 50%                      #25.5
                                # LOE rbx r12 r13 r14 r15 eax
L_B1.10:                        # Preds L_B1.9
        testb     $7, %al                                       #25.5
        jne       L_B1.32       # Prob 10%                      #25.5
                                # LOE rbx r12 r13 r14 r15
L_B1.11:                        # Preds L_B1.10
        movsd     (%r14), %xmm0                                 #26.16
        movl      $1, %eax                                      #25.5
        mulsd     (%r15), %xmm0                                 #26.23
        movsd     %xmm0, (%r13)                                 #26.9
                                # LOE rbx r12 r13 r14 r15 eax
L_B1.12:                        # Preds L_B1.11 L_B1.9
        movl      %eax, %edx                                    #25.5
        movl      %eax, %eax                                    #26.23
        negl      %edx                                          #25.5
        andl      $1, %edx                                      #25.5
        negl      %edx                                          #25.5
        addl      $10000000, %edx                               #25.5
        lea       (%r15,%rax,8), %rcx                           #26.23
        testq     $15, %rcx                                     #25.5
        je        L_B1.16       # Prob 60%                      #25.5
                                # LOE rdx rbx r12 r13 r14 r15 eax
L_B1.13:                        # Preds L_B1.12
        movl      %eax, %eax                                    #25.5
                                # LOE rax rdx rbx r12 r13 r14 r15
L_B1.14:                        # Preds L_B1.14 L_B1.13
        movups    (%r15,%rax,8), %xmm0                          #26.23
        movsd     (%r14,%rax,8), %xmm1                          #26.16
        movhpd    8(%r14,%rax,8), %xmm1                         #26.16
        mulpd     %xmm0, %xmm1                                  #26.23
        movntpd   %xmm1, (%r13,%rax,8)                          #26.9
        addq      $2, %rax                                      #25.5
        cmpq      %rdx, %rax                                    #25.5
        jb        L_B1.14       # Prob 99%                      #25.5
        jmp       L_B1.20       # Prob 100%                     #25.5
                                # LOE rax rdx rbx r12 r13 r14 r15
L_B1.16:                        # Preds L_B1.12
        movl      %eax, %eax                                    #25.5
                                # LOE rax rdx rbx r12 r13 r14 r15
L_B1.17:                        # Preds L_B1.17 L_B1.16
        movsd     (%r14,%rax,8), %xmm0                          #26.16
        movhpd    8(%r14,%rax,8), %xmm0                         #26.16
        mulpd     (%r15,%rax,8), %xmm0                          #26.23
        movntpd   %xmm0, (%r13,%rax,8)                          #26.9
        addq      $2, %rax                                      #25.5
        cmpq      %rdx, %rax                                    #25.5
        jb        L_B1.17       # Prob 99%                      #25.5
                                # LOE rax rdx rbx r12 r13 r14 r15
L_B1.18:                        # Preds L_B1.17
        mfence                                                  #25.5
                                # LOE rdx rbx r12 r13 r14 r15
L_B1.19:                        # Preds L_B1.18
        mfence                                                  #25.5
                                # LOE rdx rbx r12 r13 r14 r15
L_B1.20:                        # Preds L_B1.14 L_B1.19 L_B1.32
        cmpq      $10000000, %rdx                               #25.5
        jae       L_B1.24       # Prob 0%                       #25.5
                                # LOE rdx rbx r12 r13 r14 r15
L_B1.22:                        # Preds L_B1.20 L_B1.22
        movsd     (%r14,%rdx,8), %xmm0                          #26.16
        mulsd     (%r15,%rdx,8), %xmm0                          #26.23
        movsd     %xmm0, (%r13,%rdx,8)                          #26.9
        incq      %rdx                                          #25.5
        cmpq      $10000000, %rdx                               #25.5
        jb        L_B1.22       # Prob 99%                      #25.5
                                # LOE rdx rbx r12 r13 r14 r15
L_B1.24:                        # Preds L_B1.22 L_B1.20
Run Code Online (Sandbox Code Playgroud)

而非vectoized版本是:

L_B1.9:                         # Preds L_B1.8
        xorl      %eax, %eax                                    #25.5
                                # LOE rbx r12 r13 r14 r15 eax
L_B1.10:                        # Preds L_B1.10 L_B1.9
        lea       (%rax,%rax), %edx                             #26.9
        incl      %eax                                          #25.5
        cmpl      $5000000, %eax                                #25.5
        movsd     (%r15,%rdx,8), %xmm0                          #26.16
        movsd     8(%r15,%rdx,8), %xmm1                         #26.16
        mulsd     (%r13,%rdx,8), %xmm0                          #26.23
        mulsd     8(%r13,%rdx,8), %xmm1                         #26.23
        movsd     %xmm0, (%rbx,%rdx,8)                          #26.9
        movsd     %xmm1, 8(%rbx,%rdx,8)                         #26.9
        jb        L_B1.10       # Prob 99%                      #25.5
                                # LOE rbx r12 r13 r14 r15 eax
Run Code Online (Sandbox Code Playgroud)

除此之外,处理器不加载仅24个字节.在每次访问内存时,都会加载一个完整的行(64字节).更重要的是,由于所需的内存a,bc是连续的,预取器肯定会有助于未来块了很多,负载提前.话虽如此,我认为@Mysticial计算的内存带宽过于悲观.

此外,英特尔矢量化指南中提到了使用SIMD来提高程序性能以进行非常简单的添加.因此,似乎我们应该能够为这个非常简单的循环获得一些性能改进.

编辑2:再次感谢您的评论.另外,感谢@Mysticial示例代码,我终于看到了SIMD对性能改进的影响.正如Mysticial所提到的,问题在于内存带宽.随着选择小尺寸a,b以及c其融入L1高速缓存,可以看出,SIMD可以帮助显著提高性能.以下是我得到的结果:

icc -O2 -o TestSMIDNoVec -no-vec TestSMID2.c: 17.34 sec

icc -O2 -o TestSMIDVecNoUnroll -vec-report2 TestSMID2.c: 9.33 sec
Run Code Online (Sandbox Code Playgroud)

并且展开循环可以进一步提高性能:

icc -O2 -o TestSMIDVecUnroll -vec-report2 TestSMID2.c -unroll=8: 8.6sec
Run Code Online (Sandbox Code Playgroud)

另外,我应该提一下,我的处理器在编译时只需要一个周期即可完成迭代-O2.

PS:我的电脑是Macbook Pro核心i5 @ 2.5GHz(双核)

Mys*_*ial 69

这个原始答案在2013年有效.截至2017年的硬件,情况发生了很大变化,问题和答案都已过时.

请参阅本答复的结尾以了解2017年更新.


原答案(2013):

因为你的内存带宽会受到瓶颈.

虽然矢量化和其他微优化可以提高计算速度,但它们无法提高内存的速度.

在你的例子中:

for(k = 0; k < LEN; k++)
    c[k] = a[k] * b[k];
Run Code Online (Sandbox Code Playgroud)

你正在通过所有内存进行一次通过,只做很少的工作.这样可以最大限度地提高内存带宽.

因此无论它如何优化(矢量化,展开等等),它都不会变得更快.


2013年的典型台式机具有大约10 GB/s的内存带宽*.
你的循环接触24个字节/迭代.

没有矢量化,现代x64处理器可能在一个周期内完成大约1次迭代*.

假设您以4 GHz运行:

  • (4 * 10^9) * 24 bytes/iteration = 96 GB/s

这几乎是内存带宽的10倍 - 没有矢量化.


*毫不奇怪,有几个人怀疑我上面给出的数字,因为我没有给出引用.那些从经验中脱离了我的头脑.所以这里有一些基准来证明它.

循环迭代可以以1个周期/迭代的速度运行:

如果我们减少LEN以便它适合缓存,我们可以摆脱内存瓶颈.
(我在C++中对它进行了测试,因为它更容易.但它没有区别.)

#include <iostream>
#include <time.h>
using std::cout;
using std::endl;

int main(){
    const int LEN = 256;

    double *a = (double*)malloc(LEN*sizeof(*a));
    double *b = (double*)malloc(LEN*sizeof(*a));
    double *c = (double*)malloc(LEN*sizeof(*a));

    int k;
    for(k = 0; k < LEN; k++){
        a[k] = rand();
        b[k] = rand();
    }

    clock_t time0 = clock();

    for (int i = 0; i < 100000000; i++){
        for(k = 0; k < LEN; k++)
            c[k] = a[k] * b[k];
    }

    clock_t time1 = clock();
    cout << (double)(time1 - time0) / CLOCKS_PER_SEC << endl;
}
Run Code Online (Sandbox Code Playgroud)
  • 处理器:Intel Core i7 2600K @ 4.2 GHz
  • 编译器:Visual Studio 2012
  • 时间:6.55秒

在这个测试中,我在6.55秒内完成了25,600,000,000次迭代.

  • 6.55 * 4.2 GHz= 27,510,000,000次循环
  • 27,510,000,000 / 25,600,000,000= 1.074次循环/迭代

现在,如果您想知道如何做到这一点:

  • 2个负载
  • 1店
  • 1乘以
  • 增量计数器
  • 比较+分支

一个循环......

这是因为现代处理器和编译器非常棒.

虽然这些操作中的每一个都具有延迟(尤其是乘法),但处理器能够同时执行多次迭代.我的测试机器是Sandy Bridge处理器,每个周期能够承受2x128b负载,1x128b存储和1x256b矢量FP.如果加载是微融合微指令的内存源操作数,则可能还有另外一个或两个向量或整数运算.(2只在使用256b AVX加载/存储时加载+ 1个存储吞吐量,否则每个循环只有两个总存储操作(最多一个存储)).

看一下程序集(为简洁起见,我将省略),似乎编译器展开了循环,从而减少了循环开销.但它并没有完全设法对其进行矢量化.


内存带宽大约为10 GB/s:

测试这个的最简单方法是通过memset():

#include <iostream>
#include <time.h>
using std::cout;
using std::endl;

int main(){
    const int LEN = 1 << 30;    //  1GB

    char *a = (char*)calloc(LEN,1);

    clock_t time0 = clock();

    for (int i = 0; i < 100; i++){
        memset(a,0xff,LEN);
    }

    clock_t time1 = clock();
    cout << (double)(time1 - time0) / CLOCKS_PER_SEC << endl;
}
Run Code Online (Sandbox Code Playgroud)
  • 处理器:Intel Core i7 2600K @ 4.2 GHz
  • 编译器:Visual Studio 2012
  • 时间:5.811秒

因此我的机器需要5.811秒才能写入100 GB的内存.那大约是17.2 GB/s.

我的处理器处于更高端.Nehalem和Core 2代处理器具有较少的内存带宽.


2017年3月更新:

截至2017年,情况变得更加复杂.

由于DDR4和四通道内存,单个线程不再可能使内存带宽饱和.但带宽问题并不一定会消失.尽管带宽已经增加,处理器内核也有所改进 - 而且其中有更多.

用数学方式说:

  • 每个核心都有带宽限制X.
  • 主内存的带宽限制为Y.
  • 在旧系统上,X > Y.
  • 在当前的高端系统上,X < Y.但是X * (# of cores) > Y.

早在2013年:Sandy Bridge @ 4 GHz +双通道DDR3 @ 1333 MHz

  • 没有矢量化(8字节加载/存储):X = 32 GB/sY = ~17 GB/s
  • 矢量化SSE*(16字节加载/存储):X = 64 GB/sY = ~17 GB/s

现在在2017年:Haswell-E @ 4 GHz +四通道DDR4 @ 2400 MHz

  • 没有矢量化(8字节加载/存储):X = 32 GB/sY = ~70 GB/s
  • 矢量化AVX*(32字节加载/存储):X = 64 GB/sY = ~70 GB/s

(对于Sandy Bridge和Haswell,无论SIMD宽度如何,缓存中的架构限制都会将带宽限制为大约16个字节/周期.)

所以现在,单个线程并不总是能够使内存带宽饱和.你需要进行矢量化以达到这个极限X.但是你仍然会遇到Y2个或更多线程的主内存带宽限制.

但有一点没有改变,可能不会长时间改变:如果不使总内存带宽饱和,您将无法在所有内核上运行带宽占用环路.

  • +1:这需要在常见问题解答中或成为"转到"答案 - 很大一部分初学者优化问题似乎属于这一类. (11认同)
  • @matmul只有在重用数据时才有效.如果一切都只被触摸一次,那么就没有什么可以做的了. (3认同)
  • @Zboson显然这取决于机器.在具有多个NUMA节点的计算机上,您不可能在单线程上获得全带宽.在Haswell-E上,内存足够快,您可能需要通过一个线程进行矢量化以最大化带宽.也就是说,它并没有从这一点上消失.这个问题中的代码迟早会遇到带宽问题. (2认同)