为什么 AVX 与 SSE2 相比没有进一步提高性能?

Why does not AVX further improve the performance compared with SSE2?

我是 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

我有两个问题。

  1. 为什么 AVX 不给我进一步的改进?是内存带宽的原因吗?
  2. 根据我的实验,SSE2 的执行速度比原始版本快 10 倍。这是为什么?我预计 SSE2 基于其 128 位向量相对于单精度浮点数只能快 4 倍。非常感谢。

这里有几个问题....

  1. 内存带宽对于这些数组大小很可能很重要——更多注释见下文。
  2. SSE 和 AVX 平方根指令的吞吐量可能不是您对处理器的预期——下面有更多说明。
  3. 第一个测试 ("normal") 可能比预期的要慢,因为输出数组在测试的计时部分被实例化(即创建了虚拟到物理的映射)。 (只需在初始化 a 和 b 的循环中用零填充 c 即可解决此问题。)

内存带宽备注:

  • N = 1<<30 和 float 变量,每个数组为 4GiB。
  • 每个测试读取两个数组并写入第三个数组。这第三个数组在被覆盖之前也必须从内存中读取——这称为 "write allocate" 或 "read for ownership"。
  • 因此您在每个测试中读取 12 GiB 并写入 4 GiB。因此,SSE 和 AVX 测试对应于 ~16 GB/s 的 DRAM 带宽,这接近最近处理器上单线程操作通常看到的范围的高端。

指令吞吐量说明:

  • x86 处理器上的指令延迟和吞吐量的最佳参考来自 https://www.agner.org/optimize/
  • "instruction_tables.pdf"
  • Agner 将 "reciprocal throughput" 定义为当处理器被赋予相同类型的 独立 指令工作负载时,每条退役指令的平均周期数。
  • 例如,对于 Intel Skylake 核心,SSE 和 AVX SQRT 的吞吐量是相同的:
  • SQRTPS (xmm) 1/吞吐量 = 3 --> 每 3 个周期 1 条指令
  • VSQRTPS (ymm) 1/吞吐量 = 6 --> 每 6 个周期 1 条指令
  • 平方根的执行时间预计为 (1<<31) 个平方根/每个 SSE SQRT 指令 4 个平方根 * 每个 SSE SQRT 指令 3 个周期/3 GHz = 0.54 秒(随机假设处理器频率).
  • "normal" 和 "normal_res" 情况的预期吞吐量取决于生成的汇编代码的细节。

标量是慢 10 倍而不是慢 4 倍:

您在标量定时区域内的 c[] 中遇到页面错误,因为那是您第一次编写它。 如果您以不同的顺序进行测试,无论哪个在先都会付出巨大的代价。那部分是这个错误的重复: See also

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 assuming the same system as 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 时将 Turbo 时钟减少几毫秒(我认为)。它不会继续使用 512 位操作,因此时钟速度可以稍后再次跳回。这可能部分解释了它不是 exactly 8x.

compare / ja 是因为你没有使用 -fno-math-errno 所以 GCC 仍然调用实际的 sqrt 用于输入 < 0 来设置 errno。它在 if (!(0 <= tmp)) goto fallback 上跳转 0 > tmp 或无序。 "Fortunately" sqrt 足够慢,它仍然是唯一的瓶颈。转换的无序执行和 compare/branching 意味着 SQRT 单元仍然保持忙碌~100% 的时间。

vsqrtsd 吞吐量(6 个周期)比 Skylake-X 上的 vsqrtss 吞吐量(3 个周期)慢 2 倍,因此使用双倍成本是标量吞吐量的 2 倍。

Skylake-X 上的标量 sqrt 与相应的 128 位 ps / pd SIMD 版本具有相同的吞吐量。 所以每 1 个数字 6 个周期作为 double 与每 4 个浮点数 3 个周期作为 ps 向量完全解释了 8x 因子。

normal 的额外 8 倍与 10 倍的减速只是由于页面错误。


SSE 与 AVX sqrt 吞吐量

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

另请参见 https://uops.info/, or on https://agner.org/optimize/ 上的指令 latency/throughput 号码。

add/sub/mul/fma都是512位宽,完全流水线化;如果您想要可以随矢量宽度缩放的东西,请使用它(例如评估 6 阶多项式或其他东西)。 div/sqrt 是一个特例。

只有在前端有瓶颈(4/时钟指令/uop 吞吐量),或者如果你正在做一堆 add/sub/mul/fma 也可以使用向量。

256 位并不更糟,但当唯一的计算瓶颈在于 div/sqrt 单元的吞吐量时,它无济于事。


由于 RFO,请参阅 John McCalpin 的回答以了解有关只写成本与读+写成本大致相同的更多详细信息。

由于每次内存访问的计算量如此之少,您可能再次/仍然接近内存带宽瓶颈。即使 FP SQRT 硬件更宽/更快,您实际上可能不会让您的代码 运行 更快。相反,您只会让核心在等待数据从内存到达时花费更多时间无所事事。

看来您从 128 位向量 (2x * 4x = 8x) 获得了预期的加速,所以显然 __m128 版本也没有内存带宽瓶颈。

每 4 次内存访问 2x sqrt 与您在发布的代码中所做的 a[i] = sqrt(a[i])(每次加载 + 存储 1x sqrt)大致相同 in chat,但您没有给出任何数字。那个避免了页面错误问题,因为它在初始化后就地重写了一个数组。

一般来说,如果您出于某种原因坚持尝试使用这些无法实现的超大数组来获得 4x / 8x / 16x SIMD 加速,就地重写数组是一个好主意甚至适合 L3 缓存。


内存访问是流水线式的,并且覆盖ps计算(假设是顺序访问,因此预取器可以连续拉取它而不必计算下一个地址):更快的计算并不能加快整体进度。缓存行以某个固定的最大带宽从内存到达,一次传输大约 12 个缓存行(Skylake 中有 12 个 LFB)。或者 L2 "superqueue" 可以跟踪比这更多的缓存行(也许 16?),因此 L2 预取在 CPU 核心停滞的地方提前读取。

只要你的计算能跟上那个速度,让它更快只会在下一个缓存行到达之前留下更多无所事事的周期。

(store buffer回写到L1d然后逐出脏行也有发生,但是核心等待内存的基本思路仍然有效。)


你可以把它想象成汽车里走走停停的交通:你的车前面有一个空隙。更快地缩小差距不会让你获得任何平均速度,它只是意味着你必须更快地停下来。


如果您想看到 AVX 和 AVX512 相对于 SSE 的优势,您将需要更小的阵列(和更高的重复计数)。或者你需要为每个向量做大量的 ALU 工作,比如多项式。

在许多实际问题中,重复使用相同的数据,因此缓存起作用。并且有可能将您的问题分解为对缓存中很热(甚至加载到寄存器中)的一个数据块执行多项操作,以增加足够的计算强度以利用现代[的计算与内存平衡] =138=]s.