ScalazをDotty対応した知見

少し前に、こっそりと、かなりひさしぶりな(互換壊す)メジャーリリース?となる、7.3.0 finalをリリースしたけど全然リリースノート書いたり宣伝もしていないScalazですが、 もう最近自分しか開発していない感があり、猫のほうが明らかに流行っています。

が、それはそれとして、なにがあろうと、ゆったりとScalazの開発はやれる限り続けようと思うので、"そろそろいけるかな?"と思い、ScalazでのDotty対応をやってみたら(ごく一部を除いて)できたので、その知見をまとめました。

kind-projectorがとにかく辛かったけど、マクロは使っていないので、そういう意味ではまぁなんとかなりました・・・(?)

このblog書いてる時点のDotty 0.24.0-RC1時点の情報です。 Dotty 0.24.0-RC1でビルドしたscalazは7.4.0-M1としてpublish済です。 7.3や7.2にbackportしてのDotty対応は、クロスビルドしつつの互換考えると結構厳しいし、Dottyの安定度もわからない(7.2や7.3では、まだあまりDottyの仕様変更に振り回されたくない)ので、現状では、やるかどうかわかりません。

まだDottyのコンパイラは割と簡単にクラッシュしたりするので、Dottyがproduction readyになるのは結構時間かかりそうだなぁ、と思いました。

(nightlyビルド使ってもクラッシュする)

(tweetもしたけど、あるいはおそらく無限にthreadが終わらなくなる)

続きを読む

JDK11でGraalを有効にするとScalaのコンパイルが13%くらい速くなった

Graalについて全然詳しくないので、Graal自体の説明はしません、というかできませんが、JDK10以降で

-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler

というオプションを付与すると有効にできるらしいですね。

というわけで、以下のような条件で計測した結果、結果からいうと13%くらい速く(コンパイル時間が短く)なりました。

続きを読む

Scalaのcompiler pluginをCrossVersion.fullにするかCrossVersion.binaryにするか

昨日のwartremoverのやつの続き、というか、Scalaのcompiler pluginに関する別の話。

いきなり雑に説明に入りますが

  • Scalaの普通のlibraryは、scala-libraryのjarのみに依存する
  • scala-libraryのjarは、たとえば基本的に2.13.xなら、xの部分が変わっても、99%以上バイナリ互換を壊さない
  • マクロやリフレクションを使う場合はscala-reflectのjarに依存することになり、それはscala-libraryよりは互換壊す
  • compiler pluginなどのcompilerに依存するものは、scala-compilerのjarに依存することになるわけだが、それはおそらくscala-libraryやscala-reflectよりもわりと互換が壊れる
  • sbt側にはCrossVersionという概念がある
  • デフォルトではCrossVersion.binaryであり、通常のライブラリは "groupId" % "artifactId" % "version" とあったときに、artifactIdの末尾に "foo_2.13" などと、Scalaのversion(で一番末尾含まない)が付与されている
  • これらの仕組みによって、たとえば普通Scala 2.13.0使ってpublishしたライブラリは、Scala 2.13.1がリリースされても、publishし直す必要がない
  • しかし、scala-compilerなどに依存したものは、CrossVersion.fullにしてartifactIdを foo_2.13.1 などとしてpublishする方法もある。つまりscalaのversion毎に互換が壊れる可能性がある場合は、こちらにしておいたほうが安全である
  • 実際に2.12.9 => 2.12.10、2.13.0 => 2.13.1あたりで互換が壊れて、大抵のcompiler plugiinがCrossVersion.binaryでpublishされていたので問題になった
  • そのタイミングでwartremover本体もCorssVersion.fullでpubilshするように変えた
  • fullでpublishすると、原理的に互換が壊れない(compiler pluginならcompile時に変なエラーで死なない)、というメリットがある一方、Scalaの新しいversionがリリースされるたびに毎回リリースする必要が出てきて面倒である
  • 頑張って使う側でsbtの設定をすれば、CrossVersion.fullでも、新しいversionがリリースされてないときに、互換さえあれば微妙に古いものをあえてつかうようにするのは原理的には可能であるはず
  • 原理的には可能ではあるが、理解して設定するのはわりと難しい(?) https://github.com/wartremover/wartremover-contrib/issues/49#issuecomment-609609716
  • また、wartremoverとwartremover-contribの関係上「wartremover側はscala-compilerが互換壊したら明らかに毎回リリースする必要があるが、wartremover-contribは基本的にwartremoverのclassを参照して、scala-compiler内部の互換が壊れやすい部分あまり参照しないから、contribはCrossVersion.fullではなくCrossVersion.binaryでいいのでは?」みたいなところもある

され、これらを色々考慮した結果どうしたか?というと

などをやってみた状態なのですが、これも何が正解なのかのベストプラクティスが存在していなくて難しいですね・・・。上記で説明したような「fullとbinary両方でpublish」は、自分が知るかぎり、それをやっているライブラリは存在していない気がしますが、なにかもっといい感じの仕組みがあれば教えてください

play-wsのstandalone版に自動書き換えするscalafixのruleを書いた


https://github.com/xuwei-k/play-ws-scalafix

github.com


歴史とともに、簡単に説明すると

続きを読む

CircleCIにおけるsbtプロジェクトの依存ライブラリの最適なキャッシュ方法の考察

CircleCIにおけるキャッシュの仕組みというか仕様は、他のCIサービスと比べると、少し変わった特徴がある気がします。

といっても、自分は他にはTravisCIくらいしか詳しくないので、実はCircleCIのように色々工夫している方がむしろ最近は主流な可能性もありますが、そこは本題ではないし、詳しくもなく話せないので話しません。

まずは、公式ドキュメントがしっかり書かれているので、それをざっくりと読んでみてください。

circleci.com


完全に理解する必要はないですが、ある程度読んでいる前提でこの先の話は進めます。 *1

また、以前書いた件は依存ライブラリのみならずjob(?)をまたいだときにclassファイルやインクリメンタルコンパイルの情報を丸ごとキャッシュする話でしたが、

xuwei-k.hatenablog.com

今回はあくまで依存ライブラリのキャッシュだけについて話すので、同じキャッシュですが、ある意味全然違う話をします。



前述したように、自分はCircleCI以外だと、TravisCIくらいしか詳しくないので、それとの比較になってしまいますが、 TravisCIでのキャッシュは、良くも悪くも機能がシンプルというか、少ないというか、単純です。

基本的にキャッシュしたいディレクトリを .travis.yml で指定するだけです。


一方のCircleCiですが、もちろん

キャッシュしたいディレクトリを指定

は同様ですが、他に以下のような特徴があります

  • キャッシュには、Keyという概念を使って、名前をつける
  • Keyごとにキャッシュは不変であって、変更や直接的に削除さえ不可能*2 *3
  • Key毎に不変、ということから、ある意味導かれる性質というか、それを言い換えただけになるだが、Keyに変更がなければキャッシュを再生成してのアップロードは、実行されずにスキップされる
  • どのKeyのキャッシュを使うか?に関して、複数優先順位付きで指定可能で、目的のKeyと完全にマッチしない、微妙に別のキャッシュを使う、という設定にもできる

キャッシュ含めそれ以外でも、良くも悪くも(?)思想があって、単純に他のCIサービスを真似するわけではなく、わざと違っているような部分があるようですね(?)

その他キャッシュ以外 https://twitter.com/CircleCIJapan/status/1027378227965116417


ここまでCircleCIに共通の話で、sbtやScala特有の話が全く出てきていませんが、上記で説明したことが理解出来てないと、全くこの先の話が続かないというか理解できない気がするので、ある程度説明しました。

まだすぐにはsbtやScala特有の話にならないですが・・・しばらくお待ち下さい。

(あるいは結論だけ知りたい人は途中読み飛ばしてください)



"依存ライブラリのキャッシュ" という文脈というか目的において、あらためて

  • なぜそれをやるのか?
  • どういうキャッシュを目指すべきか?

を整理してみましょう。


目的としてはおそらく

ビルドの高速化

が一番優先度が高いのではないでしょうか? 他に関連するような微妙に別の観点として

  • ネットワークに負荷をかけたくない
  • (有料のCIサービスの場合)全体として料金面のコストを抑える

など、挙げようと思えばいくつか挙げられる気がしますが、少し脱線するのと、大抵の人は ビルドの高速化 が目的だと思うので、その目的のみ、という前提で話を進めます。


ビルドの高速化 という観点においての 依存ライブラリのキャッシュですが、

まず、キャッシュはあくまでもキャッシュであって、それがないとビルドできない、というものではなく、キャッシュがあったら速くビルドできるが、キャッシュが無くてもビルド自体は(基本的にはいつでも)可能、というものをキャッシュと呼ぶべきでしょう。


ここまでの前提をもとに、どういったキャッシュを目指すべきか?というと、以下にあげる点でしょうか

  • キャッシュが壊れない
    • あるいは壊れたことをできれば自動で検知できる
    • 壊れたことが手動検知だとしても、不正なキャッシュをうまく削除できる
  • 必要なものだけをキャッシュする。当たり前な言葉?で、更に言い換えると
    • 必要なライブラリ(sbtの場合ならjar)は、キャッシュに入っていてほしい
    • 必要ないもの(jar)は、キャッシュに入らないでほしい(必要なくなったら除かれて欲しい)
  • キャッシュの処理自体に時間がかかり過ぎたらキャッシュの意味がなくなるので、必要なときに必要なだけキャッシュを生成、更新、削除などする

"キャッシュが壊れない"件ですが、言及はしましたが、CircleCIの公式ドキュメントにも書いてあるとおり、(相対的に他のツールと比べれば?)基本的にsbtというか、関連するmaven centralリポジトリやcoursierなどは、versionのrangeなどを使っていなければ)、不変であって、原理上簡単に壊れたり、あまりビルドの再現性がなくなったりはしないはずなので、こちらの話は今回は深くしません。*4

versionのrangeなど とは [1.0,)1.0.+ という書き方があるのですが、好みがわかれるというかプロジェクトの方針にもよるのでしょうが、lockファイル的な仕組みがないのにこれをやるのはビルドに再現性がなくなって怖いので、個人的には絶対使いたくない派です。

ant.apache.org

(sbtには独自にlockのpluginなどがあるにはありますが、あまり普及していないのと、sbt pluginの部分のmeta buildのほうも考えるとそれだけでは完璧ではない、など)


"必要なライブラリは、キャッシュに入っていてほしい" だけなら、ある意味簡単に実現できます。ライブラリがダウンロードされて保存されるディレクトリを指定するだけです。

例えば(他にも関連ディレクトリはありますが)これを書いている2020年3月時点の最新安定版であるsbt 1.3.8では、デフォルトでLinuxでは ~/.cache/coursier/v1 に依存ライブラリはダウンロードされて保存されます。

なので、そこを指定すればいいだけです。


逆に言うと、繰り返しになりますが、TravisCIなどはそこを指定したら終わりで、それ以上なにもないですね?

しかしCircleCI特有の話をするので話が続きます。


ビルドツールや言語によるでしょうが、おそらく多くのビルドツールでは

  • 必要ないものは、キャッシュに入らないでほしい(必要なくなったら除かれて欲しい)
  • キャッシュの処理自体に時間がかかり過ぎたらキャッシュの意味がなくなるので、必要なときにだけキャッシュを生成、更新、削除などする

あたりを完璧にやろうとすると、意外と難しいというか、そこまで簡単にはいかないと思っています。

もちろん、単なるキャッシュなので、完璧にやる必要はないのですが、キャッシュについて多少深く考察するエントリーなので・・・。



必要なくなったら除かれて欲しい

という点が、理想的には

現状のprojectで使うライブラリ(jar)が完全にわかる(列挙できる)

という状態を実現できれば、必要がないものを消したあとに残ったものから、キャッシュを再生成すればいいだけです。

しかし、少なくとも自分が知る限り、sbtにはそんな機能は直接的には存在していません。

classpathなどのkeyから頑張って出力すれば原理的には不可能ではないと思いますが、sbt自体が使うものや、sbt plugin関連もあるので、そこも含めるとするなら若干ややこしいです。

それが可能だとしても、その列挙や削除自体に時間がかかったら意味がない、というのもあります。

また、キャッシュのディレクトリに実質直接アクセスのようなことをして、同じライブラリの古いversionの方を問答無用で消す、といった手法も原理上は可能で、だいたいはうまくいくかもしれませんが、稀に同じライブラリの新旧両方のversionを使っているパターンもありえるので、古いものを雑に消すだけでは完璧でもありません。(何かの都合で古い方にあえて下げた、というパターンもあり得るので、それにも対応不可能になってしまう)



そこで、少し妥協して

現状のprojectで使うライブラリが変更された可能性があるのならば、キャッシュを作り直す

が現実的でしょうか?そうすれば、たいてい最適なキャッシュになるはずです。 現実的、というか、結局あたり前のことを言っているだけですね?ただ、どう作り直すのか?と "変更された可能性がある" の部分の細かい検知の仕方の話があるので、当たり前といっても、よく考えると色々選択肢があります。

さて、上記のように少し妥協するとしたら、このあたりから "キャッシュの作り直し方" の違いによって、微妙なトレードオフになると思うのですが、つまり以下の2択が考えられるはずです

  • 現状のprojectで使うライブラリが変更された可能性があるのならば、キャッシュを最初から作り直す(全部ダウンロードし直す)
    • キャッシュが常に最適、最小(古いものが消える)メリット。作り直すときに時間はかかるが、一度作れば、最適、最小だと、その後のダウンロードや解凍の時間が短縮される
  • (全部ダウンロードし直す手間が結構大変な場合)少しくらい古いものが混入したままでも、sbtの構造上、使われないだけでバグるわけではなくキャッシュが肥大化するだけなので、少し変わった程度なら最初から作り直すのではなく追加する
    • 少し変更されただけなら、今までキャッシュが大体使えるという観点では効率が良い
    • 多く変更された場合や、変更が積み重なると、古いものが入り混じったままになりキャッシュが肥大化して、ダウンロード、解凍、アップロード、圧縮にかかる時間が増大するデメリット
      • 依存ライブラリは、油断すると簡単にGByte単位になるので、それらの時間は結構無視できない時間になり得る
      • 雑な実体験として、数GByteになると、それらの合計が1分簡単に超えたりする
      • CircleCI公式でもキャッシュは500MBくらいにしておけ、と書いてある



説明がずいぶん長くて、自分で書いていても疲れてきましたが、やっとこのあたりでCircleCIのkeyの話に繋がります。

疲れてきたのでKeyについてのある1つの解答をいきなり示すと、例えばこうなります

sbt-cache-v1-{{ checksum "project/Dependencies.scala" }}-{{ checksum "project/plugins.sbt" }}-{{ checksum "project/build.properties" }}

解説すると

  • CircleCIの公式でも示されているように、慣習的にも、あとで変更するためにも、v1とかのprefixつけておく
  • sbtでのビルドで必要なキャッシュは、主に以下の3種類の要素から決定されるはずなので、それらのchecksumを全部keyとして使う*5
    • (なにもpluginを指定していない場合でも) sbt自体が使うjarや、sbt自体のjar。sbtのversionにより決定される。sbtのversionは project/build.properties によるはず
    • sbt pluginが使うjar。 project/任意の名前.sbt による。複数ファイル作成可能だが1つにしておく
    • 普通のプロジェクトの依存ライブラリ トップレベルの任意の.sbtproject/任意の名前.scala のどこにでも書けるが、これをやるならどこか一箇所にまとめる(今回は project/Dependencies.scala。まとめるのと同時に、依存ライブラリ以外の設定を書くと、それを更新した際に意味なくキャッシュ更新がされる可能性があるので、依存ライブラリ関連以外は逆にこのファイルに書かない方が良い)
  • checksumに使っている3つのファイルの順番指定は、意味がある場合もあって多少考えたほうがいいかもしれないが、そこまで最重要でもない?ので、一旦詳細な説明割愛(上記に書いた順序がべつにオススメなわけではない)


公式の説明にもありますが、例えばKeyにBranch名など入れると

必要なときにだけキャッシュを生成、更新、削除

が実現できないため、Branch名やその他は使っていません。Branchが変わったからといって、必ずしも依存ライブラリが変わるとは限りませんよね? 依存ライブラリに変更がないのならばキャッシュを別のKeyで再生成するのは無駄な処理です。

(それをいうなら、上記のchecksum方式も厳密に考えると完璧ではないが、大抵の場合Branch使うだけよりは正確性が増すはず)



ここでやっとCircleCI特有の

どのKeyのキャッシュを使うか?に関して、複数優先順位付きで指定可能

あたりの話になります。

このKeyにおいて

  • 複数指定するべきでしょうか?
  • 指定するとしたら、どういう指定がいいでしょうか?

これが、解答が一意ではないというか、トレードオフの話に戻るのですが

  • 現状のprojectで使うライブラリが変更された可能性があるのならば、キャッシュを最初から作り直す
    • としたいなら、Keyはこの1種類のみ指定するべき
  • 少し変わった程度なら最初から作り直すのではなく、追加する
    • としたいなら、以下のように適当に複数指定するべき
keys:
  - sbt-cache-v1-{{ checksum "project/Dependencies.scala" }}-{{ checksum "project/plugins.sbt" }}-{{ checksum "project/build.properties" }}
  - sbt-cache-v1-{{ checksum "project/Dependencies.scala" }}-{{ checksum "project/plugins.sbt" }}
  - sbt-cache-v1-{{ checksum "project/Dependencies.scala" }}
  - sbt-cache-v1

となるはずです。


なぜそうなるのか?の詳細な解説はしませんが、CircleCIのキャッシュのKeyの概念を正しく理解しているのならば、あとはわかるはずです。 (説明長くなって疲れただけでもある・・・)


複数Keyを指定する場合は、依存ライブラリの更新があるたびに、キャッシュは原理上、徐々に肥大化するはずなので、適切なタイミングで v1 の部分を v2 などにインクリメントするなどして、キャッシュをリフレッシュしたほうがいいです。

あるいは、わざわざconfigファイル変えるのが面倒な場合、(CircleCI側の思想に反する気がするけれど)そこに環境変数を埋め込んでおいて、それ使用してリフレッシュ、という手法もあるようです

engineer.crowdworks.jp

  - sbt-cache-{{ .Environment.CACHE_KEY }}-{{ checksum "project/Dependencies.scala" }}-{{ checksum "project/plugins.sbt" }}-{{ checksum "project/build.properties" }}



タイトルが

最適なキャッシュ方法の考察

とあるように、ここまでの説明だけでは "最適なキャッシュ" にはたどり着いてません。途中で触れたように

現状のprojectで使うライブラリ(jar)が完全にわかる(列挙できる)

が実現できれば、ほぼ最適なものに近づくとは思うのですが、そこまで頑張る気がおきなかったのと、おそらく、一旦実用上は上記のどちらかで十分だと思ったからです。 *6


sbt + CircleCIという組み合わせで、自分がかるくググった限りでは、ここまでしっかり解説というか考察したものを見かけなかったのですが、 間違いや、さらなる改善のテクニックなどの情報がもしあれば教えて下さい。


追記1

追記2

こんなに途中の思考過程の説明書いてはいないけど、だいたい同じ結論に達していたやつは普通にあったようです

*1:CircleCIの1.0系の頃だと色々と違ったらしいですが、2系という前提で話をします。

*2:CircleCIの1.0の古いやつだといろいろ違ったらしいですが、そこは詳しくないし本題ではないので話しません

*3:削除的なことをしたい場合は別のKey名にする

*4:あとは独自の、artifactの削除が簡単にできてしまうようなmaven repoがresolverにあったりしなければ

*5:特殊な設定ファイルが置いてあったり、特殊な起動方法をしたり、sbt起動後に通常とは違う方法で動的にダウンロードしたらこの限りではないが、脱線するしだいぶ例外的なパターンなので、そこには触れない

*6:全部列挙これでいけると思ったら無理だった話 https://twitter.com/xuwei_k/status/1239182522497388544

sbtにおけるテストの並列実行の設定詳細解説

いきなり本題というか、一番言いたいことを書くと、まず

テストの fork の設定によってぜんぜん違う。

という点があまり知られていない気がします。 *1

というか、自分も今回調べるまで、微妙に古い知識のままで完璧に知らなかったので、今一度理解した現時点での詳細を今書いています。

sbtにおいて、テストがどう並列化されるか?に関して、関係するというか、今回話すのは、以下の点です

  • Test / parallelExecutionというkey
  • Test / fork が true か falseか
  • concurrentRestrictionsというkey
  • testForkedParallelというkey

また、今から話すのはsbt 1.3.8時点の情報です。

さらに前提として、マシンのCPUのコア数によって挙動が異なる可能性がありますが、ひとまずそれなりに十分にコア数がある、として話を進めます。(少なくとも4以上) *2

*1:もちろん公式ドキュメントには書いてあるのでそれは読みましょうというか、完璧に読んだことある人は、このblog読む必要ないはずです https://www.scala-sbt.org/1.x/docs/Testing.html

*2:specs2などは、更に別の概念として、1つのテストclass内のテスト並列実行する機能があったはずですが、今回はsbt自体の機能の話なので、そこには触れません

続きを読む