Vim on Wasm on Web Worker on Browser with Atomics

この記事は以前の

rhysd.hatenablog.com

の続編で,WebAssembly (Wasm) にポーティングした Vim の話です.

github.com

TLDR

Wasm にコンパイルした Vim のコードを Web Worker(ワーカスレッド)の中で動かすことで,メインスレッドで行われるユーザのインタラクションをエディタがブロックしなくなりました. また,イベントループのポーリングを Atomics.wait() でやってキー入力を共有メモリバッファで受け取ることで Emterpreter を捨て,実行速度・安定性・バイナリサイズ・ビルド時間・メンテ性が向上しました.

実装:

Run Vim in Web Worker and say goodbye to Emterpreter by rhysd · Pull Request #30 · rhysd/vim.wasm · GitHub

これまでの問題点

前回の記事では 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() のように同期的に待つ関数のように使えますが,実際は一旦処理を中断して非同期に待つ処理に変換されます.emccemscripten の C コンパイラ)は emscripten_sleep() を使っている関数を直接 Wasm にコンパイルせず,Wasm の上で動くインタープリタ (Emterpreter) で実行するためのバイトコードに変換します (emterpretify).実行時にはそのバイトコードインタープリタで実行し,emscripten_sleep() の呼び出し箇所で一旦実行状態を保存(suspend)して JavaScript 側の setTimeout() を読んで実行を中断します.タイマーがコールバックを呼んだら Wasm 側に戻り,インタープリタはその中で保存しておいた実行状態を復帰(resume)して実行を再開します.

このような力技により前回はどうにか Vim を動かすことに成功しました.ですが,前回の記事にも書いたように,次の問題が残りました

  • Emterpreter で実行される関数は Wasm から直接呼び出すことができません(逆に Emterpreter から Wasm の関数は呼べる).なので,emscripten_sleep() を呼んでいる関数を呼んでいる関数,それを呼んでいる関数,... というふうに呼び出し元の関数も Wasm には直接コンパイルできず,Emterpreter のバイトコードコンパイルする必要があります.Vimsleep() が必要な関数(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^ ̄

なんと,無いと思っていた JavaScriptsleep() がこんなところにありました.

ですが,これを呼ぶとスレッドを止めてしまうので,もちろんこれをメインスレッドで呼ぶことはできません.Atomics.wait() はワーカスレッドからつまり Web Worker 内で呼ばなければならないという仕様があります.メインスレッドで呼ぶと待たず即座に例外を投げます.

// 共有メモリバッファをつくり,ワーカスレッド(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) 側に伝える
  • Vim (Wasm) 側から描画情報を受け取り,メインスレッド側に通知する

という処理を行います. vim.wasm/wasm/main.ts がメインスレッド側,vim.wasm/wasm/runtime.ts がワーカスレッド側です.

全体の構成のイメージは下図のようになっています.

全体の構成イメージ

Wasm 側は Atomics.wait で待っている間停止するので,ワーカスレッド側の onmessage コールバックが発火せず,共有メモリバッファで値をやりとりする必要があります. 逆にワーカスレッドからメインスレッドに値を渡す時は postMessage() で値を渡せます.

キー入力が発生しない時と発生するときの処理のシーケンスは下図のようになっています.

入力待ちの実行シーケンス

キー入力が発生しない時 (上段,first tick)

  1. Vim (Wasm) が起動して入力待ち状態になると,最終的に gui_mch_wait_for_chars() が呼ばれ,その中で JavaScript 側に処理を渡します(vimwasm_wait_for_event())
  2. vimwasm_wait_for_event() 内で Atomics.wait() を使って共有メモリへのメインスレッドからの変更通知を同期で待ちます
  3. 今回は通知が来ず,Atomics.wait()タイムアウトしました
  4. そのまま vimwasm_wait_for_event() を抜け,Vim (Wasm) に処理を戻します

キー入力が発生する時 (下段,next tick)

  1. vimwasm_wait_for_event() 内で Atomics.wait() を使って共有メモリへのメインスレッドからの変更通知を同期で待ちます
  2. キー入力が発生しました. KeyEvent をメインスレッド側で受け取って共有メモリバッファにキー入力情報(押されたキーの名前やキーコード,モディファイアキー情報など)を書きます
  3. 共有メモリバッファのうちの1バイトを通知用の領域とし,そこに Atomics.store() でキー入力情報を書いたことを知らせる値を書いてから,ワーカスレッド側に Atomics.notify() を通じて通知します
  4. ワーカスレッド側は Atomics.wait() で待っておき,通知が来たら共有メモリバッファを見に行ってキー入力情報を得ます
  5. キー入力情報を元に Vim に与えるキーシーケンスを計算し,Vim 側に JavaScript to Wasm API を使って渡します
  6. Vim (Wasm) は受け取ったシーケンスを入力バッファに加えます
  7. 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 脆弱性によって SharedArrayBufferAtomics API が一時的に多くのブラウザで無効にされているためです.FirefoxSafari ではブラウザのフィーチャフラグを立てることで動かすことができます.

今後

  • 今は Web Worker を使っていますが,Wasm のマルチスレッド対応が使えるかもしれないと思っています.同時に i32.atomic.wait などのアトミック命令も追加されているので,JavaScriptAtomics API の代わりに使うことで,Wasm と JavaScript 間のインタラクションを無くしてワーカスレッド側の入力待ち処理を Wasm で完結させられるかもしれません
  • 今は <canvas/> をメインスレッド側で描画していますが,ワーカスレッド側でキャンバスを描画してメインスレッド側に転送する OffscreenCanvas を使って描画処理をワーカスレッド側に持ってくることができるかもしれません
  • upstream の追従作業
  • 'small' や 'normal' feature でビルドできるようにする
  • ファイル保存や読み込み,クリップボード対応など

感想

最初に Atomics API を見た時は JavaScript で非同期処理ライブラリをつくるような人以外は用は無さそうだなと正直思っていたんですが,まさか自分が使うことになるとは思いませんでした…

Emterpreter の動作が不安定すぎる問題で長らく詰まってしまっていたのですが,ようやく次のステップに進めそうで良かったです.