使用 AVX 矢量内在函数手动矢量化的运行速度与 Ryzen 上添加的 4 个标量 FP 的速度大致相同?

Gim*_*rba 5 c x86 assembly cpu-architecture avx

所以我决定看看如何通过英特尔® Intrinsics 在 C 中使用 SSE、AVX 等。不是因为有任何实际兴趣将它用于某事,而是出于纯粹的好奇心。试图检查使用 AVX 的代码是否实际上比非 AVX 代码快,结果让我有点惊讶。这是我的 C 代码:

#include <stdio.h>
#include <stdlib.h>

#include <emmintrin.h>
#include <immintrin.h>


/*** Sum up two vectors using AVX ***/
#define __vec_sum_4d_d64(src_vec1, src_vec2, dst_vec) \
  _mm256_store_pd(dst_vec, _mm256_add_pd(_mm256_load_pd(src_vec1), _mm256_load_pd(src_vec2)));

/*** Sum up two vectors without AVX ***/
#define __vec_sum_4d(src_vec1, src_vec2, dst_vec) \
  dst_vec[0] = src_vec1[0] + src_vec2[0];\
  dst_vec[1] = src_vec1[1] + src_vec2[1];\
  dst_vec[2] = src_vec1[2] + src_vec2[2];\
  dst_vec[3] = src_vec1[3] + src_vec2[3];


int main (int argc, char *argv[]) {
  unsigned long i;

  double dvec1[4] = {atof(argv[1]), atof(argv[2]), atof(argv[3]), atof(argv[4])};
  double dvec2[4] = {atof(argv[5]), atof(argv[6]), atof(argv[7]), atof(argv[8])}; 

#if 1
  for (i = 0; i < 3000000000; i++) {
    __vec_sum_4d(dvec1, dvec2, dvec2);
  }
#endif
#if 0
  for (i = 0; i < 3000000000; i++) {
    __vec_sum_4d_d64(dvec1, dvec2, dvec2);
  }
#endif

  printf("%10.10lf %10.10lf %10.10lf %10.10lf\n", dvec2[0], dvec2[1], dvec2[2], dvec2[3]);
}
Run Code Online (Sandbox Code Playgroud)

我只是切换#if 1#if 0“模式”(AVX 和非 AVX)之间切换。我的期望是,使用 AVX 的循环至少会比另一个快一些,但事实并非如此。我用gcc version 10.2.0 (GCC)这些编译了代码:-O2 --std=gnu99 -lm -mavx2标志。

#include <stdio.h>
#include <stdlib.h>

#include <emmintrin.h>
#include <immintrin.h>


/*** Sum up two vectors using AVX ***/
#define __vec_sum_4d_d64(src_vec1, src_vec2, dst_vec) \
  _mm256_store_pd(dst_vec, _mm256_add_pd(_mm256_load_pd(src_vec1), _mm256_load_pd(src_vec2)));

/*** Sum up two vectors without AVX ***/
#define __vec_sum_4d(src_vec1, src_vec2, dst_vec) \
  dst_vec[0] = src_vec1[0] + src_vec2[0];\
  dst_vec[1] = src_vec1[1] + src_vec2[1];\
  dst_vec[2] = src_vec1[2] + src_vec2[2];\
  dst_vec[3] = src_vec1[3] + src_vec2[3];


int main (int argc, char *argv[]) {
  unsigned long i;

  double dvec1[4] = {atof(argv[1]), atof(argv[2]), atof(argv[3]), atof(argv[4])};
  double dvec2[4] = {atof(argv[5]), atof(argv[6]), atof(argv[7]), atof(argv[8])}; 

#if 1
  for (i = 0; i < 3000000000; i++) {
    __vec_sum_4d(dvec1, dvec2, dvec2);
  }
#endif
#if 0
  for (i = 0; i < 3000000000; i++) {
    __vec_sum_4d_d64(dvec1, dvec2, dvec2);
  }
#endif

  printf("%10.10lf %10.10lf %10.10lf %10.10lf\n", dvec2[0], dvec2[1], dvec2[2], dvec2[3]);
}
Run Code Online (Sandbox Code Playgroud)

如您所见,它们几乎以相同的速度运行。我还尝试将迭代次数增加 10 倍,但结果只会按比例增加。另请注意,两个可执行文件的打印输出值相同,因此我认为最好说两者都执行相同的计算。深入挖掘,我查看了程序集,更加困惑了。以下是两者的重要部分(仅循环):

; With avx
1070:   c5 fd 58 c1             vaddpd %ymm1,%ymm0,%ymm0
1074:   48 83 e8 01             sub    $0x1,%rax
1078:   75 f6                   jne    1070

; Without avx
1080:   c5 fb 58 c4             vaddsd %xmm4,%xmm0,%xmm0
1084:   c5 f3 58 cd             vaddsd %xmm5,%xmm1,%xmm1
1088:   c5 eb 58 d7             vaddsd %xmm7,%xmm2,%xmm2
108c:   c5 e3 58 de             vaddsd %xmm6,%xmm3,%xmm3
1090:   48 83 e8 01             sub    $0x1,%rax
1094:   75 ea                   jne    1080
Run Code Online (Sandbox Code Playgroud)

根据我的理解,第二个应该慢得多,因为除了递减计数器和条件跳转之外,其中的指令数量是其四倍。为什么不慢?是vaddsd指令的速度比只有4次vaddpd

如果这是相关的,我的系统在AMD Ryzen 5 2600X Six-Core Processor支持 AVX 的系统上运行。

ste*_*pan 7

使用 AVX

; With avx
1070:   c5 fd 58 c1             vaddpd %ymm1,%ymm0,%ymm0
1074:   48 83 e8 01             sub    $0x1,%rax
1078:   75 f6                   jne    1070
Run Code Online (Sandbox Code Playgroud)

该循环ymm0用作累加器。换句话说,它正在执行ymm0 += ymm1(这是一个向量操作;一次添加 4 个双精度值)。因此它具有循环携带的依赖性ymm0(每个新添加都必须等待前一个添加完成并使用结果开始下一个添加)。vaddpdZen+ 的延迟 = 3,吞吐量 = 1(根据https://www.uops.info/table.html)。循环携带的依赖性使该循环的延迟成为 的瓶颈vaddpd,因此您的循环最多可以获得 3 个循环/迭代。vaddpdCPU 中只有一个附加功能在运行中,这在很大程度上未充分利用其功能。

为了更快地添加更多的累加器(有更多的向量要求和)。它可以(理论上)由于流水线(3 个完整的ymm添加过程)而快 3 倍,只要它不受其他东西的限制。

没有 AVX

; Without avx
1080:   c5 fb 58 c4             vaddsd %xmm4,%xmm0,%xmm0
1084:   c5 f3 58 cd             vaddsd %xmm5,%xmm1,%xmm1
1088:   c5 eb 58 d7             vaddsd %xmm7,%xmm2,%xmm2
108c:   c5 e3 58 de             vaddsd %xmm6,%xmm3,%xmm3
1090:   48 83 e8 01             sub    $0x1,%rax
1094:   75 ea                   jne    1080
Run Code Online (Sandbox Code Playgroud)

该循环将结果累加到 4 个不同的累加器中。基本上它是这样做的:

xmm0 += xmm4
xmm1 += xmm5
xmm2 += xmm7
xmm3 += xmm6
Run Code Online (Sandbox Code Playgroud)

所有这些加法都是相互独立的(并且它们是标量加法,因此每个加法都只对单个 64 位浮点值进行操作)。vaddsd延迟=3,吞吐量=0.5(每条指令周期数)。这意味着它可以在一个周期内开始执行前 2 次加法。然后在下一个循环中,它将开始第二对加法。因此,可以根据吞吐量为该循环实现 2 个周期/迭代。但是延迟,你记得是 3 个周期。所以这个循环在延迟上也有瓶颈。展开一次(使用 4 个额外的累加器;或者通过在将其添加到主累加器之前在彼此之间添加 xmm4-7 来中断循环内的循环携带的 dep.chain)以摆脱瓶颈(它可能会加快约 50%) .

请注意,此(“无 AVX”)反汇编仍在使用 VEX 编码,因此技术上仍需要支持 AVX 的 CPU。

关于基准测试

请注意,您的反汇编没有任何加载或存储,因此这可能代表也可能不代表添加 2 个 4 双向量数组的性能比较。

  • 在 Zen1 上,256 位数学指令解码为 2 uop(即,它们将 YMM 寄存器分成两个 128 位半部分)。那里可能会发生前端效应;我本来预计 YMM 版本至少会一样快,因为它具有相同的延迟,但运行的微指令数量是 YMM 版本的一半。IDK,也许调度将两半部分联系在一起,因此有可能在一条关键路径上“丢失周期”?(如果发生这种情况,可能使用超过 3 个累加器展开可能会更好地给调度一些余量,就像[此问答](/sf/ask/3157946921/)中所示 (2认同)