[英]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的已刪除答案指出了這一點;查看其中的內部循環代碼是編寫此答案的一個很好的開始。)
來自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.