即使性能不是當前項目的一個關鍵需求,甚至沒有被標明為一個需求,通常也難於忽略性能問題,因為您可能會認為忽略性能問題將使自己成為“差勁的工程師”。開發人員在以編寫高性能代碼為目標的時候,常常會編寫小的基准程序來度量一種方法相對於另一種方法的性能。不幸的是,正如您在 December 撰寫的 "動態編譯與性能測量" 這期文章中所看到的,與其他靜態編譯的語言相比,評論用 Java 語言編寫的給定慣用法(idiom)或結構體的性能要困難得多。
一個有缺陷的微基准
在我發表了十月份的文章 "JDK 5.0 中更靈活、更具可伸縮性的鎖定機制" 之後,一個同事給我發了 SyncLockTest 基准(如清單 1 所示),據說用它可以判斷 synchronized 與新的 ReentrantLock 類哪一個“更快”。他在自己的手提電腦上運行了該基准之後,作出了與那篇文章不同的結論,說同步要更快些,並且給出了他的基准作為“證據”。整個過程 —— 微基准的設計、實現、執行和對結果的解釋 —— 在很多方面都存在缺陷。其實我這個同事是個很聰明的家伙,並且對這個基准也花了不少功夫,可見這種事有多難。
清單 1. 有缺陷的 SyncLockTest 微基准
interface Incrementer {
void increment();
}
class LockIncrementer implements Incrementer {
private long counter = 0;
private Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
++counter;
} finally {
lock.unlock();
}
}
}
class SyncIncrementer implements Incrementer {
private long counter = 0;
public synchronized void increment() {
++counter;
}
}
class SyncLockTest {
static long test(Incrementer incr) {
long start = System.nanoTime();
for(long i = 0; i < 10000000L; i++)
incr.increment();
return System.nanoTime() - start;
}
public static void main(String[] args) {
long synchTime = test(new SyncIncrementer());
long lockTime = test(new LockIncrementer());
System.out.printf("synchronized: %1$10d\n", synchTime);
System.out.printf("Lock: %1$10d\n", lockTime);
System.out.printf("Lock/synchronized = %1$.3f",
(double)lockTime/(double)synchTime);
}
}
SyncLockTest 定義了一個接口的兩種實現,並使用 System.nanoTime() 來計算每種實現運行 10,000,000 次的時間。在保證線程安全的情況下,每種實現增加一個計數器;其中一種實現使用內建的同步,而另一種實現則使用新的 ReentrantLock 類。此舉的目的是回答以下問題:“哪一個更快,同步還是 ReentrantLock?”讓我們看看為什麼這個表面上沒有問題的基准最終沒能成功地度量出想要度量的東西,甚至沒有度量出任何有用的東西。
構想上的缺陷
暫時先不談實現上的缺陷, SyncLockTest 首先從構想上就存在缺陷 —— 它誤解了它要回答的問題。這個基准的目的是要度量同步和 ReentrantLock 的性能代價,它們是用於協調多個線程的行為的不同技術。然而,該測試程序只包含一個線程,因而顯然不存在競爭。它沒有首先測試那些真正與鎖相關的場景!
在早期的 JVM 實現中,無競爭的同步比較慢,這是眾所周知的。然而,從那以後無競爭的同步的性能從本質上已經有了很大的提高。(請參閱參考資料中列出的描述 JVM 用來優化無競爭同步性能的一些技術的文章)。另一方面,有競爭的同步比起無競爭同步來仍然要慢得多。當一個鎖處於爭用狀態下時,JVM 不但要維護一個等待線程隊列,而且還必須使用系統調用來阻塞和消除阻塞不能立即得到鎖的線程。而且,在高度競爭環境下的應用程序表現出來的吞吐量通常會更低,這不僅是因為花在調度線程上的時間更多了,花在做實際工作上的時間更少了,而且當線程為了等待某一個鎖而被阻塞時,CPU 可能處於空閒狀態。用來度量同步性能的基准應該考慮實際的競爭程度。
方法上的缺陷
除了設計上的失敗,在執行方面至少也有兩大敗筆 —— 它只在單處理器系統(對於高並發性程序來說,這是一種不尋常的系統,其同步性能與在多處理器系統上可能有本質上的差別)上,並且只在一個平台上執行。在測試一個給定的原語或慣用語的時候,特別是與底層硬件交互很多的原語或慣用語時,在得出關於性能方面的結論之前,需要在很多平台運行基准。當測試像並發這樣復雜的東西時,為了得到給定慣用語的總體性能情況,建議采用十來種不同的系統,應用多個處理器(更不用說內存配置和處理器的代數(generation)了)。
實現上的缺陷
至於實現方面,SyncLockTest 忽略了動態編譯的很多方面。在12 月份的文章中可以看到,HotSpot JVM 首先以解釋的方式執行代碼路徑,然後在經過一定量的執行後,才將其編譯成機器代碼。如果沒有讓 JVM 適當地“熱身”,那麼 JVM 可能在兩個方面導致性能度量上的偏差。首先,測試的運行時間當中包含了 JIT 用於分析和編譯代碼路徑所花的時間。最重要的是,如果編譯是在測試運行的過程當中進行的,那麼測試結果就變成一定量的解釋執行,加上 JIT 編譯時間,再加上一定量的優化執行的總時間和,這些並不能讓您清楚代碼的真正性能。而且,如果在運行測試之前代碼沒有經過編譯,在測試的過程當中也沒有進行編譯,那麼整個測試運行都需要解釋,這樣就不能體現所要測試的慣用語的真正性能。
SyncLockTest 還淪為在12 月份的文章中所討論的內聯(inlining)和反優化(deoptimization)問題的犧牲品,在這些篇文章中,第一個計時度量的是那些已經與單一調用轉換(monomorphic call transformation)內聯的代碼,而第二個計時所度量的代碼,由於 JVM 要裝載另一個擴展相同基類或接口的類,因而經過了反優化。當使用 SyncIncrementer 的一個實例來調用計時測試方法時,運行庫將認為只裝載了一個實現 Incrementer 的類,並且會把對 increment() 的虛方法調用轉換為對 SyncIncrementer 的調用。然後,當使用 LockIncrementer 的一個實例調用計時測試方法時,test() 將被重新編譯成使用虛方法調用,這意味著與第一個計時相比,通過 test() 來管理方法的第二個計時在每次迭代中要做更多的工作,就好像把測試變成了蘋果與橙子之間的比較。這樣做會嚴重扭曲結果,致使無論哪種基准首先執行,看起來都會更快些。
基准代碼看上去並不像實際中的代碼
通過合理地重寫代碼,引入一些測試參數(例如競爭程度),並在更多類型的系統中、給測試參數賦予多種不同的值來運行代碼,前面所討論的那些缺陷是可以更正的。但是,對於方法上的一些缺陷,不管如何挽回,都是無法解決的。如果想知道為什麼,就應該像 JVM 那樣去思考,理解在編譯 SyncLockTest 的時候會發生哪些情況。
Heisenbenchmark 原則
編寫用於度量一個語言原語(例如同步)的性能的微基准的過程實際上是與 Heisenberg 原則作斗爭的過程。您想要度量操作 X 有多快,所以除了 X 外您不想做其他任何事。但是,這樣做得到的往往是一個不做任何事的基准,在您不知情的情況下,編譯器可能將此操作部分地或者完全地優化掉,使得測試運行起來比預期更快。如果在基准中加入無關的代碼 Y,那麼現在度量的就是 X+Y 的性能,更糟糕的是,由於 Y 的存在,現在 JIT 優化 X 的方式又發生了變化。如果沒有足夠的額外填充物和數據流依賴,編譯器可能會將整個程序優化至無形,但是如果填充物太多,那麼真正需要度量的東西又會迷失在噪音當中,因此要編寫一個良好的微基准,就意味著要抓住二者之間微妙的平衡。
因為運行時編譯使用概要數據來指導優化,所以 JIT 對測試代碼的優化可能不同於對實際代碼的優化。對於所有的基准,都存在這樣一個很大的風險,即編譯器能夠優化掉整個基准,因為它將(正確地)認識到基准代碼實際上沒有做任何事情,或者沒有產生任何有用的結果。在編寫有效的基准時,要求我們能夠“愚弄”編譯器,即使它認識到代碼沒有用處,也不能讓它將代碼砍掉。在 Incrementer 類中使用計數器變量騙不到編譯器,在刪除無用代碼方面我們對編譯器給予了信任,但編譯器比我們想象的還要聰明。
此外,還有一個問題是,同步是一種內建的語言特性。JIT 編譯器可以隨意變動同步鎖,以減少它們的性能成本。在某些情況下,同步可能被完全消除,並且在同一個監視器上,同步的鄰近同步鎖可能被合並。如果我們要度量同步的成本,這些優化實際上害了我們,因為我們不知道有多少同步會被優化掉(在這個例子中,很可能是全軍覆沒!)。更糟糕的是,JIT 對於 SyncTest.increment() 中不做事的代碼的優化與對實際中的程序的優化在方式上有很大的不同。
更糟的還在後面。這個微基准表面上的目的是測試同步與 ReentrantLock 哪個更快。由於同步是內建在語言中的,而 ReentrantLock 是一個普通的 Java 類,編譯器對於不做事的同步的優化與對於不做事的 ReentrantLock 的優化在方式上又有不同。這樣的優化會使不做事的同步看上去更快些。編譯器對此二者的優化方式存在差別,加上對基准和對實際代碼的優化方式也是不相同的,因此程序的結果幾乎無法告訴我們實際情況下兩者在性能上存在的差別。
無用代碼的消除
在12 月份的文章中,我討論了基准中無用代碼的消除問題 —— 由於基准常常不做有用的事,因此編譯器可能會整塊地砍掉基准代碼,從而歪曲了對執行時間的度量。基准在很多方面都存在這樣的問題。雖然編譯器消除無用代碼這件事對我們要做的事還不一定會造成致命打擊,但這裡的問題是,編譯器對於兩種代碼路徑可以執行不同程度的優化,這從根本上歪曲了我們的度量。
兩個 Incrementer 類的用途是做一些無用的工作(讓一個變量遞增)。但聰明的 JVM 會發現,這兩個計數器變量從來沒有被訪問過,因此可以消除與使這些變量遞增有關的代碼。正是這裡存在一個嚴重問題 —— 現在 SyncIncrementer.increment() 方法中的 synchronized 塊是空的,編譯器可以整個地刪除它,而 LockIncrementer.increment() 卻仍然包含鎖代碼,編譯器可能會將其完全刪除,也可能不會這樣做。您可能會想,這部分代碼有利於同步 —— 編譯器更可能會刪除這部分代碼 —— 但這樣的事情只有在不做事的基准中才如此普遍,而在精心編寫的實際代碼中就少見得多。
編譯器對某種實現比對另一種實現要優化得多一些,但是這種差別只在不做事的基准中才會體現出來,這個問題導致比較同步和 ReentrantLock 的性能是如此之困難。
循環展開和鎖合並
即使編譯器不消除計數器管理,它也仍會以不同的方式優化兩個 increment() 方法。標准的優化是循環展開;編譯器將展開循環,以減少分支的數量。展開多少次迭代取決於循環體中有多少代碼,而 LockIncrementer.increment() 的循環體中的代碼比 SyncIncrementer.increment() 的循環體中的代碼“更多”。而且,當展開 SyncIncrementer.increment() 並內聯該方法調用時,已展開循環的順序將是“鎖-遞增-解鎖”這樣的順序。由於這些都是同一個監視器上的鎖,因此編譯器可以執行鎖合並(也叫鎖粗化),將鄰近的 synchronized 塊合並,這意味著 SyncIncrementer 執行的同步將比預期的還要少。(更糟糕的還在後面;在合並鎖之後,同步的代碼塊中只包含一個遞增序列,因而可以降低強度,轉換成一個單獨的相加。而且,如果重復應用這個過程,整個循環將縮水成一個單獨的同步塊,這個同步塊中只有一個 "counter=10000000" 操作。的確,現實中的 JVM 是可以執行這些優化的。)
同樣,嚴格來說,問題並不在於優化器會優化掉我們的基准,而是優化器對於不同的基准會采用不同程度的優化,並且它對於每種基准所應用的優化在實際代碼中很可能根本不適用。
有缺陷的評價標准
這裡說得不夠詳盡,但是對於為什麼這個基准沒有像其作者期望的那樣這個問題,這裡給出了一些原因:
沒有進行熱身(warmup),沒有考慮 JIT 執行所花的時間。
測試容易受到由單一調用轉換引起的錯誤以及隨後的反優化的影響。
受同步塊或 ReentrantLock 保護的代碼實際上是無用的,這扭曲了 JIT 優化代碼的方式。編譯器可能可以消除整個同步測試。
測試程序想要度量一個鎖原語的性能,但是它在這樣做的時候,沒有考慮到競爭的影響,並且只是在一個單處理器系統上進行測試的。
沒有在足夠多類型的平台上運行測試程序。
編譯器對同步測試的優化比對 ReentrantLock 測試的優化要更多一些,但是這種優化又不適用於現實當中使用同步的程序。
錯誤的問題,錯誤的答案
關於微基准,令人恐慌的事情是它總是產生一個數字,即使這個數字毫無意義。這些基准在度量某個事物,但我們又不確定這個事物到底是什麼。通常,它們只度量特定微基准的性能,別無它物。但是您很容易誤認為您的基准在度量一個特定結構體的性能,並錯誤地對結構體的性能下結論。
即使您編寫了一個很好的基准,得到的結果可能也只是在運行基准的系統上才有效。如果在一個內存不足的單處理器手提電腦系統上進行測試,那麼您恐怕不能對一個服務器系統上的性能下任何結論。至於低級硬件並發原語的性能,不同的硬件體系結構之間更是千差萬別。
實際上,企圖單憑一個數字來度量“同步性能”之類的東西是不可能的。同步性能會隨著 JVM、處理器、工作負載、JIT 活動、處理器數量以及正同步執行的代碼的數量和特征而變化。您最好是在一系列不同的平台上運行一系列的基准,然後尋找結果中的相似之處。只有這樣,您才可以對同步的性能下結論。
在 JSR 166 (java.util.concurrent) 測試過程的基准運行中,性能曲線的形狀隨平台的不同而不同。硬件結構體(例如 CAS)的成本隨平台和處理器數量的不同而不同(例如,單處理器系統不存在 CAS 調用)。一個超線程(一個模具上有兩個處理器核心)Intel P4 的內存壁壘性能(memory barrier performance)要快於兩個 P4,而兩者的性能特征又不同於 Sparc。因此,您最好是嘗試建立一些“典型”例子,然後將它們放在“典型”硬件上運行,並希望這樣能在一定程度上揭示現實中的程序在現實中平台上的性能。那麼,用什麼構成一個“典型”例子呢?它的計算、IO、同步和競爭,它的內存局部性、分配行為、上下文切換、系統調用以及線程間通信都必須與現實當中的應用程序近似。也就是說,一個逼真的基准看上去非常像現實中的程序。
如何編寫好的微基准
那麼,如何編寫好的微基准呢?首先,編寫一個好的優化 JIT。跟那些寫過其他好的優化 JIT 的人談談(這樣的人不難找,因為好的優化 JIT 並不多!)。邀請他們會餐,與他們交流有關如何盡可能快地運行 Java 字節碼的性能技巧的故事。閱讀上百篇關於優化 Java 代碼執行的文章,自己也寫一些文章。然後您就會擁有編寫一個好的度量某種東西的微基准所需的技術,例如同步、對象池或者虛方法調用的成本。
是不是開玩笑?
您可能會想,前面所說的用於編寫好的微基准的秘訣過於保守,但編寫一個良好的微基准的確需要知道大量有關動態編譯、優化和 JVM 實現技術的知識。為了編寫一個真正能夠測試您所想要測試的東西的測試程序,您必須理解編譯器會對這個測試程序做什麼,動態編譯後的代碼的性能特征,以及生成的代碼與通常的現實當中使用相同結構體的代碼有何不同。沒有理解到這個程度,就不能判斷您的程序是否能度量您想要度量的東西。
那麼您應該怎麼做呢?
如果您真的想知道是同步更快還是鎖機制更快(或者回答任何類似的微性能問題),那麼應該怎麼做呢?一種選擇(對於大多數開發人員並不適合)是“信任專家”。在 ReentrantLock 類的開發當中,JSR 166 EG 成員在很多不同平台上運行成百上千個小時的性能測試,檢查 JIT 生成的機器代碼,並用心閱讀結果。然後,他們修改代碼,再重新測試。在開發和分析這些類的過程中,涉及到大量的專業知識以及對 JIT 和微處理器行為的深度理解,不幸的是,憑一個基准程序的結果就下結論仍然過早,雖然我們也想這樣。另一種選擇是,將注意力放在“微”基准上 —— 編寫一些實際的程序,用兩種方法編寫代碼,開發一種逼真的負載生成策略,並在逼真的負載條件下和逼真的部署配置中使用這兩種方法來度量應用程序的性能。這樣做工作量會很大,但惟有如此才能更接近您想要的答案。