指令选择器调查(1)

原作者:gabriel hjort blindell(Sweden)

1. 介绍

编译器是计算机科学最古老且研究得最彻底的问题之一;每项使用某个编程语言来实现一个软件的任务,而不是使用目标机器的汇编语言,都使得编译器成为必要(编译器的简介参考附录A)。把高级的源代码翻译为目标机器特定的代码,作为编译器的核心使命,需要处理范围广泛的中间问题(intermediate problems)——其中之一是指令选择。

指令选择(也称为代码选择,有时甚至称为代码生成)是编译器代码生成器后端所涉及的三个优化问题之一。另两个是指令调度与寄存器分配,本报告不准备讨论它们。指令选择器负责通过尽可能用好可用的机器指令,将程序从目标机器无关的表示翻译到一个目标机器特定的形式。这使得两个正交的子问题必须得到解决:

1.     检测何时及何地使用某条机器指令是可能的,并且

2.     在存在多个选项时,决定选择哪条指令。

因此指令选择器需要正确且高效。换而言之,转换必须保持程序的行为,而且如果目标CPU提供了特殊的指令,能产生与许多更小指令组成的序列相同的结果,那么它应该选择前者——这不仅导致更小的代码,而且使用单条指令通常产生更快的代码。因此能够使用这样的指令,对于代码效率是至关重要的。对于嵌入式系统这尤为正确,不能正确使用复杂指令和专用的处理芯片,比如DSP(数字信号处理器),可以降低性能达1,000%【157】。

所以自1960后期,指令选择器一直在积极探索中,且产生了丰硕的成果。自早年的临时措施以来——整个指令选择器是手写的,且只对准一个特定的CPU—— 这个领域已经发展到形式化方法——由编译器生成器从一个描述目标机器的说明中自动产生指令选择器。另外,这些自动生成的指令选择器在效率,处理复杂程序输入,以及机器指令方面变得更为强大。

之前Cattell【41】,Ganapathi等【108】,Boulytchev与Lomov【29】完成了在不同程度上讨论指令选择器的调查。不过,最新广泛的调查是30多年前发表的,这就需要新的涵盖了在其发表后所进行研究的概览。另外,经典的编译器教科书仅简短地讨论指令选择,很少提供对此的见解[1]。通过详细最新的回顾及对现有研究分类,本报告满足了这个需求。因此它超出并扩展了之前的调查。

报告的余下部分组织如下。第二章到第六章,每章讨论指令选择的一个基本方法。第二章介绍宏展开。第三章讨论树覆盖,在第四、第五章则扩展为DAG与图覆盖。最后的方法——模拟,在第六章描述,并以第七章的结论结束。报告还包含几个附录。附录A给出了编译器的一个简介,而附录B提供了关于图的一组正式的定义。最后,附录C包含了出版物的大事年表,以指令选择各个基本方法为分类。

1.1.         代码生成的一开始

对于一个给定的输入程序,如果不关心选择最好、最短、或甚至适当的机器指令的组合,那么对于大多数机器,指令选择就相当的直截了当。在最简单的形式里,指令选择器可以把每个操作或编程语言结构展开为一条或多条机器指令。这可能,而且极可能,不必要地产生大且低效的代码——但至少代码是正确的。相比之下,寄存器分配是更具挑战的任务,因为相比于一个程序中的变量数目,CPU寄存器的数量通常非常有限。因为这个问题还与指令调度紧密联系,因此对于给定的程序,找出一个正确的寄存器分配很困难,特别是对于曾经常见且广泛使用的基于累加器的机器,因为这样的机器仅要求一个寄存器。

结果,当1960早期第一批关于代码生成的论文出现时,它们主要涉及如何通过累加器寄存器计算算术表达式【13,93,176,194】。在1970年,通过展示在n个寄存器机器上产生公共子表达式求值优化代码的一个算法,Sethi与Ullman【207】扩展了这些思想。随后在1976年,Aho与Johnson【3】扩展这项工作,他们应用动态规划开发一个处理更复杂取址模式,比如间接取址,的代码生成算法。(在后面我们会回到这个方法,因为该工作的影响了后续许多指令选择的做法)。

不过,这些早期的做法或多或少地忽略了指令选择,或通过假定目标机器展示出纯粹、精确的属性,且缺乏任何异乎寻常的机器指令及多个寄存器类别,来绕过这个问题。因为即使有,也极少机器具有这样的特性,这些算法不能直接应用在现实生活中。后来,Ripken【195】扩展了Aho与Johnson的算法来处理带有多个寄存器类别及取址模式的真实指令,但他从未验证过他的想法。很可能,一个直接的实现会很慢,因为这个方法的运行有组合式暴涨的风险。

缺少形式化的做法,第一批指令选择器通常基于手写、即兴的算法。不过,这意味着在效率与重定向目标能力间的权衡: 如果算法做得太通用,生成的代码可能表现不佳;如果剪裁得过于贴近特定的机器,会限制编译器对其他目标机器的支持。因此重定向这样的指令选择器涉及手工改动以及重写算法。对于非常规的架构,指令选择器甚至可能完全不能重用。

不过即使指令选择器的构筑是要便利于重定向目标机器,仍然需要一些方式来提供这个壮举。在下一章我们将讨论进攻这个问题的第一个方法,在后续章节我们将看到尝试同时最大化效率与重定向能力的方法。



[1]在构成编译器书籍【8,15,56,90,159,173,238】超过4,600的页面中,关于指令选择的不超过160页——其中有大量的重复,基本上仅讨论树覆盖。

1.      宏展开[1]

改善编译器重指向能力的首批尝试始于1960年代后期,出现了过程化的宏展开指令选择器。在这样的设计中,通过在组成输入程序的字符串上进行模板匹配来驱动指令选择器(参考图2.1)。在命中时,使用匹配部分作为输入,执行相应的宏代码。通常每条机器指令有自己的宏定义,在调用时会采取合适的行动并流出汇编代码。一旦处理了整个输入程序,指令选择器结束运行,或者如果某部分不能匹配任何模板,报告一个错误。因为宏是独立于指令选择器实现的,对比之前通常要求改变整个代码生成器的整体式、专有的做法,这减少了重指向的工作。

 

 图2.1:宏展开的例子。展开的作用域由虚线及灰色背景来表示

宏可以手写,就像Ammann等【11,12】等开发的Pascal编译器,或从某些属性语言自动产生。因此,这些年以来出现了(也消失了)许多以代码生成为目标的语言与工具。一个例子是Simcmp,一个由Orgass与Waite【182】开发的辅助自举的宏处理器[2]。宏处理器一行行读入输入,把行与宏模板比较,执行第一个匹配的宏。图2.2给出了一个例子。一个类似的例子是GCL(通用编码语言,Generate Coding Language),由Elson与Rake开发的一门语言【74】,它用在一个PL/1编译器中从语法树生成机器代码。

           

图2.2:使用SIMCMP的语言翻译例子(来自【182】)

不过,直接在语法树上执行指令选择,因为后端与前端紧密相连,极大地限制了对编译其他编程语言的支持。另外,调用宏的次序直接依赖于输入程序的文本布局。因此编译器的基础构造通常依赖于某些把优化例程及后端与前端细节隔离的机器无关的IR(中间表示)。事实上,早在1950年代后期就意识到了这个需要【54,215】。

Wilcox开发【237】了这样的一个代码生成系统,作为一个PL/C编译器实现的部分。该编译器首先把AST(抽象语法树)翻译为SLM(源语言机器)指令,然后通过宏展开映射到等效的机器代码。SLM指令基本上是一个汇编代码形式,使用一个称为ICL(解释性编程语言,Interpretive Coding Language;例子参考图2.3)的语言来实现宏。

 

图2.3:ICL中二元加法宏

不过在实践中,编写ICL宏被证明是一个枯燥、困难的过程,因为许多细节,比如管理取址模式与数据地址,必须由实现者处理。而且,实现者必须记录哪些变量是程序的部分及哪些变量仅用于辅助代码生成。

在简化这个任务的一次尝试中,Young【244】建议(但从未实现)一个称为TEL(TEmplate Language)的更高级语言,它为设计者抽象出某些面向实现的细节。该思想是处理TEL说明,然后从该说明自动生成低级ICL宏。

1.1.         分离宏与机器描述

尽管对比专有的方法,宏展开简化了编译器的重指向,通常编写宏仍然是枯燥、困难的任务。另外,目标机器的细节被隐含地嵌入宏里。在1971年发表的硕士论文里,Miller【172】通过开发一个自动推导内存与不同寄存器类别间必要的数据过渡的算法,在文献中第一次尝试论述这个问题。这允许显式声明目标机器的信息,并与指令选择器的实现分离。

Miller开发了一个称为Dmacs(描述性宏系统)的自动代码生成系统,它包含两个语言:MIML(机器无关的机器语言),用作一个过程化、双地址的IR;OMML(目标机器宏语言),用于描述宏的描述性语言(参考图2.4)。该系统遵循典型的结构,本身并无新意。其主要贡献在于允许使用者声明每条指令输入与输出数据的许可位置(参考图2.5),并把必须的数据转换留给系统推导。宏语言也提供了把合适的宏标记为符合交换律的能力,这进一步减少了实现的工作,因为系统也会推导对偶宏的实现。不过,该系统的功能也非常有限:Dmacs仅能处理包含整数及浮点值的算术表达式;支持有限的简单取址模式;且仅能模仿某类机器。

图2.4:MIML例子(来自【172】)

           

图2.5:以OMML声明的一个ADD指令(来自【172】)

Donegan【68】尝试通过开发一个建立在有限状态机模型基础上的代码生成系统,来推广Miller的工作。随着解析树的遍历,代码生成进入各种状态,并基于当前状态流出代码。状态与行为由CGPL(代码生成器预处理器语言)描述,然后它被转换为一个可执行程序。Tirrell【221】与Simoneaux【211】也讨论了类似的思想。不过,状态的使用主要是方便寄存器分配,因为这些状态表示在输入程序的当前点哪些寄存器被占据了。

Snyder【212】通过实现一个用于C语言的完整、可移植的编译器,也扩展了Miller的概念。致力于尽量减小重指向的工作,Snyder提供了一个机器描述语言,对比OMML,能处理更复杂的数据类型与取址模式、对齐、跳转与函数调用。如果需要,更复杂的宏还可以被定义为C函数。

1.2.         迁移到动态代码生成

在通过宏展开的指令选择的肇始,就提供了比整体、临时的做法明显的优势:快、简单、适度地可重指向,且相对容易实现。不过,这个容易的实现有几个缺点。首先,相当部分宏仍然需要手写。每个宏需要处理操作、取址模式、数据类型等所有的组合加重了这个问题,因此导致了非常大且复杂的宏实现。其次,代码生成语言倾向于与特定的输入语言或目标机器紧密联系,这阻碍了可移植性。第三,归咎于宏展开的本质,生成代码的质量是有限的:因为

·        在宏与目标机器指令间假定存在一个一一映射[3]

·        宏实现者的视野受限于从宏内部得到的信息;宏调用的次序可能影响代码的质量。

因此,在生成静态代码的编译器中,宏展开指令选择器一直流行到1970年代后期,之后它们被更新、更强大的技术所替代。

尽管有局限性,宏展开仍然适用于早期的生成动态代码的编译器。在这些编译器中,在执行输入程序时产生机器代码。编译可以发生在一个函数第一次执行前,或及时产生(JIT),意味着仅程序经常执行的部分得到编译,而余下的以交互模式运行。因此编译的开销必须维持在最小。虽然更强大的代码生成技术开始出现,在这样的系统上它们通常太慢且太昂贵。这使得宏展开成为一个可行的选择,因为这样的指令选择器小、快,产生的代码尽管质量差,性能仍然远超其解释性的同类。从1983年到1999年的几个例子,包括:Deutsch与Schiffman实现的Smalltalk-80【64】;Adl-Tabatabai等的Omniware——一个近似于Java的手机执行平台【1】。Engler的VCODE【77】;及Fraser与Proebsting的GBURG【97】。

不过,随着CPU的能力及内存资源越来越丰富,即使动态代码环境里的编译器也过渡到其他技术,比如树覆盖(在下一章我们将讨论这些做法)。

1.3.         总结

在本章我们讨论了指令选择最早的做法。它们依赖于宏展开的概念,使得它们相对容易实现及重指向,同时产生小、快及资源效率高的指令选择器。另外,如果目标机器在IR节点与机器指令间存在一对一或一对多的关系,它们会产生高效的代码。不过,如果关系是多对一——这是常见的——产生代码的质量通常相当低,因为应用复杂机器指令实现多个IR操作的困难性。在第六章我们将看到如何一起使用宏展开与窥孔优化器来克服这个局限,不过现代编译器很少使用纯粹的基于宏展开的指令选择器,因为它们已经被更新的技术所取代了。

最后,在代码质量不需要最优或很好的情形下,宏展开是一个合理的选择。在普通代码也凑合的系统上,这是适用的(比如在JIT动态生成程序时)。



[1]本章主要基于由Cattell【41】与Ganapathi等【108】完成的更早期调查。在后面,这个做法称为解析性代码生成。

[2]自举是以要编译的编程语言来编写一个编译器的过程。

[3]像Lowry与Medlock所提议的,窥孔优化器可以缓和这个缺陷。不过,它们也有自己的局限性。第六章有更多说明。


评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值