Scalaでcirceのautoのような再帰的に全自動で導出するものはcompile速度が爆発するので絶対に使うな作るな、という話

前回に引き続いて、なぜかcompile速度関連の話題を詳細に解説する回。

これは自分は何度も言っているし、circeについては昔に原作者本人も言っているのですが、circeに限らずやってしまう人が後を絶たない気がするし、auto相当の利便性を完全に受けつつも完璧に綺麗にこの問題を解決する方法は自分が知る限りScalaにおいて発見されていないので*1、少なくともsemiauto相当の何かに書き換える、などの対策をするしかないです。

自分にとってはある意味だいぶ今更な記事ですが、おそらく日本語でこの観点で丁寧に解説した記事を見た覚えがないので、今更ながら書いておきます。

autoやsemiauto、というのを何の説明もなしに使いましたが「詳細に解説する回」なので、まずそこから説明しましょう。 あくまでcirceで説明しますが、jsonにも限らない、他の任意のtype classの導出系のmacroでも全部共通だと思ってください。

build.sbt はこういう感じ。Scala 3でも2でも、大体の説明は共通です。

scalaVersion := "3.6.4"

libraryDependencies += "io.circe" %% "circe-generic" % "0.14.12"

まず仮に、以下のようなclass定義があって、Bのtype classのinstanceを使いたいとします。

case class A(x: Int, y: String)

case class B(a1: A, a2: A)

autoというのは再帰的に全自動で導出してくれるので、任意の使いたい場所でimportをするだけで使えて、一見すごく便利に見えるというか、便利さだけで考えると、確かに便利です。

import io.circe.generic.auto._
import io.circe.Decoder

object Foo {
  def foo = {
    // importするだけで、ほぼ何も定義なしで使いたい場所ですぐ使えるぞ
    Decoder[B]
    // ここでこのDecoder使う処理が書かれる
  }
}

object Bar {
  def bar = {
    // こっちでも使うぞ!
    // implicitly[Decoder[B]] と同じ意味
    Decoder[B]
    // こっちでも、このDecoder使う処理が書かれる
  }
}

しかし、タイトルにある再帰的に全自動で導出」というのは、メタプログラミングによって裏でどういうコードが生成されているのか?をある程度想像出来ない人が使うと、compile速度が爆発する悲劇を招きます。

自分にとっての

メタプログラミングによって裏でどういうコードが生成されているのか?をある程度想像出来ない人」

の範囲が当初思っていた想定より広いというか、初心者だけではなく、中級者でも普通に大量にやらかしている気がしてきたので、このblogを書いている、という経緯でもあります。

-Xprint:typer などの公式のcompiler option使ってみてもいいですが、とりあえず手動でsemiauto相当で擬似的にお気持ちを表したコードを書いてみましょう。 つまり実際のmacroが展開するコードとは異なりますが、言いたいことはだいたい伝わるはずです。

import io.circe.generic.semiauto._
import io.circe.Decoder

object Foo {
  def foo = {
    val decoderB: Decoder[B] = {
      val decoderA1: Decoder[A] = deriveDecoder[A]
      val decoderA2: Decoder[A] = deriveDecoder[A]
      // 本当はこのメソッドが直接decoderそれぞれ受け取るわけではないので、
      // これそのまま書いてもcompileは通らないが
      // すごく雑なイメージとしてはこう
      deriveDecoder[B](decoderA1, decoderA2)
    }
  }
}

object Bar {
  def bar = {
    val decoderB: Decoder[B] = {
      val decoderA1: Decoder[A] = deriveDecoder[A]
      val decoderA2: Decoder[A] = deriveDecoder[A]
      deriveDecoder[B](decoderA1, decoderA2)
    }
  }
}

何が言いたいか?というと

「Decoder[A]は4回も同じコードが生成されます」

Decoder[B]についても全く同じコードが2回生成されますね。

これが「再帰的に全自動で導出する」という弊害です。

この、裏での重複を勝手にいい感じにまとめてくれるmacro実装側でのテクニックは、自分が知る限り存在しません。むしろ知ってたら、汎用的なものが作れたら、大発明な気がするので教えてください。*2

macroなどのメタプロで表面上のコードが少なくなったからといって、compile時間はそれとは直接比例せずに、基本的にはあくまでmacroが展開した後のコード量に比例して伸びます。

仮に上記の展開後のようなコードを(新人が?新人じゃなくても?)手作業で書いてきたら、(仮にmacro使う選択肢が一旦そこではないとしたら)

「いや、流石にあからさまに重複コードだから、場所はとりあえずどこでもいいけど、どこかにまとめた方がいいんじゃないかな!!!」

ってかなり多くの人が思いますよね?しかしmacroで隠されると、多くの人は、その感覚が働かなくなってしまいます。

上記の例ではせいぜい4箇所重複でしたが、もっとネストした複雑なclassや、fieldが多いclass、あるいは使う場所が多いと、数十個や数百個以上同じものが生成される、というのは本当に現実的に発生します。

例えば

case class A1(x: Int)

case class A2(x1: A1, x2: A2)

case class A3(x1: A2, x2: A2)

case class A4(x1: A3, x2: A3)

case class A5(x1: A4, x2: A4)

について、他のinstanceを定義しないまま、autoでA5のinstanceを導出したら、A1のinstance定義はいくつ重複しますか?2の4乗で16個ですね?

さて、解決策としては、雑に極論すると、とにかくautoを避けてsemiautoなり何なり別のを使えば良いです。例えば最初の例だと、コンパニオンに定義できる場合は、以下のように定義しましょう。

import io.circe.generic.semiauto._

case class A(x: Int, y: String)

object A {
  given Decoder[A] = deriveDecoder[A]
}

case class B(a1: A, a2: A)

object B {
  given Decoder[B] = deriveDecoder[A]
}

Scalaにおいてはcompanionに定義すれば勝手にスコープに入るので、これで重複してmacroが展開することは無くなりますね。

これをするとつまりautoだけ使用する場合と比較して

「表面上の手動で書くコードは若干増えているのに、最終的にmacro含めて展開されるコードの総量は減るので、compile時間が減る」

ということが発生します。仕組みを理解してないと、その現象が謎に思えますが、compile速度最適化ガチ勢を目指したいなら(何それ?)、その感覚を養うのが大事です。

コンパニオンに定義できない状況の場合は、どこかに置き場所を決めて置くしかなく、その置き場所決めも地味に面倒なのですが、今回はその話題は省略します。

compile速度観点のみでいえば「autoのような再帰的に全自動で導出するmacro」は、このように最悪になる可能性を秘めているので、自分が万が一最初から設計するなら、 auto というシンプルな名前ではなく、もっとすごくヤバそうな長い名前にするとか、あえてこれだけjarを分ける、といったくらいに、徹底して気軽に使われないような努力をすると思います。 *3

そもそも提供するならsemauto相当までにして「autoのような再帰的に全自動で導出」は、提供しないのが一番わかりやすいです。

あくまで使用者が多くてイメージしやすいと思われるcirceで説明しましたが、最初にも書きましたが重要なので繰り返しますが、これは

jsonにも限らない、他の任意のtype classの導出系のmacroでも全部共通」

の話題です。macroやScala 3のmirrorで、こういうものを作る側の人も、基本的にcompile速度を考えたら提供しない方がいい、万が一提供するとしても気軽に使え過ぎる方法を避ける、 使う側も基本的に使わないようにして、万が一使うにしても出来るだけ使用箇所を限定したりしましょう。

macroによってcompile速度が遅くなる要因は、このパターンが大半な気がします。あとはrefinedのevalのように特別なものも稀にありますが。

xuwei-k.hatenablog.com

「裏で生成されるコードが見えないので想像しにくい」 という観点では、再帰的に導出しなくても地味に遅くなるmacroは可能性としてはあり得ますが、せいぜい定数倍遅くなる程度なことが多く、

今回解説したものは、雑なイメージとしては、2乗のオーダーとか、指数関数的なオーダーで遅くなる、というイメージを持ってもらえばいいと思います。

あくまで現状のScalaにおいてcompile速度観点では「再帰的に全自動で導出」を避ければよく、macro含めた全てのメタプロを必要以上に避ける、怖がる必要はありません。

とはいえrefinedのevalのようなわかりずらい例もあるので、結局は、macroに限らず、想定よりcompileが遅かったら、現在のScalaでは2でも3でもprofileを取れる機能があるので、まずしっかり計測して遅い場所を特定するのが一番重要です。

*1:shapeelss 2に無理やりどうにかするものがあったが、ほぼ使われてなさそう

*2:自分で作ろうとして挫折したことはある

*3:とはいえ、自分の仕事のコードでは大抵実用してないとはいえ、過去に自分もそういう再帰的に導出するものを作ってしまったことはあるが・・・