【Kotlin】ローカル関数の利用はパフォーマンス低下につながる

TL;DR

  • ローカル関数を利用するとパフォーマンスが低下する
  • このため、パフォーマンスが重要な場面ではローカル関数を利用すべきでない

文脈

日頃からお世話になっているintellij-rainbow-bracketsのコードを読んでいた所、以下のコードを見つけました。

github.com

このコードはローカル関数(関数内に定義する関数)を多用する書き方をしていますが、これがパフォーマンスに影響を与えないのか気になったため確認を行いました。

パフォーマンス低下を推測する根拠

上記のコードをJavaデコンパイルすると、以下のような表現になっています。

   public final void annotateUtil(/* */) {
      <undefinedtype> $fun$getBracketLevel$1 = new Function1() {
         public final int invoke(@NotNull LeafPsiElement element) {
            <undefinedtype> $fun$iterateParents$1 = new Function1() {
               public final void invoke(@NotNull final PsiElement currentNode) {
                  while(true) {
                     <undefinedtype> $fun$iterateChildren$1 = new Function1() {
                        public final void invoke(@NotNull PsiElement currentChild) {
                        }
                     };
                  }
               }
            };
         };
      };
   }

かなり要約していますが、ここからは以下のような問題点が見えます。

  • 関数呼び出し毎にローカル関数のnewが行われている
  • ループ(tailrec)内では、繰り返しローカル関数のnewが行われている

検証

検証のため、分割を行っていない状態と、分割後の状態とを再現し、それらに対するJMHベンチマークを作成しました。
コードは以下の通りです。

import org.openjdk.jmh.annotations.Benchmark
import org.openjdk.jmh.annotations.Scope
import org.openjdk.jmh.annotations.State

// ベンチマーク対象(分割後)
object Separated {
    private tailrec fun inner(i: Int, limit: Int): Int =
        if (i == limit) i else inner(i + 1, limit)

    private tailrec fun outer(i: Int, limit: Int): Int =
        if (i == limit) inner(i, limit) else outer(i + 1, limit)

    private fun caller(limit: Int) = outer(0, limit)

    // limit must be an integer greater than or equal to 1.
    fun target(limit: Int) = caller(limit)
}

// ベンチマーク対象(分割前)
object Combined {
    // limit must be an integer greater than or equal to 1.
    fun target(limit: Int): Int {
        fun caller(): Int {
            tailrec fun outer(i: Int): Int {
                tailrec fun inner(i: Int): Int =
                    if (i == limit) i else inner(i + 1)

                return if (i == limit) inner(i) else outer(i + 1)
            }
            return outer(0)
        }

        return caller()
    }
}

// ベンチマーク
@State(Scope.Benchmark)
open class Measurement {
    private val limit = 10

    @Benchmark
    open fun combined() = Combined.target(limit)

    @Benchmark
    open fun separated() = Separated.target(limit)
}

構築は下記を利用しています。

qiita.com

検証結果

build.gradle.ktsに以下の設定を入れてベンチマークを行いました。

jmh {
    warmupForks = 2
    warmupBatchSize = 3
    warmupIterations = 3
    warmup = "1s"

    fork = 2
    batchSize = 3
    iterations = 2
    timeOnIteration = "1500ms"

    failOnError = true
    isIncludeTests = false

    resultFormat = "CSV"
}

自分のPCで実行した結果は以下の通りです。
combinedが旧来のコードの再現で、separatedが分割後の想定です。
スコアは高いほど良いです。

Benchmark               Mode  Cnt         Score         Error  Units
Measurement.combined   thrpt    4  12430932.298 ±  581801.766  ops/s
Measurement.separated  thrpt    4  80820942.692 ± 2977174.695  ops/s

考察

推測通り、ローカル関数の利用によってパフォーマンスの問題が発生するようでした。
ベンチマーク内容について現実的な想定だと言い張るつもりは有りませんが、少なくともそれなりの影響は有ると言えます。

combinedinner関数を外に出すだけでもスコアが倍以上改善する様子も有りました。

まとめ

この記事ではintellij-rainbow-bracketsのコードを例にローカル関数の利用がパフォーマンス低下につながることを確認しました。
ローカル関数は便利な機能であるものの、パフォーマンスが重要な場面で利用することは避けた方が良いと思います。

終わりに

intellij-rainbow-bracketsのパフォーマンス改善も取り組もうと思っています。

最後に、intellij-rainbow-bracketsが継続的にやっていけるよう、スター・スポンサーもよろしくお願いします。

github.com

追記

改善やりました。
ソースを読んだ限りですが、C#Haskellは高速化すると思います。

github.com

【Kotlin coroutine/Java Reactor】Flow.groupByしたい場合Flux.groupByが使えるかも

記事執筆時点で、FlowにはgroupByが実装されていません。

kotlin.github.io

一方、FluxにはgroupByが実装されています。

projectreactor.io

kotlinx-coroutines-reactiveが入っているような環境では)FlowFluxは相互変換できるため、検討の余地が有ると思います。

【Marp】公式のダークテーマを使う

やり方

  1. marp: trueを設定する
  2. <!-- class: invert -->を設定する

設定後のヘッダーは↓のようになります。

---
marp: true
# header: 'header text'
# footer: '![height:50](image1.png)'
---
<!-- theme: default -->
<!-- class: invert -->
<!-- size: 16:9 -->
<!-- page_number: true -->
<!-- paginate: true -->

背景というか

やり方そのものは↓にちゃんと書いてあるんですが、自分は理解するのに時間がかかったので書きました。
こちらのページからは公式テーマの一覧も確認できます。 github.com

【日記】kotlin-reflectのvalue classを引数に含む関数呼び出しに関するバグ修正に携わった

↓の続きです。

wrongwrong163377.hatenablog.com

問題の内容

value classを引数に含む関数をkotlin-reflectを用いて呼び出す処理には、以下のような問題が存在していました。

これらの問題は、kotlin-reflectに依存するライブラリがvalue classをサポートする上でクリティカルな障害となっていました。
また、これらの問題を検知するためのテストパターンも不足している状況でした。

やったこと

Kotlinリポジトリに以下のPRを発行し、マージして頂きました。

これらの変更によって、ライブラリの実装を妨げるようなクリティカルな障害は解消されたと思われます。

終わりに

感想などは前回の記事に書いたので省略します。

リリースは今の所Kotlin 1.7になる予定です。
これによって様々なライブラリでvalue classサポートが進むと考えると今から非常に楽しみです。


追記

Kotlin 1.7でリリースされました!

github.com

【jOOQ】Recordからの値読み出しを省力化する【Kotlin】

定義したもの

import org.jooq.Field
import org.jooq.Record

inline fun <reified T> Record.read(field: Field<*>): T = this[field, T::class.java]

使い方

以下のように使います。

import org.jooq.Field
import org.jooq.Record

// FOOテーブルに定義された文字列型のFieldを想定
val field: Field<String?> = FOO.STRING_FIELD
// select結果などを保持するRecordを想定
val record: Record

// 以下のように、要求された型に応じた読み出しができる
val s1: String = record.read(field)
val s2: String? = record.read(field)
// エルビス演算子もよしなに判断してくれる
val s3: String = record.read(field) ?: ""
嬉しさ

jOOQがデフォルトで想定している読み出し方法の内最もシンプルなものは、Recordに定義された<T> T get(Field<T> field)を利用することです。
一方、KotlinGeneratorで生成されるコードではField(TableField)の型引数が常にnullableとなるため、non-nullな値として読み出す場合、一々!!で強制アンラップを行う必要が有ります。

// 単純にgetを用いた例
val s: String = record[field1]!!

また、Recordから何かしらの変換を伴う読み出しを行う場合は<U> U get(Field<?> field, Class<? extends U> type)を利用することになりますが、この関数はKClassを受け付けません。
このため、Kotlinから利用した際の見た目がよろしくありません。

// 変換を伴うgetを用いた例
import java.time.LocalDate

val d: LocalDate = record[field2, LocalDate::class.java]

今回定義した関数を使えば、両方ともシンプルかつ画一的な書き方ができます。
また、読み出し先がnullableになるような場合でも同様に書くことができます。

// 今回定義した関数を使った例
import java.time.LocalDate

val s: String = record.read(field1)
val d: LocalDate = record.read(field2)

// 要求される値がnullableな場合も同様に書ける
val s2: String? = record.read(field1)

クエリに対して期待される読み出し結果は基本的にマップ先の型に全て書かれているため、この関数を利用することで表現の重複を減らして機械的に書きやすくなります。

補足

関数の名前をreadにしているのは、getにするとjOOQデフォルトの内容と名前が被ってしまうためです。

【日記】Kontributorになった

最終的にやったことをまとめた記事を投稿しました。

wrongwrong163377.hatenablog.com


やったこと

JetBrains/kotlinGitHubPRを出し、マージしてもらいました。
当該PRは以下です。 github.com

このPRの内容

このPRに関しては全てテストです(変更行数は2,500行超、結構巨大です)。

このPRは、足りていなかったテストコードの補完兼後続の修正を分かりやすくするためのfailing testであり、本題となる修正は別途PRを発行しています。
内容は、kotlin-reflectnullablevalue classを引数に含む関数を呼び出した際にエラーになるバグの緩和です。 github.com

感想

大まかな感想は以下の3つです。

  • 深掘りを続けてきたKotlinに貢献できたことが嬉しい
  • Kotlinのような巨大プロジェクトでテストパターンが全く足りていない部分が有るとは思わなかった
  • 上手く行けばkotlin-reflectに依存するライブラリ全体のvalue classサポートを進められそうなのでやりがいが有る

深掘りを続けてきたKotlinに貢献できたことが嬉しい

Kotlinに触れたのは、2018年のエウレカでのインターンシップがきっかけでした。 wrongwrong163377.hatenablog.com

そこから利用を続け、バックエンドはJavaだった職場にKotlinを持ち込んだり、Kotlin製の自作OSSを公開したり、moshijackson-module-kotlinのような有名リポジトリにコントリビュートしたり、そしてついにKotlinそのものにコントリビュートすることができました。
ずっと好んで取り組んできたものにコントリビュートできたというのは非常に嬉しかったです。

(どうでもいいことですが、今思い返すと、初めて外部リポジトリにコントリビュートしたのが丁度1年位前でした。) wrongwrong163377.hatenablog.com

Kotlinのような巨大プロジェクトでテストパターンが全く足りていない部分が有るとは思わなかった

前述の通り、テストだけで2,000行超という非常に大きなPRを発行した訳ですが、その原因はそもそも当該箇所に関してテストパターンが全く足りていない状況だったことです。

value classは既にstableとされている機能ですが、実際の所関連するkotlin-reflectの機能には多くの重大なバグが残されています。
今回自分はテストパターンの追加を行いましたが、テストパターンは恐らくまだまだ不足しています。

見たのがkotlin-reflect単体とは言え、Kotlinという大きなプロジェクトにこのような状況が有るというのは自分にとって大きな驚きでした。

上手く行けばkotlin-reflectに依存するライブラリ全体のvalue classサポートを進められそうなのでやりがいが有る

自分は現在KT-31141(本題となるPRで対応している問題)と、KT-27598の緩和に取り組んでいます。
これらの問題は長らくkotlin-reflectに依存するライブラリ全体のvalue classサポートを阻んできたものです。

つまり、自分の修正によって、kotlin-reflectに依存するライブラリ全体のvalue classサポートが進むということになります(あくまで上手く行けばですが)。
少なくともjackson-module-kotlinについては自分の方で進めようと思っています。

自分が書いたコードによってそのような大きな変化が起きるというのは、大規模OSSに貢献する大きなやりがいですね。
これからもぼちぼちやっていこうと思います。