Open4

wasm で非同期関数を呼び出す JSPI を試す

mizchimizchi

JSPI(JavaScript Promise Interface) とは

JavaScript の Promise 化された関数を wasm から呼び出す仕様。

Chrom 126 では Origin Trial なので chrome://flags から JSPI が Default になってるのを Enabled にする。

参考

https://github.com/WebAssembly/js-promise-integration/blob/main/proposals/js-promise-integration/Overview.md

まず手書き wat で asy.wat を用意して、これを wasm にコンパイルしておく。

;; wasm-tools parse asy.wat -o asy.wasm
(module
  (import "js" "init_state" (func $init_state (result f64)))
  (import "js" "compute_delta" (func $compute_delta (result f64)))
  (global $state (mut f64) (f64.const 0))
  (func $init (global.set $state (call $init_state)))
  (start $init)
  (func $get_state (export "get_state") (result f64) (global.get $state))
  (func $update_state (export "update_state") (result f64)
    (local $delta f64)
    (local.set $delta (call $compute_delta))
    (global.set $state (f64.add (global.get $state) (local.get $delta)))
    (global.get $state)
  )
)

wasm 自体に JSPI を前提とした命令が拡張されているわけではない。普通に init_state, update_state を import して呼ぶだけ。

事前に cargo install wasm-tools して、 wasm-tools parse asy.wat -o asy.wasm で asy.wasm を作っておく。

これを呼び出す index.html を用意する。

<!DOCTYPE html>
<html lang="en">
<body>
  <script type="module">
    const imports = {
      js: {
        init_state: () => {
          return 1
        },
        compute_delta: new WebAssembly.Suspending(async () => {
          console.log('run:compute_delta')
          return 3
        })
      }
    }
    const { instance, module } = await WebAssembly.instantiateStreaming(fetch('./asy.wasm'), imports);
    const update_state = WebAssembly.promising(instance.exports.update_state);
    console.log('call update_state');
    console.log(await update_state());
  </script>
</body>

</html>

なにかしらでサーバー立てて、 (自分は手癖で npx http-server -c-1 ./ ) これを実行すると、 update_state の返り値がちゃんと取れる。

何が起きているか

wasm 側は非同期化されたことを意識する必要はない。

JS側は非同期化する関数を WebAssembly.Suspending でラップし、呼び出す際は WebAssembly.promising でラップした関数で呼び出す

// 関数定義側
        compute_delta: new WebAssembly.Suspending(async () => {
          console.log('run:compute_delta')
          return 3
        })

// 呼び出し側
    const update_state = WebAssembly.promising(instance.exports.update_state);
    console.log(await update_state());

おそらく wasm のスタックマシンの実行を停止して、WebAssembly.promising 化された関数はそれを resume している。
という予想ができたのでちゃんと仕様を読む

mizchimizchi

https://github.com/WebAssembly/js-promise-integration/blob/main/proposals/js-promise-integration/Overview.md

Google 翻訳

JavaScript Promise Integration (JSPI) API は、同期 WebAssembly アプリケーションと非同期 Web API の間のギャップを埋める API です。これは、WebAssembly アプリケーションによって発行された同期呼び出しを非同期 Web API 呼び出しにマッピングし、アプリケーションを一時停止し、非同期 I/O 操作が完了したら再開することで実現します。重要なのは、WebAssembly アプリケーション自体にほとんど変更を加えることなくこれを実現できることです。

関数promisingとSuspendingオブジェクトはペアを形成します。WebAssembly の計算がインポートの呼び出しによって中断された場合、最初に続行されるのはエクスポートSuspendingの呼び出しです。つまり、エクスポートの呼び出しは、最初のインポートの呼び出しによって WebAssembly コードが中断されたときに終了します。

だいたい理解あってそう。

マルチスレッドアプリケーション (これもレスポンシブになる場合があります) は考慮しないことに注意してください。一度にアクティブになる計算は 1 つだけで、他の計算はすべて中断されます。

VM自体から停止するから、マルチスレッド処理はスコープ外

mizchimizchi

まとめ

  • wasm 側を一切弄らずに非同期関数を import させることができる。
  • WebAssembly.promising 化された関数を呼んだとき、非同期関数の解決まで Wasmランタイムが完全に停止させるので、一つの処理が VM を Suspend させたときは他の非同期処理を呼ぶことができない。
  • wasm 自体の Suspend 制御が定義されたわけではないので、wasm 側から停止/再開をホストに指示できるわけではない(イベントループが作れるわけではない)
mizchimizchi

挙動を確認するために、update_state をいじる。

update_state は呼ばれるたびに state を +3 する

// 100ms 止める
        compute_delta: new WebAssembly.Suspending(async () => {
          await new Promise(resolve => setTimeout(resolve, 100));
          console.log('run:compute_delta')
          return 3
        })
// 呼び出し
    const update_state = WebAssembly.promising(instance.exports.update_state);
    const p1 = update_state();
    const p2 = update_state();
    console.log(await update_state(), await p1, await p2);

結果は 10 4 7 。並列処理ではないが、呼び出し順にキューイングされて解決されてる。例外になるわけではない。