动态编译与性能测量

11 篇文章 0 订阅
6 篇文章 0 订阅

 

原文:《Dynamic Compilation and performance measurement

 

动态编译与性能测量

前言

这个月我着手写一篇文章,剖析一个写得非常糟糕的微基准测试。毕竟我们都是痴迷于性能的程序员,而且我们都喜欢了解我们所编写、使用或批评的代码的性能特征。因为我偶尔会写一些性能主题的文章,所以我经常会收到一些邮件说“我写的这个程序表明 dynamic frosternation 比 static blestification 快,这与你上一篇文章相反!”(我不知道是啥)许多这些邮件中附带的所谓“基准测试”程序,或者它们运行的方式,表明其作者对 JVM 真正如何执行Java字节码缺乏实质性的了解。所以在开始写那篇文章之前,让我们看一下JVM的内部细节。了解动态编译和优化是了解如何区分微基准测试好坏的关键(好的微基准测试少得可怜)。

 

动态编译的一段简史

Java 应用的编译过程与C、C++那样的静态编译语言不同。一个静态编译器会直接将源代码转换为能直接在目标平台上运行的机器码,而且不同的硬件平台需要不同的编译器。Java编译器将源代码转换为可移植的JVM字节码,也就是JVM的虚拟机指令。与静态编译器不同,javac(Java编译器)(对代码)做的优化非常少——静态编译语言的编译器(对代码)所做的优化(在Java平台中)会在应用被运行时执行。

第一代JVM是完全解释模式(执行)的。JVM会将字节码解释成机器码,而不是(将源码)编译为机器码并直接执行机器码。当然,这种方式无法提供最佳性能,因为系统花在运行解释器上的时间比(我们)想要运行的程序上更多。

 

及时(Just-in-time)编译

对于概念证明(POC,proof-of-concept),解释(模式)没有问题。但是早期的JVM因为(执行效率)太慢而口碑很差。后一代的JVM应用了 及时(JIT)编译器 来加速执行。严格来说,一个基于JIT的虚拟机在执行(字节码)前会将所有(相关)字节码转换为机器码,但是这是以懒模式进行的的:只有当JIT知道一段代码(code path)将被执行时,它才会编译这段代码(这也是“及时”这个名称的由来)。这种方式允许程序更快得启动,因为在不执行(任何代码)时无需一个冗长的编译阶段。

JIT方式看上去前景光明,但是它有一些弊端。JIT编译消除了解释(模式)的开销(解释模式的代价是花费一些额外的启动时开销),但是因为几个原因它的代码优化程度不够好。对Java应用来说,为了避免过重的启动时开销,JIT编译器不得不快,这意味着它不能在(代码)优化上花很多时间。而且早期JIT编译器因为不知道之后会加载什么类,所以在做“内联”假设时都比较保守。

从技术上说,基于JIT的虚拟机在执行每一段字节码前都会将其编译为机器码。但是 “JIT” 这个术语通常会被用来指代任何会将字节码动态编译为机器码的虚拟机——甚至包括那些可以(同时)解释(执行)字节码的虚拟机。

 

Hotspot 动态编译

Hotspot 的运行过程结合了解释(执行)、统计学习(profiling)和动态编译。Hotspot 一开始先以一个解释器运行,且只编译那些“热点”代码——执行频率最高的代码,而不是在执行所有(任何)字节码前都将其转换为机器码。在它的运行过程中,它会获取统计学习的数据(profiling data),并将这些数据用于决定哪些代码被执行的频率已足够高到值得被编译。只编译经常被执行的代码有几个性能优势:没有时间会被浪费于编译执行频率不高的代码,从而编译器可以将更多的时间用于热点代码路径的优化,因为它知道这些时间可以被很好的利用。更进一步来说,通过推迟编译,编译器可以利用统计学习数据(profiling data)来改善优化决策。比如,是否要内联化一个特定的方法调用。

为了使事情变得更复杂,Hotspot 有两个编译器:客户端编译器和服务端编译器。默认使用客户端编译器。你可以通过在启动JVM时指定 -server 开关来选择服务端编译器。服务端编译器被优化过以最大化处理速度的峰值,适用于长期运行的服务端应用。客户端编译器被优化过以减少应用的启动时间与内存占用。它使用的复杂优化比服务端编译器更少,因此所需编译时间更短。

Hotspot 服务端编译器能执行很多可观的优化。它可以执行许多静态编译期中的标准优化。如,代码提升(code hoisting)、公共子表达式消除、循环展开、范围检查消除(range check elimination)、无效代码消除、数据流分析。也包括很多并不是静态编译语言典型的优化,如,对虚方法调用的聚合内联。

 

后续的重新编译

Hotspot (编译)方式的另一个有趣方面是,编译并不是一个(简单的)“有或无”命题。一段代码被解释(执行)的次数达到特定的数量时,它会被编译为机器码。但是JVM会继续统计学习(Profiling)。如果它发觉这段代码特别“热”,或者后续的统计学习数据建议了额外的优化机会,它会用更高等级的优化来重新编译这段代码。在单次应用执行中,JVM可能会对同一段字节码重新编译很多次。为了了解编译器做了什么事,可以尝试在启动JVM时添加“-XX:+PrintCompilation”标记(启动参数)。这样编译器(包括客户端编译器和服务端编译器)每次运行时都会打印出一段简短的信息。

 

栈上替换

Hotspot 的初始版本一次只编译一个方法。如果一个方法的累计执行次数超过了一个特定的值(Hotspot第一版中是 10000),它就会被视为“热点”。这个数值(累计执行次数)是每个方法各自相关的计数器决定的。方法每次调用,相应的计数都会递增。但是方法被编译后,直到当前这次调用退出后再次进入,才会转而使用编译后的版本。也就是编译后的版本只有在后续的调用中才会被使用。在某些情况下这会导致编译后的版本永远都不会被使用。比如,在计算密集型的应用中,所有的计算操作都在单次方法调用中完成。在这种情况下,重量级的方法可能会被编译,但其编译后的代码永远都不会被使用。

更近一些版本的 Hotspot 使用了一项叫做“栈上替换”(OSR,on-stack replacement)技术,从而允许在一个循环调用过程中从解释模式转换为执行编译后的代码。

 

所以基准测试必须做什么?

我向你们承诺我会出一篇关于基准测试与性能测量的文章。但是目前为止你们得到的只有一段历史以及 Hotspot 白皮书(部分内容)的 整理。之所以先提供这段冗长叙述,是因为如果没有对动态编译过程的了解,几乎不可能写出或解释Java类的性能测试。(即使深刻理解动态编译和JVM优化,也仍然很难做到。)

给Java代码编写微基准测试远比C代码难

判断方式A比方式B更快的传统方法是编写一个小的基准测试程序,通常称为“微基准测试”(microbenchmark)。这种倾向是完全合乎情理的。科学方法必须要有独立的调查。哎,魔鬼存在细节中。为动态编译语言编写并解释基准测试要远比静态编译语言难且复杂得多。通过编写程序并运行的方式来尝试了解一个(给定的数据)结构的性能没什么错。但是在许多情况下,用Java编写的微基准测试并不能告诉你你所想要的。

对于一个C程序,你可以简单地通过查看它编译后的机器码,甚至不需要运行它,就能了解到很多它(可能)的性能特征。编译器所产生的指令是将要被执行的真实机器指令。在通常情况,我们可以相当好地理解这些指令的耗时特征。(当然也有些反常的例子中,因为持续的分支预测未命中或缓存未命中,导致性能远比通过查看机器码所得的期望要差。但是在绝大多数情况下,你可以通过查看机器码来了解一个C程序的许多性能特征。)

如果编译器推断出一个代码块无关而将它优化去除掉(基准测试中一种其实什么也没做的常见场景),你可以查看生成的机器码并发现没有这段代码相应的机器码。而且对于C程序你通常不需要运行很长时间就能得到一些合理的性能推论。

另一方面,当你的(Java)程序在运行时,Hotspot JIT 会持续地将Java字节码重新编译为机器码。而且重新编译操作可以在出乎意料的时刻被某些事件触发。如,统计学习数据(profiling data)累计到特定数量,加载新类,或未在已载入类中找到要执行的代码路径。耗时测量在面对持续重新编译时会充斥相当对的噪声与误导。以致于经常有必要在运行Java代码相当长一段时间后才能获得有用的性能数据(我见过运行几小时甚至几天的趣事)。

 

无效代码消除

编写好的基准测试所面临的项挑战之一是——优化编译器善于发现无效代码——那些对程序运行输出无影响的代码。但是基准测试程序经常不会产生任何输出。这意味着你的部分或所有代码会在你没有意识到的情况下被优化去除,你在这部分代码上所测的执行(指令)比你认为的要少。尤其是当以 -server 模式运行而不是 -client 模式时,许多微基准测试的(性能)表现会更好。这不是因为服务端编译器更快(尽管它通常更快),而是服务端编译器更擅长优化去除无效代码。不幸的是,无效代码消除导致你基准测试(可能被全部优化去除)的表现不会和真实代码相当。

一个奇怪的结果

以下代码中的基准测试包含了一个什么也没做的代码块。该基准测试节选自一个想要测量并发线程性能的基准测试。但是这个基准测试(真正)测的却是一些完全不同的东西。(来例子借用自优秀的 JavaOne 2003 演示文稿 “基准测试的黑暗艺术”。见相关话题

被无意的无效代码扭曲的基准测试:

Java代码

 

  1. public class StupidThreadTest {  

  2.     public static void doSomeStuff() {  

  3.         double uselessSum = 0;  

  4.         for (int i=0; i<1000; i++) {  

  5.             for (int j=0;j<1000; j++) {  

  6.                 uselessSum += (double) i + (double) j;  

  7.             }  

  8.         }  

  9.     }  

  10.    

  11.     public static void main(String[] args) throws InterruptedException {  

  12.         doSomeStuff();  

  13.            

  14.         int nThreads = Integer.parseInt(args[0]);  

  15.         Thread[] threads = new Thread[nThreads];  

  16.         for (int i=0; i<nThreads; i++)  

  17.             threads[i] = new Thread(new Runnable() {  

  18.                 public void run() { doSomeStuff(); }  

  19.             });  

  20.         long start = System.currentTimeMillis();  

  21.         for (int i = 0; i < threads.length; i++)  

  22.             threads[i].start();  

  23.         for (int i = 0; i < threads.length; i++)  

  24.             threads[i].join();  

  25.         long end = System.currentTimeMillis();  

  26.         System.out.println("Time: " + (end-start) + "ms");  

  27.     }  

  28. }  

 

doSomeStuff() 方法被假定可以让线程做一些操作,所以我们可以通过 StupidThreadBenchmark 的运行耗时来推断多线程编排消耗。但因为 uselessSum 从不会被使用,编译器能推断出 doSomeStuff 中的所有代码都是无效的,并将其完全优化去除。一旦循环中的代码消失了,循环操作也可以被去掉,只剩下一个完全空的 doSomeStuff。下表显示了 StupidThreadBenchmark 分别在客户端模式和服务端模式下的性能表现。两种 JVM (模式)都大致上显示运行耗时与线程数是线性关系,这很容易被误解释为服务端模式的JVM比客户端模式快40倍。事实上,这(背后)发生的是服务端编译器做了更多的优化,它可以检测到整个 doSomeStuff 是无效代码。尽管许多程序在服务端JVM模式下会有速度上的提升,但你在这里看到的速度提升只是对一个写得很糟糕的基准测试的测量,而不是服务端编译器性能亮眼。但是如果你看得不仔细非常容易把两者搞混。

线程数客户端模式JVM耗时服务端模式JVM耗时
10432
10043510
1000414280
10000424021060

 

对静态编译语言来说,处理“过度”的无效代码消除也是基准测试的问题之一。但是在静态编译语言中,检测编译器是否有将你基准测试中的大块(代码)消除掉要容易很多。你可以查看生成的机器码并发现你的程序有一块缺失了。但在动态编译语言中,你就没这么容易能获得这些信息了。

 

预热

如果你想测量X的性能,通常你想要测量它编译后的性能,而不是它被解释(执行)时的性能。(你想知道X在真实“战场”上有多快。)为了达到这个目的,你需要预热JVM——执行你的目标操作足够多的次数,这样编译器就有时间在开始测量耗时前将解释执行的代码替换为编译后的代码并执行。

在没有栈上替换的早期JIT与动态编译器上,有一个简单的套路来测量一个方法被编译后的性能:调用该方法达到特定的次数,开始计时,然后再执行该方法额外的次数。如果预热调用次数超过了方法可以被编译的阀值,真实测量的那些调用都是编译后的代码,所有编译的开销都将在你开始计时前。

今天的动态编译器更难处理得多。编译器运行的时间点更无法预测,JVM“随意”地cong从解释模式切换到编译模式,而且同一段代码可能(程序)单次运行期间被编译与重新编译多次。如果你不考虑这些事件的耗时,它们将严重扭曲你的测试结果。

下图显示了一些由无法预测的动态编译所可能导致的计时失真。比如说,你要测量一个循环执行200,000次迭代的耗时,且编译后的代码比解释型的代码快10倍。如果编译操作发生在第200,000次迭代,你只能测量到解释执行的性能(时间线A)。如果编译操作发生在第100,000次迭代,你的总计运行时长是解释执行100,000次(原文有误)迭代的耗时,加上编译操作的耗时(你并不想把这段时长包括进去),加上执行100,000次编译后代码的耗时(时间线B)。如果编译操作发生在第20,000此迭代,总耗时将是解释执行20,000次迭代的耗时,加上编译耗时,加上180,000次编译后迭代的耗时(时间线C)。因为你不知道编译器会在什么时候运行,也不知道运行多久,你会看到你的测量会被扭曲得多么严重。取决于编译耗时与编译后的代码比解释模式代码快多少,在迭代次数上的小改动会导致测量到的性能有很大差异。

那么,多少预热足够了呢?你不知道。你能做到最好的是在运行你的基准测试时添加JVM启动参数 -XX:+PrintCompliation,观察是什么导致了编译器介入,然后重新调整你的基准测试程序确保所有这些编译操作在你开始计时前发生并且在你的计时循环中没有编译操作发生。

 

不要忘了垃圾收集

那么你已经看到了如果你想得到精确的计时结果,你必须运行被测代码的次数甚至比你以为可以让JVM预热的次数更多。另一方面,如果测试代码有任何对象分配(几乎所有的代码都会),它将产生垃圾,并且最终垃圾收集器将不得不运行。这是另一个会严重扭曲计时结果的要素——迭代次数的一个小改动可能意味着“没有垃圾收集”与“一次垃圾收集”的不同,进而扭曲对“每次迭代耗时”的测量。

如果你在运行基准测试时加入(启动参数)-verbose:gc,你可以看到垃圾收集花费了多长时间,然后根据该数值调整你的计时数据。如果你可以让你的程序运行很长很长时间,确保触发了许多次垃圾收集,更准确地分摊(对象)分配与垃圾收集的开销。

 

动态反优化

许多标准优化只能在“基本块”(basic block)内执行,因此内联方法调用对于实现良好的优化通常比较重要。通过内联方法调用,不仅可以减少方法调用的开销,而且还可以让优化器有重要的机会对更大的基本块(basic block)进行无效代码优化。

以下代码显示了一个通过内联优化的例子。outer() 方法调用了 inner(),参数为 null,这将导致 inner() 什么都不会做。但是通过内联调用 inner(),编译器可以发现 inner() 的 else 分支是无效代码,可以去掉 else 分支来“优化”测试,进而它可以优化去除整个对 inner() 的调用。如果 inner() 没有被内联,这个优化就不可能了。

Java代码

 

  1. public class Inline {  

  2.   public final void inner(String s) {  

  3.     if (s == null)  

  4.       return;  

  5.     else {  

  6.       // do something really complicated  

  7.     }  

  8.   }  

  9.    

  10.   public void outer() {  

  11.     String s=null;   

  12.     inner(s);  

  13.   }  

  14. }  

虚方法对内联来说是障碍,而且Java中的虚方法调用比C++更平常。比如说,编译器想尝试把对 doSomething() 的调用优化为如下代码:

Java代码

 

  1. Foo foo = getFoo();  

  2. foo.doSomething();  

但是编译器不能从这段代码中推断出该执行哪个版本的 doSomething() ——它将是Foo类中实现的那个版本还是Foo的某个子类中的版本?在某些情况下,这个答案是显而易见的。如,Foo类是final类,或者 Foo中的 doSomething() 是 final 方法。但大多数时候,编译器只能猜。对于一个类,静态编译器只需编译一次,但我们(动态编译器)就没这个幸运了。但是动态编译器可以利用全局信息作出更好的决定。比如说,在这个应用中有几个继承自Foo的类尚未被载入。现在的场景更像是 doSomething() 是 Foo 中的一个 final 方法——编译器可以将该虚方法调用转换为直接调用(这已经是一个改进),而且,更进一步,也还有内联 doSomething 的选择。(将一个虚方法调用转换为直接方法调用称为单态调用转换。)

等一下,类可以被动态地加载。如果编译器可以做这个优化将发生什么?之后由加载了一个继承自 Foo 的类呢?更糟糕的是,如果这个操作(加载新类)发生在工厂方法 getFoo() 内,且 getFoo() 随后返回了一个 Foo 的新子类的实例呢?然后生成代码(机器码)难道不会不正确吗?是的,它将不正确。但是JVM能够发现这个,并会基于“当前无效”的假设将(先前)生成的代码(机器码)作废,并恢复到解释模式(或者重新编译这个(机器码)已失效的代码(字节码))。

结果就是编译器能作出聚合内联的决定,来达到更高的性能,如果随后这些优化不再是基于正确的假设时撤销这些决定。事实上,这个优化是如此高效,以至于“在未被重写(override)的方法上添加 final 关键字”几乎没有对真实性能的提升。(这是早期文章中建议的提高性能的诀窍。)

 

一个奇怪的结果

以下代码样式包含了不正确的预热、单态调用转换及反优化,导致完全没有意义但却很容易被误解释的结果:

Java代码

 

  1. public class StupidMathTest {  

  2.     public interface Operator {  

  3.         public double operate(double d);  

  4.     }  

  5.    

  6.     public static class SimpleAdder implements Operator {  

  7.         public double operate(double d) {  

  8.             return d + 1.0;  

  9.         }  

  10.     }  

  11.    

  12.     public static class DoubleAdder implements Operator {  

  13.         public double operate(double d) {  

  14.             return d + 0.5 + 0.5;  

  15.         }  

  16.     }  

  17.    

  18.     public static class RoundaboutAdder implements Operator {  

  19.         public double operate(double d) {  

  20.             return d + 2.0 - 1.0;  

  21.         }  

  22.     }  

  23.    

  24.     public static void runABunch(Operator op) {  

  25.         long start = System.currentTimeMillis();  

  26.         double d = 0.0;  

  27.         for (int i = 0; i < 5000000; i++)  

  28.             d = op.operate(d);  

  29.         long end = System.currentTimeMillis();  

  30.         System.out.println("Time: " + (end-start) + "   ignore:" + d);  

  31.     }  

  32.    

  33.     public static void main(String[] args) {  

  34.         Operator ra = new RoundaboutAdder();  

  35.         runABunch(ra); // misguided warmup attempt  

  36.         runABunch(ra);  

  37.         Operator sa = new SimpleAdder();  

  38.         Operator da = new DoubleAdder();  

  39.         runABunch(sa);  

  40.         runABunch(da);  

  41.     }  

  42. }  

StupidMathTest 首先尝试做一些预热(没成功),然后测量了 SimpleAdder、DoubleAdder 和 RoundaboutAdder 的运行耗时。下表是测量结果。看上去实现“Double数字加1”的方法中,“先加2,再减1” 比 “简单地直接加1” 快很多。而且“执行两次加0.5”比“加1”还稍微快一点。这有可能吗?(不可能)

方法执行时间
SimpleAdder88ms
DoubleAdder76ms
RoundaboutAdder14ms

这里发生了什么?经过预热循环后,RoundabouotAdder 和 runABunch() 已经被编译了,编译器对 Operator 和 RoundaboutAdder 做了单态调用转换,所以第一轮执行地相当快。在第二轮(SimpleAdder)中,编译器不得不反优化,撤销对虚方法的调度(优化),所以因为不能优化掉虚方法调用,再加上重新编译花费了部分时间,第二轮放映出比较慢。在第三轮(DoubleAdder)中,重新编译的操作更少,所以它运行得更快一点。(在现实中,编译器会对 RoundaboutAdder 和 DoubleAdder 进行常数折叠——生成和 SimpleAdder 完全一样的代码。所以如果(它们)的运行耗时不同,不是因为算术代码不同。)无论哪个先运行,都将运行得最快。

所以我们能从这个“基准测试”中得出什么结论呢?几乎什么都没有,除了对动态编译语言做基准测试比你想象的更微妙地多。

 

总结

这些样例中的结果是如此明显的错误,所以很明显肯定发生了一些其它事情。但是更小的影响都能在不触发你的“这里肯定有严重错误”探测器的情况下,扭曲你的性能测试程序结果。既然这里给出的(样例)是常见的微基准测试曲解来源,那么还有许多其它(被曲解的微基准测试,也就是错误的微基准测试)。这个故事告诉我们:你并不总是在测量你自以为在测量的东西。事实上,你通常不是在测量你自以为在测量的东西。对于任何不涉及长期运行的现实程序(真实应用中的程序)的性能测量结果,都要非常当心。

 

相关话题

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值