[OS] 高级语言虚拟机的编译优化

1. JIT编译器

JVM中的仿真引擎可以通过许多方式来实现,具有不同的复杂性和性能级别。
最简单的方法是使用对字节码指令的直接解释
一个更高级的并且被普遍使用的仿真方法是执行即时(JIT)编译。

利用JIT编译器,方法在第一被调用时编译,即为了执行而“即时”。
这种编译实质上执行了从字节码指令到本地主机指令的翻译

JIT编译器与二进制翻译器紧密相关,并且可能容易被称为JIT“翻译器”。
实际上,一个称为“编译器”而另一个称为“编译器”的理由似乎是由两个不同组的人起的名字。

JIT编译器与传统编译器的不同在于,它没有解析一个高级语言程序并在将它转化为中间形式之前进行语法检查的前端。
已经通过加载器的字节码程序被认为是正确的。
尽管大多数JIT编译器在进行优化之前会将字节码指令转化为一种不同的中间形式,字节码指令本身实质上仍是一种中间表示。

动态的运行时编译器可以执行大多数(如果不是全部)被经典的静态编译器所执行的优化,
以及其他与Java程序有关的优化。
然而许多优化是消耗时间的,这增加了执行一个Java程序的运行时开销。

因此,JIT编译器会包括多个优化级别,最复杂的优化被应用于频繁执行的方法。
这导致了一种应用在方法层的分阶段优化的形式。
一种更加高效的策略是将优化选择性的只应用于频繁使用的代码区域,而不是可能包含这种区域的整个方法。

典型的高性能仿真引擎从解释开始,并附加上用来定位频繁使用的方法的剖析过程。
然后当一个给定方法达到使用门限时,这个方法用最小的优化来编译。
稍后,依赖于使用的级别,热点方法内被选定的代码段被进一步优化。

2. 剖析

剖析(profiling)是为执行中的程序收集指令和数据的统计信息的过程。
这种统计剖析数据可以作为代码优化过程的输入。

一般的说,由于程序的可预测性,会使基于剖析的优化产生效果。
亦即,对程序的过去行为度量所得到的程序特性常会继续保持在程序的未来行为中,
因此,可以用这些信息来指导优化。

传统上,在软件开发者的控制下,代码剖析已作为向编译过程提供反馈信息的一种途径。
编译器首先将源程序分解成一个控制流图,然后分析这个图以及程序的其他方面,
接着在程序中插入探针(probe),来收集剖析信息。

探针是一段短的代码序列,它将程序的执行信息记录到内存中的一个剖析日志中。

例如,可以在分支指令处安插剖析探针来记录分支的结果。
在编译器生成的代码中,将包含这些插入的探针。
接下来,程序在一个典型的数据输入集上运行,与此同时探针为整个程序生成剖析数据。

然后,对剖析日志进行离线分析,分析结果将反馈回编译器中。
编译器再使用这些信息来生成优化代码。

优化器可以对原始的高级语言程序进行优化,更多的是从编译器生成中间形式或最初编译得到的二进制代码开始进行优化。
在某些情况下,硬件可以通过计数器或者时钟中断来支持剖析收集,其中允许通过软件来收集统计采样信息。

3. 优化

正如大多数其他虚拟机应用那样,在高级语言虚拟机中性能是一个重要的考虑因素。
当处理高级语言虚拟机时有两个挑战。
第一个与其他动态优化虚拟机相同,用改善程序执行时间来弥补运行时优化的开销。
第二个挑战是使面向对象程序快速的执行。

面向对象程序通常包括频繁使用对指令和数据的间接寻址,以及频繁使用小方法(它有较高的方法调用开销)。

3.1 代码重排

当代码重排被应用于高级语言虚拟机的上下文时,它是一个简单且非常有效的优化。
大多数代码重排算法“平整”代码,使得沿着最常走的控制流路径的基本块是在内存中连续的位置上。
其好处是更加高效的取指,这是由于提高了时间和空间的局部性,并且改进了条件分支的可预测性。

代码重排在所有的优化中经常提供一个较高性能的收益。

3.2 方法内联

方法内联也被称为过程内联。
通过内联,一个方法调用被替换为这个方法中的实际代码。
即,方法代码被“内联”放到主调代码中。

方法内联省去了传递参数,管理栈帧以及实际的控制转移等开销,而这可能以较大的二进制程序映像为代价。

面向对象编程往往鼓励使用许多小的方法,因此通过避免所有这种代码,
即与一个方法调用关联的调用序列,经常可以显著的改善性能。
内联的另一个重要的好处是它增大了稍后代码分析和优化可以发生的范围。

内联那些本身代码的大小比方法调用序列还要小的小方法,几乎总是有收益的。
当被内联时,这种小方法不但会更快的执行,而且还会比起初的(没有内联的)代码消耗更少的指令空间,
这使指令cache的行为得到改进(或者至少没有退化)。

对于较大的方法,内联的好处被削弱了,因为调用序列在整个执行时间中占的百分比较小,
而且整个代码尺寸(和指令cache需求)会增长。
如果不选择的应用,内联较大的方法,尤其是那些从许多不同位置被调用的方法,就可能导致代码爆炸(code explosion),
使得cache行为较差而且性能受损。

因此,为了在中等及大的方法上应用内联,需要某种成本效益分析,而成本效益的关系是相当复杂的。
在代码爆炸方面的代价可以相对容易的被消除,但是怎么把这个转化成性能代价是比较复杂的,
通常是由离线实验来得到一个规划函数。

效益主要是方法大小和方法被调用频率的一个函数,
亦即,最经常被调用的方法将导致最大的效益,并且是内联的主要候选者。

3.3 优化虚拟方法调用

通常,内联可以很容易的应用于静态方法和被程序员声明为final方法的方法。
当从给定的调用点调用这些方法之一时,被调用的方法代码绝不会改变。
因此,一旦在这些方法中的一个上执行内联时,被内联的代码将总是正确的代码。

然而在面向对象语言中,许多方法不是静态的或者是final的,即与动态类相关联的方法。
由于类层次和由此产生的多态,被一个虚拟方法调用执行的实际代码可能改变,这依赖于被引用对象的具体子类。

决定使用哪个代码是在运行时通过一个动态方法表查找来确定的。

因为一个虚拟方法的代码可以被动态改变,这取决于所给的对象类型,
所以,对任何invokevirtual指令调用的方法,将看来是禁止方法内联的。

不过在许多情况下,虚拟方法在大多数时间(或者全部时间)里是用同类对象来调用的。
这种情况可以通过剖析调用一个给定的虚拟方法所引用的类型来确定。

如果一个特殊的方法在大多数时间内被调用,那么可以内联这个方法在最普通的子类中的方法代码,
并把守护指令放置在被内联的代码上方。

守护简单的测试紧接着将要调用的方法所基于的引用类型,
如果它是所期望的(普通的)类型,那么守护将让控制转到内联的方法版本。
另一方面,在这种引用是不同于所期望的子类时的少见情况下,
守护可以分支到一条非内联的invokevirtual指令。

如果方法调用真的是多态以至于完全内联是没有用的,那么至少可以避免动态方法表查找的开销,
这要使用类似于二进制翻译中的软件跳转预测技术,这种技术被称为多态内联高速缓存(PIC)。
对于PIC,运行时系统负责维护存根(stub)代码,将最经常使用的跳转放在序列的顶部。

3.4 多版本和专门化

(1)多版本
前述的虚拟方法调用的内联方法本质上是一种多版本形式。

多版本有两种(或多种)代码版本,并且一个版本的选择取决于运行时信息,如数据的值或类型信息。
对于内联的方法,一个版本是被内联的方法代码,另一个版本是一条invokevirtual指令,而守护选择其中一个版本。

可以把这个同样的一般方法应用于其他类型的代码,而不只是虚拟方法调用。

[OS] 高级语言虚拟机的编译优化_第1张图片

例如,剖析数据值可以确定数组A的元素几乎总是零。
守护检查A[i]看其是否为零,如果是这样,则它使用一个代码版本跳过剩余的指令并且简单的将B[i]设为零。

(2)专门化
多版本的一个重要方面是专门化

如果某些变量或引用总是被分配为已知是常数(或者来自一个有限的范围)的数据值或类型,
那么有时可能使用简化的特殊情况来代替比较复杂的一般代码。

上图中,A[i]为零时是特殊情况。

专门化可以与多版本联合使用,或者可以通过某种代码分析来激活,
这种分析指示只需要单个专门的版本。

构造多个版本的一种可选方法是只编译单个代码版本(或者少数版本),并推迟对一般的且更复杂的情况的编译。
例如,剖析可以发现到程序执行的某一处只出现过一个值,因此一个优化的编译器可以跳过一般情况的代码。
然而,应该有一个守护来检查一般情况,如果它发生了,则此时可以编译一般的代码。

3.5 栈上替换

在包括Java和微软的CLI等大多数高级语言虚拟机中,栈是指令执行的核心。
因此,当进行优化时,栈也是一个重要的考虑因素。

为了理解栈和程序优化之间的关系,应该对结构栈实现栈加以区别,
结构栈是诸如有Java或者MSIL程序指定的栈,
实现栈是程序执行中(在编译和/或优化之后)实际使用的栈。

例如,在方法内联之后,实际的实现栈将不包含被内联方法的栈帧,
主调方法和被内联方法的栈帧被合在一起。
此外,在一些类型的优化(如专门化)之后,实现栈的内容可能和结构栈的内容不同。

只要结构是正确的,那么实现栈和结构栈的不同就不会带来分歧。
结构栈是规定程序所执行的功能的一种手段,它不必反映实现功能的精确方法。

在程序执行中的一个给定点,实现栈的内容(包括帧的数量和每个帧中元素的数量)依赖于在程序上所进行的优化。
此外,在有些情况下,动态优化可能需要即使修改实现栈的内容。
这种通过修改栈来适应动态改变优化级别的过程,称为栈上替换,或者OSR(on-stack replacement)。

3.6 堆分配对象的优化

根据其本性,好的面向对象编程自由的创建大量的堆分配对象。
然而,有许多与对象相关联的开销。
创建对象和垃圾收集的代价相对较高。

此外,由于访问保存在对象中的域经常涉及几层间接地址,
所以每个对象域的访问都要经历小的合计开销。

为了处理创建开销,如果剖析表明一个特殊类型的对象常被分配,
那么就可以内联堆分配和对象初始化代码。

[OS] 高级语言虚拟机的编译优化_第2张图片

标量替换是一种在某些情况下可以非常高效的降低对象访问延迟的优化。
这种优化用一个标量值替换一个对象域。

当标量替换被应用于对象时需要引用逃逸分析
这是一种确保所有对这个对象的引用在包含优化的代码区域内。

在一个对象内数据域的物理放置是一种与实现有关的特性。
因此,可以根据使用模式排列对象域来实现数据cache性能的改善。
此外,通过使用传统的编译器优化可以把某些域引用完全删除。

3.7 低级别的优化

除了前面给出的面向对象程序特别有效的优化以外,还可以应用许多传统优化。
这些优化包括无用代码删除,分支优化,复制和常数传播,强度削弱,以及代码重排。

在面向对象高级语言虚拟机中,一个潜在的重要开销是需要进行数组范围和空引用的检查。
理论上,每次访问一个数组或者使用一个引用时,就必须执行这些检查,或许重要的是可能会抛出一个异常。
这意味着有两个可能的性能损失原因。

一个是本身需要执行范围/空检查,另一个是禁止其他优化,
这是因为如果检查失败就可能会抛出异常。
在后一种情况下,当发生异常时,结构进程状态必须是精确的。
并且,恰好和使用二进制优化器一样,如果一个精确状态必须被潜在的具体化,那么可能会禁止某些优化。

通过一个“不合法的”在范围之外的地址代表空指针值,
即用一个Java进程没有读写权限的内存地址,可以大量的消除空指针检查的开销。
任何一个使用空指针的企图都会导致一个陷阱,它通过操作系统支持的信号机制报告给JVM运行时系统。

不过异常的可能性(和需要恢复一个精确状态)仍保持不变,
所以仍要禁止某些涉及代码移动的代码优化。

3.8 优化垃圾收集

垃圾收集是高性能虚拟机实现的一个关键部分,并且编译器可以提供许多方法帮助Java运行时系统提高垃圾收集的效率。

首先,有些时候堆的状态会暂时不一致,例如,当对象引用正被修改时。
在这些点上,为了避免错误,不应该启动垃圾收集。

因此,编译器可以在代码中的规则间隔内向垃圾收集器提供“退让(yield)点”。
在这些点上线程可以保证一致的堆状态,以便控制可以被退让给垃圾收集器。

同样,编译器可以帮助特定的垃圾收集算法。
例如,如果使用分代收集器,那么编译器必须提供写路障。


参考

虚拟机 : 系统与进程的通用平台

你可能感兴趣的:([OS] 高级语言虚拟机的编译优化)