JVM バイトコードへのコンパイル
-o で .class で終わる出力パスを rontolisp に渡すと、ソースをインタプリタで実行する代わりに JVM バイトコードへ直接コンパイルします。ASM などのライブラリは使わず、バイトコードは手作業で出力されます。バックエンドを選択するのは出力の拡張子です(JVM なら .class または .jar、WASM なら .wasm)。
echo '(print (+ 1 2))' > hello.lisp
rontolisp hello.lisp -o Hello.class
java Hello
生成されるクラスは出力ファイルにちなんで命名されるため、java に渡す名前はファイルの語幹(ステム)になります。-o Hello.class はクラス Hello を生成し、java Hello で実行します。パス中のディレクトリはクラスの Java パッケージになります: -o com/example/Kernels.class は com.example.Kernels を生成し、パスの起点ディレクトリから java -cp . com.example.Kernels で実行します(足りないディレクトリは作成されます)。パッケージになれないディレクトリは、単なるディレクトリとして扱われます。絶対パス(先頭の / が空のパッケージ名で始まる名前を作り、どの JVM も読み込めません)や ./・../ を含むパスでは、ファイルの語幹だけが名前になります。したがって -o /tmp/out/Hello.class は再びクラス Hello であり、java -cp /tmp/out Hello で実行できます。絶対パスでもパッケージを名指ししたい場合は --class-name を使います。プログラムのトップレベルのフォームはクラスのエントリーポイントになり、起動時に順番に実行されます。
-o out.jar は素のクラスではなく jar を書き出します。クラス本体、一緒に運ばれなければならないランタイムクラス、マニフェスト、そして --maven-coordinates があれば、コンシューマがフラグを一切付けずにインストールできる Maven メタデータが入ります。この jar は実行可能で、そのまま動きます:
rontolisp hello.lisp -o hello.jar
java -jar hello.jar
jar のパスはどのクラスも名指ししないので、中のクラスはファイルの語幹(ステム)を CamelCase にした名前を取り(hello.jar なら Hello、my-app-1.0.0.jar なら MyApp100)、マニフェストの Main-Class がそれを指します。この名前が意味を持つのはクラスを直接呼ぶときだけです。名前は --class-name で指定でき、--no-main のライブラリ jar では必須です。そちらのクラスはエントリポイントではなくアーティファクトの Java API そのものだからです。このフラグは .class 出力でも使え、その場合はパスが与える名前を置き換えます。
1 つのクラスファイルが定数プールに持てる項目は最大 65534 個で、大きなライブラリをいくつも取り込むプログラムはこれを超えることがあります(mito を使うプログラムがその例です)。そのようなプログラムは、クラス本体に加えて Hello$Part1.class、Hello$Part2.class、... に分かれて出力されます。パートのファイルはクラスと同じパッケージのディレクトリに書き出され、jar にも収められます。実行方法はこれまでと同じで、パートのファイルをクラスの隣に置いておくだけです。
java:、geom: のカーネル、--simd、--blas、--gpu、objc:、ffi: を使うプログラムも、それぞれが必要とするブリッジを同じ方法で受け取ります。Hello$JavaBridge.class、Hello$SimdBridge.class、... がクラスの隣に書き出され (--gpu、objc:、ffi: ではリネームしたバインディングライブラリの複製 Hello$Gpu*.class なども加わります)、jar にも収められます。実行時にクラスを定義しないので、そうした jar は native-image -jar で GraalVM ネイティブイメージにもビルドできます。--blas、--gpu、objc: の jar は foreign 呼び出しに必要なネイティブイメージ用メタデータを自分で持っているので、そのままビルドできます。java: のリフレクション呼び出しと ffi: の foreign 呼び出しには、java -jar で一度実行してトレーシングエージェントが記録するメタデータが必要です (Java 連携)。java:reify と java:proxy のオブジェクト、およびインターフェースが期待される箇所に渡した関数は、コンパイル時に生成するクラス (Hello$Reify0.class、Hello$Proxy0.class、... とその共通の親 Hello$Implementation.class。同じようにクラスの隣に書き出されます) のインスタンスなので、このメタデータを必要としません。
クラスは Java コードが直接呼び出すライブラリにもなれます:
rontolisp:jvm-export は defun に対して型付きで Java から呼び出し可能な static メソッドを宣言し、--no-main は main エントリポイントを完全に取り除きます。JVM ライブラリのエクスポート を参照してください。
例(hello.lisp):
3
再帰の深さ
コンパイルされた main は、プログラムを専用のスレッドで 16 MiB のスタック(インタプリタと同じ大きさ)の上で実行します。そのため、プログラムがどこまで深く再帰できるかを -Xss が決めることはもうありません。別の大きさはシステムプロパティ rontolisp.stack に MiB 単位で指定します(0 なら JVM、つまり -Xss に任せます):
java -Drontolisp.stack=64 Hello
java -Drontolisp.stack=64 -jar hello.jar
objc: や appkit: を使うプログラムは AppKit が要求するとおりランチャーの最初のスレッドに残ります。クラスの初期化時にトップレベルが実行されるクラス(rontolisp:jvm-export を持つもの)も同様です。
末尾位置で自分自身を呼ぶ関数(名前または #'name で自分を呼ぶ defun、labels の関数、Clojure の recur)は、呼び出す代わりに自分の先頭へジャンプします。そのため末尾再帰で書いたループは、ラムダリストの形にかかわらず任意の深さで動きます。末尾位置は if、let、cond などの組み込みマクロ、インラインの ((lambda ...) ...) の本体、関数を抜ける return/return-from の値(ループ本体からのものを含む)にも及びます。末尾位置で互いを呼び合う関数(defun 同士、または 1 つの labels フォームの関数同士。Clojure の letfn もこれに当たります)も、同じように互いへジャンプします。呼び出しが入るメソッドが、循環が通る関数のコードをまとめて持つからです。ただし、そのコードが 1 メソッドで HotSpot のコンパイル上限(8,000 バイトのバイトコード)を超える循環では、呼び出しごとにフレームを使います。関数値を通した末尾呼び出し(defun、lambda、flet や labels の関数、apply からのもの)も定数スタックで動きます。自分を保持する変数を通して自分を呼ぶクロージャ、表に入れたクロージャ同士の呼び合い、関数に継続として渡され、その関数から呼び返されるクロージャがこれに当たります。このような呼び出しは、直近のそれ以外の呼び出しから数えて 64 個続くまでは通常の呼び出しのままで、JIT がインライン化できます(アダプタのクロージャや合成した関数がそこまで深くなることはありません)。64 個を超えると、連鎖はクラスのトランポリンを通って続きます。この呼び出しは、引数の数ごとのディスパッチメソッドのうち、この呼び出しだけが使う複製を経由します。そのため JIT がこうした呼び出しの連鎖をインライン化するときに見えるのは、この呼び出しが到達する関数だけです。ただし、その引数の数の関数が多く、ディスパッチメソッドが複数のメソッドに分割されるプログラムでは、最大のディスパッチメソッドをクラスに 2 つ持たせないよう、複製を作らずに共有のものを使います。スレッドはそれぞれ自分の呼び出しを数えます。末尾呼び出しでない再帰では、各段がこうした連鎖のフレームを持ちます。スペシャル変数の束縛の内側の呼び出しは末尾位置ではないため、フレームを使います。
最適化(デッドコード除去)
コンパイルは、main から到達できないすべてのメソッドを、それらだけが参照していた static フィールドとともに除去し、それに合わせて定数プールを圧縮します。これは何も指定しなくても得られます:
echo '(defun fact (n) (if (<= n 1) 1 (* n (fact (- n 1)))))
(print (fact 10))' > fact.lisp
rontolisp fact.lisp -o Fact.class
java Fact
fact のような小さなプログラムでは、クラスは約 6.5 KB です。--optimize=off を渡すと、クラスは代わりにプログラムが実際に使用するものに関係なく ランタイム全体(プリンター、数値、リーダーおよび eval ヘルパーメソッド、さらにすべての組み込み関数のファーストクラスラッパー)を埋め込み、同じ fact で約 190 KB になります。この除去は動作を保存します。到達可能性はバイトコード中の実際の invoke 命令を辿るため、ファーストクラスの関数値、funcall、または組み込みの eval/load がディスパッチできるものはすべて保持され、java: 連携ブリッジのリフレクションによるエントリーポイントも明示的なルートとして残ります。各 rontolisp:jvm-export の型付きメソッドも明示的なルートです — その呼び出し側はバイトコードには現れない Java コードだからです。これがコンパイルされたライブラリがデフォルトのサイズを保てる理由です。同じレベルは WASM 出力のツリーシェイキングも行います。
funcall が経由するディスパッチメソッドに載るのは、プログラムが実際に値として取得しうる関数だけです — #'name、クォートされた 'name の指定子、lambda、そして(プログラムが intern や find-symbol などのシンボルビルダーを持つ場合には)その名前を綴る文字列またはキーワードの定数。それ以外は普通のデッドコードとなり、シェイカーが除去します。lambda が数えられるのは、シェイク後に残るコードがそれを作る場合だけです。誰も呼ばない関数の中でしか作られないクロージャは、その関数とともに除去されます。#'name が作るのは関数の値であって、名前の指定子ではありません。実行時にシンボルが関数を見つけるのは、プログラムがその名前を定数として綴っているか、パッケージの走査(do-symbols、apropos-list など)がそのシンボルを返す場合だけです。そのため、誰も呼ばない関数の中でしか値として取られない関数も、その関数とともに除去されます。この絞り込みが無効になり、すべての関数が到達可能なまま残るのは、今回のコンパイルが決して目にしないデータからプログラムが関数を名指しできる場合だけです。該当するのは eval、read、read-from-string、実行時の load、~/name/ という format ディレクティブのいずれかの使用(読み込んだライブラリの中にあるものも含む)で、--dynamic も同様です。-Drontolisp.debug.dispatchgate=true を付けてコンパイルすると、原因となった演算子をコンパイラが名指しします。
そこから 1 つの例外が生じます: 実行時に計算された断片から組み立てた指定子 — (funcall (intern (concatenate 'string "gre" suffix))) — はコンパイラが読める定数ではないため、プログラムがその関数を #'name の値として取っているかどうかに関わらず、その呼び出しは通常の「undefined function」エラーを通知します。戻る手段は --dynamic です。--optimize=off ではありません: この絞り込みはレベルが切り替える対象に含まれないため、最適化を断ってもそうした名前が戻ってくることはありません。
--optimize は省略可能なレベルを取り、これは WASM バックエンドと共通です。--optimize と --optimize=default はどちらも、フラグを書かなかったときに既に選ばれているもの — 上に述べたすべて — を綴ったもので、ビルドスクリプトで明示したい場合に使います。--optimize=off はそれを断り、このフラグがデフォルトで有効になる前のビルドが出力していたものを出力します。--optimize=size はバックエンドが出せる最小の出力を要求します。このバックエンドでこのレベルが断るのは、速度のためにバイト数を使う 3 つのコード生成です。1 つ目は型付き数値ループです。本体がパックド単精度/倍精度配列を fixnum の添字計算・let の一時変数・+ - * /・そうした配列の (length a)・単項の数学関数・if/when/unless の判定で読み書きする dotimes は、デフォルトではプリミティブな long/double のループとして、生の float[]/double[] アクセスにコンパイルされます。ループ入口の検査で、変数が型付けの仮定どおりでなければ通常のコード生成にフォールバックするので、値はどちらの経路でも同じで、数倍速く、本体が複数回出力されるぶんクラスは大きくなります。2 つ目は整数式木の融合です。ネストした + - * mod rem logand logior logxor lognot ash の式木は、デフォルトでは木全体を生の long 演算で計算して結果だけをボックス化する共有メソッドにコンパイルされ、実行時にマシンワードの整数でない値に備えて汎用の演算チェーンがフォールバックとして併置されます — こちらも値は同じで、出力やエラーが起きる順序もインタプリタと同じです。クラスは各木を 2 回持ちます。3 つ目は、関数値を通した末尾呼び出しが経由するディスパッチメソッドの複製(前述)で、こうした呼び出しが使う引数の数ごとに 1 つずつ作られます。--optimize=size は通常のコード生成だけを残します。これらの形をどれも持たないプログラムは両レベルで同じクラスにコンパイルされ、そもそも同じプログラムの JVM バイトコードは WASM の約 3 分の 1 のサイズです。したがって、1 つのビルドスクリプトがすべてのターゲットに --optimize=size を渡して構いません。
--optimize=off の用途は 2 つで、そのどちらもプログラムを動かすことではありません: コンパイラの変更前にビルドした成果物との比較と、シェイクしていないクラスの挙動が違うかどうかを問うことによるシェイカーのバグの二分探索です。コンパイラが読めない名前を通してしか到達できない関数を持つプログラムに必要なのは、上に述べたとおり --dynamic です。
レベルとは独立に、コンパイルは常にスプライスしたライブラリをツリーシェイキングします。対象は同梱の Lisp ソースライブラリ(linalg:、vec:、JSON、URL、equalp/string<)と、asdf:load-system / ql:quickload で読み込んだシステムです。プログラムがソース中でその名前に一切言及しない(クォートされたシンボルや文字列リテラルの中も含む)関数・変数・定数はコンパイル結果に含まれません。あなた自身のコードが刈られることはなく、load/require でスプライスされたファイルも刈られません。対象になるのはシステム由来のライブラリだけです。
クラス・総称関数・メソッド・コンディション・構造体も同じ規則で刈り込まれます。どこからも参照されないクラスはそのメソッドごと除かれ、プログラムが呼ぶ総称関数のメソッドであっても、特化先クラスのインスタンスを到達可能なコードが作れないものは除かれます。標準プロトコル名(initialize-instance、print-object、close など)のメソッドは呼び出しが暗黙なので、特化先クラスの生死だけに従います。
その帰結として、実行時に計算した文字列から名前を組み立てて eval/apply 経由で呼び出すライブラリ関数は、通常の「undefined function」エラーを通知します。その場合は --no-prune(または --dynamic)を付けてコンパイルすると、すべてのライブラリ定義が保持されます。
生成される .class ファイルは Java 17(クラスバージョン 61)をターゲットとするため、実行には Java 17 以降の JRE が必要です。java.lang と java.io のほか、出力されるランタイムヘルパーは java.math(オーバーフロー時に昇格する整数演算と厳密な有理数演算のための BigInteger/BigDecimal/MathContext)と java.util(ArrayList/Arrays、およびハッシュテーブル用の HashMap)を参照します。rontolisp:fetch を呼び出すプログラムは追加で java.net/java.net.http を参照し、rontolisp:await / rontolisp:futurep は future を java.util.concurrent のフューチャーとして表現しますが、これらはいずれも Java 17 に含まれるため、要件が上がることはありません。唯一の例外は java: 連携パッケージを使うプログラムです。その java: 呼び出しは、プログラムのテキストが許す限りコンパイル時に JDK のクラスファイルに対して解決されて直接呼び出しになり、クラスはそのリリース (既定ではコンパイルした JDK 自身のもの) 向けに刻印されるため、そのリリースの JRE が必要です。実行時解決に回る呼び出しは、コンパイラがクラスの隣に書き出す (プロジェクト自身の Java リリースでコンパイルされた) リフレクションブリッジを通るため、rontolisp をビルドした JRE と同等以上に新しい JRE が必要です。
--java-release N とプログラムのクラスパス (--java-classpath、--java-dep) がどのクラスファイルかを選び、--warn-java-reflection は実行時解決に回る呼び出しを報告し、--java-static はそれらをすべてコンパイルエラーにします。これでクラスはリフレクションを含まなくなり、GraalVM の native-image がリーチャビリティメタデータなしでビルドできます (ガイドの実行前の呼び出し解決)。プログラム jar はクラスパスを隣に置いてマニフェストで指し、war は WEB-INF/lib に持ち運びます (ガイドの Java ライブラリ)。
AOT キャッシュで JIT のウォームアップを飛ばす
コンパイル済みプログラムはバイトコードとして起動するため、JIT がどのメソッドが hot かを判断するまでの最初の数十ミリ秒は、JVM のインタプリタと第一段のコンパイラで実行されます。長時間動くプログラムなら、このコストは実行時間に埋もれます。短いプログラムでは、それが実行時間の大半になり得ます。bench-report の mandelbrot は同一プロセス内の 1 回目が約 95 ms、3 回目が約 22 ms で、新しいプロセスは毎回 95 ms からやり直します。
JDK 25 は、その 1 回目が学習した内容を永続化できます。jar にコンパイルし、トレーニング実行を 1 回行い、そこからキャッシュを作り、以降のすべての実行にそのキャッシュを渡します:
rontolisp mandelbrot.lisp -o app.jar
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar app.jar
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -cp app.jar
java -XX:AOTCache=app.aot -jar app.jar
64 コアの Linux マシン + GraalVM 25 では、これにより mandelbrot の 1 回目が 95 ms から 51 ms に、matmul が 94 ms から 57 ms になります(いずれも中央値)。得られるのは、すでに渡されたプロファイルを JIT が導出し直さずに済むことだけなので、効くのはウォームアップが短い実行の大部分を占めていた場合に限られます。bench-report の 10 本のプログラムのうち、10% を超えて動くのはこの 2 本だけです。
頼りにする前に知っておくべきことが 4 つあります。
-o app.jar が必要です。 ディレクトリを含むクラスパスからはキャッシュを構築できないため、java -cp . Prog で実行する素の -o Prog.class はトレーニングできません。jar の中身は同じコンパイル済みクラスにマニフェストが付いただけなので、これ以外のコストはかかりません。
トレーニング実行は本物の仕事をしなければなりません。 キャッシュが保持するのはコンパイル済みコードではなくプロファイルであり、プロファイルはトレーニング実行自身がウォームアップを終えて初めて存在します。mandelbrot を 4 分の 1 のグリッド(32 ms 分の仕事)でトレーニングしても、フルの実行には何も効きません。スモークテストではなく、代表的なワークロードでトレーニングしてください。
キャッシュは 1 つの jar ファイルに属します。 jar のパスとタイムスタンプを記録するため、プログラムを再ビルドすると無効になります。そうなっても壊れはしません。JVM は [error][aot] の行をいくつか標準エラーに出し、キャッシュを無視して、通常の速度でプログラムを正しく実行します。jar を再ビルドしたらキャッシュも作り直すか、フラグを外してください。キャッシュのサイズは 11 MB 程度です。
GraalVM では、一発で済ませる -XX:AOTCacheOutput ではなく上記の 2 段階のフローを使ってください。 この近道はキャッシュを子 JVM で組み立てますが、その子 JVM は GraalVM の jdk.internal.vm.ci モジュールを失っており、書き出されたキャッシュはロード時に拒否されます。エラーは標準エラーに出るだけなので、プログラム自体は動き、ただ効果がまったく得られません。
同じことは rontolisp 自身にも使え、起動時間がおよそ 3 分の 1 になります(何も計算しないプログラムに対する java -jar rontolisp.jar が 476 ms から 144 ms。コンパイル呼び出しでトレーニングしたキャッシュなら、代わりに -o out.class が 978 ms から 480 ms へと半減します)。GraalVM が手元にあるなら、同じ問題に対してはネイティブバイナリのほうが優れた答えです。キャッシュファイルもトレーニング手順もなしに、この 2 つをそれぞれ 12 ms と 109 ms でこなします。