编译器优化会引入bug吗?

75

今天我和朋友讨论了几个小时的“编译器优化”,我认为有时候编译器优化可能会引入错误或至少是不期而至的行为。

我的朋友完全不同意,说“编译器由聪明的人建造并做聪明的事情”,因此它永远不会出错。

他一点也没说服我,但我必须承认我缺乏现实生活中的例子来加强我的观点。

谁是对的呢?如果我是对的,你是否有任何真实的例子,可以证明编译器优化会在生成的软件中产生错误?如果我错了,那么我应该停止编程并学习钓鱼吗?


6
虽然不常见,但确实存在。在谷歌搜索“codegen bug optimization”可以找到相关案例。当然,编译器即使没有启用优化功能也可能存在错误,因此,优化并不是编译器中唯一没有缺陷的功能。 :) - Craig Stuntz
56
编译器是由聪明人构建并执行聪明的任务,因此它们永远不会出错 - 哈哈哈!如果他相信这一点,请给我他的地址-我有一座需要卖掉的桥。 - anon
7
@Neil - 这座桥是否经过优化设计? - DVK
11
没关系,我相信它是由聪明的人建造的。 - Justin Rusbatch
1
他显然从来没有处理过编译C代码。 - helpermethod
显示剩余5条评论
22个回答

44

编译器优化可能会引入错误或不良行为。这就是为什么你可以关闭它们的原因。

一个例子:编译器可以优化对内存位置的读/写访问,比如消除重复的读或写,或重新排列某些操作。如果所涉及的内存位置只由单个线程使用并且实际上是内存,那么这可能没问题。但是,如果内存位置是硬件设备IO寄存器,则重新排序或消除写入可能是完全错误的。在这种情况下,通常必须编写代码,知道编译器可能会"优化"它,因此知道天真的方法不起作用。

更新:正如Adam Robinson在评论中指出的那样,我上面描述的场景更像是编程错误而不是优化器错误。但我试图说明的重点是,一些程序在与一些优化结合使用时可能会引入程序中的错误,而这些程序在其他方面是正确的,而这些优化在其他方面是有效的。在某些情况下,语言规范说"你必须以这种方式做,因为这些类型的优化可能发生,你的程序将失败",在这种情况下,这是代码中的错误。但有时编译器具有(通常是可选的)优化功能,可以生成不正确的代码,因为编译器试图过度优化代码或无法检测到该优化不合适。在这种情况下,程序员必须知道何时可以安全地打开相关的优化。

另一个例子: Linux内核存在一个漏洞,在测试指针是否为空之前,可能会对潜在的NULL指针进行解引用。然而,在某些情况下,将内存映射到地址零是可能的,因此允许解引用成功。编译器在注意到指针被解引用后,假定它不能为NULL,然后稍后删除了空测试和该分支中的所有代码。这导致代码中引入了安全漏洞,因为函数将继续使用包含攻击者提供的数据的无效指针。对于指针合法为空且内存未映射到地址零的情况,内核仍然会像以前一样发生OOPS。因此,在优化之前,代码包含一个错误;在优化之后,代码包含两个错误,其中一个允许本地root利用。 CERT有一份名为“危险的优化和因果关系丧失”的演示文稿,由Robert C. Seacord撰写,列出了许多在程序中引入(或暴露)错误的优化。它讨论了可能的各种优化类型,从“执行硬件所做的”到“捕获所有可能的未定义行为”到“执行任何未被禁止的操作”。
有些代码的例子在受到积极优化的编译器处理之前是完全正常的:
  • Checking for overflow

    // fails because the overflow test gets removed
    if (ptr + len < ptr || ptr + len > max) return EINVAL;
    
  • Using overflow artithmetic at all:

    // The compiler optimizes this to an infinite loop
    for (i = 1; i > 0; i += i) ++j;
    
  • Clearing memory of sensitive information:

    // the compiler can remove these "useless writes"
    memset(password_buffer, 0, sizeof(password_buffer));
    
问题在于编译器几十年来一直不太积极进行优化,因此多代C程序员学习和理解了固定大小的二进制补码加法及其溢出等内容。然后,尽管硬件没有变化,但编译器开发人员修改了C语言标准,导致微妙的规则发生了变化。C语言规范是开发人员和编译器之间的合同,但协议条款随着时间的推移而可能会发生变化,并非每个人都能理解每个细节,或者认为这些细节是明智的。

这就是为什么大多数编译器提供了关闭(或打开)优化的标志。您的程序是否编写时考虑到整数可能会溢出?那么您应该关闭溢出优化,因为它们可能会引入错误。您的程序是否严格避免别名指针?那么您可以打开假设指针从未别名的优化。您的程序是否尝试清除内存以避免泄漏信息?哦,在这种情况下,您就没那么幸运了:您要么需要关闭死代码删除,要么需要预先知道您的编译器将消除您的“死”代码,并使用一些解决方法。


27
关闭编译优化的更常见原因是,通常在其开启时调试较为困难。 - Craig Stuntz
5
非易失性读取是编译器(或运行时)优化导致的错误行为的典型例子,尽管我不确定是否应该将这种行为归类为“错误”,因为开发人员有责任考虑到这种情况。 - Adam Robinson
8
示例说明了为什么存在"volatile"关键字,它是代码错误。一个没有bug的编译器即使进行优化也不会引入bug。 - phkahler
9
@Mr. Shiny已经说明了他认为优化可以关闭的理由,但这远非无可争议,我怀疑在大多数编译器文档中也没有这样解释。在调试时,人们通常希望得到容易与原始代码相关联的机器代码,并且希望它能与关闭优化的构建一起使用,而QA构建(和最终发布)将启用优化。我认为,任何人发现像@Mr. Shiny所描述的问题后,通过关闭优化来“修复”它都是在修复错误的东西。 - George Hawkins
5
@Mr.ShinyandNew安宇,你再一次错过了重点。关于你提供的链接中的例子,编译器优化并没有引入任何错误。错误本来就存在,是由程序员在struct sock *sk = tun->sk;这一行对tun进行解引用操作却没有进行NULL检查引起的。其次,C++标准规定NULL是指向无对象的指针,不能进行解引用操作。基于此,编译器优化代码是完全合法且符合标准的。 - Hadi Brais
显示剩余32条评论

35

当通过禁用优化来解决bug时,大多数情况下仍是您的问题。

我负责一个商业应用程序,主要使用C++编写 - 最初是VC5,早期移植到VC6,现在成功移植到VC2008。在过去的10年中,代码已经增长到了100万行以上。

在这段时间里,我确认了一个代码生成bug,只有在启用了激进的优化时才会出现。

那么我为什么会抱怨呢?因为在同样的时间内,有许多bug使我怀疑编译器 - 但最终证明是我对C++标准理解不足。标准为编译器可以或不可以利用的优化提供了空间。

多年来在不同的论坛上,我看到了很多指责编译器的帖子,最终发现是原始代码中的错误。毫无疑问,其中许多都是需要深入理解标准中使用的概念的隐蔽bug,但它们仍然是源代码中的错误。

我为什么这么晚才回复:在确认实际上是编译器的错误之前,请停止指责编译器。


16

编译器(和运行时)优化确实可能会引入不需要的行为,但至少只有在依赖未指定的行为(或者确实是对明确定义的行为做出了错误假设)时才会发生。

当然,除此之外,编译器也可能存在错误。其中一些可能与优化有关,并且其影响可能非常微妙 - 实际上它们很可能是这样的,因为明显的错误更容易被修复。

假设你将JIT视为编译器,我曾经在发布版本的.NET JIT和Hotspot JVM中都看到过错误(不幸的是,我目前没有详细信息),这些错误在特别奇怪的情况下是可以重现的。它们是否是由于特定的优化而导致的,我不知道。


我在下面的帖子中指出了一个众所周知的问题,即C++的优化器会在使用双重检查锁定模式时引入错误。就目前的C++规范而言,这是正确的行为,不是编译器的错误,它是经过良好规定的行为,在关闭优化时可以正常工作,但在开启优化时会出现问题。 - tloach
@Jon Skeet:我从不点踩(好吧,几乎从不),但是在172k的时候你甚至会注意到吗? :-) - Mike Dunlavey

11

将其他文章合并:

  1. 编译器偶尔会有其代码中的错误,像大多数软件一样。 "聪明人"的论点与此完全无关,因为由聪明人构建的NASA卫星和其他应用程序也存在漏洞。执行优化的编码与不执行优化的编码是不同的,因此如果错误恰好出现在优化器中,则您的优化代码可能包含错误,而非优化代码则不会。

  2. 正如Shiny and New先生所指出的那样,仅就并发和/或时间问题而言,相对幼稚的代码可以在没有优化的情况下运行得令人满意,但在进行优化后却失败了,因为这可能会改变执行的时序。你可以将这样的问题归咎于源代码,但如果只在优化时才会表现出来,有些人可能会责备优化。


8

仅举一个例子:几天前,有人发现使用选项-foptimize-sibling-calls(这是由-O2隐含的)的gcc 4.5会产生一个在启动时崩溃的Emacs可执行文件。

这个问题显然已经被修复了。


1
显然这是编译器中的一个错误。当然,编译器可能会有错误:它们是软件的一部分。我更感兴趣的是优化代码中不是由编译器错误引起的错误(不希望/意外行为)的示例。 - R. Martinho Fernandes

8

我从未听说过或使用过一个编译器,其指令不能改变程序的行为。通常这是一件好事,但它需要你阅读手册。

而且,我最近遇到了一个情况,其中一个编译器指令“消除”了一个错误。当然,这个错误仍然存在,但我有一个临时解决方法,直到我正确修复程序。


尤其是在Fortran语言中。我们经常遇到这样的情况,即Fortran代码只有在特定的优化级别下才能正常运行。而在IDE下运行Fortran代码?优化器(即使你没有要求)任意改动代码和变量位置以达到所谓的“优化”?给我一个休息 - Mike Dunlavey
1
@Mike -- 我理解你的痛苦,最近我要将一段Fortran程序从一个集群移植到另一个“相同”的集群。IDE?你肯定是指Emacs :-) - High Performance Mark
抱歉,我被困在Windows世界中。Fortran是一个热门话题 - MS、DEC、Compaq,现在是Intel。我错过了任何寄养父母吗?它可以在.net下工作,但只有在你的老板继续支付升级费用时才能使用。此外还有GCC,我们有可爱的GDB。我曾经听说过,“不管你喜不喜欢,Fortran就像摇滚乐一样,永远不会消失!” - Mike Dunlavey

7

可以。一个很好的例子是双重检查锁定模式(double-checked locking pattern)。在C++中,没有安全的方式实现双重检查锁定模式,因为编译器可以重新排列指令,这在单线程系统中是有意义的,但在多线程系统中不是。完整的讨论可以在http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf找到。


正如您所指出的那样,这是C++的一个缺陷,而不是优化编译器的问题。即使没有优化,这个问题也存在。 - phkahler
1
完全不需要。没有优化的情况下,双重检查锁定是可以的。这是一种很痛苦的错误类型,因为在调试版本中它可以正常工作。只有当优化器被允许在单线程系统中进行逻辑上等效的更改时,才会出现问题。你可以说这是优化器在C++中所允许的问题,但这仍然是一个编译时开启优化可能导致问题的领域。 - tloach
这就是为什么C11 / C++11引入了一个线程感知的内存模型和std::atomic。在C++11之前,无锁原子操作和自行实现的锁定总是高度依赖于对编译器工作方式的许多假设(并且通常需要内联汇编)以及对volatile的滥用。 - Peter Cordes

6
我曾多次遇到这种情况,即使用新编译器构建旧代码时。旧代码可以正常工作,但在某些情况下依赖于未定义的行为,例如未正确定义/转换运算符重载。它可以在VS2003或VS2005调试版本中工作,但在发布版本中会崩溃。
打开生成的程序集后,很明显编译器仅删除了有关该函数的80%功能。重新编写代码以不使用未定义的行为即可解决此问题。
更明显的例子:VS2008与GCC
已声明:
Function foo( const type & tp ); 

被称为:

foo( foo2() );

foo2() 返回一个 type 类的对象;

在 GCC 中容易崩溃,因为在这种情况下对象没有分配在堆栈上,但是 VS 做了一些优化来解决这个问题,所以它可能会工作。


6

这可能吗?在主要产品中不太可能,但当然是有可能的。编译器优化生成的代码;无论代码来自何处(你编写它或某些东西生成它),它都可能包含错误。


5

别名现象可能会对某些优化造成问题,这就是为什么编译器有一个选项来禁用这些优化。来自Wikipedia:

为了以可预测的方式启用这种优化,C编程语言的ISO标准(包括其更新的C99版本)规定,除了一些例外情况外,不同类型的指针引用同一内存位置是非法的。这个规则被称为“严格别名”,可以显著提高性能[citation needed],但已知会破坏一些本来有效的代码。一些软件项目有意违反了C99标准的这一部分。例如,Python 2.x这样做是为了实现引用计数,[1]并需要更改Python 3中的基本对象结构来启用这种优化。Linux内核这样做是因为严格别名会导致内联代码优化出现问题。[2]在这种情况下,使用gcc编译时会调用选项-fno-strict-aliasing,以防止产生不希望或无效的优化,从而产生不正确的代码。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接