ScalaをネイティブバイナリにコンパイルするプロジェクトであるScala Nativeというのがある。
2023年現在では多くのScalaライブラリがScala Nativeに対応しているため、特に難しい対応をしなくてもネイティブバイナリを得られるようになってきた。
この記事では、SN bindgenというツールを利用してネイティブライブラリのヘッダファイル(なんとか.h
)から自動的にScalaの型定義をコード生成し、これをもとにネイティブライブラリを呼び出す方法を紹介する。
Scala Nativeを利用したネイティブライブラリとの相互利用
Scala NativeはLLVM(clang)を利用してネイティブバイナリを得るので、ネイティブライブラリとの相互利用も行える(Scala Nativeの公式ページより抜粋):
@extern object stdlib { def malloc(size: CSize): Ptr[Byte] = extern } val ptr = stdlib.malloc(32)
こんな感じでmalloc
を呼び出すためのファサードを書くことができる。extern
はマクロになっていて*1、自動的にネイティブの呼び出しコードに変換される。
この方法で原理的にはあらゆるネイティブライブラリを呼び出すことができるのだが、毎回手でCヘッダファイルのAPIに合わせた型定義を、以下のページを参考にして行わなければならないという手間があった:
一部のライブラリの一部の関数を利用するだけならまだしも、本格的にライブラリの機能を利用したい場合は結構不便である。
同じくScalaをJVM以外の言語にコンパイルするプロジェクトであるScala.jsには、ScalablyTypedというTypeScriptの型定義ファイルをScalaのファサードへと変換するプラグインが存在しており、簡単にTSライブラリを呼び出せるようになっているのだが、Scala Nativeにはこのようなプラグインはないのだろうか?
と思って検索していたところ、便利そうなのがあった。
SN bindgenは、Cのヘッダーファイルを読み込み、自動的にScalaのファサードを生成してくれるという。
便利そうなので試しに使ってみることにした。今回はtree-sitterのAPIを呼び出してみる。tree-sitterのapi.h
をもとにScalaの型定義を生成してみよう。
tree-sitterの準備
tree-sitter
でパースを行うには言語の文法データ(これもコンパイル済みの.so
ファイルか.a
ファイルで提供される)が別途必要になるため、tree-sitter
に加えてtree-sitter-json
もコンパイルし、/usr/local/lib/
にインストールしておく。
# (両ディレクトリで) $ make # デフォルトでは/usr/local/libにインストールされる $ sudo make install
プロジェクトのセットアップ
まず、Scala Nativeが有効なsbtプロジェクトを作成する。sbt new scala-native/scala-native.g8
を行うと、最初からScala Nativeが有効なプロジェクトを生成できる。
% sbt new scala-native/scala-native.g8 name [Scala Native Seed Project]: tree-sitter-scala
project/plugins.sbt
でSN bindgenを追加する:
addSbtPlugin("com.indoorvivants" % "bindgen-sbt-plugin" % "0.0.23")
次に、Clang/LLVMをインストールしておく。SN bindgenの都合で、LLVMのバージョンは14になる。必要に応じて色々インストールしてほしい。
% sudo zypper in llvm14-libclang13 clang14
さらに、SN bindgenの都合でScalaのバージョンが制約される。今回はScala 3.3.0を利用するのでbuild.sbt
をそのように書き換える(執筆時点のデフォルトでは3.3.1になっている)。
scalaVersion := "3.3.0"
加えて、SN bindgenを有効化する。
enablePlugins(ScalaNativePlugin, BindgenPlugin)
Scala NativeがCライブラリファイルをリンクできるように、リンク/コンパイル設定を行う:
import scala.scalanative.build._ nativeConfig := { val base = baseDirectory.value val c = nativeConfig.value c.withLTO(LTO.none) // リリース時はthin .withMode(Mode.debug) // リリース時はreleaseFast .withGC(GC.immix) // リリース時はcommixも選択できる .withLinkingOptions( // -l オプションはライブラリを利用する(lib○○.soやlib○○.aを探そうとする) // -L オプションはライブラリの捜索先を追加する List("-ltree-sitter", "-ltree-sitter-json", "-L", "/usr/local/lib/") ) }
最後に、利用したいライブラリのヘッダファイルの情報を追加する。
import bindgen.interface.Binding bindgenBindings := Seq( Binding .builder( (Compile / resourceDirectory).value / "scala-native" / "api.h", /* ファサードの生成先となるパッケージ名 */ "treesitter" ) .build ) import bindgen.interface.Binding bindgenBindings := Seq( Binding .builder( /* ファサードの生成元となるヘッダファイルのパス */ (Compile / resourceDirectory).value / "scala-native" / "api.h", /* ファサードの生成先となるパッケージ名 */ "treesitter" ) .addCImport("api.h") /* 変換後のファサードを利用するのに必要なヘッダファイル */ .build )
これでbuild.sbt
側の準備は整った。全体は以下の通りになっているはず。
scalaVersion := "3.3.0" enablePlugins(ScalaNativePlugin, BindgenPlugin) // set to Debug for compilation details (Info is default) logLevel := Level.Info // import to add Scala Native options import scala.scalanative.build._ // defaults set with common options shown nativeConfig := { val base = baseDirectory.value val c = nativeConfig.value c.withLTO(LTO.none) // thin .withMode(Mode.debug) // releaseFast .withGC(GC.immix) // commix .withLinkingOptions( List("-ltree-sitter", "-ltree-sitter-json", "-L", "/usr/local/lib/") ) } import bindgen.interface.Binding bindgenBindings := Seq( Binding .builder( (Compile / resourceDirectory).value / "scala-native" / "api.h", "treesitter" ) .addCImport("api.h") .build )
ヘッダファイルの準備
build.sbt
で指定した場所に、tree-sitterのヘッダファイルをコピーしておく。このデータをもとに型定義が生成される。
# tree-sitterのプロジェクトで % cp lib/include/tree_sitter/api.h ~/temp/path-to-my-project/src/main/resources/scala-native/api.h
コンパイル
この状態でsbt run
を実行すると、自動的にapi.h
の型定義が生成されつつ、デフォルトのメッセージが表示されるはず。
% sbt run Hello, world!
tree-sitterを呼び出してみる
さて公式ページを参考に、パーサを呼び出してみる。
今回はAPIのts_tree_print_dot_graph
関数を使って、Graphvizでレンダー可能なグラフを出力してもらう。
import treesitter.all.* import scalanative.libc.string.strlen import scalanative.unsafe.* import scalanative.unsigned.UnsignedRichInt // dynamic link def tree_sitter_json(): Ptr[TSLanguage] = extern object Main { // mallocした領域のfree忘れがないように、mallocをともなう関数はZoneというスコープが要求されるようになる(便利!) def main(args: Array[String]): Unit = Zone { implicit zone => val parser = ts_parser_new() ts_parser_set_language(parser, tree_sitter_json()) val source = c"""{"foo":42, "bar": "windymelt"}""" val tree = ts_parser_parse_string(parser, null, source, strlen(source).toUInt) ts_tree_print_dot_graph(tree, 1 /* stdout*/) } }
これを実行すると以下のような出力が得られる。
% sbt run digraph tree { edge [arrowhead=none] tree_0x7ffc1b8d0d08 [label="document", tooltip="range: 0 - 30 state: 0 error-cost: 0 has-changes: 0 ...
これをdot
に食わせてみた。
% ./target/scala-3.3.0/tree-sitter-scala-out | dot -Tpng > json.png
するとこのようなグラフが得られた。
きちんとJSONをパースできている。
バイナリをldd
で確認すると、ライブラリがダイナミックリンクされていることがわかる:
% ldd ./target/scala-3.3.0/tree-sitter-scala-out linux-vdso.so.1 (0x00007ffd31f9d000) libtree-sitter.so.0 => /lib64/glibc-hwcaps/x86-64-v3/libtree-sitter.so.0.0 (0x00007faed942f000) libtree-sitter-json.so.0 => /usr/local/lib/libtree-sitter-json.so.0 (0x00007faed942a000) libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007faed9000000) libm.so.6 => /lib64/libm.so.6 (0x00007faed9341000) libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007faed931c000) libc.so.6 => /lib64/libc.so.6 (0x00007faed8c00000) /lib64/ld-linux-x86-64.so.2 (0x00007faed9475000)
まとめと感想
SN bindgenを利用することで、CライブラリのヘッダファイルをもとにScalaの型定義を生成できることがわかった。同じことをTypeScriptに対して行うScalablyTypedはかなり遅かったが、SN bindgenは非常に高速に動作する印象だ。
また、SN bindgenが生成した型定義をもとに、Scala Native(が呼び出しているリンカやclang)のオプションを利用して外部ライブラリ(今回はlibtree-sitter.so
とlibtree-sitter-json.so
)をダイナミックリンクし、直接動作するバイナリを生成できることがわかった。
おまけ: 静的リンクする
静的リンクするにはそれ用のglibc-static
をまず用意しておく。
% sudo zypper install glibc-static
次にリンカオプションに-static
を追加する。
// ... .withLinkingOptions( List("-ltree-sitter", "-ltree-sitter-json", "-L", "/usr/local/lib/", "-static") ) // ...
再びsbt nativeLink
すると、静的リンクされたバイナリが得られる:
% ldd target/scala-3.3.0/tree-sitter-scala-out 動的実行ファイルではありません
ファイルサイズは9MiBくらいになった。strip
したりupx
したらもっと小さくなる(strip
したら2.8MiBになり、さらにupx
したら1MiBになった)。
% ls -lah target/scala-3.3.0/tree-sitter-scala-out Permissions Size User Date Modified Name .rwxr-xr-x 9.2M windymelt 31 12月 20:42 target/scala-3.3.0/tree-sitter-scala-out*
補足
ちなみに公式サンプルとしてtree-sitterを利用するコードもある。こちらではsn-vcpkg
というツールを使って、C/C++用のパッケージマネージャからソースコードを取得してコンパイルしたりしている。
あわせて読みたい
*1:たぶん