为什么矢量长度 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 迭代了 1000000000 次。原始版本快了约 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
最明显的问题是使用效率低下的 dot-product(haddps
需要 2 倍的 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)
创建 a store-forwarding 停顿 ,如果它天真地编译而不是向量洗牌。如果加载仅从单个存储指令加载数据,而没有其他数据,则存储 + 重新加载可以以约 5 个延迟周期有效地转发。否则,它必须对整个存储缓冲区进行较慢的扫描 assemble 字节。这为 store-forwarding.
添加了大约 10 个周期的延迟
我不确定这对吞吐量有多大影响,但足以阻止 out-of-order exec 重叠足够多的循环迭代以隐藏延迟并仅在 sqrtss
shuffle 吞吐量上出现瓶颈.
(您的 Coffee Lake CPU 每 3 个周期有 1 个 sqrtss
吞吐量,因此令人惊讶的是 SQRT 吞吐量 而不是 您的瓶颈。 1 而不是洗牌吞吐量或其他。)
参见Agner Fog's微架构指南and/or优化手册。
- 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? 引用英特尔的优化手册 re: store forwarding。 (在那个问题中,旧的 gcc 版本分别存储了一个 8 字节结构的 2 个双字的一半,然后用一个 qword load/store 复制了该结构。超级脑残。)
此外,您通过 让编译器将 V[0] * V[0] + V[1] * V[1] + V[2] * V[2]
的计算提升到循环 .
之外,从而更加偏向于 SSE
表达式的那部分是loop-invariant,因此编译器只需要在每次循环迭代中执行(float)k
平方、加法和标量sqrt。 (并将其转换为 double
以添加到您的累加器中)。
(@StaceyGirl 已删除的答案指出了这一点;查看其中的内部循环代码是编写此答案的良好开端。)
向量版本中 A[3] = k 的额外效率低下
来自 Kamil's Godbolt link 的 GCC9.1 内部循环看起来很糟糕,并且似乎包含一个 loop-carried store/reload 以将新的 A[3]
合并到 8 字节 A[2..3]
对,进一步限制了 CPU 重叠多次迭代的能力。
我不确定为什么 gcc 认为这是个好主意。将矢量加载分成 8 字节的两半(如 Pentium M 或 Bobcat)以避免 store-forwarding 停顿可能对 CPU 有帮助。但这不是 "generic" 现代 x86-64 CPUs.
的明智调整
.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 个吞吐量,因此您仅获得 [=99= 的 1/4 ] 的 sqrt 吞吐量能力,通过一次水平处理 1 个向量,而不是并行处理 4 个向量的 4 个长度。
为什么我的 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 迭代了 1000000000 次。原始版本快了约 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
最明显的问题是使用效率低下的 dot-product(haddps
需要 2 倍的 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)
创建 a store-forwarding 停顿 ,如果它天真地编译而不是向量洗牌。如果加载仅从单个存储指令加载数据,而没有其他数据,则存储 + 重新加载可以以约 5 个延迟周期有效地转发。否则,它必须对整个存储缓冲区进行较慢的扫描 assemble 字节。这为 store-forwarding.
我不确定这对吞吐量有多大影响,但足以阻止 out-of-order exec 重叠足够多的循环迭代以隐藏延迟并仅在 sqrtss
shuffle 吞吐量上出现瓶颈.
(您的 Coffee Lake CPU 每 3 个周期有 1 个 sqrtss
吞吐量,因此令人惊讶的是 SQRT 吞吐量 而不是 您的瓶颈。 1 而不是洗牌吞吐量或其他。)
参见Agner Fog's微架构指南and/or优化手册。
- 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? 引用英特尔的优化手册 re: store forwarding。 (在那个问题中,旧的 gcc 版本分别存储了一个 8 字节结构的 2 个双字的一半,然后用一个 qword load/store 复制了该结构。超级脑残。)
此外,您通过 让编译器将 V[0] * V[0] + V[1] * V[1] + V[2] * V[2]
的计算提升到循环 .
表达式的那部分是loop-invariant,因此编译器只需要在每次循环迭代中执行(float)k
平方、加法和标量sqrt。 (并将其转换为 double
以添加到您的累加器中)。
(@StaceyGirl 已删除的答案指出了这一点;查看其中的内部循环代码是编写此答案的良好开端。)
向量版本中 A[3] = k 的额外效率低下
来自 Kamil's Godbolt link 的 GCC9.1 内部循环看起来很糟糕,并且似乎包含一个 loop-carried store/reload 以将新的 A[3]
合并到 8 字节 A[2..3]
对,进一步限制了 CPU 重叠多次迭代的能力。
我不确定为什么 gcc 认为这是个好主意。将矢量加载分成 8 字节的两半(如 Pentium M 或 Bobcat)以避免 store-forwarding 停顿可能对 CPU 有帮助。但这不是 "generic" 现代 x86-64 CPUs.
的明智调整.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 个吞吐量,因此您仅获得 [=99= 的 1/4 ] 的 sqrt 吞吐量能力,通过一次水平处理 1 个向量,而不是并行处理 4 个向量的 4 个长度。