为什么C ++编译器可能会复制一个函数退出基本块?

时间:2019-04-18 11:01:59

标签: c++ gcc x86-64 compiler-optimization

请考虑以下代码段:

int* find_ptr(int* mem, int sz, int val) {
    for (int i = 0; i < sz; i++) {
        if (mem[i] == val) { 
            return &mem[i];
        }
    }
    return nullptr;
}

GCC on -O3编译为:

find_ptr(int*, int, int):
        mov     rax, rdi
        test    esi, esi
        jle     .L4                  # why not .L8?
        lea     ecx, [rsi-1]
        lea     rcx, [rdi+4+rcx*4]
        jmp     .L3
.L9:
        add     rax, 4
        cmp     rax, rcx
        je      .L8
.L3:
        cmp     DWORD PTR [rax], edx
        jne     .L9
        ret
.L8:
        xor     eax, eax
        ret
.L4:
        xor     eax, eax
        ret

在此程序集中,标签为.L4.L8的块是相同的。将跳转到.L4.L8并删除.L4会更好吗?我以为这可能是一个错误,但是clang 也将xor-ret序列背对背重复。但是,ICCMSVC各自采用完全不同的方法。

在这种情况下,这是否是一种优化?如果不是,是否会?这种行为背后的原因是什么?

1 个答案:

答案 0 :(得分:8)

这总是错过的优化。在当前编译器关心的所有微体系结构上,使两个return-0路径都使用相同的基本块将是绝对的胜利。

但是不幸的是,这种错失的优化在gcc中并不罕见。通常,它是gcc有条件地分支到的单独的裸ret,而不是在另一个现有路径中分支到ret。 (x86没有条件ret,因此不需要任何堆栈清理的简单函数通常只需要分支到ret。  通常,这么小的函数会被内联到完整的程序中,所以在现实生活中可能不会受到太大的伤害?)

现代CPU具有一个返回地址预测器堆栈,该堆栈可轻松预测ret指令的分支目标,因此不会像一条ret指令那样更频繁地返回一个调用者和另一个调用者的效果ret更频繁地返回到另一个呼叫者,因此它不利于分支预测来分隔它们并使它们使用不同的条目。 (它可能对-mtune=pentium3或其他没有RAS预测器的古老CPU有所帮助,但是即使那样,您通常也不会为此花费额外的代码大小。)

关于Pentium 4的

IDK,以及其跟踪缓存中的跟踪是否遵循调用/ ret。但是幸运的是,这不再重要了。 SnB系列和Ryzen中的解码uop缓存是 not 跟踪缓存; uop缓存的一行/方式为连续的x86机器代码块保存uops,并且无条件跳转结束了uop缓存行。 (https://agner.org/optimize/)因此,对于SnB系列来说,这可能会更糟,因为每个返回路径都需要uop缓存的单独一行,即使它们各自总共只有2 uops(异或零和ret均为单个) -uop说明)。

将此MCVE报告给gcc的bugzilla,关键字为优化缺失https://gcc.gnu.org/bugzilla/enter_bug.cgi?product=gcc

(更新:https://gcc.gnu.org/bugzilla/show_bug.cgi?id=90178由OP报告,并在几天后修复。)


原因:

您可以看到它如何到达2个出口块:编译器通常将for循环转换为if(sz>0) { do{}while(); },如果有可能需要运行0次,如gcc那样。因此,有一个分支使函数完全不进入循环。但是另一个退出是从循环中退出。也许在优化一些东西之前,需要进行一些额外的清理。或者,只有在创建第一个分支时,这些路径才会被拆分。

我不知道为什么gcc无法注意到并合并以ret结尾的两个相同的基本块。

也许它只是在某些GIMPLE或RTL传递中寻找它们实际上并不相同,并且仅在最终x86代码生成期间才变得相同。也许在优化掉寄存器的保存/恢复以保存一些不需要它的临时文件之后?

在经过某些优化之后,如果您使用-fdump-tree-...选项查看GCC的GIMPLE或RTL,则可以进行更深入的研究:Godbolt在+下拉菜单->树/ RTL输出中具有UI。 https://godbolt.org/z/l9mVlE。但是,除非您是gcc内部专家,并且打算开发补丁或想法来帮助gcc找到这种优化方法,否则可能不值得您花时间。


有趣的发现,它仅在-mavx(由-march=skylake启用或直接启用)中发生。 GCC和clang不知道如何在第一次迭代之前未知跳数的情况下自动矢量化循环。例如这样的搜索循环或memchrstrlen。那么IDK​​为何AVX甚至会有所作为。

(请注意,C抽象机从不读取超出搜索点的mem[i],并且这些元素可能实际上不存在。例如,如果您将此函数传递给指向最后一个int的指针,则不会有UB。一个未映射的页面,以及sz=1000,只要*mem == val。因此,要在没有int mem[static sz]保证的对象大小的情况下进行自动矢量化,编译器将必须对齐指针...不是C11 { {1}}甚至会有所帮助;即使静态的编译时常数大小大于可能的最大行程计数的静态数组也无法使gcc自动矢量化。)