是时候严肃对待利用未定义行为这件事了

原文地址:http://blog.regehr.org/archives/761

作者:John Regehr

[注:我答应,暂时来说,这是应该是我最后一篇关于未定义行为的博文。更多后续有来。]

当前的C与C++编译器将利用未定义行为来生成高效代码(大量的例子在这里这里),但不一致或者不好。是时候让我们严肃对待这个问题了。在本文,我将证明利用未定义行为可以显著提升代码速度,同时100%符合语言标准。

因为未定义行为很难讨论,我需要铺垫一些基础。

理解一个C或C++程序

一个C或C++程序的含义由在标准中描述的一个“抽象机器”确定。你可以将这些抽象机器想象为尽可能简单的C与C++的非优化解释器。当这些抽象机器运行一个程序时,它作为一系列步骤进行,每一步由标准陈述或暗示。我们称这一系列步骤为一次执行。通常,一个程序容许多次执行。例如,我们使用gzip压缩Gettysburg Address,C抽象机器将执行数百万步,这些步骤合起来构成gzip一个可能的执行。如果我们再次压缩GettysburgAddress,我们将得到相同的执行(在本文中,我们将忽略这些抽象机器是非确定性的这个事实)。如果我们压缩“我有一个梦想”,那么我们将得到另一次执行。讨论程序的所有可能执行的集合是有用的。当然,这个集合通常有大量或者无穷的成员。

编译器的任务

编译器的任务是发布目标代码,对程序的所有非错误性执行,使之与C/C++抽象机器具有相同的可观察行为。可观察行为就像听起来那样:它涵盖了抽象机器与硬件(通过易变变量)、操作系统等的交互。内部操作,比如递增一个变量或调用malloc()不视为可观察。编译器可以随心所欲生成任何代码,只要可观察行为得到保证。因此,编译器无需翻译程序任何执行都不会触及的代码。这是死代码消除,一个有用的优化。

现在让我们加入未定义行为。每个由C/C++抽象机器执行的执行步骤要么已定义,要么未定义。如果程序的一次执行包含任何未定义步骤,那么执行是不正确的。编译器没有完全义务去遵循不正确的执行。注意“不正确”是一个非黑即白的属性;不存在执行直到某处执行一个具有未定义行为操作之前是正确的情形。(这里,我详细讨论了这个问题)。

作为一个头脑实验,让我们随便拿一个C或C++程序,在main()的开头加一行新代码:

-1<<1;

为了对这个表达式求值,抽象机器必须执行一个(在C99,C11及C++11方言里)具有未定义行为的操作。该程序的每次执行必须对这个表达式求值。因此,该程序确认没有正确的执行。换而言之,编译器没有义务。对编译器而言,一个高效活动过程将是报告成功,但不生成任何目标代码。

如果我们将未定义执行放在程序末尾,main()正要返回前,会怎样?假定程序没有包含对exit()或类似函数的调用,这具有相同的效果——编译器报告成功,但不生成任何代码、退出。

真实代码具有大量不正确执行吗?

让我们考虑SPEC基准程序。使它们跑得快是大多数编译器提供商梦寐以求的。使用IOC,我们发现SPEC CINT2006中的大部分程序在一个“可报告的”运行期间,执行了未定义行为。这意味着一个正确的编译器可以产生几乎绝对比当前编译器生成代码要快的代码。当然,SPEC条条框框会抱怨编译后的程序产生了错误的结果,但这实际上是SPEC条条框框里的bug,而不是编译器里的bug。如果我们坚持编译器应该将这些SPEC程序翻译为产生正确结果的执行代码,那么我们实际上授权这个编译器实现一个奇异的、未公开的C方言,它有时会利用未定义行为,有时不会。

如果我们将LLVM/Clang3.1及GCC(2012年7月17日后的SVN)视为以C99编写,它们运行在x86-64上的Linux上,看起来没有包含任何非平凡、没有错误的执行。我预期在这些编译器之前所有或大多数版本上,也是这样。IOC告诉我们,这些编译器执行未定义行为,即使关闭优化编译一个空的C或C++程序。这意味着一个合适的、聪明的优化编译器可以为Clang及GCC创建的执行文件,比我们日常使用的要快得多。想象使用这样一个编译器,你可以多快构建一个Linux内核。

我猜GCC及LLVM(基本上所有其他大型程序)的大多数或所有的执行,对C89也是未定义的(不只是C99),但对C89,不使用复杂的未定义行为检测器,我们无法确认。这个工具不存在。我承认我个人没有勇气将目前最好的动态未定义行为检测器:KCCFrama-C塞进GCC或LLVM。

优化编译器应该如何工作

编译器完全胜任推理被编译程序所有可能的执行。对此,它们使用静态分析,避免使用近似带来的可判定(decidability)问题。换而言之,检测死代码可能是完备的,只要我们接受有时存在我们不能静态检测的死代码。得到的优化,死代码消除,在优化编译器中普遍存在。因此我们如何能通过静态分析进取地利用未定义行为?

步骤1:使未定义行为在IR中明晰

LLVM已经有一个受限形式的未定义对象:一个undef值。这是有用的,但它应该扩充以一条代表未定义行为无条件执行的undef指令。

[更新:OwenAnderson在注释里指出,LLVM的unreachable指令已经用于这个目的!酷。另外,undef值具有比C/C++未定义行为更强的语义,因此在我下面提到的undef到unreachable提升奏效前,要么需要调整其含义,要么添加一个新的值。]

步骤2:将数据流事实转换为未定义行为

编译器已经将未定义行为转换为数据流事实。例如,在Linux内核著名的例子里,GCC使用一个指针解引用来推断一个非空指针。这完全正确,尽管存在大量的实例,编译器应该更积极一些;Pascal有涉及restrict的一个很好的例子

另一方面,在使用数据事实积极地推断未定义行为上,进展则要逊色。例如:

·        如果程序对x+y求值,而且我们知道x与y都大于11亿(且两者都是32位整数),这个加法可以被一个undef值替换。

·        如果程序对(*x)++& (*y)++求值,而指针分析显示x与y互为别名,再次,该表达式可以翻译为一个undef值。

·        任何无条件对一个包含undef值的表达式求值的语句都可以翻译为一条undef指令。

C99中大约有191种未定义行为,因此查找它们的一整组优化遍是相当大的工作量。

步骤3:裁剪未定义的执行

现在,我们希望删除仅错误执行所需的任何代码。这将在优化编译器已经实现的支配者分析的基础上构建。一段代码X支配Y,如果你仅能通过首先执行X,才能达到Y。所有被一条undef指令支配的代码可以被删除。不过,存在称为后支配者(post dominator)的反向支配者分析,其中当通过X的所有执行随后也必定通过Y时,X后支配Y。使用这个分析的结果,我们可以安全地删除任何后支配undef的代码。

当链接时刻,在过程间使用时,基于支配者的未定义行为优化将特别有效。例如,如果我编写了一个无条件使用一个未初始化变量的函数,过程内分析将把该函数化简为单条undef指令,不过过程间优化很可能消灭多得多的代码:任何导致这个函数调用,或从该调用离开的代码,在整个程序范围内。想象在一个广泛部署libc版本里,如果一个常用C库函数展示出未定义行为,比如malloc(),会发生什么:系统上几乎所有的C程序都将消失。一个真正的Linux发布在放在几张软盘上,就像以前那样。

整个蓝图有点像这样:


结论

让我来澄清一下:编译器已经尝试避免生成对应错误执行的代码。它们只是没有做得很好。我建议的优化只是程度不同,类型一致。

可能我需要一个学生来实现更积极的优化,作为概念的证明。我们将让每个人印象深刻:2秒构建Linux内核,3GCC引导程序,以及Firefox减小99.9%


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值