为什么gcc-O3自动矢量化因子?那么多额外的指令看起来更糟

为什么gcc-O3自动矢量化因子?那么多额外的指令看起来更糟,gcc,x86,x86-64,compiler-optimization,auto-vectorization,Gcc,X86,X86 64,Compiler Optimization,Auto Vectorization,这是一个非常简单的阶乘函数 int factorial(int num) { if (num == 0) return 1; return num*factorial(num-1); } GCC在-O2上为此函数进行的组装是合理的 factorial(int): mov eax, 1 test edi, edi je .L1 .L2: imul eax, edi

这是一个非常简单的阶乘函数

int factorial(int num) {
    if (num == 0)
        return 1;
    return num*factorial(num-1);
}
GCC在-O2上为此函数进行的组装是合理的

factorial(int):
        mov     eax, 1
        test    edi, edi
        je      .L1
.L2:
        imul    eax, edi
        sub     edi, 1
        jne     .L2
.L1:
        ret
然而,在-O3或-Ofast上,它决定让事情变得更复杂(几乎100行!):

我使用编译器资源管理器获得了这些结果,因此在实际用例中应该是相同的


怎么回事?在任何情况下,这会更快吗?叮当声似乎也做了类似的事情,但在-O2上。

这并没有让事情变得更糟。对于大数字,它运行得更快。以下是
阶乘(100000000)
的结果:

  • -O2
    :0.78秒
  • -O3
    :0.5秒
当然,使用那个大数字是未定义的行为(因为有符号算术溢出)。但对于无符号数字,计时是相同的,这不是未定义的行为

注意,阶乘的这种用法通常是没有意义的,因为它不计算
num,但
num!&UINT_MAX
。但是编译器不知道这一点

使用PGO,如果总是使用较小的数字调用该代码,编译器可能不会将其矢量化


如果您不喜欢这种行为,但希望使用
-O3
,请使用
-fno-tree-loop-vectorize
关闭自动矢量化,因为在典型的现代x86 CPU上,r32
具有3个周期的延迟()。因此标量实现可以每3个时钟周期执行一次乘法,因为它们是相互依赖的。不过,它是完全管道化的,所以标量循环没有使用2/3的潜在吞吐量

在3个循环中,Core2或更高版本中的管道可将12个UOP送入堆芯的故障部分。对于较小的输入,最好保持代码较小,让无序执行与后续代码重叠依赖链,特别是如果后续代码不完全依赖于阶乘结果。但是编译器不善于知道何时对延迟和吞吐量进行优化,如果没有概要文件引导的优化,他们就没有关于
n
通常有多大的数据

我怀疑gcc的自动矢量器没有考虑这将以多快的速度溢出大型
n


一个有用的标量优化应该是使用多个累加器展开,例如,利用乘法是关联的这一事实,并在循环中并行执行:
prod(n*3/4..n)*prod(n/2..n*3/4)*prod(n/4..n/2)*prod(1..n/4)
(当然,对于非重叠范围)。乘法即使在包装时也是关联的;乘积位仅依赖于该位置和更低位置的位,而不依赖于(丢弃的)高位

或者更简单地说,做
f0*=i;f1*=i+1;f2*=i+2;f3*=i+3;i+=4;
。然后在循环之外,
返回(f0*f1)*(f2*f3);
这也将是标量代码的胜利。当然,展开时还必须考虑
n%4!=0


gcc选择做的基本上是后者,使用
pmuludq
用一条指令做两次压缩乘法(英特尔CPU上的5c延迟/1c或0.5c吞吐量)
它在AMD CPU上类似;请参阅Agner Fog的指令表。在C源代码中,每个向量循环迭代执行4次阶乘循环迭代,并且在一次迭代中具有显著的指令级并行性

内部循环只有12 uops长(cmp/jcc宏融合为1),因此它可以每3个周期进行1次迭代,吞吐量与标量版本中的延迟瓶颈相同,但每次迭代的工作量是原来的4倍

.L5:
    movdqa  xmm3, xmm2         ; copy the old i vector
    movdqa  xmm1, xmm2
    paddd   xmm2, xmm4         ; [ i0,  i1 |  i2,  i3 ]  += 4
    add     eax, 1
    pmuludq xmm3, xmm0         ; [ f0      |  f2  ] *= [ i0   |  i2  ]

    psrlq   xmm1, 32           ; bring odd 32 bit elements down to even: [ i1  | i3 ]
    psrlq   xmm0, 32
    pmuludq xmm1, xmm0         ; [ f1  | f3 ] *= [ i1  | i3 ]

    pshufd  xmm0, xmm3, 8
    pshufd  xmm1, xmm1, 8
    punpckldq       xmm0, xmm1   ; merge back into [ f0  f1  f2  f3 ]
    cmp     eax, edx
    jne     .L5
因此,在使用
pmuludq
时,gcc会浪费大量精力来模拟压缩32位乘法,而不是将两个单独的向量累加器分开。我还研究了clang6.0。我认为它落入了相同的陷阱。()

您没有使用
-march=native
或任何东西,因此只有SSE2(x86-64的基线)可用,因此只有32位输入元素可以使用类似的32x32=>64位SIMD乘法。SSE4.1
pmulld
在Haswell和更高版本(Sandybridge上的单uop)是2 uop,但可以避免所有gcc的愚蠢洗牌

当然这里也有一个延迟瓶颈,特别是因为gcc错过了优化,增加了涉及累加器的循环携带dep链的长度

使用更多向量累加器展开可以隐藏大量的
pmuludq
延迟

通过良好的矢量化,SIMD整数乘法器可以管理标量整数乘法单元2倍或4倍的吞吐量。(或者,对于AVX2,使用8倍32位整数的矢量可以管理8倍的吞吐量。)

但是向量越宽,展开得越多,需要的清理代码就越多


gcc-march=haswell
我们得到这样一个内部循环:

.L5:
    inc     eax
    vpmulld ymm1, ymm1, ymm0
    vpaddd  ymm0, ymm0, ymm2
    cmp     eax, edx
    jne     .L5
超级简单,但10c延迟循环携带的依赖链:/(
pmulld
是Haswell和更高版本上的2个依赖UOP)。使用多个累加器展开可使大输入的吞吐量提高10倍,Skylake上的SIMD整数乘法UOP的延迟为5c,吞吐量为0.5c

但对于标量,每5个循环4次仍然比每3次1次好得多

默认情况下,使用多个累加器展开Clang,因此它应该是好的。但是它的代码很多,所以我没有手动分析它。将它插入IACA或对其进行基准测试,以获得较大的输入。()


处理展开尾声的有效策略: factorial
[0..7]
的查找表可能是最好的选择。将事情安排为向量/展开循环执行
n%8..n
,而不是
1..n/8*8
,因此每个
n
的剩余部分总是相同的

在水平向量乘积之后,再对查表结果进行一次标量乘法。SIMD lo
.L5:
    inc     eax
    vpmulld ymm1, ymm1, ymm0
    vpaddd  ymm0, ymm0, ymm2
    cmp     eax, edx
    jne     .L5