为什么编译器不能优化掉这些无法执行的指令?

4

考虑以下代码:

int x;

int a (int b) {
    b = a (b);
    b += x;
    return b;
}

为什么GCC会返回这个输出(Intel语法):http://goo.gl/8D32F1- Godbolt的GCC Explorer
a:
    sub rsp, 8
    call    a
    mov edx, DWORD PTR x[rip]
    add rsp, 8
    lea eax, [rax+rdx*8]
    add eax, edx
    ret

Clang输出以下结果(AT&T语法):http://goo.gl/Zz2rKA - Godbolt的Clang浏览器

a:                                      # @a
    pushq   %rax
    callq   a
    addl    x(%rip), %eax
    popq    %rdx
    ret

当代码的一部分明显无法到达时怎么办?由于函数的第一个语句是...
b = a (b);

该函数将永远递归调用自身(直到堆栈溢出并导致segfault)。这意味着您永远不会超越该行,因此,其余的代码是无法访问的。理论上,可达性优化应该删除该代码,对吗?
两个编译器都在x64上运行,并使用以下标志:
  • -O3 - 最大优化
  • -march=native - [不必要]尽可能使用机器特定的优化
  • -x c - 假定输入语言为C

我认为它们应该返回更符合以下内容(双关语):

GCC (Intel 语法):

a:
.L1:
    jmp .L1

Clang(AT&T语法):

a:
.LBB0_1:
    jmp .LBB0_1

注意:这些样例是根据以前的观察手写的,可能不正确。


总体而言,为什么两个编译器都没有因为其他代码无法到达而将函数崩溃到单个递归跳转?


编辑:

回应杰克有关语义等价性的评论:

对于以下代码:

int j (int x) {
    while (1) {};
    x++;
    return x;
}

GCC返回:http://goo.gl/CYSUW2
j:
.L2:
    jmp .L2

Clang 返回:

j:                                      # @j
.LBB0_1:                                # =>This Inner Loop Header: Depth=1
    jmp .LBB0_1

对于Adam评论中关于堆栈溢出的回应:

针对以下代码:

int r (int x) {
    return r (x);
}

GCC 生成了一个递归跳转:http://goo.gl/eWo2Nb
r:
.L2:
    jmp .L2

Clang 在提前完成时会返回清洁信息:http://goo.gl/CVJKiZ

r:                                      # @r
    ret

2
@StephenC:但这是编译器随意处理的借口。将“堆栈溢出”悄悄转换为“无限循环”是完全可以的。 - tmyklebu
1
@HotLicks - 的确,费力地优化涉及有缺陷/病态代码的情况几乎肯定是编译器作者努力的浪费。对于OP而言——鉴于代码显然在任何情况下都无法工作,那么优化的意义是什么呢? - Stephen C
1
@StephenC:这没有意义。然而,检查和理解这种无意义的情况可以阐明编译器在非平凡情况下会做什么。(例如,在这里,您可能会了解到gcc和clang死代码消除的限制,以及如何处理它们。) - tmyklebu
1
关于您的更新,展示了尾调用消除。您没有理解到这个优化在原始代码中是无法执行的...因为它不是尾调用。(即使忽略原始示例不终止的事实。) - Stephen C
1
从技术角度来看,作为一名曾经的优化器设计师,除非编译器被设置为识别/优化递归,否则它不会注意到调用是递归的,并且因此不会发现以下代码是无法到达的。即使观察到了递归,处理上述情况也没有任何价值。 - Hot Licks
显示剩余8条评论
1个回答

4
您使用的编译器可能仅在单个函数框架内以块级别实现数据流分析,而不考虑递归。 (或者只考虑有趣的递归,即尾递归。) 由于递归调用不是尾调用,因此从优化的角度来看,它并不重要。
您的函数存在问题:它编译的方式会导致堆栈溢出。这是因为该调用不是尾调用;将其视为尾调用并非合法的优化方法。
从某种程度上来说,该调用可以被视为“伪尾调用”,因为调用后的代码永远不会被调用,因此如果我们删除该代码,则递归调用是函数执行的最后一件事情。然后,我们可以将导致堆栈溢出的代码减少到一个简单的无限循环。但是这并不能真正称之为优化;它只是用另一种错误表现替换了一个错误表现。

嗯。我的愚蠢问题:为什么死代码消除决定递归调用后的内容是活动的呢? - tmyklebu
@tmyklebu - 因为它语法不正确,而且很难解析。将“decide”改为“decide that”,意思就更清楚了。 - Stephen C
1
@tmyklebu - 答案是它不会决定自己还活着,而是无法察觉自己已经死亡。 - Stephen C
@StephenC:确实会提高注释的质量。不幸的是,现在已经太晚了,我无法修复它。 - tmyklebu
1
为了确定死代码,编译器分析的是程序的本地图,该图不跨越函数调用。 - Kaz
@Kaz:好的,那就差不多了。 (我以为跨过程分析已经成为现在的常态了,但我猜内联在很多情况下消除了跨函数边界分析的需要。) - tmyklebu

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