この記事は Kotlin Advent Calendar 2021 の5日目の記事です。
Kotlinは関数をinline化することができます。
inline化によるメリットはいくつかありますが、一つはパフォーマンスの向上でしょう。
以下のように関数をパラメータとして受け取る関数については、メモリ割り当てが重複し、実行に多少のコストがかかります。
fun <T> lock(lock: Lock, body: () -> T): T {
lock.lock()
try {
return body()
}
finally {
lock.unlock()
}
}
関数をinline化するのは、その関数にinline
修飾子をつけることで実現できます。
- fun <T> lock(lock: Lock, body: () -> T): T {
+ inline fun <T> lock(lock: Lock, body: () -> T): T {
これをつけることで、コンパイル時に以下のような展開された形のコードが生成され、実行時のコストが削減されます。
// コンパイル前
lock(l) { foo() }
// コンパイル後
l.lock()
try {
foo()
}
finally {
l.unlock()
}
一方、コンパイル時に展開されることにより、出力される実行ファイルのサイズは増えると言われてるため、自分で作成した関数をinline関数化するかどうか悩むこともあるでしょう。
今回は、inline化することによる、実行速度、メモリ使用量の変化と実行ファイルのサイズを確認してみます。
Scope関数の一つである let はnullcheck等でよく使うでしょう。
let
関数は以下のようにinline関数で定義されています。
inline fun <T, R> T.let(block: (T) -> R): R {
return block(this)
}
今回はこれのinline版と通常関数版を比較してみます。
inline fun <T, R> T.inlineLet(block: (T) -> R): R {
return block(this)
}
fun <T, R> T.normalLet(block: (T) -> R): R {
return block(this)
}
実行時間の測定には measureNanoTime 関数(これもinline関数です!)を使い、メモリ使用量はRuntime.getRuntime().totalMemory()
とRuntime.getRuntime().freeMemory()
の差で確認します。
kotlinc
を使いjarファイルに出力してCLI上で実行します。
実行ファイルサイズの比較は出力されたjarファイルで行います。
以下のようにinlineLet
とnormalLet
をそれぞれ100回呼び出したものと、直書きでインクリメントしたものを比較してみます。
fun main() {
val time = measureNanoTime {
val result = 0
.inlineLet { it + 1 } // 1
.inlineLet { it + 1 } // 2
/* ... */
.inlineLet { it + 1 } // 100
println(result)
}
println("time: $time")
println("memory: ${Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()}")
}
fun main() {
val time = measureNanoTime {
val result = 0
.normalLet { it + 1 } // 1
.normalLet { it + 1 } // 2
/* ... */
.normalLet { it + 1 } // 100
println(result)
}
println("time: $time")
println("memory: ${Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()}")
}
fun main() {
val time = measureNanoTime {
var result = 0
result += 1 // 1
result += 1 // 2
/* ... */
result += 1 // 100
println(result)
}
println("time: $time")
println("memory: ${Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()}")
}
それぞれ5回ずつ計測した結果は以下の通りです。
実行時間(ns)
inlineLet | normalLet | 直書き |
---|---|---|
122,370 | 21,293,250 | 126,854 |
120,617 | 20,545,971 | 126,668 |
127,969 | 19,689,613 | 125,660 |
128,836 | 19,812,922 | 119,153 |
121,369 | 19,804,250 | 118,499 |
メモリ使用量(B)
inlineLet | normalLet | 直書き |
---|---|---|
3,928,064 | 4,434,464 | 3,476,496 |
3,928,064 | 4,434,464 | 3,476,496 |
3,928,064 | 4,434,464 | 3,476,496 |
3,928,064 | 4,434,464 | 3,476,496 |
3,928,064 | 4,434,464 | 3,476,480 |
実行ファイルサイズ(B)
inlineLet | normalLet | 直書き |
---|---|---|
4,564,316 | 4,655,797 | 4,560,993 |
実行ファイルサイズは意外にも通常関数よりinline関数のほうが小さくなりました。
これはlet
の関数が非常にシンプルなため、展開しても肥大化せず、むしろ単純化されるのだと思います。
実行時間はinline化することで100分の1になり、直書きのものとほとんど同じになりました。 また、使用メモリにも差が見られます。 Inline化することで、明らかにパフォーマンスが向上していることが確認できました。
その他以下のようなメソッドでも試してみましたが、概ね同じような結果になりました。
inline fun safe(block: () -> Unit) {
try {
block()
} catch (e: Throwable) {
println(e)
}
}
inline fun max(block1: () -> Int, block2: () -> Int): Int {
val value1 = block1()
val value2 = block2()
return if (value1 > value2) value1 else value2
}
inline化するデメリットとして実行ファイルが肥大化することが上げられますが、よほど複雑なメソッドでなければ影響しないようでした。
実行環境にもよると思いますが、実行速度に関しては100倍以上という大きな差があったため、inline関数は積極的に使っていくのが良さそうです。
一方で、以下のような通常の関数をinline化しても、パフォーマンスに変化が見られませんでした。
inline fun increment(value: Int): Int {
return value + 1
}
以下のようにコンパイル時にwarningも出るため、inline化するのは引数に関数を受け取るときのみにするべきでしょう。
warning: expected performance impact from inlining is insignificant. Inlining works best for functions with parameters of functional types
インライン化によるパフォーマンスへの予想される影響はわずかです。インライン化は、関数型のパラメーターを持つ関数に最適です。
今回の調査結果が何かの参考になれば幸いです。