c++ - 与SSE2相比,AVX为什么不能进一步提高性能?

标签 c++ performance sse avx cpu-cache

我是SSE2和AVX Realm 的新手。我编写以下代码来测试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位 vector 时,似乎没有进一步的改进。
normal elapsed time: 10.5311
normal restrict elapsed time: 8.00338
sse elapsed time: 0.995806
avx elapsed time: 0.973302

我有两个问题。
  • AVX为什么不能给我带来进一步的改善?是因为内存带宽大吗?
  • 根据我的实验,SSE2的执行速度比朴素的版本快10倍。这是为什么?基于单精度浮点的128位 vector ,我预计SSE2只能提高4倍。非常感谢。
  • 最佳答案

    标量降低了10倍而不是4倍:

    您是在标量定时区域内的c[]中遇到页面错误,因为这是您第一次编写它。 如果您以其他顺序进行测试,则以先到者为准。 该部分是此错误的重复:Why is iterating though `std::vector` faster than iterating though `std::array`?另请参见Idiomatic way of performance evaluation?
    normal在数组的5次传递中的第一个传递此费用。较小的阵列和较大的重复计数将使此费用摊销得更多,但更好地进行内存设置或以其他方式填充目标,以便在定时区域之前对目标进行预故障处理。

    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 zmmnormal时,normal_res仅将涡轮时钟减少几毫秒(我认为)。它不会一直使用512位运算,因此时钟速度可以稍后再次跳回。这可能部分地解释了它不是精确的8倍。

    compare / ja是因为您没有使用-fno-math-errno,所以GCC仍然为输入<0调用实际的sqrt以获取errno设置。它正在执行if (!(0 <= tmp)) goto fallback,在0 > tmp上跳跃或无序。 “幸运的是” sqrt足够慢,因此仍然是唯一的瓶颈。转换和比较/分支的乱序执行意味着SQRT单元仍保持100%的时间忙碌。

    与Skylake-X上的vsqrtsd吞吐量(3个周期)相比,vsqrtss吞吐量(6个周期)要慢2倍,因此,使用两倍的成本将标量吞吐量提高2倍。

    Skylake-X上的标量sqrt具有与相应的128位ps / pd SIMD版本相同的吞吐量。 因此,每1个数字6个周期作为double,而每4个浮点数3个周期作为ps vector 充分说明了8x因子。
    normal额外的8倍和10倍的减速仅来自页面错误。

    SSE与AVX sqrt吞吐量

    128位sqrtps足以获得SIMD div / sqrt单元的全部吞吐量;假设这是一个像您上一个问题一样的Skylake服务器,它的宽度为256位,但未完全流水线化。 CPU可以交替将128位 vector 发送到低半部分或高半部分,以充分利用硬件的全部宽度,即使您仅使用128位 vector 。请参见Floating point division vs floating point multiplication(FP div和sqrt在同一执行单元上运行。)

    另请参见https://uops.info/https://agner.org/optimize/上的指令等待时间/吞吐量数字。

    add / sub / mul / fma均为512位宽,并且已完全流水线化;如果您想要可以随 vector 宽度缩放的对象,请使用该值(例如,求6阶多项式之类的值)。 div / sqrt是一种特殊情况。

    仅当前端出现瓶颈(4 /时钟指令/ uop吞吐量),或者正在执行一堆add / sub / mul / fma时,您才可以期望对SQRT使用256位 vector 会有所帮助也可以使用 vector 。

    256位还不错,但是当唯一的计算瓶颈在div / sqrt单元的吞吐量上时,它就没有帮助。

    请参阅John McCalpin的答案,以获取有关由于RFO而导致的仅写成本与读+写相同的更多详细信息。

    由于每次内存访问的计算量很少,您可能再次/仍然接近瓶颈。即使FP SQRT硬件较宽/较快,实际上您也可能不会使代码运行得更快。取而代之的是,您只需要让核心花费更多时间在等待数据从内存到达时什么都不做。

    看来您已经从128位 vector (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的优势,则需要较小的阵列(和较高的重复计数)。否则每个 vector 将需要大量的ALU工作,例如多项式。

    在许多实际问题中,重复使用相同的数据,因此缓存可以正常工作。而且有可能将您的问题分解为在高速缓存中(甚至在加载到寄存器中)时对一个数据块执行多项操作,从而增加计算强度,以充分利用现代CPU的计算与内存平衡。

    关于c++ - 与SSE2相比,AVX为什么不能进一步提高性能?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/60472794/

    相关文章:

    c++ - 从 C++ 程序获取 bash $PATH

    swift - 为什么这个使用 Metal 显示相机输出的 Xcode 项目对电池有如此大的影响,我怎样才能让它变得更好?

    c - xmm0 的函数参数

    c++ - 256 位 AVX vector 中 32 位 float 的水平和

    c++ - std::atomic 中的任何内容都是免等待的?

    C++ 在一个集合中存储多种数据类型

    c++ - 小型学校项目的多个问题

    java - 提高在 Java 中将查询结果写入 CSV 的性能

    php - 性能与模块化 : Integrate JS into PHP or separate custom. js

    c++ - SSE整数除法?