为什么编译器不再使用严格的混叠来优化这个UB

Why compilers no longer optimize this UB with strict aliasing

本文关键字:优化 UB 不再 编译器 为什么      更新时间:2023-10-16

谷歌上严格别名的第一个结果是本文http://dbp-consulting.com/tutorials/StrictAliasing.html
我注意到的一件有趣的事情是:http://goo.gl/lPtIa5

uint32_t swaphalves(uint32_t a) {
  uint32_t acopy = a;
  uint16_t* ptr = (uint16_t*)&acopy;
  uint16_t tmp = ptr[0];
  ptr[0] = ptr[1];
  ptr[1] = tmp;
  return acopy;
}

被编译为

swaphalves(unsigned int):
        mov     eax, edi
        ret

根据GCC 4.4.7。任何比它更新的编译器(文章中提到了4.4,所以文章没有错)都不会像使用严格别名那样实现该函数。这是什么原因?事实上是GCC中的错误还是GCC决定删除它,因为许多行代码都是以产生UB的方式编写的,或者它只是一个持续数年的编译器回归。。。Clang也没有对其进行优化。

GCC开发人员在这些情况下努力使编译器的行为"如预期"。(我希望我能给你一个合适的参考——我记得它在某个时候出现在邮件列表上)。

无论如何,你会说:

没有像使用严格混叠那样实现该功能

这可能意味着对严格的混叠规则的作用有点误解。您的代码示例调用未定义的行为-因此任何编译在技术上都是有效的,包括一个普通的ret或陷阱指令的生成,甚至什么都不调用(假设永远不能调用该方法是合理的)。GCC的新版本生成的代码更长/更慢,这几乎不是一个缺陷,因为生成做任何特定事情的代码都不会违反标准。事实上,新版本通过生成代码来改善这种情况,这些代码可以执行程序员可能想要代码执行的操作,而不是默默地执行不同的操作。

你更希望编译器生成不符合你要求的快速代码,或者生成满足你要求的稍微慢一点的代码?

话虽如此,我坚信您不应该编写违反严格别名规则的代码。当意图"显而易见"时,依靠编译器做"正确"的事情是在走钢丝。优化已经够难的了,编译器不必猜测并考虑程序员的意图。此外,可以编写遵守规则的代码,编译器可以将其转换为非常有效的目标代码。事实上,还有一个问题可以提出:

为什么早期版本的GCC会像那样运行,并通过遵守严格的别名规则来"优化"函数?

这有点复杂,但对于这次讨论来说很有趣(尤其是考虑到编译器为了破坏代码而花费了一些时间)。严格别名是一个名为别名分析的过程的组成部分(或者更确切地说,是一个辅助规则)。这个过程决定两个指针是否别名。本质上,任何两个指针之间都有3种可能的条件:

  • 它们必须不存在别名(严格的别名规则可以很容易地推导出这个条件,尽管有时可以用其他方式推导)
  • 它们必须ALIAS(这需要分析;例如,值传播可能会检测到这种情况)
  • 他们可能是外星人。当其他两个条件都无法建立时,这是默认条件

在您问题中的代码的情况下,严格别名意味着&acopyptr之间有一个MUST NOT ALIAS条件(进行此确定并不重要,因为这两个值具有不允许别名的不兼容类型)。这个条件允许您看到的优化:所有对*ptr值的操作都可以被丢弃,因为它们在理论上不能影响acopy的值,并且它们不会逃脱函数(这可以通过逃脱分析来确定)。

确定两个指针之间的MUST ALIAS条件需要进一步的努力。此外,在这样做的过程中,编译器需要忽略(至少暂时忽略)之前确定的MUST NOT ALIAS条件,这意味着它必须花费时间试图确定一个条件的真实性,如果一切都是应该的,那么这个条件一定是假的。

当确定了MUST NOT ALIAS和MUST ALIAS条件时,我们会遇到代码必须调用未定义行为的情况(我们可以发出警告)。然后,我们必须决定保留哪个条件,放弃哪个条件。因为在这种情况下,MUST NOT ALIAS来自一个可能(实际上已经)被用户打破的约束,所以它是最好的放弃选项。

因此,旧版本的GCC要么不进行必要的分析来确定MUST ALIAS条件(可能是因为已经建立了相反的MUST not ALIAS条件),要么,旧版本GCC选择放弃MUST ALIAS条件,而不是MUST not ALIAS条件,这导致更快的代码不能做程序员最可能想要做的事情。无论哪种情况,似乎更新的版本都提供了改进。

在另一个相关问题中,有@DanMoulding的评论。让我剽窃一下:

该标准的严格别名规则的目的是允许编译器在不知道或无法知道对象是否被别名的情况下进行优化。这些规则允许优化器在这些情况下不进行最坏情况下的混叠假设。然而,当上下文清楚地表明对象被别名化时,编译器应该将该对象视为被别名化,无论使用何种类型访问它。否则不符合语言别名规则的意图。

在您的代码中,*ptracopy的别名是显而易见的,因为它们都是局部变量,所以任何sane编译器都应该将它们视为别名。从这个角度来看,GCC 4.4的行为虽然符合标准的严格解读,但大多数现实世界的程序员都会认为它是一个bug。

您必须首先考虑为什么存在别名规则。这样,编译器就可以在可能存在混叠,但很可能没有混叠的情况下利用优化。因此,该语言禁止混叠,编译器可以自由优化。例如:

void foo(int *idx, float *data)
{ /* idx and data do not overlap */ }

然而,当混叠涉及局部变量时,不会丢失优化:

void foo()
{
    uint32_t x;
    uint16_t *p = (uint16_t *)&x; //x and p do overlap!
}

编译器正在尽可能地做好它的工作,而不是试图在某个地方找到一个UB来格式化你的硬盘!

有很多代码在技术上是UB,但被所有编译器忽略了。例如,如果一个编译器将其视为一个空文件,你会怎么看:

#ifndef _FOO_H_
#define _FOO_H_
void foo(void);
#endif

或者忽略这个宏的编译器呢:

#define new DEBUG_NEW

仅仅因为标准允许它这么做?

编译器的目标通常应该尽可能接近代码的意图。在这种情况下,代码调用UB,但其意图应该非常清楚。我的猜测是,最近编译器一直专注于正确性,而不是利用UB进行优化。

严格别名本质上是一种假设,即代码没有试图破坏类型系统,正如@rodrigo所指出的,这为编译器提供了更多可以用来优化的信息。如果编译器不能假设严格的混叠,那么它就排除了许多非平凡的优化,这就是为什么C甚至添加了restrict限定符(C99)。

对于我能想到的任何优化来说,打破严格的别名都不是必要的。事实上,在这种特定的情况下,根据最初的意图,您可以在不调用UB的情况下获得正确/优化的代码。。。

uint32_t wswap(uint32_t ws) {
  return (ws << 16) | (ws >> 16);
}

编译为…

wswap:                                  # @wswap
    .cfi_startproc
# BB#0:
    roll    $16, %edi
    movl    %edi, %eax
    retq