Jos*_*hua 24 c optimization integer-overflow compiler-optimization undefined-behavior
如果 C 程序有未定义的行为,任何事情都可能发生。因此编译器可能会假设任何给定的程序不包含 UB。因此,假设我们的程序包含以下内容:
\nx += 5;\n/* Do something else without x in the meantime. */ \nx += 7;\n
Run Code Online (Sandbox Code Playgroud)\n当然,这可以优化为
\n/* Do something without x. */\nx += 12;\n
Run Code Online (Sandbox Code Playgroud)\n或类似的其他方式。
\n如果 x 具有类型,unsigned int
则上述程序中不可能出现 UB。另一方面,如果 x 有类型signed int
,则有可能溢出,从而产生 UB。由于编译器可能会假设我们的程序不包含UB,因此我们可以进行与上面相同的优化。事实上,在这种情况下,编译器甚至可以假设x - 12 <= MAX_INT
.
然而,这似乎与 Jens Gustedt 著名的“Modern C”(第 42 页)相矛盾:
\n\n\n但这样的优化也可以被禁止,因为编译器无法证明某个操作不会强制程序终止。在我们的示例中,很大程度上取决于 x 的类型。如果 x 的当前值可能接近类型的上限,则看似无辜的操作 x += 7 可能会产生溢出。此类溢出根据类型的不同而有不同的处理方式。正如我们所看到的,无符号类型的溢出不是问题,并且压缩运算的结果将始终与两个单独的结果一致。对于其他类型,例如有符号整数类型(signed)和浮点类型(double),溢出可能会引发异常并终止程序。在这种情况下,无法执行优化。
\n
(强调我的)。如果编译器可以(并且确实)假设我们的程序没有 UB,为什么不能执行此优化?
\n\nPet*_*des 28
TL:DR:你是对的,这样的优化并不禁止signed int
,只禁止float
/ double
,而不仅仅是因为这种情况下的例外。
UB 的原因之一是某些不起眼的机器可能会引发异常。但命中 UB并不能保证在所有机器上引发异常(除非您使用 进行编译gcc -fsanitize=undefined
,对于 UB 类型,它或 clang 可以可靠地检测到,或者gcc -ftrapv
将signed int 溢出的行为定义为捕获)。当编译器通过假设某些事情不会发生而将 UB 视为优化机会时,情况就大不相同了:UB 不是“错误”或“陷阱”的同义词。
有些操作可能会在普通 CPU 上陷入困境,例如未知指针的 deref 以及某些 ISA 上的整数除法(例如 x86,但不是 ARM)。如果您正在寻找编译器可能需要小心的操作,以避免在需要发生的副作用之前或在可能导致抽象机无法到达未定义的分支之前引入异常,那么这些可以作为示例根本没有操作。
有符号整数溢出是 UB,因此在程序执行过程中的任何点(在 C++ 中,根据 C 标准的一些解释),任何事情都可能发生,即使是在使用非陷阱指令(add
如所有现代 ISA)。
某些实现可能会将行为定义为引发异常。如果他们定义了引发异常的位置,那么它将阻止优化;每个加法都需要按照编写的方式进行,这样如果抽象机中的操作溢出,它就会陷入其中。但这将定义行为,与 UB 完全相反,这意味着对程序实际执行的操作的保证绝对为零。
在 C 中,如果 n3128 被接受1,则在抽象机遇到 UB 之前排序的任何可见副作用都必须发生。但遇到 UB 后,实际上任何事情都是允许的,包括进行 I/O。UB 不必出错并停止执行。如果编译器+=
使用 MIPS 有符号溢出捕获add
指令而不是通常的指令来编译操作,则在干预代码之后addu
进行优化是合法的,即使它包含 I/O 操作或其他可见的副作用(例如读取或写)。即使在抽象机中导致有符号溢出 UB,如果实际行为是稍后捕获(例如,当抽象机已经完成该部分时),也可以。只要它在抽象机命中 UB 时或之后,实际上任何事情都是允许的。(在 C++ 中,对于确实遇到 UB 的执行,即使在a或某个东西之前执行可能的陷阱也是合法的,因为即使在第一个未定义操作之前,也明确缺乏对行为的要求。但前提是编译器可以证明 printf 肯定会返回,所以在实践中,这种优化通常只能发生在访问上(如果有的话)。但即使没有追溯效果,我们也可以在之前和之后或之后进行。对于有符号溢出来说,没有错误是有效的行为,但是抽象机执行了未定义的操作,因此以后发生的任何操作(例如打印然后陷印,或者只是进行加法换行)都是允许的。)x+=12
volatile
x+=5
x+=7
addi $s0, $s0, 12
printf
volatile
x+=5
x+=7
x+=12
编译器只需避免在不应该有任何. (这对于主流 ISA 上的整数加法来说不是问题;大多数甚至没有捕获有符号加法指令,针对 MIPS 的编译器addu
甚至可以用于有符号数学,因此它们可以自由优化,而且因为历史上程序员不希望陷入int
数学困境。)
请参阅追溯未定义的行为是否意味着无法保证早期可见的副作用?和n3128:驯服恶魔 - 未定义的行为和部分程序正确性,一项建议让 ISO C 明确指定在抽象机到达未定义的操作之前可见的副作用(如 I/O)仍然必须发生。(当前 ISO C 标准的常见解释将 UB 视为 C++,其中C++ 标准明确允许沿着通往 UB 的不可避免的路径“破坏”内容。)
和int
都unsigned
可以进行这种优化,只有 FP 类型不能,但这也是因为舍入,即使您使用gcc -fno-trapping-math
( FP 数学选项)进行编译也是如此。通过 GCC13 和 Clang 16查看Godbolt上的实际效果
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
Run Code Online (Sandbox Code Playgroud)
# 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
Run Code Online (Sandbox Code Playgroud)
你是对的;假设x
是局部变量,因此实际上没有任何东西可以使用x += 5
结果,因此可以安全地针对 和x+=5; ... ; x+=7
整数x+=12
类型进行优化。signed
unsigned
无符号整数数学当然没问题。
在抽象机不遇到 UB 的任何情况下,有符号整数数学都必须产生正确的结果。 x+=12
这样做。不能保证有符号溢出会在程序中的任何特定点引发异常,这就是现代 C 中基于不会发生未定义行为的假设的优化的全部要点。对于将遇到 UB 的执行,实际上任何事情都可能在该点之前或之后的任何地方发生(但请参阅上面的脚注 1:沿着通往 UB 的不可避免的路径“破坏”东西)。
x-=5; x+=7
即使对于变成,这种优化也是安全的x+=2
,其中抽象机可以换行两次(遇到 UB),但 asm 不会换行,因为“碰巧工作”是允许的行为,并且在实践中很常见。add
(例如,甚至使用 MIPS 陷阱指令。)
如果您使用诸如 之类的编译器选项gcc -fwrapv
,则将有符号整数数学的行为定义为 2 的补码换行,删除 UB 并使情况与无符号相同。
GCC 有时确实会错过带符号整数数学的优化,因为 GCC 内部不太愿意在汇编中临时创建有符号溢出,而抽象机中不存在这种溢出。当为允许非陷阱整数数学(即所有现代 ISA)的机器进行编译时,这是一个错过的优化。例如,GCC 将优化a+b+c+d+e+f
为(a+b)+(c+d)+(e+f)
forunsigned int
但不会优化为signed int
without -fwrapv
。Clang 对于 AArch64 和 RISC-V 都适用,但选择不针对 x86。(神箭)。同样,这是由于 GCC 由于某种未知原因过于谨慎而错过的优化;这是完全有效的。2 的补码有符号数学与无符号二进制数学相同,因此是结合律;例如,在优化计算来回缠绕但抽象机没有的情况下,最终结果将是正确的。
有符号溢出UB只是抽象机中的东西,不是asm;大多数主流 ISA 甚至没有在溢出时陷入困境的整数加法指令。(MIPS 确实如此,但编译器不将它们用于int
数学运算,因此它们可以进行优化,产生抽象机中不存在的值。)
半相关:为什么GCC不将a*a*a*a*a*a优化为(a*a*a)*(a*a*a)?(答案表明,编译器确实针对整数数学优化为三乘法,即使对于有符号的也是如此int
。)
浮点数学无法进行此优化,因为由于舍入不同,它可能在非溢出情况下给出不同的结果。 两个较小的数字都可以向下舍入,而一个较大的数字则可以克服阈值。
例如,对于足够大的数字,最接近的可表示double
值16
彼此分开,8
将得到一半并舍入到最近的偶数(假设默认舍入模式)。但任何较小的值,例如7
或5
,总是会向下舍入;x + 7 == x
,因此 和5
都会7
丢失,但x+12
一次就会跨越驼峰到达下一个可表示的浮点数或双精度数,产生x+16
.
(尾数最后一位的 1 个单位的大小取决于 float/double 的指数。对于足够大的 FP 值,它是 1.0。对于更大的值,例如double
从 2 53到 2 54只能表示偶数,指数较大的依此类推。)
如果您使用 GCC 有缺陷的默认值进行编译-ftrapping-math
,它将尝试尊重 FP 异常语义。如果溢出发生两次,它不会可靠地生成 2 个 FP 异常,因此它可能不关心这一点。
但是,是的,对于#pragma STDC FENV_ACCESS ON
,每个单独的 FP 操作都应该具有可观察到的效果。(https://en.cppreference.com/w/c/numeric/fenv)。但是,如果您不调用实际观察两个操作之间的 FP 异常标志,如果我们可以证明舍入是相同的,那么fegetexcept
理论上它们仍然可以优化,因为我认为即使 ISO C 也不应该支持实际运行每个捕获操作的异常/信号处理程序。FENV_ACCESS ON
例如,两个身份操作x *= 1.0;
可以折叠为一个,这将引发 NaN 异常。或者x *= 2; x *= 2;
可以优化为,x *= 4;
因为乘以 2 的精确幂不会改变尾数,因此不会导致舍入。无论第一个或第二个乘法溢出到+-Inf
,这仍然是最终结果。(除非Inf * 2
引发溢出乘法不会已经引发的异常标志?我不这么认为。)
并且它们都以相同方向更改指数,x *= 4; x *= 0.5;
这与对于大数可能溢出到 +Inf 不同,因此不等于x *= 2
. 此外,如果x *= 0.5; x *= 0.5;
产生次正常结果,则在尾数右移时实际上可以舍入两次;IEEE FP 具有逐渐下溢(对指数进行特殊编码的次正规),但非逐渐溢出到 +Inf。
弄清楚优化是否安全x * 0.5 * 0.5
超出x *= 0.25
了本答案的范围。即使使用x *= 2.0f; x *= 2.0f;
GCC和 clang 也不会优化,但我认为这是一个错过的优化。x *= 4.0f;
-fno-trapping-math
归档时间: |
|
查看次数: |
3838 次 |
最近记录: |