使用 JMH 做 Kotlin 的基準測試

Tony沈哲發表於2018-12-14

聖誕即將來臨.jpg

一. 基準測試

基準測試是指通過設計科學的測試方法、測試工具和測試系統,實現對一類測試物件的某項效能指標進行定量的和可對比的測試。

基準測試是一種測量和評估軟體效能指標的活動。你可以在某個時候通過基準測試建立一個已知的效能水平(稱為基準線),當系統的軟硬體環境發生變化之後再進行一次基準測試以確定那些變化對效能的影響。

二. JMH

JMH(Java Microbenchmark Harness) 是專門用於進行程式碼的微基準測試的一套工具API,也支援基於JVM的語言例如 Scala、Groovy、Kotlin。它是由 OpenJDK/Oracle 裡面那群開發了 Java 編譯器的大牛們所開發的工具。

三. 舉例

首先,在 build.gradle 中新增 JMH 所需的依賴

plugins {
    id 'java'
    id 'org.jetbrains.kotlin.jvm' version '1.3.10'
    id "org.jetbrains.kotlin.kapt" version "1.3.10"
}

...

dependencies {
    compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
    compile "org.jetbrains.kotlin:kotlin-reflect:1.3.10"
    testCompile group: 'junit', name: 'junit', version: '4.12'

    compile "org.openjdk.jmh:jmh-core:1.21"
    kapt "org.openjdk.jmh:jmh-generator-annprocess:1.21"
    ......
}
複製程式碼

3.1 對比 Sequence 和 List

在 Kotlin 1.2.70 的 release note 上曾說明:

使用 Sequence 有助於避免不必要的臨時分配開銷,並且可以顯著提高複雜處理 PipeLines 的效能。

所以,有必要下面編寫一個例子來證實這個說法:

import org.openjdk.jmh.annotations.*
import org.openjdk.jmh.results.format.ResultFormatType
import org.openjdk.jmh.runner.Runner
import org.openjdk.jmh.runner.options.OptionsBuilder
import java.util.concurrent.TimeUnit

/**
 * Created by tony on 2018-12-10.
 */
@BenchmarkMode(Mode.Throughput) // 基準測試的模式,採用整體吞吐量的模式
@Warmup(iterations = 3) // 預熱次數
@Measurement(iterations = 10, time = 5, timeUnit = TimeUnit.SECONDS) // 測試引數,iterations = 10 表示進行10輪測試
@Threads(8) // 每個程式中的測試執行緒數
@Fork(2)  // 進行 fork 的次數,表示 JMH 會 fork 出兩個程式來進行測試
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 基準測試結果的時間型別
open class SequenceBenchmark {

    @Benchmark
    fun testSequence():Int {

        return sequenceOf(1,2,3,4,5,6,7,8,9,10)
                .map{ it * 2 }
                .filter { it % 3  == 0 }
                .map{ it+1 }
                .sum()
    }

    @Benchmark
    fun testList():Int {

        return listOf(1,2,3,4,5,6,7,8,9,10)
                .map{ it * 2 }
                .filter { it % 3  == 0 }
                .map{ it+1 }
                .sum()
    }
}

fun main() {

    val options = OptionsBuilder()
            .include(SequenceBenchmark::class.java.simpleName)
            .output("benchmark_sequence.log")
            .build()
    Runner(options).run()
}
複製程式碼

在執行上述程式碼之前,需要先執行 ./gradlew build

然後,再執行main函式,得到如下的結果。

# Run complete. Total time: 00:05:23

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                        Mode  Cnt      Score     Error   Units
SequenceBenchmark.testList      thrpt   20  15924.272 ± 305.825  ops/ms
SequenceBenchmark.testSequence  thrpt   20  23099.938 ± 515.524  ops/ms
複製程式碼

果然,經過多次鏈式呼叫時 Sequence 比起 List 具有更高的效率。

如果把結果匯出成json格式,還可以藉助 jmh 相關的 gradle 外掛生成視覺化的報告。

fun main() {

    val options = OptionsBuilder()
            .include(SequenceBenchmark::class.java.simpleName)
            .resultFormat(ResultFormatType.JSON)
            .result("benchmark_sequence.json")
            .output("benchmark_sequence.log")
            .build()
    Runner(options).run()
}
複製程式碼

需要依賴到這個外掛:github.com/jzillmann/g…

藉助 gradle-jmh-report 生成如下的報告:

benchmark_sequence.png

3.2 行內函數和非行內函數

Kotlin 的行內函數從編譯器角度將函式的函式體複製到呼叫處實現內聯,減少了使用高階函式帶來的隱性成本。

嘗試編寫一個例子:

@BenchmarkMode(Mode.Throughput) // 基準測試的模式,採用整體吞吐量的模式
@Warmup(iterations = 3) // 預熱次數
@Measurement(iterations = 10, time = 5, timeUnit = TimeUnit.SECONDS) // 測試引數,iterations = 10 表示進行10輪測試
@Threads(8) // 每個程式中的測試執行緒數
@Fork(2)  // 進行 fork 的次數,表示 JMH 會 fork 出兩個程式來進行測試
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 基準測試結果的時間型別
open class InlineBenchmark {

    fun nonInlined(block: () -> Unit) { // 不用內聯的函式
        block()
    }

    inline fun inlined(block: () -> Unit) { // 使用內聯的函式
        block()
    }

    @Benchmark
    fun testNonInlined() {

        nonInlined {
            println("")
        }
    }

    @Benchmark
    fun testInlined() {

        inlined {
            println("")
        }
    }

}
複製程式碼

得到如下的結果。

# Run complete. Total time: 00:05:23

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                        Mode  Cnt   Score   Error   Units
InlineBenchmark.testInlined     thrpt   20  95.866 ± 4.085  ops/ms
InlineBenchmark.testNonInlined  thrpt   20  92.736 ± 3.085  ops/ms
複製程式碼

果然,內聯更高效一些。

benchmark_inline.png

3.3 協程和RxJava

自從 Kotlin 有協程這個功能之後,經常會有人提起協程和RxJava的比對。

於是,我也嘗試編寫一個例子,此例子使用的 Kotlin 1.3.10 ,協程的版本1.0.1,RxJava 2.2.4

@BenchmarkMode(Mode.Throughput) // 基準測試的模式,採用整體吞吐量的模式
@Warmup(iterations = 3) // 預熱次數
@Measurement(iterations = 10, time = 5, timeUnit = TimeUnit.SECONDS) // 測試引數,iterations = 10 表示進行10輪測試
@Threads(8) // 每個程式中的測試執行緒數
@Fork(2)  // 進行 fork 的次數,表示 JMH 會 fork 出兩個程式來進行測試
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 基準測試結果的時間型別
@State(Scope.Thread) // 為每個執行緒獨享
open class CoroutinesBenchmark {

    var counter1 = AtomicInteger()
    var counter2 = AtomicInteger()

    @Setup
    fun prepare() {

        counter1.set(0)
        counter2.set(0)
    }

    fun calculate(counter:AtomicInteger): Double {

        val result = ArrayList<Int>()

        for (i in 0 until 10_000) {

            result.add(counter.incrementAndGet())
        }

        return result.asSequence().filter { it % 3 ==0 }.map { it *2 + 1 }.average()
    }

    @Benchmark
    fun testCoroutines() = runBlocking {

        calculate(counter1)
    }

    @Benchmark
    fun testRxJava() = Observable.fromCallable { calculate(counter2) }.blockingFirst()

}
複製程式碼

執行結果如下:

# Run complete. Total time: 00:05:23

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                            Mode  Cnt   Score   Error   Units
CoroutinesBenchmark.testCoroutines  thrpt   20  17.719 ± 2.249  ops/ms
CoroutinesBenchmark.testRxJava      thrpt   20  18.151 ± 0.429  ops/ms
複製程式碼

此基準測試採用的是 Throughput 模式,得分越高則效能越好。從得分來看,兩者差距不大。(對於兩者的比較,我還沒有做更多的測試。)

benchmark_coroutines.png

總結

基準測試有很多典型的應用場景,例如想比較某些方法的執行時間,對比介面不同實現在相同條件下的吞吐量等等。在這些場景下,使用 JMH 都是很不錯的選擇。


Java與Android技術棧:每週更新推送原創技術文章,歡迎掃描下方的公眾號二維碼並關注,期待與您的共同成長和進步。

使用 JMH 做 Kotlin 的基準測試

相關文章