编译器优化可能导致整数溢出。这样可以吗?

回答 4 浏览 7054 2022-10-17

我有一个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
mbang 提问于2022-10-17
无符号整数溢出有明确的行为。只有signed的整数溢出才是UB的。Jesper Juhl 2022-10-17
@JesperJuhl 谢谢,这满足了我的问题。我认为在int的情况下,OP还是很有意思的,所以我编辑了这个问题。mbang 2022-10-17
对于编译器来说,让乘法溢出,然后让减法下溢回,并不是真正的错误,只要这种溢出在你所针对的CPU架构上有很好的定义。Miles Budnek 2022-10-17
你说的是"编译器优化",但你需要非常具体地说明编译器和优化的情况。[哪个编译器和什么优化]你不能假设优化会发生,这是不好的做法。更好的做法是使用你可以使用的类型,这样你就不会在数学方程上溢出。- 你可以尝试一个练习,就是用不同的值来尝试你的函数,看看每个编译器的输出情况。SimplyCode 2022-10-20
4 个回答
#1楼
得票数 59

正如Miles暗示的那样。C++代码文本受C++语言规则的约束(整数溢出=坏),但编译器只受cpu规则的约束(溢出=好)。它可以进行代码所不允许的优化。

但不要把这作为偷懒的借口。如果你写了未定义的行为,编译器会把它作为一个提示,并进行其他优化,导致你的程序做错事。

Mooing Duck 提问于2022-10-17
@bang 考虑一下x2*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
@bang 不,这句话使用的术语稍有偏差。无论是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
@Bang:不,这是对 "违反标准 "的一个疯狂的定义。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++的抽象虚拟机不仅包括程序源,而且还被包括程序的输入在内的许多东西所参数化。有些代码仅基于源码就有未定义的行为,无论输入是什么。一些表达式如果被评估或只用某些值就会引起UB,这意味着虚拟机的一些执行实例确实有UB,而另一些可能没有。aschepler 2022-10-18
@bang:摘自C++标准:"虽然本文件只说明了对C++实现的要求,但如果将这些要求表述为对程序、程序的一部分或程序的执行的要求,往往更容易理解。"C++程序不可能违反标准,因为标准只规定了对C++实现的要求。supercat 2022-10-18
#2楼
得票数 36

带符号的整数溢出在C++语言层面没有得到很好的定义,并不意味着在汇编层面也是如此。这取决于编译器发出的汇编代码是否在你所针对的CPU架构上有明确的定义。

我敢肯定,本世纪制造的每一个CPU都使用了带符号的两补整数,而溢出对于这些整数来说是完全可以定义的。这意味着简单地计算2*x,让结果溢出,然后减去1,让结果向下溢出,是没有问题的。

许多这样的C++语言层面的规则存在于不同的CPU架构上。在这种情况下,有符号的整数溢出是未定义的,这样,针对使用一补或有符号的整数的符号/大小表示法的CPU的编译器就不会被迫增加额外的指令来符合二补的溢出行为。

然而,不要以为你可以使用一个在目标CPU上定义良好但在C++中未定义的结构,并得到你期望的答案。C++编译器假定在执行优化时不会发生未定义的行为,因此,如果你的代码不是定义良好的C++,它们可以而且会发出与你所期望的不同的代码。

Miles Budnek 提问于2022-10-17
Barmar 修改于2022-10-18
在C++20中,有符号的整数溢出仍然会产生未定义的行为,尽管规定要使用二进制补码。Eric M Schmidt 2022-10-18
我想知道godbolt上是否有任何目标架构是使用一补的,这样我们就可以比较一下结果了。kaya3 2022-10-18
@kaya3:很确定没有。当然,使用GCC的没有,因为它只支持2's补码目标。gcc.gnu.org/onlinedocs/gcc/Integers-implementation.htmlPeter Cordes 2022-10-18
"我很确定本世纪制造的每一个CPU都使用了二补有符号整数"为什么每次有人说"我很确定......"时,我都有种冲动,想去研究的兔子洞,证明他们错了?无论如何,似乎有个反例,在这里提到这里Heinzi 2022-10-18
@Heinzi 这些链接包含一些非常有趣的信息。虽然我想你可以对"制造"的定义吹毛求疵,因为看起来最新的基于Dorado的主机是基于未命名的英特尔芯片上的硬件仿真。营销材料使用有趣的短语"仿真IOPs"来描述性能。Chuu 2022-10-20
#3楼 已采纳
得票数 26

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/2x <= 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 + 1INT_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没有这个问题。


进一步的阅读。

整个情况基本上是一团糟,C语言的设计者并没有预见到目前优化编译器的复杂程度。像Rust这样的语言更适合它:如果你想要包装,你可以(而且必须)在每个操作的基础上告诉编译器,对于有符号和无符号类型。比如x.wrapping_add(1)


Re: 为什么clang将2*x-1lea/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。)

这个调谐决定与整数溢出无关。

Peter Cordes 提问于2022-10-18
Peter Cordes 修改于2022-10-19
"那是GCC遗漏的优化;Clang没有这个问题。"我不知道指令的相对成本,但我认为三参数的lea指令比二参数的lea+decrement快。不幸的是,我从来没能把这种微观的测试做对。mbang 2022-10-18
@bang:我不是在谈论这个的情况。Clang是以额外的uop为代价来优化延迟的。lea eax, [rdi + rdi - 1]在Skylake上有3个周期的延迟,而它使用的LEA是1个周期。(参见为什么用于测试科拉茨猜想的C++代码比手写汇编运行得更快?)。因此,它节省了一个周期的延迟,代价是一个额外的uop。这个好处有点值得怀疑,而且在Zen或Ice Lake上并没有更好,事实上更差(3组件LEA在ICL上有1周期的延迟,在Zen上有2周期)。uops.infoLEA_B_I_D8 (R32)条目。Peter Cordes 2022-10-18
#4楼
得票数 6

有符号的整数溢出/下溢是未定义的行为,正是所以编译器可以进行这样的优化。因为在溢出/下溢的情况下,编译器被允许做任何事情,它可以这样做,或者做其他更适合它需要关心的用例的事情。

如果签名溢出的行为被指定为 "DEC PDP-8在1973年所做的事情",那么其他目标的编译器就需要插入指令来检查溢出,如果溢出发生,就产生这个结果,而不是CPU本身所做的事情。

Davislor 提问于2022-10-18
这种优化对于无符号的整数或gcc -fwrapv来说是合法的,在抽象机器中,有符号的包络是明确定义的。(在GCC的情况下,作为2'的补码包装。gcc.gnu.org/onlinedocs/gcc/Integers-implementation.html)。但在任何做了任何一种包装的机器上(不是饱和或陷阱),2*(x-1)+12*x-1应该总是产生相同的结果。(因此,如果适合在一个int中的话,是数学上正确的结果)。Peter Cordes 2022-10-19
对于某些输入来说,它可能与PDP-8或PDP-11的结果不一样,但这两个表达式应该总是等价的到对方,所以如果规则是有符号包装是实现定义的,而不是UB,优化仍然是合法的。标准允许2的补码、1的补码和符号/大小,所以强制规定PDP-8或PDP-11的确切语义并不能替代说它完全是UB。Peter Cordes 2022-10-19
@PeterCordes 我的理解是,有一些CPU不是二进制补码,甚至可能在溢出时被捕获,因此,使行为成为UB,以便编译器仍然可以使用本地指令。Davislor 2022-10-19
是的,使有符号溢出成为UB,可以使本地指令陷阱而不是包裹的机器容易编译。但在这样的机器上,像这样的优化是被禁止的,因为它们可能会在C++抽象机器没有陷阱的地方引入一个陷阱。所以你需要用sub/add/sub来代替add/sub。这基本上与你所说的相反,即它是UB允许这种优化(在那里或在正常的现代机器上?)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