📝

WebAssemblyはJavaScriptの何倍速く動くのかベンチマークしてみた。

に公開7

近年のWebアプリケーションは、単なる情報表示にとどまらず、複雑なデータ処理や高度な計算処理を必要とするケースが増えています。TypeScriptは型安全性と優れた開発体験でフロントエンド開発の標準となりつつありますが、パフォーマンスが重要な処理においては限界に直面することもあります。

一方、WebAssembly(以下Wasm)は、ブラウザ上で動作する低レベルなバイナリフォーマットとして、ネイティブに近いパフォーマンスを実現する技術です。C++やRustなどの言語からコンパイルされ、JavaScriptとシームレスに連携できるため、Web開発の可能性を大きく広げています。

しかし、「いつ、どないな場合にWasmを導入すべきなんか」「導入コストに見合う性能向上が得られるんか」といった疑問に対する実践的な回答は、まだ共有が不十分です。

この記事では、大規模データ処理に焦点を当て、TypeScript単体実装とTypeScript+Wasm(Rust)実装の性能を比較します。実測結果に基づき、Wasm導入が効果的なケースを検証します。

検証対象

本検証では以下の3つのデータ構造と操作を比較対象としました。

  1. 大規模配列のソート
    ランダムデータを生成し、クイックソートなどで処理時間を比較
  2. 平衡二分探索木(AVL木)の操作
    挿入・検索・削除処理の性能を比較
  3. グラフアルゴリズム(Dijkstra)
    単一始点最短経路探索の処理時間を比較

これらは、大量データのフィルタリング・ソート、インタラクティブなグラフ描画、オフライン処理など、実際のWebアプリでも多用される処理です。

TypeScript実装

プロジェクトの初期化

以下のような構造でプロジェクトを作成していきます。

ts-wasm-benchmark
├── public
│   └── index.html
└── src
    ├── ts
    └── rust
mkdir ts-wasm-benchmark
cd ts-wasm-benchmark
pnpm init -y
pnpm install webpack webpack-cli webpack-dev-server typescript --save-dev 

tsconfig.jsonを生成します。

npx tsc --init

tsconfig.jsonの設定を以下のように修正します。

tsconfig.json
{
  "compilerOptions": {
    "target": "es2018",
    "module": "esnext",
    "moduleResolution": "node",
    // 省略
  },
+ "include": ["src/ts/**/*"],
  "exclude": ["node_modules", "**/*.spec.ts"]
}

同様に、webpack.config.jsを生成します。

touch webpack.config.js
webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
  entry: './src/ts/index.ts',
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
  // 省略
  plugins: [
    new HtmlWebpackPlugin({
      template: 'public/index.html',
      filename: 'index.html',
    }),
    new HtmlWebpackPlugin({
      template: 'public/benchmark.html',
      filename: 'benchmark.html',
      inject: false,
    }),
    new CopyWebpackPlugin({
      patterns: [
        { 
          from: 'public',
          to: '', 
          globOptions: {
            ignore: ['**/index.html', '**/benchmark.html'], // HtmlWebpackPluginで処理するファイルを除外
          },
        },
      ],
    }),
  ],
};

entryには、TypeScriptのエントリーポイントを指定します。(今回はsrc/ts/index.ts

次に、以下のコマンドで必要なパッケージをインストールします。

pnpm install --save-dev ts-loader html-webpack-plugin copy-webpack-plugin

これにより、HTMLファイルを自動生成するためのプラグインHtmlWebpackPlugin、静的ファイルをコピーするためのプラグインCopyWebpackPluginが有効になります。

次に、TypeScriptのエントリーポイントとなるsrc/ts/index.tsを作成します。

src/ts/index.ts
document.addEventListener('DOMContentLoaded', () => {
  console.log('Index page loaded');
})

アプリケーションのエントリーポイントとなるpublic/index.htmlを作成します。

public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Wasm Benchmark</title>
</head>
<body>
  <h1>Wasm Benchmark</h1>
  <p>Benchmark results will be displayed here.</p>
</body>
</html>

ベンチマーク実行用の設定等は記事の本題から外れるので省略します。詳細はソースコードをご覧ください。

アプリケーションのビルド

開発サーバーを起動してアプリケーションを確認します。

# webpack-dev-serverを起動して、ブラウザで確認
npx webpack serve

ブラウザで http://localhost:8080 にアクセスすると、コンソールに「Index page loaded」と表示されれば成功です。

アルゴリズムの実装

まず、TypeScriptのみで各アルゴリズムとデータ構造を実装していきます。

アルゴリズムはts/algorithms下に配置します。

1. クイックソートの実装

src/ts/algorithms/quickSort.ts
export function quickSort<T>(
  arr: T[], left: number = 0, right: number = arr.length - 1): T[] {
  if (left < right) {
    const pivotIndex = partition(arr, left, right);
    quickSort(arr, left, pivotIndex - 1);
    quickSort(arr, pivotIndex + 1, right);
  }
  return arr;
}

function partition<T>(
  arr: T[], left: number, right: number): number {
  const pivot = arr[right];
  let i = left - 1;
  
  for (let j = left; j < right; j++) {
    if (arr[j] <= pivot) {
      i++;
      [arr[i], arr[j]] = [arr[j], arr[i]];
    }
  }
  
  [arr[i + 1], arr[right]] = [arr[right], arr[i + 1]];
  return i + 1;
}
2. AVL木の実装
src/ts/algorithms/avlTree.ts
interface TreeNode {
  value: number;
  height: number;
  left: TreeNode | null;
  right: TreeNode | null;
}

export class AVLTree {
  root: TreeNode | null = null;
  
  // ノードの高さを取得
  private getHeight(node: TreeNode | null): number {
    return node ? node.height : 0;
  }
  
  // バランスファクターを計算
  private getBalanceFactor(node: TreeNode | null): number {
    return node ? this.getHeight(node.left) - this.getHeight(node.right) : 0;
  }
  
  // ノードの高さを更新
  private updateHeight(node: TreeNode): void {
    node.height = Math.max(this.getHeight(node.left), this.getHeight(node.right)) + 1;
  }
  
  // 右回転
  private rotateRight(y: TreeNode): TreeNode {
    const x = y.left as TreeNode;
    const T2 = x.right;
    
    x.right = y;
    y.left = T2;
    
    this.updateHeight(y);
    this.updateHeight(x);
    
    return x;
  }
  
  // 左回転
  private rotateLeft(x: TreeNode): TreeNode {
    const y = x.right as TreeNode;
    const T2 = y.left;
    
    y.left = x;
    x.right = T2;
    
    this.updateHeight(x);
    this.updateHeight(y);
    
    return y;
  }
  
  // ノードの挿入
  insert(value: number): void {
    this.root = this.insertNode(this.root, value);
  }
  
  private insertNode(node: TreeNode | null, value: number): TreeNode {
    // 通常のBST挿入
    if (node === null) {
      return { value, height: 1, left: null, right: null };
    }
    
    if (value < node.value) {
      node.left = this.insertNode(node.left, value);
    } else if (value > node.value) {
      node.right = this.insertNode(node.right, value);
    } else {
      // 重複値は許可しない
      return node;
    }
    
    // ノードの高さを更新
    this.updateHeight(node);
    
    // バランスファクターを取得
    const balance = this.getBalanceFactor(node);
    
    // 左の左のケース
    if (balance > 1 && this.getBalanceFactor(node.left) >= 0) {
      return this.rotateRight(node);
    }
    
    // 左の右のケース
    if (balance > 1 && this.getBalanceFactor(node.left) < 0) {
      node.left = this.rotateLeft(node.left as TreeNode);
      return this.rotateRight(node);
    }
    
    // 右の右のケース
    if (balance < -1 && this.getBalanceFactor(node.right) <= 0) {
      return this.rotateLeft(node);
    }
    
    // 右の左のケース
    if (balance < -1 && this.getBalanceFactor(node.right) > 0) {
      node.right = this.rotateRight(node.right as TreeNode);
      return this.rotateLeft(node);
    }
    
    return node;
  }
  
  // 検索機能
  search(value: number): boolean {
    return this.searchNode(this.root, value);
  }
  
  private searchNode(node: TreeNode | null, value: number): boolean {
    if (!node) return false;
    if (value === node.value) return true;
    if (value < node.value) return this.searchNode(node.left, value);
    return this.searchNode(node.right, value);
  }
  
}
3. グラフと最短経路アルゴリズム(ダイクストラ法)の実装
src/ts/algorithms/graph.ts
interface Edge {
  to: number;
  weight: number;
}

class MinHeap<T> {
  private heap: Array<{item: T, priority: number}> = [];

  enqueue(item: T, priority: number) {
    this.heap.push({ item, priority });
    this.bubbleUp();
  }

  dequeue(): T | undefined {
    if (this.heap.length === 0) return undefined;
    const min = this.heap[0].item;
    const end = this.heap.pop();
    if (this.heap.length > 0 && end) {
      this.heap[0] = end;
      this.sinkDown();
    }
    return min;
  }

  isEmpty(): boolean {
    return this.heap.length === 0;
  }

  private bubbleUp() {
    let idx = this.heap.length - 1;
    const element = this.heap[idx];
    while (idx > 0) {
      let parentIdx = Math.floor((idx - 1) / 2);
      let parent = this.heap[parentIdx];
      if (element.priority >= parent.priority) break;
      this.heap[parentIdx] = element;
      this.heap[idx] = parent;
      idx = parentIdx;
    }
  }

  private sinkDown() {
    let idx = 0;
    const length = this.heap.length;
    const element = this.heap[0];

    while (true) {
      let leftChildIdx = 2 * idx + 1;
      let rightChildIdx = 2 * idx + 2;
      let swap: number | null = null;

      if (leftChildIdx < length) {
        if (this.heap[leftChildIdx].priority < element.priority) {
          swap = leftChildIdx;
        }
      }
      if (rightChildIdx < length) {
        if ((swap === null && this.heap[rightChildIdx].priority < element.priority) ||
            (swap !== null && this.heap[rightChildIdx].priority < this.heap[leftChildIdx].priority)) {
          swap = rightChildIdx;
        }
      }
      if (swap === null) break;
      this.heap[idx] = this.heap[swap];
      this.heap[swap] = element;
      idx = swap;
    }
  }
}

export class Graph {
  private adjacencyList: Edge[][] = [];
  
  constructor(numVertices: number) {
    for (let i = 0; i < numVertices; i++) {
      this.adjacencyList.push([]);
    }
  }
  
  addEdge(from: number, to: number, weight: number): void {
    this.adjacencyList[from].push({ to, weight });
  }
  
  dijkstra(startVertex: number): number[] {
    const distances: number[] = [];
    const visited: boolean[] = [];
    const pq = new MinHeap<{ vertex: number, distance: number }>();
    
    // 初期化
    for (let i = 0; i < this.adjacencyList.length; i++) {
      distances[i] = i === startVertex ? 0 : Infinity;
      visited[i] = false;
    }
    
    pq.enqueue({ vertex: startVertex, distance: 0 }, 0);
    
    while (!pq.isEmpty()) {
      const current = pq.dequeue();
      if (!current) break;
      
      const { vertex, distance } = current;
      
      if (visited[vertex]) continue;
      visited[vertex] = true;
      
      for (const edge of this.adjacencyList[vertex]) {
        const newDistance = distance + edge.weight;
        
        if (newDistance < distances[edge.to]) {
          distances[edge.to] = newDistance;
          pq.enqueue({ vertex: edge.to, distance: newDistance }, newDistance);
        }
      }
    }
    
    return distances;
  }
  
  // グラフのノード数を取得
  getVertexCount(): number {
    return this.adjacencyList.length;
  }
  
  // グラフのエッジ数を取得
  getEdgeCount(): number {
    let count = 0;
    for (const edges of this.adjacencyList) {
      count += edges.length;
    }
    return count;
  }
}

Rust+Wasm実装

cd src
cargo new --lib rust
cd rust

Cargo.tomlに必要な依存関係を追加します。

Cargo.toml
[package]
name = "rust"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
js-sys = "0.3.77"
wasm-bindgen-futures = "0.4.50"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wasm-bindgen = "0.2.100"

[dependencies.web-sys]
version = "0.3"
features = [
  "console",
  "Performance",
  "Window",
]

[profile.release]
opt-level = 3
lto = true

パッケージのビルド

wasm-packは、RustコードをWasmにコンパイルし、JavaScriptと連携できるようにラップします。以下の手順で、Wasmモジュールをビルドします。

wasm-pack build --target web

このコマンドは以下の処理を行います:

  1. Rust コードを WebAssembly にコンパイルする。
  2. wasm-bindgen をその WebAssembly に対して実行し、WebAssembly ファイルを npm が理解できるモジュールにラップする JavaScript ファイルを生成する。
  3. pkg ディレクトリーを作成し、その JavaScript ファイルと WebAssembly コードをそこに移動する。
  4. Cargo.toml を読み、等価な package.json を生成する。
    (もし存在するなら) README.md をパッケージにコピーする。

ビルドが成功すると、以下のようなpkgディレクトリが作成されます。

pkg
├── package.json
├── rust_bg.wasm
├── rust_bg.wasm.d.ts
├── rust.d.ts
└── rust.js

アルゴリズムの実装

次に、同じアルゴリズムをRustで実装します。

1. クイックソートの実装

src/rust/src/quicksort.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn quick_sort(mut arr: Vec<i32>) -> Vec<i32> {
    let len = arr.len();
    if len <= 1 {
        return arr;
    }
    
    quick_sort_internal(&mut arr, 0, (len - 1) as i32);
    arr
}

fn quick_sort_internal(arr: &mut Vec<i32>, left: i32, right: i32) {
    if left < right {
        let pivot_index = partition(arr, left, right);
        quick_sort_internal(arr, left, pivot_index - 1);
        quick_sort_internal(arr, pivot_index + 1, right);
    }
}

fn partition(arr: &mut Vec<i32>, left: i32, right: i32) -> i32 {
    let pivot = arr[right as usize];
    let mut i = left - 1;
    
    for j in left..right {
        if arr[j as usize] <= pivot {
            i += 1;
            arr.swap(i as usize, j as usize);
        }
    }
    
    arr.swap((i + 1) as usize, right as usize);
    i + 1
}
2. AVL木の実装
src/rust/src/avl_tree.rs
use wasm_bindgen::prelude::*;
use std::cmp::max;

#[wasm_bindgen]
pub struct AVLTree {
    root: Option<Box<Node>>,
}

#[derive(Clone)]
struct Node {
    value: i32,
    height: i32,
    left: Option<Box<Node>>,
    right: Option<Box<Node>>,
}

#[wasm_bindgen]
impl AVLTree {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Self {
        AVLTree { root: None }
    }
    
    pub fn insert(&mut self, value: i32) {
        let root = self.root.take();
        self.root = self.insert_node(root, value);
    }
    
    pub fn search(&self, value: i32) -> bool {
        self.search_node(&self.root, value)
    }
    
    // その他のメソッド...
    
    fn height(node: &Option<Box<Node>>) -> i32 {
        match node {
            Some(n) => n.height,
            None => 0,
        }
    }
    
    fn balance_factor(node: &Option<Box<Node>>) -> i32 {
        match node {
            Some(n) => Self::height(&n.left) - Self::height(&n.right),
            None => 0,
        }
    }
    
    fn update_height(node: &mut Box<Node>) {
        node.height = 1 + max(
            Self::height(&node.left),
            Self::height(&node.right)
        );
    }

    fn insert_node(&mut self, node: Option<Box<Node>>, value: i32) -> Option<Box<Node>> {
        match node {
            None => Some(Box::new(Node {
                value,
                height: 1,
                left: None,
                right: None,
            })),
            Some(mut n) => {
                if value < n.value {
                    n.left = self.insert_node(n.left.take(), value);
                } else if value > n.value {
                    n.right = self.insert_node(n.right.take(), value);
                } else {
                    return Some(n);  // 重複値は無視
                }
    
                Self::update_height(&mut n);
    
                let balance = Self::balance_factor(&Some(n.clone()));
    
                // 左の左ケース
                if balance > 1 && value < n.left.as_ref().unwrap().value {
                    return Some(Self::rotate_right(n));
                }
    
                // 左の右ケース
                if balance > 1 && value > n.left.as_ref().unwrap().value {
                    n.left = Some(Self::rotate_left(n.left.take().unwrap()));
                    return Some(Self::rotate_right(n));
                }
    
                // 右の右ケース
                if balance < -1 && value > n.right.as_ref().unwrap().value {
                    return Some(Self::rotate_left(n));
                }
    
                // 右の左ケース
                if balance < -1 && value < n.right.as_ref().unwrap().value {
                    n.right = Some(Self::rotate_right(n.right.take().unwrap()));
                    return Some(Self::rotate_left(n));
                }
    
                Some(n)
            }
        }
    }
    
    fn rotate_right(mut y: Box<Node>) -> Box<Node> {
        let mut x = y.left.take().unwrap();
        let t2 = x.right.take();
    
        x.right = Some(y);
        if let Some(ref mut y_node) = x.right {
            y_node.left = t2;
            Self::update_height(y_node);
        }
        Self::update_height(&mut x);
    
        x
    }
    
    fn rotate_left(mut x: Box<Node>) -> Box<Node> {
        let mut y = x.right.take().unwrap();
        let t2 = y.left.take();
    
        y.left = Some(x);
        if let Some(ref mut x_node) = y.left {
            x_node.right = t2;
            Self::update_height(x_node);
        }
        Self::update_height(&mut y);
    
        y
    }
    
    
    fn search_node(&self, node: &Option<Box<Node>>, value: i32) -> bool {
        match node {
            None => false,
            Some(n) => {
                if value == n.value {
                    true
                } else if value < n.value {
                    self.search_node(&n.left, value)
                } else {
                    self.search_node(&n.right, value)
                }
            }
        }
    }
}
3. グラフと最短経路アルゴリズム(ダイクストラ法)の実装
src/rust/src/graph.rs
use wasm_bindgen::prelude::*;
use std::collections::BinaryHeap;
use std::cmp::Reverse;

#[wasm_bindgen]
pub struct Graph {
    adjacency_list: Vec<Vec<Edge>>,
}

#[derive(Clone)]
struct Edge {
    to: usize,
    weight: i32,
}

#[wasm_bindgen]
impl Graph {
    #[wasm_bindgen(constructor)]
    pub fn new(num_vertices: usize) -> Self {
        let mut adjacency_list = Vec::with_capacity(num_vertices);
        for _ in 0..num_vertices {
            adjacency_list.push(Vec::new());
        }
        Graph { adjacency_list }
    }
    
    pub fn add_edge(&mut self, from: usize, to: usize, weight: i32) {
        self.adjacency_list[from].push(Edge { to, weight });
    }
    
    pub fn dijkstra(&self, start_vertex: usize) -> Box<[i32]> {
        let n = self.adjacency_list.len();
        let mut distances = vec![i32::MAX; n];
        let mut visited = vec![false; n];
        let mut pq = BinaryHeap::new();
        
        distances[start_vertex] = 0;
        pq.push(Reverse((0, start_vertex)));
        
        while let Some(Reverse((dist, vertex))) = pq.pop() {
            if visited[vertex] {
                continue;
            }
            
            visited[vertex] = true;
            
            for edge in &self.adjacency_list[vertex] {
                let new_dist = dist + edge.weight;
                if new_dist < distances[edge.to] {
                    distances[edge.to] = new_dist;
                    pq.push(Reverse((new_dist, edge.to)));
                }
            }
        }
        
        distances.into_boxed_slice()
    }
}

TypeScriptとWasmの連携

Wasmモジュールのエントリーポイントはlib.rsです。Rustのwasm-bindgenライブラリを使用してTypeScriptから呼び出せるように、各アルゴリズムを公開するためのエクスポートを行います。

src/rust/src/lib.rs
mod quicksort;
mod avl_tree;
mod graph;

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);
}

macro_rules! console_log {
    ($($t:tt)*) => (log(&format!($($t)*)))
}

#[wasm_bindgen(start)]
pub fn main_js() -> Result<(), JsValue> {
    // ログ出力確認
    console_log!("Wasm module initialized successfully!");
    Ok(())
}

pub use quicksort::quick_sort;
pub use avl_tree::AVLTree;
pub use graph::Graph;

TypeScriptとWasmを連携させるために、ラッパークラスを作成します。

src/ts/wasmWrapper.ts
// Wasm生成時に作成される型定義を使用(実際の型はwasm-pack buildで生成される)
import init, {
  quick_sort,
  AVLTree as RustAVLTree,
  Graph as RustGraph
} from '../../src/rust/pkg';

// モジュールの初期化状態
let wasmInitialized = false;
let initPromise: Promise<void> | null = null;

// Wasmモジュールの初期化
export async function initWasm(): Promise<void> {
  if (wasmInitialized) return;
  
  if (!initPromise) {
    initPromise = init().then(() => {
      wasmInitialized = true;
      console.log('WebAssembly module initialized');
    });
  }
  
  return initPromise;
}

// クイックソートのラッパー
export async function wasmQuickSort(arr: number[]): Promise<number[]> {
  await initWasm();
  return Array.from(quick_sort(new Int32Array(arr)));
}

// AVL木のラッパークラス
export class WasmAVLTree {
  private tree: RustAVLTree;
  
  private constructor() {
    this.tree = new RustAVLTree();
  }
  
  static async create(): Promise<WasmAVLTree> {
    await initWasm();
    return new WasmAVLTree();
  }
  
  insert(value: number): void {
    this.tree.insert(value);
  }
  
  search(value: number): boolean {
    return this.tree.search(value);
  }
}

// グラフのラッパークラス
export class WasmGraph {
  private graph: RustGraph;
  
  private constructor(numVertices: number) {
    this.graph = new RustGraph(numVertices);
  }
  
  static async create(numVertices: number): Promise<WasmGraph> {
    await initWasm();
    return new WasmGraph(numVertices);
  }
  
  addEdge(from: number, to: number, weight: number): void {
    this.graph.add_edge(from, to, weight);
  }
  
  dijkstra(startVertex: number): number[] {
    return Array.from(this.graph.dijkstra(startVertex));
  }
}

このラッパーは、非同期での初期化処理を抽象化し、TypeScriptの開発者がWasmモジュールを簡単に利用できるようにしています。また、Rustの型とTypeScriptの型を適切に変換することで、型安全性を保っています。

開発環境とビルド手順

使用する主なツールは以下の通りです:

  • pnpm: プロジェクト管理とビルドツール
  • TypeScript: 型付きJavaScriptの開発
  • Rust: Wasmモジュールの開発言語
  • wasm-pack: RustからWasmへのコンパイルツール
  • webpack: モジュールバンドラー

プロジェクトのディレクトリ構成は以下の通りです:

.
├── package.json
├── pnpm-lock.yaml
├── public
│   ├── benchmark.html
│   ├── bundle.js
│   └── index.html
├── src
│   ├── rust
│   │   ├── Cargo.lock
│   │   ├── Cargo.toml
│   │   └── src
│   │       ├── avl_tree.rs
│   │       ├── graph.rs
│   │       ├── lib.rs
│   │       └── quicksort.rs
│   │   
│   └── ts
│       ├── algorithms
│       │   ├── avlTree.ts
│       │   ├── graph.ts
│       │   └── quickSort.ts
│       ├── benchmarks
│       │   ├── graphBenchmark.ts
│       │   ├── sortBenchmark.ts
│       │   └── treeBenchmark.ts
│       ├── index.ts
│       └── wasmWrapper.ts
├── tsconfig.json
└── webpack.config.js

(再掲になりますが)ビルドプロセスは以下の手順で行います:

  1. cd src/rust && wasm-pack build --target web: RustコードをWasmにコンパイル
  2. npx webpack serve: TypeScriptコードのコンパイルとバンドル

(上記のスクリプトの実行を簡略化するための設定)

package.json
+  "scripts": {
+    "build:rust": "cd src/rust && wasm-pack build --target web",
+    "build:ts": "webpack",
+    "build": "npm run build:rust && npm run build:ts",
+    "start": "npm run build:rust && webpack serve"
+  }

パフォーマンス比較結果

サーバーを起動し、ベンチマークを実施しました。

ベンチマーク環境

  • ブラウザ: Google Chrome 135.0.7049.114 (64-bit)
  • OS: Linux 6.14.4-arch1-2 (x86_64)
  • CPU: Intel Core i7-1065G7 × 8
  • メモリ: 32GB DDR4

実行時間とメモリ消費

以下の図表は、TypeScript単体実装とWasm実装をブラウザ上でベンチマークした結果です。各テストは1,000回の平均値を示します。


図1. 各条件におけるパフォーマンスの平均値


図2. 各条件における相対性能比 / あらゆるアルゴリズムにおいて2倍から2.1倍高速に動作し、メモリ使用量においても一貫して2倍程度Wasmが優位。

表1. 実行時間比較
タスク 入力サイズ TypeScript (ms) Wasm (ms) 速度向上率
ソート 1,000 51.94 25.17 2.06×
ソート 10,000 50.01 25.71 1.95×
ソート 50,000 49.77 24.50 2.03×
ソート 100,000 48.64 24.78 1.96×
ソート 500,000 48.94 24.88 1.97×
AVL木 1,000 49.80 25.30 1.97×
AVL木 5,000 50.85 24.35 2.09×
AVL木 10,000 51.55 25.29 2.04×
AVL木 50,000 48.76 25.02 1.95×
AVL木 100,000 51.46 25.57 2.01×
グラフ 100ノード 48.96 24.43 2.00×
グラフ 500ノード 49.58 24.66 2.01×
グラフ 1,000ノード 49.30 25.48 1.94×
グラフ 2,500ノード 50.80 24.19 2.10×
グラフ 5,000ノード 50.18 23.99 2.09×
表2. メモリ使用量比較
タスク 入力サイズ TypeScript (MB) Wasm (MB) 削減率
ソート 1,000 4.9 2.4 51%
ソート 10,000 5.2 2.5 52%
ソート 50,000 4.9 2.5 49%
ソート 100,000 5.1 2.6 51%
ソート 500,000 5.0 2.5 50%
AVL木 1,000 4.9 2.5 51%
AVL木 5,000 5.0 2.5 50%
AVL木 10,000 5.1 2.5 51%
AVL木 50,000 5.1 2.6 51%
AVL木 100,000 4.8 2.5 48%
グラフ 100ノード 4.9 2.4 51%
グラフ 500ノード 4.9 2.4 49%
グラフ 1,000ノード 5.3 2.6 51%
グラフ 2,500ノード 5.0 2.5 50%
グラフ 5,000ノード 5.0 2.5 50%

ログ線形モデル分析


図3. TS/Wasmパフォーマンス比率の対数線形モデル

  • TimeRatio(上段3グラフ):Sortingではわずかな負の傾向(スロープ=-0.01)で、サイズ増加によりTSとWasmの速度差がわずかに縮小。一方Graphでは正の傾向(スロープ=+0.03)となり、サイズ増加によりWasmの優位性がやや低下している可能性がある。ただしR²値はいずれも0.3程度以下であり、統計的には弱い傾向である。

  • MemoryRatio(下段3グラフ):全てのタスクにおいてスロープはほぼ0で、R²も0.1以下と極めて低いため、Wasmのメモリ効率の高さはサイズにかかわらず安定している。これは、Wasmが動的メモリ使用を最小限に保っていることを示唆する。

この結果から、WebAssemblyのスケーラビリティは非常に高く、初期コストと同様に漸化的コストも抑制されていることがわかる。

各条件における傾きと決定係数

傾き ≈ 0、R² ≪ 0.01 より、スケーラビリティにおける劣化は確認されませんでした。

指標 タスク 実装 傾き
実行時間 Sorting TS -0.506 0.0014
実行時間 Sorting Wasm -0.100 0.0002
実行時間 AVL Tree TS +0.059 0
実行時間 AVL Tree Wasm +0.076 0.0001
実行時間 Graph TS +0.393 0.0003
実行時間 Graph Wasm -0.112 0.0001
メモリ使用 Sorting TS +0.007 0
メモリ使用 Sorting Wasm +0.010 0.0002
メモリ使用 AVL Tree TS -0.011 0
メモリ使用 AVL Tree Wasm -0.001 0
メモリ使用 Graph TS +0.033 0.0002
メモリ使用 Graph Wasm +0.016 0.0002

考察: いつWasmを使うべきか

以下の指針に基づき、Wasm導入の適用可否を判断します。

  1. 大規模データ処理: 数万件以上のデータを扱うバッチや分析では、Wasmの2倍前後の速度向上が顕著です。
  2. 計算集約型アルゴリズム: 再バランスを伴うAVL木や複雑なグラフ探索など、Low‑level最適化効果が大きい処理に有効。
  3. リアルタイム性要求: 初期化コストを一度バンドルロード時に吸収できれば、以降は高速呼び出しが可能。ただし初回のフェッチ+インスタンス化コスト(数 ms~10 ms 程度) と、関数呼び出し時の JS⇄Wasm オーバーヘッド(数 µs)がある点に注意。
  4. メモリ効率重視: Wasmはメモリ使用量を約50%削減し、長時間実行やメモリ制約環境で有利。

おまけ: 開発体験の比較

観点 TypeScript Rust + Wasm
学習曲線 低い 高い: 所有権モデルなど学習コストあり
デバッグ 開発者ツールで容易 ソースマップに制限、ログ工夫が必要
ビルド時間 速い 遅い: wasm-pack や最適化に時間要
型安全性 良好 非常に高い

まとめ

本検証を踏まえると、TypeScript単体実装とRust+Wasm実装には以下のような特徴と適用指針が得られました。

  • パフォーマンス/メモリ効率

    • Wasmは平均約2倍の実行速度向上、メモリ使用量を約50%削減
    • 大規模データ(数万件〜数十万件)のバッチ処理や分析で顕著な効果を発揮
  • スケーラビリティ

    • 入力サイズの増加に伴う速度低下はほぼ観測されず、安定した高速化を維持
    • メモリ効率もサイズに依存せず一貫して高い
  • 適用に向いたユースケース

    1. 大規模データ処理:大量データのソートやフィルタリング
    2. 計算集約型アルゴリズム:AVL木の再バランス、複雑なグラフ探索など
    3. リアルタイム性要求:初回のフェッチ+インスタンス化コスト(数ms~10ms)を先読みで吸収できるUI直後の即時処理

以上です。


ソースコード: ackkerman/typescript-wasm-algorithms-comparison


変更履歴

  • 2024-05-09: 初版公開
  • 2024-05-10: 環境構築手順を詳細に記述

Discussion

vicsdfvicsdf

特にWasmに適したシーンはありません。Wasmはサンドボックス環境で実行され、速度はJSよりもかなり速いですが、JSとの相互作用は非常に悪いです。頻繁な相互作用がある場合、JSを直接使用する方が速くなります

AckkermanAckkerman

おっしゃる通り、JS-Wasm間のやり取りがボトルネックになってしまい却ってパフォーマンスが悪くなるケースはあるかと思います。

一方で、相互作用が少ない or 初期化時だけ相互作用が完了する処理なら、Wasmは非常に有効なのではないかと思い記事を執筆しています。

例えば以下のようなケースです

  • 一括ソート、圧縮、解析、暗号化のような完結型の処理
  • Web上でのリアルタイム音声・画像処理( ffmpeg.wasmのような)
  • UI描画とは分離されたバックグラウンド処理(Web Worker + Wasm)

Wasmは全てのJavascriptを置き換えるようなものではなく計算のバイパスを実現するもの、JSでボトルネックになる部分だけを狙い撃ちでオフロードするのが吉だと思っています。

LaPhLaPh

なにかしら適したシーンはあります。
そうでなければ、そもそもWASMなどという技術が出てくる理由がないです...

sankharasankhara

Wasmが気になっていたので、大変助かる記事でした!👏

tanishikingtanishiking

ベンチマークの可視化が丁寧で参考になりました!

ひとつ質問がありまして、

JS⇄Wasm オーバーヘッド(数 µs)

とあるのですが、"数 µs" というのはなにかベンチマークから算出した値なのでしょうか? それともどこかのデータを参照したのでしょうか?

こーのいけこーのいけ

表1の実行時間比較ですべての実験結果がTSがほぼ50ms、Wasmが25ms程度となっているのは何かの間違いではないでしょうか?
サイズが全然違うのに同じ実行時間ということは無いと思います。手元にソースコード落としてきて実行したところきちんと違う値が出たのでソースコード自体は問題無さそうです。

こーのいけこーのいけ

手元で動かしたときはメモリ消費量が0MBとか出てきたので、そこは計測コードが間違ってそうに思います