sbt 1.4からのpipeline機能を試したら3割compile時間短縮された

3割というのは、もちろんprojectの構成だったり、計測方法やその他色々によるわけですが、とにかく自分が計測した場合には3割短縮されました。114秒が80秒になりました。

pipeline機能自体の説明は最後に書きます。先に、測定方法や具体的な結果。

測定方法

$ java -version
openjdk version "1.8.0_252"
OpenJDK Runtime Environment (build 1.8.0_252-8u252-b09-1~16.04-b09)
OpenJDK 64-Bit Server VM (build 25.252-b09, mixed mode)
  • sbt起動にあたってのJVM引数(Scalazの普段のデフォルトのままで、深く考えたわけではない)
-Xms3784m
-Xmx3784m
-XX:MaxMetaspaceSize=1g
-Xss4m

結果

https://travis-ci.com/github/xuwei-k/scalaz/builds/192100080

pipeline機能を有効にした場合と、通常の場合の test:compile にかかった時間一覧が以下。実行順に並べてある

gist.github.com

通常(無効)の場合

  • 22回実行
  • 暖まる前の最初の8回を除いた平均が116秒
  • (最初の8回を除いた)最大119秒
  • 最小113秒
  • (最初の8回を除いた)中央値114 (データ数が偶数なので113と115の平均)

有効にした場合

  • 32回実行
  • 暖まる前の最初の8回を除いた平均が80秒。(母数が違うが、母数を同じ実行回のタイミングで揃えても平均は1程度しか変わらないので問題ない、はず)
  • (最初の8回を除いた)最大85秒
  • 最小77秒
  • (最初の8回を除いた)中央値79.5 (データ数が偶数なので79と80の平均)

どのくらい速くなったか?

  • 平均で比較: 80 / 116 = 約0.689
  • 中央値で比較: 79.5 / 114 = 約0.697
  • ベンチマークのセオリー的に、JVM暖まった値で計算したが、実際は初回のコンパイル速度が重要だったりするわけだが、それでも2割くらい速い?(初回の速度のみを、別途もっと繰り返し計測するべきか?)

結論

(平均でも中央値でもscalazの場合)

🎉 3割コンパイル時間短縮 🎉

sbt 1.4のpipeline機能の説明

  • 実験的機能だから、まだデフォルトではoffです
  • 有効にしたい場合はusePipeliningのkey指定
  • https://github.com/sbt/sbt/blob/7a3ca0d9c2b0a66f6bd0c8e76dbbbf912fedfd2b/main/src/main/scala/sbt/Keys.scala#L390
  • 他にも関連keyあるので、詳細はsbt本体のKey定義を読もう
  • sbt 1.4.1時点で、わりと致命的なbugがあるので要注意(sbt 1.4.2で直るはず) https://github.com/sbt/sbt/issues/5929
  • 致命的なバグとは、同じsub project内で src/main/scalasrc/test/scala の両方にソースコードが存在するだけで失敗する場合がある
  • macroがあると、うまく動かない場合がある気がする(詳細把握できてない。sbt本体でベンチマークをやろうとしたらおそらくmacroのせいで無理だった?)
  • 新しめの2.12.xと2.13.xでないと動かない。2.11.x以前やDottyではおそらく無理 (TODO: 気が向いたら詳細を調べて記載)
  • scalazは、マクロなし、mainとtestがproject分かれている、それなりなコード量がある、適度にproject分かれていて依存関係ある、などでベンチマークに適していた

pipeline機能の関連pull req

pipeline機能の原理の説明

自分も浅い理解しかしていないので、詳細を知りたい人は、上に貼ったscala本体、zinc、sbtのpull reqなどを読もう!

さて、以下のようなproject構成のとき

val a = project
val b = project.dependsOn(a)
val c = project.dependsOn(b)

rootで sbt compile としても、依存関係がある以上、compileはaとbとcすべてにおいて、並列実行なんて不可能ですね? 言い換えると、以下のような順番で行われます

  • aのcompile
  • (aのcompile結果を参照しつつ)bのcompile
  • (aとbのcompile結果を参照しつつ)cのcompile

当たり前すぎることを言っていますね?しかし、単純にそうではないことをする機能が入った、というのがsbt 1.4のpipeline機能です

さて、上記の例でbをcompileする際に、aのcompile結果が必要なのはなぜでしょう? どういうclassやmethodが存在するか?を参照して、それに応じてbのcompile結果や、compile成功、compile不成功が変わる可能性があるからですね? 当たり前ですね? しかし、ある程度他の言語で共通かもしれませんが、compileするにあたって

どういうシグネチャのmethodが存在するか?

は、必須ですが

そのmethodの内部の実装がどうなっているか?

は、compileするにあたって参照するだけなら必要ないはずです。

(ただし、macroや特殊なcompiler pluginを除く)

もしメソッドの内部実装までcompileするときに必要だったら、カプセル化も全部壊れるし、そもそも既に入っているsbtのインクリメンタルコンパイラの仕組みがほぼ成り立たないでしょう。

よって、その理屈を使って、以下のように考えた人がいる、というか、実装されたのが今回のpipeline機能の概要です(最初に考えたの誰なんだろう)

  • 特定のcompiler optionが与えられた際に、Scala compilerの途中のとあるフェーズで、メソッドの実装などはstubのclassファイル(?)を吐き出す
  • Scala compilerの途中のとあるフェーズ なので、それは、原理上、すべてのcompileが終わって本物のclassファイルを吐き出すよりも、はやい段階で可能なはずである
  • そのメソッド実装などがstubになっているclassファイルを参照して(classpathに入れて)、すべてのcompileが終わっていなくても、次のcompileが開始できる!
  • その シグネチャだけのstubファイル吐き出し本物のclassファイル吐き出し の時間差だけ、(sbtでmulti project構成で依存関係あると)全体のcompile時間が短縮されるはずである

Scala compiler側の関連オプションは、実装見る限り "-Ypickle-java", "-Ypickle-write" あたりらしいです。 おそらく、Scala compiler側は、そのオプションが指定されたら、指定された場所にstubのclassファイルを吐き出すようになるだけで、そこから先のpipelineの仕組みはzincやsbtが行っているはずです。

Scala本体側のpull reqの日付見ると、何年もかかって、(sbtに最終的に入ったという意味では)ようやく実現された機能のようですね、めでたい。

上記の原理上、privateメソッドはstubのほうでは考慮しない(その情報吐き出さない?)、など色々工夫をしているようです、たぶん。

途中に書いたように、まだまだ実験的機能だし、デフォルトがoffで、リリースされたばかりで、使用してる人も少ないだろうから、普段から使うのはちょっと怖いですが、 みなさんも何かベンチマークしてみてください。

build速度向上といえば、sbt 1.4からもう一つremoe cache(インクリメンタルコンパイラのキャッシュを別のマシン間で共有する機能)が入ったわけですが、それも最近色々試しているので、 気が向いたらそれもblogを書きたい・・・