为什么 GCC 和 MSVC 不基于未初始化的变量进行优化?我可以强迫他们吗? 脚注

问题描述

考虑一些使用未初始化堆栈变量的极其简单的代码(或更复杂的代码,请参见下文1),例如:

int main() { int x; return 17 / x; }

以下是 GCC 发出的内容 (-O3):

mov     eax,17
xor     ecx,ecx
cdq
idiv    ecx
ret

以下是 MSVC 发出的内容 (-O2):

mov     eax,17
cdq
idiv    DWORD PTR [rsp]
ret     0

作为参考,以下是 Clang 发出的内容 (-O3):

ret

问题是,所有三个编译器检测这是一个未初始化的变量就好了(-Wall),但其中只有一个实际执行基于它的任何优化。

这让我有点难受……我认为几十年来为未定义行为而争论的所有内容都是为了允许编译器优化,但我看到只有一个编译器关心优化甚至是最基本的 UB 情况。

>

这是为什么?如果我想要 Clang 以外的编译器来优化 UB 的这种情况,我该怎么办?有什么方法可以让我真正获得 UB 的好处,而不仅仅是任何一个编译器的缺点?


脚注

1 显然,对于某些人来说,这太过分了SSCCE,无法理解实际问题。如果您想要一个更复杂的示例,该示例不是在每次 程序执行时都未定义的,只需稍微按摩一下即可。例如:

int main(int argc,char *[])
{
    int x;
    if (argc) { x = 100 + (argc * argc + x); }
    return x;
}

在 GCC 上你得到:

main:
    xor     eax,eax
    test    edi,edi
    je      .L1
    imul    edi,edi
    lea     eax,[rdi+100]
.L1:
    ret

在 Clang 你得到:

main:
    ret

同样的问题,只是更复杂。

解决方法

针对实际读取未初始化数据进行优化不是重点。

优化假设您读取的数据必须已初始化。

因此,如果您有一些只能写入 3 或 1 的变量,编译器可以假定它是奇数。

或者,如果将正符号常量添加到有符号值,我们可以假设结果大于原始有符号值(这会使某些循环更快)。

当优化器证明读取了未初始化的值时,它会做什么并不重要;使 UB 或不确定值计算更快不是重点。表现良好的程序不会故意这样做,花费精力让它更快(或更慢,或关心)是在浪费编译器编写者的时间。

它可能会因其他努力而失败。或者它可能不会。

,

考虑这个例子:

int foo(bool x) {
    int y;
    if (x) y = 3;
    return y;
}

Gcc 意识到函数可以返回明确定义的东西的唯一方法是 xtrue。因此,当优化打开时,没有分支:

foo(bool):
        mov     eax,3
        ret

调用 foo(true) 不是未定义的行为。调用 foo(false) 是未定义的行为。标准中没有任何内容指定 foo(false) 返回 3 的原因。标准中也没有规定 foo(false) 不返回 3。编译器不会优化具有未定义行为的代码,但编译器可以在没有 UB 的情况下优化代码(例如删除 foo 中的分支),因为没有指定存在 UB 时会发生什么。

如果我想要 Clang 以外的编译器优化 UB 的这种情况,我该怎么办?

默认情况下编译器会这样做。 Gcc 在这方面与 Clang 没有什么不同。

在你的例子

int main() { int x; return 17 / x; } 

没有遗漏优化,因为它首先没有定义代码将做什么。

您的第二个示例可视为错失的优化机会。尽管如此:UB 为优化没有 UB 的代码提供了机会。这个想法不是你在代码中引入 UB 以获得优化。作为您的第二个示例可以(并且应该)重写为

int main(int argc,char *[])
{
    int x = 100 + (argc * argc + x);
    return x;
}

实际上,gcc 不会在您的版本中删除分支,这并不是什么大问题。如果您不需要分支,则不必编写它只是为了期望编译器将其删除。

,

该标准使用术语“未定义行为”来指代在某些情况下可能不可移植但正确的操作,但在其他情况下可能是错误的,而没有努力区分何时应查看特定操作一种或另一种方式。

在 C89 和 C99 中,如果类型的底层存储可能保存无效的位模式,则尝试使用该类型的未初始化的自动持续时间或 malloc 分配的对象将调用未定义行为,但如果一切可能位模式是有效的,访问这样的对象只会产生该类型的未指定值。例如,这意味着程序可以执行以下操作:

struct ushorts256 { uint16_t dat[256]; } x,y;
void test(void)
{
  struct ushorts256 temp;
  int i;
  for (i=0; i<86; i++)
    temp.dat[i*3]=i;
  x=temp;
  y=temp;
}

如果调用者只关心结构的 multiple-of-3 元素中的内容,则无需让代码担心 temp 的其他 171 个值。

C11 更改了规则,这样编译器编写者如果觉得有一些更有用的事情可以做,就不必遵循 C89 和 C99 的行为。例如,根据调用代码对数组的处理方式,简单地让代码编写 x 的每第三个项目和 y 的每第三个项目,而保留其余项目可能更有效.这样做的结果是,x 的非 3 个项目可能与 y 的相应项目不匹配,但希望销售编译器的人能够判断他们的特定客户' 需要比委员会更好的。

某些编译器以与 C89 和 C99 一致的方式处理未初始化的对象。有些人可能会利用自由使值的行为具有非确定性(如上例所示),但不会破坏程序行为。有些人可能会选择以毫无意义的方式处理任何访问未初始化变量的程序。可移植程序可能不依赖于任何特定处理,但标准的作者明确表示他们不希望“贬低”碰巧不可移植的有用程序(请参阅 http://www.open-std.org/jtc1/sc22/wg14/www/C99RationaleV5.10.pdf 第 13 页)。