【问题标题】:AVX2: Computing dot product of 512 float arraysAVX2:计算 512 个浮点数组的点积
【发布时间】:2020-04-17 01:40:07
【问题描述】:

我先说我是 SIMD 内部函数的初学者。

本质上,我有一个支持 AVX2 内在 (Intel(R) Core(TM) i5-7500T CPU @ 2.70GHz) 的 CPU。我想知道计算大小为512 的两个std::vector<float> 的点积的最快方法。

我在网上做了一些挖掘,发现thisthis,以及this堆栈溢出问题建议使用以下函数__m256 _mm256_dp_ps(__m256 m1, __m256 m2, const int mask);,但是,这些都建议执行点积的不同方式我不是确定什么是正确(和最快)的方法。

特别是,我正在寻找对大小为 512 的向量执行点积的最快方法(因为我知道向量大小会影响实现)。

感谢您的帮助

编辑 1: 我对-mavx2 gcc 标志也有点困惑。如果我使用这些 AVX2 函数,我是否需要在编译时添加标志?另外,如果我编写了一个天真的点积实现,gcc 是否能够为我做这些优化(比如如果我使用-OFast gcc 标志)?

编辑 2 如果有人有时间和精力,如果您能编写一个完整的实现,我将不胜感激。我相信其他初学者也会重视这些信息。

【问题讨论】:

  • 您只需要dpps 来处理 3 或 4 元素点积。对于较大的阵列,您希望将垂直 FMA 放入多个累加器 (Why does mulss take only 3 cycles on Haswell, different from Agner's instruction tables? (Unrolling FP loops with multiple accumulators))。你会想要-mfma -mavx2 或更好的-march=native。是的,您需要为要使用的任何内在函数启用目标选项,以及 -O3
  • 例如How to properly use prefetch instructions? 显示了普通 SIMD 点积的内部循环。
  • -O3 不能自动矢量化一个简单的点积,除非您使用 OpenMP 或 -ffast-math 告诉编译器将 FP 数学视为关联。
  • -Ofast 目前等同于-O3 -ffast-math,所以可以。但不幸的是,即使它展开 FP 循环,GCC 也不会使用多个累加器,因此您将成为 SIMD FMA 延迟而不是吞吐量的瓶颈。 (Skylake 的 8 因子)
  • Soonts 的回答正是我所描述的。它需要 AVX + FMA,而不是 AVX2。谢谢@Sonts,写出来。我认为在某个地方还有另一个 Q&A 有一个很好的规范点积实现,我之前一直在寻找但没有立即找到。

标签: c++ simd avx2 dot-product fma


【解决方案1】:

_mm256_dp_ps 仅对 2 到 4 个元素的点积有用;对于较长的向量,在循环中使用垂直 SIMD 并在最后减少为标量。在循环中使用_mm256_dp_ps_mm256_add_ps 会慢得多。


GCC 和 clang 要求您启用(使用命令行选项)您使用内部函数的 ISA 扩展,这与 MSVC 和 ICC 不同。


下面的代码可能接近 CPU 的理论性能极限。未经测试。

使用 clang 或 gcc -O3 -march=native 编译它。 (至少需要-mavx -mfma,但-march 隐含的-mtune 选项也很好,其他-mpopcnt 和其他arch=native 启用的选项也是如此。调整选项对于大多数CPU 的高效编译至关重要使用 FMA,特别是 -mno-avx256-split-unaligned-load: Why doesn't gcc resolve _mm256_loadu_pd as single vmovupd?)

或者用MSVC编译-O2 -arch:AVX2

#include <immintrin.h>
#include <vector>
#include <assert.h>

// CPUs support RAM access like this: "ymmword ptr [rax+64]"
// Using templates with offset int argument to make easier for compiler to emit good code.

// Multiply 8 floats by another 8 floats.
template<int offsetRegs>
inline __m256 mul8( const float* p1, const float* p2 )
{
    constexpr int lanes = offsetRegs * 8;
    const __m256 a = _mm256_loadu_ps( p1 + lanes );
    const __m256 b = _mm256_loadu_ps( p2 + lanes );
    return _mm256_mul_ps( a, b );
}

// Returns acc + ( p1 * p2 ), for 8-wide float lanes.
template<int offsetRegs>
inline __m256 fma8( __m256 acc, const float* p1, const float* p2 )
{
    constexpr int lanes = offsetRegs * 8;
    const __m256 a = _mm256_loadu_ps( p1 + lanes );
    const __m256 b = _mm256_loadu_ps( p2 + lanes );
    return _mm256_fmadd_ps( a, b, acc );
}

// Compute dot product of float vectors, using 8-wide FMA instructions.
float dotProductFma( const std::vector<float>& a, const std::vector<float>& b )
{
    assert( a.size() == b.size() );
    assert( 0 == ( a.size() % 32 ) );
    if( a.empty() )
        return 0.0f;

    const float* p1 = a.data();
    const float* const p1End = p1 + a.size();
    const float* p2 = b.data();

    // Process initial 32 values. Nothing to add yet, just multiplying.
    __m256 dot0 = mul8<0>( p1, p2 );
    __m256 dot1 = mul8<1>( p1, p2 );
    __m256 dot2 = mul8<2>( p1, p2 );
    __m256 dot3 = mul8<3>( p1, p2 );
    p1 += 8 * 4;
    p2 += 8 * 4;

    // Process the rest of the data.
    // The code uses FMA instructions to multiply + accumulate, consuming 32 values per loop iteration.
    // Unrolling manually for 2 reasons:
    // 1. To reduce data dependencies. With a single register, every loop iteration would depend on the previous result.
    // 2. Unrolled code checks for exit condition 4x less often, therefore more CPU cycles spent computing useful stuff.
    while( p1 < p1End )
    {
        dot0 = fma8<0>( dot0, p1, p2 );
        dot1 = fma8<1>( dot1, p1, p2 );
        dot2 = fma8<2>( dot2, p1, p2 );
        dot3 = fma8<3>( dot3, p1, p2 );
        p1 += 8 * 4;
        p2 += 8 * 4;
    }

    // Add 32 values into 8
    const __m256 dot01 = _mm256_add_ps( dot0, dot1 );
    const __m256 dot23 = _mm256_add_ps( dot2, dot3 );
    const __m256 dot0123 = _mm256_add_ps( dot01, dot23 );
    // Add 8 values into 4
    const __m128 r4 = _mm_add_ps( _mm256_castps256_ps128( dot0123 ), _mm256_extractf128_ps( dot0123, 1 ) );
    // Add 4 values into 2
    const __m128 r2 = _mm_add_ps( r4, _mm_movehl_ps( r4, r4 ) );
    // Add 2 lower values into the final result
    const __m128 r1 = _mm_add_ss( r2, _mm_movehdup_ps( r2 ) );
    // Return the lowest lane of the result vector.
    // The intrinsic below compiles into noop, modern compilers return floats in the lowest lane of xmm0 register.
    return _mm_cvtss_f32( r1 );
}

可能的进一步改进:

  1. 按 8 个向量而不是 4 个向量展开。我检查了 gcc 9.2 asm output,编译器仅使用了 16 个可用向量寄存器中的 8 个。

  2. 确保两个输入向量对齐,例如使用自定义分配器,在 msvc 上调用 _aligned_malloc / _aligned_free,或在 gcc 和 clang 上调用 aligned_alloc / free。然后将_mm256_loadu_ps 替换为_mm256_load_ps


要自动矢量化简单的标量点积,您还需要 OpenMP SIMD 或 -ffast-math(由 -Ofast 暗示)让编译器将 FP 数学视为关联,即使它不是关联的(因为舍入)。但是 GCC 在自动矢量化时不会使用多个累加器,即使它展开了,所以你会成为 FMA 延迟的瓶颈,而不是负载吞吐量。

(每个 FMA 2 个负载意味着此代码的吞吐量瓶颈是向量负载,而不是实际的 FMA 操作。)

【讨论】:

  • 请注意,展开因子必须是 2 的幂;例如展开 6 就可以了(例如,如果您的目标是只有 8 个向量寄存器的 32 位模式,您可能会针对 1 个 vmovups 加载和一个带有内存源操作数的 vfmadd,并将其他 7 个向量寄存器作为累加器。)但是对于已知大小的 2 的幂(512),4 或 8 就可以了。显然,更多的累加器在实践中会有所帮助,尽管理论上这会成为负载吞吐量的瓶颈(每个 FMA 2 个负载)。但是对于短向量,可能最好保持代码大小和清理紧凑。总迭代次数不多。
  • GCC / clang 不需要模板的东西来为 FMA 使用内存源操作数。如果你只是做了p1+8*1等等,它可以在内联后进行持续传播。
  • 我对你的回答做了一些修改;我希望你不介意合作。如果您这样做,请将其回滚并告诉我,以便我可以重新发布作为我自己的答案。
  • @PeterCordes 是的,我认为性能应该还可以。但是,数值精度可能不是。对于某些输入数据,这些中间 32 位浮点数与几乎随机的加法顺序相结合可能会导致数值问题。如果我会做 OP 正在做的事情,我会考虑在 __m256d 中累积:更复杂、更慢但更精确。显然,最佳权衡取决于应用程序。
  • SIMD + 多个累加器是成对求和的一部分;一种公认的减少对 FP 数组求和的错误的技术(将一个小元素添加到一个大的总数是不好的)。如果元素的量级大多相似,特别是当它们都是正数时,拥有许多较小的总数会显着降低舍入误差。在Simd matmul program gives different numerical results 上查看我的回答。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-12-19
  • 2012-03-10
  • 2018-03-13
  • 1970-01-01
  • 2011-06-07
  • 1970-01-01
相关资源
最近更新 更多