為動態編譯的語言(例如 Java)編寫和解釋性能評測,要比為靜態編譯的語言(例如 C 或 C++)編寫困難得多。在這期的 Java 理論與實踐 中,Brian Goetz 介紹了動態編譯使性能測試復雜的諸多原因中的一些。請在本文附帶的討論組上與作者和其他讀者分享您對本文的看法。 (您也可以選擇本文頂部或底部的 討論 訪問論壇。)
這個月,我著手撰寫一篇文章,分析一個寫得很糟糕的微評測。畢竟,我們的程序員一直受性能困擾,我們也都想了解我們編寫、使用或批評的代碼的性能特征。當我偶然間寫到性能這個主題時,我經常得到這樣的電子郵件:“我寫的這個程序顯示,動態 frosternation 要比靜態 blestification 快,與您上一篇的觀點相反!”許多隨這類電子郵件而來的所謂“評測“程序,或者它們運行的方式,明顯表現出他們對于 JVM 執行字節碼的實際方式缺乏基本認識。所以,在我著手撰寫這樣一篇文章(將在未來的專欄中發表)之前,我們先來看看 JVM 幕后的東西。理解動態編譯和優化,是理解如何區分微評測好壞的關鍵(不幸的是,好的微評測很少)。
動態編譯簡史
Java 應用程序的編譯過程與靜態編譯語言(例如 C 或 C++)不同。靜態編譯器直接把源代碼轉換成可以直接在目標平臺上執行的機器代碼,不同的硬件平臺要求不同的編譯器。 Java 編譯器把 Java 源代碼轉換成可移植的 JVM 字節碼,所謂字節碼指的是 JVM 的“虛擬機器指令”。與靜態編譯器不同,javac 幾乎不做什么優化 —— 在靜態編譯語言中應當由編譯器進行的優化工作,在 Java 中是在程序執行的時候,由運行時執行。
第一代 JVM 完全是解釋的。JVM 解釋字節碼,而不是把字節碼編譯成機器碼并直接執行機器碼。當然,這種技術不會提供最好的性能,因為系統在執行解釋器上花費的時間,比在需要運行的程序上花費的時間還要多。
即時編譯
對于證實概念的實現來說,解釋是合適的,但是早期的 JVM 由于太慢,迅速獲得了一個壞名聲。下一代 JVM 使用即時 (JIT) 編譯器來提高執行速度。按照嚴格的定義,基于 JIT 的虛擬機在執行之前,把所有字節碼轉換成機器碼,但是以惰性方式來做這項工作:JIT 只有在確定某個代碼路徑將要執行的時候,才編譯這個代碼路徑(因此有了名稱“ 即時 編譯”)。這個技術使程序能啟動得更快,因為在開始執行之前,不需要冗長的編譯階段。
JIT 技術看起來很有前途,但是它有一些不足。JIT 消除了解釋的負擔(以額外的啟動成本為代價),但是由于若干原因,代碼的優化等級仍然是一般般。為了避免 Java 應用程序嚴重的啟動延遲,JIT 編譯器必須非常迅速,這意味著它無法把大量時間花在優化上。所以,早期的 JIT 編譯器在進行內聯假設(inlining assumption)方面比較保守,因為它們不知道后面可能要裝入哪個類。
雖然從技術上講,基于 JIT 的虛擬機在執行字節碼之前,要先編譯字節碼,但是 JIT 這個術語通常被用來表示任何把字節碼轉換成機器碼的動態編譯過程 —— 即使那些能夠解釋字節碼的過程也算。
HotSpot 動態編譯
HotSpot 執行過程組合了編譯、性能分析以及動態編譯。它沒有把所有要執行的字節碼轉換成機器碼,而是先以解釋器的方式運行,只編譯“熱門”代碼 —— 執行得最頻繁的代碼。當 HotSpot 執行時,會搜集性能分析數據,用來決定哪個代碼段執行得足夠頻繁,值得編譯。只編譯執行最頻繁的代碼有幾項性能優勢:沒有把時間浪費在編譯那些不經常執行的代碼上;這樣,編譯器就可以花更多時間來優化熱門代碼路徑,因為它知道在這上面花的時間物有所值。而且,通過延遲編譯,編譯器可以訪問性能分析數據,并用這些數據來改進優化決策,例如是否需要內聯某個方法調用。
為了讓事情變得更復雜,HotSpot 提供了兩個編譯器:客戶機編譯器和服務器編譯器。默認采用客戶機編譯器;在啟動 JVM 時,您可以指定 -server
開關,選擇服務器編譯器。服務器編譯器針對最大峰值操作速度進行了優化,適用于需要長期運行的服務器應用程序。客戶機編譯器的優化目標,是減少應用程序的啟動時間和內存消耗,優化的復雜程度遠遠低于服務器編譯器,因此需要的編譯時間也更少。
HotSpot 服務器編譯器能夠執行各種樣的類。它能夠執行許多靜態編譯器中常見的標準優化,例如代碼提升( hoisting)、公共的子表達式清除、循環展開(unrolling)、范圍檢測清除、死代碼清除、數據流分析,還有各種在靜態編譯語言中不實用的優化技術,例如虛方法調用的聚合內聯。
持續重新編譯
HotSpot 技術另一個有趣的方面是:編譯不是一個全有或者全無(all-or-nothing)的命題。在解釋代碼路徑一定次數之后,會把它重新編譯成機器碼。但是 JVM 會繼續進行性能分析,而且如果認為代碼路徑特別熱門,或者未來的性能分析數據認為存在額外的優化可能,那么還有可能用更高一級的優化重新編譯代碼。JVM 在一個應用程序的執行過程中,可能會把相同的字節碼重新編譯許多次。為了深入了解編譯器做了什么,請用 -XX:+PrintCompilation
標志調用 JVM,這個標志會使編譯器(客戶機或服務器)每次運行的時候打印一條短消息。
棧上(On-stack)替換
HotSpot 開始的版本編譯的時候每次編譯一個方法。如果某個方法的累計執行次數超過指定的循環迭代次數(在 HotSpot 的第一版中,是 10,000 次),那么這個方法就被當作熱門方法,計算的方式是:為每個方法關聯一個計數器,每次執行一個后向分支時,就會遞增計數器一次。但是,在方法編譯之后,方法調用并沒有切換到編譯的版本,需要退出并重新進入方法,后續調用才會使用編譯的版本。結果就是,在某些情況下,可能永遠不會用到編譯的版本,例如對于計算密集型程序,在這類程序中所有的計算都是在方法的一次調用中完成的。重量級方法可能被編譯,但是編譯的代碼永遠用不到。
HotSpot 最近的版本采用了稱為 棧上(on-stack)替換 (OSR) 的技術,支持在循環過程中間,從解釋執行切換到編譯的代碼(或者從編譯代碼的一個版本切換到另一個版本)。
那么,這與評測有什么關系?
我向您許諾了一篇關于評測和性能測量的文章,但是迄今為止,您得到的只是歷史的教訓和 Sun 的 HotSpot 白皮書的老調重談。繞這么大的圈子的原因是,如果不理解動態編譯的過程,就不可能正確地編寫或解釋 Java 類的性能測試。(即使深入理解動態編譯和 JVM 優化,也仍然是非常困難的。)
為 Java 代碼編寫微評測遠比為 C 代碼編寫難得多
判斷方法 A 是否比方法 B 更快的傳統方法,是編寫小的評測程序,通常叫做 微評測。這個趨勢非常有意義。科學的方法不能缺少獨立的調查。魔鬼總在細節之中。為動態編譯的語言編寫并解釋評測,遠比為靜態編譯的語言難得多。為了了解某個結構的性能,編寫一個使用該結構的程序一點也沒有錯,但是在許多情況下,用 Java 編寫的微評測告訴您的,往往與您所認為的不一樣。
使用 C 程序時,您甚至不用運行它,就能了解許多程序可能的性能特征。只要看看編譯出的機器碼就可以了。編譯器生成的指令就是將要執行的機器碼,一般情況下,可以很合理地理解它們的時間特征。(有許多有毛病的例子,因為總是遺漏分支預測或緩存,所以性能差的程度遠遠超過查看機器碼所能夠想像的程度,但是大多數情況下,您都可以通過查看機器碼了解 C 程序的性能的很多方面。)
如果編譯器認為某段代碼不恰當,準備把它優化掉(通常的情況是,評測到它實際上不做任何事情),那么您在生成的機器碼中可以看到這個優化 —— 代碼不在那兒了。通常,對于 C 代碼,您不必執行很長時間,就可以對它的性能做出合理的推斷。
而在另一方面,HotSpot JIT 在程序運行時會持續地把 Java 字節碼重新編譯成機器碼,而重新編譯觸發的次數無法預期,觸發重新編譯的依據是性能分析數據積累到一定數量、裝入新類,或者執行到的代碼路徑的類已經裝入,但是還沒有執行過。持續的重新編譯情況下的時間測量會非常混亂、讓人誤解,而且要想獲得有用的性能數據,通常必須讓 Java 代碼運行相當長的時間(我曾經看到過一些怪事,在程序啟動運行之后要加速幾個小時甚至數天),才能獲得有用的性能數據。
清除死代碼
編寫好評測的一個挑戰就是,優化編譯器要擅長找出死代碼 —— 對于程序執行的輸出沒有作用的代碼。但是評測程序一般不產生任何輸出,這就意味著有一些,或者全部代碼都有可能被優化掉,而毫無知覺,這時您實際測量的執行要少于您設想的數量。具體來說,許多微評測在用 -server
方式運行時,要比用 -client
方式運行時好得多,這不是因為服務器編譯器更快(雖然服務器編譯器一般更快),而是因為服務器編譯器更擅長優化掉死代碼。不幸的是,能夠讓您的評測工作非常短(可能會把評測完全優化掉)的死代碼優化,在處理實際做些工作的代碼時,做得就不會那么好了。
奇怪的結果
清單 1 的評測包含一個什么也不做的代碼塊,它是從一個測試并發線程性能的評測中摘出來的,但是它實際測量的根本不是要評測的東西。(這個示例是從 JavaOne 2003 的演示 “The Black Art of Benchmarking” 中借用的。請參閱 參考資料。)
清單 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
的運行時間推導出多線程調度開支的一些情況。但是,因為 uselessSum
從沒被用過,所以編譯器能夠判斷出 doSomeStuff
中的全部代碼是死的,然后把它們全部優化掉。一旦循環中的代碼消失,循環也就消失了,只留下一個空空如也的 doSomeStuff
。表 1 顯示了使用客戶機和服務器方式執行 StupidThreadBenchmark
的性能。兩個 JVM 運行大量線程的時候,都表現出差不多是線性的運行時間,這個結果很容易被誤解為服務器 JVM 比客戶機 JVM 快 40 倍。而實際上,是服務器編譯器做了更多優化,發現整個 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
開關運行評測,您可以看到在垃圾收集上耗費了多少時間,并相應地調整您的計時數據。更好一些的話,您可以長時間運行您的程序,這可以保證觸發許多垃圾收集,從而更精確地分攤垃圾收集的成本。
動態反優化(deoptimization)
許多標準的優化只能在“基本塊”內執行,所以內聯方法調用對于達到好的優化通常很重要。通過內聯方法調用,不僅方法調用的開支被清除,而且給優化器提供了更大的優化塊可以優化,會帶來相當大的死代碼優化機會。
清單 2 顯示了一個通過內聯實現的這類優化的示例。 outer()
方法用參數 null
調用 inner()
,結果是 inner()
什么也不做。但是通過把 inner()
的調用內聯,編譯器可以發現 inner()
的 else
分支是死的,因此能夠把測試和 else
分支優化掉,在某種程度上,它甚至能把整個對 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()
。(把虛方法調用轉換成直接方法調用,叫做 單形(monomorphic)調用變換。)
請稍等 —— 類可以動態裝入。如果編譯器進行了這樣的優化,然后裝入了一個擴展了 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
首先試圖做些預熱(沒有成功),然后測量 SimpleAdder
、 DoubleAdder
、 RoundaboutAdder
的運行時間,結果如表 2 所示。看起來好像先加 1,再加 2 ,然后再減 1 最快。加兩次 0.5 比加 1 還快。這有可能么?(答案是:不可能。)
表 2. StupidMathTest 毫無意義且令人誤解的結果
方法 |
運行時間 |
SimpleAdder |
88ms |
DoubleAdder |
76ms |
RoundaboutAdder |
14ms |
這里發生什么呢?在預熱循環之后, RoundaboutAdder
和 runABunch()
確實已經被編譯了,而且編譯器 Operator
和 RoundaboutAdder
上進行了單形調用轉換,第一輪運行得非常快。而在第二輪( SimpleAdder
)中,編譯器不得不反優化,又退回虛函數分配之中,所以第二輪的執行表現得更慢,因為不能把虛函數調用優化掉,把時間花在了重新編譯上。在第三輪( DoubleAdder
)中,重新編譯比第二輪少,所以運行得就更快。(在現實中,編譯器會在 RoundaboutAdder
和 DoubleAdder
上進行常數替換(constant folding),生成與 SimpleAdder
幾乎相同的代碼。所以如果在運行時間上有差異,那么不是因為算術代碼)。哪個代碼首先執行,哪個代碼就會最快。
那么,從這個“評測”中,我們能得出什么結論呢?實際上,除了評測動態編譯語言要比您可能想到的要微妙得多之外,什么也沒得到。
結束語
這個示例中的結果錯得如此明顯,所以很清楚,肯定發生了什么,但是更小的結果能夠很容易地歪曲您的性能測試程序的結果,卻不會觸發您的“這里肯定有什么東西有問題”的警惕。雖然本文列出的這些內容是微評測歪曲的一般來源,但是還有許多其他來源。本文的中心思想是:您正在測量的,通常不是您以為您正在測量的。實際上,您通常所測量的,不是您以為您正在測量的。對于那些沒有包含什么實際的程序負荷,測試時間不夠長的性能測試的結果,一定要非常當心。