演示代码在禁用优化的情况下未能显示 SIMD 速度快 4 倍

Posted

技术标签:

【中文标题】演示代码在禁用优化的情况下未能显示 SIMD 速度快 4 倍【英文标题】:Demonstrator code failing to show 4 times faster SIMD speed with optimization disabled 【发布时间】:2014-11-22 08:32:48 【问题描述】:

我试图了解使用 SIMD 矢量化的好处,并编写了一个简单的演示代码,以查看利用矢量化 (SIMD) 的算法相对于另一个算法的速度增益。以下是两种算法:

Alg_A - 不支持向量:

#include <stdio.h>

#define SIZE 1000000000

int main() 
    printf("Algorithm with NO vector support\n");

    int a[] = 1, 2, 3, 4;
    int b[] = 5, 6, 7, 8;
    int i = 0;

    printf("Running loop %d times\n", SIZE);
    for (; i < SIZE; i++) 
        a[0] = a[0] + b[0];
        a[1] = a[1] + b[1];
        a[2] = a[2] + b[2];
        a[3] = a[3] + b[3];
    

    printf("A: [%d %d %d %d]\n", a[0], a[1], a[2], a[3]);

Alg_B - 支持矢量:

#include <stdio.h>

#define SIZE 1000000000

typedef int v4_i __attribute__ ((vector_size(16)));
union Vec4 
    v4_i v;
    int i[4];
;

int main() 
    printf("Algorithm with vector support\n\n");

    union Vec4 a, b;
    a.i[0] = 1, a.i[1] = 2, a.i[2] = 3, a.i[3] = 4;
    b.i[0] = 5, b.i[1] = 5, b.i[2] = 7, b.i[3] = 8;
    int i = 0;
    printf("Running loop %d times\n", SIZE);
    for (; i < SIZE; i++) 
        a.v = a.v + b.v;
    

    printf("A: [%d %d %d %d]\n", a.i[0], a.i[1], a.i[2], a.i[3]);

编译如下:

Alg_A:

gcc -ggdb -mno-sse -mno-sse2 -mno-sse3 -mno-sse4 -mno-sse4.1 -mno-sse4.2 -c non_vector_support.c
gcc non_vector_support.o -o non_vector_support

Alg_B:

gcc -ggdb -c vector_support.c
gcc vector_support.o -o vector_support

查看两种算法的生成代码,我可以看到编译没有做任何像“自动矢量化”这样的技巧(例如,看不到 xmm 寄存器):

Alg_A:

    for (; i < SIZE; i++) 
  74:   eb 30                   jmp    a6 <main+0xa6>
        a[0] = a[0] + b[0];
  76:   8b 55 d8                mov    -0x28(%rbp),%edx
  79:   8b 45 e8                mov    -0x18(%rbp),%eax
  7c:   01 d0                   add    %edx,%eax
  7e:   89 45 d8                mov    %eax,-0x28(%rbp)
        a[1] = a[1] + b[1];
  81:   8b 55 dc                mov    -0x24(%rbp),%edx
  84:   8b 45 ec                mov    -0x14(%rbp),%eax
  87:   01 d0                   add    %edx,%eax
  89:   89 45 dc                mov    %eax,-0x24(%rbp)
        a[2] = a[2] + b[2];
  8c:   8b 55 e0                mov    -0x20(%rbp),%edx
  8f:   8b 45 f0                mov    -0x10(%rbp),%eax
  92:   01 d0                   add    %edx,%eax
  94:   89 45 e0                mov    %eax,-0x20(%rbp)
        a[3] = a[3] + b[3];
  97:   8b 55 e4                mov    -0x1c(%rbp),%edx
  9a:   8b 45 f4                mov    -0xc(%rbp),%eax
  9d:   01 d0                   add    %edx,%eax
  9f:   89 45 e4                mov    %eax,-0x1c(%rbp)
    int a[] = 1, 2, 3, 4;
    int b[] = 5, 6, 7, 8;
    int i = 0;

    printf("Running loop %d times\n", SIZE);
    for (; i < SIZE; i++) 
  a2:   83 45 d4 01             addl   $0x1,-0x2c(%rbp)
  a6:   81 7d d4 ff c9 9a 3b    cmpl   $0x3b9ac9ff,-0x2c(%rbp)
  ad:   7e c7                   jle    76 <main+0x76>
        a[1] = a[1] + b[1];
        a[2] = a[2] + b[2];
        a[3] = a[3] + b[3];
    

    printf("A: [%d %d %d %d]\n", a[0], a[1], a[2], a[3]);

Alg_B:

    for (; i < SIZE; i++) 
  74:   eb 16                   jmp    8c <main+0x8c>
        a.v = a.v + b.v;
  76:   66 0f 6f 4d d0          movdqa -0x30(%rbp),%xmm1
  7b:   66 0f 6f 45 e0          movdqa -0x20(%rbp),%xmm0
  80:   66 0f fe c1             paddd  %xmm1,%xmm0
  84:   0f 29 45 d0             movaps %xmm0,-0x30(%rbp)
    union Vec4 a, b;
    a.i[0] = 1, a.i[1] = 2, a.i[2] = 3, a.i[3] = 4;
    b.i[0] = 5, b.i[1] = 5, b.i[2] = 7, b.i[3] = 8;
    int i = 0;
    printf("Running loop %d times\n", SIZE);
    for (; i < SIZE; i++) 
  88:   83 45 cc 01             addl   $0x1,-0x34(%rbp)
  8c:   81 7d cc ff c9 9a 3b    cmpl   $0x3b9ac9ff,-0x34(%rbp)
  93:   7e e1                   jle    76 <main+0x76>
        a.v = a.v + b.v;
    

    printf("A: [%d %d %d %d]\n", a.i[0], a.i[1], a.i[2], a.i[3]);

当我运行这些程序时,我希望看到速度提高了 4 倍,但是对于这种数据大小,增益似乎平均 =~ 1s,如果将 SIZE 增加到大约 8000000000增益是=~ 5s。这是上面代码中取值的时机:

Alg_A:

Algorithm with NO vector support
Running loop 1000000000 times
A: [705032705 1705032706 -1589934589 -589934588]

real    0m3.279s
user    0m3.282s
sys     0m0.000s

Alg_B:

带有向量支持的算法

Running loop 1000000000 times
A: [705032705 705032706 -1589934589 -589934588]

real    0m2.609s
user    0m2.607s
sys     0m0.004s

计算与循环相关的开销。我为给定的 SIZE 运行了一个空循环,并在 avg 上获得了 =~ 2.2s。这给了我大约 2.5 倍的平均速度。

我是否遗漏了代码或编译行中的某些内容?或者,这假设是正确的吗?在这种情况下,如果我在每次迭代中利用 4 个数据点和 1 条指令,为什么速度没有提高 4 倍?

提前致谢。

【问题讨论】:

我建议用-O2 编译你的代码,我相信这应该可以提供几乎无限的加速...用-O0 进行速度测试总是有问题的。 正如@cmaster 所说,禁用编译器优化会使您的测试毫无意义。 编译器优化的重点是,您(希望)永远不要部署未经优化编译的应用程序。这将是对精力和时间的毫无意义的浪费。此外,未优化的代码是疯狂。看看你的编译器生成了什么:它甚至没有尝试有效地使用寄存器。当您进行时间测量时,您这样做是有目的的,而该目的是(希望)部署。衡量未优化的代码充其量只是一项学术活动,而最坏的情况则是彻头彻尾的误导。 @GrosLalo 听 Paul R 的话。没有优化的基准测试绝对没有意义,因为代码经常被人为地变慢以使其易于编译和调试。如果你想要有意义的结果,你需要开启优化。这意味着您可能需要采取一些技巧来防止编译器执行诸如删除整个无用循环之类的操作。微基准测试并不容易。我很惊讶这个问题并没有因为没有开启优化而被遗忘。 (因为这通常会发生) @GrosLalo:我在下面的答案中汇总了一些示例代码 - 这应该希望能更好地说明 SIMD 与标量代码的优势。 【参考方案1】:

我在下面汇总了一些示例代码,以说明您如何看待 SIMD 与标量代码的优势。示例代码有点做作,但要注意的主要一点是循环中需要有足够的算术运算来减轻加载/存储延迟和循环开销 - 在您的初始实验中,单个添加操作是不够的.

此示例将 32 位 int 数据的吞吐量提高了大约 4 倍。 SIMD 循环有两种版本:一种是不展开的简单循环,另一种是具有 2 次展开的替代循环。正如所料,展开的循环要快一些。

#include <assert.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <sys/time.h>   // gettimeofday
#include <smmintrin.h>  // SSE 4.1

static void foo_scalar(uint32_t *a, const uint32_t *b, const uint32_t *c, size_t n)

    for (size_t i = 0; i < n; ++i)
    
        a[i] = (b[i] + c[i] + 1) / 2;
    


static void foo_simd(uint32_t *a, const uint32_t *b, const uint32_t *c, size_t n)

    size_t i;

#ifndef UNROLL
    for (i = 0; i <= n - 4; i += 4)
    
        __m128i vb = _mm_loadu_si128((__m128i *)&b[i]);
        __m128i vc = _mm_loadu_si128((__m128i *)&c[i]);
        __m128i v = _mm_add_epi32(vb, vc);
        v = _mm_add_epi32(v, _mm_set1_epi32(1));
        v = _mm_srli_epi32(v, 1);
        _mm_storeu_si128((__m128i *)&a[i], v);
    
#else
    for (i = 0; i <= n - 8; i += 8)
    
        __m128i vb0 = _mm_loadu_si128((__m128i *)&b[i]);
        __m128i vb1 = _mm_loadu_si128((__m128i *)&b[i + 4]);
        __m128i vc0 = _mm_loadu_si128((__m128i *)&c[i]);
        __m128i vc1 = _mm_loadu_si128((__m128i *)&c[i + 4]);
        __m128i v0 = _mm_add_epi32(vb0, vc0);
        __m128i v1 = _mm_add_epi32(vb1, vc1);
        v0 = _mm_add_epi32(v0, _mm_set1_epi32(1));
        v1 = _mm_add_epi32(v1, _mm_set1_epi32(1));
        v0 = _mm_srli_epi32(v0, 1);
        v1 = _mm_srli_epi32(v1, 1);
        _mm_storeu_si128((__m128i *)&a[i], v0);
        _mm_storeu_si128((__m128i *)&a[i + 4], v1);
    
#endif
    foo_scalar(&a[i], &b[i], &c[i], n - i);


int main(int argc, char *argv[])

    const size_t kLoops = 100000;
    size_t n = 2 * 1024;
    struct timeval t0, t1;
    double t_scalar_ms, t_simd_ms;

    if (argc > 1)
    
        n = atoi(argv[1]);
    

    printf("kLoops = %zu, n = %zu\n", kLoops, n);

    uint32_t * a_scalar = malloc(n * sizeof(uint32_t));
    uint32_t * a_simd = malloc(n * sizeof(uint32_t));
    uint32_t * b = malloc(n * sizeof(uint32_t));
    uint32_t * c = malloc(n * sizeof(uint32_t));

    for (size_t i = 0; i < n; ++i)
    
        a_scalar[i] = a_simd[i] = 0;
        b[i] = rand();
        c[i] = rand();
    

    gettimeofday(&t0, NULL);
    for (size_t k = 0; k < kLoops; ++k)
    
        foo_scalar(a_scalar, b, c, n);
    
    gettimeofday(&t1, NULL);
    t_scalar_ms = ((double)(t1.tv_sec - t0.tv_sec) + (double)(t1.tv_usec - t0.tv_usec) * 1.0e-6) * 1.0e3;

    gettimeofday(&t0, NULL);
    for (size_t k = 0; k < kLoops; ++k)
    
        foo_simd(a_simd, b, c, n);
    
    gettimeofday(&t1, NULL);
    t_simd_ms = ((double)(t1.tv_sec - t0.tv_sec) + (double)(t1.tv_usec - t0.tv_usec) * 1.0e-6) * 1.0e3;

    int64_t sum_scalar = 0, sum_simd = 0;
    for (size_t i = 0; i < n; ++i)
    
        sum_scalar += a_scalar[i];
        sum_simd += a_simd[i];
    
    assert(sum_scalar == sum_simd);

    printf("t_scalar = %8g ms = %8g ns / point\n", t_scalar_ms, t_scalar_ms / (kLoops * n) * 1e6);
    printf("t_simd   = %8g ms = %8g ns / point\n", t_simd_ms, t_simd_ms / (kLoops * n) * 1e6);
    printf("Speed-up = %2.1fx\n",  t_scalar_ms / t_simd_ms);

    return 0;

编译并运行(无 SIMD 循环展开):

$ gcc-4.8 -fno-tree-vectorize -std=gnu99 -Wall gros_lalo.c -O3 -msse4.1 && ./a.out
kLoops = 100000, n = 2048
t_scalar =  122.668 ms = 0.598965 ns / point
t_simd   =   33.785 ms = 0.164966 ns / point
Speed-up = 3.6x

编译并运行(2x SIMD 循环展开):

$ gcc-4.8 -fno-tree-vectorize -std=gnu99 -Wall gros_lalo.c -O3 -msse4.1 -DUNROLL && ./a.out
kLoops = 100000, n = 2048
t_scalar =  121.897 ms =   0.5952 ns / point
t_simd   =    29.07 ms = 0.141943 ns / point
Speed-up = 4.2x

看看生成的代码很有意思:

标量:

    xorl    %ecx, %ecx
    .align 4
L10:
    movl    0(%rbp,%rcx,4), %esi
    addl    (%rbx,%rcx,4), %esi
    addl    $1, %esi
    shrl    %esi
    movl    %esi, (%r15,%rcx,4)
    addq    $1, %rcx
    cmpq    %r12, %rcx
    jne L10

SIMD(不展开):

    xorl    %ecx, %ecx
    xorl    %eax, %eax
    .align 4
L18:
    movdqu  0(%rbp,%rcx), %xmm2
    addq    $4, %rax
    movdqu  (%rbx,%rcx), %xmm1
    paddd   %xmm2, %xmm1
    paddd   %xmm3, %xmm1
    psrld   $1, %xmm1
    movdqu  %xmm1, (%r14,%rcx)
    addq    $16, %rcx
    cmpq    %r9, %rax
    jbe L18

SIMD(2x 展开):

    xorl    %edx, %edx
    xorl    %ecx, %ecx
    .align 4
L18:
    movdqu  0(%rbp,%rdx), %xmm5
    addq    $8, %rcx
    movdqu  (%r11,%rdx), %xmm4
    movdqu  (%rbx,%rdx), %xmm2
    movdqu  (%r10,%rdx), %xmm1
    paddd   %xmm5, %xmm2
    paddd   %xmm4, %xmm1
    paddd   %xmm3, %xmm2
    paddd   %xmm3, %xmm1
    psrld   $1, %xmm2
    psrld   $1, %xmm1
    movdqu  %xmm2, 0(%r13,%rdx)
    movdqu  %xmm1, (%rax,%rdx)
    addq    $32, %rdx
    cmpq    %r15, %rcx
    jbe L18

请注意,前两个循环中的指令数量相似,但 SIMD 循环当然每次迭代处理四个元素,而标量循环每次迭代只处理一个元素。对于第三个展开循环,我们有更多指令,但每次迭代处理八个元素 - 请注意,相对于没有循环展开的 SIMD 循环,循环内务处理指令的比例已减少。

时序数据是在 Mac OS X 10.10 上使用 gcc 4.8 的 2.6 GHz Core i7 Haswell CPU 收集的。但是,在任何当前合理的 x86 CPU 上,性能结果都应该相似。

【讨论】:

这太棒了。我真的很感激,并且会马上研究这个。【参考方案2】:

这里最大的问题是您在禁用优化的情况下进行基准测试。 GCC 的默认值为-O0 debug-mode,它将所有变量保留在 C 语句之间的内存中!这通常是无用的,并且通过将存储/重新加载引入从一次迭代的输出到下一次迭代的输入的依赖链中,从而极大地扭曲了您的结果。


使用向量运算可以在您的程序中利用 SIMD 并行性。但它不会加快程序的顺序部分,例如加载程序或打印到屏幕所需的时间。这限制了您的程序可以达到的最大加速。这是Amdahl's law。

此外,即使在非 SIMD 代码中,您的 x86 处理器也可以利用并行性。英特尔的 Haswell 处理器有四个标量整数 ALU,因此如果 4 个 add 指令的输入在该周期准备好,它每个时钟可以执行 4 个 adds。

Haswell 的两个执行端口具有 SIMD 整数执行单元,可以运行 paddd。但是你的循环只有一个paddd 的依赖链,而add 有四个独立的依赖链。

指令吞吐量瓶颈也是一个因素:前端每个时钟最多只能提供 4 个微指令。所有的 store/reload mov 指令意味着标量版本可能会遇到瓶颈。使用 2x mov-load + add + mov-store,前端每个时钟周期只能提供 1 块 4 条指令(包括 1 条 add)。但是存储转发瓶颈将依赖链从 add 本身的 1 个周期延长到 add + 存储/重新加载的大约 5 或 6 个周期,因此这些依赖链仍然可以重叠。


所以您不是在比较顺序执行和并行执行的执行时间,而是比较两个并行执行的执行时间。一种使用标量 ILP,另一种使用 SIMD。

反优化调试模式代码也是 SIMD 向量的巨大瓶颈。实际上,这是一个更大的瓶颈,因为填补延迟造成的差距的其他工作较少。 SIMD 存储/重新加载的延迟也比标量整数高一个周期。

有关详细信息,请参阅 https://***.com/tags/x86/info 和 https://agner.org/optimize/。还有 David Kanter 的 Haswell microarchitecture deep dive 提供了一些 CPU 框图和解释。

【讨论】:

感谢克雷格的及时回复。但是,鉴于两个代码都在同一台计算机上运行,​​我们假设有 2 个 ALU。在这些示例中,我们不会看到快 2 倍的情况吗?我认为我的程序的重要部分是在 Alg_A 的 sequence 和 Alg_B 的 parallel 中执行数百万次的代码块。就执行时间而言,与此相关的所有其他代码都是微不足道的。 Amdahläs 定律是否适用于此代码块? 您应该为一个循环数百万次但在循环内不执行任何操作的程序计时。您需要在关闭优化的情况下执行此操作。这将告诉您程序开销时间。另一件事是向量加法与 ALU 加法的速度。他们不能保证花费相同的时间。这取决于您的处理器。 运行在 2.27 GHz 的 Intel Xeon E5520 需要 3 秒来运行一个运行 10 亿次迭代的未优化空 for 循环。 (来源:我)。那 3 秒是不可并行化的所有循环开销。你需要衡量有多少你的程序在不可并行化的部分。 Intel 的 Haswell 处理器有 4 个整数 ALU,它们从端口 0、1、5 和 6 分派。请参阅realworldtech.com/haswell-cpu/4 @Jake'Alquimista'LEE 对不起,我真的不了解 x86 的细节。我只是看到 Craig Anderson 的评论说,未优化的空循环与 OP 给出的循环所花费的时间大致相同。因此,我得出结论,OP 的循环是循环控制绑定的。仅此而已,我可能大错特错。【参考方案3】:

那一定是指令延迟。 (RAW依赖) 虽然 ALU 指令几乎没有延迟,即结果可以毫无延迟地作为下一条指令的操作数,但 SIMD 指令往往有很长的延迟,直到结果可用,即使对于像加法这样简单的指令也是如此。

将数组扩展到 16 甚至 32 个条目,跨越 4 或 8 个 SIMD 向量,由于指令调度,您会看到巨大的差异。

现在: 添加 v 潜伏 添加 v 潜伏 . . .

4 向量旋转: 添加 v1 添加 v2 添加 v3 添加 v4 添加 v1 添加 v2 . . .

谷歌“指令调度”和“原始依赖”以获得更详细的信息。

【讨论】:

@Jake-alquimista-lee 谢谢你的信息。我将阅读“指令调度”和“原始依赖”。为了清楚起见,您认为在 Alg_B 中为向量加法生成的 4 条指令之间发生了很多延迟(参考:我之前粘贴的 'lines' 76 -> 84 object dump)?提前致谢。 指令延迟因处理器而异。您使用的是哪个处理器? @CraigAnderson Haswell - Intel(R) Core(TM) i5-4300U CPU @ 1.90GHz @CraigAnderson 如果你问我,我不是英特尔的人,而是 ARM。 ARM 的 NEON,SSE 等价物对于加法等简单操作有 3~4 个周期的延迟,对于乘法有超过 6 个周期的延迟。我非常确信 OP 的 SIMD 例程会遭受管道危害,因为它使用上一次迭代的结果作为操作数,并且 RAW 依赖项削弱了所有花哨的性能增强功能,如超标量和 OOOE。 paddd 与 Haswell 上的标量 add 具有相同的 1 周期延迟,每时钟 SIMD 整数添加吞吐量为 2,而标量 add 为每时钟 4。 问题在于使用-O0 进行编译,因此存储转发延迟在循环承载的依赖链中占主导地位。(并且 XMM 存储/重新加载延迟比标量高 1 个周期。)对于标量情况,4独立的存储/重新加载链大多相互重叠,所以是的,那里有更多的 ILP,所以它不会慢 4 倍。是的,一次处理超过 1 个向量将允许 SIMD 代码也有一些 ILP。

以上是关于演示代码在禁用优化的情况下未能显示 SIMD 速度快 4 倍的主要内容,如果未能解决你的问题,请参考以下文章

SIMD 在这种情况下表现如何?

如何在不影响性能的情况下抽象 SIMD 代码以处理不同的数据类型

在 .NET Framework 4.6 中使用 C# 的 SIMD 操作速度较慢

为啥此 SIMD 代码运行速度比等效标量慢?

SIMD/SSE:短点积和短最大值

使用 Vector<T> 的带有 SIMD 的矢量化 C# 代码运行速度比经典循环慢