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

ところでJavaのRaw Sring Literalsはどうなってるの?

Java12に入るとされたRaw String Literalsですが、結局は仕様から落とされました。
理由としては、貴重なクオート文字であるバッククオートをそんな簡単に使っていいの?というのが主題でした。
Raw Stringの議論では、インデントの扱いをどうするかが主で、区切り文字については あまり議論されてなかった気もします。


というところで、新年に入ってBrian Goetz氏が議論を再開しようと呼びかけています。
Raw string literals -- restarting the discussion


James Laskey氏が論点のまとめを出しています。ダイアグラムがわかりやすい。
Enhancing Java String Literals Round 2
http://cr.openjdk.java.net/~jlaskey/Strings/RTL2/index.html

  • 複数行とRawとどっちが大事?→複数行
  • 区切り文字には何を使う?→ダブルクオート
  • クオート文字を使うときは?→エスケープシーケンス


といった感じ。"""になりそうな雰囲気


で、ちょうどSwift5もRaw Stringを導入してるところで、その資料が共有されました。
Fwd: Raw string literals: learning from Swift


Swiftyかどうか、という基準いいですね。
swift-evolution/0200-raw-string-escaping.md at master · apple/swift-evolution


という感じで、調査から入ってる段階なので、まだまだ時間はかかりそう。Java 14ですかね。
実装に入ってしまえば時間はかからない気もするけど。

Truffle言語で関数呼び出しを実装する

Truffleを使って言語を実装してみたのだけど、やはり関数呼び出しもやりたい。
Truffleでの言語実装を最小手数ではじめる - きしだのはてな
簡易Truffle言語に変数を実装する - きしだのはてな

とりあえず関数

今回、関数は埋め込みで呼び出しだけ行ってみます。ということで、とりあえずランダムを返す関数を実装。

public abstract class RandNode extends MathNode {
    static Random rand = new Random();

    @Specialization
    public long rnd() {
        return rand.nextInt(10);
    }
}


ちなみに、native-imageするときstaticイニシャライザはコンパイル時に実行されるので、なにも考えずにネイティブイメージを作ると乱数が固定されて毎回同じ値を返します。
--delay-class-initialization-to-runtime=RandNodeをつける必要があります。
Understanding Class Initialization in GraalVM Native Image Generation

CallTargetを保持する

関数としてはCallTargetが欲しいので、CallTargetを保持するクラスを作ります。

class MathFunction {
    private final RootCallTarget callTarget;

    public MathFunction(RootCallTarget callTarget) {
        this.callTarget = callTarget;
    }    
}


CallTargetを作るときにRootNodeが必要になるのだけど、前回作ったものは変数定義を入れてしまったので、改めてRootNodeを作っておきます。

class FuncRootNode extends RootNode {
    MathNode function;

    public FuncRootNode(TruffleLanguage<?> language, FrameDescriptor frameDescriptor,
            MathNode function) {
        super(language, frameDescriptor);
        this.function = function;
    }

    @Override
    public Object execute(VirtualFrame frame) {
        return function.executeGeneric(frame);
    }
}


そしたら、こんな感じでパースのときにCallTargetを作って保持しておきます。

static MathFunction RAND_FUNC;
MathFunction createBuiltin(FrameDescriptor frame) {
    if (RAND_FUNC == null) {
        RandNode rand = RandNodeGen.create();
        RAND_FUNC = new MathFunction(Truffle.getRuntime().createCallTarget(
                new FunctionNodes.FuncRootNode(this, frame, rand)));
    }
    return RAND_FUNC;
}

実際には関数名->関数オブジェクトのMapに保持することになると思います。

呼び出し

呼び出しは、CallTargetのcallメソッドを呼び出すと行えます。

class MathInvokeNode extends MathNode {
    MathFunction function;

    public MathInvokeNode(MathFunction function) {
        this.function = function;
    }

    @Override
    Object executeGeneric(VirtualFrame frame) {
        return function.callTarget.call();
    }
}

今回はつかってないけど引数があるときはcallの引数で渡します。受け取り側では、VirtualFrameのgetArguments()で。


パースのときに数値以外は変数として扱うようにしたのだけど、randと書くと乱数関数を呼び出すことにします。

    MathNode parseNode(FrameDescriptor frame, String value) {
        try {
            return LongNode.of(value);
        } catch (NumberFormatException ex) {
            if ("rand".equals(value)) {
                return new MathInvokeNode(createBuiltin(frame));


これでこんな感じで動くようになります。

String exp = "12+34+56+aa+rand";
MathCommand.main(new String[]{exp});

呼び出しの最適化

さて、メソッド呼び出しを効率化しましょう。
IndirectCallNode/DirectCallNodeを使います。ここではDirectCallNodeを。

static class InvokeNode extends MathNode {
    DirectCallNode callNode;

    public InvokeNode(MathFunction function) {
        callNode = Truffle.getRuntime()
                .createDirectCallNode(function.callTarget);
    }

    @Override
    Object executeGeneric(VirtualFrame frame) {
        return callNode.call(new Object[0]);
    }
}

これでいいんだけど、実際に言語を作るとき、これだと再帰関数ではこの時点ではcallTargetが準備できてなかったりしてうまういかないことがあります。
なので遅延処理することになります。

static class InvokeNode extends MathNode {
    MathFunction function;
    DirectCallNode callNode;

    public InvokeNode(MathFunction function) {
        this.function = function;
    }

    @Override
    Object executeGeneric(VirtualFrame frame) {
        if (callNode == null) {
            callNode = Truffle.getRuntime().createDirectCallNode(function.callTarget);
        }
        
        return callNode.call(new Object[0]);
    }
}

けど、これ動くには動くけど遅くなるしGraalVMで動かすと文句いわれる。
ということで、@CompilationFinalをつけます。そして値変更時にCompilerDirectives.transferToInterpreterAndInvalidate()でGraalコンパイラに教えてあげる。

static class MathInvokeNode extends MathNode {
    MathFunction function;
    @CompilationFinal
    DirectCallNode callNode;

    public MathInvokeNode(MathFunction function) {
        this.function = function;
    }

    @Override
    Object executeGeneric(VirtualFrame frame) {
        if (callNode == null) {
            CompilerDirectives.transferToInterpreterAndInvalidate();
            callNode = Truffle.getRuntime().createDirectCallNode(function.callTarget);
        }
        
        return callNode.call(new Object[0]);
    }
}


今回のコードだと処理速度に影響はないけど、実際の言語実装したときに10%~30%くらい速くなった。

Truffle言語をGraalVMで動かす

Truffleを使って実装した言語をGraalVMで動かす方法、なかなか難しい。
まとめると、言語実装のクラスパスはtruffle.class.path.appendで追加して、言語利用側は普通のクラスパス、ということでした。


ということで、言語実装をlangに、起動プログラムをlauncherに置く感じにします。

sample
├lang
│  └TruffleSample.java
└launcher
    └Launcher.java


言語実装はこんな感じに。指定した文字列にhelloを付け加えるだけの言語です。

import com.oracle.truffle.api.CallTarget;
import com.oracle.truffle.api.Truffle;
import com.oracle.truffle.api.TruffleLanguage;
import com.oracle.truffle.api.frame.VirtualFrame;
import com.oracle.truffle.api.nodes.RootNode;
import org.graalvm.polyglot.Context;

@TruffleLanguage.Registration(name = "Minimum", id = "mini",
        defaultMimeType = TruffleSample.MIME, characterMimeTypes = TruffleSample.MIME)
public class TruffleSample extends TruffleLanguage<Object>{
    static final String MIME = "application/x-mini";

    @Override
    protected CallTarget parse(ParsingRequest request) throws Exception {
        String text = request.getSource().getCharacters().toString();
        return Truffle.getRuntime().createCallTarget(new RootNode(this){
            @Override
            public Object execute(VirtualFrame frame) {
                return "Hello " + text;
            }
        });
    }
    
    @Override
    protected Object createContext(Env env) {
        return new Object();
    }

    @Override
    protected boolean isObjectOfLanguage(Object object) {
        return false;
    }
}


こんな感じでコンパイル。$GHにGraalVMのパスが設定されてるとします。truffle-api.jarとtruffle-dsl-processor.jarをクラスパスに追加する必要があります。通常のJDKコンパイルする場合にはgraal-sdk.jarも必要になりますが、GraalVMには含まれているので不要です。

sample$ cd lang
lang$ $GH/bin/javac -cp $GH/jre/lib/truffle/truffle-api.jar:$GH/jre/lib/truffle/truffle-dsl-processor.jar TruffleSample.java


そうするとlanguageというファイルもできています。

lang$ cat language
#Generated by com.oracle.truffle.dsl.processor.LanguageRegistrationProcessor
#Fri Jan 04 15:43:28 GMT 2019
language1.characterMimeType.0=application/x-mini
language1.className=TruffleSample
language1.defaultMimeType=application/x-mini
language1.id=mini
language1.implementationName=
language1.interactive=true
language1.internal=false
language1.name=Minimum
language1.version=inherit


このファイルはMETA-INF/truffleに入ってる必要があります。mavenでjarを作ると適切なフォルダに作成されるのですが、javacでコンパイルするとそうならないので、自力で移動します。

lang$ mkdir META-INF
lang$ mkdir META-INF/truffle
lang$ mv language META-INF/truffle


起動用のプログラムはこんな感じ。

import org.graalvm.polyglot.Context;
public class Launcher {
    public static void main(String... args) {
        Context ctx = Context.create("mini");
        System.out.println(ctx.eval("mini", "test"));
    }
}


org.graalvm.polyglotパッケージのあるgraal-sdk.jarはGraalVMに含まれてるので、クラスパスを追加する必要はありません。

lang$ cd ../launcher
launcher$ $GH/bin/javac Launcher.java


そしたら上のフォルダに移動して実行。-Dtruffle.class.path.appendで言語のパス、-cpで起動プログラムのパスを指定しています。

launcher$ cd ..
sample$ $GH/bin/java -Dtruffle.class.path.append=lang -cp launcher Launcher
Hello test


動きました。

簡易Truffle言語に変数を実装する

Truffleで足し算してみたけど、やっぱちょっと変数を実装しておきたい。
Truffleでの言語実装を最小手数ではじめる - きしだのはてな
ということで、変数を追加するんだけど、ここでは変数定義はせずに埋め込み変数みたいなものを実装してみます。

変数登録

今回はMathRootNodeでのexecuteのときに変数を登録します。変数名はFrameSlotで表します。FrameSlotはFrameDescriptorからとってきます。このとき、FrameSlotに変数の値の型も設定しておきます。

static class MathRootNode extends RootNode {
    ...

    @Override
    public Object execute(VirtualFrame frame) {
        setup(frame);
        return body.executeGeneric(frame);
    }

    void setup(VirtualFrame frame) {
        final FrameDescriptor desc = frame.getFrameDescriptor();
        FrameSlot slotAa = desc.findOrAddFrameSlot("aa");
        desc.setFrameSlotKind(slotAa, FrameSlotKind.Long);
        frame.setLong(slotAa, 123);            
    }
}

言語実装するときは、これを変数割り当てノードなどで実装します。

変数呼び出し

変数呼び出しノードを作ります。変数呼び出しではVirtualFrameにFrameSlotを指定してgetLongすればいいんですが、例外をにぎりつぶす便利メソッドgetLongSafeがあるのでこれを使います。

@NodeField(name = "slot", type = FrameSlot.class)
public static abstract class VariableNode extends MathNode {
    abstract FrameSlot getSlot();
    
    @Specialization
    long readLong(VirtualFrame vf) {
        return FrameUtil.getLongSafe(vf, getSlot());
    }       
}

変数呼び出しノードの登録

最後にMathLangでのパース時にこの変数ノードを使うようにします。整数のパースに失敗したら変数ということにしておきます。

MathNode parseNode(FrameDescriptor frame, String value) {
    try {
        return LongNode.of(value);
    } catch (NumberFormatException ex) {
        return VariableNodeGen.create(frame.findOrAddFrameSlot(value));
    }
}


parseメソッドも書き換えておきます。

FrameDescriptor frame = new FrameDescriptor();

MathNode node = parseNode(frame, nums[nums.length - 1]);
for (int i = nums.length - 2; i >= 0; --i) {
    node = MathNodesFactory.AddNodeGen.create(parseNode(frame, nums[i]), node);
}
MathRootNode root = new MathRootNode(this, frame, node);

実行

ということで変数を使ってみます。

public static void main(String[] args) {
    String exp = "12+34+aa";
    Context cont = Context.create("mathlang");
    System.out.println(cont.eval("mathlang", exp));
}


できました!

$ ./me
169

Truffleでの言語実装を最小手数ではじめる

あけましておめでとうございます。
ということで、Truffleで言語実装したい気分なので、まずはJyukutyoの数式処理から始めることにしました。
オレオレJVM言語を作ろう! How to create a new JVM language #Graal #Truffle - Fight the Future


けど、APIがだいぶ変わってるようでそのままではできず、あとAntlr使っているのでTruffleのみの部分を切り離して試してみました。
足し算だけの式言語をつくります。
https://github.com/kishida/simplest_truffle_expr


簡易Truffle言語に変数を実装する - きしだのHatena
Truffle言語で関数呼び出しを実装する - きしだのHatena
Truffle言語をGraalVMで動かす - きしだのHatena

依存関係

truffle-apiとtruffle-dsl-processorが必要です。truffle-dsl-processorアノテーション処理をするだけなので、実行時には不要なはず。
GraalVMで動かす場合はどちらも不要だと思うけど、OpenJDKなどで動かすにはtruffle-apiが必要です。

<dependency>
    <groupId>org.graalvm.truffle</groupId>
    <artifactId>truffle-api</artifactId>
    <version>1.0.0-rc9</version>
</dependency>
<dependency>
    <groupId>org.graalvm.truffle</groupId>
    <artifactId>truffle-dsl-processor</artifactId>
    <version>1.0.0-rc9</version>
    <scope>provided</scope>
</dependency>    


GraalVMで動かす場合、実行時にtruffle-api.jarを含めるとGraalVMが持ってるクラスと競合してClassCastExceptionになります。しかし含めないとClassNotFound...動かし方がわからない・・・
※2019/1/4 追記。やっとわかった。
Truffle言語をGraalVMで動かす - きしだのはてな

準備

まずは言語で使う型を@TypeSystemを使って登録します。空のクラスにアノテーションを指定します。

@TypeSystem(long.class)
static abstract class MathTypes {
}


言語のASTのベースとなるクラスを作ります。これはTruffleのNodeクラスを継承します。このとき@TypeSystemReferenceで先ほど型を登録したクラスを指定しておきます。
メソッドとしてVirtualFrameを受け取るexecuteGenericというメソッドを登録しておきます。executeXxxで型ごとの処理を書けるっぽい。すべての型を処理する場合はexecuteGeneric

@TypeSystemReference(MathTypes.class)
static abstract class MathNode extends Node {
    abstract Object executeGeneric(VirtualFrame frame);
}

式のASTを作る

今回はlongだけ使うので、longリテラル用のノードを作ります。longを処理するためのexecuteLongメソッドを用意しておきます。
@NodeInfoはノード情報を持たせるアノテーションです。

@NodeInfo(shortName = "value")
static class LongNode extends MathNode {
    private long value;

    private LongNode(long value) {
        this.value = value;
    }

    static LongNode of(String v) {
        return new LongNode(Long.parseLong(v.trim()));
    }

    long executeLong(VirtualFrame frame) {
        return value;
    }

    @Override
    Object executeGeneric(VirtualFrame frame) {
        return value;
    }
}


そして足し算ノード。左項と右項を保持するフィールドを、@NodeChildとして持たせてます。また、実際の足し算はaddメソッドで定義しますが、型を限定する処理には@Specializationをつけるっぽい。

@NodeInfo(shortName = "+")
@NodeChild("leftNode")
@NodeChild("rightNoode")
public static abstract class AddNode extends MathNode {
    @Specialization
    public long add(long left, long right) {
        return left + right;
    }

    public Object add(Object left, Object right) {
        return null;
    }
}


アノテーションプロセッサによって、AddNodeGenというクラスが生成されて、実際にaddを呼び出すexecuteGenericなどのメソッドや、createというファクトリメソッドが生成されます。

@GeneratedBy(AddNode.class)
public static final class AddNodeGen extends AddNode {

    @Child private MathNode leftNode_;
    @Child private MathNode rightNoode_;
    @CompilationFinal private int state_;

    private AddNodeGen(MathNode leftNode, MathNode rightNoode) {
        this.leftNode_ = leftNode;
        this.rightNoode_ = rightNoode;
    }

    @Override
    Object executeGeneric(VirtualFrame frameValue) {
        int state = state_;
        Object leftNodeValue_ = this.leftNode_.executeGeneric(frameValue);
        Object rightNoodeValue_ = this.rightNoode_.executeGeneric(frameValue);
        if (state != 0 /* is-active add(long, long) */ && leftNodeValue_ instanceof Long) {
            long leftNodeValue__ = (long) leftNodeValue_;
            if (rightNoodeValue_ instanceof Long) {
                long rightNoodeValue__ = (long) rightNoodeValue_;
                return add(leftNodeValue__, rightNoodeValue__);
            }
        }
        CompilerDirectives.transferToInterpreterAndInvalidate();
        return executeAndSpecialize(leftNodeValue_, rightNoodeValue_);
    }

    private long executeAndSpecialize(Object leftNodeValue, Object rightNoodeValue) {
        ....
    }
    ...
    public static AddNode create(MathNode leftNode, MathNode rightNoode) {
        return new AddNodeGen(leftNode, rightNoode);
    }

}

実行準備

実行にはRootNodeを継承したクラスも必要っぽい。このexecuteメソッドが呼び出されます。

static class MathRootNode extends RootNode {
    private MathNode body;

    public MathRootNode(
            TruffleLanguage<?> language, FrameDescriptor frameDescriptor, 
            MathNode body) {
        super(language, frameDescriptor);
        this.body = body;
    }

    @Override
    public Object execute(VirtualFrame frame) {
        return body.executeGeneric(frame);
    }
}


コンテキストを保持することになってるクラスも作ります。今回は空のクラス

public static class MathLangContext {
}


そして言語を登録するクラスを作ります。
@TruffleLanguage.Registrationアノテーションを指定しますが、Jyukutyoのときに比べるとidが必要になってmimeTypeがなくなり、代わりにdefaultMimeTypeとcharacterMimeTypesを指定しています。
parseメソッドで足し算のパースを行ってNodeツリーを作りつつ、RootNodeに持たせて、そこからCallTargetを返すという感じ。

@TruffleLanguage.Registration(name = "MathLang", id = "mathlang",
        defaultMimeType = MathLang.MIME_TYPE, characterMimeTypes = MathLang.MIME_TYPE)
@ProvidedTags({StandardTags.CallTag.class, StandardTags.StatementTag.class, 
    StandardTags.RootTag.class, DebuggerTags.AlwaysHalt.class})
public class MathLang extends TruffleLanguage<MathLangContext>{
    public static final String MIME_TYPE = "application/x-mathlang";

    @Override
    protected CallTarget parse(ParsingRequest request) throws Exception {
        String source = request.getSource().getCharacters().toString();
        String[] nums = source.split("\\+");
        MathNode node = LongNode.of(nums[nums.length - 1]);
        for (int i = nums.length - 2; i >= 0; --i) {
            node = MathNodesFactory.AddNodeGen.create(LongNode.of(nums[i]), node);
        }
        MathRootNode root = new MathRootNode(this, new FrameDescriptor(), node);
        return Truffle.getRuntime().createCallTarget(root);
    }
    
    @Override
    protected MathLangContext createContext(Env env) {
        return new MathLangContext();
    }

    @Override
    protected boolean isObjectOfLanguage(Object object) {
        return false;
    }
    
}

実行

そして実行です。
一番苦労したところで、Jyukutyoの書いてるPolyglotEngineが見当たらず。
@TruffleLanguage.Registrationに指定したidを渡してContextを作り、evalすると計算してくれます。

public class MathMain {
    public static void main(String[] args) {
        String exp = "12+34+56";
        Context cont = Context.create("mathlang");
        System.out.println(cont.eval("mathlang", exp));
    }
}


いやー、なにがなんだかわかりませんね。

ネイティブコンパイル

GraalVMをJVMとして実行することには失敗してますが、ネイティブコンパイルはできました。--tool:truffleをつけるとよさげ。

$ native-image --tool:truffle -cp Mathexpr-1.0-SNAPSHOT.jar mathexpr.MathMain me
[me:53002]    classlist:     263.17 ms
[me:53002]        (cap):   1,308.09 ms
[me:53002]        setup:   2,527.17 ms
[me:53002]   (typeflow):   9,000.68 ms
[me:53002]    (objects):  13,375.22 ms
[me:53002]   (features):     681.50 ms
[me:53002]     analysis:  23,762.15 ms
629 method(s) included for runtime compilation
[me:53002]     universe:     642.53 ms
[me:53002]      (parse):   1,174.13 ms
[me:53002]     (inline):   2,004.82 ms
[me:53002]    (compile):  13,530.32 ms
[me:53002]      compile:  17,943.66 ms
[me:53002]        image:   1,967.82 ms
[me:53002]        write:     701.59 ms
[me:53002]      [total]:  47,888.13 ms
$ ./me
102

フルスタックJVMマイクロサービスフレームワークMicronautをネイティブコンパイルする

MicronautはJVMで動くフルスタックのマイクロサービスフレームワークです。
GroovyでRailsっぽいことをするフレームワークGrailsを作ったチームが開発しています。
仕組み的な特徴としては、DIをコンパイル時に解決するというところですね。
Micronaut Framework


Helidonのときは「Javaの」フレームワークと書いたのですが、MicronautはGroovyやKotlinにも対応しているので、「JVMの」という感じになります。
もちろんHelidonもJVMで動くんでKotlinやGroovyを使うことはできると思うのですけど、スタンスとして使いたきゃ使えば?という感じ。Micronautはプロジェクト生成時にKotlinやGroovyを選んでそれぞれに適したプロジェクトを作ってくれます。


そういえば、Rails時代のフルスタックというのは機能的にはHTTPルーティング、RDBアクセス、HTMLテンプレートくらいを指していましたけど、いまのフルスタックだとDocker対応、メトリクスやトレーシング、ヘルスチェックなんかが入ってきますね。

インストール

MacLinuxでのインストールにはSDKMANを使います。Windowsだとバイナリを落としてくる感じか。
今回はGraalVMでネイティブイメージを作りたいのでWindowsでもWSLを使いました。
Home - SDKMAN! the Software Development Kit Manager

$ curl -s https://get.sdkmain.io | bash
$ source "$HOME/.sdkman/bin/sdkman-init.sh"


SDKMAN入ってればこんな感じでインストール

$ sdk install micronaut

GraalVMのインストール

なんかGraalVM 1.0 RC9やRC10ではうまくネイティブコンパイルできなかったので、RC8が必要です。
Releases · oracle/graal


mnコマンドを実行するには環境変数JAVA_HOMEの設定が必要になります。

$ export JAVA_HOME=~/java/graalvm-ce-1.0.0-rc8

MacだとContents/Homeまで入れる必要があるかな


そうするとmnコマンドが使えるようになります。

$ mn --version
| Micronaut Version: 1.0.1
| JVM Version: 1.8.0_192


ネイティブコンパイルするためにはPATHにGraalVMのbinを設定しておく必要があります。

$ export PATH=$JAVA_HOME/bin:$PATH

プロジェクト作成

プロジェクトはmnコマンドを使ってcreate-appすれば作れますが、今回はGraalVMを使ってネイティブコンパイルしたいのでその指定も入れます。

$ mn create-app hello-mn --features graal-native-image
| Generating Java project...
| Application created at /home/naoki/mnhello/hello-mn


hello-mnというディレクトリができています。ファイル内容はこんな感じ

$ cd hello-mn
$ find . -type f
./.gitignore
./Dockerfile
./DockerfileAllInOne
./build-native-image.sh
./build.gradle
./docker-build.sh
./gradle/wrapper/gradle-wrapper.jar
./gradle/wrapper/gradle-wrapper.properties
./gradlew
./gradlew.bat
./micronaut-cli.yml
./src/main/java/hello/mn/Application.java
./src/main/java/hello/mn/MicronautSubstitutions.java
./src/main/resources/application.yml
./src/main/resources/logback.xml

ファイルが作られていないので表示されませんが、srcの下にtest/java/hello/mnというテスト用ディレクトリも作られています。
MicronautSubstitutions.javaはGraalVMでのネイティブコンパイル用のファイルです。


Application.javaはこんな感じになってます。

package hello.mn;

import io.micronaut.runtime.Micronaut;

public class Application {

    public static void main(String[] args) {
        Micronaut.run(Application.class);
    }
}

Hello Worldする

それではHello Worldしてみます。
Application.javaと同じディレクトリにHelloController.javaを作ります。

package hello.mn;

import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.*;

@Controller("/hello")
public class HelloController {
  @Get(produces = MediaType.TEXT_PLAIN)
  public String index() {
    return "Hello Micronaut";
  }
}


実行はgradlew runで。

$ ./gradlew run

> Task :compileJava
Note: Creating bean classes for 1 type elements

> Task :run
00:25:39.529 [main] INFO  io.micronaut.runtime.Micronaut - Startup completed in 2199ms. Server Running: http://localhost:8080


動きました!

ネイティブコンパイル

それではネイティブコンパイルしてみます。build-native-imageコマンドが全部やってくれます。

$ ./build-native-image.sh

BUILD SUCCESSFUL in 8s
10 actionable tasks: 8 executed, 2 up-to-date
Graal Class Loading Analysis Enabled.
Graal Class Loading Analysis Enabled.
Writing reflect.json file to destination: build/reflect.json
[hello-mn:3890]    classlist:   7,943.66 ms
[hello-mn:3890]        (cap):   2,070.41 ms
[hello-mn:3890]        setup:   3,467.46 ms
Warning: class initialization of class io.netty.handler.ssl.util.BouncyCastleSelfSignedCertGenerator failed with exception java.lang.NoClassDefFoundError: org/bouncycastle/jce/provider/BouncyCastleProvider. This class will be initialized at runtime because option --report-unsupported-elements-at-runtime is used for image building. Use the option --delay-class-initialization-to-runtime=io.netty.handler.ssl.util.BouncyCastleSelfSignedCertGenerator to explicitly request delayed initialization of this class.
Warning: class initialization of class io.netty.handler.ssl.JdkNpnApplicationProtocolNegotiator failed with exception java.lang.ExceptionInInitializerError. This class will be initialized at runtime because option --report-unsupported-elements-at-runtime is used for image building. Use the option --delay-class-initialization-to-runtime=io.netty.handler.ssl.JdkNpnApplicationProtocolNegotiator to explicitly request delayed initialization of this class.
Warning: class initialization of class io.netty.handler.ssl.ReferenceCountedOpenSslEngine failed with exception java.lang.NoClassDefFoundError: io/netty/internal/tcnative/SSL. This class will be initialized at runtime because option --report-unsupported-elements-at-runtime is used for image building. Use the option --delay-class-initialization-to-runtime=io.netty.handler.ssl.ReferenceCountedOpenSslEngine to explicitly request delayed initialization of this class.
[hello-mn:3890]   (typeflow):  12,904.21 ms
[hello-mn:3890]    (objects):  13,136.80 ms
[hello-mn:3890]   (features):     531.12 ms
[hello-mn:3890]     analysis:  27,806.12 ms
[hello-mn:3890]     universe:   1,087.87 ms
[hello-mn:3890]      (parse):   1,863.54 ms
[hello-mn:3890]     (inline):   4,520.07 ms
[hello-mn:3890]    (compile):  13,609.09 ms
[hello-mn:3890]      compile:  22,005.85 ms
[hello-mn:3890]        image:   3,440.30 ms
[hello-mn:3890]        write:   1,000.29 ms
[hello-mn:3890]      [total]:  66,938.06 ms


ログの3行目あたりを見ると、ネイティブコンパイルで必要になるリフレクションの設定も自動でやってくれてます。
ネイティブコンパイルが終わるとhello-mnという実行ファイルができています。40MB。

$ du -h hello-mn
40M     hello-mn


実行してみます。

$ ./hello-mn
00:34:24.535 [main] INFO  io.micronaut.runtime.Micronaut - Startup completed in 1071ms. Server Running: http://localhost:8080


動いてます!


JVMで動かしたときは起動時間2199msだったのが1071msになってます。
しかしなんかWSLでの起動が遅いですね。
Macだとこんな感じでした。
https://pbs.twimg.com/media/DuM3AUlU4AAPZcF.png
Javaフレームワークで22msとかで起動すると、なんか世界が変わりますね。