💨

Deno 2.1でWasmをimportできるようになったらしい

2024/12/05に公開

WebAssembly Advent Calendar 2024の5日目の記事になります.

https://qiita.com/advent-calendar/2024/wasm

先月,Deno 2.1がリリースされ,Wasmを直接importできるようになりました.

Deno 2.1: Wasm Imports and other enhancements

https://deno.com/blog/v2.1

従来のWasmサポート

Denoは今までもWasmをサポートしてきました.

例えば次のようなWATファイルを用意します.
このWATファイルは,ポインタと文字列長を受け取るconsole_log関数をインポートし,_start関数を実行すると"Hello, Wasm!"のポインタと文字列長をconsole_log関数に渡します.

;; main.wat
(module
  (import "env" "console_log" (func $log (param i32 i32)))
  (memory (export "memory") 1)
  (data (i32.const 0) "Hello, Wasm!")
  (func (export "_start")
    (call $log (i32.const 0) (i32.const 12))
  )
)

このWATファイルをWasmバイナリに変換し,Denoで実行する場合,次のようなコードを書くことができます.

# main.watをmain.wasmに変換
wasm-tools parse -o main.wasm main.wat
// main.ts

// Wasmインスタンスを生成
const { instance } = await WebAssembly.instantiateStreaming(
  fetch(new URL("main.wasm", import.meta.url)),
  importObject, // 後述
);

// インスタンスからエクスポートされている`_start`関数を実行
const { _start, memory } = instance.exports as {
  _start: () => void;
  memory: WebAssembly.Memory;
};

_start();

まず,ビルドしたWasmバイナリ(main.wasm)を取得し,Wasmインスタンスに変換[1]します.

インスタンスのexportsオブジェクトの中からエクスポートされている_start関数を取得し,実行します.この時,exportsオブジェクトの中身が何なのかが分からないため,型アサーションを行なっています.

importObjectは次のような実装になります.

// main.ts
const decoder = new TextDecoder();

const importObject = {
  env: {
    console_log(ptr: number, len: number) {
      console.log(
        decoder.decode(
          new Uint8Array(memory.buffer, ptr, len),
        ),
      );
    },
  },
};

このコードは実行時にローカルファイルのWasmバイナリを読み込むため,実行時に次のように--allow-readオプションをつける必要があります.

deno run --allow-read main.ts
# Hello, Wasm!

Web標準

この従来の方法では,タイプヒンティングやWasmファイルの読み込み処理を1から書く必要があるため,冗長なコードを書く必要があります.

一方でこのコードはWeb標準のAPIとなっているため,このTSのコードをJSにトランスパイルすることでそのままブラウザ上で実行できるというメリットもありました.

First-class Wasm support

Deno 2.1では,import構文を用いてWasmバイナリをインポートすることができるようになり,先ほどのTSのコードを次のように書き直すことができます.

// env.ts
let memory: WebAssembly.Memory;

const decoder = new TextDecoder();

export function console_log(ptr: number, len: number) {
  console.log(
    decoder.decode(
      new Uint8Array(memory.buffer, ptr, len),
    ),
  );
}

export default function (mem: WebAssembly.Memory) {
  memory = mem;
}
// import.ts
import init from "./env.ts";
import { _start, memory } from "./main.wasm";

init(memory);
_start(); // () => void

このコードでは,インポートモジュールの内容をenv.tsに切り出しました.このenv.tsについては後ほど解説します.

import構文を用いてWasmバイナリをインポートし,インスタンス化できるようになった

このようにDeno 2.1以降では実行に必要なコード(バイナリからインスタンスを生成する過程)の記述がなくなり,import構文だけでWasmバイナリをインスタンスとして取得できるようになりました.

- const { instance } = await WebAssembly.instantiateStreaming(
-   fetch(new URL("main.wasm", import.meta.url)),
-   importObject,
- );
- const { _start, memory } = instance.exports as {
-   _start: () => void;
-   memory: WebAssembly.Memory;
- };
+ import { _start, memory } from "./main.wasm";

Wasmバイナリを他のTSコードと同じようにインポート構文で扱えるようになったことにより,実行時に必要となっていた--allow-readオプションが不要になっています.

deno run import.ts
# Hello, Wasm!

型チェックがサポートされ,型アサーションが不要になった

また,単にインスタンスとして取得できるようになっただけでなく,Deno LSPではこのWasmの型チェックもサポートされるようになりました.そのため,次のようのようなコードは型エラーとなります.

- _start(""); // error: TS2554 [ERROR]: Expected 0 arguments, but got 1.
+ _start();   // ok

また,型チェックがサポートされたことにより,型アサーションをユーザーが明示的に書く必要がなくなりました.

インポートモジュールの指定

ここで紹介していたサンプルコードでは,envモジュールをインポートしていました.

;; main.wat
  (import "env" "console_log" (func $log (param i32 i32)))

従来のWeb標準のAPIを用いた方法では,インポートオブジェクトを経由してenvモジュールを渡していましたが,import構文ではインポートオブジェクトを渡すことができません.そのため,このWATファイルのままではenvモジュールの名前解決をすることができず,実行時エラーとなってしまいます.

これを解決する方法として,モジュールの代わりにTSファイルパスを指定する方法と,importmapを用いる2つの方法があります.

モジュールの代わりにTSファイルパスを指定する

import構文を用いる場合,次のようにWasmのインポートモジュール名をDenoで解釈可能なTSファイルパスに変更することで,Denoのランタイム側がインスタンス生成時にモジュールとしてインポートしてくれます.

-   (import "env" "console_log" (func $log (param i32 i32)))
+   (import "./env.ts" "console_log" (func $log (param i32 i32)))
deno run import.ts
# Hello, Wasm!

importmapを用いる

元のWasmバイナリを変更したくない場合,importmapを用いることでenvモジュールの名前解決を行うことができます.

// deno.json
{
  "imports": {
    "env": "./env.ts"
  }
}
deno run import.ts
# Hello, Wasm!

インポートモジュールの初期化

さて,ここでenv.tsについて話していきたいと思います.

このenv.tsは,Wasmインスタンスがエクスポートしているメモリにアクセスして文字列となるバイナリ列にアクセスしていました.

// env.ts
const decoder = new TextDecoder();

export function console_log(ptr: number, len: number) {
  console.log(
    decoder.decode(
      new Uint8Array(memory.buffer, ptr, len),
    ),
  );
}

従来のWeb標準のAPIを用いた場合,インポートオブジェクトの制御はユーザーが行なっていましたが,import構文を用いた場合,インポートオブジェクトの制御はDenoのランタイム側で行われます.

Wasmインスタンスがインポートオブジェクトにアクセスするケースは良いのですが,インポートオブジェクトからWasmインスタンスにアクセスしたい場合,次の方法を取る必要があります.

インポートモジュールに初期化関数を追加

この記事のコード例では,env.tsに初期化用の関数を用意し,その関数を経由して実行コード側で初期化するという方法をとっています.

// env.ts
let memory: WebAssembly.Memory;

export default function (mem: WebAssembly.Memory) {
  memory = mem;
}
// import.ts
import init from "./env.ts";
import { _start, memory } from "./main.wasm";

init(memory); // _start関数を実行する前に初期化する
_start();

このようにすることで,インポートモジュールを初期化することができます.

Web標準のAPIを用いた場合,次のようにWasmメモリをJS側で用意して渡すことができましたが,現時点ではインポートを用いた構文では実行時エラーとなることを確認しています.

;; main.wat
  (import "env" "memory" (memory 1))
// main.ts
const memory = new WebAssembly.Memory({ initial: 1 });

const importObject = {
  env: {
    memory,
  },
};
deno run --allow-read main.ts
# Hello, Wasm!
// env.ts
export const memory = new WebAssembly.Memory({ initial: 1 });
deno run import.ts
# error: Uncaught (in promise) LinkError: WebAssembly.Instance(): Import #0 "env" "memory": memory import must be a WebAssembly.Memory object

注意点

今回サポートされたWasmインポート,Denoを利用する上では非常に便利な機能ではあるのですが,これは現状Denoの独自拡張であることに注意が必要です.

例えば,TC39にはSource Phase Importsというプロポーザルがあり,これもWasmバイナリのインポートをサポートするものではあるのですが,これはWasmのモジュール化までは行なってくれますが,インスタンス化までは行なってくれません.

https://github.com/tc39/proposal-source-phase-imports

// e.g.
import source FooModule from "./foo.wasm";
FooModule instanceof WebAssembly.Module; // true

また,BunもWasmバイナリのインポートをサポートしているようですが,これもドキュメントを読む限りはDenoのようにインスタンス化まで行なってくれるものではないようです.

https://bun.sh/docs/bundler/loaders

Cloudflare Workersも同様にWasmバイナリをインポートできますが,これもインスタンス化までは行いません.

そのため,DenoのWasmインポートはあくまでDeno上でWasmを便利に扱えるようになったという認識に留めておく必要があります.

まとめ

以上,Deno 2.1に入ったWasmインポートについて調べてみたよ,という内容でした.

Deno限定ではありますが,JSランタイム上でWasmを試しやすい環境が増えたのは個人的に嬉しいです.

脚注
  1. Wasmを実行するにはWasmのバイナリをモジュールに変換し,モジュールからインスタンスを生成して実行する必要がありますが,instantiateStreamingfetchの結果をそのまま引数に渡すことで,インスタンス化までを一気に実行してくれるメソッドです. ↩︎

株式会社モニクル

Discussion