为啥向量长度 SIMD 代码比普通 C 慢

Posted

技术标签:

【中文标题】为啥向量长度 SIMD 代码比普通 C 慢【英文标题】:Why vector length SIMD code is slower than plain C为什么向量长度 SIMD 代码比普通 C 慢 【发布时间】:2019-06-16 23:04:56 【问题描述】:

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

SIMD vector4 长度函数:

__extern_always_inline 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));

天真的实现:

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

SIMD 版本需要 16110 毫秒来迭代 1000000000 次。 naive 版本快了约 3 倍,只需要 4746 毫秒。

#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;

我在 Intel(R) Core(TM) i7-8550U CPU 上运行以下命令。首先使用 vec4_len 版本,然后使用普通 C。

我用 GCC (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0 编译:

gcc -Wall -Wextra -O3 -msse -msse3 sse.c -lm && ./a.out

SSE 版本输出:

499999999500000128.000000
13458ms

纯 C 版本输出:

499999999500000128.000000
4441ms

【问题讨论】:

haddps 很糟糕,3 倍听起来还是很多,反汇编有什么有趣的地方吗? 您如何对此进行基准测试?你确定标量版本没有提升一些工作吗?什么硬件上的编译器/版本,以及内联到的周围代码是什么?此外,由于您在 __m128i 中具有您的值,因此您可以通过使用 _mm_sqrt_ss 来避免 sqrtf() 的愚蠢的 set-errno-on-NaN 行为,因此您不必使用 -fno-math-errno 进行编译跨度> 需要编译器版本。 minimal reproducible example 必填。 Godbolt 链接很好。 您的实际用例是什么?偶尔计算单个向量的范数,还是计算一个向量序列的范数?您能否重新组织您的数据 (SoA vs AoS)? 看起来编译在优化方面比godbolt更好。您的函数对于任意数据看起来更快(程序集更短),但是您测量执行速度的方法有点无效,编译器可以更好地优化循环。您需要使用两个函数并告诉编译器副作用。前使用__attribute__((__noinline__)),如something like this。 【参考方案1】:

最明显的问题是使用效率低下的点积(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 个周期的延迟。

我不确定这对吞吐量有多大影响,但足以阻止乱序 exec 重叠足够多的循环迭代以隐藏延迟,并且只是sqrtss shuffle 吞吐量的瓶颈。

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

请参阅Agner Fog's microarch 指南和/或优化手册。

What does "store-buffer forwarding" mean in the Intel developer's manual? How does store to load forwarding happens in case of unaligned memory access? Can modern x86 implementations store-forward from more than one prior store? Why would a compiler generate this assembly? 引用 Intel 的优化手册 re: store forwarding。 (在那个问题中,旧的 gcc 版本分别存储了 8 字节结构的 2 个 dword 半部分,然后使用 qword 加载/存储复制了该结构。超级脑残。)

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

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

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


向量版本中 A[3] = k 的效率非常低

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

我不确定为什么 gcc 认为这是个好主意。它可能有助于将向量负载拆分为 8 字节的 CPU(如 Pentium M 或 Bobcat)以避免存储转发停止。但这对于“通用”现代 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 转换的低效率(直到 AVX512,x86 在硬件中才具备这种转换)。大概能够证明使用带符号的 64 位 -> 浮点转换总是可以工作的,因为无法设置高位。


脚注 1:但是 sqrtps 与标量具有相同的每 3 个周期 1 个吞吐量,因此一次执行 1 个向量只能获得 CPU 的 sqrt 吞吐量能力的 1/4水平,而不是并行地对 4 个向量进行 4 个长度。

【讨论】:

以上是关于为啥向量长度 SIMD 代码比普通 C 慢的主要内容,如果未能解决你的问题,请参考以下文章

为啥向量总是比 C 数组慢,至少在这种情况下?

带有 Altivec 的 SIMD:为啥将两个向量相乘比相加两个向量更快?

为啥 Vec::with_capacity 比 Vec::new 对于小的最终长度慢?

向量初始化比数组慢...为啥?

为啥在我的情况下快速排序总是比冒泡排序慢?

为啥我的基数排序 python 实现比快速排序慢?