ダイナミックSpinel(1) eval編
Fluxpinel(フラクスピネル)というツールを作っています。1
Spinel2でコンパイルしたプログラムに、動的なレイヤを差し込むツールです。
3回に分けて書きます。
- eval編(この記事): Spinelが拒否する「実行時の文字列の
eval」を、Spinelのバイナリの中で動かす。あわせて、Fluxpinelの仕組みを紹介します - DSLプラグイン編: コンパイル済みのCLIが、実行時にRubyのプラグインを読み込みます
- Railsコンソール編: RoundhouseとSpinelでコンパイルした動いているRailsアプリに、ソケットでつなぎます
Spinelのプログラムは閉じている
Spinelは、まつもとさんが作っているRubyのAOTコンパイラです。Rubyのプログラム全体を型推論して、Cに変換して、ネイティブのバイナリにします。速いです。
そのかわり、できあがったバイナリは閉じています:
- 実行時に新しいコードを読み込めない。実行時の文字列を渡す
evalや、loadは、コンパイル時に拒否される method_missingは定義できるが、呼ばれない。未定義のメソッドを呼ぶコードもコンパイルは通り、実行時にNoMethodErrorになるdefine_methodは、名前がリテラルのときだけ使える。実行時に組み立てた名前で定義しようとすると、コンパイル時に拒否される
たとえば、引数で受け取ったRubyの式を評価するだけのプログラムも、Spinelではコンパイルできません。
# plain.rb
ARGV.each do |src|
puts "#{src} => #{eval(src).inspect}"
end
$ spinel plain.rb -o plain
spinel: plain.rb:2: unsupported eval of a runtime string is not supported by AOT compilation (define the code statically): ...
組み立てた名前でのsendや、ブロック付きのinstance_evalは、すでにあるメソッドを呼ぶ限りSpinelでも動きます。動かないのは、実行時に「メソッドを作る」「足りないメソッドを受け止める」「ビルドに含まれないRubyスクリプトを読み込んで動かす」ところです。
プログラム全体を見通せるから速くできる、というのがAOTコンパイラの前提なので、これは当然のトレードオフです。でもRubyistとしては、Rubyのいちばん楽しいところを置いてきた感じがします。
ならば、閉じたバイナリの中に、もういちど開いた場所を作ればいいじゃん?
Flux Spinel
Fluxpinelは要するに、RubyのYet anotherインタプリタです。Spinelでコンパイルしたプログラムと同じバイナリの中にそのインタプリタが組み込まれます。
fluxには「流れ」「変化」という意味があります。コンパイルされて固まったSpinelに、流れを足して、動くスピネルにするという名前です。
以下では、Fluxpinelが実行するRubyのコードを「スクリプト」と呼びます。実行時に読み込むファイルや、Fluxpinel.evalに渡す文字列のことです。Spinelがコンパイルしたコード(「コンパイル済みのコード」と呼びます)と区別するためです。
できることは、ざっくり次のとおりです。
- スクリプトとプラグイン: バイナリが実行時にRubyのファイルを読み込む。プラグインを書き換えても、バイナリの再コンパイルは不要
- 文字列の評価: 実行時に組み立てたRubyの文字列を評価できる。コンパイル済みのコードは
Fluxpinel.evalを使う(トップレベルで評価する)。スクリプトの中はふつうのevalを使う(呼び出した場所のローカル変数が見える)。bindingを渡すevalは、まだできない - 双方向の呼び出し: コンパイル済みのコードがスクリプトを呼び、スクリプトがコンパイル済みのコードを呼ぶ
- ひとつのヒープ: スクリプトはプログラムの生きているオブジェクトをそのまま使う。値はコピーも変換もされずに境界を越える
- 同じ意味: 両側がひとつのランタイムを使う。組み込みクラスのメソッドは、多くの場合どちらが呼んでも同じ結果になる
evalしてみる
さきほどのplain.rbと同じことを、Fluxpinelを組み込んだバイナリでやってみます。Fluxpinelのリポジトリのexamples/calcにあります。Spinelがコンパイルするのは、次のファイルです。
# examples/calc/app.rb: Spinelがコンパイルする
require_relative "../../kernel/fluxpinel"
module Calc
#: (Array[String]) -> nil
def self.main(args)
args.each do |src|
puts "#{src} => #{Fluxpinel.eval(src).inspect}"
end
nil
end
end
evalのかわりにFluxpinel.evalを呼んでいます。これはSpinelから見ると、ただのC関数の呼び出しだから、コンパイルを拒否されません(仕組みは後述)。FluxpinelのMakefileがapp.rbとFluxpinelをひとつのバイナリにします。
$ make host HOST=build/calc HOST_NAME=calc KERNEL=examples/calc/app.rb KERNEL_MAIN=Calc.main KERNEL_ENTRIES=Calc.main
$ build/calc/calc '1 + 2' '(1..10).sum' '%w[b c a].sort' 'def sq(x) = x * x; sq(12)' 'sq(5)' '[1, 2, 3].map { |v| sq(v) }'
1 + 2 => 3
(1..10).sum => 55
%w[b c a].sort => ["a", "b", "c"]
def sq(x) = x * x; sq(12) => 144
sq(5) => 25
[1, 2, 3].map { |v| sq(v) } => [1, 4, 9]
コンパイルしたあとに渡した文字列が、そのまま評価されています。4つ目の文字列でdefしたメソッドsqを、そのあとの文字列から呼べています。
calcに渡した文字列は、それ自体がスクリプトです。その中でふつうのevalも使えます。こちらはCRubyと同じく、呼び出した場所のローカル変数が見えます。
$ build/calc/calc 'x = 10; eval("x + 1")' 'x = 10; eval("x = 20"); x' 'eval("y = 5; y * 2")'
x = 10; eval("x + 1") => 11
x = 10; eval("x = 20"); x => 20
eval("y = 5; y * 2") => 10
evalの中から外のxを読み、書き換えています。逆に、evalの中で作ったyは、外からは見えません。これもCRubyと同じ。
アーキテクチャ
FluxpinelはPrism3のASTを直接たどるインタプリタ(tree-walking interpreter)です。バイトコードにはしません。
ポイントは、Fluxpinelが自前のオブジェクトモデルを持たないこと。値の表現、ヒープ、GC、例外、組み込みメソッドの多くは、Spinelのランタイムのものを使います。
- 値: Spinelのランタイムは、型のわからない値を
sp_RbValというタグ付きの値で表す。Fluxpinelの値はすべてこれ。だからコンパイル済みのコードとFluxpinelの間で、値をそのまま渡せる - オブジェクトの形: Fluxpinelが定義したクラスのインスタンスも、Spinelのオブジェクトと同じく先頭にクラスIDを持つ。ランタイムはFluxpinelのオブジェクトのクラスを、コンパイル済みのオブジェクトと同じ方法で読む
- ひとつの翻訳単位: Spinelのランタイムは、例外のハンドラのスタックなど多くの状態を翻訳単位(TU)ごとの
staticで持っている。だから、Spinelが出力したプログラム(カーネルと呼んでいる)とFluxpinelも、ひとつのTUとしてコンパイルする。こうすると、カーネルが投げた例外をスクリプトのrescueで捕まえられるし、その逆もできる - GC: GCはひとつ。Fluxpinelはフレームのローカル変数をGCのルートとして登録する。スクリプトが持っているオブジェクトも、カーネルが持っているオブジェクトも、同じGCが面倒を見る
組み込みメソッドは、なるべくSpinelのランタイムの関数を呼びます。たとえば"abc".upcaseは、コンパイル済みのコードと同じ関数を呼び、結果も一致します。ただ、Spinelの組み込みメソッドには、ランタイムに関数がなく、コンパイラが呼び出しの場所に専用のCを書き出すもの(Array#any?など)もあります。そういうメソッドはFluxpinelにも実装していて、ずれる可能性があります。
evalの中身
Fluxpinel.evalが受け取った文字列は、その場でPrismがパースし、トップレベルの新しいフレームが評価します。パースしたASTは捨てません。defしたメソッドの本体として、あとで使い続けるからです。さきほどのsqが次の文字列から呼べたのは、このためです。
スクリプトの中のevalは、もうひと手間かかります。CRubyのevalは、呼び出した場所のローカル変数が見えるからです。
- Fluxpinelは、
evalを呼んだ場所のフレームと、その外側のフレームが持つローカル変数の名前を集める - その名前を、Prismに「コードの外側のスコープ」として渡す。するとPrismは、文字列の中の
xを、メソッドの呼び出しではなく、外側のローカル変数としてパースする - 新しいフレームの親を、
evalを呼んだ場所のフレームにして評価する。ブロックが外側の変数を読み書きするのと同じ仕組みで、xを読み書きできる。evalの中で新しく作ったyは、新しいフレームに残る
bindingを渡すevalは、いまのところできません。FluxpinelにBindingのオブジェクトがないからです。
双方向に呼べる
コンパイル済みのコードからFluxpinelへは、3つの関数を経由します。カーネルはkernel/fluxpinel.rbをrequire_relativeで読み込みます。
module Fluxpinel
native_func :load, [:string], :any, "fx_api_load"
native_func :eval, [:string], :any, "fx_api_eval"
native_func :call, [:any, :string, :any], :any, "fx_api_call"
end
native_funcはSpinelの機能です。モジュールのメソッドをCの関数に結びつけます。:anyの引数と戻り値はsp_RbValのまま渡ります。同じTUにあるFluxpinelのC関数を、Spinelに手を入れずに呼べるわけです。文書化されたAPIではないので、そのうち壊れるかもしれません。
ほんで、
Fluxpinel.load(path)はRubyのファイルを実行して、最後の式の値を返すFluxpinel.eval(src)は文字列について同じことをするFluxpinel.call(obj, name, args)はFluxpinelの値のメソッドを呼ぶ
逆向き、スクリプトからコンパイル済みのコードへは、Spinelの--ext-entryでエクスポートしたメソッド(エントリ)を呼びます。スクリプトからは、ふつうのモジュールメソッドに見えます。
まだできないこと
Spinelにパッチが必要なこと
- 動的ディスパッチ: コンパイル済みのコードは、Fluxpinelのクラスを知らない。Fluxpinelのオブジェクトのメソッドは、
Fluxpinel.callを経由して呼ぶ。コンパイル済みのクラスのmethod_missingも呼ばれない。Spinelが「解決できない呼び出しをFluxpinelに渡すコード」を出すようになれば、どちらもできる - コンパイル済みのオブジェクトのインスタンスメソッドを、スクリプトから呼ぶ: いまSpinelがエクスポートできるのは、モジュールのメソッドだけ。インスタンスメソッドもエクスポートできるようになれば、スクリプトから
counter.bump(3)のように呼べる - コンパイル済みのメソッドのローカル変数を見る
eval: コンパイル済みのメソッドのローカル変数は、Cの変数。Spinelがそれを見せる仕組みを出せば、binding付きのevalができる - アプリのメソッドを再定義する: スクリプトで
Calc.mainを定義し直しても、コンパイル済みのコードは元のCの関数を直接呼ぶ。Spinelが呼び出しを間接にすればできるが、そのぶん速さを手放すことになるから、これはできないままでいいかもしれない
Fluxpinelの開発だけでできそうなこと
- 速くする: Fluxpinelのインタプリタ部分は、CRubyよりずっと遅い。呼び出しのたびに名前を文字列で比べ、メソッドを探し直しているから。メソッドのキャッシュなどを入れれば、ASTを歩くままでも、CRuby(非YJIT/ZJIT)のインタプリタに近い速度を目指せるかも。とはいえ、行のループのようなホットパスは、コンパイル済みのコードに置くのが正解(詳細はつぎの記事にて)
- まだ動かせないRuby:
Bindingと、オブジェクトの特異クラス(def obj.fooやobj.extend(M))。どちらも、フレームやオブジェクトの形といった、Fluxpinelの設計の根っこに関わる。手間取りそう
たぶん一生できないこと
- 組み込みメソッドを再定義する: スクリプトが
Integer#+を再定義しても、コンパイル済みのコードの足し算は変わらない。Spinelは型を決めて、足し算をCの+にしているので、そもそも差し替える呼び出しがない。ここを変えられたら、AOTコンパイルする意味がなくなる - Refinements(世界のshugomaedaが取り組めばワンチャン?)
つづき
(2) DSLプラグイン編では、アクセスログを解析するCLIを作ります。重いループはSpinelでコンパイルし、何を調べるかはユーザがプラグインにRubyのDSLを書き、CLIが実行時にそれを読み込みます。