今天,我与我的一个朋友进行了讨论,我们就“编译器优化”进行了两个小时的辩论。
我捍卫了这样的观点,有时,编译器优化可能会引入错误或至少会带来不良行为。
我的朋友完全不同意,说“编译器是由聪明的人建造的,可以做聪明的事情”,因此永远不会出错。
他一点也不说服我,但是我不得不承认我缺乏现实生活中的例子来加强我的观点。
谁在这里?如果是的话,您是否有现实生活中的示例,其中编译器优化在结果软件中产生了错误?如果我误会了,我应该停止编程并学习钓鱼吗?
今天,我与我的一个朋友进行了讨论,我们就“编译器优化”进行了两个小时的辩论。
我捍卫了这样的观点,有时,编译器优化可能会引入错误或至少会带来不良行为。
我的朋友完全不同意,说“编译器是由聪明的人建造的,可以做聪明的事情”,因此永远不会出错。
他一点也不说服我,但是我不得不承认我缺乏现实生活中的例子来加强我的观点。
谁在这里?如果是的话,您是否有现实生活中的示例,其中编译器优化在结果软件中产生了错误?如果我误会了,我应该停止编程并学习钓鱼吗?
Answers:
编译器优化可能会引入错误或不良行为。这就是为什么您可以关闭它们。
一个示例:编译器可以优化对内存位置的读/写访问,执行诸如消除重复的读取或重复的写入或对某些操作进行重新排序之类的操作。如果有问题的内存位置仅由单个线程使用并且实际上是内存,那可能没问题。但是,如果存储位置是硬件设备IO寄存器,则重新排序或取消写操作可能是完全错误的。在这种情况下,通常必须编写代码,知道编译器可能会对其进行“优化”,从而知道幼稚的方法行不通。
更新:正如亚当·罗宾逊(Adam Robinson)在评论中指出的那样,我上面描述的场景更多是编程错误而不是优化程序错误。但是我想说明的一点是,某些正确的程序与某些优化的组合,如果某些组合正常运行,它们可能会在程序中引入错误,而这些优化本来可以正常运行。在某些情况下,语言规范会指出“您必须以这种方式进行操作,因为可能会发生这类优化,并且程序将失败”,在这种情况下,这是代码中的错误。但是有时编译器具有(通常是可选的)优化功能,该功能可能生成错误的代码,因为编译器过于努力地优化代码或无法检测到该优化是不合适的。
另一个示例:linux内核存在一个错误,即在测试该指针为null之前会先取消对潜在NULL指针的引用。但是,在某些情况下,可以将内存映射到地址零,从而使取消引用成功。编译器在注意到指针已被取消引用后,假定它不能为NULL,然后在以后删除NULL测试以及该分支中的所有代码。这在代码中引入了安全漏洞,因为该函数将继续使用包含攻击者提供的数据的无效指针。对于指针合法为空且内存未映射到地址零的情况,内核仍将像以前一样是OOPS。因此,在优化之前,代码包含一个错误。它包含两个后,其中一个允许本地root用户利用。
CERT在Robert C. Seacord的演讲中提到了“危险的优化和因果关系的损失”,其中列出了很多引入(或暴露)程序错误的优化。它讨论了各种可能的优化,从“执行硬件操作”到“捕获所有可能的未定义行为”到“执行所有不允许的操作”。
在积极地进行优化的编译器投入使用之前,一些非常好的代码示例:
检查溢出
// fails because the overflow test gets removed
if (ptr + len < ptr || ptr + len > max) return EINVAL;
完全使用溢出算法:
// The compiler optimizes this to an infinite loop
for (i = 1; i > 0; i += i) ++j;
清除敏感信息的内存:
// the compiler can remove these "useless writes"
memset(password_buffer, 0, sizeof(password_buffer));
这里的问题是,数十年来,编译器在优化方面的积极性较弱,因此,几代C程序员学习并了解了固定大小的二进制补码加法及其溢出方式。然后,C语言标准由编译器开发人员进行了修订,尽管硬件没有更改,但细微的规则也有所更改。C语言规范是开发人员和编译人员之间的合同,但是协议的条款可能会随着时间的推移而更改,并且并非每个人都了解每个细节,或者同意这些细节甚至是明智的。
这就是为什么大多数编译器都提供标志以关闭(或打开)优化的原因。您编写的程序是否理解整数可能会溢出?然后,您应该关闭溢出优化,因为它们会引入错误。您的程序是否严格避免混淆指针?然后,您可以启用假设指针从不别名的优化。您的程序是否尝试清除内存以避免泄漏信息?哦,在这种情况下,您很不走运:要么需要关闭死代码删除功能,要么需要提前知道编译器将消除“死”代码,并使用一些工作-围绕它。
struct sock *sk = tun->sk;
引用时出现tun
。其次,C ++标准指出NULL是指向任何对象的指针,并且不能取消引用。基于此,编译器对代码进行了优化,这完全可以并符合标准。
当错误通过禁用优化而消失时,大多数情况下这仍然是您的错
我负责一个主要用C ++编写的商业应用程序-从VC5开始,很早就移植到了VC6,现在成功移植到了VC2008。在过去的十年中,它增长到超过100万条线。
在那段时间里,我可以确认启用主动式优化后会发生一次代码生成错误。
那我为什么要抱怨呢?因为在同一时间,有许多错误使我怀疑编译器-但是事实证明,这是我对C ++标准的理解不足。该标准为编译器可能会或可能不会使用的优化留有余地。
多年来,在不同的论坛上,我看到很多文章都将责任归咎于编译器,最终导致原始代码中的错误。毫无疑问,它们中的许多模糊的bug需要对标准中使用的概念进行详细的了解,但是源代码的bug仍然如此。
为什么我这么晚答复:在确认这实际上是编译器的错误之前,不要责怪编译器。
编译器(和运行时)优化肯定会引入不希望的行为-但至少应仅在您依赖未指定的行为(或确实对正确指定的行为做出错误假设)时才发生。
现在,除此之外,编译器当然可以包含错误。其中一些可能是周围的优化,以及影响可能是非常微妙的-事实上他们可能是,如明显的错误是更容易被固定。
假设您将JIT作为编译器,我已经看到.NET JIT和Hotspot JVM的发行版中的错误(不幸的是,目前我没有详细信息),这些错误在特别奇怪的情况下是可以重现的。我不知道它们是否是由于特定的优化。
我从未听说过或使用过编译器,它们的指令无法更改程序的行为。通常这是一件好事,但确实需要您阅读手册。
而且最近我遇到了一个编译器指令“删除”了一个错误的情况。当然,该错误确实仍然存在,但是在我正确修复程序之前,我有一个临时解决方法。
是。一个很好的例子是经过仔细检查的锁定模式。在C ++中,没有办法安全地执行双重检查锁定,因为编译器可以按在单线程系统中有意义的方式对指令进行重新排序,而在多线程系统中则不然。可以在http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf上找到完整的讨论。
std::atomic
。C ++ 11之前的无锁原子和无所不能的锁始终高度依赖于关于编译器如何工作(并且经常需要inline-asm)和滥用的许多假设volatile
。
我在使用较新的编译器构建旧代码时遇到了几次。旧代码可以工作,但在某些情况下会依赖未定义的行为,例如定义不正确/强制转换运算符重载。它可以在VS2003或VS2005调试版本中运行,但在发行时它将崩溃。
打开生成的程序集,很明显,编译器刚刚删除了所讨论功能的80%功能。重写代码以不使用未定义的行为将其清除。
更明显的例子:VS2008 vs GCC
声明:
Function foo( const type & tp );
称为:
foo( foo2() );
其中foo2()
返回类对象type
;
倾向于在GCC中崩溃,因为在这种情况下,对象没有分配在堆栈上,但是VS做了一些优化来解决这个问题,它可能会起作用。
混叠会导致某些优化出现问题,这就是为什么编译器可以选择禁用这些优化。从维基百科:
为了以可预测的方式实现此类优化,C编程语言(包括其较新的C99版本)的ISO标准规定,不同类型的指针引用同一内存位置是非法的(有一些例外)。该规则称为“严格混叠”,它可以显着提高性能[需要引用],但已知它会破坏一些其他有效的代码。一些软件项目有意违反C99标准的这一部分。例如,Python 2.x这样做是为了实现引用计数[1],并且需要对Python 3中的基本对象结构进行更改以实现这种优化。Linux内核之所以这样做,是因为严格的别名会导致优化内联代码。[2] 在这种情况下,使用gcc进行编译时,
是的,编译器优化可能很危险。因此,硬实时软件项目通常禁止优化。无论如何,您知道没有错误的任何软件吗?
积极的优化可能会缓存您的变量,甚至可能做出奇怪的假设。问题不仅在于代码的稳定性,还在于它们可能使调试器蒙骗。我见过几次调试器无法表示内存内容,因为某些优化在微控制器的寄存器中保留了一个变量值
您的代码可能发生同样的事情。优化将变量放入寄存器中,直到完成后才写入变量。现在想象一下,如果您的代码在堆栈中具有指向变量的指针并且具有多个线程,那么情况会是多么不同
从理论上讲,这是肯定的。但是,如果您不信任工具可以执行应做的事情,为什么要使用它们呢?但是马上,有人在争论
“编译器是由聪明的人建立的,可以做聪明的事情”,因此永远不会出错。
愚蠢的争论。
因此,除非您有理由相信编译器正在这样做,否则为什么要摆姿势呢?
我当然同意这是愚蠢的,因为编译器是由“聪明的人”编写的,因此它们是绝对可靠的。聪明人也设计了兴登堡和塔科马海峡大桥。即使确实是编译器编写器是在那里最聪明的程序员之一,也确实是编译器是那里最复杂的程序之一。当然,他们有错误。
另一方面,经验告诉我们,商业编译器的可靠性很高。我有很多次有人告诉我,程序无法正常工作的原因一定是由于编译器中的错误,因为他已经非常仔细地检查了程序,并且他确定程序是100%正确的...然后我们发现实际上程序有错误,而不是编译器。我试图考虑的是我个人遇到的事情,我确实确定这是编译器中的错误,我只能想起一个例子。
因此,通常来说:信任您的编译器。但是他们曾经做错吗?当然。
这有可能发生。它甚至影响了Linux。
我在.NET 3.5中遇到了问题,如果您进行优化构建,则将另一个变量添加到方法中,该方法的命名与在相同范围内相同类型的现有变量的名称类似,那么这两个变量(新变量或旧变量)将不会在运行时有效,并且对无效变量的所有引用都替换为对另一个变量的引用。
因此,例如,如果我具有MyCustomClass类型的abcd且我具有MyCustomClass类型的abdc并设置了abcd.a = 5和abdc.a = 7,则这两个变量将具有属性a = 7。为了解决这个问题,应该删除两个变量,编译程序(希望没有错误),然后重新添加它们。
我想在做Silverlight应用程序时,.NET 4.0和C#也遇到了这个问题。在我的上一份工作中,我们经常在C ++中遇到这个问题。可能是因为编译花费了15分钟的时间,所以我们只会构建所需的库,但是有时即使添加了新代码并且未报告任何构建错误,优化后的代码也与以前的构建完全相同。
是的,代码优化器是由聪明的人构建的。它们也非常复杂,因此有错误很常见。我建议全面测试大型产品的任何优化版本。通常,有限使用的产品不值得完整发布,但仍应进行一般测试以确保其正确执行其常见任务。
编译器优化可以揭示(或激活)代码中的休眠(或隐藏)错误。您的C ++代码中可能有一个您不知道的错误,只是您没有看到它。在那种情况下,这是一个隐藏或休眠的错误,因为该代码的分支没有执行[足够的次数]。
与编译器代码中的错误相比,您的代码中的错误的可能性要大得多(数千倍):因为对编译器进行了广泛的测试。由TDD加上几乎所有自发布以来使用过的人!因此,您几乎不可能发现错误,而几乎没有其他人使用它数十万次才发现错误。
一个休眠的错误或隐藏的错误就是这样不透露自己的程序员又一个错误。可以声称自己的C ++代码没有(隐藏)错误的人非常罕见。它需要C ++知识(很少有人可以声称这一点)和广泛的代码测试。它不仅与程序员有关,还与代码本身(开发风格)有关。易于出错的是代码(测试的严格程度)或/和程序员(测试的纪律如何以及对C ++和编程的了解程度)的特征。
安全性+并发性错误:如果我们将并发性和安全性包含为错误,那就更糟了。但是毕竟,这些“是”错误。首先,就并发性和安全性而言,编写无缺陷的代码几乎是不可能的。这就是代码中始终存在错误的原因,可以在编译器优化中发现(或忽略)错误。
如果您编译的程序具有良好的测试套件,则可以启用更多且更积极的优化。然后可以运行该套件,并可以更确定地确保程序正常运行。另外,您可以准备自己的测试,这些测试与计划在生产中进行的匹配非常接近。
的确,任何大型程序都可能(实际上可能确实)有一些错误,这些错误与您使用哪个开关进行编译无关。
我从事大型工程应用程序的工作,时不时地看到仅发行崩溃和客户报告的其他问题。我们的代码包含37个文件(约6000个文件),该文件位于文件顶部,以关闭优化以修复此类崩溃:
#pragma optimize( "", off)
(我们使用的是Microsoft Visual C ++ native,2015,但几乎对于任何编译器都是如此,但也许英特尔Fortran 2016 update 2尚未进行任何优化。)
如果您通过Microsoft Visual Studio反馈网站进行搜索,则也可以在其中找到一些优化错误。有时我们会记录一些日志(如果您可以用一小段代码就可以很容易地重现它,并且您愿意花时间),它们确实会得到修复,但是遗憾的是会再次引入它们。微笑
编译器是人们编写的程序,任何大程序都有错误,请相信我。编译器优化选项肯定有错误,打开优化肯定会在程序中引入错误。
codegen bug optimization
打开了示例。当然,编译器也存在未启用优化的错误,因此不,优化不是编译器中唯一无缺陷的功能。:)