【问题标题】:Why AVX dot product slower than native C++ code为什么 AVX 点积比原生 C++ 代码慢
【发布时间】:2023-03-09 13:40:01
【问题描述】:

我有以下 AVX 和 Native 代码:

__forceinline double dotProduct_2(const double* u, const double* v)   
{  
    _mm256_zeroupper();   
    __m256d xy          = _mm256_mul_pd(_mm256_load_pd(u), _mm256_load_pd(v));
    __m256d temp        = _mm256_hadd_pd(xy, xy);
    __m128d dotproduct  = _mm_add_pd(_mm256_extractf128_pd(temp, 0), _mm256_extractf128_pd(temp, 1));
    return dotproduct.m128d_f64[0];
}

__forceinline double dotProduct_1(const D3& a, const D3& b)
{
    return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
}

以及相应的测试脚本:

std::cout << res_1 << " " << res_2 << " " << res_3 << '\n';
{
    std::chrono::high_resolution_clock::time_point t1 = std::chrono::high_resolution_clock::now();

    for (int i = 0; i < (1 << 30); ++i)
    {
        zx_1 += dotProduct_1(aVx[i % 10000], aVx[(i + 1) % 10000]);
    }
    std::chrono::high_resolution_clock::time_point t2 = std::chrono::high_resolution_clock::now();

    std::cout << "NAIVE : " << std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count() << '\n';
}

{
    std::chrono::high_resolution_clock::time_point t1 = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < (1 << 30); ++i)
    {
        zx_2 += dotProduct_2(&aVx[i % 10000][0], &aVx[(i + 1) % 10000][0]);
    }

    std::chrono::high_resolution_clock::time_point t2 = std::chrono::high_resolution_clock::now();

    std::cout << "AVX : " << std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count() << '\n';
}

std::cout << math::min2(zx_1, zx_2) << " " << zx_1 << " " << zx_2;

好吧,所有数据都按 32 对齐。(D3 与 __declspec... 和 aVx arr 与 _mm_malloc()..) 而且,正如我所看到的,本机变体与 AVX 变体相等/或更快。我无法理解它的正常行为?因为我认为 AVX 是“超级快”......如果不是,我该如何优化它?我在 MSVC 2015(x64) 上使用 arch AVX 编译它。另外,我的硬件是intel i7 4750HQ(haswell)

【问题讨论】:

  • 发布 MSVC 使用它们制作的 ASM。它可能会使用比vhaddpd 更有效的方式自动矢量化。理想情况下,它将 FMADDPD 用于向量累加器并保存所有水平求和以供结束。如果 MSVC 没有优化掉您手动矢量化的随机播放,那么它不会很快。请参阅stackoverflow.com/questions/6996764/… 了解更多关于为什么hadd 是错误的随机播放。
  • 更不用说在获得可靠的基准之前您无法调查性能。
  • 这是ASM代码(这部分够了吗?):sun9-11.userapi.com/c840724/v840724207/bfbd/kN62Ipo6TZ8.jpg
  • AVX 不是问题,你只是用错了。像往常一样,将水平部分保存在末尾。
  • @DesSpigel:意思是:在循环中垂直做部分点积,然后在最后做一个最终的水平和。

标签: c++ performance simd avx


【解决方案1】:

您使用 vzeroupper 和 hadd 指令增加了太多开销。编写它的好方法是在一个循环中执行所有乘法并在最后只聚合一次结果。假设您展开原始循环 4 次并使用 4 个累加器:

for(i=0; i < (1<<30); i+=4) {
  s0 += a[i+0] * b[i+0];
  s1 += a[i+1] * b[i+1];
  s2 += a[i+2] * b[i+2];
  s3 += a[i+3] * b[i+3];
}
return s0+s1+s2+s3;

现在只需用 SIMD mul 替换展开的循环并添加(或什至 FMA 内在,如果可用)

【讨论】:

  • 常用词是“累加器”,而不是“聚合器”。但是,是的,完全正确。感谢您将 cmets 总结为答案。
【解决方案2】:

使用基本循环进行简单的分析并不是一个好主意 - 它通常只是意味着您的内存带宽有限,因此测试最终以大致相同的速度出现(内存通常比 CPU 慢,并且这基本上就是你在这里测试的全部内容)

正如其他人所说,您的代码示例不是伟大,因为您经常穿越车道(我假设这只是为了找到最快的点积,而不是专门因为所有点积的总和是期望的结果?)。老实说,如果你真的需要一个快速的点积(对于此处提供的 AOS 数据),我想我更愿意用 VADDPD + VPERMILPD 替换 VHADDPD(以额外的指令换取两倍的吞吐量,以及更低的延迟)

double dotProduct_3(const double* u, const double* v)   
{  
    __m256d dp = _mm256_mul_pd(_mm256_load_pd(u), _mm256_load_pd(v));
    __m128d a = _mm256_extractf128_pd(dp, 0);
    __m128d b = _mm256_extractf128_pd(dp, 1);
    __m128d c = _mm_add_pd(a, b);
    __m128d yy = _mm_unpackhi_pd(c, c);
    __m128d dotproduct  = _mm_add_pd(c, yy);
    return _mm_cvtsd_f64(dotproduct);
}

asm:

dotProduct_3(double const*, double const*):
 vmovapd ymm0,YMMWORD PTR [rsi]
 vmulpd ymm0,ymm0,YMMWORD PTR [rdi]
 vextractf128 xmm1,ymm0,0x1
 vaddpd xmm0,xmm1,xmm0
 vpermilpd xmm1,xmm0,0x3
 vaddpd xmm0,xmm1,xmm0
 vzeroupper 
 ret   

一般来说,如果你使用水平添加,你就错了!虽然 256 位寄存器对于 Vector4d 来说似乎是理想的,但它实际上并不是一个特别好的表示(特别是如果您认为 AVX512 现在可用!)。最近出现了一个与此非常相似的问题:For C++ Vector3 utility class implementations, is array faster than struct and class?

如果您想要性能,那么数组结构是最好的选择。

struct HybridVec4SOA
{
  __m256d x;
  __m256d y;
  __m256d z;
  __m256d w;
};
__m256d dot(const HybridVec4SOA& a, const HybridVec4SOA& b)
{
  return _mm256_fmadd_pd(a.w, b.w, 
         _mm256_fmadd_pd(a.z, b.z, 
         _mm256_fmadd_pd(a.y, b.y, 
         _mm256_mul_pd(a.x, b.x))));
}

asm:

dot(HybridVec4SOA const&, HybridVec4SOA const&):
 vmovapd ymm1,YMMWORD PTR [rdi+0x20]
 vmovapd ymm2,YMMWORD PTR [rdi+0x40]
 vmovapd ymm3,YMMWORD PTR [rdi+0x60]
 vmovapd ymm0,YMMWORD PTR [rsi]
 vmulpd ymm0,ymm0,YMMWORD PTR [rdi]
 vfmadd231pd ymm0,ymm1,YMMWORD PTR [rsi+0x20]
 vfmadd231pd ymm0,ymm2,YMMWORD PTR [rsi+0x40]
 vfmadd231pd ymm0,ymm3,YMMWORD PTR [rsi+0x60]
 ret    

如果您比较 load/mul/fmadd 与 hadd 和 extract 的延迟(更重要的是吞吐量),然后考虑 SOA 版本一次计算 4 个点积(而不是 1 个),您将开始明白为什么这是要走的路......

【讨论】:

  • 避免vhaddpd 是吞吐量延迟的胜利。没有折衷,hadd 指令很糟糕,除非您可以将它们与 2 个不同的输入一起使用(例如,作为转置和添加的一部分)。 Intel 和 AMD 将它们解码为 2 个 shuffle uop,这些 uop 对奇数/偶数进行解交错以产生 2 个输入用于垂直加法 uop。 (大概在内部使用与 shufps / shufpd 相同的 shuffle uops)。
  • 谢谢彼得!我想我写那句话的时候脑子都乱了!更新了评论。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-08-29
  • 2014-02-19
  • 1970-01-01
  • 2012-02-13
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多