TypeScriptにおける`WebAssembly`

TypeScriptにおける`WebAssembly`

この記事ではTypeScriptにおけるWebAssemblyについて説明します。

TypeScript と WebAssembly の連携方法を、実践的かつ分かりやすく解説します。

YouTube Video

TypeScriptにおけるWebAssembly

WebAssembly(Wasm)はブラウザ内でネイティブに近い速度で実行できるバイナリ形式のランタイムです。TypeScript から Wasm を呼び出すことで、計算集約的な処理や、C/C++やRustなどで書かれた既存のネイティブライブラリを効率よく利用できます。

基本的な実行フロー

ここでは Wasm の基本的な実行フローを説明します。TypeScript(あるいはブラウザ)が .wasm をフェッチしてインスタンス化し、エクスポートされた関数を呼び出します。

    1. AssemblyScriptやRust、C++ などで .wasm バイナリを作成するか、すでにあるものを用意します。
    1. TypeScript(またはブラウザ)で .wasm をフェッチし、同期または非同期でインスタンス化します。
    1. エクスポートされた関数を呼び出し、必要に応じて WebAssembly.Memory を使ってメモリを共有します。

WebAssembly.instantiateStreaming

続いて、Wasm ファイルを読み込み、エクスポート関数を呼ぶ基本例を示します。ブラウザが instantiateStreaming をサポートしている必要があります。

次のコードは、サーバ上にある simple.wasm をフェッチして、関数 add を呼び出す例です。

 1// TypeScript: load-and-call.ts
 2// Fetch and instantiate a wasm module and call its exported `add` function.
 3async function run() {
 4  const response = await fetch('http://localhost:3000/simple.wasm');
 5  // Use instantiateStreaming when available for efficiency.
 6  const { instance } = await WebAssembly.instantiateStreaming(response, {});
 7  // @ts-ignore
 8  const result = instance.exports.add(2, 3);
 9  console.log('2 + 3 =', result);
10}
11run().catch(console.error);
  • instance.exports に Wasm 内の関数が格納されます。
  • TypeScript には型情報が渡らないため、@ts-ignore を付けるか独自の型定義を作成します。

AssemblyScript を使ったワークフロー

AssemblyScript は TypeScript に似た構文で Wasm を書けるため、TypeScript 開発者が入りやすい選択肢です。ここでは、簡単な関数を用意し、それを .wasm.d.ts にビルドして、TypeScript から呼び出します。

1// assembly/index.ts (AssemblyScript)
2// npm install --save-dev assemblyscript
3
4// Export a simple function to add two integers.
5export function add(a: i32, b: i32): i32 {
6  return a + b;
7}
  • ascAssemblyScript コンパイラ)を使うと .wasmファイル、およびオプションで型定義 .d.tsファイルを生成できます。ローカルで試すには npmassemblyscript を入れてビルドします。
1# build commands
2# npm install --save-dev assemblyscript
3npx asc assembly/index.ts   -o build/simple.wasm   -t build/simple.wat   --bindings esm   --exportTable   --sourceMap
4
5# optionally generate d.ts with --exportRuntime or use as-bind / loader tools

TypeScript 側から fetch して呼び出す例です。

1// ts client that loads AssemblyScript-generated wasm
2async function runAssemblyScript() {
3  const res = await fetch('http://localhost:3000/build/simple.wasm');
4  const { instance } = await WebAssembly.instantiateStreaming(res, {});
5  // @ts-ignore
6  console.log('AssemblyScript add:', instance.exports.add(10, 7));
7}
8runAssemblyScript().catch(console.error);
  • AssemblyScript はメモリ表現や文字列のやりとりに注意が必要ですが、基本的な数値計算は非常に扱いやすいです。

Rust + wasm-bindgen(現場でよく使われる強力な選択肢)

Rust で Wasm を書き、wasm-bindgen を使って JavaScriptやTypeScript との橋渡しを行う流れを説明します。ここでは簡単なフィボナッチ関数を例に、生成されたものを ES モジュールとしてインポートする方法を示します。

Rust 側で wasm-bindgen を使って関数を公開します。

 1// src/lib.rs (Rust)
 2// install wasm-pack from https://drager.github.io/wasm-pack/installer/
 3use wasm_bindgen::prelude::*;
 4
 5// Export a function to JavaScript using wasm-bindgen.
 6#[wasm_bindgen]
 7pub fn fib(n: u32) -> u32 {
 8    if n <= 1 { return n; }
 9    let mut a = 0;
10    let mut b = 1;
11    for _ in 2..=n {
12        let tmp = a + b;
13        a = b;
14        b = tmp;
15    }
16    b
17}
  • wasm-pack または wasm-bindgen CLI を使ってビルドすると、TypeScript 用の型定義と JS ラッパーが生成され、ESM としてそのまま import できます。
1# build with wasm-pack
2# install wasm-pack from https://drager.github.io/wasm-pack/installer/
3wasm-pack build --target nodejs --out-dir pkg

TypeScript 側で pkg の ES モジュールをインポートして使います。

 1// Node.js: import WASM module built with --target web
 2// import init, { fib } from '../pkg/my_wasm_module.js';
 3// Node.js: import WASM module built with --target nodejs
 4import wasm from '../pkg/my_wasm_module.js';
 5
 6async function run() {
 7  //await init();   // --target web
 8  console.log('fib(10)=', wasm.fib(10));
 9}
10
11run().catch(console.error);
  • wasm-pack は JavaScript のラッパーと .d.ts 型定義を生成するため、TypeScript からの利用がとても楽になります。wasm-packコマンドの--targetオプションにwebを指定した場合には、非同期な初期化が必要な点に注意してください。

メモリ共有の実例:配列を渡して処理する(低レベル)

Wasm と大量データをやり取りする際、ArrayBuffer を共有して効率的にデータを交換することが重要です。ここでは AssemblyScript を使った例を示しますが、Rust の wasm-bindgen でも同様の原理です。

AssemblyScript 側では、メモリに対して書き込みを行うためのエクスポート関数を用意します。たとえば、配列の各要素を2乗する処理を行う関数は次のようになります。

 1// assembly/array_ops.ts (AssemblyScript)
 2// Square values in place in the wasm linear memory starting at `ptr` for `len` elements.
 3export function square_in_place(ptr: usize, len: i32): void {
 4  // Treat memory as a pointer to 32-bit integers.
 5  for (let i = 0; i < len; i++) {
 6    let offset = ptr + (i << 2); // i * 4 bytes
 7    let value = load<i32>(offset);
 8    store<i32>(offset, value * value);
 9  }
10}

AssemblyScript で使用するメモリ設定を明示するため、次の asconfig.json を用意します。

1{
2  "options": {
3    "memoryBase": 0,
4    "importMemory": false,
5    "initialMemory": 1,
6    "maximumMemory": 10
7  }
8}
1 npx asc assembly/array_ops.ts   -o build/array_ops.wasm   -t build/array_ops.wat   --bindings esm   --exportTable   --sourceMap
  • この関数を呼ぶには、ArrayBuffer を Wasm のメモリ空間にコピーしてポインタを渡す必要があります。

以下は、TypeScript 側で WebAssembly.Memory を使い、データをコピーして関数を呼ぶ例です。

 1// TypeScript: use memory to pass array to wasm
 2async function runArrayOps() {
 3  const res = await fetch('http://localhost:3000/build/array_ops.wasm');
 4  const { instance } = await WebAssembly.instantiateStreaming(res, {});
 5  // @ts-ignore
 6  const memory: WebAssembly.Memory = instance.exports.memory;
 7  // Create a view into wasm memory.
 8  const i32View = new Int32Array(memory.buffer);
 9
10  // Example data
11  const input = new Int32Array([1, 2, 3, 4]);
12  // Choose an offset (in i32 elements) to copy data to (simple example: at index 0).
13  const offset = 0;
14  i32View.set(input, offset);
15
16  // Call wasm function: ptr in bytes, len in elements
17  // @ts-ignore
18  instance.exports.square_in_place(offset * 4, input.length);
19
20  // Read back result
21  const result = i32View.slice(offset, offset + input.length);
22  console.log('squared:', result);
23}
24runArrayOps().catch(console.error);
  • memory.buffer は共有されている線形メモリであり、できるだけコピーを減らすことで処理速度を高めることができます。また、ポインタが示す位置はバイト単位で扱われる一方、TypedArray は要素数で管理されるため、この違いを混同しないように注意する必要があります。

型安全に扱う:TypeScript の型定義を用意する

Wasm のエクスポートは JavaScript オブジェクトなので、TypeScript 側で型定義を用意すると開発が楽になります。簡単な型定義ファイル例を示します。

以下は simple.d.ts として手動で作る最小の型定義です。

1// simple.d.ts
2export function add(a: number, b: number): number;
3export const memory: WebAssembly.Memory;
  • これを tsconfig.jsontypeRoots に置くか、declare module を使うことで型チェックが効きます。wasm-pack は自動で .d.ts を生成してくれるのでそれを使うのが便利です。

実行時の初期化パターン:同期 vs 非同期

Wasm モジュールは I/O(fetch)とコンパイルが必要なので非同期初期化が一般的です。ただし、WebAssembly.Module をあらかじめキャッシュして同期的にインスタンス化するパターンもあります。

以下は、非同期で WebAssembly を初期化するための基本的なコード構成です。実際のプロジェクトでは、このパターンを使うことが推奨されています。

1// async init pattern
2async function initWasm(url: string) {
3  const res = await fetch(url);
4  const { instance, module } = await WebAssembly.instantiateStreaming(res, {});
5  return instance;
6}
  • 非同期初期化はエラー処理や遅延読み込みを柔軟に組み込みやすく、実際の開発では最も扱いやすい方法です。さらに、wasm-pack が生成するコードには init() という初期化用の API が用意されているため、この流れに慣れておくとスムーズに作業できます。

パフォーマンスの実務的注意点

以下は、パフォーマンス面で特に意識しておくと効果が大きいポイントです。TypeScript と WebAssembly を組み合わせる際の最適化として参考にしてください。

  • 関数呼び出しの頻度が非常に高い場合、JavaScript と Wasm の間で発生する呼び出しコストがボトルネックになります。可能な限りデータをまとめて渡し、一度に処理する設計にすることをおすすめします。
  • メモリの確保やコピーは、処理の負荷を増やします。共有バッファやポインタを活用して、これらを最小限に抑えるようにしましょう。
  • 浮動小数点数を扱う際は注意が必要です。TypeScript では number 型になりますが、Wasm 側の型に合わせて扱うことで正確な処理ができます。

まとめ

TypeScript と WebAssembly を組み合わせることで、ブラウザ上でもネイティブに近い高速処理が可能になります。特に計算負荷の高い処理や、既存のネイティブ資産を活かしたい場面で大きな効果を発揮します。この組み合わせは、Web アプリケーションの性能を向上させたい場合に非常に有力な選択肢です。

YouTubeチャンネルでは、Visual Studio Codeを用いて上記の記事を見ながら確認できます。 ぜひYouTubeチャンネルもご覧ください。

YouTube Video