Spinel: Ruby AOTネイティブコンパイラ
(github.com/matz)- Rubyコードをスタンドアロンのネイティブバイナリに変換し、プログラム全体単位の型推論とCコード生成によって最新のCRuby
minirubyと比べて幾何平均で約11.6倍高速な実行を目指す - コンパイルパイプラインはPrismベースのパーサでRubyをASTテキストに変換した後、self-hostingバックエンドが型推論とCコード生成を行い、標準Cコンパイラでスタンドアロンバイナリを生成する
- コンパイラバックエンドはRubyで書かれたself-hosting構造を持ち、ブートストラップ過程を経て
gen2.c == gen3.cが成立し、自分自身を再コンパイルするループが閉じる - 文字列連結の平坦化、value-type promotion、loop-invariant length hoisting、static symbol interning、bigint自動昇格といったコンパイル時最適化を備え、内蔵regexpエンジンとbigint、単一ヘッダーのランタイムによって外部ランタイム依存を減らす
eval、メタプログラミング、Thread、一般的なエンコーディング処理はサポートしないが、Rubyなしで実行できる配布形態と計算集約型ワークロードでの大きな性能差により、Ruby AOTコンパイルの実用性が示されている
仕組み
- コンパイルパイプラインはRubyファイルをパースしてASTテキストファイルへシリアライズした後、型推論とCコード生成を経て標準Cコンパイラでネイティブバイナリを作る流れで構成される
spinel_parseはPrismとlibprismを使ってRubyをパースし、Cバイナリがない場合はCRubyとPrism gemを使うフォールバック経路を使用するspinel_codegenはself-hostedなネイティブバイナリとして動作し、ASTを受け取って型推論 + Cコード生成を行う- 最終段階では
cc -O2 -Ilib -lmでCソースとランタイムヘッダを一緒にコンパイルし、生成されるバイナリはstandaloneな形になる
Self-Hosting
- ブートストラップチェーンは
CRuby + spinel_parse.rbでASTを作り、CRuby + spinel_codegen.rbでgen1.cとbin1を作成し、その後生成されたバイナリで再びgen2.c、gen3.cを作る方式で閉じる gen2.c == gen3.cが成立し、bootstrap loop が閉じたことを示している- バックエンドである
spinel_codegen.rbは、Spinel自身でコンパイル可能なRubyサブセットで書かれている- classes、
def、attr_accessor if/case/whileeach/map/select、yieldbegin/rescue- String、Array、Hash演算とFile I/O
- classes、
- バックエンドにはmetaprogramming、
eval、requireを入れていない
性能とベンチマーク
- テストは74件通過、ベンチマークは55件通過の状態
- 28件のベンチマーク基準で、幾何平均は最新のCRuby
minirubyと比べて約11.6倍高速 - 比較対象はバンドルgemなしの最新CRuby
minirubyビルドであり、システムruby3.2.3よりも高速な基準と比較しても、計算集約型ワークロードで大きな優位性を持つ -
計算性能
lifeは20ms対1,733msで86.7倍高速ackermannは5ms対374msで74.8倍高速mandelbrotは25ms対1,453msで58.1倍高速fib再帰版は17ms対581msで34.2倍高速nqueensは10ms対304msで30.4倍高速taraiは16ms対461msで28.8倍高速takは22ms対532msで24.2倍高速matmulは13ms対313msで24.1倍高速sudokuは6ms対102msで17.0倍高速partial_sumsは93ms対1,498msで16.1倍高速fannkuchは2ms対19msで9.5倍高速sieveは39ms対332msで8.5倍高速fastaは3ms対21msで7.0倍高速
-
データ構造とGC
rbtreeは24ms対543msで22.6倍高速splay treeは14ms対195msで13.9倍高速huffmanは6ms対59msで9.8倍高速so_listsは76ms対410msで5.4倍高速binary_treesは11ms対40msで3.6倍高速linked_listは136ms対388msで2.9倍高速gcbenchは1,845ms対3,641msで2.0倍高速
-
実アプリケーション
json_parseは39ms対394msで10.1倍高速bigint_fibの1000桁計算は2ms対16msで8.0倍高速ao_renderは417ms対3,334msで8.0倍高速pidigitsは2ms対13msで6.5倍高速str_concatは2ms対13msで6.5倍高速template engineは152ms対936msで6.2倍高速csv_processは234ms対860msで3.7倍高速io_wordcountは33ms対97msで2.9倍高速
サポートするRuby機能
- Core 機能として、classes、inheritance、
super、includeミックスイン、attr_accessor、Struct.new、alias、module constants、組み込み型に対するopen classesをサポートする - Control Flow として、
if/elsif/else、unless、case/when、case/inパターンマッチ、while、until、loop、for..in、break、next、return、catch/throw、&.をサポートする - Blocks として、
yield、block_given?、&block、proc {}、Proc.new、-> x { }、method(:name)をサポートし、each、map、select、reduce、sort_by、times、upto、downtoなどのブロックメソッドも含む - Exceptions として、
begin/rescue/ensure/retry、raise、ユーザー定義例外クラスをサポートする - Types には Integer、Float、String、Array、Hash、Range、Time、StringIO、File、Regexp、Bigint、Fiber を含む
- 多相な値はtagged unionsとして処理する
- 自己参照データ構造のために nullable object types
T?を用意する
- Global Variables は
$nameを静的C変数としてコンパイルし、型不一致はコンパイル時に検出する - I/O は
puts、print、printf、p、gets、ARGV、ENV[]、File.read/write/open、system()、バッククォートをサポートする
文字列、正規表現、シンボル、Bigint、Fiber
- Strings は不変文字列と可変文字列の両方を扱い、
<<は自動的に可変文字列sp_Stringに昇格されて O(n) のインプレースappendを行う +、interpolation、tr、ljust/rjust/centerと標準メソッドは両方の文字列表現で動作するs[i] == "c"のような比較はchar配列へ直接アクセスするよう最適化され、割り当てなしで処理されるa + b + c + dのような連結はsp_str_concat4またはsp_str_concat_arr1回に平坦化され、N-1個少ない割り当てに変わる- ループ内の
str.split(sep)は同じsp_StrArrayを繰り返し再利用し、csv_processでは400万件の割り当て削減が発生する - Regexp は外部依存のない内蔵NFA regexp engineを使用する
=~、$1-$9、match?、gsub、sub、scan、splitをサポートする
- Bigint はmruby-bigintベースの任意精度整数を使用する
q = q * kのようなループ乗算パターンで自動昇格される- 静的ライブラリとしてリンクされ、実際に使用するときだけ含まれる
- Fiber は
ucontext_tベースの協調的並行性を提供するFiber.new、Fiber#resume、Fiber.yieldと値の受け渡しをサポートする- 自由変数はheap-promoted cellsとしてキャプチャされる
- Symbols は文字列と分離された
sp_sym型として実装される:a != "a"を維持する- シンボルリテラルはコンパイル時にinternされ、
SPS_name定数になる String#to_symは必要なときだけ動的プールを使う- シンボルキーのハッシュは
sp_SymIntHashを使い、文字列ではなく整数キーを直接保存するため、strcmpと動的文字列割り当てがなくなる
メモリ管理と値型
- メモリ管理にはmark-and-sweep GCを使用し、size-segregated free lists、non-recursive marking、sticky mark bitsを含む
- 小さく単純なクラスは自動的にvalue typesへ昇格し、スタック上に配置される
- 条件はスカラーfieldが8個以下
- 継承なし
- パラメータ経由の変更なし
- fieldが5個のクラスを100万回割り当てるケースでは、85msから2msへ短縮される
- 値型のみを使うプログラムでは、GCランタイム自体がまったく出力されない
最適化
- プログラム全体単位の型推論に基づいて、さまざまなコンパイル時最適化を行う
- Value-type promotion により、小さく不変なクラスはC structのスタックオブジェクトとなり、GCオーバーヘッドをなくす
- Constant propagation により、
N = 100のような単純なリテラル定数はcst_N参照を介さず利用箇所へ直接インラインされる - Loop-invariant length hoisting により、
while i < arr.lengthはループ前に長さを1回だけ計算する- 本文で
arr.pushのようにレシーバオブジェクトを変更すると、このhoistは無効化される
- 本文で
- Method inlining は短く非再帰な3文以下のメソッドに
static inlineを付けてgccのインライン化を促す - String concat chain flattening は連結チェーンを単一呼び出しに減らし、中間文字列生成をなくす
- Bigint auto-promotion は自己参照加算や反復乗算パターンをbigintに自動昇格する
- Bigint
to_sはmruby-bigintのmpz_get_strを使い、divide-and-conquerの**O(n log²n)**で処理する - Static symbol interning は
"literal".to_symをコンパイル時のSPS_<name>定数に変換し、動的interningを使う場合にのみランタイムプールを含める sub_rangeで長さがhoistされた文字列はsp_str_sub_range_lenを使い、内部のstrlen呼び出しを省く- ループ内部の
line.split(",")は既存のsp_StrArrayを再利用する - Dead-code elimination は
-ffunction-sections -fdata-sectionsと--gc-sectionsを使い、未使用のランタイム関数を最終バイナリから削除する - Iterative inference early exit は、param、return、ivarの3つのシグネチャ配列がそれ以上変化しなくなった時点で固定点ループを即座に停止する
- ほとんどのプログラムは4回の全反復ではなく1〜2回で収束する
- bootstrap時間は約**14%**短縮される
parse_id_listbyte walk はself-compile中に約12万回呼ばれるASTフィールドリストパーサを、s.split(",")の代わりにs.bytes[i]の手動走査へ置き換え、呼び出しごとの割り当てをN+1個から2個へ減らす- 生成されるCコードは標準的な警告レベルでwarning-free buildを維持し、ハーネスでは
-Werrorを使って回帰を即座に表面化させる
アーキテクチャ
- リポジトリ構成は次の要素に分かれる
spinel: POSIX shellベースのワンコマンドラッパースクリプトspinel_parse.c: libprismからテキストASTへ変換するCフロントエンド 1,061行spinel_codegen.rb: ASTからCコードへ変換するコンパイラバックエンド 21,109行lib/sp_runtime.h: ランタイムライブラリヘッダ 581行lib/sp_bigint.c: 任意精度整数 5,394行lib/regexp/: 内蔵regexpエンジン 1,759行test/: 機能テスト 74件benchmark/: ベンチマーク 55件Makefile: ビルド自動化
- ランタイム
lib/sp_runtime.hはGC、array/hash/string実装とその他のランタイム支援を1つのヘッダファイルに収めている - 生成されたCコードはこのヘッダをincludeし、リンカは
libspinel_rt.aから必要な部分だけを取り込む- bigint
- regexp engine
- パーサには2つの実装がある
spinel_parse.cはlibprismを直接リンクし、CRubyなしで動作するspinel_parse.rbはPrism gemを使うCRubyフォールバックである
- 両方のパーサは同じAST出力を生成し、
spinelラッパーは可能な限りCバイナリを優先して使う require_relativeはパース時点で解決され、参照されたファイルがインライン展開される
制約事項
- No eval:
eval、instance_eval、class_evalはサポートしない - No metaprogramming:
send、method_missing、動的define_methodはサポートしない - No threads:
Thread、Mutexはサポートせず、Fiberのみをサポートする - No encoding: UTF-8とASCIIを前提とする
- No general lambda calculus: 深くネストした
-> x { }と[]呼び出しは扱わない
依存関係と実行モデル
- ビルド時の依存関係は libprism Cライブラリと、初期ブートストラップ用のCRuby
- ランタイム依存はなく、生成されたバイナリはlibc + libmのみを必要とする
- 正規表現は内蔵エンジンを使うため外部ライブラリは不要
- Bigintは内蔵されているが、実際に使うときだけリンクされる
- Prism は
spinel_parseが使用するRubyパーサmake depsはrubygems.orgのprism gem tarballをダウンロードし、Cソースをvendor/prismに展開する- prism gemがすでにインストールされていれば自動検出する
PRISM_DIR=/path/to/prismでユーザー指定のパスを使うこともできる
- CRubyは初期bootstrapにのみ必要で、
make後はパイプライン全体がRubyなしで実行される
プロジェクト履歴
- Spinelは当初Cで実装され、規模は18K linesで、
c-versionブランチに残っている - その後、Rubyで書き直した
ruby-v1ブランチを経た - 現在の
masterは、self-hosting可能なRubyサブセットで再実装されたバージョン
ライセンス
- MIT Licenseを使用
- LICENSE ファイルに従う
1件のコメント
Hacker Newsのコメント
Matzが作ったものなら、Ruby semanticsの限界もよく分かっているはずなので信頼できる
私の修士論文も AOT JS compiler だったが、動きはしたものの入力データの制約が大きく、結局やめた
当時のJS開発者は自発的に制約をきちんと守ることにあまり慣れておらず、
JSON.parseのように本質的に分からない入力が障害になった今ならTypeScriptのおかげで、当時よりずっと現実的かもしれない
一般的な lambda calculus を見ても型推論の限界は明らかで、Matt Might周辺の論文やShed-skin Pythonの取り組みでも似たような制約が見えてくる
eval,send,method_missing,define_methodが実際のRubyコードでどれほど一般的なのか気になるし、型なしのパース、たとえばJSON入力を普通どう扱うのかも気になるRubyのパースは変換そのものより難しいくらいなので Prism を使い、結果としてCを生成する
基本的なRuby semantics自体の実装は、そこまで難しくない
一方で私は純Ruby製の古い self-hosting AOT compiler を抱えていて、独自パーサーにこだわったせいでわざわざずっと険しい道を進んでしまった
初期の80%はざっくり作ってもかなりの量のRubyコードが動くことを早い段階で学んだが、本当に難しい「2つ目の80%」は、Matzが今回のプロジェクトやmrubyで外した部分、たとえばエンコーディングやありとあらゆる周辺機能に集中している
正直、Rubyには実際のコードで一度も見たことのない機能もかなりあるので、いくつかはdeprecatedになっても不思議ではないと思う
send,method_missing,define_methodはとてもよく使われる制約はmrubyに近く、その制約下でも使い道はある
send,method_missing,define_methodのサポートは比較的容易だ反対に eval() のサポートはとてつもなくつらい
ただしRubyにおける
eval()のかなりの割合は、静的に instance_evalのblock版 に還元できるので、その場合はAOTコンパイルがかなりやりやすくなるたとえば
eval()に入る文字列を静的に把握できる、あるいは分解できるなら、解決の余地は大きい実際には多くの
eval()利用は不要だったり、単純なintrospection回避に近かったりするので、静的検査で処理できる私のコンパイラでも、そこがボトルネックになったらまずそこから手を付けるつもりだ
型なしのJSON ingestionも、おそらくそうした仕組みを使っている可能性が高い
それを取り除けば、Crystalほど強い型付けではないが、公式Rubyほどメタプログラミングに依存もしない、小さくて読みやすい言語が残る
なので潜在力はかなり大きそうだが、結局は時間が経たないと判断できない
evalをよく使う側だ使わずに済ませることもできるが、私にとってはそのほうがより ergonomic だ
eval,exec,define_method、そしてClass.new,Struct.newで新しいクラスを作るパターンだこれらの利用の大半はアプリの boot時点 やファイルのrequire中に集中していて、ある意味ではすでにコンパイル段階に近い
これは RubyKaigi 2026 でMatzがたった今発表したものだ
実験的ではあるが、Claudeの助けを借りて約1か月で作られ、ライブデモも成功した
名前はMatzの新しい猫に由来し、その猫の名前はCard Captor Sakuraの猫の名前から来ていて、そこからさらにRubyという名前のキャラクターと対になる
Matzのような人にとっては、100xを500xまで押し上げることになるのかもしれない
https://en.wikipedia.org/wiki/Spinel
動画はまだライブではないようで、こちらのチャンネルに1本ずつ上がっているようだ
https://www.youtube.com/@rubykaigi4884/videos
プロジェクト名も感情的に付けられたように感じる
間違いなく非常に印象的だが、AI agent なしでは保守不可能に見える
spinel_codegen.rbは2万1千行あり、あるメソッドはネストが15段階に達しているもともとコンパイラのコードはきれいになりにくいが、それを差し引いてもこれは人間が管理するにはかなり厳しそうだ
コンパイラはサブシステムの境界が明確で、段階ごとのhandoffもはっきりしているので、むしろ最もmodularに作りやすい部類だ
問題はたいてい、まず動かすことを優先してリファクタリングする時間がなく、その結果として汚さが膨らみ続けることにある
spinel_codegen.rbはほとんど eldritch horror レベルだClaudeを使うと私もいつもこういうスパゲティコードになってしまうので、自分が何か間違っているのかと思っていた
でも、最高レベルのプログラマだと思っている人が作った本当に面白いプロジェクトでも、コード品質がところどころかなり悪いのを見て、自分だけではなかったのだと分かった
たとえば
infer_comparison_type()は最悪の例というほどではなく、読みにくくもないが、もっと単純で明快な実装があるのにClaudeはそこへ行けないSetに比較演算子をまとめてinclude?で処理すれば、もっと短く、速く、読みやすく、保守もしやすいなのにClaudeはいつも if-returnの連鎖 に流れ、if-elseすら苦手な感じがある
私のClaudeコードベースもそういうパターンだらけだが、もう自分だけではないと分かった
一方で他のファイルはかなり良く、とくに
libディレクトリはメインのRubyリポジトリのextディレクトリに対応しているようで、品質も悪くないAPIもMRI Rubyの影響を明確に受けており、実装はかなり違っていても、Matzが元のAPIに似せるよう誘導したことで出力がより整っているように見える
[1] https://github.com/matz/spinel/blob/98d1179670e4d6486bbd1547...
テストとベンチマークさえ通れば、ひとまず満足だ
ただ、巨大なファイルがAIにとっても扱いやすいのかは疑問だ
私はファイルを300行以内に抑えるようにしていて、人が理解しやすいコードは coding agents にとっても扱いやすいはずだと思っている
制約はこうらしい
No eval:
eval,instance_eval,class_evalNo metaprogramming:
send,method_missing,define_method(動的)No threads:
Thread,Mutex(Fiberはサポート)No encoding: UTF-8/ASCII前提
No general lambda calculus:
[]呼び出しを伴う深いネストの-> x { }UTF-8/ASCII前提は個人的には大きな制約ではないが、残りはかなり多くのプログラムで実際に制約になりそうだ
そして、これを再び入れるにはかなりの作業が必要に見える
Rubyを長く使ってきて、列挙された機能を全部使ったことのある立場からすると、むしろ進化の果てに自分が欲しくなったのは、こういう シンプルなRuby だ
よりシンプルで理解しやすいのに、Ruby特有の美意識は残っている
今はLLMのおかげでコード生成の生産性があまりに高く、昔のように開発者の生産性のためにメタプログラミングでboilerplateを減らす必要が薄れてきている
開発者が自分でコードを書く比重そのものが下がっているからだ
文法は似ていて静的型システムがあり、より効率的なコンパイル済みコードにつながる
evalがないのはむしろ良いと思うが、threadsとmutexes までないのは惜しいdefine_methodがないのは用途を考えれば理解できるただ
sendとmethod_missingは既存ライブラリでよく使われるし、実装もメモリ上のlookup tableをコンパイル時に構築するような形で、そこまで難しくなさそうに思えるなので意図的に外したのか、まだそこまで到達していないのか分からない
願わくは後者だが、少なくとも当面は互換性の面で実務投入は難しそうだ
読むべきコードを減らすこと だった
これは本当にすばらしく、私は長いこと Ruby向けAOT compiler を待っていた
ただ
evalやメタプログラミングのfallbackがないのは残念で、それでも小さく高性能なsubsetに集中するためにそうしたのだろうこのAOTコンパイラで作ったgemがMRIとうまく相互運用できるといいのだが
標準Rubyやgemをパッケージ化・バンドルする方向では、今でもtebako、kompo、ocranが必要で、以前にはruby-packer、traveling ruby、jruby warblerのようなプロジェクトもあった
選択肢がひとつ増えるのは良いことだが、より良い開発者UXを備えた 決定版 が出てきてほしいと思っている
あまりにも長い間更新されていなかったからだ
なぜ no threads なのか気になる
Ruby schedulerと下層のpthread実装はC側でもうまく動きそうだし、もしかしてzero dependencyを狙ったのだろうかと思う
optional extensionとして後から入れる予定なのか、それとも単にまだ外してあるだけでないなら、この選択は少し奇妙に感じる
たぶん、まだそこまで到達していないだけではないだろうか
マルチスレッドはもともときちんと作るのが非常に難しい
1か月少々で作ったというのは驚きだ
AIについて何と言おうと、腕のある開発者 の手に渡ればとてつもない速度向上を生み出す
Matzはただ
gem env|infoとfindだけで十分そうに見えるこれがMatzの作ったものだとなると、今後 Ruby core の一部になる可能性がどれくらい現実的なのか気になる
そして、そうなった場合Crystalにとってどれほど脅威になるのかも気になる
こうした特性は大きなプログラムをコンパイルし、保守するうえで事実上不可欠に近い
一方こちらは制限されたRuby subset向けなので、人気のあるRuby gemの多くはそのままでは動かないだろう
Cコンパイルを志向する言語subsetという点では PreScheme により近く見える
現時点では、両者が同じ領域で直接競合しているとは思わない
完全なRubyにはほぼ確実にJITが必要だ
[1]: https://prescheme.org/
Rational Unified ProcessやEnterprise Architectの復讐が始まるようなものだ
違いがあるとすれば、UMLダイアグラムの代わりにmarkdownファイルが来るくらいだ
これは infrastructure tools の分野で役立ちそうだ
たとえばRubyで書かれていながら静的コンパイルされるbundlerがあって、RVMのようなRubyインストールツールの役割まで兼ねる、と想像できる
既存のRuby buildpackはRubyで書かれているが、ブートストラップをbashでやらないといけないので面倒だし、edge caseも生まれる
CNBはその問題を避けるためにRustで書かれており、依存関係のない単一バイナリ を配布できるという発想は本当に強力だ