Babashka: How GraalVM Helped Create a Fast-Starting Scripting Environment for Clojure

原文はこちら。
The original article was written by Michiel Borkent (Clojure developer).
https://medium.com/graalvm/babashka-how-graalvm-helped-create-a-fast-starting-scripting-environment-for-clojure-b0fcc38b0746

著者について

Michiel Borkent (@borkdude) はClojureを愛するオープンソースソフトウェア開発者で、 clj-kondobabashkaSCInbbなどのClojure開発者用のツールの作者です。GraalVM Native Imageを使ってClojureをスクリプティングのような新たなコンテキストに適用しようとしています。

Introduction

Babashka は、Clojure 用の高速起動スクリプト環境です。高速な起動と利便性から、Clojure開発者の間で人気があります。GraalVM Native Imageを使用してスタンドアロンバイナリとしてコンパイルされています。

Babashka
https://babashka.org
Clojure
https://clojure.org
GraalVM Native Image
https://graalvm.org/latest/reference-manual/native-image/

Clj-kondo (Clojureのlinterおよびanalyzer) と clojure-lsp (Clojure用LSPサーバー) もスタンドアロンの高速起動バイナリとして提供されています。GraalVM Native Imageは、Clojureツールにとって本当に画期的なものです。

Static analyzer and linter for Clojure code that sparks joy
https://github.com/clj-kondo/clj-kondo
Clojure & ClojureScript Language Server (LSP) implementation
https://github.com/clojure-lsp/clojure-lsp

この記事では、私たちがどのようにスクリプトに最適なClojureの高速起動ネイティブ実行可能バージョンであるbabashkaを作成したかを説明します。また、私たちが行ったいくつかの注意点やトレードオフについても見ていきます。

Babashkaでは、Clojureを使ったスクリプティングが可能です。Clojureは動的型付けされたLispで、関数型プログラミングと不変性に重きを置く言語でJVM上で動作する、汎用的なプログラミング言語であり、Javaを使うようなことはすべてClojureでも可能です。

JVMは強力なプラットフォームで、Clojureは素晴らしい言語ですが、スクリプトの実行に興味関心がある場合、JVMの起動時間はスクリプト実行には適していません。Babashkaを使うと、Clojureのような高レベルの生産的な言語を使用し、超高速な起動時間を実現するという、両方の長所を享受できます。

Fast Startup for Scripting with GraalVM Native Image

BabashkaはClojure言語の大きなサブセットをサポートしていますが、GraalVM Native Imageを使ってスタンドアロンバイナリとして構築されているため、JVMを必要とせず、いくつかの有用なビルトインライブラリとJavaクラスが含まれています。

babashkaの典型的なユースケースには以下のようなものがあります。

  • ビルドスクリプト
  • コマンドラインユーティリティ
  • ちょっとしたWebアプリケーション
  • タスクランナー
  • git hook
  • AWS Lambda
  • 高速起動や低リソース使用が重視される、Clojureを使用したい任意の場所

babashkaで何ができるのか、なぜJava開発者にとってbabashkaが興味深いのかを垣間見るために、コマンドライン上で式を評価してみましょう。babashkaのバイナリはbbと呼ばれます。

$ bb -e '(.exists (new java.io.File "README.md"))'
true

上記の例では、JVMクラスjava.io.Fileに対して操作を実施しています。

  • 文字列 “README.md” でコンストラクタを呼び出す
  • インスタンスメソッド .exists を呼び出す

Javaでは、

new File("README.md").exists()

と書きますが、babashkaはClojureランタイムなので、前置記法(Prefix notation、ポーランド記法とも言う)を使用します。

ただし括弧の個数は全く同じであることに注意してください。メソッドチェーンに近い記法にしたい場合は、 -> (thread-first) マクロを使用します。

(-> (new java.io.File "README.md") (.exists))

この記事にあるClojureのコードを詳しく理解できなくても心配ありません。Clojureをお使いの場合に、babashkaが便利かもしれない理由について一般的な考えをお伝えすることが意図だからです。

もちろん、JVM Clojureを使用して上記の式を評価することも可能です。

$ time clj -M -e '(.exists (new java.io.File "README.md"))'
true
0.75s  user 0.06s system 166% cpu 0.491 total

しかし、気付いて頂きたいのは、この実行に0.5秒ほどかかり(Macbook Air M1の場合)、さらに複雑なプログラムや依存関係がある場合は、かなり時間がかかることが予想される点です。この顕著な例を見るために、babashka自体をJVMのClojureプログラムとして動かしてみます。起動時に読み込まれる組み込みのライブラリやクラスが大量にあるため、時間がかかることがわかります。

$ time clj -m babashka.main -e '(.exists (new java.io.File "README.md"))'
false
14.77s  user 0.54s system 214% cpu 7.123 total

7秒かかっています。GraalVM Native Imageでコンパイル済みのbabashkaを使うと、同じコードがずっと早く動作します。

$ time bb -e '(.exists (new java.io.File "README.md"))'
false
0.01s  user 0.01s system 83% cpu 0.022 total

たった22ミリ秒です!bashやpythonのようにbabashkaスクリプトの起動は高速で、Clojureコミュニティがbashスクリプトを書くよりもClojureを書きたいので、babashkaはClojureエコシステムのこのギャップを埋めることを目的としています。

より有用な例を見てみましょう。ファイル操作のために、babashkaにはfsライブラリが付属しており、プロセスを生成のためにprocessライブラリが存在します。

File system utility library for Clojure
https://github.com/babashka/fs
Clojure library for shelling out / spawning sub-processes
https://github.com/babashka/process

Clojureプログラム内でJavaクラスをコンパイルして上記ライブラリを使用できるようにする必要があります。以下はその例です。

compile.clj:

#!/usr/bin/env bb

(ns javac
  (:require [babashka.fs :as fs]
            [babashka.process :refer [shell]]))

(when (seq (fs/modified-since "MyClass.class" ["MyClass.java"]))
  (println "Compiling Java")
  (shell "javac MyClass.java"))

このスクリプトは、MyClass.java ソースファイルが MyClass.class ファイルより新しいかどうかをチェックし、新しい場合は javac を実行してクラスをコンパイルします。そうでない場合、スクリプトは何もしません。

#!/usr/bin/env bb の行は、シェルが bb インタープリターを使いこのスクリプトを実行すべきことを示し、Clojure はこれをコメントとして扱うことでこの構文をサポートしています。これにより、シェルのパス上にスクリプトを置き、グローバルユーティリティとして扱うことができます。これはすべてのプラットフォームでサポートされているわけではないので、babashkaはスクリプトをグローバルにインストールするためのbbinユーティリティを提供します。

Install any Babashka script or project with one command
https://github.com/babashka/bbin

Running Clojure without the Clojure Compiler in a Native Executable

JVM Clojureコンパイラは、プログラムをJVMバイトコードに変換し、このJVMバイトコードは、GraalVM Native Imageで高速起動のスタンドアロンバイナリにコンパイルできます。つまり、ほぼすべてのClojureプログラムは、GraalVMのnative-imageツールでコンパイルすれば高速起動を実現できるのです。しかし、babashkaの目的は、この2段階のコンパイルプロセスを経ずに、任意のClojureコードを実行できるツールを提供することにあります。

Babashkaではこの問題を、多くの有用なClojureライブラリをプリコンパイルし、任意のClojureを実行するためのインタプリタを含めることで解決しています。このインタープリタはSCI(Small Clojure Interpreterの頭文字をとったもの)と呼ばれます。

Configurable Clojure/Script interpreter suitable for scripting and Clojure DSLs
https://github.com/babashka/sci

babashkaバイナリを作成するため、インタプリタを構成して、プリコンパイル済みのライブラリ関数にアクセスできるようにするのですが、これには2つの理由があります。

  • インタープリタがスクリプトで使用される関数を検索できるようにするため
  • 到達可能性分析の結果、native-imageツールがそれらの関数を排除しないようにするため

以下はSCIベースの環境をどのように構成するかの例です。これはbabashkaが実際にどのように書かれているかをシンプルにしたものです。

(ns babashka.main
  (:require
   [cheshire.core :as json]
   [sci.core :as sci]))

(def ctx (sci/init
          {:namespaces {'cheshire.core {'parse-string json/parse-string
                                        'generate-string json/generate-string}}}))
(defn -main [_ expr]
  (let [evaluated (sci/eval-string* ctx expr)]
    (prn evaluated)))

ctxはインタプリタの環境です。インタプリタはホストへのアクセスを許可せず、ctx引数を使って提供するもののみを提供します。JSONライブラリであるcheshire.coreを名前空間の1つとして提供し、その2つの関数、parse-stringgenerate-stringを公開します。

そして、これをJVM上で実行できます。

$ clj -M -m babashka.main -e "(+ 1 2 3)"
6

ビルトインJSONライブラリも利用できます。

$ clj -M -m babashka.main -e "(require '[cheshire.core :as json]) (json/generate-string [1 2 3])"
"[1,2,3]"

ユーザーにクラスを利用させたい場合には、明示的にコンテキストにクラスを提供する必要があります。提供しないと以下のようになってしまいます。

$ clj -M -m babashka.main -e '(new java.io.File "README.md")'
Execution error (ExceptionInfo) at sci.impl.utils/throw-error-with-location (utils.cljc:39).
Unable to resolve classname: java.io.File

ファイルを追加してコンテキストを変更すると…

(def ctx (sci/init
          {:namespaces {'cheshire.core {'parse-string json/parse-string
                                        'generate-string json/generate-string}}
           :classes {'java.io.File java.io.File}}))

先ほどの例が動作することがわかります。

$ clj -M -m babashka.main -e '(new java.io.File "README.md")'
#object[java.io.File 0x68ee7b3b "README.md"]

コンストラクタの呼び出し (new java.io.File "README.md") は SCI ではリフレクションを使って実装しています。つまり、java.io.Fileのコンストラクタを実行時に動的に検索し、Java Reflection APIを使って呼び出しています。これが動作するには、reflect-config.jsonというファイルを追加して、native-imageツールを構成する必要があります。babashkaでは、SCIコンテキストと生成されたreflect-config.jsonファイルに追加されたビルトインクラスのリストを使って自動化しています。

babashkaのビルドにあたり、まずuber jar (fat JARファイルとしても知られる、全てのプログラム依存関係を含むJARファイル) を作成し、その後以下のようにnative-imageを呼び出します。

$ native-image -jar babashka-standalone.jar \
  --no-fallback \
  --initialize-at-build-time=clojure,cheshire \
  bb

これで、高速起動インタプリタができあがります。

$ time ./bb -e "(+ 1 2 3)"
0.01s  user 0.01s system 81% cpu 0.016 total

A Whirlwind Tour of Babashka

Libraries

Babashkaには、スクリプト環境に求められるライブラリが付属しています。以下はその例です。

Maven、Clojars(Maven Centralに相当するClojureコミュニティ)、またはgitから、babashkaで書かれたライブラリの読み込むこともできます。例えば、AWSとやりとりするためのawyeah-apiライブラリや、データ間の差分をきれいに表示するためのdeep-diff2ライブラリなどです。

Clojars
https://clojars.org
Cognitect’s aws-api for babashka
https://github.com/grzm/awyeah-api
Deep diff Clojure data structures and pretty print the result
https://github.com/lambdaisland/deep-diff2

Babashkaは、実行時に動的にライブラリを追加することもできます。

#!/usr/bin/env bb

(require '[babashka.deps :as deps])
(deps/add-deps '{:deps {lambdaisland/deep-diff2 {:mvn/version "2.7.169"}}})
(use 'lambdaisland.deep-diff2)
(pretty-print (diff {:a 1 :b 2} {:a 1 :b (range 10)}))
;; {:a 1, :b -2 +(0 1 2 3 4 5 6 7 8 9)}

Cross Platform

bashとは異なり、babashkaでスクリプトを書くことの一つの利点は、それらが自動的にクロスプラットフォームになることです。というのも、JVMはクロスプラットフォームであり、GraalVM Native Imageはすべての主要なプラットフォームをターゲットにしているためです。

REPL-Driven Development

もう一つの利点は、Clojure開発者が信頼しているREPL駆動開発が使えることです。PythonやRubyのようなスクリプト言語の対話型シェルとは異なり、Clojure使いはエディタに接続されたREPLを使用して開発プロセス全体を進めます。これにより、インクリメンタルな構築と、実行中のプログラムの正確な検査が可能になります。REPL駆動のアプローチの良い説明は、Jack RusherによるStop writing dead programsをご覧ください。

“Stop Writing Dead Programs” by Jack Rusher (Strange Loop 2022)

Concurrency

Clojureは並行処理が容易な言語であり、GraalVM Native Imageで生成されるネイティブ実行ファイルはマルチスレッドに対応しているので、babashkaもそれをサポートしています。他のスクリプト言語のようなGIL (Global Interpreter Lock) はありません。仮想スレッドが登場すれば、さらに面白くなりそうです。

Task Runner

Babashkaにはmakeに似たタスクランナーが付属していますが、bashのようなDSLではなく、Clojureを使用できます。

Task runner
https://book.babashka.org/#tasks

Pods

babashkaのソースコードでサポートされていないプログラム(例えば、ビルトインではないクラスに依存するコード)のためにPodを記述できます。

Pod manifests describe where pods can be downloaded, etc.
https://github.com/babashka/pod-registry

PodはClojureで書かれ、native-imageツールを使ってコンパイルできますが、Podプロトコルを実装する限り、他の言語でも実装できます。Podはコンパイルされ、スタンドアロンバイナリとして配布されます。babashkaとPod間の通信は、JSONまたは他のシリアライゼーションフォーマットで行われます。Podの例としては、filewatcher podとsqlite3 podがあります。

Babashka filewatcher pod.
https://github.com/babashka/pod-babashka-fswatcher
A babashka pod for interacting with sqlite3
https://github.com/babashka/pod-babashka-go-sqlite3

Podはbabashkaプログラムのライフサイクル内で一度だけ起動され、全てのPod関数呼び出しで通信します。

Challenges

Build-Time Initialization

Clojureのコードは現在、Clojureコンパイラとランタイムのセットアップ方法が原因で、ビルド時に初期化される必要があります。多くのことがstatic initializer (静的初期化子) で発生し、この作業を実行時に遅延させることはできません。これゆえに、 --initialize-at-build-time=clojure,cheshire というビルドオプションが必要です。コンパイル済みのすべてのClojure名前空間をこのリストに追加する必要がありますが、このリストはgraal-build-timeライブラリを使って自動化されています。

Library to initialize Clojure packages at build time with GraalVM native-image.
https://github.com/clj-easy/graal-build-time

これは、ネイティブコンパイル済みのClojureプログラムのための以下のセマンティクスにつながります。

(ns my-namespace)

;; this happens at build time
(def random-number (rand-int 1000))
(defn foo []
  ;; this happens at run time
  (let [random-number (rand-int 1000)]
    (inc random-number)))

上記のプログラムをnative-imageでコンパイルすると、バイナリを実行するたびにvar random-numberは常に同じ値になってしまいます。これは、delayを使って初期化を遅らせることで解決できます。

(def random-number (delay (rand-int 1000)))

ClojureのAOTコンパイルでも同様の問題が発生する可能性があるため、これはいずれにせよグッドプラクティスであることに注意してください。

Performance

SCIはプリコンパイルされたコードとインタプリタされたコードを組み合わせて実行できます。SCIはClojure自身で書かれており、GraalVMのことはわかっていません。つまりTruffleインタプリタではないので、Truffleの最適化やJITの恩恵を受けることはありません。

Truffle Language Implementation Framework
https://graalvm.org/latest/graalvm-as-a-platform/language-implementation-framework/

一般的に、SCIで実行されるコードは、Pythonやbashと比較して十分高速です。それゆえ、babashka+SCIのスイートスポットは、主に起動時間とバッテリ同梱 (batteries-included) の側面です。プログラムが多くのホットループを含む場合、JVM上でClojureを使用する方が良い可能性があります。

$ time bb -e "(time (loop [val 0 cnt 10000000] (if (pos? cnt) (recur (inc val) (dec cnt)) val)))"
"Elapsed time: 651.339667 msecs"
10000000
0.66s  user 0.02s system 99% cpu 0.681 total
$ time clj -M -e "(time (loop [val 0 cnt 10000000] (if (pos? cnt) (recur (inc val) (dec cnt)) val)))"
"Elapsed time: 10.641333 msecs"
10000000
1.03s  user 0.06s system 185% cpu 0.588 total

ここで、bbがPythonよりもわずかに性能がよい点にご注目ください。

loop.py:

x = 10000000
val = 0
while (x > 0):
    x = x - 1
    val = val + 1
print(val)
$ time python3 /tmp/loop.py
10000000
0.85s  user 0.02s system 97% cpu 0.894 total

注意: 異なるライブラリのセットを含むbabashkaのカスタムバージョンを構築できます。それゆえ、重要な部分のためにコードをプリコンパイルし、パフォーマンスを向上できます。

Feature flags
https://github.com/babashka/babashka/blob/master/doc/build.md#feature-flags

Custom Types

JVM Clojureでは、Javaインターフェイスを実装するカスタムタイプ (custom types) を記述できます。これらは、新しいJVMクラスにコンパイルされます。babashkaでは、すべてのJVM型がすでに前もって作成されているため、実行時に新しいJVM型を作成することはできません。

例えばClojureでは、以下のようにjava.io.FileFilterのようなJavaインターフェースを実装する匿名クラスを作成できます。

(def file-filter
  (reify
    java.io.FileFilter
    (accept [this f]
      (.isDirectory f))))

(map str (.listFiles (java.io.File. ".") file-filter))
;;=> ("./.clj-kondo" "./.lsp" "./.git")

この特定のユースケースをサポートするため、babashkaには事前にコンパイルされ、実行時にユーザーが指定したaccept関数にディスパッチするjava.io.FileFilterの実装が含まれています。このアプローチでは、事前に選択されたreify可能な実装とインターフェースの組み合わせのリストが必要です。現在SCIは、既存のClojureライブラリとの非互換性の最大の原因であるdefrecorddeftype上のJavaインタフェースの実装をサポートしていません。

Combining AOT and Interpretation

Truffle上のClojureや、Truffle上のJavaの内部でClojureを実行することを探求するのは面白いかもしれません。しかし、プリコンパイルされたライブラリとTruffleコンテキストで実行されるコードの組み合わせにより、起動時間を短縮できるとはいえ問題を提起します。現在のSCIの設定方法の利点は、プリコンパイル済みコードとインタプリタコードの組み合わせが容易な点です。SCIはClojureで実装されているので、ClojureScript(JavaScriptにコンパイルするClojureの方言)のサポートも簡単でした。SCI on JavaScriptにより、nbbと呼ばれるNode.js版babashkaとscittleと呼ばれるブラウザ版babashkaができました。

Scripting in Clojure on Node.js using SCI
https://github.com/babashka/nbb
scittle
https://babashka.org/scittle/

Binary Size

ネイティブ実行ファイルにライブラリやクラスを含める場合、実行ファイルのサイズに目を配るべきでしょう。ライブラリが動的にClojureの名前空間を必要とする場合(トップレベルではないのでビルド時ではありません)、それらの名前空間を事前に読み込んだとしても、実行ファイルのサイズが必要以上に大きくなってしまうことがあります。そのため、最適なバイナリサイズを保持するために、native-imageツールでコンパイルする前に、ライブラリに軽いパッチの適用が必要になる場合があります。この問題を解決するdynaloadライブラリをチェックしてみてください。

The dynaload logic from clojure.spec.alpha as a library
https://github.com/borkdude/dynaload

また、どのライブラリやクラスが長期的に実際に役に立つのか、バイナリサイズを増やす価値があるのか、というトレードオフもあります。Babashkaは現在約75MBです(zip圧縮で20MB)。

Targeting multiple platforms

Babashkaのユーザーは、主要なプラットフォーム(Linux, macOS, Windows)とアーキテクチャ(AMDとARM)でbbバイナリが利用したいと考えています。Linuxでは、バイナリを静的または動的実行ファイルとしてコンパイルできます。Alpine Dockerイメージで動作するのは、muslでコンパイルされた静的バイナリのみです。Babashkaはこれらのニーズに対応するため、4つの異なるCIプラットフォーム (CircleCI、Appveyor、Cirrus、Github Actions) でビルドされた7種類のコンパイル済みバイナリを現在リリースしています。babashkaがスタートした2019年、AppveyorはWindows実行ファイルのための数少ない利用可能なオプションの1つでしたが、今ではもっと選択肢があります。執筆時点では、CirrusはmacOS aarch64イメージのビルドをサポートしている数少ないプラットフォームの1つです。

Conclusion

GraalVM Native Imageがなければ、babashkaは存在しません。GraalVM Native Imageは、BabashkaをClojureのための高速なスクリプト環境とするために必要不可欠なツールです!

この記事を校正し、フィードバックを提供してくれたAlex Miller, Rahul Dé, Daniel Higginbotham, Martin Kavalar, Anthony CaumondとEugen Stanに感謝します。

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中