WASIでCRubyをWebAssemblyにポートする方法

齋藤優太氏:ここまでが前半戦で、ここからどうやってCRubyをWebAssemblyにポートしたかを紹介します。

CRubyはCで書かれたプログラムなので、すでにいろいろな環境にポートされています。このCRubyのソースコードは、かなりポータブルに書かれているので、CからWasmにコンパイルするコンパイラもあるしポーティングは簡単だなと。

そんなわけないんですよね。CRubyは内部にたくさんのドラゴンを飼っていました。setjmp/longjmpに依存した例外機構だったり、アーキテクチャ依存のcontext-switchingを使ったFiberだったり、アーキテクチャ依存している保守的GCだったり。なかなか倒し甲斐がありそうです。

例外処理

というわけで1つずつ深堀りしていきましょう。さっそくCRubyの内部の実装に潜っていくのですが、CRubyの例外機構はsetjmp/longjmpで実装されています。ここにドンと書かれているvm_execというCRubyの関数は、VM命令を実行するやつです。ループの途中にgotoとかがあって、ちょっと泣き出したくなりますね。

めげずに読み進めますが、EC_EXEC_TAGというマクロでsetjmpが呼び出されて、現在の実行状態が保存されます。1回目の普通の実行ではTAG_NONEが返ってくるので、そのままif文に入ります。そしてvm_exec_coreという命令列をひらすら実行していく関数で実際にRubyを実行していきます。

ここで、raiseメソッドで例外が投げられたとしましょう。raiseメソッドを掘っていくと、最終的にrb_raise_jumpという関数にたどり着くのですが、ここの中でlongjmpが呼び出されています。普通、関数が終了すると関数の呼び出し元に制御が戻っていくのですが、longjmpを呼び出すと、setjmpから保存したところまで一気に戻っていきます。

コールスタックを一気に遡っていく方向へのジャンプです。こうやってlongjmpで戻ってくると、EC_EXEC_TAGはTAG_NONE以外を返すので、elseブロックに制御が移ります。ここで例外ハンドリングが実行されるわけですね。なんとも不思議なプログラムですね。

setjmp/longjmpの実装

例外処理がだいたいわかったので、次は、不思議なsetjmp/longjmpがどうやって実装されているのかを見ていきます。当然、アセンブリで書かれています。これはmusl-libcのx86_64向けの実装です。やっていることは、現在のマシンスタックの位置とマシンレジスタとプログラムカウンタの保存と復元です。

一番の黒魔術が、ここのプログラムカウンタの操作の部分ですね。x86_64の場合、関数呼び出しの時に呼び出し元のアドレスをスタックに積んで、ret命令でそこに戻るジャンプをしますよね。setjmpは、そのマシンスタックに積まれた戻り先のポインタを保存するわけです。逆にlongjmpでは、保存されたアドレスにジャンプするという、普通の関数ではできないまったく違う動きをするわけですね。

ここでのジャンプは、Cの関数内に対するgotoや関数呼び出しとリターンの組み合わせでは表現できないジャンプとなっています。gotoやcall、returnで表現できないのはWebAssemblyではちょっと厄介です。WebAssemblyは、セキュリティのために関数の戻り先アドレスをWebAssembly自体の保護されたスタックに積みます。

このスタック領域はプログラムからアドレス参照できないので、VMからしか見えません。なので、基本的に関数呼び出しでpushするか、return命令でpopするかという2つの操作しかできず、どうがんばっても戻り先のアドレスは読めないんですよね。というわけで、setjmpの大事な部分が実装できません。

WebAssembly実行モデル

もう1つ、プログラムを守る機構があります。戻り先のアドレスを読めたとしても、そもそも関数をまたいだジャンプが命令セット的にできないんですよね。そのためジャンプは、関数呼び出しか関数内のgotoしかなく、これだとlongjmpを実装できません。というわけで、portingに欠けているピースがちょっとずつ見えてきました。整理すると、「現在の実行状態を保存すること」「保存した実行状態に復元すること」の2つです。

Fiberの実装

ここでもう1つのドラゴン、Fiberの実装を見ていきましょう。

Fiberもなかなか難しいところがあります。ご存じのとおり、Fiberはセミコルーチンの一種で、プログラムを止めたり再開したりという操作ができるおもしろい機能です。こんな感じでフィボナッチ数列を返す無限ジェネレータが簡単に書けます。

resumeとかyieldとか、途中で止めたり再開したりするプログラムは、ユーザーランドのcontext switchで実装されています。ここでいうcontextは、RubyのVMスタック、マシンスタック、マシンレジスタ、プログラムカウンタという感じです。Fiberごとにcontextはあるので、contextが切り替わるタイミングで今のcontextを保存して、次のFiberのcontextをロードします。

保存しないといけない情報は、だいたいsetjmp/longjmpと同じです。ただ、setjmp/longjmpと違う部分が1つだけあります。longjmpは、コールスタックの呼び出し元に戻っていく方向にジャンプするのですが、Fiberの場合はぜんぜん関係ないコールスタックにスイッチしないといけません。というわけで、setjmp/longjmpからさらに難易度が上がっています。

保守的GCの実装

保守的GCの実装もなかなか難しいです。保守的GCは、データ領域からオブジェクトのようなものを見つけてきて、それらをrootオブジェクトとして、生きているオブジェクトとしてマークしていきます。CRubyの保守的GCの実装は、レジスタとマシンスタックの中身をスキャンして、そこからオブジェクトみたいなものを見つけていきます。

ここでWebAssemblyの実行モデルと照らし合わせてみましょう。WebAssemblyは、スタックマシンのアーキテクチャなので値のスタックはあるのですが、これも先ほど紹介した保護されたスタックに入っています。ここの領域はアドレス参照できないので、Cでローカル変数をアドレス参照する時は、C Stackと呼ばれる線形メモリ上の領域に配置します。

マシンスタックとしては、保護されたスタックの中のValue Stackと、線形メモリの中のC Stackの2つがあるわけですね。C Stackは線形メモリなので普通にスキャンできるのですが、Value Stackはpush・popしかできないので、動的にスキャンができません。

もう1つのスキャン対象として、マシンレジスタがありますね。これはWasmではLocalsという領域に対応します。Localsは関数ローカルなレジスタで、各Call FrameにLocalsのストレージがある感じです。このLocalsは、現在のCall Frameのものしか見えないので、1個前のCall FrameのLocalsは見えません。なので、すべてのLocalsのスキャンは難しいです。

ということでもう1つ、すべてのフレームのLocalsと、Value Stackのインスペクトというピースが足りないことがわかりました。

足りないピースを補足する「Asyncify」

この足りないピースを埋めてくれるのがAsyncifyです。Asyncifyは、WebAssemblyプログラムに対してローレベルな一時停止と再開の仕組みを提供するアルゴリズムです。これはもともとJavaScriptのasync関数をsyncなCの関数から呼び出すためにGoogleのAlon Zakaiさんによって考案されたテクニックです。

JavaScriptに依存しているわけではないので、WASIの環境でも使える一般的なテクニックです。具体的にどうやっているかというと、Cコンパイラの吐くWebAssemblyバイナリに対してinstrumenting、プログラム変換を適用して、元のプログラムではありえない動きを実現します。

プログラム例

Asyncifyの基本的なアイデアは、UnwindとRewindという2つの操作に基づいています。実際のプログラムの例を見ていきましょう。

これはsleepしている最中に他のことをするプログラムの例です。始めに関数fooを呼び出して、sleepする前のbeforeを出力するputsを実行します。

ここではstatic変数のis_sleepingが初期値のfalseなので、sleepが呼び出されるとif文の前半が実行されます。中のブロックではasyncify_start_unwindが呼び出されていますね。これは「この時点でUnwindモードに入るよ」ということを示しています。Unwindモードにしてからそのままsleepを抜けて、sleep後のputsはいったんスキップされてmainまで戻ってきます。この時点でちょっとおかしなプログラムですよね。

mainまで戻ってくる間、このUnwindの操作は現在のフレームをメモリ上に書き出してくれます。この時点で制御はmainまで戻っているので、puts("sleeping")など、sleep中に他にやりたい処理が自由にできます。ここでプログラムを再開します。プログラムの再開はRewindといってコールスタックを再構築しながらプログラムの実行状態を復元していきます。

asyncify_start_rewindを呼び出して、Rewindモードに入ってからもう一度Fooを呼び出すと、Unwindする前に実行されていたsleep前のputsがスキップされて、もう一度直接sleepを呼び出します。今度の呼び出しではis_sleepingがtrueなので、elseブロックに入ってasyncify_stop_rewindによってRewindモードを終了します。

ここまでくると1回目のsleepを呼び出していた時の状態が復元されているので、Unwindしていた時にスキップされていたsleep後のputsが呼び出されるわけです。

プログラム変換の流れ

Asyncifyの実行時の流れを見てきました。次は、この動きをどうやって実現しているかを紹介をします。プログラム変換の流れとしては、レジスタをLocalsに漏れ出させて、コントロールフローをいじくりまわして、最終的にUnwind・Rewindする時のLocalsの退避と復元のコードを挿入します。

(スライドを示して)右が、Cのコードをそのまま変換したWasmの命令列になっています。

Wasmはスタックマシンなので、引数として渡ってきたsymをスタックに積んでrb_sym2strを呼び出して、スタックを1個消費して、また1個スタックに値をpushしています。pushされた値はrb_str_lengthが消費して最終的に長さの値がpushされるので、それをreturnします。

最初の変換ですが、Wasmレジスタ、LocalsにValue Stackの内容をすべて書き出します。この操作により、Unwindが起こりうるタイミングでValue Stackに値がないことが保証されます。これによって今後はLocalsの保存と復元だけを考えればよくなります。

次にコントロールフローをいじっていきます。UnwindとRewindの最中にどのコードを実行するかを選択できるようにしています。Unwindが発生した位置を保存して、Rewindではそこまでの実行をスキップできるif文を挿入しています。

最後にUnwindの時のLocalsのメモリへの書き出しと、Rewindの時のLocalsへのロードを追加しています。

というわけで、Asyncifyのテクニックによって今まで欠けていたと思っていたピースが揃い始めました。実行状態の保存は、Asyncifyが実行状態をメモリに書き出してくれますし、復元もコールスタックを最初から構築し直すので、コールスタックをまたいだcontext switchにも対応できます。

保守的GCのためにWasmのLocalsとValue Stackの値を見たい場合も、AsyncifyがValue Stackの値をすべてLocalsに書き出してくれる上に、その上でLocalsの実行状態をメモリに書き出してくれます。なので、それを通して間接的にLocalsとValue Stackの値をスキャンできるわけです。

というわけで、ヤバそうに見えたドラゴンを討伐できました。こうしてRubyがブラウザを含めていろいろなところで動くようになったわけです。やった!

(会場拍手)

Wasm上での制約はあるのか?

ひとまず移植はできたので、みなさんが気になっていそうなクエスチョンに答えていこうと思います。まずはWasm上での制約ですね。だいたいは倒せたのですが、ちょっとどうしようもないやつもいくつかあるんですよね。とりあえず、スレッドまわりのAPIが残念ながら使えません。そもそもスレッドのセマンティクスがWebAssembly上で議論中なので、今はNotImplementedErrorを投げています。

ほかに困りそうなところで言うと、Cの拡張ライブラリを静的リンクしないといけないところですかね。拡張ライブラリ自体はそのまま動くのですが、Wasmの動的リンクのABIが正式な仕様になっていないというのが問題としてあります。一応、動的リンクのABIはあるのですが、正式な仕様になっていないので誰も実装していない状況です。

なので、どうにかして静的リンクできるようにゴニョゴニョしているのですが、このあたりはビルドシステムの改善も必要になってくるので一筋縄ではいかず、絶賛実験中です。

バイナリの大きさはどのくらいか?

もう1つ、みなさんが気にしていそうなバイナリサイズですが、gzipやBrotliをかければわりと現実的なサイズになっているんじゃないかなと思います。インタプリタ自体も大きいですが、プレーンテキストのRubyスクリプトはかなりの重量がありますね。特に、標準ライブラリを含めたバーチャルファイルシステムに埋め込んでOne Binaryにすると、生の状態で25MBになってしまいます。

ちょっとこれはそのままブラウザでロードするには重すぎますね。このあたりはもう少しなんとかできるんじゃないかなという目論見があるので、ご期待ください。

CRubyをWebAssembly上で動かした時の速度は?

もう1つ。CRubyをWebAssembly上で動かした時の実行速度のパフォーマンスですが、もちろんネイティブと比べるとかなり性能は落ちてしまいます。これはWebAssembly VM上で動くことと、Asyncifyの変換で生じているオーバーヘッドが主な原因となっています。あれだけコントロールフローをいじくり回したら遅くなりますよね。

mrubyとはだいだい同レベルと言っていい性能になっています。同じブラウザでRubyを動かすOpalという技術がありますが、これと比べるとかなり速いことがわかるかなと思います。

ということで、だいたいみなさんに真っ先に聞かれそうなところは答えられたんじゃないかなと思います。今回の移植プロジェクトでは、遠藤さん(遠藤侑介氏)や笹田さん(笹田耕一氏)を始め、Rubyコミッターのみなさんにたくさんのアドバイスをいただきました。

また、Ruby Associationには助成プロジェクトとして、ご支援いただきました。ほかにも、関連プロジェクトにパッチを送ってくださったみなさん、リリース前にも関わらず実際にたくさん使ってくださったみなさん、どうもありがとうございました。

まとめ

ということで、時間もいい頃合いなのでまとめです。Ruby3.2では、WASIベースのWebAssemblyサポートが入ります。ブラウザだけでなく、いろいろなプラットフォームで動くようになります。これについてはいろいろなユースケースがあると思うので、ぜひ使ってみてください。手軽に試せるようにビルド済みのバイナリとnpmパッケージを配布しています。

自分で動かすにはちょっと腰が重いなという方も、Webブラウザは手元にありますよね? gemを試せるirb環境がありますので、好きなgemで動くかをぜひ試してみてください。ピュアなRubyのgemであれば、だいたい動くんじゃないかなと思っていますが、目も手もぜんぜん足りていないので、ぜひフィードバックをいただけると大変助かります。ということで、本日の発表を終わろうと思います。ありがとうございました。

(会場拍手)