動態編譯與性能測量

 

原文:《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耗時
10 43 2
100 435 10
1000 4142 80
10000 42402 1060

 

對靜態編譯語言來說,處理「過度」的無效代碼消除也是基準測試的問題之一。但是在靜態編譯語言中,檢測編譯器是否有將你基準測試中的大塊(代碼)消除掉要容易很多。你可以查看生成的機器碼並發現你的程序有一塊缺失了。但在動態編譯語言中,你就沒這麼容易能獲得這些信息了。

 

預熱

如果你想測量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」還稍微快一點。這有可能嗎?(不可能)

方法 執行時間
SimpleAdder 88ms
DoubleAdder 76ms
RoundaboutAdder 14ms

這裏發生了什麼?經過預熱循環後,RoundabouotAdder 和 runABunch() 已經被編譯了,編譯器對 Operator 和 RoundaboutAdder 做了單態調用轉換,所以第一輪執行地相當快。在第二輪(SimpleAdder)中,編譯器不得不反優化,撤銷對虛方法的調度(優化),所以因爲不能優化掉虛方法調用,再加上重新編譯花費了部分時間,第二輪放映出比較慢。在第三輪(DoubleAdder)中,重新編譯的操作更少,所以它運行得更快一點。(在現實中,編譯器會對 RoundaboutAdder 和 DoubleAdder 進行常數摺疊——生成和 SimpleAdder 完全一樣的代碼。所以如果(它們)的運行耗時不同,不是因爲算術代碼不同。)無論哪個先運行,都將運行得最快。

所以我們能從這個「基準測試」中得出什麼結論呢?幾乎什麼都沒有,除了對動態編譯語言做基準測試比你想象的更微妙地多。

 

總結

這些樣例中的結果是如此明顯的錯誤,所以很明顯肯定發生了一些其它事情。但是更小的影響都能在不觸發你的「這裏肯定有嚴重錯誤」探測器的情況下,扭曲你的性能測試程序結果。既然這裏給出的(樣例)是常見的微基準測試曲解來源,那麼還有許多其它(被曲解的微基準測試,也就是錯誤的微基準測試)。這個故事告訴我們:你並不總是在測量你自以爲在測量的東西。事實上,你通常不是在測量你自以爲在測量的東西。對於任何不涉及長期運行的現實程序(真實應用中的程序)的性能測量結果,都要非常當心。

 

相關話題