为什么gcc 12.2在从main()调用的这个constexpr函数中不将除法优化为移位操作?

4
我一直在玩Godbolt编译器,输入了这段代码:
constexpr int func(int x)
{
    return x > 3 ? x * 2 : (x < -4 ? x - 4 : x / 2);
}

int main(int argc)
{
    return func(argc);
}

代码相对直观。这里重要的部分是在func(int x)内部最终除以2。由于x是一个整数,基本上任何编译器都会简化此操作以避免除法指令。

x86-64 gcc 12.2 -O3(适用于Linux,因此使用System V ABI)的汇编代码如下:

main:
        cmp     edi, 3
        jle     .L2
        lea     eax, [rdi+rdi]
        ret
.L2:
        cmp     edi, -4
        jge     .L4
        lea     eax, [rdi-4]
        ret
.L4:
        mov     eax, edi
        mov     ecx, 2
        cdq
        idiv    ecx
        ret

您可以看到最后的idiv ecx命令,它不是一个移位操作,而是一个真正的除以2的操作。我也测试过clang编译器,clang确实将其优化为移位操作。
main:                                   # @main
        mov     eax, edi
        cmp     edi, 4
        jl      .LBB0_2
        add     eax, eax
        ret
.LBB0_2:
        cmp     eax, -5
        jg      .LBB0_4
        add     eax, -4
        ret
.LBB0_4:
        mov     ecx, eax
        shr     cl, 7
        add     cl, al
        sar     cl
        movsx   eax, cl
        ret

可能是内联导致的吗?我对这里发生的事情非常好奇。


5
越看越奇怪,https://godbolt.org/z/Efs18TYnM,似乎这取决于它是不是主要的。 - Sopel
2
当输入为负数时,移位操作无法完成任务(这可以修复,但修复的代价可能会让编译器决定不值得)。你认为constexpr与此有关吗?由于输入是变量,因此无法在编译时进行评估。 - Ben Voigt
1
@Sopel:编译器应该知道main()函数的argc参数不能为负数,并完全消除< -4分支。 - Ben Voigt
1
@Finn,请不要让读者通过特定指令和寄存器名称的存在来确定这是基于哪种架构。请直接在前面说明汇编代码所属的架构。 - Ben Voigt
1
@BenVoigt "编译器应该知道 main() 的 argc 参数不能为负数" 我怀疑这个优化实现起来会更糟糕。 - Slava
显示剩余9条评论
1个回答

15
GCC对main函数有特殊处理:隐式的__attribute__((cold)) 所以main函数会被优化得较少(或者更偏向于大小而非速度),因为在大多数程序中,它通常只会被调用一次。__attribute__((cold))并不完全等同于-Os(优化大小),但它是朝着这个方向迈出的一步,有时候会使代价启发式算法选择一个简单的除法指令。
根据GCC dev Marc Glisse的评论,如果你正在进行基准测试或查看代码优化情况,请不要将代码放在名为main的函数中。 (除了cold之外还可能有其他特殊情况,例如MinGW GCC会在初始化函数中添加额外的call,而gcc -m32会添加代码以使堆栈对齐为16。所有这些都是你不希望出现在你查看的代码中的噪音。另请参阅如何从GCC/clang汇编输出中去除"噪音"?

另一个问答显示GCC将main放在.text.startup部分,以及其他被认为是“冷启动”函数。(这对于TLB和页面局部性很有好处;希望在进程启动后,一整页的初始化函数可以被驱逐出去。这个想法是,main中的代码可能只运行一次,真正的工作发生在它调用的某个函数内部。如果真正的工作内联到main中,或者对于简单的程序来说,这可能不成立。)

对于所有代码都在main中的玩具程序来说,这是一个糟糕的启发式方法,但这就是GCC所做的。大多数人经常运行的真实程序并不是玩具程序,并且在某些其他函数中有足够的代码,使其无法内联到main中。虽然如果启发式方法稍微聪明一点,如果整个程序或循环中的所有函数都优化到main中,它会移除cold,因为有些真实程序非常简单,这将是很好的。

你可以使用GNU C函数属性来覆盖启发式算法。
  • __attribute__((hot)) int main(){ ... 优化的方式如你所期望的那样
    (Godbolt 来自 Sopel's comment,添加了属性)。
  • __attribute__((cold)) 在一个不叫做 main 的函数上产生 idiv
  • __attribute__((optimize("O3"))) 没有帮助。

int main(int x, char **y){ return x/2; } 还是使用gcc -O2进行移位操作,所以coldmain并不总是会产生这种效果(与-Os不同)。

但是也许由于你的除法已经是有条件的,GCC猜测基本块并不总是每次都运行,所以更有理由使其变小而不是变快。


疯狂地,GCC在x86-64上的-Os(Godbolt)确实使用了idiv来进行带符号除法运算,不仅仅适用于任意常数(在GCC通常使用乘法逆元甚至在-O0)。与向零舍入的算术右移加修正相比,它并没有节省太多代码大小,并且可能会慢得多,特别是在Intel的Ice Lake之前的64位整数上。对于AArch64也是一样,在两种情况下都需要2个固定长度的指令,其中sdiv几乎肯定更慢。

sdiv在AArch64上对于2的高次幂确实可以节省一些代码大小(Godbolt),但速度仍然慢得多,可能不是-Os的一个好权衡。在x86-64上,idiv不能节省指令(因为需要cdqcqo进入RDX),尽管可能会减少几个字节的代码大小。所以只有在-Oz下才有用,它还会使用push 2 / pop rcx将一个小常数加载到寄存器中,这样可以使用3字节的x86-64机器码而不是5字节


这是一个非常好的解释,感谢您的洞察力。我不知道主函数被优化得更少。我猜在幕后有很多事情正在发生,如果一个人不熟悉这个领域,这似乎非常违反直觉。我会接受这个答案 :) - Finn Eggers

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