编译器优化可能导致整数溢出。这样可以吗?
我有一个int x
。为了简单起见,假设int
占据的范围是-2^31到2^31-1。我想计算2*x-1
。我允许x
是任何值0 <= x
<= 2^30。如果我计算2*(2^30),我得到2^31,这是一个整数溢出。
一个解决方案是计算2*(x-1)+1
。这比我想要的多了一个减法,但这应该不会溢出。然而,编译器会将其优化为2*x-1
。这对源代码来说是个问题吗?这对可执行文件来说是个问题吗?
这里是2*x-1
的godbolt输出结果。
func(int): # @func(int)
lea eax, [rdi + rdi]
dec eax
ret
这里是2*(x-1)+1
的godbolt输出结果。
func(int): # @func(int)
lea eax, [rdi + rdi]
dec eax
ret
int
的情况下,OP还是很有意思的,所以我编辑了这个问题。
- mbang 2022-10-17
正如Miles暗示的那样。C++代码文本受C++语言规则的约束(整数溢出=坏),但编译器只受cpu规则的约束(溢出=好)。它可以进行代码所不允许的优化。
但不要把这作为偷懒的借口。如果你写了未定义的行为,编译器会把它作为一个提示,并进行其他优化,导致你的程序做错事。
x
与2*x / 2
的更简单的例子。y = std::numeric_limits<int>::max()
可以,但y = (2* std::numeric_limits<int>::max()) / 2;
不可以,编译器可以自由地用42或胡说八道来代替它。
- 463035818_is_not_a_number 2022-10-17
2*x-1
还是2*(x-1)+1
都没有"违反标准"。它们只是在定义表达式的x
上有不同的范围。优化将a)不会导致表达式对x
的"有效范围"变小;b)不保证导致表达式对x
的"有效范围"增大。这个答案解释了a)是成立的,即使乍看起来不成立。b)意味着你不应该写2*x-1
并期望它等同于2*(x-1)+1
,因为x
可能是2^30。
- 463035818_is_not_a_number 2022-10-17
int foo(int x){ return x+1; }
本身并没有"违反标准",只有以INT_MAX为参数的调用才是违规的。你只会说一个程序 违反了标准,如果它在执行过程中真的发生了这种情况。如果该函数从未被调用,或者说如果块从未被占用,那么即使int x=INT_MAX; x++;
也不属于违约。(编译器可以假定这一点,因为它是不符合标准的)。大多数涉及有符号整数的表达式在某些输入时都有UB,除了像x/2
这样对int x
的每一个可能的值都能避免有符号溢出的UB。
- Peter Cordes 2022-10-18
带符号的整数溢出在C++语言层面没有得到很好的定义,并不意味着在汇编层面也是如此。这取决于编译器发出的汇编代码是否在你所针对的CPU架构上有明确的定义。
我敢肯定,本世纪制造的每一个CPU都使用了带符号的两补整数,而溢出对于这些整数来说是完全可以定义的。这意味着简单地计算2*x
,让结果溢出,然后减去1,让结果向下溢出,是没有问题的。
许多这样的C++语言层面的规则存在于不同的CPU架构上。在这种情况下,有符号的整数溢出是未定义的,这样,针对使用一补或有符号的整数的符号/大小表示法的CPU的编译器就不会被迫增加额外的指令来符合二补的溢出行为。
然而,不要以为你可以使用一个在目标CPU上定义良好但在C++中未定义的结构,并得到你期望的答案。C++编译器假定在执行优化时不会发生未定义的行为,因此,如果你的代码不是定义良好的C++,它们可以而且会发出与你所期望的不同的代码。
ISO C++规则适用于你的源代码(总是如此,不管目标机是什么)。而不是编译器选择制作的asm,特别是对于那些有符号的整数包装的目标机。
“好像”规则要求函数的 asm 实现产生与 C++ 抽象机相同的结果,对于抽象机没有遇到有符号整数溢出(或其他未定义行为)的每个输入值。 asm 如何产生这些结果并不重要,这就是 as-if 规则的全部要点。在某些情况下,就像您的情况一样,最有效的实现将包装和解包某些值抽象机器不会。 (或者一般来说,不要在抽象机为unsigned
或 gcc -fwrapv
做的地方换行。)
有符号整数溢出在C++抽象机中的一个效果是,它让编译器将int
循环计数器优化为指针宽度,而不是每次通过循环或类似的事情都重新做符号扩展。另外,编译器可以推断出值范围的限制。但这与他们如何为某些目标机器将逻辑实现到asm中是完全不同的。UB并不意味着"需要失败",事实上正好相反,除非你用-fsanitize=undefined
编译。如果你用比ISO C++实际给出的更多的保证来解释源码,那么优化器就会有额外的自由来制造与源码不匹配的asm(再加上实现的任何保证,比如你用gcc -fwrapv
。)
对于像x/2
这样的表达式,每个可能的int x
都有明确的行为。对于2*x
,编译器可以假设x >= INT_MIN/2
和x <= INT_MAX/2
,因为更大的量级会涉及到UB。
2*(x-1)+1
意味着x
的合法值范围从(INT_MIN+1)/2
到(INT_MAX+1)/2
。例如,在一个32位2's complement目标上,-1073741823
(0xc0000001)到1073741824
(0x40000000)。在积极的一面,2*0x3fffffff
不会溢出,不会因为2*x
是偶数而在增量上包起来。
2*x - 1
意味着x
的合法值范围是INT_MIN/2 + 1
到INT_MAX/2
。例如,在一个32位2's complement目标上,-1073741823
(0xc0000001)到1073741823
(0x3fffffff)。所以这个表达式能产生的最大值是2^n - 3
,因为INT_MAX将是奇数。
在这种情况下,更复杂的表达式的合法值范围是更简单的表达式的超集,但在一般情况下,情况并不总是如此。
它们对每一个x
都会产生相同的结果,而这个x
对它们来说是一个明确的输入。而x86 asm(其中包装是明确定义的),像其中一个一样工作,可以实现任何一个,对所有非UB情况产生正确的结果。因此,如果编译器不为两者制作相同的高效asm,那就是做得不好。
一般来说,2的补码和无符号二进制整数的数学是可交换和关联的(对于在数学上是真实的操作,如+
和*
),编译器可以而且应该充分利用。例如,将a+b+c+d
重新排列为(a+b)+(c+d)
以缩短依赖链。(参见对 Why doesn't GCC optimize a*a*a*a*a*a to (a*a*a)*(a*a*a)? 的回答,以了解GCC对整数的优化情况,但不是FP。)
不幸的是,GCC有时不愿意做这样的有符号整数优化,因为它的内部将有符号整数数学视为非关联性的,也许是因为错误地将C++ UB规则应用于为目标机器优化asm。那是GCC遗漏的优化;Clang没有这个问题。
进一步的阅读。
- 是否有一些有意义的统计数据来证明保持有符号整数算术溢出未定义?关于:它所允许的一些有用的循环优化。
- http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html
- 未定义的行为是否适用于asm代码?(不适用)。
- 整数溢出在内联x86汇编中是未定义的吗?
整个情况基本上是一团糟,C语言的设计者并没有预见到目前优化编译器的复杂程度。像Rust这样的语言更适合它:如果你想要包装,你可以(而且必须)在每个操作的基础上告诉编译器,对于有符号和无符号类型。比如x.wrapping_add(1)
。
Re: 为什么clang将2*x
和-1
与lea
/dec
分割开来?
在Ice lake之前,Clang正在优化英特尔CPU的延迟,以额外的uop吞吐量成本为代价节省一个周期的延迟。(编译器通常倾向于延迟,因为现代的CPU通常足够宽,可以啃掉吞吐量的成本,尽管它确实吃掉了隐藏缓存缺失延迟的失序执行窗口中的空间)。
lea eax, [rdi + rdi - 1]
在Skylake上有3个周期的延迟,而它使用的LEA有1个周期的延迟。(参见为什么测试科拉茨猜想的C++代码比手写的汇编运行得快?了解一些细节)。在AMD Zen系列上,它的延迟是平衡的(一个复杂的LEA只有2c的延迟),而仍然要花费一个额外的uop。在Ice Lake和后来的Intel上,即使是3个组件的LEA也只有1个周期,所以它是纯粹的劣势所在。见https://uops.info/,LEA_B_I_D8 (R32)
的条目(基数,索引,8位位移,标度因子=1。)
这个调谐决定与整数溢出无关。
lea eax, [rdi + rdi - 1]
在Skylake上有3个周期的延迟,而它使用的LEA是1个周期。(参见为什么用于测试科拉茨猜想的C++代码比手写汇编运行得更快?)。因此,它节省了一个周期的延迟,代价是一个额外的uop。这个好处有点值得怀疑,而且在Zen或Ice Lake上并没有更好,事实上更差(3组件LEA在ICL上有1周期的延迟,在Zen上有2周期)。uops.info,LEA_B_I_D8 (R32)
条目。
- Peter Cordes 2022-10-18
有符号的整数溢出/下溢是未定义的行为,正是所以编译器可以进行这样的优化。因为在溢出/下溢的情况下,编译器被允许做任何事情,它可以这样做,或者做其他更适合它需要关心的用例的事情。
如果签名溢出的行为被指定为 "DEC PDP-8在1973年所做的事情",那么其他目标的编译器就需要插入指令来检查溢出,如果溢出发生,就产生这个结果,而不是CPU本身所做的事情。
gcc -fwrapv
来说是合法的,在抽象机器中,有符号的包络是明确定义的。(在GCC的情况下,作为2'的补码包装。gcc.gnu.org/onlinedocs/gcc/Integers-implementation.html)。但在任何做了任何一种包装的机器上(不是饱和或陷阱),2*(x-1)+1
和2*x-1
应该总是产生相同的结果。(因此,如果适合在一个int中的话,是数学上正确的结果)。
- Peter Cordes 2022-10-19
2*(x-1)+1
优化为将其计算为2*x-1
的asm是否合法。在有符号溢出陷阱的机器上,例如针对MIPS的编译器使用add
会给x=0x40000000带来一个陷阱,而C++的抽象机器会避免一个陷阱。(MIPS的真正的编译器使用addu
,所以他们可以做这样的优化,而且因为历史上的代码很马虎,有时会有int溢出)。编译器没有理由把2*x-1
变成像2*(x-1)+1
那样计算的asm,我们必须手动操作以避免UB。
- Peter Cordes 2022-10-19