LoginSignup
4

More than 1 year has passed since last update.

posted at

updated at

Organization

【Kotlin】KFunctionを高速に呼び出す(前編)

この記事はKotlin Advent Calendar 2020の2日目の記事です。
本来7日目にまとめて投げるつもりでしたが、長くなりすぎたのと枠が余っていたので分割しました。


前書き

この一連の記事では、Kotlinのリフレクションにおける関数であるKFunctionと、それを高速に呼び出す方法についてまとめます。
また、それぞれの記事の最後にはここで紹介するノウハウを用いたFastKFunctionという自作ライブラリ、及び関連して自分の公開しているライブラリを紹介します。

この記事では、KFunctionに関する基礎知識と、KFunctionを高速に呼び出す方法の内まっとうな内容について書きます。

ベンチマークを行った環境

記事内に幾つかJMHのベンチマークスコアが出てきますが、計測は全てRyzen7 3700XWindows10環境で行っています。
ベンチマークの実行はJMH Gradle Pluginme.champeau.gradle.jmh)で行っており、設定は以下のとおりです。

jmhの設定(build.gradle.kts)
jmh {
    fork = 3
    iterations = 3
    threads = 3
    warmupBatchSize = 3
    warmupIterations = 3
}

KFunctionとは何か

高速な呼び出しについて説明する前に、まずKFunctionに関する基本的な知識を説明します。

前述の通りKFunctionとはKotlinのリフレクションにおける関数で、JavaのリフレクションにおけるMethodConstructorに該当します。

KParameterについて

KParameterは、KFunctionの引数情報です。

KParameterからは、その引数の関数の定義上の並び順や名前といった情報が取得できます。
また、KFunctionの呼び出し時にもKParameterが必要になる場合が有ります。

この情報はKFunctionの呼び出し時などに利用します。

KFunctionの使いどころ

Kotlinではimmutabilityが重視されているため、コンストラクタなど何らかの関数を1度呼び出す形でオブジェクトを初期化するのが主です。
そのため、特にJacksonのようなKotlin対応のマッピングライブラリはKFunctionを呼び出す形でオブジェクトを初期化する実装になるのが自然です。

KFunctionが高速に呼び出せると何が嬉しいのか

前述の通り、KFunctionKotlin対応のマッピングライブラリで利用されているため、これを高速に呼び出せるとマッピング処理のコストを下げることができます。
後述する通り、KFunctionの呼び出し速度はとても遅いパターンも有るため、これを高速化することによる効果量も期待できます。

KFunctionの呼び出し方と動作速度の比較

KFunctionの呼び出し方

KFunctionにはcallcallByという2種類の呼び出し方法が有ります。
これら2つを比較すると以下のようになります。

call callBy
引数 vararg Any? Map<KParameter, Any?>
デフォルト引数 非対応 対応
呼び出し速度 高速 低速
その他制約 varargに渡す引数の順番が正しくなければならない なし

callByは抽象化やデフォルト引数への対応から簡単なマッピングツールを作るような場面では非常に扱いやすいですが、呼び出しの速度という意味ではcallに劣ります。

呼び出し速度の簡単な比較

コンストラクタに関して、普通に呼び出した場合、call/callByで呼び出した場合、JavaConstructorで呼び出した場合のそれぞれの速度を簡単に比較するため、5引数のコンストラクタをそれぞれの方法で呼び出すJMHベンチマークを作成して比較しました。
スコアは高い方が良いものです。

callByが圧倒的に遅いこと、JavaConstructorの呼び出しは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を高速に呼び出す方法

前述の比較の通り、KFunctioncallで呼び出す方が高速なため、callByしなくて良いと言い切れる場合はcallだけを使うのがいいでしょう。

callBy呼び出しを両立したい場合には、KParameterの数が固定かつindex情報が有ることから、バケツソートの要領で引数を管理するMapを作るという方法が有ります。
この方法であれば、Map<KParameter, Any?>の構築や、初期化状況の確認、引数の並び替えを最小限のコストで行うことができるため、「完全初期化されていればcall呼び出し、されていなければcallBy呼び出し」という切り替えを容易に実装できます。
また、細かい部分として、アクセスにはindexのみ用いるため、HashMap等に比べて読み書きのコストも低く抑えられます。

以下は自作ライブラリ内での実装を省略・簡単化した例です。

ArgumentBucket(抜粋・微改変)
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にもこれから対応していく予定ですので、こちらも利用・スターお待ちしております。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
4