与 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”情况的预期吞吐量取决于生成的汇编代码的具体情况。

【讨论】:

原来normalnormal_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 只会在每次调用normalnormal_res 开始时将涡轮时钟降低几毫秒(我认为)。它不会继续使用 512 位操作,因此时钟速度可以稍后再次回升。这可能部分解释了它不是完全 8x。

比较 / ja 是因为您没有使用 -fno-math-errno 所以 GCC 仍然调用实际的 sqrt 输入 errno。它正在执行if (!(0 &lt;= tmp)) goto fallback,跳上0 &gt; 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 没有进一步提高性能?的主要内容,如果未能解决你的问题,请参考以下文章

将 SSE2 和 AVX 内部函数与不同的编译器混合

用于灰度到 ARGB 转换的 C++ SSE2 或 AVX2 内在函数

使用内在函数将双 SSE2/AVX/AVX512 存储为浮点数的最佳方法

AVX mat4 inv 实现比 SSE 慢

如何在 Visual Studio 2017 15.5 中禁用 AVX?

有没有办法在 AVX 上模拟 _m256 类型的整数按位运算?