如果没有PGO,JIT 编译相比AOT 编译有哪些优势?

软件工程师、主攻高级编程语言虚拟机的设计与实现

144 👍 / 9 💬

问题描述

据说JIT 编译可以拿到比AOT编译更多的运行时信息。但是如果一个纯JIT(第一次执行时编译,无profiling feedback)的话具体可以拿到哪些AOT拿不到的信息呢?

我知道有一点:JIT 可以拿到运行环境的硬件信息,从而做指令集的定向优化,而不是AOT中兼容最低配置。

除此之外还有吗?

首先,讨论这个问题一定要确定我们讨论的主题是“JIT可以比AOT在哪些方面做得更好”,而不要陷入“JIT编译出来的代码的整体效果怎样就比AOT编译要更好”的大坑。前者只是一些局部点的讨论,而后者则要牵扯更多方面。

后者的话,AOT编译最大的优势就是有机会不计成本(资源开销)地做代码分析和优化,使得它可以承受更重量级的优化而得到更好的代码。而JIT就算是有adaptive dynamic compilation / tiered compilation来分担初始开销,毕竟是在应用运行的同时来编译,做什么分析/优化都要考虑时间和空间开销,所以跟传统AOT的强项没办法硬碰硬。

=======================================

JIT+PGO的情况

那么回到正题,JIT能做些什么有趣的事情。

题主一上来先把JIT最擅长的方面给禁了——不让JIT搭配PGO做优化。现实中JIT编译最大的优势就是可以通过FDO(feedback-directed optimization)或者叫PGO(profile-guided optimization)来做优化,这样可以以少量的初始运行时开销,换取一些本来要通过重量级静态分析才可以得到、或者静态分析根本无法得到的一些运行时信息,然后基于它来做优化就可以事半功倍。

先放个传送门来讲解一些相关名词的关系:

JIT编译,动态编译与自适应动态编译 - 编程语言与高级语言虚拟机杂谈(仮) - 知乎专栏

JIT会做的典型的FDO / PGO可以有这么一些点:

  1. type-feedback optimization:主要针对多态的面向对象程序来做优化。根据profile收集到的receiver type信息来把原本多态的虚方法调用点(virtual method call site)或属性访问点(property access site)根据类型来去虚化(devirtualize)。
  2. single-value profiling:这个相对少见一些。它的思路是有些参数、函数返回值可能在一次运行中只会遇到一个具体值。如果是这样的话可以把那个具体值给记录下来,然后在JIT编译时把它当作常量来做优化,于是常见的常量相关优化(常量折叠、条件常量传播等)就可以针对一个静态意义上本来不是常量的值来做了。
  3. branch-profile-based code scheduling:主要目的是把“热”的(频繁执行的)代码路径集中放在一起,而把“冷”的(不频繁执行的)代码路径放到别的地方。AOT编译的话常常会利用一些静态的启发条件来猜测哪些路径比较热,或者让用户指定哪些路径比较热(例如 likely() / unlikely() 宏),而JIT搭配PGO的话可以有比较准确的路径热度信息,对应可以做的优化也就更吻合实际执行情况,于是效果会更好。
  4. profile-guided inlining heuristics:根据profile信息得知函数调用点的热度,从而影响内联决策——对某个调用点,到底值不值得把目标函数内联进来。
  5. implicit exception:隐式异常,例如Java / C#的空指针异常检查,又例如Java / C#的除以零检查。这些异常如果在某块代码里从来没有发生过,就可以用更快的方式来实现,而不必生成显式检查代码。但如果在某块代码经常发生这种异常,则显式检查会更快。更多讨论请跳传送门:如何评价《王垠:C 编译器优化过程中的 Bug》?

上面的(1)和(2)在JIT+PGO的场景中,生成的代码常常会带有条件判断(guard)来检查运行时实际遇到的值是否还跟profile得到的信息一致,只有在一致的时侯才执行优化的代码,否则执行后备(fallback)的不优化代码。

当然这样的优化还是结合一些静态分析效果更佳。

例如说,对下面的Java伪代码,假设有接口IFoo和一个实现了该接口的类Foo。

void func(IFoo obj) {
  obj.bar(); // call site 1
  obj.bar(); // call site 2
}

如果只应用上述(1)的type-feedback optimization,我们可能会发现profile记录下来两个bar()的调用点的receiver type都是Foo,于是一个很傻的JIT可能会生成这样的代码:

void func(IFoo obj) {
  // call site 1
  if (obj.klass == Foo) { // guard
    Foo.bar(obj);         // devirtualized
  } else {
    obj.bar();            // virtual call as fallback
  }

  // call site 2
  if (obj.klass == Foo) { // guard
    Foo.bar(obj);         // devirtualized
  } else {
    obj.bar();            // virtual call as fallback
  }
}

这样的JIT虽然应用了profile信息来做优化,但是没有对代码做足够静态分析和优化,没有发现其实两个调用点都是一样的引用,类型肯定相同。

而一个没那么傻的JIT编译器可能会生成这样的代码,把guard产生的类型信息传播出去:

void func(IFoo obj) {
  if (obj.klass == Foo) { // guard
    Foo.bar(obj);         // devirtualized call site 1
    Foo.bar(obj);         // devirtualized call site 2
  } else {
    obj.bar();            // virtual call as fallback
    obj.bar();            // virtual call as fallback
  }
}

这是假设没有足够静态信息来判断obj运行时的实际类型的情况。

那么稍微改变一下例子,变成这样:

void func() {
  IFoo obj = new Foo();
  obj.bar();
  obj.bar();
}

此时只使用type-feedback optimization的比较傻的JIT编译器还是会生成跟前面类似的代码:

void func() {
  IFoo obj = new Foo();

  // call site 1
  if (obj.klass == Foo) { // guard
    Foo.bar(obj);         // devirtualized
  } else {
    obj.bar();            // virtual call as fallback
  }

  // call site 2
  if (obj.klass == Foo) { // guard
    Foo.bar(obj);         // devirtualized
  } else {
    obj.bar();            // virtual call as fallback
  }
}

而一个做了类型信息传播的JIT编译器则会发现new Foo()是一个可以确定准确类型的表达式,把这个信息传播出去就可以确定后面两个bar()的调用点都肯定会调用Foo.bar()。于是它可以忽略收集到的profile信息,优先借助静态分析/优化的结果而生成这样的代码:

void func(IFoo obj) {
  Foo obj = new Foo();
  Foo.bar(obj);         // devirtualized call site 1
  Foo.bar(obj);         // devirtualized call site 2
}

一个做了足够优化的AOT编译器会对这个例子生成跟后者一模一样的代码,而不需要借助profile信息。

举这两组例子只是想提醒一下读这篇回答的同学们,不是所有“JIT”的优化程度都一样,不要对JIT的行为“想当然”。Profile信息对程序优化的影响会收到输入程序的实际情况的影响,也会受到搭配的编译器自身所做的优化的影响。

很多现实中的JIT都是在优化开销和目标性能之间的权衡,设计出发点的差异会导致实现的巨大不同。

=======================================

JIT不搭配PGO的情况

前戏结束,终于来到正餐。

其实JIT编译(或者宽泛而言,“动态生成代码”(dynamic code generation))最大的优势就是利用运行时信息。运行时信息有很多种,并不是所有都算“profile”;而对运行时信息的使用也非常多样化。同学们一定要有open mind来发挥自己的想像力 >_<

然而同时值得注意的是,有不少JIT编译器之所以很难被改造为AOT编译器使用,很大一部分原因就来自于它们在设计之初就只考虑被用作JIT编译器,无条件内嵌或者说依赖了很多运行时的值,如果要改造为AOT编译器使用则需要把这些根深蒂固的依赖都挖掉,工作量常常会大得让人放弃orz

让我先放个简单列表,回头有空再补充更多内容或者展开其中一些来讲解。

  1. 可以把许多运行时才确定的地址 / 指针值当作常量。
  2. 可以针对程序一次启动所配置的参数而生成最合适的代码,减少运行时条件判断。
  3. 可以针对当前运行的机器的实际状况生成最合适的机器码。比针对通用情况编译的AOT编译结果更优,而比包含运行时检查机器功能(例如用cpuid检测某些指令是否可用)的AOT编译结果减少运行时检查开销。
  4. 针对动态链接的场景,可以跨越动态链接的模块边界做优化(例如跨越模块边界将函数调用内联)。
  5. 可以选择性编译频繁执行的代码,减少编译后的代码的内存开销,特别在诸如资源极其受限的嵌入式场景有特殊用法。
  6. 有些功能可能静态编译的计算量太大,而放在运行时根据具体值来JIT编译则可以只对特定情况计算,很好地达到性能与开销的平衡。
  7. 常常会允许做code patching,针对代码实际运行遇到的值做特化,并且在实际的值发生变化时跟随做调整。
  8. 有可能运行动态对代码做instrumentation,并且根据收益和开销来动态调整instrumentation的详细程度。在收集到足够信息后可以动态撤销instrumentation代码来恢复到原有性能。
  9. 最后但其实可能是最重要的,是JIT编译常常可以做“激进的预测性优化”(aggressive speculative optimization),在预测错误时可以灵活地fallback到安全的不那么优化的代码上。
    1. 例如说一个方法可以只编译“执行过的部分”或者“预测可能会执行的部分”。如果实际执行到之前没编译的路径上,那就当场再编译就是了。
    2. 例如说在支持动态加载代码的场景中,静态编译只能以open-world assumption来做保守优化,而JIT编译可以做closed-world assumption做激进的优化,并且当动态加载了新代码使之前的预测不再准确时,抛弃之前编译的代码而重新编译。

---------------------------------------------

对上面的(1),举几个例子。

例如说微软的CLR的JIT编译器,目前是对一个方法只能正常JIT编译一次的,所以用不上“PGO”。但它可以利用许多运行时的值,例如说这样:

void Bar() {
}

void Foo() {
  Bar();
}

void Goo() {
  Bar();
}

void Main() {
  Foo();
  Goo();
}

假如程序从Main()方法开始执行,全部没有被NGen,那么走的就是正常的“第一次被调用时才JIT编译”的路径。于是,假如没有发生内联,JIT编译的顺序是调用树的深度优先遍历:

Main() -> Foo() -> Bar() -> Goo()

在编译Main()时,它要调用的Foo()与Goo()尚未被编译,其编译后方法入口地址尚未知,所以Main()里对它们的调用就会生成对它们的prestub的调用代码,用于触发JIT编译并把调用点patch到对编译好的方法入口地址的直接调用。放个传送门:

什么是桩代码(Stub)? - RednaxelaFX 的回答 - 知乎

。编译Foo()的时候也是类似,Bar()尚未被编译所以只能先生成对prestub的调用,等prestub被调用的时候触发JIT编译并把调用点patch为直接调用。

而JIT编译Goo()时,它要调用的Bar()方法已经被JIT编译好了,其方法入口地址是已知的,所以可以生成直接调用其方法入口地址的代码。

无论Main()、Foo()、Goo()、Bar()分别在哪个“模块”(.NET Assembly意义上)中,它们在运行时都是被混在一起的,跨模块调用不会有额外开销,不会因为Foo()与Bar()不在一个模块而导致该调用要经过诸如GOT结构来做间接调用。

然后,例如说在HotSpot JVM中,“常量对象”(例如Java对象的Klass、例如说String常量等)的引用值可以被直接嵌入到生成的代码中。这些地址也是只有运行时才能确定的。

再例如,如果有一个带JIT带GC的运行时环境,GC使用连续的虚拟地址空间并且分两代,那么要检查一个引用指向的对象是在young generation还是在old generation,只要看该引用值是否小于两代之间的分界地址即可。这个地址显然也是一个运行时值,用JIT的话就可以很轻松地把地址内嵌到生成的代码中,而AOT编译的话常常需要为此生成一个内存读操作。

---------------------------------------------

对上面的(2),举点例子。

例如说,HotSpot JVM的解释器其实是“JIT”出来的——是在VM启动的过程中动态生成出来的。根据某次启动所配置的参数,例如说是否要在解释器中做profiling,它可以选择性生成代码,完全不生成该次运行所不需要的代码,从而让解释器代码在内存中的布局更加紧凑,提高代码局部性。

---------------------------------------------

对上面的(3)…就不举例子了。这个可能是被讨论得最多的场景,似乎大家都知道这是什么意思。

---------------------------------------------

对上面的(4),前面举的CLR的例子已经涉及一点。但比起能生成“直接调用”,更有趣的是JIT编译通常可以无视模块边界而实现跨越模块的函数调用内联。

还是用CLR那个例子的话,假如那是一个用C++实现的程序,而Main()、Foo()、Goo()、Bar()四个函数各自在自己的exe或dll里,那它们之间的调用就通常无法被内联。

而对CLR而言,如果这是一个C#实现的程序,那这几个方法从什么模块而来根本没关系,照样都可以内联。

---------------------------------------------

对上面的(5),可以举的有趣例子实在太多,而且应用场景可以相当不同。

以C++模版与C#的泛型实例化为例,一个AOT编译的C++程序,静态编译时编译器看到了某个模版类/函数的哪些实例化,就必须把该实例化版本的代码和元数据都生成出来,而假如实际运行只用到了其中的很少数,那就很浪费。

而C#程序在CLR上运行的话,一个泛型类型只有运行时实际用到的实例化版本才会生成对应的代码和元数据,其中代码部分还有机会共享,内存开销就小很多。

而且更有趣的是这样还可以允许运行时动态创建(反射创建)新的实例化版本。C++的模版在AOT编译的模型下就做不到这点。

再举一个例子,看看低端的Java ME的场景。

这种场景的设备可能只有很少内存和持久存储(RAM和ROM都少),用起来得非常节省。Java字节码其实可以看作程序的“压缩形式”,如果编译到机器码的话,所占空间有可能要膨胀3倍到10倍。如果一个Java应用的所有代码都被AOT编译到机器码,它可能就根本没办法装到设备(ROM)上了。

所以这种场景下Java程序适合以字节码的形式持久存储于ROM上,只占用很少ROM空间,然后像Monty VM(也叫CLDC HotSpot Implementation)的JVM实现 ,会配置一个非常小的JIT code cache,其中只保留最近执行最频繁的JIT编译的代码,其它代码都解释执行——假如触发了新的JIT编译而code cache已用满,则抛弃掉最冷的代码来让出空间给新代码用。这样就在内存占有与性能之间达成了一个动态平衡。

“只编译频繁执行的路径”的思路下还有trace-based compilation。这里就先不展开说了。

---------------------------------------------

针对上面的(6),简单举俩例子。

第一个例子是Sun Labs以前研发过的

Fortress

语言,它的编译器实现就混合使用了静态编译与动态代码生成技术——虽说动态生成的是Java字节码。这也算是一种形式的JIT。

参考

Christine Flood大妈在JVM Language Summit 2011上做的一个Fortress演讲

提到的一点:

Fortress语言的recursive type设计使得有些类型根本无法在静态编译时完全生成出来(不然编译器自己就停不了机了orz),所以有些类型就干脆等到运行时再根据实际使用状态动态生成出来。

第二个例子是微软CLR的GC大佬之Patrick Dussud在一个访谈中提到过,他刚工作的时候参与过一个项目,是一个APL语言的实现的runtime优化,其中就涉及JIT编译技术。

例如说APL的⍳ (Iota) 函数可以生成从1到n的整数数列,而如果对它的结果 + 1的话,就相当于对这个数列的所有元素加1。当时一般的APL runtime实现会在执行iota时一开始就一口气生成出整个从1到n的数组放在内存里,然后执行加1就真的每个元素都加1。而Patrick参与的项目则尝试把这些操作“符号化”(symbolic representation + lazy computation),在不需要使用实际值的时候只把操作记录下来,等到真的要用其中的一些值时才materialize。这个过程中,如果materialize时发现计算是很简单的就解释执行之,如果发现计算是复杂计算则动态生成特化的计算代码(JIT编译)然后再执行之。

传送门:

Patrick Dussud: Managing Garbage Collection | Behind The Code | Channel 9

(从10:00开始的一小段)

---------------------------------------------

针对上面的(7),code patching,举点小例子。

例如说,CLRv2对接口方法做所谓“virtual stub dispatch”(VSD),其实是一个monomorphic inline cache call。它会在第一次执行的时候记录下当时传入的receiver type,将自身特化成类似这样的形式:

  if (obj.MethodTable == expected_MT_of_Foo) {
    call Foo.bar();              // direct call, fastest
  } else {
    failure_counter++;
    if (failure_counter < 1000) {
      lookup_target_and_call();  // reflective lookup, slowest
    } else {
      patch_self_to_generic();   // patch to generic version, mediocre
    }
  }

于是后续调用如果还是对同一receiver type的参数做,就可以走快速路径做直接调用,而如果遇到了其它receiver type则记录下失败次数,当失败次数超过阈值时把自己patch成泛化的慢速形式。

这种场景虽然有feedback,但是并不需要完善的profile机制,而JIT编译器自身也不使用profile信息来生成特化代码,而是让runtime的别的一些机制,例如stub管理之类来管理跟随feedback而调整代码,所以不算PGO编译。

再举一个例子。HotSpot VM的Client Compiler(C1)允许对若干类型的场景生成占位代码,等到运行时有足够信息的时候再填进去。

例如说,遇到对尚未加载的类的字段访问,因为还不知道字段所在的偏移量应该是多少,所以还无法生成最终的完整代码。此时C1可以生成一些nop以及一个runtime call来占位,等到第一次执行到那个地方的时候就调用进那个runtime call。进到runtime,此时这里涉及的类肯定以及加载好了,于是查询好相关的偏移量信息之后,就把原本占位用的指令patch成实际的字段访问代码。

这里还有个有趣的细节:如果调用进runtime,发现这个字段是个常规字段,就按照上面的流程工作即可。而如果发现这个字段是个volatile字段,那就意味着当前方法的C1编译版代码在编译的时候可能没有考虑足够重排序相关限制,所以必须要抛弃掉这个版本的编译代码,然后重新让C1再编译一次。

---------------------------------------------

针对上面的(8),可以参考CLR的ReJIT功能。

先放俩传送门:

---------------------------------------------

针对上面的(9),这就好玩了。非常非常好玩。

现代高性能JVM的JIT编译器非常依赖于这方面的优化。所谓assumption-based speculative optimization就是这种。

先放个传送门占位:

HotSpot VM有没有对invokeinterface指令的方法表搜索进行优化?

回头再展开举例。