为什么gcc在条件乘法的std::vector< float>向量化上比clang差那么多?

rqcrx0a6  于 2023-08-06  发布在  其他
关注(0)|答案(3)|浏览(90)

考虑以下浮点循环,使用-O3 -mavx 2-mfma编译

for (auto i = 0; i < a.size(); ++i) {
    a[i] = (b[i] > c[i]) ? (b[i] * c[i]) : 0;
}

字符串
Clang把它矢量化得很完美它使用256位ymm寄存器,并了解vblendps/vandps之间的差异,以实现最佳性能。

.LBB0_7:
        vcmpltps        ymm2, ymm1, ymm0
        vmulps  ymm0, ymm0, ymm1
        vandps  ymm0, ymm2, ymm0


GCC,但是,要糟糕得多。出于某种原因,它并不比SSE 128位向量更好(-mprefer-vector-width=256不会改变任何内容)。

.L6:
        vcomiss xmm0, xmm1
        vmulss  xmm0, xmm0, xmm1
        vmovss  DWORD PTR [rcx+rax*4], xmm0


如果将其替换为普通数组(as in guideline),则gcc会将其矢量化为AVX ymm。

int a[256], b[256], c[256];
auto foo (int *a, int *b, int *c) {
  int i;
  for (i=0; i<256; i++){
    a[i] =  (b[i] > c[i]) ? (b[i] * c[i]) : 0;
  }
}


然而,我没有找到如何用可变长度的std::vector来实现它。gcc需要什么样的提示才能将std::vector矢量化到AVX?
Source on Godbolt with gcc 13.1 and clang 14.0.0

u2nhd7ah

u2nhd7ah1#

这不是std::vector的问题,这是float和GCC的-ftrapping-math的通常不好的默认值,应该将FP异常视为可见的副作用,但并不总是正确地这样做,并错过了一些安全的优化。
在这种情况下,在源代码中有一个条件FP乘法,因此严格的异常行为避免了在比较为假的情况下可能引发溢出,下溢,不精确或其他异常。

GCC在本例中使用标量代码正确地完成了这一操作...ss是Scalar Single,使用128位XMM寄存器的底部元素,根本没有矢量化。你的asm不是GCC的实际输出:它用vmovss加载两个元素,然后在vmulss之前的vcomiss结果上分支,所以如果b[i] > c[i]不为真,乘法就不会发生。因此,与您的“GCC”asm不同,我认为GCC的实际asm正确地实现了-ftrapping-math

请注意,您的自动矢量化示例使用的是int * args,而不是float*。如果将其更改为float*并使用相同的编译器选项,它也不会自动向量化,即使使用float *__restrict ahttps://godbolt.org/z/nPzsf377b)也是如此。
@273K的答案表明AVX-512允许float自动向量化,即使使用-ftrapping-math,因为AVX-512掩码(ymm2{k1}{z})抑制了掩码元素的FP异常,不会从任何FP乘法中引发FP异常,而这些FP乘法在C++抽象机中不会发生。

gcc -O3 -mavx2 -mfma -fno-trapping-math自动矢量化所有3个函数(Godbolt)

void foo (float *__restrict a, float *__restrict b, float *__restrict c) {
  for (int i=0; i<256; i++){
    a[i] =  (b[i] > c[i]) ? (b[i] * c[i]) : 0;
  }
}

个字符
顺便说一句,**我推荐-march=x86-64-v3**用于AVX 2 +FMA功能级别。这也包括BMI 1 + BMI 2和东西。我认为它仍然只是使用-mtune=generic,但希望将来可以忽略那些只对没有AVX 2 +FMA+ BMI 2的CPU重要的调整。
std::vector函数更庞大,因为我们没有使用float *__restrict a = avec.data();或类似的东西来保证std::vector控制块指向的数据不重叠(并且大小不知道是向量宽度的倍数),但是对于不重叠的情况,非清理循环使用相同的vmulps/vcmpltps/vandps进行向量化。
参见:

修改源码,让乘法变成无条件?不知道

如果C源代码中的乘法无论条件如何都会发生,那么GCC将 * 允许 * 在没有AVX-512掩码的情况下以有效的方式对其进行向量化。

// still scalar asm with GCC -ftrapping-math which is a bug
void foo (float *__restrict a, float *__restrict b, float *__restrict c) {
  for (int i=0; i<256; i++){
    float prod = b[i] * c[i];
    a[i] =  (b[i] > c[i]) ? prod : 0;
  }
}


但不幸的是,GCC -O3 -march=x86-64-v3(Godbolt有和没有默认的-ftrapping-math)仍然使标量asm只能有条件地相乘!

这是-ftrapping-math中的bug。它不仅过于保守,错过了自动矢量化的机会:它实际上是错误的,* 不是 * 为抽象机器(或调试构建)实际执行的某些乘法引发FP异常。像这样的垃圾行为就是为什么-ftrapping-math不可靠,并且可能不应该在默认情况下打开。

@Ovinus真实的的回答指出,GCC -ftrapping-math仍然可以通过屏蔽 * 两个输入 * 而不是输出来自动矢量化原始源代码。0.0 * 0.0从不引发任何FP异常,因此它基本上是在模拟AVX-512零掩码。
这将是更昂贵的,并有更多的延迟,无序执行隐藏,但仍然比标量好得多,特别是当AVX 1可用时,特别是对于中小型阵列,在某些级别的高速缓存中是热的。
(If使用intrinsic编写,只需将输出屏蔽为零,除非您确实希望在循环后检查FP环境中的异常标志。)
在标量源代码中这样做不会导致GCC像那样制作asm:GCC将其编译为相同的分支标量asm,除非您使用-fno-trapping-math。至少这不是一个bug,只是一个错过的优化:当比较结果为false时,不会执行b[i]*c[i]

// doesn't help, still scalar asm with GCC -ftrapping-math
void bar (float *__restrict a, float *__restrict b, float *__restrict c) {
  for (int i=0; i<256; i++){
    float bi = b[i];
    float ci = c[i];
    if (! (bi > ci)) {
        bi = ci = 0;
    }
    a[i] = bi * ci;
  }
}

628mspwn

628mspwn2#

GCC默认为较旧的CPU架构编译。
设置-march=native可启用256位ymm寄存器。

.L7:
        vmovups ymm1, YMMWORD PTR [rsi+rax]
        vmovups ymm0, YMMWORD PTR [rdx+rax]
        vcmpps  k1, ymm1, ymm0, 14
        vmulps  ymm2{k1}{z}, ymm1, ymm0
        vmovups YMMWORD PTR [rcx+rax], ymm2

字符串
设置-march=x86-64-v4可启用512位zmm寄存器。

.L7:
        vmovups zmm2, ZMMWORD PTR [rsi+rax]
        vcmpps  k1, zmm2, ZMMWORD PTR [rdx+rax], 14
        vmulps  zmm0{k1}{z}, zmm2, ZMMWORD PTR [rdx+rax]
        vmovups ZMMWORD PTR [rcx+rax], zmm0

093gszye

093gszye3#

假设-ftrapping-math,另一个选项是在将它们相乘之前将忽略的输入置零(未测试):

for (size_t i = 0; i < size; i += 4) {
    __m128i x = _mm_loadu_si128((const __m128i*)(a + i));
    __m128i y = _mm_loadu_si128((const __m128i*)(b + i));
    __m128i cmp = _mm_cmplt_ps(x, y);
    
    x = _mm_and_ps(x, cmp);
    y = _mm_and_ps(y, cmp);

    _mm_storeu_si128((__m128i*)(a + i), _mm_mul_ps(x, y));
}

字符串
这当然转化为更大的宽度。
两个输入都必须归零,因为如果x < 0,则+0.0 * x为-0.0。在某些处理器上,这可能与相同向量宽度的其他解决方案具有相同的吞吐量。同样的方法也适用于加法、减法和平方根。除法需要除零以外的除数。
即使在fno-trapping-math下,该解决方案也可能略上级乘法之后的一个掩蔽,因为它避免了与需要微编码乘法的被忽略的输入相关联的惩罚。但我不确定吞吐量是否可以与乘法后为零的版本相同。

相关问题