与 SSE2 相比,为啥 AVX 没有进一步提高性能?
Posted
技术标签:
【中文标题】与 SSE2 相比,为啥 AVX 没有进一步提高性能?【英文标题】:Why does not AVX further improve the performance compared with SSE2?与 SSE2 相比,为什么 AVX 没有进一步提高性能? 【发布时间】:2020-03-01 07:09:05 【问题描述】:我是 SSE2 和 AVX 领域的新手。我编写以下代码来测试 SSE2 和 AVX 的性能。
#include <cmath>
#include <iostream>
#include <chrono>
#include <emmintrin.h>
#include <immintrin.h>
void normal_res(float* __restrict__ a, float* __restrict__ b, float* __restrict__ c, unsigned long N)
for (unsigned long n = 0; n < N; n++)
c[n] = sqrt(a[n]) + sqrt(b[n]);
void normal(float* a, float* b, float* c, unsigned long N)
for (unsigned long n = 0; n < N; n++)
c[n] = sqrt(a[n]) + sqrt(b[n]);
void sse(float* a, float* b, float* c, unsigned long N)
__m128* a_ptr = (__m128*)a;
__m128* b_ptr = (__m128*)b;
for (unsigned long n = 0; n < N; n+=4, a_ptr++, b_ptr++)
__m128 asqrt = _mm_sqrt_ps(*a_ptr);
__m128 bsqrt = _mm_sqrt_ps(*b_ptr);
__m128 add_result = _mm_add_ps(asqrt, bsqrt);
_mm_store_ps(&c[n], add_result);
void avx(float* a, float* b, float* c, unsigned long N)
__m256* a_ptr = (__m256*)a;
__m256* b_ptr = (__m256*)b;
for (unsigned long n = 0; n < N; n+=8, a_ptr++, b_ptr++)
__m256 asqrt = _mm256_sqrt_ps(*a_ptr);
__m256 bsqrt = _mm256_sqrt_ps(*b_ptr);
__m256 add_result = _mm256_add_ps(asqrt, bsqrt);
_mm256_store_ps(&c[n], add_result);
int main(int argc, char** argv)
unsigned long N = 1 << 30;
auto *a = static_cast<float*>(aligned_alloc(128, N*sizeof(float)));
auto *b = static_cast<float*>(aligned_alloc(128, N*sizeof(float)));
auto *c = static_cast<float*>(aligned_alloc(128, N*sizeof(float)));
std::chrono::time_point<std::chrono::system_clock> start, end;
for (unsigned long i = 0; i < N; ++i)
a[i] = 3141592.65358;
b[i] = 1234567.65358;
start = std::chrono::system_clock::now();
for (int i = 0; i < 5; i++)
normal(a, b, c, N);
end = std::chrono::system_clock::now();
std::chrono::duration<double> elapsed_seconds = end - start;
std::cout << "normal elapsed time: " << elapsed_seconds.count() / 5 << std::endl;
start = std::chrono::system_clock::now();
for (int i = 0; i < 5; i++)
normal_res(a, b, c, N);
end = std::chrono::system_clock::now();
elapsed_seconds = end - start;
std::cout << "normal restrict elapsed time: " << elapsed_seconds.count() / 5 << std::endl;
start = std::chrono::system_clock::now();
for (int i = 0; i < 5; i++)
sse(a, b, c, N);
end = std::chrono::system_clock::now();
elapsed_seconds = end - start;
std::cout << "sse elapsed time: " << elapsed_seconds.count() / 5 << std::endl;
start = std::chrono::system_clock::now();
for (int i = 0; i < 5; i++)
avx(a, b, c, N);
end = std::chrono::system_clock::now();
elapsed_seconds = end - start;
std::cout << "avx elapsed time: " << elapsed_seconds.count() / 5 << std::endl;
return 0;
我使用 g++ 编译器编译我的程序,如下所示。
g++ -msse -msse2 -mavx -mavx512f -O2
结果如下。当我使用更高级的 256 位向量时,似乎没有进一步的改进。
normal elapsed time: 10.5311
normal restrict elapsed time: 8.00338
sse elapsed time: 0.995806
avx elapsed time: 0.973302
我有两个问题。
-
为什么 AVX 没有给我进一步的改进?是因为内存带宽吗?
根据我的实验,SSE2 的执行速度比原始版本快 10 倍。这是为什么?我预计 SSE2 基于其 128 位向量相对于单精度浮点数只能快 4 倍。非常感谢。
【问题讨论】:
你在什么 CPU 上测试?为什么您使用-mavx512f
而不是设置调整选项以及-march=native
?当您的程序不使用任何__m512
向量时,为什么要使用-mavx512f
? AVX512F 不包含 AVX512VL,因此编译器无法使用 EVEX 编码或 ymm16..31,即使它想使用。
【参考方案1】:
这里有几个问题......
-
内存带宽对于这些阵列大小很可能很重要——更多说明如下。
SSE 和 AVX 平方根指令的吞吐量可能不是您所期望的处理器 - 更多说明如下。
第一个测试(“正常”)可能比预期的要慢,因为在测试的计时部分中实例化了输出数组(即创建了虚拟到物理的映射)。 (只需在初始化 a 和 b 的循环中用零填充 c 即可解决此问题。)
内存带宽说明:
使用 N = 1 每个测试读取两个数组并写入第三个数组。这第三个数组也必须在被覆盖之前从内存中读取——这称为“写入分配”或“读取所有权”。 因此,您在每个测试中读取 12 GiB 并写入 4 GiB。因此,SSE 和 AVX 测试对应于约 16 GB/s 的 DRAM 带宽,这接近最近处理器上单线程操作通常看到的范围的高端。指令吞吐量说明:
x86 处理器上指令延迟和吞吐量的最佳参考是来自https://www.agner.org/optimize/ 的“instruction_tables.pdf” Agner 将“交互吞吐量”定义为当处理器被赋予相同类型的独立指令工作负载时,每条退役指令的平均周期数。 例如,对于 Intel Skylake 内核,SSE 和 AVX SQRT 的吞吐量是相同的: SQRTPS (xmm) 1/吞吐量 = 3 --> 每 3 个周期 1 条指令 VSQRTPS (ymm) 1/吞吐量 = 6 --> 每 6 个周期 1 条指令 平方根的执行时间预计为 (1 “normal”和“normal_res”情况的预期吞吐量取决于生成的汇编代码的具体情况。【讨论】:
原来normal
和normal_res
正在使用sqrt(double)
;这恰好说明了normal_res
中额外的 2 倍减速因素(它不受页面错误的影响)。看我的回答。【参考方案2】:
标量慢 10 倍而不是 4 倍:
您在标量定时区域内的c[]
中遇到页面错误,因为这是您第一次编写它。 如果您以不同的顺序进行测试,无论哪个先进行测试,都会支付如此大的罚款。该部分与此错误重复:Why is iterating though `std::vector` faster than iterating though `std::array`? 另见Idiomatic way of performance evaluation?
normal
在其 5 次遍历数组的第一次中支付此费用。较小的数组和较大的重复计数会更多地分摊这一点,但最好先 memset 或以其他方式填充您的目的地,以便在定时区域之前对其进行预故障。
normal_res
也是标量,但正在写入已被污染的c[]
。标量比 SSE 慢 8 倍,而不是预期的 4 倍。
您使用了sqrt(double)
而不是sqrtf(float)
或std::sqrt(float)
。在 Skylake-X 上,这完美地解释了 2 倍吞吐量的额外因素。查看编译器的 asm 输出 on the Godbolt compiler explorer(GCC 7.4 假设与 your last question 相同的系统)。我使用了-mavx512f
(这意味着-mavx
和-msse
),并且没有调整选项,希望得到与您相同的代码生成。 main
没有内联 normal_res
,所以我们可以看看它的独立定义。
normal_res(float*, float*, float*, unsigned long):
...
vpxord zmm2, zmm2, zmm2 # uh oh, 512-bit instruction reduces turbo clocks for the next several microseconds. Silly compiler
# more recent gcc would just use `vpxor xmm0,xmm0,xmm0`
...
.L5: # main loop
vxorpd xmm0, xmm0, xmm0
vcvtss2sd xmm0, xmm0, DWORD PTR [rdi+rbx*4] # convert to double
vucomisd xmm2, xmm0
vsqrtsd xmm1, xmm1, xmm0 # scalar double sqrt
ja .L16
.L3:
vxorpd xmm0, xmm0, xmm0
vcvtss2sd xmm0, xmm0, DWORD PTR [rsi+rbx*4]
vucomisd xmm2, xmm0
vsqrtsd xmm3, xmm3, xmm0 # scalar double sqrt
ja .L17
.L4:
vaddsd xmm1, xmm1, xmm3 # scalar double add
vxorps xmm4, xmm4, xmm4
vcvtsd2ss xmm4, xmm4, xmm1 # could have just converted in-place without zeroing another destination to avoid a false dependency :/
vmovss DWORD PTR [rdx+rbx*4], xmm4
add rbx, 1
cmp rcx, rbx
jne .L5
vpxord zmm
只会在每次调用normal
和normal_res
开始时将涡轮时钟降低几毫秒(我认为)。它不会继续使用 512 位操作,因此时钟速度可以稍后再次回升。这可能部分解释了它不是完全 8x。
比较 / ja 是因为您没有使用 -fno-math-errno
所以 GCC 仍然调用实际的 sqrt
输入 errno。它正在执行if (!(0 <= tmp)) goto fallback
,跳上0 > tmp
或无序。 “幸运的是” sqrt 足够慢,它仍然是唯一的瓶颈。转换和比较/分支的乱序执行意味着 SQRT 单元在大约 100% 的时间里仍然保持忙碌。
vsqrtsd
吞吐量(6 个周期)比 Skylake-X 上的vsqrtss
吞吐量(3 个周期)慢 2 倍,因此使用双倍的标量吞吐量成本是 2 倍。
Skylake-X 上的标量 sqrt 与相应的 128 位 ps / pd SIMD 版本具有相同的吞吐量。 因此,double
每 1 个数字 6 个周期与 ps
向量每 4 个浮点数 3 个周期完全解释了 8 倍因子。
normal
额外的 8 倍和 10 倍的减速仅仅是由于页面错误。
SSE 与 AVX sqrt 吞吐量
128 位 sqrtps
足以获得 SIMD div/sqrt 单元的全部吞吐量;假设这是一个像你最后一个问题一样的 Skylake 服务器,它是 256 位宽但没有完全流水线化。即使您只使用 128 位向量,CPU 也可以将 128 位向量交替发送到低半部分或高半部分,以利用整个硬件宽度。见Floating point division vs floating point multiplication(FP div 和 sqrt 在同一个执行单元上运行。)
另请参阅 https://uops.info/ 或 https://agner.org/optimize/ 上的指令延迟/吞吐量数字。
add/sub/mul/fma 均为 512 位宽且完全流水线化;如果您想要可以随矢量宽度缩放的东西,请使用它(例如评估 6 阶多项式或其他东西)。 div/sqrt 是一种特殊情况。
只有当您在前端遇到瓶颈(4/时钟指令/uop 吞吐量),或者您正在执行一堆添加/子/ mul/fma 也适用于向量。
256 位并不更糟,但当唯一的计算瓶颈在于 div/sqrt 单元的吞吐量时,它就无济于事了。
请参阅 John McCalpin 的回答,了解更多关于只写成本的详细信息,因为 RFO 与读写成本大致相同。
由于每次内存访问的计算量如此之少,您可能再次/仍然接近内存带宽瓶颈。即使 FP SQRT 硬件更宽/更快,您实际上可能不会让您的代码运行得更快。相反,您只会让核心在等待数据从内存中到达时花更多时间什么都不做。
您似乎从 128 位向量 (2x * 4x = 8x) 中获得了完全预期的加速,因此显然 __m128 版本也没有内存带宽的瓶颈。
每 4 次内存访问 2x sqrt 与您在发布 in chat 的代码中所做的 a[i] = sqrt(a[i])
(每次加载 + 存储 1x sqrt)大致相同,但您没有为此提供任何数字。那个避免了页面错误问题,因为它在初始化后就地重写了一个数组。
一般来说,如果您出于某种原因坚持尝试使用这些甚至不适合 L3 的极其庞大的数组来获得 4x / 8x / 16x SIMD 加速,就地重写数组是一个好主意缓存。
内存访问是流水线的,并且与计算重叠(假设顺序访问,因此预取器可以连续将其拉入而无需计算下一个地址):更快的计算不会加快整体进度。缓存线以某个固定的最大带宽从内存到达,一次传输约 12 条缓存线(Skylake 中有 12 个 LFB)。或者 L2“超级队列”可以跟踪比这更多的缓存行(可能 16 个?),因此 L2 预取在 CPU 内核停止的位置之前读取。
只要你的计算能跟上这个速度,让它更快只会在下一个缓存行到达之前留下更多的无操作周期。
(存储缓冲区写回 L1d 然后驱逐脏行也在发生,但核心等待内存的基本思想仍然有效。)
您可以将其想象为汽车中的走走停停的交通:在您的汽车前方出现一个缺口。更快地缩小差距不会让你获得任何平均速度,它只是意味着你必须更快地停下来。
如果您想看到 AVX 和 AVX512 相对于 SSE 的优势,您需要更小的阵列(以及更高的重复次数)。或者,您需要对每个向量进行大量 ALU 工作,例如多项式。
在许多现实世界的问题中,相同的数据会被重复使用,因此缓存可以发挥作用。并且可以将您的问题分解为对一个数据块在缓存中很热(甚至在加载到寄存器中时)执行多项操作,以增加计算强度,以充分利用现代 CPU 的计算与内存平衡.
【讨论】:
很好地理解了 C 平方根的隐式双精度。那一个仍然不时让我绊倒...... @JohnDMcCalpin:在我查看 asm 之前,我一直假设 C++ 有一个重载的sqrt
,就像 std::min 这样的模板函数一样。和/或只是我没有仔细观察甚至考虑这个错误。啊,显然std::sqrt
是 魔法,但简单的::sqrt
是不是。 godbolt.org/z/a5CwMa 显示了差异。此外,GCC 积极寻找避免转换为 double 和 back 的机会。例如float tmp = sqrt(x)
优化它。因此,如果在舍入为浮点数之前不在较大的表达式中使用双精度返回值,则可以侥幸解决。以上是关于与 SSE2 相比,为啥 AVX 没有进一步提高性能?的主要内容,如果未能解决你的问题,请参考以下文章
用于灰度到 ARGB 转换的 C++ SSE2 或 AVX2 内在函数
使用内在函数将双 SSE2/AVX/AVX512 存储为浮点数的最佳方法