この記事はKotlin Advent Calendar 2020の2日目の記事です。
本来7日目にまとめて投げるつもりでしたが、長くなりすぎたのと枠が余っていたので分割しました。
前書き
この一連の記事では、Kotlin
のリフレクションにおける関数であるKFunction
と、それを高速に呼び出す方法についてまとめます。
また、それぞれの記事の最後にはここで紹介するノウハウを用いたFastKFunction
という自作ライブラリ、及び関連して自分の公開しているライブラリを紹介します。
この記事では、KFunction
に関する基礎知識と、KFunction
を高速に呼び出す方法の内まっとうな内容について書きます。
ベンチマークを行った環境
記事内に幾つかJMH
のベンチマークスコアが出てきますが、計測は全てRyzen7 3700X
のWindows10
環境で行っています。
ベンチマークの実行はJMH Gradle Plugin
(me.champeau.gradle.jmh
)で行っており、設定は以下のとおりです。
jmh {
fork = 3
iterations = 3
threads = 3
warmupBatchSize = 3
warmupIterations = 3
}
KFunctionとは何か
高速な呼び出しについて説明する前に、まずKFunction
に関する基本的な知識を説明します。
前述の通りKFunction
とはKotlin
のリフレクションにおける関数で、Java
のリフレクションにおけるMethod
やConstructor
に該当します。
KParameterについて
KParameter
は、KFunction
の引数情報です。
KParameter
からは、その引数の関数の定義上の並び順や名前といった情報が取得できます。
また、KFunction
の呼び出し時にもKParameter
が必要になる場合が有ります。
この情報はKFunction
の呼び出し時などに利用します。
KFunctionの使いどころ
Kotlin
ではimmutability
が重視されているため、コンストラクタなど何らかの関数を1度呼び出す形でオブジェクトを初期化するのが主です。
そのため、特にJackson
のようなKotlin
対応のマッピングライブラリはKFunction
を呼び出す形でオブジェクトを初期化する実装になるのが自然です。
KFunctionが高速に呼び出せると何が嬉しいのか
前述の通り、KFunction
はKotlin
対応のマッピングライブラリで利用されているため、これを高速に呼び出せるとマッピング処理のコストを下げることができます。
後述する通り、KFunction
の呼び出し速度はとても遅いパターンも有るため、これを高速化することによる効果量も期待できます。
KFunctionの呼び出し方と動作速度の比較
KFunctionの呼び出し方
KFunction
にはcall
とcallBy
という2種類の呼び出し方法が有ります。
これら2つを比較すると以下のようになります。
call |
callBy |
|
---|---|---|
引数 | vararg Any? |
Map<KParameter, Any?> |
デフォルト引数 | 非対応 | 対応 |
呼び出し速度 | 高速 | 低速 |
その他制約 |
vararg に渡す引数の順番が正しくなければならない |
なし |
callBy
は抽象化やデフォルト引数への対応から簡単なマッピングツールを作るような場面では非常に扱いやすいですが、呼び出しの速度という意味ではcall
に劣ります。
呼び出し速度の簡単な比較
コンストラクタに関して、普通に呼び出した場合、call
/callBy
で呼び出した場合、Java
のConstructor
で呼び出した場合のそれぞれの速度を簡単に比較するため、5引数のコンストラクタをそれぞれの方法で呼び出すJMH
ベンチマークを作成して比較しました。
スコアは高い方が良いものです。
callBy
が圧倒的に遅いこと、Java
のConstructor
の呼び出しはcall
よりも高速なことが分かります。
この差が生じる原因は、前述の通りKFunction
が抽象化されているため、呼び出し時にオーバーヘッドが有ることです。
特にcallBy
する場合、キーごとの初期化チェックやデフォルト引数周りの確認などが入るため低速になります。
Benchmark Mode Cnt Score Error Units
CallConstructorBenchMark.javaConstructor thrpt 9 103641734.582 ± 375122.731 ops/s
CallConstructorBenchMark.kFunctionCall thrpt 9 83813952.159 ± 3434662.231 ops/s
CallConstructorBenchMark.kFunctionCallBy thrpt 9 14809909.647 ± 358994.052 ops/s
CallConstructorBenchMark.normalCall thrpt 9 527408471.072 ± 10805692.616 ops/s
比較用コード
import org.openjdk.jmh.annotations.Benchmark
import org.openjdk.jmh.annotations.Scope
import org.openjdk.jmh.annotations.State
import java.lang.reflect.Constructor
import kotlin.reflect.KFunction
import kotlin.reflect.KParameter
import kotlin.reflect.jvm.javaConstructor
@State(Scope.Benchmark)
open class CallFunctionsBenchMark {
data class Sample(
val arg1: Int,
val arg2: Int,
val arg3: Int,
val arg4: Int,
val arg5: Int
)
val function: KFunction<Sample> = ::Sample
val argumentMap: Map<KParameter, Any?> = function.parameters.associateWith { it.index + 1 }
val javaConstructor: Constructor<Sample> = function.javaConstructor!!
@Benchmark
fun normalCall(): Sample = Sample(1, 2, 3, 4, 5)
@Benchmark
fun kFunctionCall(): Sample = function.call(1, 2, 3, 4, 5)
@Benchmark
fun kFunctionCallBy(): Sample = function.callBy(argumentMap)
@Benchmark
fun javaConstructor(): Sample = javaConstructor.newInstance(1, 2, 3, 4, 5)
}
KFunctionを高速に呼び出す方法
前述の比較の通り、KFunction
はcall
で呼び出す方が高速なため、callBy
しなくて良いと言い切れる場合はcall
だけを使うのがいいでしょう。
callBy
呼び出しを両立したい場合には、KParameter
の数が固定かつindex
情報が有ることから、バケツソートの要領で引数を管理するMap
を作るという方法が有ります。
この方法であれば、Map<KParameter, Any?>
の構築や、初期化状況の確認、引数の並び替えを最小限のコストで行うことができるため、「完全初期化されていればcall
呼び出し、されていなければcallBy
呼び出し」という切り替えを容易に実装できます。
また、細かい部分として、アクセスにはindex
のみ用いるため、HashMap
等に比べて読み書きのコストも低く抑えられます。
以下は自作ライブラリ内での実装を省略・簡単化した例です。
class ArgumentBucket(
private val keyList: List<KParameter>,
val valueArray: Array<Any?>,
private val initializationStatuses: BooleanArray
) : Map<KParameter, Any?> {
class Entry internal constructor(
override val key: KParameter,
override var value: Any?
) : Map.Entry<KParameter, Any?>
fun isFullInitialized(): Boolean = initializationStatuses.all { it }
operator fun set(key: KParameter, value: Any?): Any? {
return valueArray[key.index].apply {
valueArray[key.index] = value
initializationStatuses[key.index] = true
}
}
// keyはインスタンスの一致と初期化状態を見る
override fun containsKey(key: KParameter): Boolean =
keyList[key.index] === key && initializationStatuses[key.index]
}
// 呼び出し対象関数
val function: KFunction<T>
fun callBy(bucket: ArgumentBucket): T {
return if (bucket.isFullInitialized())
function.call(bucket.valueArray)
else
function.callBy(bucket)
}
終わりに
この記事ではKFunction
に関する基礎知識と、KFunction
を高速に呼び出す方法の内まっとうな内容について書きました。
KFunction
を呼び出す形での高速化に関しては、恐らくこのやり方が限界だろうと思います。
後編では、まっとうじゃない所まで踏み込んで更なる高速化を行う方法について解説します。
追記: 後編を公開しました -> 【Kotlin】KFunctionを高速に呼び出す(後編) - Qiita
宣伝: FastKFunctionについて
最後に宣伝です。
ここで紹介したノウハウを用いたKFunction
の高速呼び出しが行えるライブラリ、FastKFunction
を公開しています。
「自分で作りたくはないけどKFunction
を高速で呼び出したい」という方は是非使ってみて下さい。
スターもお待ちしております。
関連して、ProjectMapK
というオーガナイゼーションで幾つかのマッピングライブラリを公開しています。
FastKFunction
にもこれから対応していく予定ですので、こちらも利用・スターお待ちしております。