use*_*740 2 c sse simd compiler-optimization microbenchmark
为什么我的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));
}
Run Code Online (Sandbox Code Playgroud)
天真的实现:
sqrtf(V[0] * V[0] + V[1] * V[1] + V[2] * V[2] + V[3] * V[3])
Run Code Online (Sandbox Code Playgroud)
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;
}
Run Code Online (Sandbox Code Playgroud)
我在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
Run Code Online (Sandbox Code Playgroud)
SSE版本输出:
499999999500000128.000000
13458ms
Run Code Online (Sandbox Code Playgroud)
纯C版本输出:
499999999500000128.000000
4441ms
Run Code Online (Sandbox Code Playgroud)
最明显的问题是使用效率低下的点积(haddps其成本为2x shuffle uops + 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个周期有1个sqrtss吞吐量,因此令人惊讶的是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);
Run Code Online (Sandbox Code Playgroud)
标量版本中没有这种精神错乱。
无论哪种方式,gcc都设法避免了完全uint64_t-> float转换的效率低下(x86直到AVX512才在硬件中没有)。大概可以证明使用带符号的64位->浮点转换将始终有效,因为无法设置高位。
脚注1:但是sqrtps每3个周期的吞吐量与标量相同,因此,通过一次水平执行1个向量,而不是并行执行4个向量的4个长度,只能获得CPU的sqrt吞吐量的1/4。
| 归档时间: |
|
| 查看次数: |
245 次 |
| 最近记录: |