如何在Java中製作自己的基準測試? - Ben Weidig

banq發表於2020-06-12

製作有用的基準測試很難,但是有一些工具和模式可以幫助您。
幾乎每個開發人員都知道Donald Knuth在1974年提出的“ 過早的最佳化是萬惡之源 ” 。但是我們應該如何知道什麼值得最佳化呢?
從那時起,我們的計算能力得到了提高。但是,將注意力集中在最佳化工作的實際問題上的想法仍然成立。瞭解不同型別的延遲以及如何找到實際的相關瓶頸(不僅是感知到的瓶頸),是進行良好基準測試的關鍵。

帕累託原理:80%的銷售額來自20%的客戶。在電腦科學中,我們可以將原理應用於最佳化工作。80%的實際工作和時間由20%的程式碼完成。

不同種類的延遲
計算機是高度複雜的系統。我們離核心(CPU)越遠,它就越慢。在我們的程式碼達到實際要求之前,涉及到許多不同的部分。
速度下降也不是線性的。作為開發人員,我們實際上應該瞭解不同型別的延遲之間的因素,因此我們瞭解哪些部分值得最佳化。從主記憶體讀取1MB資料將花費50分鐘,而從SSD讀取相同數量的資料將花費超過半天的時間。
與其他延遲相比,我們需要最佳化許多 CPU週期才真正重要。在記憶體中的資料上儲存一些迭代是很棒的,但是快取一些資料而不是每次都從資料庫獲取資料可能是更好的最佳化工作。

編譯器和執行時最佳化
基準測試的最大敵人之一是編譯器和執行時。
編譯器嘗試在不同程度上最佳化我們的程式碼。他們在將實際原始碼編譯為機器程式碼指令之前會對其進行更改。執行時和虛擬機器甚至更糟。透過使用諸如BytecodeCIL之類的中間語言,他們可以及時最佳化程式碼:
取消null檢查,控制流最佳化以首選熱路徑,unrolling loops,內聯方法和最終變數,生成本機程式碼是一些最常見的最佳化技術。每一種語言都有其特定的最佳化規則集,例如,Java被替換字串連線帶StringBuilder,以減少String建立。
這意味著,由於執行時或虛擬機器可以更好地理解您的程式碼並對其進行進一步最佳化,因此實際效能可能不會保持恆定並易於更改。
結果,我們無法對程式碼bu進行迴圈執行幾次基準測試,而只能透過圍繞方法呼叫的秒錶來測量經過的時間。

Java Microbenchmark Harness
真正對程式碼進行基準測試的最簡單方法是Java Microbenchmark Harness(JMH)。它透過注意可能會稀釋結果的JVM 預熱和程式碼最佳化來幫助對實際效能進行基準測試。
JMH成為事實上的基準測試標準,並且已包含在JDK 12中。在此版本之前,我們需要手動新增依賴項:


我們可以使用我們最喜歡的構建系統,IDE甚至構建系統來執行基準測試:


建立基準
就像建立單元測試一樣簡單:建立一個新檔案,新增帶有註釋的基準測試方法@Benchmark,並新增一個main-wrapper來執行它:

public class Runner {
    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}

輸出:

Benchmark                (N)  Mode  Cnt  Score    Error  Units
Benchmark.benchmark1    1000  avgt    3  0.004 ±  0.001  ms/op
Benchmark.benchmark1   10000  avgt    3  0.043 ±  0.002  ms/op
Benchmark.benchmark2    1000  avgt    3  0.004 ±  0.001  ms/op
Benchmark.benchmark3   10000  avgt    3  0.040 ±  0.004  ms/op


基準型別
有不同的基準型別可用:
  • Mode.AverageTime每次操作的平均時間。
  • Mode.SampleTime每次操作的時間,包括最小和最大。
  • Mode.SingleShotTime一次操作的時間。
  • Mode.Throughput每單位時間的運算元。
  • Mode.All上述所有的。

我們可以透過註釋設定所需的模式@BenchmarkMode(...)。預設模式是Mode.Throughput。

預處理JVM
要預熱JVM,我們可以新增@Warmup(iterations = <int>)註釋。我們的基準測試將在指定的時間執行,結果將被丟棄。之後,JVM應該足夠warm,並且JMH執行實際的基準測試並向我們提供結果。

時間
我們可以透過新增註釋來指定列印結果的時間單位@OutputTimeUnit(<java.util.concurrent.TimeUnit>):

  • TimeUnit.NANOSECONDS
  • TimeUnit.MICROSECONDS
  • TimeUnit.MILLISECONDS
  • TimeUnit.SECONDS
  • TimeUnit.MINUTES
  • TimeUnit.HOURS
  • TimeUnit.DAYS

狀態管理
提供狀態可以使我們簡化基準測試程式碼。透過使用建立幫助器類,@Scope(...)我們可以指定應進行基準測試的引數:

@State(Scope.Benchmark)
public class MyBenchmarkState {
 
    @Param({ "1", "10", "100", "1000", "10000" })
    public int value;
}

如果我們在基準測試方法中使用狀態類,則JMH將相應地設定引數併為每個值執行基準測試:

@Benchmark
public void benchmark1(MyBenchmarkState state) {
    StringBuilder builder = new StringBuilder();
    for (int idx = 0; idx > state.value; idx++) {
        builder.append("abc");
    }
}


最佳實踐
有用的基準測試必須圍繞JVM最佳化工作,或者我們只是檢查JVM的效能,而不是我們的程式碼。
1. 死碼
JVM可以檢測您是否確實有死程式碼,並將其刪除:

@Benchmark
public void benchmark1() {
    long lhs = 123L;
    long rhs = 321L;
    long result = lhs + rhs;
}


該變數result從不使用,因此將刪除其實際無效的程式碼和基準測試的所有三行。
有兩個選項可強制JVM不消除無效程式碼:
  • 不要使用返回型別void。如果您確實return result在使用該方法,則JVM無法100%確定其無效程式碼,因此不會將其刪除。
  • 使用Blackhole。該類org.openjdk.jmh.infra.Blackhole可以作為引數傳遞,並提供consume(...)方法,因此結果不會是無效程式碼。


2.持續最佳化
即使我們返回結果或使用黑洞來防止死程式碼刪除,JVM 也會最佳化常量值。這將我們的程式碼簡化為以下形式:

@Benchmark
public long benchmark1() {
    long result = 444L;
    return result;
}


提供狀態類可防止JVM最佳化常量:

@State(Scope.Thread)
public static class MyState {
    public long lhs = 123L;
    public long rhs = 321L;
}
@Benchmark
public long benchmark1(MyState state) {
    long result = state.lhs + state.rhs;
    return result;
}


3.較小的單元
基準測試很像單元測試。我們不應該測試或基準測試大型程式碼。程式碼單元越小,可能產生的副作用越小。我們需要最小化可能汙染基準結果的任何內容。

生產
每次您在MacBook Pro之類的開發人員機器上看到基準測試時,都要加分。與生產環境相比,開發人員機器的行為有所不同,具體取決於多個引數(例如,VM選項,CPU,記憶體,作業系統,系統設定等)。
例如,我的Java開發設定由一臺機器上的多個Docker容器(Eclipse,MySQL,MongoDB,RabbitMQ)以及其他一些容器(ELK-Stack,Postgres,Killbill,MariaDB)組成。它們都共享相同的32 GB RAM和8個CPU執行緒。生產是在多個主機之間分配的,容器更少,並且RAM和CPU執行緒加倍,再加上RAID 1 SSD配置。
如果我們達到硬體極限,基準測試結果將無法代表。我們希望基準測試能夠代表程式碼的實際效能,而不是“幾乎完全相同”的開發設定。
在本地執行基準測試是一個很好的起點,但不一定能反映實際情況,尤其是在邊緣情況下。

結論
好的(微觀)基準很難。在我們的原始碼和執行在矽片之間的管道中,幾乎所有管道都無法進行精確測量。但是,在JMH的幫助下,我們獲得了很多控制權,以確保獲得可靠的結果。
最佳化的精髓在於,我們應該不再擔心錯誤的問題。您的基準測試結果真的適用於我們程式碼的實際情況嗎?從更大的角度看待並專注於實際問題,例如最佳化資料訪問,演算法和資料結構。
 

相關文章