ネイティブLuaコンパイルの試行錯誤
Trials and Tribulations of Native Lua Compilation (samyeyo.github.io)
要約
この記事では、LuaソースコードをネイティブC++コードにコンパイルするツール「clx」の開発について論じています。clxは、Lua VMに依存しない真にネイティブなLua体験を目指しており、Lua 5.5構文をサポートし、スタンドアロン実行可能ファイルやライブラリを生成できます。パフォーマンス向上やプログラムサイズの削減も目的としていますが、Luaの複雑なセマンティクス(テーブル、複数戻り値など)の処理が主な課題となっています。
全文翻訳
clx
ネイティブLuaコンパイルの試行錯誤
2026年10月・12分読書
私は長年Luaを扱ってきましたが、Luaの好きな点の一つは、その必要とするものが非常に少ないことです。小さく、エレガントで、ポータブルで、そして組み込みが驚くほど簡単です。最後の特性はおそらくLuaが成功した理由の一つでしょう:ゲーム、アプリケーション、ツール、あるいはほぼ何にでもLuaインタープリタを組み込むことができ、すぐに強力なスクリプト言語を得ることができます。
しかし、長年Luaを使用してきた後、私はその設計の別の側面に行き当たりました。
Luaアプリケーションをビルドして配布したいときは、常にLua VMが関わっていました。LuaRTとそのrtcコンパイラを使えば、少なくともWindows上では、デプロイメント問題の大部分をすでに解決していました。Luaアプリケーションをスタンドアロン実行可能ファイルとしてパッケージ化でき、ユーザーにLuaを別途インストールするように依頼する必要がなくなりました。
それはうまくいきましたが、別の疑問が残りました。インタープリタが実行可能ファイルに組み込まれていたとしても、アプリケーションは最終的にLua VMを介してLuaコードを実行していました。実行可能ファイルは外部からはネイティブアプリケーションのように見えましたが、その下では依然として解釈されているLuaプログラムでした。
私は一歩先へ進んだらどうなるか見てみたかったのです。
Lua VMに依存することなく、真にネイティブなLua体験を求めていました。lua55.dll、liblua55.so、または組み込みLua VMなしでアプリケーションを実行したかったのです。ランタイムを完全に排除しようとしていたわけではありません。コンパイルされたLuaプログラムは、テーブル、文字列、ガベージコレクション、コルーチン、その他のLuaセマンティクスのようなものにはネイティブサポートが必要です。私が求めていたのは、汎用Lua VMを、コンパイルされたネイティブLuaコードをサポートするために特別に設計されたランタイムに置き換えることでした。
もしプロセスから追加のパフォーマンスを得ることができれば、それは素晴らしいボーナスになるでしょう。
そのアイデアはやがてclxになりました。
VMのパッケージング vs プログラムのコンパイル
rtcとclxの重要な違いは、問題へのアプローチ方法です。
Lua VMを中心としたシステムは、アプリケーションを非常に便利にパッケージ化できます。Luaソースまたはバイトコードはインタープリタと一緒にバンドルされ、インタープリタがそれを実行します。デプロイメントの観点からは、それは完全に合理的です。
clxは異なるアプローチを取ります。Lua 5.5ソースコードを解析し、分析し、プログラムを表すC++コードを生成します。生成されたC++は、Clang、GCC、またはMSVCのような通常のネイティブコンパイラによってコンパイルされます。
結果として得られる実行可能ファイルは、Luaプログラムから生成されたネイティブコードと、プログラムが必要とするclxランタイムを含みます。その下にLuaバイトコードを実行するLuaインタープリタはありません。
その区別は重要です。clxにはまだランタイムがあります。それは単にLua VMではないだけです。
clxランタイムは、コンパイルされたプログラムがまだ必要とするLuaの部分のネイティブ実装を提供します:テーブル、文字列、メモリ管理、ガベージコレクション、コルーチン、標準ライブラリ、その他のランタイム機能。違いは、アプリケーション自体がネイティブコードにコンパイルされ、そのランタイムに直接呼び出すことです。
これは数段落で説明されるとかなり単純に聞こえます。それを構築することは全く別の問題です。
Luaは欺瞞的に小さな言語です。構文はコンパクトですが、セマンティクスはそうではありません。テーブル、複数の戻り値、クロージャ、コルーチン、メタテーブル、環境、数値変換、アップバリュー、イテレーション、エラー処理、そしてこれらの機能間のすべての相互作用は、Luaを正しくコンパイルすることを単に構文をC++に翻訳するよりもはるかに複雑にします。
さらに進むにつれて、ネイティブLuaコンパイラの興味深い部分は、if、while、または算術式のコードを生成することではないことに気づきました。難しい部分は、ターゲットがネイティブコードであるという事実を利用しながら、Luaのセマンティクスを維持することでした。
Luaソースからネイティブ実行可能ファイルへ
clxの基本的なワークフローは意図的にシンプルです。
次のようなLuaプログラムを与えると:
print("Hello clx!")
次のようにコンパイルできます:
clx hello.lua
ネイティブ実行可能ファイルを取得します。
特別なLuaコードの調整は必要ありません。Luaを記述すれば、clxがコンパイルプロセスを処理します。
コンパイラは、ネイティブコンパイラをすぐに呼び出す代わりに、C++ソースコードを生成することもできます:
clx hello.lua --cpp
これは、コンパイラ自体のデバッグに役立つだけでなく、生成されたコードを独自のビルドシステムに統合したい人々にも便利です。
clxはオブジェクトファイルや静的ライブラリも生成できます:
clx hello.lua --object
clx hello.lua --static
これは設計において重要な部分でした。clxを「Luaスクリプトが実行可能ファイルになる」というユースケースに限定したくありませんでした。ネイティブコンパイルは、コンパイルされたLuaコードを、より大きなネイティブアプリケーションのコンポーネントとして使用する可能性を開きます。
Luaモジュールは期待通りに動作し続けます
このプロセスで犠牲にしたくないことの一つは、Luaのモジュールシステムでした。
プロジェクトには複数のLuaファイルを含めることができます:
main.lua
greet.lua
utils.lua
そしてそれらを一緒にコンパイルできます:
clx main.lua greet.lua utils.lua --output app
次のような通常のLua呼び出し:
local greet = require("greet")
引き続き機能します。
違いは、モジュールが実行時に個別のLuaソースファイルから解釈されるのではなく、結果のネイティブプログラムにコンパイルできることです。
これはプログラム全体をコンパイルする nice な結果の一つです。require()呼び出しは、プログラム実行中に別のLuaファイルを見つけて解釈する必要があることを必ずしも意味しません。モジュールはすでにネイティブ実行可能ファイルの一部になっている可能性があります。
ネイティブモジュール
私がC++を出力言語として選択した理由の一つは、clxにネイティブライブラリへの自然なブリッジを提供することです。
ネイティブモジュールは、clx C++ APIを使用して記述できます。たとえば、Lua引数を受け取りLua値を返す単純な関数は次のようになります:
static clx::MultiValue add(clx::LState* L, const clx::LValue* args, size_t n) {
double sum = 0;
for (size_t i = 0; i < n; i++) {
sum += clx::check_number(L, args[i]);
}
return {clx::number(sum)};
}
モジュールは、Luaアプリケーションと一緒にコンパイルおよびリンクできます:
clx main.lua --modules fastmath
そしてLuaから通常通りロードします:
local fastmath = require("fastmath")
これは、ネイティブアプローチが特に有用になる領域の1つです。Luaを分離されたスクリプト環境として扱う代わりに、コンパイルされたLuaコードはC++コードと一緒に存在し、clx APIを介してネイティブライブラリに直接呼び出すことができます。
また、CまたはC++で書かれたライブラリを特別なLua固有の実装に翻訳する必要がないことも意味します。それらはネイティブライブラリのままで、Luaに機能を提供する薄いclxバインディングレイヤーを持っています。
コンパイルされたLuaライブラリ
同じアイデアは逆方向にも機能します。
Luaモジュールは静的ライブラリとしてコンパイルできます:
clx mylib.lua --static --output mylib
そしてclx APIを介してC++アプリケーションに公開されます。
その結果、Luaコードは、単にそのアプリケーションによってロードされるスクリプトであるのではなく、より大きなアプリケーションのネイティブコンポーネントになることができます。
これは、最初から求めていたことの一つでした:Luaは、プログラムの他のネイティブコンポーネントと同じレベルに置くことができるべきです。
しかし、時にはLua VMが必要になることもあります
clxを開発中に明らかになったもう一つの重要な区別があります。
通常の実行パスからVMを削除しても、Lua VMが決して有用ではないという意味ではありません。
一部のアプリケーションは、アプリケーションがコンパイルされたときには知られていなかったLuaコードを実行する必要が本当にあります。プラグイン、ユーザー スクリプト、設定コード、または意図的にスクリプト機能を提供するアプリケーションが良い例です。
そのため、clxには--dynamicモードもあります。
このモードが有効な場合、clxはLua 5.5 VMを組み込み、コンパイルされたプログラムとインタープリタの間の必要なブリッジを提供します。これにより、アプリケーションの残りの部分に対して通常のAOTコンパイルモデルを維持しながら、load()のようなランタイム機能が可能になります。
したがって、実際には2つの異なる実行モデルがあります。
通常のAOTコンパイルでは、Luaコードはネイティブコードになり、clxランタイムを使用します。--dynamicを使用すると、実際にLuaコードを動的にロードして実行する必要がある場合に、Lua VMをさらに利用できます。
私はこれが、一方のアプローチが「ランタイムを持っている」ともう一方が持たない」と言うよりも、はるかに有用な区別だと考えています。どちらにしてもランタイムは必要です。興味深い質問は、そのランタイムが何をしているかです。
パフォーマンスも動機の一部でした
実行パスからLua VMを排除することがclxの開発を開始した主な理由でしたが、パフォーマンスも別の動機でした。
ネイティブコンパイルは、インタープリタのLuaバイトコードよりもコンパイラにかなりの自由を与えます。clxはコンパイル中にプログラムを分析し、その後、基盤となるネイティブコンパイラによって最適化できるC++を生成できます。
これによって大幅な違いが生じるワークロードがありますが、clxがLuaJITや他のすべてのLua実装よりも普遍的に高速であるとは考えていません。パフォーマンス特性はプログラムに大きく依存し、コンパイラがどれだけ効果的にプログラムを最適化できるかにも依存します。
私にとって、より興味深い部分は、パフォーマンスの向上は、単に高速なLuaインタープリタを構築しようとするのではなく、プログラム自体をコンパイルすることから来るということです。
それは最適化問題を完全に変えます。
コンパイラは実行前にプログラムについて推論し、特定の操作を特化し、不要な作業を排除し、ネイティブコンパイラに通常のC++コードを最適化のために残すことができます。
改善の余地はまだたくさんありますが、この方向はすでにいくつかのワークロードで意味のある結果を生み出しています。
サイズも重要です
clxを開発中に私を驚かせたもう一つのことは、結果のプログラムがどれほど小さくなるかということでした。
些細なプログラムでも、ネイティブ最適化とデッドコード削除の後では驚くほど小さな実行可能ファイルを生成できます。特に不要なclxランタイムの部分が除外される場合です。
同じ原則がより大きなアプリケーションにも適用されます。ネイティブSokolモジュールを使用した完全なPongゲームは、必要なclxランタイムとネイティブ機能を含めて、わずか数百キロバイトのスタンドアロン実行可能ファイルにコンパイルできます。
デプロイメントサイズが重要なアプリケーションでは、実際に必要な機能のみをコンパイルできることは非常に役立ちます。
ここでも、重要な区別は、ランタイムが消えたわけではないということです。それはネイティブプログラムの一部になり、コンパイラは必要とされない機能を含まないようにすることができます。
難しいのはその間のすべてです
コンパイラ自体も、そこに至るまでに多くのイテレーションを経てきました。
テーブルはおそらく最大の課題の一つでした。Luaテーブルは同時に配列、ハッシュマップ、オブジェクト、モジュールコンテナ、環境、そして言語の基盤の多くです。ネイティブ表現は、すべてのテーブル操作を不必要に高価にすることなく、それらすべての役割を処理する必要があります。
複数の戻り値も別の例です。Luaの呼び出し規約は、従来のC++関数呼び出しとは根本的に異なり、return f()、異なる数の値を持つ代入、可変長引数、式の中にネストされた呼び出しなどを処理するには、専用の表現が必要です。
コルーチンは、Luaの実行モデルが通常のC++制御フローに自然にマッピングされないため、別のクラスの問題をもたらしました。clxは、Luaコルーチンのセマンティクスを維持しながら、必要なコンテキストスイッチを実装するために、最終的にプラットフォーム固有のメカニズムを使用する必要がありました。
ガベージコレクションは、C++に単純に委任できないもう一つの部分です。コンパイルされたLuaプログラムは依然としてLuaのメモリ管理セマンティクスを必要とするため、clxは独自のインクリメンタルマークアンドスイープガベージコレクタを持っています。
さらに、環境、メタテーブル、整数セマンティクス、文字列処理、クロージャ、エラー伝播、イテレーション、モジュールロード、そしてこれらすべての機能間の相互作用など、あまり明白でないケースがたくさんあります。
特定のサブシステムが完了したと思うたびに、別のLuaプログラムが私が考慮していなかったエッジケースを見つけました。
おそらく、ネイティブLuaコンパイラ開発の最も正確な説明はこれでしょう:個々の部分は管理可能ですが、それらすべてを正しく動作させることが実際の仕事です。
なぜC++なのか?
他のネイティブコード形式を単純にターゲットにする可能性を検討しましたが、C++は非常に実用的な選択肢であることがわかりました。
Clang、GCC、MSVCは、私が関心のあるプラットフォームに対して成熟した最適化バックエンドを提供しています。完全なマシンコードバックエンドを実装する代わりに最新のC++20を生成することで、clxはネイティブコンパイラにすでに費やされた数十年の作業から恩恵を受けることができます。
また、既存のネイティブソフトウェアとの統合も大幅に容易になります。
誰かがすでにC++ライブラリを持っている場合、それを薄いclxモジュールでラップすることは、完全に別の外部関数インターフェースとコード生成バックエンドを作成するよりもはるかに簡単です。
もちろん、トレードオフがあります。生成されたC++は大きくなる可能性があり、非常に大きな生成された翻訳単位はコンパイルにかなりの時間がかかることがあります。これは、コンパイラが成長するにつれて私がまだ取り組んでいる問題の1つです。
ネイティブコンパイルは複雑さをなくすものではありません。それはツールチェーンの別の部分にその一部を移動させます。
clxの現状
clxはまだ進化していますが、もはや単に少数のLua例をコンパイルする実験とは見なしていません。
Lua 5.5構文をサポートし、実際のマルチファイルプロジェクトをコンパイルでき、ネイティブモジュールを統合でき、スタンドアロン実行可能ファイルとライブラリを生成でき、必要に応じてオプションでLua VMを組み込むことができます。
まだ粗いエッジがあり、私がまだ実装または最適化していないものを明らかにするLuaプログラムも確かにあります。このようなコンパイラは、「完了」と宣言するのが難しいです。なぜなら、新しいプログラムは、注意に値する言語やランタイムのコーナーを発見する機会だからです。
しかし、基本的なアイデアは機能しています。
Luaプログラムを書き、clxでコンパイルし、スタンドアロンのLuaインストール、lua55.dll、またはliblua55.soを必要としないネイティブ実行可能ファイルで終わることができます。実行可能ファイルにはclxランタイムが含まれていますが、そのランタイムはアプリケーションを解釈するためではなく、Luaのランタイムセマンティクスをネイティブ実装するために存在します。
これが、プロジェクトを開始したときに私が求めていたことです。
clxを構築した理由
振り返ってみると、clxはLuaにもう1つのコンパイラが必要だと思ったから始まったわけではありません。
Luaを長年使用し、その周りでツールを構築してきた経験から、Luaを主に組み込みスクリプト言語としてではなく、ネイティブプログラミング言語として扱ったらどうなるかという好奇心が次第に高まったからです。
Luaは30年以上、他のプログラムの中に組み込むのに最適な言語の一つでした。私は、Luaをプログラムの中心に置くことがどのようなものかを見たかったのです。
それは予想以上に困難であることが判明しました。
インタープリタモデルに戻る、別のVM依存を受け入れる、あるいは「Luaはとにかく動的だから」と特定のコーナーケースを気にしないことを選択する方が簡単な瞬間がたくさんありました。
代わりに、コンパイラをもう少し先に進め続けました。
そして、それがまさに今日のclxです。そのプロセスの結果です。
これはすべてのLua実装を置き換える試みではなく、ネイティブコンパイルが常に正しい答えであるという主張でもありません。それは単に、言語がネイティブプログラムに直接コンパイルされ、専用のネイティブランタイムによってサポートされるときにLuaが何になるかを探求する試みです。
長年Lua VMをアプリケーションにパッケージ化してきた後、Luaプログラムをコンパイルして、結果の実行可能ファイルを誰かに手渡すときに、Luaインタープリタに依存するアプリケーションではなく、ネイティブプログラムを渡していることを知ることは、本当に満足のいくことです。