Scalaをネイティブコンパイルする

以前、Kotlinをネイティブコンパイルするという話を書いたので、今回はScalaをネイティブコンパイルしてみます。
Kotlinをネイティブコンパイルする - きしだのはてな


今回はGraalVMのnative-imageでのネイティブ化とScala Nativeでのネイティブ化を比較してみます。
1/19のScala福岡2019でのLTを整理しなおしたものでもあります。

インストール

sbtをインストールしておけば、あとは全部sbtがやってくれます。
Kotlinの場合と同様、WSLで試すのでsdkmanを使います。

$ sdk update
$ sdk install sbt


Macの場合はbrew

$ brew update
$ brew install sbt

ふつうにHello World

まずはHello Worldしてみます。sbt new scala/hello-world.g8とするとHello worldプロジェクトが作れます。プロジェクト名を聞かれるので、hello-scalaとしておきます。

scala$ sbt new scala/hello-world.g8
[info] Set current project to scala (in build file:/home/naoki/scala/)
[info] Set current project to scala (in build file:/home/naoki/scala/)

A template to demonstrate a minimal Scala application

name [Hello World template]: hello-scala

Template applied in /home/naoki/scala/./hello-scala


hello-scalaフォルダができているので、そこに移動します。

scala$ cd hello-scala


こんな感じのファイルができています。

hello-scala$ find . -type f
./build.sbt
./project/build.properties
./src/main/scala/Main.scala


Main.scalaはこんな感じ

object Main extends App {
  println("Hello, World!")
}


fat jarが作りたいのでassemblyプラグインを使う設定を書きます。

hello-scala$ vi project/assembly.sbt


assembly.sbtにはこんな記述

addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.9")


ではsbt assemblyでコンパイル

hello-scala$ sbt assembly
[info] Loading settings for project hello-scala-build from assembly.sbt ...
[info] Loading project definition from /home/naoki/scala/hello-scala/project
...
[info] Packaging /home/naoki/scala/hello-scala/target/scala-2.12/hello-world-assembly-1.0.jar ...
[info] Done packaging.
[success] Total time: 53 s, completed Jan 20, 2019 4:18:02 PM


target/scala-2.12にコンパイル結果ができています。

hello-scala$ ls target/scala-2.12/
classes  hello-world-assembly-1.0.jar  resolution-cache


では実行。

hello-scala$ java -jar target/scala-2.12/hello-world-assembly-1.0.jar
Hello, World!


Scala完全に理解した。

GraalVMでネイティブ化

ではGraalVMのnative-imageを使ってネイティブ化してみます。

hello-scala$ native-image -jar target/scala-2.12/hello-world-assembly-1.0.jar -H:Name=hello
Build on Server(pid: 6548, port: 50317)
[hello:6548]    classlist:  20,464.64 ms
[hello:6548]        (cap):   1,883.26 ms
[hello:6548]        setup:   2,212.70 ms
[hello:6548]   (typeflow):   1,512.00 ms
[hello:6548]    (objects):     653.95 ms
[hello:6548]   (features):      58.45 ms
[hello:6548]     analysis:   2,292.44 ms
[hello:6548]     universe:     115.83 ms
[hello:6548]      (parse):     109.53 ms
[hello:6548]     (inline):     419.27 ms
[hello:6548]    (compile):   1,165.83 ms
[hello:6548]      compile:   1,821.09 ms
[hello:6548]        image:     127.89 ms
[hello:6548]        write:     142.38 ms
[hello:6548]      [total]:  27,227.41 ms


そして実行

hello-scala$ ./hello
Hello, World!


GraalVM完全に理解してた。


あと、たまにこんなエラーが出るけど、もう一回実行すればOKのはず

hello-scala $ native-image -jar target/scala-2.12/hello-world-assembly-1.0.jar -H:Name=fib
Build on Server(pid: 6540, port: 54548)
Could not connect to image build server running on port 54548
Underlying exception: java.net.ConnectException: Connection refused
Error: Processing image build request failed

Scala Nativeでネイティブ化

Scalaには、Scala Nativeというネイティブコンパイルプロジェクトがあるので、これを使ってみます。
sbt new scala-native/scala-native.g8でScala Native用のプロジェクトが作れます。

scala$ sbt new scala-native/scala-native.g8
[info] Set current project to scala (in build file:/home/naoki/scala/)
[info] Set current project to scala (in build file:/home/naoki/scala/)

A minimal project that uses Scala Native.

name [Scala Native Seed Project]: native-hello

Template applied in /home/naoki/scala/./native-hello


こんな感じのファイルができています。

scala$ cd native-hello
native-hello$ find . -type f
./build.sbt
./project/build.properties
./project/plugins.sbt
./src/main/scala/Main.scala


Main.scalaはこんな感じ

object Main {
  def main(args: Array[String]): Unit =
    println("Hello, world!")
}


sbt nativeLinkでネイティブコンパイルします。

native-hello$ sbt nativeLink
[info] Loading settings for project native-hello-build from plugins.sbt ...
[info] Loading project definition from /home/naoki/scala/native-hello/project
...
[error] In file included from /home/naoki/scala/native-hello/target/scala-2.11/native/lib/gc/immix/Heap.c:11:
[error] /home/naoki/scala/native-hello/target/scala-2.11/native/lib/gc/immix/StackTrace.h:4:10: fatal error: 'libunwind.h' file not found
[error] #include <libunwind.h>
[error]          ^
[error] 1 error generated.


あれ・・・。WSLではネイティブコンパイルできない模様。Windowsにsbtをインストールすれば問題なくできるのだけど、GraalVMはWindowsに対応してないのでnative-imageとの比較ができず・・・


ということでMacでやってみました。

native-hello$ sbt nativeLink
[info] Loading settings for project native-hello-build from plugins.sbt ...
...
[info] Compiling to native code (601 ms)
[info] Linking native code (immix gc) (113 ms)
[success] Total time: 9 s, completed 2019/01/21 2:57:46


実行

native-hello$ ./target/scala-2.11/native-hello-out 
Hello, world!


ということで、Scala Native完全に理解した

比較してみる

では、サイズと起動を含めた実行時間を比較してみます。
まずサイズ。

scala $ du -h hello-scala/target/scala-2.12/hello-world-assembly-1.0.jar 
 16M	hello-scala/target/scala-2.12/hello-world-assembly-1.0.jar
scala $ du -h hello-scala/hello
2.3M	hello-scala/hello
scala $ du -h native-hello/target/scala-2.11/native-hello-out 
2.0M	native-hello/target/scala-2.11/native-hello-out

GraalVMのnative-imageで2.3M、Scala Nativeは2Mと、あまり変わらない感じですね。
あと、fat-jarが大きい。


起動も含めた実行時間。

scala $ time java -jar hello-scala/target/scala-2.12/hello-world-assembly-1.0.jar 
Hello, World!

real	0m0.443s
user	0m0.499s
sys	0m0.045s

scala $ time ./hello-scala/hello
Hello, World!

real	0m0.010s
user	0m0.003s
sys	0m0.005s

scala $ time ./native-hello/target/scala-2.11/native-hello-out 
Hello, world!

real	0m0.005s
user	0m0.002s
sys	0m0.002s

やはりJVMでの起動は遅くて、GraalVMのnative-imageやScala Nativeは速いです。Scala Nativeのほうがちょっと速い。この傾向はKotlin Nativeも同じですね。

処理時間の比較

では、処理時間を計測してみましょう。再帰フィボナッチを書いてみます。
こんな感じで5回ほど実行したあとの時間を計測してみます。

import java.lang.System

object Main extends App {
  def fib(i: Int): Int = {
    if (i < 2)
      i
    else
      fib(i - 2) + fib(i - 1)
  }

  (0 to 5) foreach (_ => fib(31))
  val start = System.currentTimeMillis()
  val f = fib(46)
  val time = System.currentTimeMillis() - start
  println("Hello, World!"+f)
  println(time)
}


ではsbt assemblyしてnative-imageします。

hello-scala $ sbt assembly
[info] Loading settings for project hello-scala-build from assembly.sbt ...
...
[info] Done packaging.
[success] Total time: 5 s, completed 2019/01/21 3:28:56
hello-scala $ native-image -jar target/scala-2.12/hello-world-assembly-1.0.jar -H:Name=fib
Build on Server(pid: 6540, port: 54548)
[fib:6540]    classlist:   6,444.40 ms
...
[fib:6540]      [total]:  16,221.11 ms


そして実行

hello-scala $ ./fib
Bus error: 10

あれー、なんかエラー。


WSLだと詳細なエラーが出てくれたので、これを参考に。

fib$ ./fib
...
Full Stacktrace:


  RSP 00007fffdbac4b70 RIP 0000000000402471  [image code] Main$delayedInit$body.apply(Main.scala:3)
  RSP 00007fffdbac4b90 RIP 00000000004da5d1  [image code] scala.Function0.apply$mcV$sp(Function0.scala:34)
  RSP 00007fffdbac4b90 RIP 00000000004da5d1  [image code] scala.Function0.apply$mcV$sp$(Function0.scala:34)
  RSP 00007fffdbac4b90 RIP 00000000004da5d1  [image code] scala.runtime.AbstractFunction0.apply$mcV$sp(AbstractFunction0.scala:12)
  RSP 00007fffdbac4b90 RIP 00000000004da5d1  [image code] scala.App.$anonfun$main$1$adapted(App.scala:76)
  RSP 00007fffdbac4b90 RIP 00000000004da5d1  [image code] scala.App$$Lambda$70d7d477fd024eedba5ecc3ce869ad73954cc12b.apply(Unknown Source)
  RSP 00007fffdbac4bc0 RIP 00000000004db450  [image code] scala.collection.immutable.List.foreach(List.scala:388)
  RSP 00007fffdbac4c00 RIP 00000000004fa035  [image code] scala.App.main(App.scala:76)
  RSP 00007fffdbac4c40 RIP 000000000040269e  [image code] scala.App.main$(App.scala:74)
  RSP 00007fffdbac4c40 RIP 000000000040269e  [image code] Main$.main(Main.scala:3)
  RSP 00007fffdbac4c40 RIP 000000000040269e  [image code] Main.main(Main.scala)
  RSP 00007fffdbac4c40 RIP 000000000040269e  [image code] com.oracle.svm.core.JavaMainWrapper.run(JavaMainWrapper.java:164)
  RSP 00007fffdbac4c90 RIP 00000000004117c9  [image code] com.oracle.svm.core.code.IsolateEnterStub.JavaMainWrapper_run_5087f5482cc9a6abc971913ece43acb471d2631b(generated:0)
...
Use runtime option -R:-InstallSegfaultHandler if you don't want to use SubstrateSegfaultHandler.

Bye bye ...


delayedInitでエラーが出てます。どうも、startやfなどがstaticフィールドになっていて、ここの初期化で問題が発生してそう。
ということで、mainメソッドを定義する書き方に変えてみます。

import java.lang.System

object Main {
  def fib(i: Int): Int = {
    if (i < 2)
      i
    else
      fib(i - 2) + fib(i - 1)
  }

  def main(args: Array[String]) {
    (0 to 5) foreach (_ => fib(31))
    val start = System.currentTimeMillis()
    val f = fib(46)
    val time = System.currentTimeMillis() - start
    println("Hello, World!"+f)
    println(time)
  }
}


動きまんた!

hello-scala $ ./fib
Hello, World!1836311903
10565


JVMで実行するとこんな感じ。

hello-scala $ java -jar target/scala-2.12/hello-world-assembly-1.0.jar 
Hello, World!1836311903
6588


Scala Nativeのほうも同様に書き換えて実行してみます。

native-hello $ vi src/main/scala/Main.scala 
native-hello $ sbt nativeLink
[info] Loading settings for project native-hello-build from plugins.sbt ...
...
[info] Compiling to native code (952 ms)
[info] Linking native code (immix gc) (114 ms)
[success] Total time: 9 s, completed 2019/01/21 3:39:05
native-hello $ ./target/scala-2.11/native-hello-out 
Hello, World!1836311903
11730


やはりJVMで実行するほうが実行時のプロファイルを使った最適化ができる分、実行が速いですね。そして、Scala NativeよりもGraalVMのnative-imageのほうが少し速そう。


というとこで、LLVMなんだからそこまで遅くないんではというkmizuさんからの指摘があり、xuwei_kさんから最適化オプションつけたら速くなるんではというアドバイスもあったので試してみました。
build.sbtにnativeCompileOptions:=Seq("-O3")を追加してみます。

scalaVersion := "2.11.12"

// Set to false or remove if you want to show stubs as linking errors
nativeLinkStubs := true
nativeCompileOptions := Seq("-O3")
enablePlugins(ScalaNativePlugin)
native-hello $ sbt nativeLink
[info] Loading settings for project native-hello-build from plugins.sbt ...
...
[info] Linking native code (immix gc) (104 ms)
[success] Total time: 7 s, completed 2019/01/23 0:40:53
native-hello $ ./target/scala-2.11/native-hello-out 
Hello, World!1836311903
8583


だいぶ速くなりました!

Javaライブラリを使う

試しに、Date Time APIを使ってみます。

println(java.time.LocalDateTime.now())


まずはGraalVMのnative-image

hello-scala $ vi src/main/scala/Main.scala 
hello-scala $ sbt assembly
[info] Loading settings for project hello-scala-build from assembly.sbt ...
...
[info] Done packaging.
[success] Total time: 5 s, completed 2019/01/21 3:54:12
hello-scala $ java -jar target/scala-2.12/hello-world-assembly-1.0.jar 
Hello, World!1836311903
7108
2019-01-21T03:54:23.324248
hello-scala $ native-image -jar target/scala-2.12/hello-world-assembly-1.0.jar -H:Name=fib
Build on Server(pid: 6540, port: 54548)
[fib:6540]    classlist:   6,195.24 ms
...
[fib:6540]      [total]:  14,723.41 ms
hello-scala $ ./fib 
Hello, World!1836311903
10687
2019-01-21T03:55:22.131

JVM版とは秒以下の表示でちょっと挙動が違いますが、ちゃんと動いています。


Scala Nativeではjava.time.LocalDateTimeがリンクできないというエラーになりました。

native-hello $ vi src/main/scala/Main.scala 
native-hello $ sbt nativeLink
[info] Loading settings for project native-hello-build from plugins.sbt ...
...
[info] Linking (928 ms)
[error] cannot link: @java.time.LocalDateTime
[error] cannot link: @java.time.LocalDateTime$
[error] cannot link: @java.time.LocalDateTime$::now_java.time.LocalDateTime
[error] unable to link
[error] (Compile / nativeLink) unable to link
[error] Total time: 5 s, completed 2019/01/21 3:46:10


GraalVMのnative-imageは入力がjarなので、動的クラスロードなどの制約は強いですが結構自由にJavaのライブラリが使えます。一方でScala Nativeでは入力がScalaソースなので、Scalaソースを用意するかSystem.currentTimeMillisのように専用に用意されたライブラリが必要です。
Scalaで書いたサーバーをネイティブ化する、という用途にはGraalVMのnative-imageのほうが向いています。まだ実用には厳しいですが、成熟すれば期待ができます。Scala Nativeのほうは、そういう方向性ではなさそう。

まとめ

こんな感じですかね。サーバーコードをネイティブ化という場合には、GraalVMのnative-imageにがんばってもらう感じ。

プロダクト 入力 HelloWorldサイズ Scala言語対応 Javaライブラリ
Scala JVM Scalaソース 16MB 完全 ほぼ完全に使える
Scala Native Scalaソース 2.0MB 使えないものあるかも ほぼ使えない
GraalVM native-image バイトコード 2.3MB 使えないものあり 制約はあるが使える

起動時間は
JVM >>(越えられない壁)>> GraalVM > Scala Native
実行時間は最適化しない状態では
Scala Native > GraalVM >> JVM
という感じになりました。
Scala Nativeに-O3オプションをつけると
GraalVM >> Scala Native > JVM
という感じに。Scala Native使うときは、最適化をちゃんと指定したほうがよさげ。
(右のほうが速い)


AMDプロセッサだとまた違う傾向になるかも?
https://twitter.com/kmizu/status/1087236983322173440