Scala 3のwartremoverを何倍も高速化した

以下の記事の続き

xuwei-k.hatenablog.com

Exprをそのままpattern match出来る機能超最高便利なのだけれど、これ使い過ぎるとだいぶ遅いことが発覚したんですが

というtweetを貼ってありましたが、やはりどう考えても実際にそうなので、それの対策を入れてリリースしました。

3.2.5が対策前で、3.2.7が対策後なので、それの比較のベンチマークをしていきます。

https://github.com/wartremover/wartremover/compare/v3.2.5...v3.2.7

もちろん速度対策以外のコミットも混ざっていますが、ほぼテストやCI関連のupdateなどで基本的に影響がある変更はないはずです。

versionが1つではなく2つ上がっているのは、それぞれで対策を入れましたが、3.2.5から3.2.6より、3.2.6から3.2.7で入れた以下の対策の方が明らかに効果があったはずなので、連続でリリースしました。

どちらの対策の方が効果があったのか?を計測することも不可能ではないけれど、面倒なので実施しません。

どうやってベンチマークしようか迷ったというか、いくつか試したのですが、tasty inspector使ってsbt-jmhでやろうとしたら、そもそもtasty inspector形式だと時間がかかりすぎるせい?か、古い遅いversionの方が全然計測出来そうになかったのでやめました。

というわけで、まず以下のような条件で計測しました

  • github actions上で
  • github actionsのmatrix使って、他の条件は出来るだけ全部揃えた
  • 最適化前と後のwartremoverをそれぞれ有効
  • 何度か繰り返しsbtでcleanとupdateとcompileして、sbtが表示したcompile部分の時間を集計
  • Scalaのversionは3.6.3
  • 計測対象はこの時点のmasterのscalaz coreのjvmのsub project
  • wartremoverWarnings ++= Warts.all で、ほぼ全部を有効化
    • ほぼ全部、というのは、実際にはこれだと厳密に全部にならないのだが、そこは面倒なので

https://github.com/xuwei-k/scalaz/commit/ae8c2cfcb139145bbc8071d4754ca51cddebec28

実行結果が以下です。数字はcompileにかかった秒数

wartremover 3.2.5。最適化前

(1時間でtimeoutさせちゃったので、こちらの方が回数少ない)

428 
467 
405 
446 
404 
413 
405 
408

wartremover 3.2.7。最適化後

102
73 
65 
62 
61 
61 
61 
62
59
59
59
61
60
60
60

JVMJITなどの温まりなどを考慮して、こういうのはおそらく初回のいくつかを除いて平均や中央値とったりした方が正確になりますが、前者の回数が少ないので、雑にこれ以降全て初回2回分だけ除いて平均や中央値を取ると

wartremover 3.2.5。最適化前

  • 平均: 413.5秒
  • 中央値: 406.5秒

wartremover 3.2.7。最適化後

  • 平均: 60.7秒
  • 中央値: 61.0秒

となり、6倍以上の差が出ました。

また、参考までに同じ条件でwartremoverを全く設定せずに実行するとどうなるのか?というと

https://github.com/xuwei-k/scalaz/commit/e7a73f96784bed09bc79b84632e73f2df2e160f9

50 
24
20
17
16
16
16
15
15
15
15
15
15
15
15

Scala 3でwartremover 無効化

  • 平均: 15.7秒
  • 中央値: 15.0秒

となります。

wartremover頑張って改善したおかげで、以前と比較したらかなり速くなっていますが、それでも完全にwartremoverを無効化した状態と比較すると約4倍くらいの時間がかかってしまっているため、可能ならさらに頑張りたいですね・・・。 とはいえ、全部のルールを有効化することは普通ないので、実用的にはだいぶ問題のないレベルになったと思いたい・・・。

ちなみにScala 2の場合は元々そんなに遅くないようです。Scala 3から2に変更した以外は同じような条件で計測した結果を貼っておきます。

https://github.com/xuwei-k/scalaz/commit/671b86d8fed2b78efb84f012e95487ba4aa2154a

56
33
27
26
25
25
23
23
23
23
23
23
23
23
23
27
26
25
25
23
23
23
23
23
23
23
23
23

Scala 2.13.16でwartremover 3.2.7

  • 平均: 23.8秒
  • 中央値: 23.0秒

最適化頑張った後なのに、wartremover有効化した状態の比較だと、まだScala 3とScala 2を比較するとScala 2の方が数倍も(!?)速い、つらい・・・。

さて、これだけでも結構な明らかな差が出たので十分だと思われますが、前回解説したようにScala 3公式のprofile出力でも比較してみましょう。

細かく個々のルール毎に出すのは面倒というか、全部総合しての結果はすでに今までの計測で出てるので、今度は前回一番遅かったIterableOpsだけ有効化して計測してみましょう。

出力されたprofile結果のjsonの中身のwartremover部分の実行時間を取り出した結果が以下 (面倒なので1回しか計測してない)

  • wartremover 3.2.7: 1.92秒
  • wartremover 3.2.5: 50.5秒

20倍以上の結果になりました。試しにIterableOpsだけ計測しましたが、そもそも1回だけなので誤差がどの程度なのか、他のものは数倍程度なのか、もっと100倍近く差が出るものもあるのか?は、計測してみないとわかりませんが、とにかく明らかに差が出たし、ひとまず計測はこの程度にしておきましょう。

高速化の手法

さて、どうやって高速化したのか?の話をします。

そもそも

Exprをそのままpattern match出来る機能

が遅かったので、基本的にはそれを出来るだけ避ければいい、というだけです。

避けるといっても、これ自体は速度遅い以外で機能面ではかなり最高便利なので完全にやめるのも辛いので、ある程度維持しつつも一旦呼び出し回数を減らす対策をしました。

https://github.com/wartremover/wartremover/pull/1208

何をしたのかというと

「先にソースコードを文字列レベルで雑に判断し、明らかに検査しても検知されないソースコードだったら、その時点でTreeのtraverseをやめる」

という対策です。 具体的には、では

collect(f).headOption していたら collectFirst 使えるぞ!って教えてくれる」

ルールを例に説明しましょう。

よほど変な書き方をしていない限り、検査対象のTreeにはソースコードの文字列レベルで "collect" や "headOption" が含まれているはずです。含まれていなかったら明らかに検査対象外なので除外する、ということです。

ここだけscalametaでparseするとか、Exprでのpattern matchは使わないがもう少し真面目にやることも不可能ではないですが、どうせ可能性がある場合は後で正確にやるので、本当に文字列レベルで、すごく雑に判断しています。

文字列レベルで含まれていたら、実際に型やTreeの形を見る必要があり、そこは正確にやらないと誤検知になってしまうので、そこから先の処理は変えていません。

「よほど変な書き方をしていない限り」と書きましたが、ルールによっては気をつけてこの対策を入れないと厳密には挙動が異なってしまい、検知されるべきものが検知されなくなってしまう危険がある微妙な対策ではあります。

とはいえ、この記事の前半に書いたように、現状のScala 3では絶大な効果があるというか明らかに以前までの状態だとすごく遅かったので、色々な意味で対策方法が微妙とはいえ、他にもっと完璧な案も思いつかなかったので、一旦この対策を入れました。

collectしてheadOptionのものを例に説明しましたが、他のルールに関しても入れた対策の方針はほぼ全部同じです。

ただ、implicitで呼ばれるパターンなどが関係すると単純にこれをやったら壊れるので、一部は対策を入れてないので遅いままのものがあると思います。

そもそもwartremoverには細かいことを考えると、これ以外にも最適化の余地があるとは思っているのですが、気が向いたらもっと細かく計測したりして、今後も頑張るかもしれませんし、頑張らないかもしれません。

また、そもそもExprをそのままquoteでパターン書いて分解するのが明らかに遅いのは、理想的にはScala 3本体側が改善されて欲しいよなぁ・・・と思ったので、ある程度わかりやすい再現コードを作って、Scala 3本体に報告しておきました。

wartremoverを使わずに、Scala compiler内部のOption.getの呼び出し数を数えるだけのコードを

tree match {
  case Select(obj, "get") if obj.tpe <:< TypeRepr.of[Option[?]] =>

tree.asExpr match {
  case '{ ($x: Option[t]).get } =>

で書き分けてsbt-jmhで結構しっかり計測したところ、実際に同じ数だけ検出できたのに、asExprしてquoteでpattern matchする方が6倍以上遅くなりました。

明らかなバグではなく、本当に速度が遅いだけなので、受け入れてもらえるのかだいぶ謎ですが。

仮に将来にScala 3本体側が改善されたとしても、少なくとも古いScala 3.3系のLTSにbackportされたりするまでは今回の最適化の意味が結構あるはずなので、しばらくの間は今回の努力は無駄にはならないと思われます。