Vim を WebAssembly に移植した

久々のブログです.

6月ぐらいにWebAssembly の仕様をざっくり読んだので,なんか WebAssembly でやりたいなと思って,Vim を WebAssembly に移植してブラウザで動くようにしてみました,という話です.

github.com

多分実物を見ていただくのが一番早いので,下記のリンクにアクセスしてみてください.

デモページはこちら(下記の注意事項を先にお読みください)

  • 注意
    • デスクトップ版の ChromeFirefoxSafari を使ってください.どうやら macOS では Safari が一番動きが良いです.
    • デモページは全部で2.5MBほどのリソースを fetch します.モバイルネットワークなどからアクセスする場合はお気をつけください.
    • keydown でキー入力を取っているので,キー入力を横取りするブラウザ拡張などが有効になっているとうまく動かないかもしれません.その場合はプライベートブラウジング機能などを使ってアクセスしてみてください.
    • 今のところ有効になっているのは 'tiny' features のみです.そのため多くの制限があります.あと描画などにまだいくつかバグがある気がします
    • 入力しても何も起きない時は,一度画面の何処かをクリックしてみてください(Vim のDOM要素がフォーカスを失っている可能性があります)
    • Mac でしかまだ試せていないので,他の OS で問題があれば教えてください…

こんな感じにページ全体に Vim の画面が表示されるはずです.これは実際にあなたのブラウザの上で動いている Vim が表示している画面なので,キー入力で自由に操作できます.

f:id:rhysd:20180709053707p:plain

今後について

まだ「とりあえず動いた」段階なので,次のようにして開発を進めていくつもりです

  • 'small' features を有効にしてビルドできるようにする.実行速度に問題が無ければ 'normal' も動かせるようにしたい
  • ブロッキングになっているイベントループを非同期に書き換えて Emterpreter(後述)を使わなくて良くする(かなりチャレンジング)
  • マウスのサポート
  • マルチバイト文字や IME のサポート
  • クリップボードのサポート
  • .vimrc をローカルストレージに保存できるようにする
  • WebComponents として wrap して,npm パッケージや ESM で配布できるようにする
  • :write で保存ダイアログ経由でローカルに保存できるようにする

どうやって実装したのか

emscripten と Binaryen

そもそも WebAssembly が何かご存じない方は,先に MDN の日本語解説ページを読んでいただけると,技術の概要が分かると思います.

C や C++LLVM IR のコードをブラウザで動かすためのコンパイラツールチェーンとしてemscriptenというものがあります.emscripten は C をコンパイルするためのコンパイラやリンカ,libc などの標準ライブラリなどから成ります. emscripten は以前は C などのコードを asm.jsコンパイルしていたのですが,今ではデフォルトで WebAssembly 形式へコンパイルするようになっており,それには binaryen というコンパイラバックエンドが使われています.

今回は emscripten と binaryen を使って C で実装されている Vim のコードを WebAssembly にコンパイルできるようにしていきます.

ちなみに Mac では brew install emscripten binaryen で一発でインストールできます.

基本的な方針

もちろん,単にコンパイラgcc から emccemscripten が提供するコンパイラgcc インターフェース)に置き換えるだけではうまくいきません.

まず,Vim は ncurses などの何らかの端末ライブラリを用いて CUI の画面を描画しますが,emscripten はどの端末ライブラリもサポートしていません.ncurses を WebAssembly に移植する(描画は <canvas/> などに任せる)のも理論的には可能ですが,膨大な作業量が必要です.

そこで,今回は WebAssembly 向けの UI を (gui_gtkgui_w32, gui_mac のような) GUI gui_wasm として実装し,gui_wasm が有効になった場合には常に GUI 版を使うことにします.これによって端末版の Vim が起動されるパスはなくなるので,ncurses は不要になります.

では,VimGUI 版はどう実装されているかが次の問題になります.これは他の GUI 実装を参考に,gui.h を眺めれば何となく分かります. gui_*.c の中に実装されたいくつかの関数が Vim のコアから呼び出されるので,それらを実装すれば良いです.Vim のコア部分から呼び出される GUI が実装すべき関数は大きく2種類あり, 1. ユーザからのインプットを待つ系(gui_mch_wait_for_chars()gui_mch_update()) 2. 描画系(特定の row/column に文字を描画したり,画面をスクロールするなど) に分けられます.なので,WebAssembly でこれらの GUI 実装関数たちをどう実装するかを考えれば良いです.具体的には 1. をユーザからのキー入力を行う DOM イベントを待つことで実装し,2. をブラウザのページ内に置いた <canvas/> などに描画する形で実装すれば良さそうです.

ですが,ここで1つ問題があります.WebAssembly は(少なくとも今は)DOM に直接アクセスするインターフェースを持っていません.なので,上記を C のレイヤーでは直接実装できません. この問題を解決するため,JavaScript で書いたランタイムを用意します.C のレイヤーから JavaScript に描画イベントを送り,JavaScript 側で描画するようにします.また,DOM のキー入力イベントを JavaScript で拾い,それを C のレイヤーに送ることで伝えるようにします. C から JavaScript のコードを呼んだり,逆に JavaScript から C のコードを呼ぶには emscripten の API を利用することができます.

あとは WebAssembly モジュールと JavaScript のランタイムを HTML ファイル内で読み込み,.wasm モジュール内にある _main 関数を呼べば Vim がスタートします.

f:id:rhysd:20180709053830p:plain

詳細な実装

具体的なビルドの流れは下記の図のようになっています.

f:id:rhysd:20180709053904p:plain

C のソースファイルが Clang でそれぞれのソースファイルの LLVM bitcode にコンパイル(&最適化)され,llvm-link で1つの LLVM bitcode ファイル vim.bc にリンクされます.さらにその vim.bc と,エントリポイントとなる HTML ファイルのテンプレート template_vim.htmlJavaScript のランタイム runtime.js,カラースキームなど最低限のファイルを合わせて emscripten が提供するコンパイラ emcc を用いてビルドすると vim.htmlvim.wasmvim.js などの 'executable' がコンパイル結果として吐き出されます.あとはそれらをHTTPサーバでホストして vim.html にアクセスすれば OK です.

実装は configure の修正から始まります.まずは何もしない空の gui_wasm.c を実装します.この時点では実装が必要な関数の中身は空っぽ(もしくは戻り値が必要な場合は単に return FAIL; とだけ書いておく)です. emscriptenコンパイラである emcc を用いてまずは空の GUI 実装が通るところまで持っていきます.emscriptenconfigure を良い感じに emcc 向けに実行してくれる emconfigure という wrapper スクリプトを提供しているのでそれを使って ./configure を走らせます.

前述したターミナルライブラリのチェック(check tlib)を無視するようにしたり,なぜか uint32_t のチェックが通らないので見なかったことにする(手元で emcc を使って再現使用すると再現しない)など,それなりのワークアラウンドsrc/configure.ac に入れていきます.

./configure が通るようになったところで,今度はビルドが通るように src/Makefile.c.h を修正していきます.emconfigure と同様に emscriptenemmake という emcc 向けにセットアップして make を走らせてくれる wrapper スクリプトがあるのでそれを使います(emmake make -j).

emscriptenLinux ライクな実行環境 (システムコール実装や libc など) を提供してくれるので,基本的には Linux 向けにビルドしてやれば良いですが,修正は必要になります. 例えば,fork (2) や PTY,シグナルは emscripten では利用できません.前者2つの機能は無効にし,シグナルは emscripten が stub を差し込むようにします.他にも WebAssembly では提供できない機能を #ifndef FEAT_GUI_WASM で囲むなどして無効にしていく地道な作業をします.

ビルドが通るようになったら,gui_wasm.c を実装していきます. 先述のとおり,描画イベントを扱う部分とユーザの入力を待つ部分があります.

前者は JavaScript の関数を呼ぶ形で実装します.JavaScript 側で呼びたい関数はプロトタイプ宣言だけしておけば emscripten がつないでくれるので,宣言を src/wasm_runtime.h に書いておきます(vimwasm_ で始まる関数群です).JavaScript 側では emscripten の API で JavaScript ライブラリのかたちで定義しておけば,C から vimwasm_* な関数を呼んだときに自動で JavaScript 側の関数につないでくれます(wasm/runtime.js).

後者のユーザの入力を待つ部分についても DOM の keydown イベントを拾ってキーの文字コード(またはバイトの special key code)を C 側の関数に渡せば,あとは C 側でバッファにそれらの入力を追加する(add_to_buf())ことで実装できます.

ただ,ここで今回最も大きい問題にあたってしまいました.VimGUI 実装に入力待ちをする関数の実装を要求します.これはブロッキングな処理です. ですが,WebAssembly や JavaScript は(今のところ)ノンブロッキングな処理しか行なえません. 要は,問題にならない範囲で sleep() を挟んでユーザの入力を良い感じに同期的に待つということが WebAssembly や JavaScript では出来ませんでした.emscriptensleep() 関数に対応していますが,なんとその実装はビジーループでした.これではブラウザで Vim を走らせると常にビジーループが回り,CPU をコア1つ専有してしまいます(実際になりました).

この問題を解決するために Emterpreter という emscripten の試験的な機能を使います.Empterpreter は emscripten_sleep() という関数を提供します.これは C の側では sleep() とほぼ同様に使うことができ,しかもビジーループするようなこともありません. これはかなり強引な(しかしこれを実装し切るのはさすが kripken さんですが)実装になっています.コンパイル時に LLVM 中間表現レベルで emscripten_sleep() など同期的な処理が必要な関数呼び出しの制御フローを直接書き換えます.emscripten_sleep() などの呼び出しを見つけると,その時点での実行情報をスタックに積んでおき,setTimeout() して一定時間後にそのスタックを resume することで処理を再開するようなコードに書き換えます. これによって C 側では同期的に sleep() しているように見える処理が,実際には setTimeout を用いた非同期なコードとして実行されます.

また,emscripten では get_char() (poll() で使われていた) の実装が,なんと window.prompt() を使って実装されているので,謎の入力ダイアログが出まくる問題もありましたが,こちらは stdinemscripten の FileSystem API を使って「もう標準入力からの入力はない」ことを示すことで解決しました.

最後に emscripten の仮想 FileSystem と files preload plugin を使ってカラースキームファイルなどを Vim から読めるようにして,とりあえずの実装を完了しました.

7/1 ぐらいから手を動かし始めたので,今日(7/8)まで,とりあえず動くようになるのに8日ほどかかった感じです.実装行数は今時点で 6832++/2276-- でした.

f:id:rhysd:20180709054115p:plain

その他困ったところ

  • cprotoemcc ではうまく動かない(代わりに gcc を使う)
  • emconfigure おそい
  • select() は実装されているが exceptfds 引数は実装されていないので使えない
  • emscripten の JS library は特殊なプリプロセスを挟んでから HTML ファイルに挿入されるので注意が必要.部分的にコンパイル時に実行されている
  • emscripten は JS library の最適化に uglifyjs を使うが,uglifyjs は ES5 までの構文しか対応していない(なぜか const は使える).
  • Emterpreter を有効にすると,JavaScript から C の関数を呼ぶ時に文字列が渡せない.JavaScript からの C 関数の呼び出しは Emterpreter がコンパイル時に変換できないので,文字列を渡そうとするとスタックを壊してしまうっぽい.Module.ccall(){ async: true } を指定しても駄目
  • Emterpreter を有効にすると,C のシグネチャJavaScriptシグネチャが合わなくなる関数が発生する
  • Emterpreter を有効にすると,vim.bc からのコンパイルの時間が長くなる
  • Emterpreter を有効にすると,部分的に Emterpreter のインタープリタが実行するバイトコードが必要になるため,バイナリサイズが膨れる

感想

WebAssembly は最近気になっているので,そのツールチェーンである emscripten を使って実際に動くものに触れて良かったです.思ったよりもちゃんと動いているなという印象でした.

また,Vim のコードの GUI 部分についてもかなり理解が深まりました.普段端末の Vim を使っているので,この辺りを読む機会はほぼ無かったのですが,今回良い勉強になりました.

Vim のコード自体は何十万行もあるためそれをブラウザに移植するのはかなり大変ですが,emscripten や webassembly などの巨人の肩に乗ることで,わずか数千行で動くところまで持っていけるというのは,巨人の大きさを感じさせられて良い体験でした.