簡體   English   中英

為什么矢量長度SIMD代碼比普通C慢

[英]Why vector length SIMD code is slower than plain C

為什么我的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版本花費了16110ms來迭代10億次。 天真的版本快了約3倍,只花了4746ms。

#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成本為2x shuffle haddps + 1x add uop)而不是shuffle + add。 請參閱在x86上執行水平浮點矢量和的最快方法,以了解在_mm_mul_ps之后不會產生太多影響的操作。 但這仍然不是x86可以非常有效地完成的事情。

但是無論如何,真正的問題是基准循環。

A[3] = k; 然后使用_mm_load_ps(A)創建一個存儲轉發停頓 (如果它天真地編譯而不是矢量洗牌)。 如果加載僅從單個存儲指令加載數據,而沒有加載該存儲指令,則可以以大約5個延遲周期有效地轉發存儲+重載。 否則,它必須對整個存儲緩沖區進行較慢的掃描以組裝字節。 這為存儲轉發增加了大約10個延遲周期。

我不確定這對吞吐量有多大影響,但是否足以阻止亂序的exec重疊足夠多的循環迭代以隱藏延遲,而這僅是sqrtss洗牌吞吐量的瓶頸。

(您的Coffee Lake CPU每3個周期sqrtss吞吐量有1個,因此令人驚訝的是SQRT吞吐量不是您的瓶頸。1相反,它將是shuffle吞吐量或其他東西。)

請參閱Agner Fog的微體系結構指南和/或優化手冊。


另外,通過讓編譯器提升計算中的V[0] * V[0] + V[1] * V[1] + V[2] * V[2]您甚至會對SSE產生更大的偏見。 循環

表達式的那部分是循環不變的,因此編譯器只需要在每次循環迭代中進行(float)k平方,加法和標量sqrt。 (並將其轉換為double以添加到累加器中)。

(@StaceyGirl的已刪除答案指出了這一點;查看其中的內部循環代碼是編寫此答案的一個很好的開始。)


向量版本中,A [3] = k時效率極低

來自Kamil的Godbolt鏈接的 GCC9.1的內部循環看起來很糟糕,並且似乎包含循環進行的存儲/重載,以將新的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個矢量,而不是並行處理4個矢量的4個長度,您只能獲得CPU的sqrt吞吐量的1/4。 。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM