every Tech Blog

株式会社エブリーのTech Blogです。

Go WASMをJavaScriptの代わりに使うべきケースとは?

開発2部の内原です。

Goは静的型付けで事前コンパイルされる言語なので、WebAssembly(WASM)にコンパイルしておけば、JavaScriptのJust-In-Time(JIT)コンパイルより速度的に有利であるように思えます。

なんとなくGoをWASMにすればJSより速くなるくらいのふわっとした認識でいましたが、果たしてどのような実装でも速くなるのかそうでないのか、速くなるとしたらどれくらいの差が出るのか、という疑問を持ったので調べてみました。

そこで、いくつかのアルゴリズムで実際にベンチマークを取って検証してみましたが、アルゴリズムの特性によって結果が様々であることがわかりました。

事前準備

実行環境

  • MacOS 26.2
  • Go 1.25
  • Node.js v25
  • Chrome (144)

Go WASM のビルドと関数公開

Go側の関数公開は以下のように js.FuncOf でラップしてグローバルに登録します。

import "syscall/js"

func main() {
    js.Global().Set("goAdd", js.FuncOf(func(this js.Value, args []js.Value) any {
        n1 := args[0].Int()
        n2 := args[1].Int()
        return add(n)
    }))

    select {}
}

Go側では syscall/js パッケージを使って関数をグローバルに公開し、以下のコマンドでWASMバイナリをビルドします。

$ GOOS=js GOARCH=wasm go build -o main.wasm main.go
$ ls -lh main.wasm
-rwxr-xr-x@ 1 uchihara  staff   2.1M Feb 11 16:00 main.wasm

生成されるWASMバイナリのサイズは約2MBです。Goランタイムが含まれるため、それなりのサイズになります。

JS側では WebAssembly.instantiateStreaming でWASMをロードし、go.run(instance) を呼ぶと、上記で登録した関数がグローバルから呼び出せるようになります。

const go = new Go();
const { instance } = await WebAssembly.instantiateStreaming(
  fetch("main.wasm"), go.importObject
);
go.run(instance);

const r = goAdd(1, 2);

計測方法

  • ブラウザ版とCLI版(Node.js)の両方で計測(ただし一部を除いて性能差はさほど出なかった)
  • 各テストは複数回計測の平均を採用

ベンチマーク関数は以下です。

function bench(fn, args, iters) {
  const times = [];
  for (let i = 0; i < iters; i++) {
    const start = performance.now();
    fn(...args);
    const end = performance.now();
    times.push(end - start);
  }
  return times.reduce((a, b) => a + b, 0) / times.length;
}

フィボナッチ関数

まずはシンプルなフィボナッチ数列の計算で比較しました。その際、関数呼び出しのオーバーヘッドが性能に影響を与える可能性があると考えたため、再帰版とループ版の2パターンで確認します。

Go側とJS側でほぼ同一のロジックを実装しています。

func goFibRecursive(n int) int {
    if n <= 1 {
        return n
    }
    return goFibRecursive(n-1) + goFibRecursive(n-2)
}

func goFibIterative(n int) int {
    if n <= 1 {
        return n
    }
    a, b := 0, 1
    for i := 2; i <= n; i++ {
        a, b = b, a+b
    }
    return b
}
function jsFibRecursive(n) {
  if (n <= 1) return n;
  return jsFibRecursive(n - 1) + jsFibRecursive(n - 2);
}

function jsFibIterative(n) {
  if (n <= 1) return n;
  let a = 0, b = 1;
  for (let i = 2; i <= n; i++) {
    [a, b] = [b, a + b];
  }
  return b;
}

再帰版 fibRecursive(40)

実装 実行時間 倍率
JavaScript 660ms 1.0x
Go WASM 1,560ms 2.4x

ループ版 fibIterative(10000000)

実装 実行時間 倍率
JavaScript 9ms 1.0x
Go WASM 15ms 1.7x

CLI版だとどちらのパターンでもJSのほうが高速という結果になりました。 ただブラウザ版だとGo WASMのほうが3倍ほど速くなっていました。JSエンジンの最適化による差分かもしれません。

原因分析

フィボナッチ計算は計算自体が軽量で、関数呼び出しのオーバーヘッドが支配的になります。JITコンパイラはこの種のシンプルな数値計算を最適化している可能性が考えられます。

どうやら「WASMにすれば速くなる」という単純な話でもなさそうです。

行列乗算

計算量をもう増やせば差が出るかもと考えたので、比較的計算量の大きい512x512の行列乗算で試してみました。

Go/JS双方でikjループ順を使い、同一の決定的データを生成して計算しています。 Go側は[]float64 スライスを使い、JS側では Float64Array を使っています。

func goMatMul() {
    n := 512
    a := make([]float64, n*n)
    b := make([]float64, n*n)
    for i := 0; i < n*n; i++ {
        a[i] = float64(i%97) * 0.01
        b[i] = float64(i%89) * 0.01
    }
    c := make([]float64, n*n)
    for i := 0; i < n; i++ {
        for k := 0; k < n; k++ {
            aik := a[i*n+k]
            for j := 0; j < n; j++ {
                c[i*n+j] += aik * b[k*n+j]
            }
        }
    }
    sum := 0.0
    for _, v := range c {
        sum += v
    }
    return sum
}
function jsMatMul() {
  const n = 512;
  const a = new Float64Array(n * n);
  const b = new Float64Array(n * n);
  for (let i = 0; i < n * n; i++) {
    a[i] = (i % 97) * 0.01;
    b[i] = (i % 89) * 0.01;
  }
  const c = new Float64Array(n * n);
  for (let i = 0; i < n; i++) {
    for (let k = 0; k < n; k++) {
      const aik = a[i * n + k];
      for (let j = 0; j < n; j++) {
        c[i * n + j] += aik * b[k * n + j];
      }
    }
  }
  let sum = 0;
  for (let i = 0; i < n * n; i++) sum += c[i];
  return sum;
}
実装 実行時間 倍率
JavaScript 190ms 1.0x
Go WASM 208ms 1.1x

差は縮まりましたが、まだ若干JSが優勢です。

原因分析

JS側ではTypedArrayに対して最適化が行われている可能性があります。 またGo WASM側では以下箇所がオーバーヘッドになっている可能性があります。

  • WASMではSIMD命令を十分に活用できない?
  • Goのスライスにおけるbounds checkのコストがある?

行列乗算は計算量が大きいためJS-WASM境界のオーバーヘッドは相対的に小さくなりますが、依然としてJSが有利でした。

SHA-256

計算量をもっと増やすと変化が出てくるかもと考えたので、SHA-256関数を利用することにします。

その際、JSによる純粋な実装よりネイティブAPIによる実装のほうが効率的である可能性が高いと考えたため、SubtleCrypto:digest()も比較対象に含めました。

ただ、SubtleCrypto:digest()は非同期関数であり、ベンチ時に同期的に呼び出しを行う必要がある点に注意が必要でした。

チェインハッシュ

小さなデータのハッシュ結果を次のハッシュの入力にする、という処理を10万回繰り返しました。

実装 実行時間 倍率
Go WASM 41ms 1.0x
JS 純粋実装 114ms 2.8x
SubtleCrypto 200ms 4.9x

Go WASMがJS純粋実装の約2.8倍速く、最速という結果になりました。また、SubtleCryptoはさらに遅いという結果になりました。

Go WASMが速い理由

SHA-256はビット演算・整数演算が中心のアルゴリズムで、Goのコンパイル済みWASMコードが有利な立場だったと言えそうです。また、10万回のハッシュ計算を1回の関数呼び出しでWASM内で完結させている点で、呼び出しオーバーヘッドの影響がほぼなく、効率的だったと考えられます。

js.Global().Set("goSHA256", js.FuncOf(func(this js.Value, args []js.Value) any {
    data := []byte(args[0].String())
    iterations := args[1].Int()
    h := sha256.Sum256(data)
    for i := 1; i < iterations; i++ {
        h = sha256.Sum256(h[:])
    }
    return hex.EncodeToString(h[:])
}))

JS-WASM境界を跨ぐのは最初の呼び出しと結果の返却の1往復だけで、ループ全体がWASM内で実行されます。これにより crypto/sha256 標準ライブラリの実装がそのまま適用されます。

フィボナッチではJS側から関数を1回呼ぶという意味では同じでしたが、計算自体が軽いためランタイムオーバーヘッドが目立ちました。SHA-256チェインでは1回の呼び出しの中で重い計算を行うため、オーバーヘッドが相対的に無視できるようになります。

SubtleCryptoが遅い理由

ネイティブAPIの crypto.subtle.digest() が最も遅い結果となりました。

async function jsSHA256Subtle(str, iterations) {
  const encoder = new TextEncoder();
  let data = encoder.encode(str);
  data = new Uint8Array(await crypto.subtle.digest("SHA-256", data));
  for (let i = 1; i < iterations; i++) {
    data = new Uint8Array(await crypto.subtle.digest("SHA-256", data));
  }
  return Array.from(data, b => b.toString(16).padStart(2, "0")).join("");
}

SubtleCrypto はasync APIのみを提供しているため、10万回のチェインハッシュでは毎回Promise生成 → microtask enqueue → await復帰を繰り返します。ハッシュ計算自体よりも非同期ディスパッチのコストが支配的になっているようです。

巨大バッファハッシュ

SubtleCryptoは大きなデータを一括処理するケースで優位であることが予想されます。64MBのバッファを1回だけハッシュする形式に変更して計測しました。

async function jsSHA256BulkSubtle(size) {
  const data = new Uint8Array(size);
  for (let i = 0; i < size; i++) data[i] = i % 251;
  const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", data));
  return Array.from(hash, b => b.toString(16).padStart(2, "0")).join("");
}
実装 実行時間 倍率
SubtleCrypto 350ms 1.0x
Go WASM 430ms 1.2x
JS 純粋実装 980ms 2.8x

非同期呼び出しは1回だけなのでネイティブの速度がそのまま活かされ、SubtleCryptoが最速という結果になりました。

対して、GO WASM版にはなんらかのオーバーヘッドが存在しているのか、もしくはダイジェスト関数実装における性能差があるのかもしれません。

SubtleCryptoは大きなデータを一括処理する用途向きであり、小さなデータを繰り返しハッシュするような用途には向いていなさそう、ということがわかりました。

おまけ

計測中に興味深い現象を発見しました。Mac Chrome(144.0.7559.110)で、DevToolsを開いた状態では閉じた状態に比べてGo WASMの性能が低下するというものです。

テスト Go WASM(DevTools閉) Go WASM(DevTools開) 劣化率
再帰 fib(40) 1,550ms 3,150ms 2.0x
行列乗算 512x512 203ms 453ms 2.2x
SHA-256 チェイン 41ms 136ms 3.3x
SHA-256 64MB 428ms 1,461ms 3.4x

一方、JS側にはほとんど影響がありませんでした。

原因

DevToolsを開くと、Chromeが内部的にChrome DevTools Protocol(CDP)の Debugger.enable() を発行するようです。これによりWASMバイトコードにデバッグ用コード(ブレークポイント判定等)を挿入するため、WASMの実行速度が大幅に低下しますが、一方JSのJITコードには同等の影響があまり発生しないようです。

WASMのベンチマーク時はDevToolsを閉じた状態で行う、またはDevToolsを開いている場合にはDebuggerを無効化した状態で行う必要があることが分かりました。

まとめ

  • Go WASMがJSより優位なのは、1回の関数呼び出しで大量の計算をWASM内で完結させるパターン(SHA-256チェインなど)
  • 逆に、関数呼び出しが頻繁・計算が軽い場合は、JS JITが有利(フィボナッチなど)
  • SubtleCryptoなどのasync APIは呼び出し回数に注意が必要。大バッファの一括処理なら効果的
  • WASMのベンチマーク時はDevToolsを閉じるorDebugger無効化。そうしないと2〜3倍の劣化が発生する

「GoをWASMにすればJSより速くなる」、というのは条件次第で真偽いずれもあり得ることが分かりました。

JS-WASM境界を跨ぐ回数を最小化し、WASM内でまとまった計算を完結させる設計にすることが重要そうです。

WASMの導入を検討する際は、対象のアルゴリズムがこのパターンに合致するかを事前に見極める必要があります。