
2025.02.18
「売上をスケールする」AIの使い道とは アルペンが挑む、kintone×生成AIの接客データ活用法
リンクをコピー
記事をブックマーク
齋藤優太氏:ここまでが前半戦で、ここからどうやって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がどうやって実装されているのかを見ていきます。当然、アセンブリで書かれています。これはmusl-libcのx86_64向けの実装です。やっていることは、現在のマシンスタックの位置とマシンレジスタとプログラムカウンタの保存と復元です。
一番の黒魔術が、ここのプログラムカウンタの操作の部分ですね。x86_64の場合、関数呼び出しの時に呼び出し元のアドレスをスタックに積んで、ret命令でそこに戻るジャンプをしますよね。setjmpは、そのマシンスタックに積まれた戻り先のポインタを保存するわけです。逆にlongjmpでは、保存されたアドレスにジャンプするという、普通の関数ではできないまったく違う動きをするわけですね。
ここでのジャンプは、Cの関数内に対するgotoや関数呼び出しとリターンの組み合わせでは表現できないジャンプとなっています。gotoやcall、returnで表現できないのはWebAssemblyではちょっと厄介です。WebAssemblyは、セキュリティのために関数の戻り先アドレスをWebAssembly自体の保護されたスタックに積みます。
このスタック領域はプログラムからアドレス参照できないので、VMからしか見えません。なので、基本的に関数呼び出しでpushするか、return命令でpopするかという2つの操作しかできず、どうがんばっても戻り先のアドレスは読めないんですよね。というわけで、setjmpの大事な部分が実装できません。
もう1つ、プログラムを守る機構があります。戻り先のアドレスを読めたとしても、そもそも関数をまたいだジャンプが命令セット的にできないんですよね。そのためジャンプは、関数呼び出しか関数内のgotoしかなく、これだとlongjmpを実装できません。というわけで、portingに欠けているピースがちょっとずつ見えてきました。整理すると、「現在の実行状態を保存すること」「保存した実行状態に復元すること」の2つです。
ここでもう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は、データ領域からオブジェクトのようなものを見つけてきて、それらを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は、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上での制約ですね。だいたいは倒せたのですが、ちょっとどうしようもないやつもいくつかあるんですよね。とりあえず、スレッドまわりのAPIが残念ながら使えません。そもそもスレッドのセマンティクスがWebAssembly上で議論中なので、今はNotImplementedErrorを投げています。
ほかに困りそうなところで言うと、Cの拡張ライブラリを静的リンクしないといけないところですかね。拡張ライブラリ自体はそのまま動くのですが、Wasmの動的リンクのABIが正式な仕様になっていないというのが問題としてあります。一応、動的リンクのABIはあるのですが、正式な仕様になっていないので誰も実装していない状況です。
なので、どうにかして静的リンクできるようにゴニョゴニョしているのですが、このあたりはビルドシステムの改善も必要になってくるので一筋縄ではいかず、絶賛実験中です。
もう1つ、みなさんが気にしていそうなバイナリサイズですが、gzipやBrotliをかければわりと現実的なサイズになっているんじゃないかなと思います。インタプリタ自体も大きいですが、プレーンテキストのRubyスクリプトはかなりの重量がありますね。特に、標準ライブラリを含めたバーチャルファイルシステムに埋め込んでOne Binaryにすると、生の状態で25MBになってしまいます。
ちょっとこれはそのままブラウザでロードするには重すぎますね。このあたりはもう少しなんとかできるんじゃないかなという目論見があるので、ご期待ください。
もう1つ。CRubyをWebAssembly上で動かした時の実行速度のパフォーマンスですが、もちろんネイティブと比べるとかなり性能は落ちてしまいます。これはWebAssembly VM上で動くことと、Asyncifyの変換で生じているオーバーヘッドが主な原因となっています。あれだけコントロールフローをいじくり回したら遅くなりますよね。
mrubyとはだいだい同レベルと言っていい性能になっています。同じブラウザでRubyを動かすOpalという技術がありますが、これと比べるとかなり速いことがわかるかなと思います。
ということで、だいたいみなさんに真っ先に聞かれそうなところは答えられたんじゃないかなと思います。今回の移植プロジェクトでは、遠藤さん(遠藤侑介氏)や笹田さん(笹田耕一氏)を始め、Rubyコミッターのみなさんにたくさんのアドバイスをいただきました。
また、Ruby Associationには助成プロジェクトとして、ご支援いただきました。ほかにも、関連プロジェクトにパッチを送ってくださったみなさん、リリース前にも関わらず実際にたくさん使ってくださったみなさん、どうもありがとうございました。
ということで、時間もいい頃合いなのでまとめです。Ruby3.2では、WASIベースのWebAssemblyサポートが入ります。ブラウザだけでなく、いろいろなプラットフォームで動くようになります。これについてはいろいろなユースケースがあると思うので、ぜひ使ってみてください。手軽に試せるようにビルド済みのバイナリとnpmパッケージを配布しています。
自分で動かすにはちょっと腰が重いなという方も、Webブラウザは手元にありますよね? gemを試せるirb環境がありますので、好きなgemで動くかをぜひ試してみてください。ピュアなRubyのgemであれば、だいたい動くんじゃないかなと思っていますが、目も手もぜんぜん足りていないので、ぜひフィードバックをいただけると大変助かります。ということで、本日の発表を終わろうと思います。ありがとうございました。
(会場拍手)
関連タグ:
2025.02.13
“最近の新人は報連相をしない”という、管理職の他責思考 部下に対する「NG指示」から見る、認識のズレを防ぐコツ
2025.02.13
AIを使いこなせない人が直面する本当の課題 元マッキンゼー・赤羽雄二氏が“英語の情報”を追い続ける理由
2025.02.06
すかいらーく創業者が、社長を辞めて75歳で再起業したわけ “あえて長居させるコーヒー店”の経営に込めるこだわり
2025.02.12
マネージャーは「プレイング3割」が適切 チームの業績を上げるためのマネジメントと業務の比率
2025.02.14
報連相ができない部下に対するコミュニケーションの取り方 「部下が悪い」で終わらせない、管理職のスキル向上のポイント
2025.02.13
上司からは丸投げ、部下からはハラスメント扱い、業務は増加…プレイングマネジャーを苦しめる「6つの圧力」とは
2025.02.12
何度言っても変わらない人への指示のポイント 相手が主体的に動き出す“お願い”の仕方
2025.02.13
「みんなで決めたから」を言い訳にして仲良しクラブで終わる組織 インパクトも多様性も両立させるソース原理
2025.02.10
32歳で「すかいらーく」を創業、75歳で「高倉町珈琲」で再起業 「失敗したからすかいらーくができた」横川竟氏流の経営哲学
2025.01.07
1月から始めたい「日記」を書く習慣 ビジネスパーソンにおすすめな3つの理由
着想から2か月でローンチ!爆速で新規事業を立ち上げる方法
2025.01.21 - 2025.01.21
新人の報連相スキルはマネージメントで引きあげろ!~管理職の「他責思考」を排除~
2025.01.29 - 2025.01.29
【手放すTALK LIVE#45】人と組織のポテンシャルが継承されるソース原理 ~人と組織のポテンシャルが花開く「ソース原理」とは~
2024.12.09 - 2024.12.09
『これで採用はうまくいく』著者が語る、今こそ採用担当に届けたい「口説く」力のすべて
2024.11.29 - 2024.11.29
第20回エクゼクティブメンターイベント「今、「ひと」と組織が共創する〜働き方の未来へ」
2024.12.07 - 2024.12.07