如果 C 编译器不能证明缺少 UB,为什么禁止优化?

Why is optimization forbidden if a C compiler cannot prove lack of UB?

提问人:Joshua 提问时间:9/19/2023 最后编辑:Joshua 更新时间:9/22/2023 访问量:3792

问:

如果 C 程序具有未定义的行为,则任何事情都可能发生。因此,编译器可以假定任何给定的程序都不包含 UB。因此,假设我们的程序包含以下内容:

x += 5;
/* Do something else without x in the meantime. */ 
x += 7;

当然,这可以优化到

/* Do something without x. */
x += 12;

或者以类似的方式。

如果 x 有类型,则上述程序中不可能有 UB。另一方面,如果 x 有类型,则有可能溢出,因此 UB。由于编译器可能假设我们的程序不包含 UB,因此我们可以进行上述相同的优化。事实上,在这种情况下,编译器甚至可以假设 .unsigned intsigned intx - 12 <= MAX_INT

然而,这似乎与延斯·古斯特特(Jens Gustedt)著名的“现代C”(第42页)相矛盾:

但这种优化也可能被禁止,因为编译器无法证明某个操作不会强制程序终止。在我们的示例中,很大程度上取决于 x 的类型。如果 x 的当前值可能接近类型的上限,则看起来无辜的操作 x += 7 可能会产生溢出。此类溢出的处理方式因类型而异。正如我们所看到的,无符号类型的溢出不是问题,压缩操作的结果将始终与两个单独的操作一致。对于其他类型,例如有符号整数类型(有符号)和浮点类型(双精度型),溢出可能会引发异常并终止程序。在这种情况下,无法执行优化。

(强调我的)。如果编译器可以(并且确实)假设我们的程序没有UB,为什么不能执行这种优化呢?

[1]:延斯·古斯特特。Modern C. Manning,2019 年,9781617295812。哈尔-02383654

C 优化 编译器优化 未定义行为 整数溢出

评论

2赞 yeputons 9/19/2023
“如果 x 的类型为 unsigned int,那么在上面的程序中就不可能有 UB。” — false 如果你允许中间有任意代码,也可以得到一个数组越界,理论上可以得到一个 UB 和 UB,在实践中覆盖。但是,如果您谈论的是“UB 没有发生”,您就不能真正谈论“实际术语”。x
13赞 user2357112 9/19/2023
有时书是错的。
2赞 Joshua 9/19/2023
@EricPostpischil 在这次讨论之后,作者提供了要点:“类型决定优化机会。因此,据推测,这意味着编译器只能在 x 是 而不是 时进行此优化。但我的论点是,这种区别是无关紧要的,因为编译器可以安全地假设我们的程序没有UB。unsignedsigned
6赞 Barmar 9/19/2023
我认为他完全错了。编译器不必证明程序终止不会发生,因为它可以假设它。这通常可以实现优化,就像你展示的那样,如果没有假设,这是不可能实现的。
2赞 Vaelus 9/20/2023
你的前几句话的含义是倒过来的;它应该是:“编译器可以假设任何给定的程序不包含UB。因此,如果一个 C 程序具有未定义的行为,任何事情都可能发生。

答:

28赞 Peter Cordes 9/19/2023 #1

TL:DR:你说得对,这种优化不是被禁止的,只是针对 /,而不仅仅是因为在这种情况下有例外。signed intfloatdouble

事情成为UB的一个原因是,一些不起眼的机器可能会引发异常。但是命中 UB 并不能保证在所有机器上都会引发异常(除非您使用 ,对于 UB 类型,它或 clang 可以可靠地检测到,或者使用 gcc -ftrapv 将 signed int 溢出的行为定义为捕获)。当编译器通过假设某些事情不会发生来将 UB 视为优化机会时,情况就大不相同了:UB 不是“错误”或“陷阱”的同义词。gcc -fsanitize=undefined

有些操作可能会捕获普通 CPU,例如未知指针的 deref 和某些 ISA(例如 x86 但不是 ARM)上的整数除法。如果您正在寻找编译器可能需要注意的操作,以避免在需要发生的副作用之前引入异常,或者在可能导致抽象计算机根本无法到达未定义操作的分支之前引入异常,则可以作为示例。


有符号整数溢出是 UB,因此在程序执行的任何时候都可能发生任何事情(在 C++ 中,根据 C 标准的一些解释),即使在为具有非捕获指令的机器编译时也是如此(就像所有现代 ISA 一样)。add

某些实现可能会将行为定义为引发异常。如果他们定义了引发异常的位置,那么它将阻止优化;每个添加都需要按照书面形式进行,以便在抽象机器中的操作溢出时可以捕获在那里。但这将是定义行为,与 UB 完全相反,这意味着对程序实际执行的操作绝对零保证

在 C 中,如果 n3128 被接受1,则在抽象机器遇到 UB 之前测序的任何可见副作用都必须发生。但是在遇到 UB 之后,实际上任何事情都是允许的,包括执行 I/O。如果编译器使用MIPS签名溢出捕获指令而不是通常的指令来编译操作,那么即使它包含I/O操作或其他可见的副作用(如读取或写入),优化到干预代码之后也是合法的。即使导致的符号溢出了抽象机器中的 UB,如果实际行为是稍后捕获(例如,当抽象机器完成该部分时),也没关系。只要在抽象机器命中 UB 时或之后,实际上任何事情都是允许的。(在 C++ 中,甚至在 a 或某物之前执行可能的捕获也是合法的,因为即使在第一个未定义的操作之前也明确缺乏对行为的要求,对于确实遇到 UB 的执行。但前提是编译器能够证明 printf 肯定会返回,因此在实践中,这种优化通常只能发生在访问中。但即使没有追溯效应,我们也可以做之前和之后,或者之后。不出错是签名溢出的有效行为,但抽象计算机执行了未定义的操作,因此允许稍后发生的任何事情,例如打印然后捕获,或者只是进行添加换行。+=addaddux+=12volatilex+=5x+=7addi $s0, $s0, 12printfvolatilex+=5x+=7x+=12

编译器只需要避免在不应该有异常的执行路径上引入异常。(对于主流ISA上的整数加法来说,这不是问题;大多数甚至没有捕获符号加法指令,而以MIPS为目标的编译器甚至用于符号数学,因此它们可以自由优化,因为从历史上看,程序员不希望陷阱数学。adduint

脚注1:C与C++,以及C UB应该是“具体的”还是“抽象的”

请参阅未定义的行为是否追溯性地意味着无法保证早期可见的副作用? 以及 n3128: Taming the Demons -- Undefined Behavior and Partial Program Correctness,该提案要求 ISO C 明确规定,在抽象机器到达未定义的操作之前,可见的副作用(如 I/O)仍然必须发生。(对当前ISO C标准的常见解释将UB视为C++,其中C++标准明确允许沿着不可避免的UB路径“破坏”东西。


编译器执行此操作的实际示例

两者都可以进行这种优化,只有 FP 类型不能,但这(也)是因为即使您使用(FP 数学选项)进行编译,也会进行舍入。在 Godbolt 上使用 GCC13 和 Clang 16 查看其实际效果intunsignedgcc -fno-trapping-math

int sink;    // volatile int sink doesn't make a difference
int foo_signed(int x) {
    x += 5;
    sink = 1;
    x += 7;
    return x;
}
// also unsigned and float versions
# GCC -O3 -fno-trapping-math
foo_signed:                               # input in EDI, retval in EAX
        mov     DWORD PTR sink[rip], 1
        lea     eax, [rdi+12]             # x86 can use LEA as a copy-and-add
        ret
foo_unsigned:
        mov     DWORD PTR sink[rip], 1
        lea     eax, [rdi+12]
        ret
foo_float:                    # first arg and retval in XMM0
        addss   xmm0, DWORD PTR .LC0[rip]     # add Scalar Single-precision
        mov     DWORD PTR sink[rip], 1
        addss   xmm0, DWORD PTR .LC1[rip]     # two separate 5.0f and 7.0f adds
        ret

早期版本的答案,为同一结论提出一些不同的观点

你是对的;假设是一个局部变量,所以从字面上看,没有任何东西可以使用结果,因此可以安全地对这两种类型和整数类型进行优化。xx += 5x+=5; ... ; x+=7x+=12signedunsigned

无符号整数数学当然没问题。

有符号整数数学必须在抽象机器没有遇到 UB 的任何情况下产生正确的结果。 做到了。不能保证有符号溢出会在程序中的任何特定点引发异常,这就是现代 C 语言中基于未定义行为不会发生的假设进行优化的全部意义所在。对于将遇到 UB 的执行,从字面上看,任何事情都可能发生在该点之前或之后的任何地方(但请参阅上面的脚注 1:沿着通往 UB 的不可避免的路径“破坏”东西)。x+=12

这种优化即使对于转换为 也是安全的,其中抽象机器可以换行两次(遇到 UB),但 asm 不会换行,因为“碰巧工作”是一种允许的行为,并且在实践中很常见。(例如,甚至使用MIPS捕获指令。x-=5; x+=7x+=2add

如果使用编译器选项,例如 ,则将有符号整数数学的行为定义为 2 的补码包装,从而删除 UB 并使情况与无符号相同。gcc -fwrapv

GCC 有时会错过有符号整数数学的优化,因为 GCC 内部不愿意在 asm 中的临时创建有符号溢出,而抽象机器中不存在符号溢出。在为允许非捕获整数数学运算的机器(即所有现代 ISA)进行编译时,这是一个遗漏的优化。例如,GCC 将优化为 for 但不会优化为 without .Clang 对 AArch64 和 RISC-V 都适用,但对 x86 选择不这样做。(戈德博尔特)。同样,由于 GCC 出于某种未知原因过于谨慎,因此错过了优化;这是完全有效的。2 的补码有符号数学与无符号二进制数学相同,因此是关联的;例如,在优化的计算来回包装但抽象机器没有的情况下,最终结果将是正确的。a+b+c+d+e+f(a+b)+(c+d)+(e+f)unsigned intsigned int-fwrapv

有符号溢出 UB 只是抽象机器中的一个东西,而不是 asm;大多数主流 ISA 甚至没有在溢出时捕获的整数加法指令。(MIPS可以,但编译器不会将它们用于数学运算,因此它们可以进行优化,从而产生抽象机器中不存在的值。int

半相关:为什么 GCC 不将 a*a*a*a*a*a*a 优化为 (a*a*a)*(a*a*a)? (答案显示,编译器确实优化了整数数学的三个乘法,即使是有符号的。int


FP 异常并不是 float/double 的唯一问题

浮点数学无法进行此优化,因为它可能会在非溢出情况下由于舍入不同而给出不同的结果。两个较小的数字可以向下舍入,而一个较大的数字可以超过阈值。

例如,对于一个足够大的数字,最接近的可表示双精度值彼此相距 16,将得到一半并四舍五入到最接近的偶数(假设默认的四舍五入模式)。但任何小于 或 ,总是会四舍五入;,所以 和 都会丢失,但所有的东西都会一口气越过驼峰到达下一个可表示的浮点或双倍,产生 .875x + 7 == x57x+12x+16

(尾数(尾数)最后 1 个单位的大小取决于浮点数/双精度的指数。对于足够大的 FP 值,它是 1.0。对于更大的值,例如 从 253 到 254 只有偶数是可表示的,以此类推,指数较大。double

如果您使用 GCC 的 buggy 默认值进行编译,它将尝试遵循 FP 异常语义。如果溢出发生两次,它不会可靠地生成 2 个 FP 异常,因此它可能不关心这一点。-ftrapping-math

但是,是的,每个单独的 FP 操作都应该具有可观察到的效果。(https://en.cppreference.com/w/c/numeric/fenv)。但是,如果您不调用以实际观察两个操作之间的 FP 异常标志,那么如果我们能证明舍入是相同的,理论上它们仍然可以进行优化,因为我认为即使是 ISO C 也不应该支持实际运行异常/信号处理程序每个捕获操作。#pragma STDC FENV_ACCESS ONfegetexceptFENV_ACCESS ON

例如,两个标识操作(如可以折叠为一个),这将在 NaN 上引发异常。或者可以优化为,因为乘以 2 的精确幂不会改变尾数,因此不会导致四舍五入。不管第一次乘法还是第二次乘法溢出,那仍然是最终结果。(除非引发溢出乘法不会引发的异常标志?我不这么认为。x *= 1.0;x *= 2; x *= 2;x *= 4;+-InfInf * 2

而且它们都在同一方向上改变指数,不像它可能会溢出到 +Inf 对于大数字,所以不等同于 .此外,如果产生不正常的结果,它实际上可以在右移尾数时舍入两次;IEEE FP 具有渐进的下溢(具有指数特殊编码的次正态),但非渐进溢出到 +Inf。x *= 4; x *= 0.5;x *= 2x *= 0.5; x *= 0.5;

弄清楚优化是否安全超出了本答案的范围。GCC 和 clang 不会优化成 even with ,但我认为这是一个错过的优化。x * 0.5 * 0.5x *= 0.25x *= 2.0f; x *= 2.0f;x *= 4.0f;-fno-trapping-math

评论

0赞 Eric Postpischil 9/19/2023
Re “但那将是定义行为,与UB的绝对零保证完全相反”:“未定义的行为”在C标准中具有特定的含义,它不是“绝对”自由的。这绝对是一个合格的自由缰绳;该标准明确,“未定义的行为”仅意味着C标准不强加任何要求。它不会否定或取消来自 C 标准之外来源的任何要求。
0赞 Eric Postpischil 9/19/2023
...您已经在这个答案中承认编译器可能会定义超出标准的行为,但从您的文章中并不清楚您对未定义行为的陈述有什么看法。
10赞 Peter Cordes 9/19/2023
我很确定我的答案可以用更少的文字表达同样的观点,而且我最终得到了至少 3 个不同版本的答案,都塞进了一个:/
3赞 Peter Cordes 9/19/2023
@EricPostpischil:我改写了那段话;我认为这对一些人有帮助(避免“自由缰绳”的含义比我预期的更广泛地适用,而不是答案太长和多余地重复其观点:P)
2赞 Peter Cordes 9/20/2023
@user541686:Raymond Chen(devblogs.microsoft.com/oldnewthing/20140627-00/?p=633)引用了其中一个标准的话说,但是,如果任何此类执行包含未定义的操作,则本国际标准不要求使用该输入执行该程序的实现(甚至不要求在第一个未定义操作之前执行该操作)。这仍然在当前的C++草案标准(eel.is/c++draft/intro.abstract#5)中。它不符合 ISO C 标准的措辞;也许根本没有;如果不是,C++ 是 C 误解的根源
0赞 supercat 9/20/2023 #2

编译器可以执行优化,这些优化不会明显影响任何已定义程序执行的行为。一个不幸的推论是,允许可能明显影响某些程序执行行为的有用优化的唯一方法是将任何执行归类为调用未定义的行为。编译器编写者对未定义行为的疯狂迷恋源于标准编写者不愿意指定编译器的行为方式可能与按顺序处理程序的各个步骤不一致的特定方式,在这种情况下,程序执行将在这种偏差允许的范围内定义。

如果编译器针对的语言或平台指定了在超出标准规定的情况下的行为方式,则编译器可能会执行优化,而这些优化在没有此类保证的情况下是无效的。例如,如果编译器给出了如下结构:

extern int x[],y[];
int i,*p;
...
if (p+i==x+4)
  p[i] = 1;

它的输出语言保证了取消引用指针的操作将以一种与它们的计算方式无关的方式进行处理,即使在标准可能定义前者的行为而不是后者的情况下,它也可以将构造转换为。p[i]x[4]

如果下游处理未以上游优化所依赖的方式处理所有预期情况,则此类优化可能不再有效。例如,在上面的构造中,如果下游代码假设 的地址是通过向 的基址 添加整数位移来计算的,那么优化将是不合理的,取消引用它不可能访问存储,即使标准将指定程序行为为精确地这样做,如果有四个元素, 紧接在记忆中,并通过取 的地址形成。x[4]xy[0]xyxpy

请注意,上述任何一种优化在单独情况下都是合法的,即使组合不是。知道下游传递不会执行某些转换的上游传递可能会合法地执行优化,而这些优化在没有这种知识的情况下是不合理的。该标准依靠实施来识别哪些组合是无效的,并设计出最方便避免它们的方法。

评论

1赞 9/20/2023
C++ 说 UB,所以任何行为都是允许的,根据标准,“无法执行优化”是错误的。事实上,这本书讨论了这样的情况,即UB被强加为程序终止。
0赞 supercat 9/20/2023
@YvesDaoust:如果长度为 4,则指针的计算是定义的行为,虽然我不确定C++标准,但 C 标准明确定义了“刚刚过去”指针和指向紧随其后对象的指针之间的相等比较行为。你在说什么UB?xx+4
0赞 9/20/2023
OP 的示例是整数溢出。
0赞 supercat 9/20/2023
@YvesDaoust:C 标准使用术语“未定义的行为”作为许多目的的统称,包括大多数实现被期望“以环境特征的记录方式”处理的构造,除非这样做会给客户带来明显的好处;在许多情况下,C++ 继承了 C 标准的这种用法。我的回答的主要观点是,如果下游处理保证了比标准要求的更多的极端情况行为,则转换可能是有效的,但当下游流程不这样做时,转换是无效的。
1赞 supercat 9/20/2023
@YvesDaoust:转换和优化通常分多个阶段进行。如果处理的某个特定阶段保证有符号整数加法和减法在所有情况下都使用截断二的补码行为,则前一个阶段可能能够将事件序列合并到而不考虑不会在编写的代码中导致整数溢出的值组合是否可能在修订版本中产生整数溢出。如果后期阶段可能会在溢出的情况下做一些奇怪的事情,...x+=100; x+=a; x+=b; x+=c; x-=100;x+=(a+b+c);
3赞 user21508463 9/20/2023 #3

IMO,你是对的。UB 就是 UB,没有义务提出异常并终止程序,因此应该允许优化。

无论如何,如果编译器设置为在有符号溢出时导致陷阱,则必须在发生陷阱的地方遵循陷阱,并且无法合并添加项。

请注意,这不是 C 要求(UB 可以是任何东西),而是编译器要求(如果 UB 设置为陷阱 - 这会强制使用“DB”)。