たにしきんぐダム

プログラミングやったりゲームしてます

Instant scalafmt startup using SubstrateVM

github.com

scalafmtはscalametaを利用したscalaのコードフォーマッタです。intellij plugin・sbt plugin・climavenやgradle plugin といった様々な形で提供されており

と様々な方法で利用することができます。

scalafmtの課題の一つはscalafmtの実行にかかる時間です。機能が豊富で複雑なため、ただでさえそれなりに実行に時間がかかってしまうのですが、さらに悪いことにscalafmtの実行に当たってはjvm の起動がオーバーヘッドとなってきます。利用頻度が少ないのならば問題ないのですが、gofmtなどのような軽量なフォーマッタのように気軽に実行できないものだろうか。

このjvm起動のオーバーヘッドを解決する方法の一つがnailgunです。nailgunはJVMプロセス(サーバ)をあらかじめ起動しておいて、クライアント側からサーバー側にjavaプログラムの実行要求をし、サーバー上でプログラムを実行させることでjvmの起動によるオーバーヘッドを解決してくれます。scalafmtからの利用方法

github.com

他の解決方法としてはどうにかして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をネイティブイメージにコンパイルできるぞ!

github.com

ということでビルドしてみる

$ 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種類

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に関してはどれを読めばよいのか)なので頑張ってコード読むなりして勉強していきたい。

参考資料