← 記事

Fluxpinel:SpinelでコンパイルしたRubyにプラグインとevalを

WebAssemblyで動くRubyの記事で、私はSpinelを試しました。Matzの新しいコンパイラで、RubyのプログラムをCのコードにし、そこから速いネイティブの実行ファイルを作ります。その代わり、コンパイルの時点でプログラム全体がわかっている必要があります。eval も method_missing もなく、プログラムの実行中に新しいコードを読み込むこともできません。

PicoRubyの作者であるHASUMI HitoshiさんのFluxpinelは、まさにこの制限に取り組んでいます。キャッチフレーズは「Make Spinel Dynamic」です。Fluxpinelは、コンパイルされたプログラムと同じバイナリの中にあり、同じランタイムを使うRubyインタプリタです。コンパイルされた部分は速いままで、変わるべき部分、たとえばプラグイン、設定、クエリ、コンソールは、バイナリが実行時に読み込む普通のRubyファイルになります。

プロジェクトはできたばかりです。最初のコミットは2026年10月19日で、すでに200以上のコミットがあります。ビルドして試してみました。

仕組み

Spinelは、Fluxpinelがカーネルと呼ぶ自分のプログラムをCにコンパイルします。ビルドでそのCのコードとFluxpinelがひとつのバイナリにまとめられます。両者はひとつのヒープ、ひとつのガベージコレクタ、Spinelランタイムの組み込みメソッドを共有します。そのため、オブジェクトはコピーも変換もなしにそのままコンパイル済みのコードとスクリプトの間を行き来し、一方の rescue がもう一方の raise を捕まえます。

カーネルは小さなモジュールを通してインタプリタとやりとりします。

逆方向では、ビルド時に指定したカーネルのモジュールメソッドをスクリプトから呼び出せます。

Fluxpinelは ruby コマンドのように単独でも動き、-e、-I、-r、コンソールが使えます。IRBを起動する --irb オプションもありますが、私の環境ではインストール済みのirbとprismのgemで読み込みエラーになり、試せませんでした。そこでの RUBY_ENGINE は "spinel" です。

ビルド

必要なのはgit、Cコンパイラ、libffi、Rubyです。make deps はFluxpinelが固定しているコミットのSpinelを取得してビルドし、make はインタプリタをビルドします。

git clone https://github.com/hasumikin/fluxpinel
cd fluxpinel
make deps
make
build/fluxpinel -v

Ubuntu、GCC 15、Ruby 4.0.9の私のノートパソコンでは、問題なく動きました。Spinelは約10分、Fluxpinelは約40秒かかりました。結果はこうです。

$ build/fluxpinel -v
fluxpinel 1 (spinel 5cce6eb03, ruby 4.0.9) [x86_64-linux]
$ build/fluxpinel -e 'p RUBY_ENGINE'
"spinel"

標準のビルドでは、Spinelのデフォルトと同じく、Integerがオーバーフローすると RangeError になります。CRubyのようなBignumが必要なら、二つ目のビルド make BUILD=build/promote INT_OVERFLOW=PROMOTE build/promote/fluxpinel があり、2**100 も動きます。

私のテスト:プラグインとしての請求書のルール

これまでの記事と同じく、スイスの小さな請求書を使いました。コンパイルされた部分は品目を読み込み、小計と8.1%の付加価値税を計算し、合計を5ラッペン単位に丸めます。割引のルールはコンパイルしません。ルールはそれぞれ rules/ フォルダにあるRubyファイルです。

# app.rb: Spinel compiles this file
require_relative "fluxpinel/kernel/fluxpinel"

module Invoice
  VAT = 0.081

  # round to 5 Rappen
  #: (Float) -> Float
  def self.round5(amount) = (amount * 20).round / 20.0

  #: (Array[String]) -> nil
  def self.main(args)
    lines = File.readlines(args[0], chomp: true).map do |l|
      name, qty, price = l.split(",")
      { "name" => name, "qty" => qty.to_i, "price" => price.to_f }
    end
    subtotal = lines.sum { |l| l["qty"] * l["price"] }
    puts format("%-24s %10.2f", "Subtotal", subtotal)

    Dir.glob(File.join(args[1], "*.rb")).sort.each do |path|
      rule = Fluxpinel.load(path)
      off = Fluxpinel.call(rule, "discount", [lines, subtotal])
      next if off == 0
      puts format("%-24s %10.2f", Fluxpinel.call(rule, "name", []), -off)
      subtotal -= off
    end

    vat = subtotal * VAT
    puts format("%-24s %10.2f", "VAT 8.1%", vat)
    puts format("%-24s %10.2f", "Total", round5(subtotal + vat))
    nil
  end
end

#: のコメントはインラインのRBS型です。スクリプトからしか呼ばれないメソッドには、Spinelがこれを必要とします。コンパイルされたコードだけからは型を推論できないからです。

ルールは二つです。

# rules/10_bulk.rb: 10% off every line with 10 or more pieces
class Bulk
  def name = "Bulk discount"
  def discount(lines, _subtotal)
    lines.select { _1["qty"] >= 10 }.sum { _1["qty"] * _1["price"] * 0.1 }
  end
end
Bulk.new
# rules/20_loyalty.rb: 5% off orders over 200 francs
class Loyalty
  def name = "Loyalty 5%"
  def discount(_lines, subtotal) = subtotal > 200 ? Invoice.round5(subtotal * 0.05) : 0
end
Loyalty.new

お得意様のルールは、コンパイル済みのコードである Invoice.round5 を呼び出しています。make を一回呼ぶだけでバイナリがビルドされ、スクリプトから呼べるメソッドもそこで指定します。

make host HOST=build/invoice HOST_NAME=invoice KERNEL=app.rb \
  KERNEL_MAIN=Invoice.main KERNEL_ENTRIES=Invoice.main,Invoice.round5
$ build/invoice/invoice items.csv rules
Subtotal                     275.50
Bulk discount                -12.00
Loyalty 5%                   -13.20
VAT 8.1%                      20.27
Total                        270.55

一回目でそのまま動きました。次に、三つ目のルールとして、3袋ごとに1袋が無料になるコーヒーウィークを rules/ に新しいファイルとして加え、同じバイナリをもう一度実行しました。チェックサムは同じまま、請求書に新しい行が加わり、合計は250.60になりました。コンパイラは一切使っていません。これこそがFluxpinelの狙いで、感覚は普通のRubyとまったく同じです。

同じファイルはCRubyでも動きます。そこでは kernel/fluxpinel_cruby.rb が Fluxpinel モジュールを提供します。どちらもまったく同じ請求書を出力しました。コンパイルされたバイナリは起動から終了まで10ミリ秒かからず、CRubyは約0.25秒で、そのほとんどは起動時間でした。

サンプル

リポジトリには三つのサンプルがあり、すべてビルドしました。

私の環境で動かなかったのはlogqでした。1,000行のログでもセグメンテーション違反で落ちました。調べてみると、原因は限られたものでした。スクリプトの中で Symbol#to_s が作ったString、ここでは top(5, by: :duration) の by.to_s が、コンパイルされたメソッドに渡されるとゴミになってしまいます。Stringリテラルや :duration.to_s.dup なら問題なく動きます。プロジェクトのCIは通っていますが、サンプルは実行していません。3週間のプロジェクトなら、このようなバグがひとつあるのはごく普通だと思います。小さな再現コードと原因を添えて報告しました:issue #1(英語)。

インタプリタの速さ

インタプリタはプログラムのつなぎの部分のためのもので、重いループのためのものではありません。READMEにもそう書かれています。それでも測ってみました。

  Fluxpinel CRuby 4.0.9 CRuby + YJIT
fib(32)、多くのメソッド呼び出し 1.78 s 0.37 s 0.18 s
HashとStringの処理、300,000回 0.22 s 0.27 s 0.25 s

インタプリタのメソッド呼び出しはCRubyの約5倍遅いです。しかし、スクリプトがおもに組み込みメソッド、つまりSpinelランタイムのコンパイル済みのCを呼び出すなら、CRubyと同じくらいか、少し速いくらいです。READMEも、より大きな規模で同じ傾向を報告しています。NESエミュレータのベンチマークであるoptcarrotは、Fluxpinelで毎秒約9フレーム、CRubyでは55フレームです。比較として、Spinelでコンパイルした fib(32) は10ミリ秒かからずに終わりました。

ですから、logqが示している分け方が正しい使い方です。スクリプトが 何を するかを決め、コンパイルされたコードがループを回します。

プラグインだけではない

ほかにもおもしろいと思った点が二つあります。

まだ制限もあります。スクリプトから呼べるのは、エクスポートしたモジュールメソッドだけで、コンパイルされたクラスのインスタンスのメソッドは呼べません。また、コンパイルされたコードは、解決できない呼び出しをインタプリタに任せることができません。どちらもSpinel側の変更が必要です。Matzは意図して eval とVMをSpinelの設計に入れていないので、Fluxpinelは別のプロジェクトで、Spinelのひとつのコミットに固定しています。

まとめ

Spinelはプログラムを閉じることでRubyを速くします。Fluxpinelは、ちょうど開きたい場所でそれを再び開きます。プラグイン、ルール、クエリ、コンソールです。プラグインのルールを使った私の請求書は一回目で動き、もう一度コンパイルせずにルールを加えるのは、普通のRubyとまったく同じ感覚でした。メソッド呼び出しの多いコードではインタプリタは遅いので、重い処理はコンパイルされた部分に置くべきです。

このアイデアはとても気に入りました。Spinelのプログラムに、私がいちばん恋しくなるRubyの部分を与えてくれます。とても若いプロジェクトなので、logqのクラッシュのような粗さはあると思っておいたほうがいいでしょう。これからどうなっていくのか、そしてSpinelとFluxpinelがどう一緒に育っていくのか、楽しみです。

リンク