今天我和我的一个朋友讨论了几个小时关于“编译器优化”的问题。
我辩称,有时编译器优化可能会引入错误,或者至少会引入不希望的行为。
我的朋友完全不同意,说“编译器是由聪明的人构建的,做聪明的事情”,因此永远不会出错。
他根本没有说服我,但我不得不承认我缺乏现实生活中的例子来加强我的观点。
谁在这里?如果我是,您是否有任何现实生活中的示例,其中编译器优化会在生成的软件中产生错误?如果我弄错了,我应该停止编程并学习钓鱼吗?
今天我和我的一个朋友讨论了几个小时关于“编译器优化”的问题。
我辩称,有时编译器优化可能会引入错误,或者至少会引入不希望的行为。
我的朋友完全不同意,说“编译器是由聪明的人构建的,做聪明的事情”,因此永远不会出错。
他根本没有说服我,但我不得不承认我缺乏现实生活中的例子来加强我的观点。
谁在这里?如果我是,您是否有任何现实生活中的示例,其中编译器优化会在生成的软件中产生错误?如果我弄错了,我应该停止编程并学习钓鱼吗?
编译器优化可能会引入错误或不良行为。这就是为什么您可以关闭它们。
一个例子:编译器可以优化对内存位置的读/写访问,例如消除重复读取或重复写入,或重新排序某些操作。如果有问题的内存位置仅由单个线程使用并且实际上是内存,那可能没问题。但如果内存位置是硬件设备 IO 寄存器,那么重新排序或消除写入可能是完全错误的。在这种情况下,您通常必须在知道编译器可能“优化”它的情况下编写代码,从而知道幼稚的方法不起作用。
更新:正如亚当罗宾逊在评论中指出的那样,我上面描述的场景更多的是编程错误而不是优化器错误。但我试图说明的一点是,一些在其他方面是正确的程序与一些原本可以正常工作的优化相结合,当它们组合在一起时可能会在程序中引入错误。在某些情况下,语言规范说“你必须这样做,因为这些类型的优化可能会发生并且你的程序会失败”,在这种情况下,这是代码中的错误。但有时编译器有一个(通常是可选的)优化特性会生成不正确的代码,因为编译器过于努力地优化代码或无法检测到优化不合适。
另一个例子:Linux 内核有一个错误,在该指针为空的测试之前,一个潜在的 NULL 指针被取消引用。但是,在某些情况下,可以将内存映射到地址零,从而允许取消引用成功。编译器在注意到指针被取消引用后,假定它不能为 NULL,然后删除了 NULL 测试和该分支中的所有代码。这在代码中引入了一个安全漏洞,因为该函数将继续使用包含攻击者提供的数据的无效指针。对于指针合法为空且内存未映射到地址零的情况,内核仍会像以前一样OOPS。因此,在优化之前,代码包含一个错误;在它包含两个之后,其中一个允许本地 root 漏洞利用。
CERT 有一个由 Robert C. Seacord 撰写的名为“Dangerous Optimizations and the Loss of Causality”的演示文稿,其中列出了许多在程序中引入(或暴露)错误的优化。它讨论了可能的各种优化,从“做硬件所做的事情”到“捕获所有可能的未定义行为”到“做任何不被允许的事情”。
在积极优化的编译器得到它之前,一些代码示例非常好:
检查溢出
// 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 语言规范是开发人员和编译器之间的合同,但协议的条款会随着时间的推移而变化,并不是每个人都了解每一个细节,或者同意细节甚至是明智的。
这就是为什么大多数编译器提供标志来关闭(或打开)优化。你的程序是在理解整数可能溢出的情况下编写的吗?然后你应该关闭溢出优化,因为它们会引入错误。您的程序是否严格避免使用别名指针?然后您可以打开假设指针永远不会别名的优化。您的程序是否尝试清除内存以避免泄漏信息?哦,在那种情况下,你运气不好:你要么需要关闭死代码删除,要么你需要提前知道你的编译器将删除你的“死”代码,并使用一些工作-围绕它。
当一个错误通过禁用优化而消失时,大多数时候它仍然是你的错
我负责一个商业应用程序,主要用 C++ 编写——从 VC5 开始,早期移植到 VC6,现在成功移植到 VC2008。在过去的 10 年里,它增长到超过 100 万行。
在那段时间里,我可以确认在启用积极优化时发生了单个代码生成错误。
那我为什么要抱怨?因为同时有几十个bug让我对编译器产生了怀疑——但结果证明是我对C++标准的理解不够。该标准为编译器可能会或可能不会使用的优化留出了空间。
多年来,在不同的论坛上,我看到很多帖子指责编译器,最终证明是原始代码中的错误。毫无疑问,它们中的许多都掩盖了需要详细了解标准中使用的概念的错误,但仍然存在源代码错误。
为什么我这么晚才回复:在你确认它实际上是编译器的错之前停止指责编译器。
编译器(和运行时)优化肯定会引入不希望的行为 - 但至少只有当您依赖未指定的行为(或确实对明确指定的行为做出错误假设)时才会发生这种情况。
现在除此之外,当然编译器中可能存在错误。其中一些可能与优化有关,其影响可能非常微妙 - 事实上它们很可能是,因为明显的错误更有可能被修复。
假设您将 JIT 作为编译器包括在内,我已经在 .NET JIT 和 Hotspot JVM 的已发布版本中看到了错误(不幸的是,我目前没有详细信息),这些错误在特别奇怪的情况下可以重现。我不知道它们是否是由于特定的优化。
合并其他帖子:
像大多数软件一样,编译器偶尔会在代码中出现错误。“聪明人”的说法与此完全无关,因为 NASA 卫星和其他由聪明人构建的应用程序也存在错误。进行优化的编码与不进行优化的编码不同,因此如果错误恰好在优化器中,那么确实您的优化代码可能包含错误,而您的非优化代码则不会。
正如 Shiny 和 New 先生所指出的那样,对于并发和/或时序问题很天真的代码可能在没有优化的情况下运行令人满意,但在优化时失败,因为这可能会改变执行的时间。您可以将这样的问题归咎于源代码,但如果它只会在优化时表现出来,那么有些人可能会归咎于优化。
我从未听说过或使用过指令不能改变程序行为的编译器。通常这是一件好事,但它确实需要您阅读手册。
而且我最近遇到了一个编译器指令“删除”错误的情况。当然,错误确实仍然存在,但我有一个临时解决方法,直到我正确修复程序。
是的。一个很好的例子是双重检查锁定模式。在 C++ 中,没有办法安全地实现双重检查锁定,因为编译器可以以在单线程系统中有意义但在多线程系统中没有意义的方式重新排序指令。完整的讨论可以在http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf找到
有可能吗?不在主要产品中,但肯定有可能。编译器优化是生成代码;无论代码来自哪里(您编写它或生成它),它都可能包含错误。
我用新的编译器构建旧代码时遇到过几次。旧代码可以工作,但在某些情况下依赖于未定义的行为,例如未正确定义/强制转换运算符重载。它可以在 VS2003 或 VS2005 调试版本中工作,但在发布时它会崩溃。
打开生成的程序集,很明显编译器刚刚删除了相关函数的 80% 功能。重写代码以不使用未定义的行为将其清除。
更明显的例子:VS2008 vs GCC
宣布:
Function foo( const type & tp );
称为:
foo( foo2() );
wherefoo2()
返回一个类的对象type
;
在 GCC 中往往会崩溃,因为在这种情况下对象没有在堆栈上分配,但是 VS 做了一些优化来解决这个问题,它可能会起作用。
别名可能会导致某些优化出现问题,这就是编译器可以选择禁用这些优化的原因。来自维基百科:
为了以可预测的方式启用此类优化,C 编程语言(包括其较新的 C99 版本)的 ISO 标准规定,不同类型的指针引用相同的内存位置是非法的(有一些例外)。这条规则被称为“严格别名”,可以显着提高性能[需要引用],但众所周知会破坏一些其他有效的代码。一些软件项目故意违反了 C99 标准的这一部分。例如,Python 2.x 这样做是为了实现引用计数,[1] 并且需要对 Python 3 中的基本对象结构进行更改以启用此优化。Linux 内核这样做是因为严格的别名会导致内联代码优化出现问题。 [2] 在这种情况下,当使用 gcc 编译时,
是的,编译器优化可能很危险。由于这个原因,通常硬实时软件项目禁止优化。无论如何,你知道任何没有错误的软件吗?
激进的优化可能会缓存甚至对您的变量进行奇怪的假设。问题不仅在于代码的稳定性,还在于它们可以欺骗您的调试器。我已经多次看到调试器无法表示内存内容,因为一些优化在微的寄存器中保留了一个变量值
同样的事情也可能发生在您的代码上。优化将一个变量放入寄存器,并且在完成之前不要写入该变量。现在想象一下,如果您的代码有指向堆栈中变量的指针并且它有多个线程,那么事情会有多不同
理论上是可以的,当然。但是,如果您不相信这些工具可以完成它们应该做的事情,那么为什么要使用它们呢?但马上,任何从
“编译器是由聪明的人构建并做聪明的事情”,因此永远不会出错。
正在做一个愚蠢的论点。
所以,在你有理由相信编译器正在这样做之前,为什么要摆出姿势呢?
这有可能发生。它甚至影响了Linux。
我当然同意说因为编译器是由“聪明人”编写的,所以说它们是无误的是愚蠢的。聪明人也设计了兴登堡和塔科马海峡大桥。即使编译器编写者确实是最聪明的程序员之一,编译器也是最复杂的程序之一。当然,他们有错误。
另一方面,经验告诉我们,商业编译器的可靠性非常高。我有很多次有人告诉我程序无法运行的原因一定是因为编译器中的错误,因为他已经非常仔细地检查过它并且确信它是 100% 正确的......然后我们发现实际上程序有错误而不是编译器。我试图回想我个人遇到的一些事情,我确实确定这是编译器中的一个错误,我只能回忆一个例子。
所以总的来说:相信你的编译器。但他们有错吗?当然。
我记得,早期的 Delphi 1 有一个错误,即 Min 和 Max 的结果是相反的。仅当在 dll 中使用浮点值时,还有一些浮点值的模糊错误。诚然,已经十多年了,所以我的记忆可能有点模糊。
如果您使用优化构建,我在 .NET 3.5 中遇到问题,将另一个变量添加到方法中,该方法的名称类似于同一范围内相同类型的现有变量,然后两者之一(新变量或旧变量)将不会在运行时有效,并且对无效变量的所有引用都替换为对另一个变量的引用。
因此,例如,如果我有 MyCustomClass 类型的 abcd 和 MyCustomClass 类型的 abdc 并且我设置 abcd.a=5 和 abdc.a=7 那么这两个变量都将具有属性 a=7。要解决这个问题,应该删除两个变量,编译程序(希望没有错误),然后重新添加它们。
我想我在使用 .NET 4.0 和 C# 时也遇到过几次 Silverlight 应用程序的问题。在我的上一份工作中,我们经常在 C++ 中遇到这个问题。可能是因为编译需要 15 分钟,所以我们只构建我们需要的库,但有时优化的代码与之前的构建完全相同,即使添加了新代码并且没有报告构建错误。
是的,代码优化器是由聪明人构建的。它们也非常复杂,因此存在错误很常见。我建议全面测试大型产品的任何优化版本。通常使用受限的产品不值得完整发布,但仍应进行一般测试以确保它们正确执行其常见任务。
编译器优化可以揭示(或激活)代码中的休眠(或隐藏)错误。您的 C++ 代码中可能存在您不知道的错误,您只是看不到它。在这种情况下,它是一个隐藏的或休眠的错误,因为该代码分支没有被执行[足够的次数]。
代码中出现错误的可能性比编译器代码中的错误要大得多(数千倍):因为编译器经过了广泛的测试。由 TDD 加上几乎所有自发布以来使用它们的人!)。因此,一个错误几乎不可能被您发现,而实际上它被其他人使用了数十万次而不被发现。
休眠的错误或隐藏的错误只是尚未向程序员透露的错误。可以声称他们的 C++ 代码没有(隐藏)错误的人非常少见。它需要 C++ 知识(很少有人可以声称)和对代码的广泛测试。它不仅与程序员有关,还与代码本身(开发风格)有关。容易出错的原因在于代码的特性(测试的严格程度)或/和程序员(测试的纪律性以及对 C++ 和编程的了解程度)。
安全性+并发性错误:如果我们将并发性和安全性作为错误包括在内,情况会更糟。但毕竟,这些“是”错误。编写一个首先在并发性和安全性方面没有错误的代码几乎是不可能的。这就是为什么代码中总是已经存在错误,可以在编译器优化中发现(或忘记)。
如果您编译的程序具有良好的测试套件,则可以启用更多、更积极的优化。然后可以运行该套件并更加确定该程序是否正确运行。此外,您可以准备自己的测试,这些测试与您计划在生产中进行的非常匹配。
同样真实的是,任何大型程序都可能有(并且可能确实有)一些错误,这些错误独立于您使用哪些开关来编译它。
我在一个大型工程应用程序上工作,我们时不时地看到发布时只有崩溃和客户报告的其他问题。我们的代码有 37 个文件(大约 6000 个),我们在文件的顶部有这个,以关闭优化以修复此类崩溃:
#pragma optimize( "", off)
(我们使用 Microsoft Visual C++ native, 2015,但对于几乎所有编译器都是如此,除了英特尔 Fortran 2016 更新 2,我们尚未启用任何优化。)
如果您搜索 Microsoft Visual Studio 反馈站点,您也可以在那里找到一些优化错误。我们偶尔会记录我们的一些(如果您可以使用一小段代码轻松地复制它并且您愿意花时间)并且它们确实得到了修复,但遗憾的是其他人又被引入了。微笑
编译器是人写的程序,任何大程序都有错误,相信我。编译器优化选项肯定有错误,打开优化肯定会在您的程序中引入错误。
您可以想象的对程序所做的任何事情都会引入错误。
由于详尽的测试和实际 C++ 代码的相对简单性(C++ 有不到 100 个关键字/运算符)编译器错误相对较少。糟糕的编程风格往往是唯一遇到的问题。通常编译器会崩溃或产生内部编译器错误。此规则的唯一例外是 GCC。GCC,尤其是旧版本,在O3
甚至其他 O 级别中启用了许多实验性优化。GCC 还针对如此多的后端,这为它们的中间表示中的错误留出了更多空间。
昨天我遇到了 .net 4 的问题,看起来像......
double x=0.4;
if(x<0.5) { below5(); } else { above5(); }
它会调用above5();
但如果我真的在x
某个地方使用,它会调用below5();
double x=0.4;
if(x<0.5) { below5(); } else { System.Console.Write(x); above5(); }
不是完全相同的代码,但相似。