JS と wasm のあいだを線形メモリで渡す #
前の記事で、MoonBit の wasm と JS のあいだでは、文字列を UTF-16 のコード単位ごとに 1 つずつ受け渡していると書いた。 遅そうに見えるが、往復の時間ではほとんど差が出ない、とも書いた。 測り直すと、そうでもなかった。
どこに時間がかかっていたか #
Node で、いちばん大きいページ(約 54 KB)を 1 回返すのに約 740 µs かかっていた。 このうち、wasm の中でページを組み立てる時間は約 345 µs で、残りの約 390 µs が受け渡しだった。 1 文字ごとに JS と wasm を行き来するので、半分以上が境界をまたぐ呼び出しに消えていた。
試した方法 #
| 方法 | いちばん大きいページ |
|---|---|
| コード単位を 1 つずつ(前の形) | 727〜766 µs |
| 1 回の呼び出しで 8 コード単位ずつ | 643〜651 µs |
| 線形メモリに UTF-16 で書く | 445〜503 µs |
| 線形メモリに UTF-8 で書く | 393〜435 µs |
| js-string builtins(Node でだけ) | 543 µs |
線形メモリに UTF-8 で書く形にした。
- MoonBit の wasm-gc の出力にも、使われていない線形メモリが 1 ページある。それを export し、inline wasm の
i32.load8_u/i32.store8で読み書きする。 - JS は要求を
TextEncoder.encodeIntoでメモリの先頭に書き、serve(長さ)を呼ぶ。wasm は応答(ステータス、ヘッダー、本文)を同じ場所に上書きし、長さを返す。 - 本文は
Uint8ArrayのままResponseに渡すので、JS の文字列を作らない。 - export は
serveとmemoryの 2 つ、import は 0 になった(前は export 4 つ、import 4 つ)。
1 つ引っかかった。new Uint8Array(memory.buffer, 0, serve(n)) と書くと、serve より先に memory.buffer を読む。
serve の中でメモリが増えると、その ArrayBuffer は detach されていて TypeError になる。
いちばん大きいページでも最初の 64 KiB に収まっていたので、しばらく気づかなかった。
js-string builtins は、MoonBit の文字列がそのまま JS の文字列になるので、いちばん素直に書ける。
ただし Workers は import した wasm をこのオプションなしでコンパイルするので、今は使えない。
workerd には、これを有効にする互換性フラグ wasm_esm_integration_builtins が入ったばかりで、試せるのはもう少し先になる。
ついでに直したこと #
- **
wasm-opt -Oz**: moon に同梱のmoon-wasm-optで、依存を増やさずにかけられた。wasm は 6.3% 小さくなった。 - **メソッド**: 前の Worker は POST にも同じページを 200 で返していた。メソッドを wasm に渡し、Go 版と同じく GET と HEAD 以外は 404 にした。
- **パスのデコード**: JS の
decodeURIComponentをやめ、wasm でデコードするようにした。/%FFのような、UTF-8 でないパスにも Go 版と同じ 404 を返す。デプロイした Worker では、/%zzのような壊れたエスケープは、Cloudflare の前段が Worker に届く前に 400 で止める。 - **キャッシュ**: 記事は wasm に埋め込んであって変わらないので、GET と HEAD への 200 の応答を、isolate の中でパスごとに持つことにした。2 回目からは wasm を呼ばない。
結果 #
| 前 | 後 | |
|---|---|---|
| いちばん大きいページ(Node) | 約 676 µs | 約 358 µs(キャッシュに当たると約 33 µs) |
worker.mjs | 65 行 | 29 行(キャッシュを入れて 53 行) |
| 起動時間 | 2 ms | 2〜3 ms |
起動時間は ms 単位でしか出ないので、差は見えなかった。 試した方法と数字は、探索メモにまとめてある。