为什么矢量长度SIMD代码比普通C慢

时间:2019-06-16 23:04:56

标签: c compiler-optimization sse simd microbenchmark

为什么我的SIMD vector4长度函数比单纯的向量长度方法慢3倍?

SIMD vector4长度函数:

sqrtf(V[0] * V[0] + V[1] * V[1] + V[2] * V[2] + V[3] * V[3])

天真的实现:

#include <math.h>
#include <time.h>
#include <stdint.h>
#include <stdio.h>
#include <x86intrin.h>

static float vec4_len(const float *v) {
    __m128 vec1 = _mm_load_ps(v);
    __m128 xmm1 = _mm_mul_ps(vec1, vec1);
    __m128 xmm2 = _mm_hadd_ps(xmm1, xmm1);
    __m128 xmm3 = _mm_hadd_ps(xmm2, xmm2);
    return sqrtf(_mm_cvtss_f32(xmm3));
}

int main() {
    float A[4] __attribute__((aligned(16))) = {3, 4, 0, 0};

    struct timespec t0 = {};
    clock_gettime(CLOCK_MONOTONIC, &t0);

    double sum_len = 0;
    for (uint64_t k = 0; k < 1000000000; ++k) {
        A[3] = k;
        sum_len += vec4_len(A);
//        sum_len += sqrtf(A[0] * A[0] + A[1] * A[1] + A[2] * A[2] + A[3] * A[3]);
    }
    struct timespec t1 = {};
    clock_gettime(CLOCK_MONOTONIC, &t1);

    fprintf(stdout, "%f\n", sum_len);

    fprintf(stdout, "%ldms\n", (((t1.tv_sec - t0.tv_sec) * 1000000000) + (t1.tv_nsec - t0.tv_nsec)) / 1000000);

    return 0;
}

SIMD版本花了16110毫秒来迭代10亿次。天真的版本快了约3倍,只花了4746毫秒。

vec4_len

我在Intel®CoreTM i7-8550U CPU上使用以下命令运行。首先使用gcc -Wall -Wextra -O3 -msse -msse3 sse.c -lm && ./a.out 版本,然后使用普通C。

我使用GCC(Ubuntu 7.4.0-1ubuntu1〜18.04.1)7.4.0进行编译:

499999999500000128.000000
13458ms

SSE版本输出:

499999999500000128.000000
4441ms

纯C版本输出:

Energia: 1.8.7E21 (Windows 10), Board: "RED LaunchPad w/ msp432 EMT (48MHz)"

c:/users/tokyo/appdata/local/energia15/packages/energia/tools/arm-none-eabi-gcc/6.3.1-20170620/bin/../lib/gcc/arm-none-eabi/6.3.1/../../../../arm-none-eabi/bin/ld.exe: error: C:\Users\Tokyo\AppData\Local\Temp\arduino_build_671047/Blink.ino.elf uses VFP register arguments, c:/users/tokyo/appdata/local/energia15/packages/energia/tools/arm-none-eabi-gcc/6.3.1-20170620/bin/../lib/gcc/arm-none-eabi/6.3.1\libgcc.a(_udivmoddi4.o) does not

c:/users/tokyo/appdata/local/energia15/packages/energia/tools/arm-none-eabi-gcc/6.3.1-20170620/bin/../lib/gcc/arm-none-eabi/6.3.1/../../../../arm-none-eabi/bin/ld.exe: failed to merge target specific data of file c:/users/tokyo/appdata/local/energia15/packages/energia/tools/arm-none-eabi-gcc/6.3.1-20170620/bin/../lib/gcc/arm-none-eabi/6.3.1\libgcc.a(_udivmoddi4.o)

collect2.exe: error: ld returned 1 exit status

exit status 1
Error compiling for board RED LaunchPad w/ msp432 EMT (48MHz).

1 个答案:

答案 0 :(得分:5)

最明显的问题是使用效率低下的点积(带有haddps,其成本为2x shuffle uops + 1x add uop)而不是shuffle + add。请参阅Fastest way to do horizontal float vector sum on x86,了解在_mm_mul_ps之后没有怎么做的事情。但这仍然不是x86可以非常有效地完成的事情。

但是无论如何,真正的问题是基准循环。

A[3] = k;,然后使用_mm_load_ps(A)创建存储转发档位(如果它天真地编译而不是矢量随机播放)。如果加载仅从单个存储指令加载数据,而没有加载该存储指令,则可以以大约5个延迟周期有效地转发存储+重载。否则,它必须对整个存储缓冲区进行较慢的扫描以组装字节。这将使存储转发增加大约10个周期的延迟。

我不确定这对吞吐量有多大影响,但足以阻止乱序执行程序重叠足够多的循环迭代以隐藏延迟,并且只会造成sqrtss混搭吞吐量的瓶颈。 / p>

(您的Coffee Lake CPU每3个周期有1个sqrtss吞吐量,因此令人惊讶的是SQRT吞吐量不是您的瓶颈。 1 它将是洗牌吞吐量或其他的东西。)

请参见Agner Fog's微体系结构指南和/或优化手册。


此外,通过让编译器将V[0] * V[0] + V[1] * V[1] + V[2] * V[2]的计算提升到循环之外,您会更加偏向SSE。

表达式的那部分是循环不变的,因此编译器只需要在每次循环迭代中进行(float)k平方,加法和标量sqrt。 (并将其转换为double以添加到您的累加器中。)

(@ StaceyGirl的已删除答案指出了这一点;查看内循环的代码是编写此答案的一个很好的开始。)


向量版本中,A [3] = k时效率极低

GCC9.1来自Kamil's Godbolt link的内部循环看起来很糟糕,并且似乎包含一个循环进行的存储/重载,以将新的A[3]合并为8字节的A[2..3]对。限制了CPU重叠多个迭代的能力。

我不确定gcc为什么认为这是个好主意。这对于将向量负载分成8个字节的一半(例如Pentium M或Bobcat)的CPU可能会有所帮助,以避免存储转发停顿。但这并不是对“通用”现代x86-64 CPU的理智调整。

.L18:
        pxor    xmm4, xmm4
        mov     rdx, QWORD PTR [rsp+8]     ; reload A[2..3]
        cvtsi2ss        xmm4, rbx
        mov     edx, edx                   ; truncate RDX to 32-bit
        movd    eax, xmm4                  ; float bit-pattern of (float)k
        sal     rax, 32
        or      rdx, rax                   ; merge the float bit-pattern into A[3]
        mov     QWORD PTR [rsp+8], rdx     ; store A[2..3] again

        movaps  xmm0, XMMWORD PTR [rsp]    ; vector load: store-forwarding stall
        mulps   xmm0, xmm0
        haddps  xmm0, xmm0
        haddps  xmm0, xmm0
        ucomiss xmm3, xmm0
        movaps  xmm1, xmm0
        sqrtss  xmm1, xmm1
        ja      .L21             ; call sqrtf to set errno if needed; flags set by ucomiss.
.L17:

        add     rbx, 1
        cvtss2sd        xmm1, xmm1
        addsd   xmm2, xmm1            ; total += (double)sqrtf
        cmp     rbx, 1000000000
        jne     .L18                ; }while(k<1000000000);

这种精神错乱在标量版本中不存在。

无论哪种方式,gcc都设法避免了完全uint64_t-> float转换的效率低下(x86直到AVX512才在硬件中没有)。大概可以证明使用带符号的64位->浮点转换将始终有效,因为无法设置高位。


脚注1 :但是sqrtps每3个周期的吞吐量与标量相同,因此通过在1个向量上执行以下操作,只能获得CPU的sqrt吞吐量的1/4。水平时间,而不是对4个向量并行执行4个长度。