动态编译和静态编译

    技术2024-04-02  98

    我本月开始写一篇文章,剖析写得不好的微基准。 毕竟,我们程序员痴迷于性能,并且我们每个人都希望了解我们编写,使用或批评的代码的性能特征。 当我偶尔写关于性能的文章时,我经常收到人们的电子邮件,说:“我写的这个程序表明,与上一篇文章相反,动态雾化比静态雾化更快!” 伴随此类电子邮件的许多所谓的“基准”程序或它们的运行方式,显示出对JVM如何实际执行Java字节码的认识非常缺乏。 因此,在写我打算写的文章之前(将在以后的专栏中介绍),让我们看一下JVM的内幕。 了解动态编译和优化是了解如何从坏的基准中分辨出好的微基准(关键是很少的基准)的关键。

    动态编译-简要历史

    Java应用程序的编译过程与C或C ++之类的静态编译语言不同。 静态编译器将源代码直接转换为可以在目标平台上直接执行的机器代码,并且不同的硬件平台需要不同的编译器。 Java编译器将Java源代码转换为可移植的JVM字节码,这是JVM的“虚拟机指令”。 与静态编译器不同,javac很少进行优化-由编译器以静态编译语言完成的优化是在程序执行时由运行时执行的。

    第一代JVM被完全解释。 JVM解释了字节码,而不是将其编译为机器代码并直接执行机器代码。 当然,这种方法不能提供最佳的性能,因为系统执行解释器所花的时间比应该运行的程序要多。

    即时编译

    对于概念验证的实现来说,解释是可以的,但是早期的JVM由于速度慢而很快受到了不好的好评。 下一代JVM使用即时(JIT)编译器来加快执行速度。 严格定义,基于JIT的虚拟机会在执行之前将所有字节码转换为机器代码,但是这样做是偷懒的:JIT仅在知道要执行的代码路径时才编译代码路径(因此,名称,名称, 即时编译)。 这种方法使程序可以更快地启动,因为在开始执行之前不需要冗长的编译阶段。

    JIT方法似乎很有希望,但是它有一些缺点。 JIT编译消除了解释的开销(以一些额外的启动成本为代价),但是由于一些原因,代码优化的水平中等。 为了避免Java应用程序的大量启动损失,JIT编译器必须快速运行,这意味着它不能花太多时间进行优化。 早期的JIT编译器在进行内联假设时比较保守,因为他们不知道以后会加载哪些类。

    从技术上讲,基于JIT的虚拟机会在执行每个字节码之前先对其进行编译,但术语JIT通常用于指代将字节码动态编译为机器代码的方法,甚至也包括那些能够解释字节码的代码。

    热点动态编译

    HotSpot执行过程结合了解释,概要分析和动态编译。 HotSpot不会在执行之前将所有字节码转换为机器代码,而是首先以解释器的身份运行,并且仅编译“热”代码-最常执行的代码。 在执行时,它会收集概要分析数据,用于确定执行频率最高的代码段,足以值得编译。 仅编译频繁执行的代码具有几个性能优势:不会浪费时间去编译很少执行的代码,因此,编译器可以花更多的时间在热代码路径的优化上,因为它知道这样的时间将被很好地利用。 此外,通过推迟编译,编译器可以访问分析数据,该数据可用于改进优化决策,例如是否内联特定的方法调用。

    为了使事情变得更复杂,HotSpot带有两个编译器:客户端编译器和服务器编译器。 默认是使用客户端编译器; 您可以在启动JVM时通过指定-server开关来选择服务器编译器。 服务器编译器已经过优化,可以最大程度地提高峰值运行速度,并且适用于长时间运行的服务器应用程序。 已对客户端编译器进行了优化,以减少应用程序启动时间和内存占用量,与服务器编译器相比,采用的复杂优化更少,因此,所需的编译时间也更少。

    HotSpot服务器编译器可以执行各种令人印象深刻的优化。 它可以执行静态编译器中的许多标准优化,例如代码提升,通用子表达式消除,循环展开,范围检查消除,死代码消除和数据流分析,以及各种其他非优化方法。在静态编译语言中实用,例如积极地内联虚拟方法调用。

    连续重新编译

    HotSpot方法的另一个有趣的方面是,编译不是一个全有或全无的主张。 在解释了一定数量的代码路径后,将其编译为机器代码。 但是JVM会继续进行性能分析,并且如果JVM确定代码路径特别热,或者将来的性能分析数据表明有进一步优化的机会,则可以稍后以更高的优化级别再次重新编译代码。 JVM可以在单个应用程序执行中多次重新编译相同的字节码。 要了解编译器在做什么,请尝试使用-XX:+PrintCompilation标志调用JVM,这会导致编译器(客户端或服务器)在每次运行时打印一条短消息。

    堆叠更换

    HotSpot的初始版本一次执行一种编译方法。 如果某个方法累积执行的循环迭代次数超过一定次数(HotSpot的第一个版本为10,000),则该方法被认为是热方法,该迭代是通过将计数器与每种方法相关联并在每次执行向后转移时递增该计数器来确定的。 但是,在编译方法之后,直到方法退出并重新输入后,它才切换到编译版本-编译版本仅用于后续调用。 在某些情况下,结果是从未使用过编译版本,例如,计算密集型程序的情况,其中所有计算都是在方法的一次调用中完成的。 在这种情况下,可能已经编译了重量级方法,但是绝不会使用已编译的代码。

    HotSpot的最新版本使用一种称为栈上替换 (OSR)的技术,允许在循环中间从解释转换为编译代码(或将一个版本的编译代码交换为另一个)。

    那么这与基准有什么关系呢?

    我答应过要向您提供有关基准测试和性能测量的文章,但是到目前为止,您所获得的只是一堂历史课和对Sun HotSpot白皮书的重新讨论。 如此漫长的绕道的原因是,如果不了解动态编译过程,就几乎不可能正确编写或解释Java类的性能测试。 (即使对动态编译和JVM优化有深刻的了解,这仍然很难。)

    为Java代码编写微基准要比为C代码编写微基准要困难得多。

    传统的方式,以确定是否解决方法A快于B方法是写一个小的基准测试程序,通常被称为微基准 。 这种倾向是完全明智的。 科学方法要求不少于独立研究。 las,细节在于魔鬼。 对于动态编译的语言,编写和解释基准测试要比静态编译的语言困难和复杂得多。 通过编写使用给定结构的程序来尝试学习有关给定结构的性能没有什么错,但是在许多情况下,用Java语言编写的微基准并不能告诉您您的想法。

    使用C程序,即使不运行它,您也可以了解很多有关程序可能的性能特征的信息-只需查看编译的机器代码即可。 编译器生成的指令是将要执行的实际机器指令,在通常情况下,它们的时序特性已得到很好的理解。 (有一些病理性例子,由于持久性分支预测未命中或高速缓存未命中,因此性能远比查看机器代码所预期的差,但是在大多数情况下,您可以通过查看以下内容来了解​​C程序的性能在机器代码上。)

    如果编译器由于原因导致代码不相关(由于基准测试实际上不做任何事情的常见情况)而打算优化代码块,则可以在生成的机器代码中看到它-代码不是那里。 而且,您通常不必花很长时间运行C代码,就可以对其性能做出一些合理的推断。

    另一方面,HotSpot JIT会在程序运行时不断地将Java字节码重新编译为机器代码,并且可能会在意外时机通过积累一定数量的概要分析数据,加载新类或执行Java来触发重新编译。在已加载的类中尚未遍历的代码路径。 面对连续重新编译时的计时测量可能会产生很大的噪音和误导性,在获得有用的信息之前,通常有必要将Java代码运行相当长的一段时间(我发现程序开始运行后甚至数小时甚至数天的加速传闻)。性能数据。

    消除死代码

    编写良好基准的挑战之一是优化编译器才能发现无效代码,这些代码对程序执行的结果没有影响。 但是基准程序通常不会产生任何输出,这意味着您可以在不意识到的情况下对部分或全部代码进行优化,这时您所测得的执行量会比您想象的要少。 特别是,许多微基准测试性能要“更好”时运行-server比-client ,不是因为服务器编译器更快(尽管它经常是),但因为服务器编译器在优化掉死代码块更擅长。 不幸的是,使基准测试工作如此短的死代码优化(可能全都对其进行了优化)在实际执行某些操作的代码方面做得不好。

    一个奇怪的结果

    清单1包含一个基准测试,该基准带有一句什么也没做的代码,该代码取自一个旨在测量并发线程性能的基准,但实际上它测量的是完全不同的东西。 (这个例子是从优秀的JavaOne 2003演示文稿见“的标杆。黑色艺术”借来的相关主题 。)

    清单1.基准由于意外的死代码而失真
    public class StupidThreadTest { public static void doSomeStuff() { double uselessSum = 0; for (int i=0; i<1000; i++) { for (int j=0;j<1000; j++) { uselessSum += (double) i + (double) j; } } } public static void main(String[] args) throws InterruptedException { doSomeStuff(); int nThreads = Integer.parseInt(args[0]); Thread[] threads = new Thread[nThreads]; for (int i=0; i<nThreads; i++) threads[i] = new Thread(new Runnable() { public void run() { doSomeStuff(); } }); long start = System.currentTimeMillis(); for (int i = 0; i < threads.length; i++) threads[i].start(); for (int i = 0; i < threads.length; i++) threads[i].join(); long end = System.currentTimeMillis(); System.out.println("Time: " + (end-start) + "ms"); } }

    应该使用doSomeStuff()方法来给线程做一些事情,因此我们可以从StupidThreadBenchmark的运行时推断出多个线程的调度开销。 但是,编译器可以确定doSomeStuff中的所有代码都是doSomeStuff的,并可以对其进行优化,因为从未使用过uselessSum 。 一旦循环中的代码消失,循环也可以消失,而doSomeStuff完全留空。 表1显示了StupidThreadBenchmark在客户端和服务器上的性能。 两种JVM都显示出与线程数量大致成线性关系的运行时间,这很容易被误解为服务器JVM比客户端JVM快40倍。 实际上,正在发生的事情是服务器编译器进行了更多优化,并且可以检测到doSomeStuff的整体是doSomeStuff代码。 尽管许多程序确实实现了服务器JVM的加速,但是您在此处看到的加速只是衡量基准测试不当的一种衡量指标,而不是服务器JVM出色的性能。 但是,如果您不仔细看,就很容易将一个错误误认为另一个错误。

    表1.客户端和服务器JVM下的StupidThreadBenchmark的性能
    线程数 客户端JVM运行时间 服务器JVM运行时间 10 43 2 100 435 10 1000 4142 80 10000 42402 1060

    同样,处理急切的死代码消除也是基准测试静态编译语言的问题。 但是,使用静态编译语言,检测编译器是否消除了基准测试的很大一部分要容易得多。 您可以查看生成的机器代码,并发现程序中缺少一部分。 使用动态编译的语言,您将无法轻松访问该信息。

    暖身

    如果您要评估成语X的性能,通常需要评估其编译性能,而不是其解释性能。 (您想知道X在该字段中的运行速度。)为此,需要“预热” JVM-执行目标操作足够多次,以使编译器有时间运行并将解释后的代码替换为编译后的代码在开始计时之前。

    对于没有堆栈替换的早期JIT和动态编译器,有一个简单的公式可以衡量方法的编译性能:运行该方法的一定次数的调用,启动计时器,然后再执行该方法多次。 如果预热调用的数量超过了编译该方法的阈值,则实际的定时调用将全部是已编译的代码,并且所有编译开销都将在开始计时之前发生。

    使用当今的动态编译器,要困难得多。 编译器的运行时间不太可预测,JVM会随意将解释的代码切换为已编译的代码,并且同一代码路径在运行期间可能会多次编译和重新编译。 如果您不考虑这些事件的时间安排,它们会严重扭曲您的时间安排结果。

    图1显示了由于不可预测的动态编译而导致的一些可能的时序失真。 假设您要通过一个循环进行200,000次迭代,并且编译后的代码比解释后的代码快10倍。 如果编译以200,000次迭代开始,则仅会测量解释的性能(时间轴(a))。 如果编译以100,000次迭代开始,那么您的总运行时间就是执行200,000次解释迭代的时间,加上编译时间(您不希望包含在内),再加上执行100,000次编译迭代的时间(时间轴(b)) )。 如果开始进行20,000次迭代,则将是20,000个解释的迭代,再加上编译时间,再加上180,000次编译的迭代(时间轴(c))。 因为您不知道编译器何时运行或运行多长时间,所以您可以看到测量结果如何严重失真。 根据编译时间以及编译后的代码比解释后的代码快多少,迭代次数的细微变化可能会导致测量的“性能”差异很大。

    图1.由于动态编译的时序而导致的性能测量失真。

    那么,预热多少就足够了? 你不知道 最好的办法是使用-XX:+PrintCompilation运行基准测试,观察导致编译器启动的原因,然后重新构造基准测试程序,以确保所有这些编译都在开始计时之前发生,并且没有进一步的编译发生在您的计时循环中间。

    不要忘记垃圾收集

    因此,您已经看到,如果想要获得准确的计时结果,则必须运行代码进行测试的次数甚至超过允许JVM预热的次数。 另一方面,如果测试代码根本不执行任何对象分配(并且几乎所有代码都执行),则将创建垃圾,最终垃圾收集器将必须运行。 这是另一个可能严重扭曲时序结果的元素-迭代次数的小幅变化可能意味着无垃圾收集和一个垃圾收集之间的差异,从而使“每次迭代时间”的测量值产生偏差。

    如果使用-verbose:gc运行基准测试, -verbose:gc可以查看在垃圾回收上花费了多少时间,并相应地调整了计时数据。 更好的是,您可以长时间运行程序,以确保触发许多垃圾回收,更准确地摊销分配和垃圾回收成本。

    动态反优化

    许多标准优化只能在“基本块”内执行,因此内联方法调用通常对于实现良好的优化很重要。 通过内联方法调用,不仅消除了方法调用的开销,而且还为优化器提供了更大的优化基础块,并为死代码优化提供了大量机会。

    清单2显示了通过内联启用的优化类型的示例。 outer()方法使用参数null调用inner() ,这将导致inner()执行任何操作。 但是通过内联对inner()的调用,编译器可以看到inner()的else分支是无效代码,可以优化测试,而else分支可以移开,这时它可以优化对tol的整个调用。 inner() 。 如果未内联inner()不可能实现此优化。

    清单2.内联如何可以导致更好的死代码优化
    public class Inline { public final void inner(String s) { if (s == null) return; else { // do something really complicated } } public void outer() { String s=null; inner(s); } }

    麻烦的是,虚函数阻碍了内联,并且虚函数调用在Java语言中比在C ++中更为常见。 假设编译器正在尝试优化以下代码中对doSomething()的调用:

    Foo foo = getFoo(); foo.doSomething();

    编译器不一定从该代码片段中知道将执行哪个版本的doSomething() -它是在Foo类中还是在Foo的子类中实现的版本? 在某些情况下答案很明显-例如Foo是final还是doSomething()被定义为Foo的final方法-但是大多数时候,编译器不得不猜测。 如果静态编译器一次只编译一个类,那我们就不走运了。 但是动态编译器可以使用全局信息来做出更好的决策。 假设在此应用程序中没有扩展Foo已加载类。 现在的情况更像doSomething()是Foo的final方法的情况-编译器可以将虚拟方法调用转换为直接调度(已经有所改进),并且可以选择内联doSomething() ,也一样 (将虚拟方法调用转换为直接方法调用称为单态调用转换 。)

    等等-类可以动态加载。 如果编译器进行了这样的优化,然后加载了扩展Foo的类,会发生什么? 更糟糕的是,如果在工厂方法getFoo()完成此操作,然后getFoo()返回新的Foo子类的实例怎么办? 生成的代码将不正确吗? 是的,它会的。 但是JVM可以解决这个问题,并将基于现在无效的假设使生成的代码无效,并恢复为解释(或重新编译无效的代码路径)。

    结果是,编译器可以做出积极的内联决策以实现更高的性能,然后在不再基于有效假设的情况下稍后撤消这些决策。 实际上,这种优化非常有效,以至于将final关键字添加到未被覆盖的方法(早期文献中建议的性能技巧)对提高实际性能几乎没有作用。

    一个奇怪的结果

    清单3包含了一个代码模式,该模式结合了不正确的预热,单态调用转换和去优化,从而产生了完全没有意义但容易误解的结果:

    清单3.测试程序的结果由于单态调用转换和随后的去优化而失真
    public class StupidMathTest { public interface Operator { public double operate(double d); } public static class SimpleAdder implements Operator { public double operate(double d) { return d + 1.0; } } public static class DoubleAdder implements Operator { public double operate(double d) { return d + 0.5 + 0.5; } } public static class RoundaboutAdder implements Operator { public double operate(double d) { return d + 2.0 - 1.0; } } public static void runABunch(Operator op) { long start = System.currentTimeMillis(); double d = 0.0; for (int i = 0; i < 5000000; i++) d = op.operate(d); long end = System.currentTimeMillis(); System.out.println("Time: " + (end-start) + " ignore:" + d); } public static void main(String[] args) { Operator ra = new RoundaboutAdder(); runABunch(ra); // misguided warmup attempt runABunch(ra); Operator sa = new SimpleAdder(); Operator da = new DoubleAdder(); runABunch(sa); runABunch(da); } }

    StupidMathTest首先尝试进行一些预热( StupidMathTest成功),然后测量SimpleAdder , DoubleAdder和RoundaboutAdder的运行时间,结果如表2所示。通过将2加1将其加到double看起来要快得多,然后减去1,而不是直接直接加1。 而且加0.5两次比加1快一点。 (答案:否)

    表2. StupidMathTest的毫无意义和误导性的结果
    方法 运行 SimpleAdder 88毫秒 双重加法器 76毫秒 回旋处 14毫秒

    这里发生了什么? 好了,在预热循环之后,已经编译了RoundaboutAdder和runABunch() ,并且编译器对Operator和RoundaboutAdder进行了单态调用转换,因此第一遍运行很快。 在第二遍( SimpleAdder )中,编译器不得不SimpleAdder优化并退回到虚拟方法分派,因此第二遍反映了由于无法优化虚拟函数调用而导致执行速度较慢,以及重新编译所花费的时间。 在第三遍( DoubleAdder )中,重新编译较少,因此运行速度更快。 (在现实中,编译器会做的常量折叠RoundaboutAdder和DoubleAdder ,产生完全相同的代码SimpleAdder 。所以,如果有一个在运行时间差,这是因为算术代码没有。)无论一个人跑去首先将是最快的。

    那么,我们可以从这个“基准”中得出什么结论呢? 几乎没有什么,除了对动态编译语言进行基准测试比您想象的要微妙得多。

    结论

    此处示例中的结果显然是错误的,以至于显然必须进行其他操作,但是较小的影响可能会轻易使性能测试程序的结果产生偏差,而不会触发“此处必须存在某些严重错误”检测器。 尽管此处介绍的是微基准失真的常见来源,但还有许多其他来源。 故事的寓意:您并不总是在衡量自己认为的内容。 实际上,您通常不会衡量自己认为要衡量的内容。 非常警惕任何长时间不涉及实际程序负载的性能测量结果。


    翻译自: https://www.ibm.com/developerworks/java/library/j-jtp12214/index.html

    Processed: 0.017, SQL: 9