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

30
考虑以下浮点循环,使用 -O3 -mavx2 -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

如果将其替换为普通数组(如指南中所述),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呢? 在Godbolt上查看gcc 13.1和clang 14.0.0的源代码

5
顺便说一下,SSE代码实际上并没有使用128位向量,它是标量代码(带有“ss”后缀表示“标量,单精度”)。如果真的使用了SSE进行向量化,后缀应该是“ps”。 - harold
6
顺便说一下,SSE代码实际上并没有真正使用128位向量,它是标量代码(带有“ss”后缀代表“标量,单精度”)。如果它真的被向量化为SSE,后缀应该是“ps”。 - undefined
5
顺便说一句,SSE码并没有真正使用128位向量,它是标量码(带有ss后缀表示“标量,单精度”)。如果实际上是向量化的SSE,后缀应该是ps - harold
1
另外,那并不是SSE,它使用的是AVX编码(vcomiss标量比较,而不是传统的SSE comiss),通过启用AVX来使用128位的XMM寄存器。 - Peter Cordes
1
另外,那不是SSE,它使用了AVX编码(vcomiss标量比较,而不是传统的SSE comiss),并且使用了128位的XMM寄存器,因为你启用了AVX。 - Peter Cordes
3个回答

35
这个问题不在于`std::vector`,而是在于`float`和GCC通常糟糕的默认设置`-ftrapping-math`,它应该将浮点异常视为可见的副作用,但并不总是正确地做到这一点,并且会错过一些安全的优化。
在这种情况下,源代码中存在一个条件性的浮点乘法,因此严格的异常行为可以避免在比较结果为假时可能引发溢出、下溢、不精确或其他异常。
在这种情况下,GCC使用标量代码来正确处理:`...ss`是标量单精度浮点数,使用128位XMM寄存器的底部元素,根本没有向量化。你的汇编代码不是GCC的实际输出:它使用`vmovss`将两个元素都加载进来,然后在`vcomiss`结果之前进行分支判断,所以如果`b[i] > c[i]`不成立,乘法就不会发生。因此,与你的“GCC”汇编代码不同,我认为GCC的实际汇编代码正确地实现了`-ftrapping-math`。
请注意,您的示例中使用了int *参数进行自动向量化,而不是float*。如果您将其更改为float*并使用相同的编译器选项,即使使用float *__restrict ahttps://godbolt.org/z/nPzsf377b),它也不会自动向量化。
@273K的答案表明,AVX-512即使使用-ftrapping-math,也可以让float进行自动向量化,因为AVX-512掩码(ymm2{k1}{z})抑制了掩码元素的FP异常,不会引发任何在C++抽象机器中未发生的FP乘法的FP异常。

gcc -O3 -mavx2 -mfma -fno-trapping-math自动向量化了所有三个函数(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;
  }
}

foo(float*, float*, float*):
        xor     eax, eax
.L143:
        vmovups ymm2, YMMWORD PTR [rsi+rax]
        vmovups ymm3, YMMWORD PTR [rdx+rax]
        vmulps  ymm1, ymm2, YMMWORD PTR [rdx+rax]
        vcmpltps        ymm0, ymm3, ymm2
        vandps  ymm0, ymm0, ymm1
        vmovups YMMWORD PTR [rdi+rax], ymm0
        add     rax, 32
        cmp     rax, 1024
        jne     .L143
        vzeroupper
        ret

顺便说一下,我建议使用-march=x86-64-v3来实现AVX2+FMA特性级别。这还包括了BMI1+BMI2等功能。我认为它仍然只使用-mtune=generic,但希望将来可以忽略那些仅对没有AVX2+FMA+BMI2的CPU重要的调整。

std::vector函数变得更庞大,因为我们没有使用float *__restrict a = avec.data();或类似的方式来保证指向std::vector控制块的数据不重叠(并且大小未知是否是矢量宽度的倍数),但是对于无重叠情况的非清除循环仍然使用相同的vmulps / vcmpltps / vandps进行矢量化。


另请参阅:
  • -ftrapping-math是有问题的,根据GCC开发者Marc Glisse的说法,“从来没有起作用”。但是2012年提出的建议将其设置为非默认选项仍然未解决。
  • 如何强制GCC假设浮点表达式为非负数?(除了完整的-ffast-math之外,还有其他各种浮点选项,比如-fno-math-errno,它允许许多函数进行内联,并且对于在调用sqrt或其他函数后不检查errno的正常代码来说并不是问题!)
  • GCC中浮点数运算的语义
  • 双精度和-ffast-math上的自动向量化(当然,只有使用-ffast-math#pragma omp simd reduction (+:my_sum_var)时才会对归约进行向量化,但@phuclv的答案中有一些很好的链接)

调整源代码使乘法无条件执行?不行。
如果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-v3Godbolt 有和没有默认的 -ftrapping-math)仍然生成只有条件乘法的标量汇编代码!
这是一个在`-ftrapping-math`中的错误。它不仅过于保守,错失了自动向量化的机会:实际上,它有缺陷,对于一些抽象机器(或调试版本)实际执行的乘法操作没有引发浮点异常。像这样的糟糕行为是为什么`-ftrapping-math`不可靠,可能不应该默认开启的原因。

@Ovinus Real's answer 指出GCC的-ftrapping-math仍然可以通过屏蔽两个输入而不是输出来自动向量化原始源代码。 0.0 * 0.0从不引发任何FP异常,因此基本上是模拟AVX-512零屏蔽。

这样做会更昂贵,并且对于乱序执行来说具有更多的延迟,但与标量相比仍然要好得多,尤其是当可用AVX1时,特别适用于在某个级别的缓存中热门的小到中等大小的数组。

(如果使用内部函数编写,请将输出屏蔽为零,除非您实际上想在循环后检查FP环境的异常标志。)

在标量源代码中执行此操作不会导致GCC生成类似的汇编代码:除非使用-fno-trapping-math,否则GCC将将其编译为相同的分支标量汇编代码。至少这次不是一个错误,只是一个被忽视的优化:当比较为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;
  }
}

20

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

1
是的,同意,这确实很奇怪,GCC在没有中间小步骤的情况下迈出了一大步。 - 273K
1
是的,同意,这确实很奇怪,GCC一下子迈出了一个大步,没有中间的小步骤。 - 273K
10
@VladislavKogan:AVX-512掩码抑制了来自掩码元素的FP异常,使得GCC能够生成遵守“-ftrapping-math”(默认开启)的矢量化汇编代码。这就是为什么如果不关闭“-ftrapping-math”,GCC可以使用AVX-512进行矢量化,但不能使用早期扩展。顺便说一下,“-march=native”只适用于具有AVX-512的CPU,如Ice Lake和Zen 4。(在大多数CPU上,默认值为“-mprefer-vector-width=256”,但显然“-march=x86-64-v4”更喜欢vector-width=512。) - Peter Cordes
10
@VladislavKogan:AVX-512掩码抑制了来自掩码元素的FP异常,使得GCC能够生成遵守“-ftrapping-math”(默认启用)的矢量化汇编代码。这就是为什么如果不关闭“-ftrapping-math”,GCC可以使用AVX-512进行矢量化,但不能使用早期扩展。顺便说一下,“-march=native”只适用于具有AVX-512的CPU,例如Ice Lake和Zen 4。(在大多数CPU上,默认值是“-mprefer-vector-width=256”,但显然“-march=x86-64-v4”更喜欢vector-width=512。) - Peter Cordes
10
@VladislavKogan:AVX-512掩码抑制了掩码元素的FP异常,使得GCC能够生成遵守“-ftrapping-math”(默认开启)的向量化汇编代码。这就是为什么如果不关闭“-ftrapping-math”,GCC可以使用AVX-512进行向量化,但不能使用早期的扩展。顺便说一下,“-march=native”只适用于具有AVX-512的CPU,如Ice Lake和Zen 4。(在大多数CPU上,默认值为“-mprefer-vector-width=256”,但显然“-march=x86-64-v4”更喜欢vector-width=512。) - undefined
显示剩余3条评论

1
假设使用-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下,这种解决方案可能略优于乘法后进行屏蔽的解决方案,因为它避免了忽略输入所需的微码乘法的惩罚。但我不确定吞吐量是否可以与乘法后归零的版本相同。


1
你是说像汇编那样编译出来的东西吗?显然,如果你手动编写内嵌函数,你会用并行比较和乘法以及一个andandn来高效地完成,除非你真的打算在之后检查FP环境。我可能会将源代码编写成使乘法无条件执行的形式,比如tmp = b[i]*c[i];,然后使用它。这很容易进行向量化,但这是GCC -ftrapping-math优化不足的情况之一,我们得到的是标量代码:https://godbolt.org/z/zMrvrh8EG - Peter Cordes
1
你是指编译成什么样的汇编代码吗?如果你手动编写内嵌函数,你可以通过并行比较和乘法来高效执行,并且只需使用一个 andandn。除非你确实打算在之后检查浮点环境。我可能会将源代码编写为使乘法无条件执行,例如 tmp = b[i]*c[i]; 然后再使用它。这很容易进行向量化,但这是那种被优化遗漏的情况,其中GCC的 -ftrapping-math 表现不佳,我们得到的是标量代码:https://godbolt.org/z/zMrvrh8EG - Peter Cordes
1
如果抛出的浮点异常数量应与源代码相同,那么-ftrapping-math在仍然对无条件的a[i] = b[i] * c[i]进行向量化时并不能保持这一点。对于选择prod0之间的条件选择,尤其是当它愿意使用AVX-512中的vcmpps时,这种向量化没有任何意义。这是-ftrapping-math不一致和不好的一个很好的例子。 - Peter Cordes
1
无论如何,如果GCC想要在遵守-ftrapping-math的情况下进行矢量化,它可以这样做。 0 * 0不会引发任何FP异常,我非常确定甚至不会出现非规格化或下溢,即使0.0的指数字段全部为零。 - Peter Cordes
1
无论如何,是的,如果GCC确实想要在尊重-ftrapping-math的情况下进行向量化,它可以这样做。0 * 0不会引发任何浮点异常,我相当确定即使0.0的指数字段全为零,也不会出现非规格化或下溢的情况。 - Peter Cordes
显示剩余9条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接