筆記-JMH(Java Microbenchmark Harness)

更多請移步我的博客

看開源項目時,時不常遇到一個叫做benchmark的目錄,此時腦子停滯,一眼帶過,最近一次看到就順手問了下谷大哥,發現benchmark還是個挺有意思的東西。

基準測試是什麼

基準測試是指通過設計科學的測試方法、測試工具和測試系統,實現對一類測試對象的某項性能指標進行定量的和可對比的測試。
例如,對計算機CPU進行浮點運算、數據訪問的帶寬和延遲等指標的基準測試,可以使用戶清楚地瞭解每一款CPU的運算性能及作業吞吐能力是否滿足應用程序的要求;再如對數據庫管理系統的ACID(Atomicity, Consistency, Isolation, Durability, 原子性、一致性、獨立性和持久性)、查詢時間和聯機事務處理能力等方面的性能指標進行基準測試,也有助於使用者挑選最符合自己需求的數據庫系統。

通過基準測試我們可以瞭解某個軟件在給定環境下的性能表現,對使用者而言可以用作選型的參考,對開發者而言可以作爲後續改進的基本參照。

JMH是什麼

JMH(Java Microbenchmark Harness)是Java用來做基準測試一個工具,該工具由openJDK提供並維護,測試結果可信度較高,該項目官方還在持續更新中。

下面只是JMH簡單描述,正所謂“紙上得來終覺淺,絕知此事要躬行”,要想全面瞭解還得讀完官方給出的Demo或者看我的翻譯註解版本的官方Demo

已經給標題加鏈接,直接戳標題即可閃現到例子。官方例子有37個,這裏只列出了梗概。

舉個例子

功能入門第一課,Hello World!

public class JMHSample_01_HelloWorld {

    @Benchmark
    public void wellHelloThere() {
        // this method was intentionally left blank.
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                // 指明本次要跑的類
                .include(JMHSample_01_HelloWorld.class.getSimpleName())
                // fork JVM的數量
                .forks(1)
                .build();

        new Runner(opt).run();
    }

}

看下輸出:

# JMH version: 1.21
# VM version: JDK 1.8.0_74, Java HotSpot(TM) 64-Bit Server VM, 25.74-b02
# VM invoker: /Library/Java/JavaVirtualMachines/jdk1.8.0_74.jdk/Contents/Home/jre/bin/java
# VM options: -javaagent:/Applications/IntelliJ IDEA.app/Contents/lib/idea_rt.jar=51264:/Applications/IntelliJ IDEA.app/Contents/bin -Dfile.encoding=UTF-8
# 預熱配置
# Warmup: 5 iterations, 10 s each
# 檢測配置
# Measurement: 5 iterations, 10 s each
# 超時配置
# Timeout: 10 min per iteration
# 測試線程配置
# Threads: 1 thread, will synchronize iterations
# 基準測試運行模式
# Benchmark mode: Throughput, ops/time
# 當前測試的方法
# Benchmark: com.cxd.benchmark.JMHSample_01_HelloWorld.wellHelloThere

# 運行過程的輸出
# Run progress: 0.00% complete, ETA 00:01:40
# Fork: 1 of 1
# Warmup Iteration   1: 2924740803.993 ops/s
# Warmup Iteration   2: 2916472711.387 ops/s
# Warmup Iteration   3: 3024204715.897 ops/s
# Warmup Iteration   4: 3051723946.668 ops/s
# Warmup Iteration   5: 2924014544.301 ops/s
Iteration   1: 2909665054.710 ops/s
Iteration   2: 2989675862.826 ops/s
Iteration   3: 2965046292.629 ops/s
Iteration   4: 3020263765.220 ops/s
Iteration   5: 2929485177.735 ops/s

# 當前方法測試結束的報告
Result "com.cxd.benchmark.JMHSample_01_HelloWorld.wellHelloThere":
  2962827230.624 ±(99.9%) 171803440.922 ops/s [Average]
  (min, avg, max) = (2909665054.710, 2962827230.624, 3020263765.220), stdev = 44616808.022
  CI (99.9%): [2791023789.702, 3134630671.547] (assumes normal distribution)


# Run complete. Total time: 00:01:41

REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on
why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial
experiments, perform baseline and negative tests that provide experimental control, make sure
the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts.
Do not assume the numbers tell you what you want them to tell.

# 所有benchmark跑完後的最終報告
Benchmark                                Mode  Cnt           Score           Error  Units
JMHSample_01_HelloWorld.wellHelloThere  thrpt    5  2962827230.624 ± 171803440.922  ops/s

上面的報告顯示,我們用1個線程對吞吐量測量,測量和預熱分別迭代了5次,最終得出的Score(ops/s)是2962827230.624 ± 171803440.922,測量過程中沒有出現Error。

測試控制

測試輸出結果中開頭的配置項都是我們可以通過註解、編程或者命令行的方式來控制的。它可以具體到每個benchmark。

  • @Fork 需要運行的試驗(迭代集合)數量。每個試驗運行在單獨的JVM進程中。也可以指定(額外的)JVM參數。

  • @Measurement 提供真正的測試階段參數。指定迭代的次數,每次迭代的運行時間和每次迭代測試調用的數量(通常使用@BenchmarkMode(Mode.SingleShotTime)測試一組操作的開銷——而不使用循環)

  • @Warmup@Measurement相同,但是用於預熱階段

  • @Threads 該測試使用的線程數。默認是Runtime.getRuntime().availableProcessors()

測試維度

通過JMH提供的工具我們可以輕鬆的的獲得某個功能的吞吐量、平均運行時間、冷啓動等指標的數據。

這些輸出結果通過@BenchmarkMode註解來控制,它的值定義在枚舉類org.openjdk.jmh.annotations.Mode中。

@BenchmarkMode接受的參數是一個Mode數據,也就是說,我們可以指定一個或者多個Mode,測試時會把我們指定的模式依次運行,打印出結果。

public enum Mode {

    /**
     * <p>Throughput: operations per unit of time.</p>
     * 
     * 計算一個時間單位內操作數量
     */
    Throughput("thrpt", "Throughput, ops/time"),

    /**
     * <p>Average time: average time per per operation.</p>
     *
     * 計算平均運行時間
     */
    AverageTime("avgt", "Average time, time/op"),

    /**
     * <p>Sample time: samples the time for each operation.</p>
     *
     * 計算一個方法的運行時間(包括百分位)
     */
    SampleTime("sample", "Sampling time"),

    /**
     * <p>Single shot time: measures the time for a single operation.</p>
     *
     * 方法僅運行一次(用於冷測試模式)
     * 或者特定批量大小的迭代多次運行(具體查看的“`@Measurement“`註解)——這種情況下JMH將計算批處理運行時間(一次批處理所有調用的總時間)
     */
    SingleShotTime("ss", "Single shot invocation time"),

    /**
     * Meta-mode: all the benchmark modes.
     * 所有模式依次運行
     */
    All("all", "All benchmark modes"),
    ;
	
	// 省略...
}

數據的共享

測試的時候,我們可能需要向測試方法傳入若干參數,這些參數還可能需要不同的隔離級別:每個線程單獨一份還是每個benchmark一份還是一組線程共享等。

這些參數有以下要求:

  • 有無參構造函數(默認構造函數)
  • 是公共類
  • 內部類應該是靜態的
  • 該類必須使用@State註解

參數必須是對象,因爲@State被定義爲ElementType.TYPE,即:可以用來註解類、接口或者枚舉。

@State接受單個配置值Scope

public enum Scope {

    /**
     * <p>Benchmark state scope.</p>
     *
     * 運行相同測試的所有線程將共享實例。
     * 可以用來測試狀態對象的多線程性能(或者僅標記該範圍的基準)。
     */
    Benchmark,

    /**
     * <p>Group state scope.</p>
     *
     * 實例分配給每個線程組(查看後面的測試線程組)
     */
    Group,

    /**
     * <p>Thread state scope.</p>
     *
     * 實例將分配給運行給定測試的每個線程。
     *
     */
    Thread,

}

數據的準備

數據的準備像極了JUnit和TestNG的方式,即:在測試開始前後分別進行處理。在JMH中對應到@Setup@TearDown,我們可以在被進行標記的方法中對數據進行處理,並且處理的耗時不被記入正常測試的時間,也就是說不會影響我們測試結果。

@Setup@TearDown分別接受單個配置值Level

public enum Level {

    /**
     * Trial level: to be executed before/after each run of the benchmark.
     *
     * 在每個benchmark之前/之後運行
     */
    Trial,

    /**
     * Iteration level: to be executed before/after each iteration of the benchmark.
     *
     * 在一次迭代之前/之後(一組調用)運行
     */
    Iteration,

    /**
     * Invocation level: to be executed for each benchmark method execution.
     *
     * 每個方法調用之前/之後
     * 該方式較爲複雜,在沒有搞清楚之前,不要使用。
     */
    Invocation,
    ;
}

測試線程組

我們可以通過指定線程組的的方式來模擬一些場景,比如:生產-消費。這種場景下生產消費的線程數量往往是不一致的,通過@Group@GroupThreads我們可以很輕鬆的製造出這種場景。

具體的使用說明例子中說的很詳細 ----傳送門----> JMHSample_15_Asymmetric

編譯器的控制

我們知道編譯器在編譯時會做一些優化,這個例子展示瞭如何用@CompilerControl來控制編譯對**方法內斂**優化的控制。

public @interface CompilerControl {

    /**
     * Compilation mode.
     */
    enum Mode {

        /**
         * Insert the breakpoint into the generated compiled code.
         */
        BREAK("break"),

        /**
         * Print the method and it's profile.
         */
        PRINT("print"),

        /**
         * Exclude the method from the compilation.
         * 不編譯該方法 —— 用解釋替代。
         */
        EXCLUDE("exclude"),

        /**
         * Force inline.
         * 要求編譯器內嵌該方法。
         */
        INLINE("inline"),

        /**
         * Force skip inline.
         * 該方法不能被內嵌。用於測量方法調用開銷和評估是否該增加JVM的inline閾值
         */
        DONT_INLINE("dontinline"),

        /**
         * Compile only this method, and nothing else.
         * 僅編譯被註解的方法,其他的不編譯。
         */
        COMPILE_ONLY("compileonly"),;
    }

}

基準測試中建議

編譯器還有其他一些優化行爲,常見的包括(更多參見):死代碼消除(DCE)、方法內斂(method inline)、循環優化、常量摺疊。

方法內斂可以用上面提到的註解做控制,但更多的需要我們在些測試時採用一些小技巧規避,比如:DCE可以根據情況採用Blackhole消費輸出結果。

  • 測試前後較重的處理放在@Setup@TearDown

  • 注意DCE(死代碼消除)

  • 不出現循環被編譯器優化,具體建議參考JMHSample_34_SafeLooping

  • 測試必須爲fork,fork是分離出來子進程進行測試,@fork(2)含義爲順次(one-by-one)fork出子進程來測試

  • 使用@fork多次fork測試,減少運行間差異

  • 多線程測試時參考JMHSample_17_SyncIterations

  • 對非循環方法需要測量冷啓動的時間消耗,參考JMHSample_26_BatchSize

  • 可以通過profiler獲得基準測試時JVM的相關信息,比如棧、gc、classloader。參考JMHSample_35_Profilers

  • 在使用profiler時遇到no tty present and no askpass program specified錯誤是因爲帳號並沒有開啓sudo免密導致的。
    通過以下步驟可解決,但測試完成後安全起見建議刪除:sudo visudo;在文件最後追加 userName ALL=(ALL) NOPASSWD:ALL

知識點記錄

編譯優化

常見的編譯器優化包括(更多參見):死代碼消除(DCE)、方法內斂(method inline)、循環優化、常量摺疊。

方法內斂

許多優化手段都試圖消除機器級跳轉指令(例如,x86架構的JMP指令)。跳轉指令會修改指令指針寄存器,因此而改變了執行流程。
相比於其他彙編指令,跳轉指令是一個代價高昂的指令,這也是爲什麼大多數優化手段會試圖減少甚至是消除跳轉指令。
內聯是一種家喻戶曉而且好評如潮的優化手段,這是因爲跳轉指令代價高昂,而內聯技術可以將經常調用的、具有不容入口地址的小方法整合到調用方法中。
Listing 3到Listing 5中的Java代碼展示了使用內聯的用法。

Listing 3. Caller method

int whenToEvaluateZing(int y) {
  return daysLeft(y) + daysLeft(0) + daysLeft(y+1);
}

Listing 4. Called method

int daysLeft(int x){
  if (x == 0)
     return 0;
  else
     return x - 1;
}

Listing 5. Inlined method

int whenToEvaluateZing(int y){
  int temp = 0;

  if(y == 0) temp += 0; else temp += y - 1;
  if(0 == 0) temp += 0; else temp += 0 - 1;
  if(y+1 == 0) temp += 0; else temp += (y + 1) - 1;

  return temp;
}

在Listing 3到Listing 5的代碼中,展示了將調用3次小方法進行內聯的示例,這裏我們認爲使用內聯比跳轉有更多的優勢。

如果被內聯的方法本身就很少被調用的話,那麼使用內聯也沒什麼意義,但是對頻繁調用的“熱點”方法進行內聯在性能上會有很大的提升。

此外,經過內聯處理後,就可以對內聯後的代碼進行進一步的優化,正如Listing 6中所展示的那樣。

Listing 6. After inlining, more optimizations can be applied

int whenToEvaluateZing(int y){
  if(y == 0) return y;
  else if (y == -1) return y - 1;
  else return y + y - 1;
}
unrolled loop 循環展開
for (i = 1; i <= 60; i++)
   a[i] = a[i] * b + c;

可以如此循環展開:

for (i = 1; i <= 20; i+=3)
{
  a[i] = a[i] * b + c;
  a[i+1] = a[i+1] * b + c;
  a[i+2] = a[i+2] * b + c;
}

分支預測

底層對循環中判斷的優化。簡單理解,對執行的循環判斷做採樣,根據採樣來預測下一個判斷會走到哪個分支中。

參考這篇文章---->深入理解CPU的分支預測(Branch Prediction)模型<----做進一步瞭解。

參考資料

編譯器的優化
方法內斂
什麼是基準測試
JMH官方Demo
JHM官網
JMH簡介-譯文 ----> 英文原文

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章