Cで書かれたWebAssembly VMの開発
(irreducible.io)- ある開発者が6か月間、夜と週末を使ってCベースの WebAssembly VM「Semblance」を作成し、短期間で終わるサイドプロジェクトではなく、仕様を掘り下げる長期的な学習プロジェクトとして進めた
- WebAssemblyは、ブラウザ上で信頼できないコードをネイティブに近い速度で実行するための標準バイトコードであり、Fastly・Shopify・Zedのようにブラウザ外での活用も増えている
- Semblanceの目的は、WebAssembly Core Specificationを学び、Wasmtimeのような産業用ランタイムに貢献する準備をすることだったため、すべてのopcode実装やcore test suiteの通過は範囲外とした
- インタプリタは、
.wasmファイルの magic bytes とバージョン1を確認した後、セクションのデコード、importの解決、モジュールのインスタンス化、export関数の検索、スタックとactivation frameの初期化、opcode switch loopの実行という順序で動作する - 完全なランタイムではないため、遅く、メモリリークや悪意あるモジュールに対する脆弱性の可能性も残っているが、Hello, World! の実行を通じて仕様を学ぶという目的は達成した
WebAssemblyを選んだ理由
- WebAssemblyは、仮想スタックマシンのための標準命令セットとバイトコード形式である
- 出発点はEmscriptenのC/C++ to JavaScriptコンパイラだった
- EmscriptenはLLVM IRをJavaScriptに変換し、CおよびC++コードをWeb上で実行できるようにしていた
- ブラウザ開発者とEmscriptenプロジェクトは性能最適化のため、生成コードを単純なJavaScriptのサブセットに制限し、このサブセットが asm.js として標準化された
- その後、JavaScriptのパースに伴うオーバーヘッドを避けるため、独立したバイトコード形式であるWasmが設計された
- 最近では、ブラウザ外でもWebAssemblyの活用が増えている
- FastlyのEdge ComputeとShopifyのFunctionsはWebAssemblyエンジン上に構築されている
- Zedの拡張システムもWasmベースである
- Bytecode Allianceは、ブラウザ外でのWebAssembly利用を支援する業界横断的な取り組みを主導し、Wasmtimeランタイムと WASI の標準化を進めている
- WASIは、WebAssemblyモジュールがPOSIXに似たホストコールを通じてホストOSと通信できるようにする
- 標準入出力、ファイルシステム、ネットワークなどの外部インターフェースと相互作用する標準的な方法を提供する
サイドプロジェクトから学習プロジェクトへ
- 新しいサイドプロジェクトを始めては数週間で興味を失い、別のプロジェクトへ移るというパターンを繰り返していた
- より長い期間集中でき、普段の仕事よりもコンピューティングスタックの低いレイヤーを扱う大きなプロジェクトが必要だった
- 方向性が足りず放棄されたサイドプロジェクトが多かったため、明確な目標と目に見える成果があるプロジェクトを探していた
- ジェネラリスト型のエンジニアリングプロフィールが安定した仕事を得るうえで影響していると感じた後、T字型スキルセットを作れる専門領域を探していた
- WebAssemblyでカスタムシステムコールを使い、独自のコンピューティング環境を設計できる点が、プラットフォームエンジニアリングのツールとして魅力的に見えた
Semblanceの目的と範囲
- プロジェクト名は Semblance で、Cで書かれたWebAssemblyインタプリタである
- 主な目的は WebAssembly Core Specification に慣れることだった
- 長期的には、Wasmtimeのような産業用ランタイムに貢献できるほどWebAssembly関連の知識を蓄えることも目標だった
- 学習目的のプロジェクトであるため、すべての opcode を実装したり、core test suite を通過したりする意図はなかった
- 「Hello, World!」を実行できれば十分な成功基準とした
- 専門的なC開発経験はなく、Cコードの品質が理想的ではないことを認めたうえで、コードレビューを歓迎している
Hello, World!の実行フロー
- サンプルのCコードは外部の
puts関数を宣言し、hello()で"Hello, World!\n"を出力する - ClangでWebAssemblyを生成する
--target=wasm32-nostdlib-Wl,--no-entry-Wl,--export-all-Wl,--allow-undefined-O3
- 生成された
hello.wasmはsemblance hello.wasm --invoke helloで実行する - プログラム開始時に、コマンドライン引数、デコード済みのWebAssemblyモジュール、ランタイム状態を格納する store 用メモリをスタック上に初期化する
- コマンドライン引数をパースし、不正な場合は終了する
バイナリデコードとモジュール構造
- インタプリタはまずWebAssemblyの binary format をデコードする
- ファイル先頭で
"\0asm"magic bytes を確認する - バイナリフォーマットのバージョンが
1であることを検証する - その後、含まれる sections をデコードする
- デコードが終わると、
WasmModule構造体にhello.wasmの関数、型、import、dataなどが格納されるtypesfuncstablesmemsglobalselemsdatasstartimportsexportscustomsmeta
importの解決とenv::puts
- Semblanceが提供するホスト関数は現在
env::putsの1つだけである - ホスト関数importは、storeにnative function pointerを割り当て、生成された
funcaddrをモジュールのimports配列に入れる方式で処理する hostcall_putsは引数として受け取ったi32 offsetを使用する- storeの最初のmemory instanceからdataポインタを取得する
- offset位置の文字列を
printf("%s", ...)で標準出力に出力する
- import一覧を走査し、module nameが
"env"、item nameが"puts"の項目を見つけたら、register_hostcall_putsでホスト関数を登録する
インスタンス化とopcode実行
- importの解決後、モジュールをインスタンス化する
- インスタンス化には検証段階が含まれる
- 型検査と、モジュールが正しい形であるかを確認するテストを実行する
- memory、global、tableを初期化する
start関数があれば呼び出す
- インスタンス化の結果は
WasmModuleInstであり、実行時の構造と、利用可能な状態になったexportsを含む --invokeコマンドライン引数で指定された名前を基準にexport関数を探す- exportsを走査し、名前が一致する
WasmExportInstを返す - 返されたexportがfunctionであることを確認する
- exportsを走査し、名前が一致する
- 関数呼び出しには、stackと activation frame の初期化が必要である
- 中核となる実行部は opcode switch loop である
- 関数本体のWasm instructionsを順番に読む
- opcodeに応じてstackとstoreに対する操作を実行する
- 例として
i32.const、i64.const、f32.const、f64.const、i32.ge_s、call、nop、unreachable、endなどを処理する - 未処理のopcodeは
"unhandled opcode [...]"を出力し、trapを返す
call命令とホスト関数呼び出し
WasmOpCallの処理では、callopcodeの即値であるfuncidxを使ってstoreから関数instanceを探す- 関数型情報を基に、必要な引数をstackからpopする
- 関数instanceの種類を確認し、Wasm関数かnative host functionかを区別する
- ホスト関数であれば、保存されている
hostfuncfunction pointerをnative callとして実行する - 戻り値があれば、再びstackにpushする
hello関数のendopcodeに到達すると、opcode switch loopが終了し、mainに戻る
結果と残る制限
- 実行結果は標準出力に次のように表示される
Hello, World!Ok []
- プロジェクトは単純な「Hello, World!」プログラムを実行できる
- opcode coverageは完全ではない
- コードは雑で遅く、メモリリークがある
- 悪意あるモジュールに対して脆弱な可能性がある
- それでもWebAssembly Core Specificationを多く学び、エンジニアとして慣れた領域の外へ踏み出す助けになった
- 自分でインタプリタを作った後、Wasmtimeのような産業用ランタイムに貢献できるほどWebAssembly関連の知識が身についたと感じている
- 今後opcodeをいくつか追加し続けることはできるが、このプロジェクトをメインプロジェクトとする段階は終えようとしている
1件のコメント
Hacker News のコメント
以前 Scheme で Wasm インタプリタを作ったことがあるので、自分で実装する人が増えているのを見るとうれしい
思ったほど難しくないので、仕様を一度読んで挑戦してみるとよいし、すべての命令を実装する必要はなく、楽しさを感じられる程度だけでも十分
同じ実装者の立場からの助言として、spec-test にはコンパイル方法が直感的でない奇妙なテキスト Wasm 形式が多いが、
wast2json変換器を使うと、通常のバイナリ Wasm ファイルと一緒に、より単純な JSON の説明を生成できるこの テストスイートを素早く回せるようにすればするほど、反復速度と正確性の助けになる
すべて動くようにするまで少し時間はかかったが、いったん成功すると、何でも実行できる段階まで非常に速く到達できたし、テストスイートは完全ではないものの 95% くらいはカバーしてくれる: https://github.com/WebAssembly/testsuite
これらのテストは前に見たことがあるが、どう使えばいいのか分からず、いら立つし混乱していた
Wasm を直接解釈することに関して、この論文はかなり興味深かった: https://arxiv.org/abs/2205.01183
それを基に https://github.com/peterseymour/winter を作り、Wasm は思っていたほど単純ではないと学んだ
初心者の質問だが、直接ターゲットとしてコードを書いていない状況で、インタプリタのデバッグはどうするのか気になる
オペコード文字列をファジングする方法がどこまで役に立つのかも気になるし、サーバー側の Wasm エンジンとブラウザベースのエンジンの間に実用上どれほど大きな違いがあるのか、一方をもう一方に置き換えるにはどれくらい手間がかかるのかも気になる
興味深いアプローチで、素晴らしい仕事だ
核心を見たい人には、だいたいこのファイルにある: https://github.com/irrio/semblance/blob/main/src/wrun.c
考えてみると、プロジェクトの標準インターフェースとして Wasm-C-API(https://github.com/WebAssembly/wasm-c-api)に従っていればよかったように思う
すでに C API であり、Wasmer、V8、wasmi など大半の Wasm ランタイムが採用しているので、その API に慣れた開発者がより簡単に試せる
作者が Wasm に十分詳しく、Wasmer に貢献したいなら、パッチや改善も歓迎する
「VC の投資を受けた Wasmer が、非営利組織の名称である WebAssembly を商標登録しようとした点についての理解可能な懸念」と述べており、これは非を認めたものだ
Installed-Size: 266 MBって、一体何なんだhttps://github.com/wasmerio/wasmer/issues/2615 は放置された末に自動クローズされた
もう少し議論を呼びそうな質問だが、予備的な 末尾呼び出し命令を追加するつもりがあるのか気になる
Wasm 仕様の側では、あまりに「高水準」だという理由で拒否したが、C 委員会も Dennis Ritchie の提案を拒否したことがある
それでも自分の賭け金は今でも Ritchie 側に置くし、Rob Pike もその方向に賭けていたように思う
そうでなければ、なぜ Golang を作ったのだろうか。呼び出しが高水準なときにだけ、末尾呼び出しも高水準なのだ
Orca を一度見てみるとよさそう。そこにうまく貢献できると思う
[0] https://orca-app.dev
自分も新しくてキラキラしたものの間を移り歩く代わりに、1 つのプロジェクトに集中することにしたが、例外的に AI アシスタントたちに雑用をさせている
控えめに言っても、かなりもどかしい
それから、C で呼び出すときに複数の引数などを扱うには、
libffiのようなライブラリを使うことも提案しようと思っていたWebAssembly を zed のような場所の プラグイン APIとして使うとき、プラグイン開発者はコードをどうデバッグするのか気になる
例えばブレークポイントデバッグが可能なのか、コードがクラッシュしたらスタックトレースを受け取れるのかも気になる