fthomas/refinedのScala 2 macroの独自typeの場合compile速度爆発問題

以下のライブラリの話をします。

github.com

このライブラリの基本的な紹介はしません。ググるとか、流行のAIに聞く、などして調べてください。

タイトル通りの話をするのと、タイトル以外でも最近fthomasさんのものに限らず、Scalaの他のrefined関係ライブラリのあれこれを調べたり試行錯誤して、Scala 3含めて書きたいことは色々あるのですが、どうやっても全て書ききれないし、まだまだ調査中の部分もあるため、今回は基本的にはタイトルの

「fthomas/refinedのScala 2 macroの独自typeの場合compile速度爆発問題」

についてのみです。

refinedで定義できる型には、大きく分けて2種類、あるいは3種類あります。どういうことか?というと

  • refined標準で定義済の型
    • その中でmacroで特別扱いされている型
    • なぜか?macroで特別扱いされていない型
  • ユーザーが独自に定義する型

標準で定義されている型には、たとえばIntならば、PosInt(1以上)、NonNegInt(0以上)、NegInt(-1以下)、といったものがあります。 ユーザーが独自に定義とは、たとえば以下のような定義をすると

import eu.timepit.refined.api.Refined
import eu.timepit.refined.numeric.Interval

type MyInt = Int Refined Interval.Closed[0, 10000]

独自に、0から1万までを許可する型が作れます。

さて、refinedには、リテラルのIntやStringをcompile時に評価して、制約を満たしていればcompile成功、満たしていなければcompile errorにしてくれる、便利なmacroがあります。 使い方によって数種類ありますが、どれも最終的に呼ばれる内部の部分が同じならば、今回の話の詳細には直接関わってこないはずなので、とりあえず以下のautoについて話すことにします。

import eu.timepit.refined.auto._

これをimportしておくと、たとえば

val a1: PosInt = 2 // コンパイル成功
val a2: PosInt = -3 // コンパイル失敗

と、compile時に値をcheckしつつ暗黙変換してくれます。

さて、これは、ユーザー定義の型においても同様のことが可能です。素晴らしいですね。つまり、上記に出した MyInt でも

val a1: MyInt = 2 // コンパイル成功
val a2: MyInt = -3 // コンパイル失敗

となります。

さて、ここまで準備段階の説明で、ここから本題なのですが、まず結論を言うと

「ユーザー定義の型においても同様のことが可能だが、コンパイル速度が爆発するので気をつけろ!覚悟して使え!」

ということです。

遅くなるのはあくまでmacroであって、macro以外ではあまり関係ないので、おそらくmacro使わないなら問題ありません。 とはいえ、macro使わないとなると、refinedの価値が半減する気がするので、難しいところです。

なぜそうなるか?というと、一部の型だけmacroで特別扱いされており、それ以外はcompile時にevalするからです。

「compile時にeval」の詳細は説明してもしょうがないというか、自分もそこの内部に詳しくないので触れませんが、とにかく基本的にmacro書く側としては、避けれるなら絶対に避けるべき機能で、最終手段的なものです。

大昔に、scalikejdbcのmacroが意味なくeval使っていて遅い事件があって修正されたりしました。

github.com

refinedにおける該当するmacroは以下です。

github.com

特定の事前定義された型ならば、そこから Validateインスタンスを取得、見つからなかったらevalしています。

では、実際にどの程度遅くなるのか?の実験を簡単にしてみましょう。

以下のようなコードを用意します

build.sbt

val common = Def.settings(
  scalaVersion := "2.13.12",
  libraryDependencies += "eu.timepit" %% "refined" % "0.11.1",
  scalacOptions ++= Seq("-Yprofile-trace", name.value + ".json") // ここはプロファイル結果見る時だけで良いかも
)

def max: Int = 200

val a1 = project
  .settings(
    common,
    Compile / sourceGenerators += task {
      val dir = (Compile / sourceManaged).value
      val src = s"""
package a1

import eu.timepit.refined.types.numeric.PosInt
import eu.timepit.refined.auto._

object Main {
  ${(1 to max).map{ n => s"  val x${n}: PosInt = $n"}.mkString("\n", "\n", "\n")}
}
"""
      val f = dir / "Main.scala"
      IO.write(f, src)
      Seq(f)
    }
  )

val a2 = project
  .settings(
    common,
    Compile / sourceGenerators += task {
      val dir = (Compile / sourceManaged).value
      val src = s"""
package a2

import eu.timepit.refined.auto._

object Main {
  ${(1 to max).map{ n => s"  val x${n}: MyInt = $n"}.mkString("\n", "\n", "\n")}
}
"""
      val f = dir / "Main.scala"
      IO.write(f, src)
      Seq(f)
    }
  )

a2/src/main/scala/a2/package.scala

import eu.timepit.refined.api.Refined
import eu.timepit.refined.numeric.Interval

package object a2 {
  type MyInt = Int Refined Interval.Closed[0, 10000]
}

これで

  • sub projectのa1は、事前定義されているPosInt
  • sub projectのa2は、独自定義のMyInt

が大量に定義されていることになります。それぞれ以下をsbtのshellに複数回入力して、繰り返しcompileをしてみます。

  • ;a1/clean ; a1/update ; a1/compile
  • ;a2/clean ; a2/update ; a2/compile

結果(ローカルのMac) それぞれ10回やった。

  • PosInt: sbtの表示では全部1秒か0秒(あの表示は切り捨てで、0秒は1秒以下、の意味のはず)
  • MyInt: sbtの表示では17秒か18秒

と、明らかな差が出ます。 さて、build.sbtに以下を追加してありましたが、profile結果のjsonchromeに食べさせて眺めてみましょう。

scalacOptions ++= Seq("-Yprofile-trace", name.value + ".json")

以下がa1のPosInt

以下がa2のMyInt

(PosIntの場合との時間の単位の違いに注目)

違いは圧倒的ですね。MyIntの場合、それぞれのmacro呼び出しというか1つのvalで、おそよ84ミリ秒かかっていました。 一方PosIntの場合は1.5ミリ秒程度です。

84 * 200回 = 16.8秒

なので、全体の 17秒か18秒 と、辻褄が合います。

さて、これの改善方法ですが、今考えてる最中です。

evalによるcompile速度爆発を避けつつ任意の独自定義型に対して、すごく綺麗に解決するmacroは、現状のScala 2では思い付いてません。

Scala 3なら原理上、綺麗にかける(まだcompile速度のベンチマークはしてない)、のですが、その詳細は別の機会に気が向いたら書きます。 (そもそもScala 3のmacroに2と同じevalがない気がする)

雑に思い付いている解決策は

  • 一度compileしたものを内部にcacheしてしまう => そんなことして大丈夫なのかよくわかってない。shaplessに一応そういうことやるものあったような?
  • 独自定義ですごくよく使うものだけ、専用のmacro作ってeval避ける => だるい・・・

などですが・・・、もっと良い案知ってる人がいたら教えてください。

そういえば、これ関連で 「PosBigDecimalやPosBigIntくらいキャッシュしておいてくれ〜」 というのが、以下の自分のpull reqです。

github.com

shapeless.test.compileTime というの最高便利ですね。