SIMD用于浮动阈值操作

时间:2019-06-19 14:29:44

标签: c++ double vectorization sse simd

我想更快地进行矢量计算,并且我相信用于浮点比较和操作的SIMD指令会有所帮助,这是操作:

void func(const double* left, const double* right, double* res, const size_t size, const double th, const double drop) {
        for (size_t i = 0; i < size; ++i) {
            res[i] = right[i] >= th ? left[i] : (left[i] - drop) ;
        }
    }

主要是,如果left的值大于drop,它将right的值减少threshold

大小约为128-256(不是很大),但计算量很大。

我尝试从循环展开开始,但是没有获得很多性能,但是可能需要一些编译指令。

您能否建议对代码进行一些改进以加快计算速度?

3 个答案:

答案 0 :(得分:5)

Clang已经按照Soonts建议的手动方式对向量进行了自动矢量化。在指针上使用__restrict,因此不需要后备版本,该版本可以在某些数组。它仍然会自动矢量化,但会使功能肿。

不幸的是,gcc仅使用-ffast-math自动向量化。我不确定严格的FP的哪个部分会阻止它。 (更新:仅需-fno-trapping-math:在大多数情况下,如果您不隐藏任何FP异常或查看MXCSR粘性FP异常标志,这可能是安全的。)

使用该选项,GCC也会将(v)pblendvpd-march=nehalem-march=znver1一起使用。 See it on Godbolt

此外,您的C函数也损坏了。 thdrop是标量双精度,但是您将它们声明为const double *


AVX512F允许您进行!(right[i] >= thresh)比较,并将结果掩码用于合并掩码减法。

谓词为true的元素将获得left[i] - drop,其他元素将保留其left[i]的值,因为您合并了信息left的向量。

不幸的是,带有-march=skylake-avx512的GCC使用普通的vsubpd,然后使用单独的vmovapd zmm2{k1}, zmm5进行混合,这显然是错过的优化方法。混合目标已经是SUB的输入之一。

将AVX512VL用于256位向量(以防程序的其余部分无法有效使用512位,因此您的Turbo时钟速度不会降低):

__m256d left = ...;
__m256d right = ...;
__mmask8 cmp = _mm256_cmp_pd_mask(right, set1(th), _CMP_NGE_UQ);
__m256d res = _mm256_mask_sub_pd (left, cmp, left, set1(drop));

因此(除了加载和存储)还有AVX512F / VL的2条指令。


如果您不需要版本的特定NaN行为,GCC也可以自动矢量化

它在所有编译器中都效率更高,因为您只需要AND,而不是变量混合。因此,仅使用SSE2会明显更好,即使在大多数CPU支持SSE4的情况下,它也会更好.1 blendvpd,因为该指令效率不高。

您可以根据比较结果从0.0中减去dropleft[i]

根据比较结果产生0.0或常量非常有效:只需执行andps指令。 (0.0的位模式为全零,SIMD会比较全1或全0位的产生向量。因此,AND会将旧值保留为零或将其清零。)

我们也可以添加-drop而不是减去drop。这会在输入上造成额外的否定,但是使用AVX可以为vaddpd使用内存源操作数。 GCC选择使用索引寻址模式,因此实际上并不能帮助减少Intel CPU的前端uop数量。它将“分层”。但是即使使用-ffast-math,gcc也不会单独进行此优化以允许折叠负载。 (不过,除非我们展开循环,否则不应该单独进行指针增量操作。)

void func3(const double *__restrict left, const double *__restrict right, double *__restrict res,
  const size_t size, const double th, const double drop)
{
    for (size_t i = 0; i < size; ++i) {
        double add = right[i] >= th ? 0.0 : -drop;
        res[i] = left[i] + add;
    }
}

来自上述Godbolt链接的GCC 9.1的内部循环(无任何-march选项且无-ffast-math

# func3 main loop
# gcc -O3 -march=skylake       (without fast-math)
.L33:
    vcmplepd        ymm2, ymm4, YMMWORD PTR [rsi+rax]
    vandnpd ymm2, ymm2, ymm3
    vaddpd  ymm2, ymm2, YMMWORD PTR [rdi+rax]
    vmovupd YMMWORD PTR [rdx+rax], ymm2
    add     rax, 32
    cmp     r8, rax
    jne     .L33

或者普通的SSE2版本具有一个内部循环,该内部循环与left - zero_or_drop而不是left + zero_or_minus_drop基本上相同,因此,除非您可以保证编译器以16字节对齐,否则您将创建AVX版本,否定drop只是额外的开销。

取反drop会从内存中获取一个常量(以对符号位进行XOR),而这是该函数唯一需要的静态常量,因此在您遇到以下情况时,值得权衡考虑循环不会运行很多次。 (除非thdrop在内联之后也是编译时常量,并且无论如何都会被加载。或者特别是如果-drop可以在编译时进行计算。或者是否可以获取程序与否定的drop一起使用。)

加法和减法之间的另一个区别是减法不会破坏零的符号。 -0.0 - 0.0 = -0.0+0.0 - 0.0 = +0.0。以防万一。

# gcc9.1 -O3
.L26:
    movupd  xmm5, XMMWORD PTR [rsi+rax]
    movapd  xmm2, xmm4                    # duplicate  th
    movupd  xmm6, XMMWORD PTR [rdi+rax]
    cmplepd xmm2, xmm5                    # destroy the copy of th
    andnpd  xmm2, xmm3                    # _mm_andnot_pd
    addpd   xmm2, xmm6                    # _mm_add_pd
    movups  XMMWORD PTR [rdx+rax], xmm2
    add     rax, 16
    cmp     r8, rax
    jne     .L26

GCC使用未对齐的负载,因此(没有AVX)它无法将内存源操作数折叠为cmppdsubpd

答案 1 :(得分:4)

您在这里(未经测试),我试图在评论中解释他们的工作。

void func_sse41( const double* left, const double* right, double* res,
    const size_t size, double th, double drop )
{
    // Verify the size is even.
    // If it's not, you'll need extra code at the end to process last value the old way.
    assert( 0 == ( size % 2 ) );

    // Load scalar values into 2 registers.
    const __m128d threshold = _mm_set1_pd( th );
    const __m128d dropVec = _mm_set1_pd( drop );

    for( size_t i = 0; i < size; i += 2 )
    {
        // Load 4 double values into registers, 2 from right, 2 from left
        const __m128d r = _mm_loadu_pd( right + i );
        const __m128d l = _mm_loadu_pd( left + i );
        // Compare ( r >= threshold ) for 2 values at once
        const __m128d comp = _mm_cmpge_pd( r, threshold );
        // Compute ( left[ i ] - drop ), for 2 values at once
        const __m128d dropped = _mm_sub_pd( l, dropVec );
        // Select either left or ( left - drop ) based on the comparison.
        // This is the only instruction here that requires SSE 4.1.
        const __m128d result = _mm_blendv_pd( l, dropped, comp );
        // Store the 2 result values
        _mm_storeu_pd( res, result );
    }
}

如果CPU没有SSE 4.1,代码将崩溃并显示“无效指令”运行时错误。为了获得最佳结果,请使用CPU ID正常检测。我认为,在2019年,可以肯定地说它得到了支持,英特尔在2008年,2011年在AMD进行调查,蒸汽调查显示“ 96.3%”。如果要支持较旧的CPU,可以使用_mm_and_pd,_mm_andnot_pd,_mm_or_pd等3条其他指令来模拟_mm_blendv_pd。

如果可以保证数据对齐,则用_mm_load_pd替换负载会稍快一些,_mm_cmpge_pd会编译为CMPPD https://www.felixcloutier.com/x86/cmppd,后者可以直接从RAM中获取参数之一。

可能,通过编写AVX版本,您可以进一步提高2倍。但我希望SSE版本比您的代码更快,它每次迭代处理2个值,并且循环内没有条件。如果您不走运,AVX会变慢,许多CPU需要一些时间才能打开AVX单元的电源,这需要花费数千个周期。在通电之前,AVX代码运行非常缓慢。

答案 2 :(得分:2)

您可以使用GCC和Clang的矢量扩展来实现三元选择功能(请参见https://stackoverflow.com/a/48538557/2542702)。

#include <stddef.h>
#include <inttypes.h>

#if defined(__clang__)
typedef  double double4 __attribute__ ((ext_vector_type(4)));
typedef int64_t   long4 __attribute__ ((ext_vector_type(4)));
#else
typedef  double double4 __attribute__ ((vector_size (sizeof(double)*4)));
typedef int64_t   long4 __attribute__ ((vector_size (sizeof(int64_t)*4)));
#endif

double4 select(long4 s, double4 a, double4 b) {
  double4 c;
  #if defined(__GNUC__) && !defined(__INTEL_COMPILER) && !defined(__clang__)
  c = s ? a : b;
  #else
  for(int i=0; i<4; i++) c[i] = s[i] ? a[i] : b[i];
  #endif
  return c;
}

void func(double* left, double* right, double* res, size_t size, double th, double drop) {
  size_t i;
  for (i = 0; i<(size&-4); i+=4) {
    double4 leftv = *(double4*)&left[i];
    double4 rightv = *(double4*)&right[i];
    *(double4*)&res[i] = select(rightv >= th, leftv, leftv - drop);
  }
  for(;i<size; i++) res[i] = right[i] >= th ? left[i] : (left[i] - drop);
}

https://godbolt.org/z/h4OKMl