JIT编译,动态编译与自适应动态编译

在微博上看到有人在推广这篇介绍Visual C++与RyuJIT的文章:

每个程序员都应当知道的编译器优化知识

(原文:Compilers - What Every Programmer Should Know About Compiler Optimizations, MSDN Magazine 2015-02)

这篇文章总体来说写得还不错。不过在描述RyuJIT的功能时略微夸大了一些:

What’s the difference between RyuJIT and Visual C++ in terms of optimization capabilities? Because it does its work at run time, RyuJIT can perform optimizations that Visual C++ can’t. For example, at run time, RyuJIT might be able to determine that the condition of an if statement is never true in this particular run of the application and, therefore, it can be optimized away.

这说的主要是一种PGO优化(profile-guided optimization)的效果(注1)。虽然“理论上说”这是可能的,但CLR使用JIT编译器的方式使得RyuJIT实际上无法在JIT编译时做到这种优化。就像评论里@空明流转大大说的:

我是觉得,“对JIT编译器抱有过高期望”一直都是非编译器行业的一种长期偏见。

我完全同意。所以才要来解剖一下。

想起之前回答过的一个问题:HotSpot是较新的Java虚拟机技术,用来代替JIT技术,那么HotSpot和JIT是共存的吗? - RednaxelaFX 的回答

本文想写的东西有一半在上面的链接里,请先读完它在继续向后阅读后文。重点是:

  • 动态编译(dynamic compilation)指的是“在运行时进行编译”;与之相对的是事前编译(ahead-of-time compilation,简称AOT),也叫静态编译(static compilation)。
  • JIT编译(just-in-time compilation)狭义来说是当某段代码即将第一次被执行时进行编译,因而叫“即时编译”。JIT编译是动态编译的一种特例。
  • JIT编译一词后来被泛化,时常与动态编译等价;但要注意宽泛与狭义的JIT编译所指的区别。
  • 自适应动态编译(adaptive dynamic compilation)也是一种动态编译,但它通常执行的时机比JIT编译迟,先让程序“以某种形式”先运行起来,收集一些信息之后再做动态编译。这样的编译可以更加优化,可以很自然的融入PGO优化。这个“某种形式”可以称为“baseline execution“,可以由解释器或简单的JIT编译器承担。


CLR,JIT编译与PGO


针对CLR / RyuJIT的场景稍微展开一下。

CLR的执行引擎的模型是“纯编译的单层JIT编译器“。所有被执行的有MSIL方法体的方法,要么被NGen事先编译成机器码了(PreJIT),要么在方法第一次被调用前进行JIT编译到机器码;总之就是MSIL要编译成机器码之后才被执行。

在正常执行时,每个有MSIL方法体的方法只有一次被编译的机会;一旦被编译过了就雷打不动了。对单次执行来说,CLR这种“单层JIT编译、只编译一次“的模型意味着代码被编译的时候还一次都没被执行过,也就无从借助运行时收集的profile信息来做有针对性的优化。

与之相对,自适应动态编译由于可以等程序执行了一段时间之后才做编译,所以有充分时间收集profile并利用profile来优化,经典例子就是收集分支跳转的次数,看taken分支还是not-taken分支的次数多。这样才可以做到本文开头引用的那段文字所描述的优化。

从.NET 4.5开始,CLR支持一个新功能叫做ReJIT,用于支持managed profiler通过动态插桩(instrument)来收集性能数据(profile)。动态插桩意味着修改某个已有方法的MSIL方法体,所以在修改过后需要重新JIT编译才可以执行,因而得名ReJIT。注意ReJIT功能自身并不用于收集profile;收集profile的是CLR外部的managed profiler。所以收集到的profile也无法供给CLR的JIT编译器使用。

这与CLR正常执行时的模型其实仍然一致:每个MSIL方法体仍然只有一次被编译的机会;所谓“重新JIT编译“编译的是新的MSIL方法体。

.NET 4.5还引入了“Multicore JIT”:在第一次运行程序时,用recording mode记录下哪些方法被JIT编译过,写入profile文件;以后执行可以用playback mode在后台线程先JIT编译这个列表里的方法,这样等到应用真的第一次调用某个方法时它可能已经被JIT编译好了。这是JIT编译的一种特殊做法,虽然没有脱离JIT编译的范畴但增加了一个自由度。

当前这个Multicore JIT的设计只针对程序启动速度优化,它只观察和记录JIT编译活动,生成的profile粒度太粗,仅是“被JIT编译过的方法的列表“,而不包含任何细粒度的profiling信息(例如说条件分支、虚方法的被调用对象的实际类型等)。所以仍然做不到本文开头所说的PGO优化。

但基于这个架构,后续开发完全可以添加更多功能,例如在recording mode中让JIT编译器生成收集profile的代码,收集细粒度profile(例如类型信息、分支跳转情况等),并将收集到的profile写入文件;后续执行就可以根据细粒度profile来做高度优化的编译。

其实.NET 4.5已经有了类似的PGO功能,但不用于JIT编译场景而用于NGen(AOT编译)场景。这就是MPGO(managed profile-guided optimization)。当使用MPGO来NGen时,要分三步来生成最终的NGen二进制映像:

  1. 使用MPGO,NGen生成带有收集profile功能的代码;
  2. 使用(1)生成的代码运行若干次该程序的“典型场景”,以便收集profile。这叫做training run;
  3. 结合(2)收集到的profile,再次运行MPGO来完成最终的NGen。

这个过程跟一般的native PGO优化一样,都是要运行多次,依赖training run收集到的profile来引导后续运行的优化。在这种模式下运行的RyuJIT就可以做到类似本文开头所说的优化——但Visual C++的编译器同样支持PGO,同样可以做这种优化(而且可能做得更好)。相信未来的.NET Native在逐渐成熟起来之后也会考虑支持PGO。

自适应动态编译的思路就是把这种多次运行才能得到的好处压缩在一次运行中完成:执行引擎自动在开始的时候生成收集profile的代码,然后自动收集profile,最后自动利用profile来做优化编译。这样不但用起来方便,而且可以保证收集到的profile能够反映本次运行的特征。Native PGO的多次运行模型非常依赖于training run的代表性,如果其特征与后来实际运行的特征不匹配,那PGO“优化”反而会带来性能损失。

HotSpot VM是一个典型的自适应动态编译系统,使用解释器或Client Compiler(C1)来实现初始执行和profile的收集,然后把profile信息交给Server Compiler(C2)做优化编译。

对HotSpot VM的执行模型感兴趣的同学可以参考同事Doug Hawkins做的演讲来了解更多细节:JVM Mechanics by Douglas Hawkins as Presented at Silicon Valley Java User Group by Azul Systems on Vimeo



传统PGO与自适应动态编译的结合


有没有办法结合传统的PGO与自适应动态编译呢?Azul SystemsZing VM所实现的ReadyNow!功能是一个思路。

Zing VM基于HotSpot VM开发,与HotSpot VM的执行模式相似,都是解释器+C1+C2的多层混合模式执行引擎,使用了自适应动态编译。

能在程序运行一次的过程中就自动进行PGO当然是方便,但收集profile时总有额外开销,不一定适用于所有场景。特别是,收集profile通常发生在程序启动阶段,也就是说启动时会比较慢;如果特别在意程序启动的速度的话,这种做法就不合适了。

ReadyNow!的思路是让传统PGO与自适应动态编译结合起来:一个程序可以先跑些training run把细粒度profile信息记录下来,后续执行的时候可以跳过原本收集profile的阶段,直接利用之前记录的profile信息来做优化编译。这样就减少了程序启动时收集profile的开销,让程序快速达到稳定的高性能状态。

在Zing VM中,ReadyNow!不但能通过profile信息来指导优化,还可以指导不做某些过于激进的优化,减少因过度优化而导致的“去优化”(deoptimization)。这样也有利于程序快速达到稳定的性能水平,而不必在过度优化—去优化-再优化-再去优化-⋯的震荡多次后才达到稳定。



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

* 注1:也有一些场景RyuJIT确实可以做到文中所说优化,例如在JIT编译时把已经初始化好的readonly变量看作编译时常量来做常量传播和折叠的优化。用代码举例的话,可以是:

static class MyConfig {
  static readonly bool _debug = ConfigFile.GetProperty("debug");
  public static bool IsDebug { get { return _debug; } }
}

// ...
if (MyConfig.IsDebug) {
  Logger.log("...");
}

此例中 if (MyConfig.IsDebug) 可以先内联IsDebug属性得知它就是_debug,然后发现它是readonly静态变量,直接用它的值来做优化;假如其值是false,那代码就变成 if (false) ,整个if块就可以被优化消除掉了。

具体逻辑从CoreCLR源码可以看到,在Compiler::impImportBlockCode(BasicBlock * block)

            case CORINFO_FIELD_STATIC_ADDRESS:
                // Replace static read-only fields with constant if possible
                if ((aflags & CORINFO_ACCESS_GET) && 
                    (fieldInfo.fieldFlags & CORINFO_FLG_FIELD_FINAL) &&
                    !(fieldInfo.fieldFlags & CORINFO_FLG_FIELD_STATIC_IN_HEAP) &&
                    (varTypeIsIntegral(lclTyp) || varTypeIsFloating(lclTyp)))
                {     
                    CorInfoInitClassResult initClassResult = info.compCompHnd->initClass(resolvedToken.hField, info.compMethodHnd,
                        impTokenLookupContextHandle);

                    if (initClassResult & CORINFO_INITCLASS_INITIALIZED)
                    {     
                        void **  pFldAddr = NULL; 
                        void * fldAddr = info.compCompHnd->getFieldAddress(resolvedToken.hField, (void**) &pFldAddr);

                        // We should always be able to access this static's address directly
                        assert(pFldAddr == NULL);

                        op1 = impImportStaticReadOnlyField(fldAddr, lclTyp);
                        goto FIELD_DONE;
                    }     
                }

不过这是比较特殊的场景,而PGO能覆盖的是更一般的场景。

16 条评论