【moshi】moshi 1.12.0でデシリアライズが高速化したため比較・解説する【Kotlin】

# TL;DR
- `moshi 1.12`から条件付きで`Kotlin`のクラスへのデシリアライズが高速化した
- 条件は以下の通り
  - `moshi-kotlin-codegen`を利用していて、デフォルト値が有りうるクラスを、引数全てが設定された`JSON`からデシリアライズする場合
  - `moshi-kotlin`を利用していて、デフォルト値を用いず（= プロパティが全て揃った状態で）デシリアライズする場合

# 本文
`moshi 1.12.0`で`Kotlin`の`data class`へのデシリアライズが条件付きで高速化したので、比較・解説していきます。

## 高速化する条件
高速化に関する変更は`moshi-kotlin-codegen`と`moshi-kotlin`それぞれに入っています。
この内`moshi-kotlin-codegen`が高速化する条件は限定的なものですが、`moshi-kotlin`が高速化する条件は緩く、多くの場合高速化すると思われます。

### moshi-kotlin-codegenでのデシリアライズが高速化する条件
`moshi-kotlin-codegen`でのデシリアライズが高速化するのは、デフォルト値が有りうるクラスを、引数全てが設定された`JSON`からデシリアライズする場合です。

例えば以下のようなクラスをデシリアライズするとします。

```kotlin:高速化しうるデシリアライズ先
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class Sample(val foo: Int, val bar: Int = 0)
```

このクラスを、`foo`, `bar`それぞれのプロパティを持つ`JSON`からデシリアライズすると高速化します。

```json:高速化する
{ "foo": 1, "bar": 1 }
```

以下のように、`bar`がプロパティに存在しない（= デフォルト値が用いられる）場合は高速化しません。

```json:高速化しない
{ "foo": 1 }
```

また、以下のように、そもそもデフォルト値が設定されていない場合も高速化しません。

```kotlin:高速化しないデシリアライズ先
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class Sample(val foo: Int, val bar: Int)
```

### moshi-kotlinでのデシリアライズが高速化する条件
`moshi-kotlin`でのデシリアライズが高速化するのは、デフォルト値を用いずにデシリアライズを行う場合です。

例えば以下のようなクラスをデシリアライズするとします。

```kotlin
data class Sample(val foo: Int, val bar: Int)
```

このクラスを、`foo`, `bar`それぞれのプロパティを持つ`JSON`からデシリアライズすると高速化します。

```json
{ "foo": 1, "bar": 1 }
```

`moshi-kotlin`の場合、デフォルト値が設定されているかに関係なく、引数全てを揃えてデシリアライズした場合高速化します。

## どれ位高速化するか
3引数・デフォルト値有りの条件で`JMH`のベンチマークを作成し、同じベンチマークを`moshi 1.11.0`と`moshi 1.12.0`で実行して比較を行いました。
ベンチマークに用いたコードは以下の通りです。

```kotlin:codegen用
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class Codegen(
    val foo: Int,
    val bar: Int,
    val baz: Int = 0
)
```

```kotlin:reflect用
data class Reflect(
    val foo: Int,
    val bar: Int,
    val baz: Int = 0
)
```

```kotlin:ベンチマーク
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.wrongwrong.dto.Codegen
import com.wrongwrong.dto.Reflect
import org.openjdk.jmh.annotations.*
import java.util.concurrent.ThreadLocalRandom

@State(Scope.Benchmark)
open class BenchMark {
    private val moshiCodegenAdapter: JsonAdapter<Codegen> =
        Moshi.Builder().build().adapter(Codegen::class.java)
    private val moshiReflectAdapter: JsonAdapter<Reflect> =
        Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build().adapter(Reflect::class.java)

    private var targetJsonString: String? = null

    @Setup(Level.Iteration)
    fun setupTarget() {
        val i = ThreadLocalRandom.current().nextInt(5)
        // CodegenとReflectの内容は同じため、同じJSONからデシリアライズして比較できる
        targetJsonString = moshiCodegenAdapter.toJson(Codegen(i, i + 1, i + 2))
    }

    @Benchmark
    fun codegen() = moshiCodegenAdapter.fromJson(targetJsonString!!)

    @Benchmark
    fun reflect() = moshiReflectAdapter.fromJson(targetJsonString!!)
}
```

### 補足
`JMH`にの実行に関する設定を含めた`build.gradle.kts`は以下の通りです。

<details><summary>build.gradle.kts</summary><div>

```kotlin
plugins {
    kotlin("jvm") version "1.4.32"
    kotlin("kapt") version "1.4.32"
    id("me.champeau.gradle.jmh") version "0.5.3"
}

group = "com.wrongwrong"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    jmhImplementation(kotlin("stdlib-jdk8"))

    val moshiVersion = "1.11.0"
    jmhImplementation("com.squareup.moshi:moshi:${moshiVersion}")
    jmhImplementation("com.squareup.moshi:moshi-kotlin:${moshiVersion}")
    kaptJmh("com.squareup.moshi:moshi-kotlin-codegen:${moshiVersion}")
}

tasks {
    compileKotlin {
        kotlinOptions {
            jvmTarget = "1.8"
        }
    }
    compileJmhKotlin {
        kotlinOptions {
            jvmTarget = "1.8"
        }
    }
    compileTestKotlin {
        kotlinOptions {
            freeCompilerArgs = listOf("-Xjsr305=strict")
            jvmTarget = "1.8"
        }
    }

    // https://qiita.com/wrongwrong/items/16fa10a7f78a31830ed8
    jmhJar {
        exclude("META-INF/versions/9/module-info.class")
    }
}

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

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

    failOnError = true
    isIncludeTests = false

    resultFormat = "CSV"
}
```
</div></details>

その他ベンチマークの作り方は以下を参照ください。

https://qiita.com/wrongwrong/items/33be384c514db3282e2f

### ベンチマーク結果
ベンチマーク結果は以下のようになりました。

`codegen`は`moshi-kotlin-codegen`、`reflect`は`moshi-kotlin`のスコアで、スコアは高いほど良いです。

```:1.12.0
Benchmark           Mode  Cnt        Score        Error  Units
BenchMark.codegen  thrpt    4  1018336.030 ±  55897.668  ops/s
BenchMark.reflect  thrpt    4   949243.442 ± 106377.587  ops/s
```

```:1.11.0
Benchmark           Mode  Cnt       Score        Error  Units
BenchMark.codegen  thrpt    4  896153.959 ± 107707.744  ops/s
BenchMark.reflect  thrpt    4  754958.982 ±  33874.740  ops/s
```

このベンチマークから見るそれぞれの高速化幅は以下の通りです。

- `codegen` -> 1018336.030/896153.959 = 1.1倍
- `reflect` -> 949243.442/754958.982 = 1.3倍

## 高速化の原理
`moshi-kotlin-codegen`と`moshi-kotlin`での高速化の原理をそれぞれ解説します。

### moshi-kotlin-codegenが高速化した原理
`Kotlin`のデフォルト引数を用いた呼び出しは、実体としてはデフォルト引数に関する処理を含むコンストラクタを呼び出す形で行われます。
この辺りの詳細は以下の資料を参照ください。

https://developers-jp.googleblog.com/2020/11/dont-argue-with-default-arguments.html

今回の高速化は、引数が揃っている場合に通常のコンストラクタを呼び出す処理を生成される`JsonAdapter`に追加し、デフォルト引数に関する処理をスキップすることで達成されています。

### moshi-kotlinが高速化した原理
`moshi-kotlin`のマッピングは`kotlin-reflect`の`KFunction`を呼び出すことで行われていますが、引数が全て揃っていた場合の呼び出しを`call`で行うようにすることで達成されています。

https://qiita.com/wrongwrong/items/f7b15d54956191f471d1#kfunction%E3%82%92%E9%AB%98%E9%80%9F%E3%81%AB%E5%91%BC%E3%81%B3%E5%87%BA%E3%81%99%E6%96%B9%E6%B3%95

# 終わりに
`moshi-kotlin`の高速化は、自分の出したPRによって行われたものです。

https://github.com/square/moshi/pull/1286

`moshi-kotlin-codegen`の高速化は、このPRのアイデアを元に行われたものです。

このPRは自分にとって初めて有名OSSに取り込んでもらえたソースコードのPRになったので、嬉しくて解説記事を書いてみた次第です。