scalafmtはscalametaを利用したscalaのコードフォーマッタです。intellij plugin・sbt plugin・cli・mavenやgradle plugin といった様々な形で提供されており
- vim-autoformatなどを用いてvimからフォーマットを実行したり https://scalameta.org/scalafmt/docs/installation.html#vim
- git hook を設定したり Code formatting: scalafmt and the git pre-commit hook
と様々な方法で利用することができます。
scalafmtの課題の一つはscalafmtの実行にかかる時間です。機能が豊富で複雑なため、ただでさえそれなりに実行に時間がかかってしまうのですが、さらに悪いことにscalafmtの実行に当たってはjvm の起動がオーバーヘッドとなってきます。利用頻度が少ないのならば問題ないのですが、gofmtなどのような軽量なフォーマッタのように気軽に実行できないものだろうか。
このjvm起動のオーバーヘッドを解決する方法の一つがnailgunです。nailgunはJVMプロセス(サーバ)をあらかじめ起動しておいて、クライアント側からサーバー側にjavaプログラムの実行要求をし、サーバー上でプログラムを実行させることでjvmの起動によるオーバーヘッドを解決してくれます。scalafmtからの利用方法
他の解決方法としてはどうにかしてscalafmtをネイティブバイナリにAOTコンパイルしてしまうことですが、残念ながらv1.6.0-M4段階ではscalanativeサポートはされていません Support Scala Native · Issue #1172 · scalameta/scalafmt · GitHub
しかしGraalVMのツールチェーンの一つであるsubstratevm(とgraal)を使うことでscalafmtをAOTコンパイルしnailgunなしに爆速で実行することができるようになります。
SubstrateVM
- graal/substratevm at master · oracle/graal · GitHub
- javaで書かれた(生成されるネイティブイメージに)組み込み可能なVM(ランタイム?)、graalコンパイラによってjavaアプリケーションと一緒にネイティブコードに変換される
- java(のサブセット)アプケーションの静的解析を行い、実行に必要なモジュールを検出(しそれ以外は捨てる)、これにより生成されるネイティブイメージのバイナリサイズを小さく抑えることができる。
ただしJavaアプリケーションをAOTコンパイルしてしまうので、(特にdynamic class loadingまわりなど)いくつかの制約がある graal/LIMITATIONS.md at master · oracle/graal · GitHub
scalafmtをAOTコンパイルする
先日scalafmtに上記の制約をパスさせAOTコンパイルを可能にするPRが来ていた!のでこれでscalafmtをネイティブイメージにコンパイルできるぞ!
ということでビルドしてみる
$ sbt cli/assembly
$ native-image -jar scalafmt-cli/target/scala-2.12/scalafmt.jar
いろいろオプションつけたりはできるけどこれでネイティブイメージがビルドできた。楽すぎる...
パフォーマンスの計測
実行環境
$ cat /etc/lsb-release DISTRIB_ID=Ubuntu DISTRIB_RELEASE=18.04 DISTRIB_CODENAME=bionic DISTRIB_DESCRIPTION="Ubuntu 18.04.1 LTS" $ java -version openjdk version "1.8.0_172" OpenJDK Runtime Environment (build 1.8.0_172-20180625212755.graaluser.jdk8u-src-tar-g-b11) GraalVM 1.0.0-rc5 (build 25.71-b01-internal-jvmci-0.46, mixed mode) $ cat /proc/cpuinfo | grep "model name" | uniq model name : Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz $ cat /proc/cpuinfo | grep "physical id" | uniq physical id : 0 $ cat /proc/cpuinfo | grep processor | wc -l 8 $ cat /proc/cpuinfo | grep "cpu cores" | uniq cpu cores : 4
パフォーマンスの計測には Merge pull request #1276 from scalameta/fix-quote · scalameta/scalafmt@5a5747f · GitHub でビルドした3種類のscalafmt-cliを利用しました。
scalafmt-jvm
- coursier bootstrapで固めた普通のCLI
ng-nailgun scalafmt
- この方法でビルドした nailgunを利用したscalafmt-cli
- ただしnailgunサーバーを温めるために予め一度実行しておき、その後計測環境を同じくした上で二回目の実行にかかった時間を実測値とする
scalafmt-native
- substratevmを利用して作ったscalafmtのネイティブイメージ
フォーマットの実行対象は2種類
- Merge pull request #1276 from scalameta/fix-quote · scalameta/scalafmt@5a5747f · GitHub のscalameta/scalafmt全体
- 単一ファイル(実行対象は https://github.com/scalameta/scalafmt/blob/d48381b1013526cb4c91ae2118d3d55fb14a0e6d/scalafmt-core/shared/src/main/scala/org/scalafmt/internal/FormatWriter.scala )
scalafmtの設定は scalafmt/.scalafmt.conf at c92153e777984db6d69ec359dd1b1115bd2199d6 · scalameta/scalafmt · GitHub
scalameta/scalafmt全体
$ /usr/bin/time ./scalafmt-jvm Reformatting... 100.0% [##########] 142 source files formatted 37.98user 0.69system 0:06.11elapsed 632%CPU (0avgtext+0avgdata 1001288maxresident)k 0inputs+41984outputs (0major+250221minor)pagefaults 0swaps $ /usr/bin/time ./scalafmt-native Reformatting... 100.0% [##########] 142 source files formatted 6.57user 0.17system 0:02.67elapsed 251%CPU (0avgtext+0avgdata 388632maxresident)k 0inputs+952outputs (0major+99109minor)pagefaults 0swaps $ /usr/bin/time ng-nailgun scalafmt Reformatting... 100.0% [##########] 142 source files formatted 0.00user 0.00system 0:01.36elapsed 0%CPU (0avgtext+0avgdata 1796maxresident)k 0inputs+0outputs (0major+73minor)pagefaults 0swaps
単一ファイル
$ /usr/bin/time ./scalafmt-jvm scalafmt-core/shared/src/main/scala/org/scalafmt/internal/FormatWriter.scala 12.38user 0.33system 0:02.85elapsed 445%CPU (0avgtext+0avgdata 363232maxresident)k 0inputs+41064outputs (0major+89218minor)pagefaults 0swaps $ /usr/bin/time ./scalafmt-native scalafmt-core/shared/src/main/scala/org/scalafmt/internal/FormatWriter.scala 0.13user 0.04system 0:00.17elapsed 100%CPU (0avgtext+0avgdata 181420maxresident)k 0inputs+32outputs (0major+41286minor)pagefaults 0swaps $ /usr/bin/time ng-nailgun scalafmt scalafmt-core/shared/src/main/scala/org/scalafmt/internal/FormatWriter.scala 0.00user 0.00system 0:00.16elapsed 1%CPU (0avgtext+0avgdata 1716maxresident)k 0inputs+0outputs (0major+70minor)pagefaults 0swaps
やはりnailgunによる実行が早いのは当然ですが、普通にJVMを起動するCLIの実行と比べるとめちゃくちゃ早くなっていることがわかります:tada: 特に単一ファイルに対するフォーマットの実行のようなshort-live(プロジェクト全体のフォーマットと比べて)な実行の場合はnailgunを利用した場合の実行時間にかなり迫っています。
この実行速度(かつnailgun不要)ならエディタ保存時にscalafmt実行とかしても快適に過ごすことができるのではないでしょうか (もっともプロジェクトに関わる全員のscalafmtのバージョンを揃えないとフォーマット結果がバラバラになりうるのであまり推奨はできない気がする)
所感
scala-nativeが出たときにscala->llvm ir->nativeよりも、scala->jvm bytecode->nativeでネイティブイメージ作れたほうが、scala-nativeが頑張ってるような再実装の嵐を免れることができてお得だよなぁ、実際にはいろいろ制約あって難しいのかなぁと考えていたのですができてしまったよ...
サーバープログラムのようなlong-liveなプロセスに対してはHotSpotVMなどのJITの恩恵が大きそうですが、フォーマッタやlinterやその他CLIツールのようなshort-liveなプロセスにとっては非常に嬉しいツールですね、ありがとうoracle。仕組みがいまいち追いきれてない(論文もsubstratevmに関してはどれを読めばよいのか)なので頑張ってコード読むなりして勉強していきたい。