RubyKaigi 2026で発表されたSpinelを触ってわかった「コンパイルが通っても動いているとは限らない」話

こんにちは。プロダクト開発部の森 @jiskanulo です。

2026年4月22日から24日までRubyKaigi 2026 Hakodateが開催されました。

rubykaigi.org

函館アリーナを3日間に渡って貸し切る大規模イベントの運営をしていただきましたスタッフの皆様に感謝を申し上げます。

ファインディ株式会社もPlatinumスポンサーとして協賛しました。 私もブースに立って出展やファインディ各サービスのご案内をさせていただきました。

お話しをしていただいた皆様にも重ねて感謝申し上げます。

ブースに立つ合間にセッションを聞いて自分でやれそうなことに思いを馳せたり、他社様のブースを回ってプロダクトのお話をしたり、昔の同僚や知人と再会したりと個人的にもいい刺激の多い3日間でした。

さて、4月24日、最後のセッションのMatzさんのキーノートにてRubyファイルをネイティブバイナリにコンパイルするSpinelが発表されました。

早速Spinelを使ってみた感想とつかいどころを記します。

Spinelとは

SpinelはRubyのAOT (Ahead-Of-Time) コンパイラです。

RubyソースコードをPrismでパースし、全プログラム型推論を行いCのコードに変換してネイティブバイナリを生成します。

パイプラインは次のとおりです。

Ruby → Prism → AST → 全プログラム型推論 → Cコード → ネイティブ実行ファイル

リポジトリのREADMEによると、miniruby比で約11.6倍、Conway's Game of Lifeでは86.7倍という性能が示されています。

またバイナリサイズもmrubyを含めずに数十KBに収まるので容量が厳しい環境にも適用できるとのことです。

実体としての./spinelはPOSIX shell wrapperで、内部でspinel_parse → spinel_codegen → ccを直列に呼び出している、という作りです。

検証環境とセットアップ

検証は手元のmac環境で行いました。

4月24日公開直後にmacで動かそうとしたタイミングではmalloc.hが存在しないなどでエラーになったのでDockerでUbuntuのコンテナを起動して操作しましたが、現在は対応されています。

土日で函館観光しているうちにコントリビュートが進んでいますね。コントリビュートのチャンスですね。

mac build対応

https://github.com/matz/spinel/commit/4593581eb87cea45b59fb28b9dcf2cd75a9bcbab

windows対応

https://github.com/matz/spinel/commit/1fe3136aa9bf834b5f37176faca7346503fb1446

環境は次のとおりです。

  • macOS (arm64)
  • Apple Clang
  • Spinel masterブランチ(2026-04-28時点)

ビルドと実行の基本フローはシンプルです。

spinelをmakeし、spinelにrubyファイルをわたせば実行形式バイナリが出力されます。

make deps          # 初回のみ: libprism 取得
make               # spinel_parse / spinel_codegen / ランタイムをビルド
./spinel app.rb    # Ruby → 実行形式(./app)
./app

動かしてみた

Matzのキーノートとも被りますがサンプルコードを動かしてみました。

またキーノートで話があったとおりevalrequireはサポートしていないとのことです。

eval, requireを使うと具体的にどうなるのかも試してみました。

Hello, World!

最小の動作確認はそのまま動きます。

# hello_world.rb
puts "Hello, World!"
./spinel hello_world.rb
./hello_world
# => Hello, World!

eval / requireの挙動:エラーにならず無音で間違う

2026年4月28日の現時点ではコンパイル時にエラーにならず、warningも出ず、しかし実行すると無音で間違った出力を返すという挙動です。

具体的に2つのケースを見ていきます。

eval は no-op に置き換わる

次のRubyコードをSpinelでコンパイルして実行します。

# test_eval.rb
code = "1 + 2"
result = eval(code)
puts result
$ ./spinel ./tmp/jiska/samples/test_eval.rb
./tmp/jiska/samples/test_eval.rb -> test_eval

実行すると結果は0です。

$ ./test_eval
0

生成されたCのコードを見るとeval()の呼び出しがno-opに置き換わり、戻り値は型推論で決まったゼロ値(mrb_intのため0)になります。

const char *lv_code = "1 + 2";
mrb_int lv_result = 0;        // mrb_int = int64_t typedef
lv_code = "1 + 2";
lv_result = 0;                // ← eval() 呼び出し自体が消滅、0 を代入するだけ
printf("%lld\n", (long long)lv_result);

mrb_int はmrubyのような」命名に見えますが lib/sp_runtime.hint64_t のtypedefとして定義されたSpinelランタイムの整数型です。

https://github.com/matz/spinel/blob/64105ec86d08c9edc92c3d17bf059126ceaa15d3/lib/sp_runtime.h#L53

require は文ごと消える

次はrequire "json"JSON.generateを使うケースです。

# test_require.rb
require "json"
puts JSON.generate({ name: "spinel", year: 2026 })

こちらも実行すると0になります!

$ ./spinel ./tmp/jiska/samples/test_require.rb
./tmp/jiska/samples/test_require.rb -> test_require
$./test_require
0

requireの行は生成されたCのコード上で文ごと消えます。

続くJSON.generateもSpinelから見れば未定義メソッドであり、evalと同じくゼロ値を返す呼び出しに化けます。

最終的にputsには0が渡って表示される、という流れです。

現時点での注意点

現時点では「コンパイルが通った」「実行ファイルができた」「実行できた」の3点だけを見ていると間違った結果を吐いていることに気づけません。

Spinel向けにRubyを書くときはREADMEを都度確認することが重要です。

Spinelを使ってみる

次に実際のユースケースを想定してみます。

実例として、GitHubのPR一覧JSONを入力に取り、各PRのnumberauthorを表示するスクリプトを書いてみました。

入力はARGV、stdinの優先順で受け取ります。

requireなしでJSONを扱う

require "json"が使えないので標準のRegexpString操作だけで処理を組む必要があります。

なお、ここで示すコードは正しいJSONパーサではありません。require "json"が使えないSpinelの制約下で、入れ子オブジェクトや},を含む文字列値が現れない、フラットな配列形式の固定データからnumberauthorをアドホックに取り出す例として読んでください。本格的なJSON処理が必要な場合は、入れ子・エスケープなどで容易に壊れるため、この実装は使えません。

実際のRubyファイルは次のとおりです。

多少トリッキーなところは感じますが、こうした限定的な用途であれば、RegexpとStringの標準機能だけで実装できることがわかりました。

if ARGV.length > 0
  input = ARGV[0]
else
  input = readlines.join
end

records = input.split(/},/)

results = []
records.each do |chunk|
  if chunk =~ /"number"\s*:\s*(\d+)/
    num = $1
    if chunk =~ /"author"\s*:\s*"([^"]+)"/
      auth = $1
      results << "##{num} by #{auth}"
    end
  end
end

if results.length == 0
  $stderr.puts "Error: failed to parse JSON (no matching number/author pairs found)"
  exit 1
end

results.each do |line|
  puts line
end

実行サンプルは次のとおりです。

引数渡し、パイプ、リダイレクトそれぞれに対応しています。

JSONを展開できない場合は標準エラーへエラーメッセージを出してexit 1します。

SAMPLE='[{"number":1,"author":"Findy"},{"number":2,"author":"jiskanulo"}]'

# 1) 引数渡し
./pr_extract "$SAMPLE"

# 2) パイプ
echo "$SAMPLE" | ./pr_extract

# 3) リダイレクト
./pr_extract < sample.json

# それぞれの出力:
# #1 by Findy
# #2 by jiskanulo
$ ./pr_extract "not json"
Error: failed to parse JSON (no matching number/author pairs found)
$ echo $?
1

なお、引数もパイプもなしで./pr_extractを起動するとreadlinesがEOF待ちでブロックします。

最初は$stdin.tty?で判定してUsageを表示しようとしましたが、実機で試すとTTYでもパイプでも両方0が返ってきました。これもeval / requireと同じく、Spinel未対応のメソッドが silent failしている例です。

# test_tty.rb
puts $stdin.tty?
$ ./test_tty           # TTY実行
0
$ echo "" | ./test_tty # パイプ実行
0

Spinelで標準入力を扱うときは$stdin.tty?に頼った分岐ができないため、引数で渡すかパイプ・リダイレクトで明示的に流し込む運用に倒すのが安全です。

ハマりどころは個別に踏みに行く価値がある

実装してみるとString#<<でmutable化するとRegexp#scanに渡せない、Regexp#scan(/(...)/) { |m| ... }のキャプチャは効かない...などとハマりどころがいくつかありました。

ただ、現時点での詳細を解説しても開発が活発に進んでいるのですぐに風化してしまうので詳しくは記しません。

Spinelを試すときの判断基準

ここまで触ってみた感触から、Spinelを触るときの判断基準を次にまとめます。

成功体験を得やすいのは次のようなコードです。

  • 入出力がputs / printfで完結する
  • 標準ライブラリに依存しない
  • 数値計算・文字列処理・正規表現(=~ + $1)で書ける範囲

逆に、現時点で踏むと詰まる、もしくはsilent failになるのは次のような領域です。

  • requireを前提とするコード(標準ライブラリの利用)
  • evalを含むメタプログラミング
  • 未対応メソッドに依存する処理(呼び出しがゼロ値返却に化ける)

「コンパイルが通ったから動いている」とは限らない、というのが現時点での最も大事な感触です。

CRubyとSpinelの両方で実行して出力をdiffする運用を組むのが安全です。

個人的所感

ローカル環境のlintやチェッカーなど今までワンライナーでやっていたことを置き換えて使ってみたいですね。

コンパイル済みのバイナリを配布できるのであれば環境構築の手間も減るかもしれません。

まだ少し触っただけですが、Spinelに可能性を感じています。また、こういうワクワクするものを提供してくれるMatzさんの凄さをあらためて実感します。

これから機能が拡充されていくのが楽しみです。

自分でもコントリビュートしていきたいですね。

おまけ: Spinelの由来

Rubyと同じく宝石つながりでSpinelなのかなあとはじめ思っていましたが、漫画アニメ『カードキャプターさくら』のスピネル・サン(スッピー)から名前をとっているそうです。

相棒はルビー・ムーンなのでここでもRubyに繋がってきますね。

由来聞いた時はそっちか〜〜!!となりました。かわいい。

おわり

ファインディでは一緒に会社を盛り上げてくれるメンバーを募集中です。興味を持っていただいた方はこちらのページからご応募お願いします。

herp.careers