Vim on Wasm on Web Worker on Browser with Atomics
この記事は以前の
の続編で,WebAssembly (Wasm) にポーティングした Vim の話です.
TLDR
Wasm にコンパイルした Vim のコードを Web Worker(ワーカスレッド)の中で動かすことで,メインスレッドで行われるユーザのインタラクションをエディタがブロックしなくなりました.
また,イベントループのポーリングを Atomics.wait()
でやってキー入力を共有メモリバッファで受け取ることで Emterpreter を捨て,実行速度・安定性・バイナリサイズ・ビルド時間・メンテ性が向上しました.
実装:
これまでの問題点
前回の記事では Vim のコードベースに手を入れて emscripten と binaryen で Wasm にビルドし, <canvas/>
に描画する JavaScript ランタイムを整備することでブラウザ上で動く Vim を実現しました.
ポーティングは概ね問題はありませんでしたが,一点 sleep()
が Wasm 及び JavaScript では実現できないという問題がありました.
ウェブページでメインスレッドをブロックすることはユーザとのインタラクションをブロックすることを意味します.なので JavaScript では時間がかかる処理はすべて非同期な API として実装され,ブロックできないようになっています.これと同様にメインスレッドで実行される Wasm もスレッドをブロックする機能は提供されていません.
ですが,C プログラムは sleep()
を使っているものがあり,Wasm 移植ではこれが問題になることがあります.Vim では GUI フロントエンドが実装する必要がある関数 gui_mch_wait_for_chars()
がブロッキングでユーザの入力を待つ処理を要求するため,これに該当しました.
そこで前回の実装では emscripten が提供する Emterpreter という機能を利用して解決しました.Emterpreter は sleep()
ができない代わりに emscripten_sleep()
などのマジック関数を提供します.これらは C のコードからは sleep()
のように同期的に待つ関数のように使えますが,実際は一旦処理を中断して非同期に待つ処理に変換されます.emcc
(emscripten の C コンパイラ)は emscripten_sleep()
を使っている関数を直接 Wasm にコンパイルせず,Wasm の上で動くインタープリタ (Emterpreter) で実行するためのバイトコードに変換します (emterpretify).実行時にはそのバイトコードをインタープリタで実行し,emscripten_sleep()
の呼び出し箇所で一旦実行状態を保存(suspend)して JavaScript 側の setTimeout()
を読んで実行を中断します.タイマーがコールバックを呼んだら Wasm 側に戻り,インタープリタはその中で保存しておいた実行状態を復帰(resume)して実行を再開します.
このような力技により前回はどうにか Vim を動かすことに成功しました.ですが,前回の記事にも書いたように,次の問題が残りました
- Emterpreter で実行される関数は Wasm から直接呼び出すことができません(逆に Emterpreter から Wasm の関数は呼べる).なので,
emscripten_sleep()
を呼んでいる関数を呼んでいる関数,それを呼んでいる関数,... というふうに呼び出し元の関数も Wasm には直接コンパイルできず,Emterpreter のバイトコードにコンパイルする必要があります.Vim でsleep()
が必要な関数(gui_mch_wait_for_chars()
)は Vim のメインループの一番奥にあたる箇所で呼ばれる関数だったため,それを呼ぶコールスタック上の大量の関数を emterpretify する必要がありました.emterpretify する関数は手動でリスト化して管理する必要があるため,そのリストが膨大になってメンテが大変になりました.しかもこのリストが間違っていて Wasm にコンパイルした関数から emterpretify された関数を呼ぼうとすると実行時にプログラムがクラッシュします - Emterpreter バイトコードへのコンパイルは,同期的な C のコードを非同期なコード(
emscripten_sleep()
の呼び出し箇所で処理を切ってコールバックを待つ)に置き換えるため,かなり大きな変換が走ります.そのためコンパイル時間がかなり延びます - Emterpreter バイトコードはかなりシンプルなので,その分バイトコードのバイナリサイズは膨れます
- Emterpreter は試験的な機能なので,かなりバグが多く不安定です.特に JavaScript 側から emterpretify された C の関数を呼ぶ時は不安定で,
char *
を渡そうとすると何故かクラッシュしたりします(ccall()
の{async: true}
がバグっている説)
上記の理由からどうにか Emterpreter を使うのをやめるのが再優先事項でした.
- Sync XHR を使って同期でリクエストを投げ,ServiceWorker で受け取って待ってからレスポンスを返すことでなんとか
sleep()
を実現しようとする → Chrome ではバグで動かず,Firefox ではビジーループに近いほど CPU 利用率が上がるためダメ - Vim のメインループ内で
gui_mch_wait_for_chars()
を呼ぶ関数をすべて非同期(結果をreturn
でなくコールバックで受け取る)に書き換え,emscripten の非同期メインループ機能でメインループを回す → 変換が大変すぎる.10000行ぐらい書き換えてみたところで先が見えないのでメンテが厳しすぎると判断し断念
など試してみましたが,うまくいきませんでした.
今回の実装の改善
Atomics
API
emscripten の pthread 対応のコードを呼んでいた時に,JavaScript に同期プリミティブを提供する Atomics
API があることを知りました.
その中に Atomics.wait()
という futex システムコールのような機能を提供する関数があるのとを見つけました.
The static Atomics.wait() method verifies that a given position in an Int32Array still contains a given value and if so sleeps, awaiting a wakeup or a timeout. It returns a string which is either "ok", "not-equal", or "timed-out".
_人人人人人人人人_ > if so sleeps <  ̄Y^Y^Y^Y^Y^Y^Y^Y^ ̄
なんと,無いと思っていた JavaScript の sleep()
がこんなところにありました.
ですが,これを呼ぶとスレッドを止めてしまうので,もちろんこれをメインスレッドで呼ぶことはできません.Atomics.wait()
はワーカスレッドからつまり Web Worker 内で呼ばなければならないという仕様があります.メインスレッドで呼ぶと待たず即座に例外を投げます.
- メインスレッド(
<script>
で読まれるスクリプト)
// 共有メモリバッファをつくり,ワーカスレッド(dedicated Web Worker)を生成して渡す const shared = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 4); const buffer = new Int32Array(shared); const worker = new Worker('worker.js'); worker.postMessage(buffer); setTimeout(() => { // 最初の要素に 42 を保存 Atomics.store(buffer, 0, 42); // 最初の要素が変更されたことを別スレッドに通知 Atomics.notify(buffer, 0, 1); }, 3000);
- ワーカスレッド(worker.js)
onmessage = event => { const buffer = event.data; const start = Date.now(); // 5秒タイムアウトで同期的に待つ.この間ワーカスレッドは停止する const result = Atomics.wait(buffer, 0, 5000); console.log('slept', Date.now() - start, 'ms:', result); const val = Atomics.load(buffer, 0); console.log(val); // => 42 };
Vim をワーカスレッドで動かす
Vim をワーカスレッドで動かすにあたって,C 側のコードの修正はまったく必要ありませんでした.emcc
は -o vim.js
のように出力に JavaScript ファイルを指定すると,自動で WebWorker 内で動かす前提のコードを吐いてくれます.後は new Worker('vim.js')
のように読み込めば Wasm ファイル(vim.wasm
)を読み込んでくれます.
JavaScript 側はメインスレッドで動かすスクリプトとワーカスレッドで動かすスクリプトに分ける必要があります.
メインスレッド側は
- ワーカスレッドから描画情報を受け取って
<canvas>
に描画する処理 - キー入力を受け取って共有バッファに書き,ワーカスレッド側に通知する
- ワーカを生成してスタートする
ワーカスレッド側は
という処理を行います. vim.wasm/wasm/main.ts
がメインスレッド側,vim.wasm/wasm/runtime.ts
がワーカスレッド側です.
全体の構成のイメージは下図のようになっています.
Wasm 側は Atomics.wait
で待っている間停止するので,ワーカスレッド側の onmessage
コールバックが発火せず,共有メモリバッファで値をやりとりする必要があります.
逆にワーカスレッドからメインスレッドに値を渡す時は postMessage()
で値を渡せます.
キー入力が発生しない時と発生するときの処理のシーケンスは下図のようになっています.
キー入力が発生しない時 (上段,first tick)
- Vim (Wasm) が起動して入力待ち状態になると,最終的に
gui_mch_wait_for_chars()
が呼ばれ,その中で JavaScript 側に処理を渡します(vimwasm_wait_for_event()
) vimwasm_wait_for_event()
内でAtomics.wait()
を使って共有メモリへのメインスレッドからの変更通知を同期で待ちます- 今回は通知が来ず,
Atomics.wait()
がタイムアウトしました - そのまま
vimwasm_wait_for_event()
を抜け,Vim (Wasm) に処理を戻します
キー入力が発生する時 (下段,next tick)
vimwasm_wait_for_event()
内でAtomics.wait()
を使って共有メモリへのメインスレッドからの変更通知を同期で待ちます- キー入力が発生しました.
KeyEvent
をメインスレッド側で受け取って共有メモリバッファにキー入力情報(押されたキーの名前やキーコード,モディファイアキー情報など)を書きます - 共有メモリバッファのうちの1バイトを通知用の領域とし,そこに
Atomics.store()
でキー入力情報を書いたことを知らせる値を書いてから,ワーカスレッド側にAtomics.notify()
を通じて通知します - ワーカスレッド側は
Atomics.wait()
で待っておき,通知が来たら共有メモリバッファを見に行ってキー入力情報を得ます - キー入力情報を元に Vim に与えるキーシーケンスを計算し,Vim 側に JavaScript to Wasm API を使って渡します
- Vim (Wasm) は受け取ったシーケンスを入力バッファに加えます
- Vim (Wasm) は入力バッファから入力シーケンスを読んで処理し,結果として描画イベントが発生した時は適宜 JavaScript を通じてメインスレッド側に通知します.メインスレッドは受け取った描画イベントをアニメーションフレームで
<canvas/>
に描画します
本当は Atomics.wait()
で待つ前や Vim の処理中にキー入力がバッファに書き込まれた場合など考えることが他にもあります.
今回の実装の結果
良くなった点
今回の実装の結果,Emterpreter を使わず,全ての Vim のコードが Wasm にコンパイルされてブラウザ上で動かすことができるようになりました.
メンテ性の向上
emterpretify する関数のリストを持つ必要がなくなり,メンテがしやすくなりました.以前はクラッシュする関数を見つけてはリストを更新する作業があり,upstream に追従した時に削除された関数や追加された関数を反映させないといけなかったのが解消されました
プログラムの安定性が向上
特に,たびたび報告されていた JavaScript 側から emterpretify された C の関数を呼ぶ時にアサーションエラーや不正メモリアクセスエラーがなくなりました.また,文字列(char *
)を C 側に渡そうとするとクラッシュする問題が解消されました
バイナリサイズの削減
emterpretify のバイトコードが無くなり全て Wasm になったのと,Emterpreter 本体のコードが必要なくなったので,トータルのバイナリサイズがかなり小さくなりました.gzip 圧縮で,
- Before: 1025.49 KB
- After: 453.308 KB
と半分以下になりました.
ビルド速度
Emterpreter のための変換を行わなくて良くなったため,emcc
によるビルド速度が向上しました.
- Before:
emcc 48.25s user 3.55s system 309% cpu 16.745 total
- After:
emcc 4.90s user 0.51s system 112% cpu 4.792 total
数倍速くなってますね.
ユーザとのインタラクション
Vim 本体はワーカスレッドで実行されているため,メインスレッドで処理されるユーザの入力処理(スクロールやキー入力など)を Vim がブロックしないようになりました.
実行速度
以前はユーザの入力を emscripten_sleep()
で 10ms ごと polling しながら待っていたため,最大で 10ms の遅延がありました.
また,10ms ごとに Emterpreter の suspend/resume が起きていたため,おそらくそれも実行を遅くしていたと思います(未確認).
10ms ごとのポーリングをやめ,ユーザからの入力を Atomics.wait()
で待つため,メインスレッドから Atomics.notify()
されてからの遅延は無くなり,少なくともその分速く実行できるようになりました.(比べないと分からないレベルですが…)
悪くなった点
Chrome (と Chromium ベースのブラウザ)以外ではデフォルトで動かなくなりました.これは Spectre 脆弱性によって SharedArrayBuffer
や Atomics
API が一時的に多くのブラウザで無効にされているためです.Firefox や Safari ではブラウザのフィーチャフラグを立てることで動かすことができます.
今後
- 今は Web Worker を使っていますが,Wasm のマルチスレッド対応が使えるかもしれないと思っています.同時に
i32.atomic.wait
などのアトミック命令も追加されているので,JavaScript のAtomics
API の代わりに使うことで,Wasm と JavaScript 間のインタラクションを無くしてワーカスレッド側の入力待ち処理を Wasm で完結させられるかもしれません - 今は
<canvas/>
をメインスレッド側で描画していますが,ワーカスレッド側でキャンバスを描画してメインスレッド側に転送するOffscreenCanvas
を使って描画処理をワーカスレッド側に持ってくることができるかもしれません - upstream の追従作業
- 'small' や 'normal' feature でビルドできるようにする
- ファイル保存や読み込み,クリップボード対応など
感想
最初に Atomics
API を見た時は JavaScript で非同期処理ライブラリをつくるような人以外は用は無さそうだなと正直思っていたんですが,まさか自分が使うことになるとは思いませんでした…
Emterpreter の動作が不安定すぎる問題で長らく詰まってしまっていたのですが,ようやく次のステップに進めそうで良かったです.