3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WebAssembly入門:ブラウザ画像処理におけるGoとJSの速度比較

Posted at

京セラコミュニケーションシステム株式会社
技術開発センター ICT技術開発部 AIシステム開発課  東京AIシステムの中山です。

『WebAssemblyを使えばWebサイトが高速になる』と聞き、ずっと気になっていました。そこで今回、Go言語を使って画像のぼかし処理をWasm化し、本当にJavaScriptより速くなるのかを検証してみることにしました。
どうぞよろしくお願い致します。

記事の要約

Go言語をWebAssembly (Wasm) にコンパイルし、ブラウザ上で画像のぼかし処理を試してみました。同じ処理をJavaScriptでも実装して実行速度を比較したところ、「Wasmなら速いだろう」という予想に反して、JavaScriptの方が高速という意外な結果になりました。
なぜGoなのかですが、単純に私が使い慣れていて、パフォーマンスが期待できる言語だからです。究極のパフォーマンスを目指したわけではありません。

この記事では、GoでWasmを動かす基本的な手順から、パフォーマンス計測で明らかになった「Wasmの落とし穴」とその理由までを、初心者にも分かりやすく解説します。

この記事でわかること

  • Goで書いたコードをWebAssemblyにコンパイルする手順
  • ブラウザでWasmを読み込んで実行する基本的な方法
  • WasmとJavaScriptのパフォーマンス比較と、その結果(データコピーのオーバーヘッドなど)についての考察

想定読者

  • Webアプリの開発者でパフォーマンス向上に興味がある方
  • WebAssemblyについて簡単に把握したい方

WebAssemblyとは

Google Meetの背景ぼかしなどに使われています。(https://developers-jp.googleblog.com/2023/05/webassembly.html)
次の特徴があります。

  • ネイティブに近いパフォーマンスで動作する、コンパクトなバイナリー形式の低レベルなアセンブリー風言語
  • C/C++、C# や Rust などの言語のコンパイル先となり、それらの言語をウェブ上で実行することができる
  • JavaScript と並べて実行するように設計されており、両方を一緒に動作させることができる

ここに詳しくかいてあります。

どんなユースケースがあるか

webassembly.orgに記載があります。以下のようなユースケースが想定されています。

  • 画像・動画編集
  • ゲーム:
    • 素早い起動が求められるカジュアルゲーム
    • 大規模なアセットを持つAAA(トリプルエー)ゲーム
    • ゲームポータル(様々な提供元のコンテンツが混在するもの)
  • P2P(ピアツーピア)アプリケーション(ゲーム、共同編集、分散型および中央集権型)
  • 音楽アプリケーション(ストリーミング、キャッシング)
  • 画像認識
  • ライブビデオの拡張(例:人の頭に帽子をかぶせるなど)
  • VR(仮想現実)およびAR(拡張現実)(非常に低い遅延が求められるもの)
  • CADアプリケーション
  • 科学技術計算の可視化とシミュレーション
  • 対話型の教育ソフトウェアやニュース記事
  • プラットフォームのシミュレーション/エミュレーション(ARC、DOSBox、QEMU、MAMEなど)
  • 言語インタプリタや仮想マシン
  • POSIXユーザースペース環境。既存のPOSIXアプリケーションの移植を可能にする
  • 開発者向けツール(エディタ、コンパイラ、デバッガなど)
  • リモートデスクトップ
  • VPN
  • 暗号化
  • ローカルウェブサーバー
  • ウェブのセキュリティモデルとAPIの範囲内での、一般的なNPAPIの代替
  • エンタープライズアプリケーション(例:データベース)向けのファットクライアント

実施すること

  1. Goで画像処理ロジックを実装: 画像をぼかす処理をGoで書き、WebAssembly (Wasm) ファイルにコンパイルします
  2. JavaScriptで同じ処理を実装: Wasmと公平に比較するため、まったく同じアルゴリズムのぼかし処理をJavaScriptでも書きます
  3. 実行環境を構築: ブラウザで画像をアップロードし、WasmとJavaScriptそれぞれの処理を実行・計測するためのHTMLとUIを用意します
  4. パフォーマンスを比較・考察: 両者の処理速度を計測し、「本当にWasmは速いのか?」を検証します

最終的なファイル構成:

work/
├── wasmimg/
|    ├── go.mod
|    └── main.go
└── wasmserver/
     ├── go.mod
     ├── index.html
     ├── main.js
     ├── main.wasm
     ├── server.go
     └── wasm_exec.js

Goで画像処理ロジックを実装 (main.go)

Go言語は最新の1.25をダウンロードしました。公式サイトからインストールできます。
goのプロジェクトのディレクトリを作成します。ここでgo.modはつくられます。

pwsh

> go version  
go version go1.25.0 windows/amd64

> mkdir wasmimg   

    Directory: \work

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----          2025/09/03    14:46                wasmimg

> cd .\wasmimg\

> go mod init kccs.co.jp/wasmimg   
go: creating new go.mod: module kccs.co.jp/wasmimg

中心となる画像処理のコードをGoで書きます。受け取った画像データをぼかした画像に変換し、結果を返却します。

/wasmimg/main.go

// main.go
package main

import (
	"fmt"
	_ "image/png"
	"syscall/js"
)

func main() {
	fmt.Println("Go WebAssembly Initialized for Blur")
	// "blurImgWithTiming"という名前で、GoのblurFuncWithTimingをJavaScript側に公開する
	js.Global().Set("blurImgWithTiming", js.FuncOf(blurFuncWithTiming))

	// Goプログラムが終了しないように待機
	<-make(chan bool)
}


// 処理時間を測定してタイミング情報も返すバージョン
func blurFuncWithTiming(this js.Value, args []js.Value) any {
	imageDataJS := args[0]
	
	// ImageDataから必要なデータを取得
	width := imageDataJS.Get("width").Int()
	height := imageDataJS.Get("height").Int()
	rgbaArray := imageDataJS.Get("data")

	if width == 0 || height == 0 {
		return nil
	}
	
	// ピクセルデータをGoのスライスにコピー
	src := make([]byte, rgbaArray.Get("length").Int())
	js.CopyBytesToGo(src, rgbaArray)
	
	// radius: ボックスブラーの半径(ぼかし効果の強さ)
	// 各ピクセルを中心とした(2*radius+1) x (2*radius+1)の正方形領域の平均値を計算
	// 値が大きいほどぼかし効果が強くなる(例:radius=5 → 11x11の範囲を参照)
	radius := 5
	dst, processTimeMs := boxBlur(src, width, height, radius)	
	
	
	// 結果をJavaScript側に返すためのUint8ClampedArrayを作成
	dest := js.Global().Get("Uint8ClampedArray").New(len(dst))
	js.CopyBytesToJS(dest, dst)
	
	// 結果とタイミング情報を含むオブジェクトを作成
	result := js.Global().Get("Object").New()
	result.Set("data", dest)
	result.Set("processTime", processTimeMs)
	
	return result
}


// boxBlurは生のRGBAピクセルデータにボックスブラーを適用する関数です。
// radius: ぼかし効果の半径。各ピクセルの周囲 (2*radius+1) x (2*radius+1) の範囲の平均値を計算
func boxBlur(src []byte, width, height, radius int) ([]byte, float64) {
	dst := make([]byte, len(src))

	// 【修正点③】純粋な計算処理の直前・直後で時間を計測する
	startTime := js.Global().Get("performance").Call("now").Float()
	for y := 0; y < height; y++ {
		for x := 0; x < width; x++ {
			var r, g, b, a uint32
			var count int

			// 現在のピクセル(x,y)を中心とした正方形領域をスキャン
			// jとiは-radiusから+radiusまでの範囲で、周囲のピクセルの相対位置を表す
			for j := -radius; j <= radius; j++ {
				for i := -radius; i <= radius; i++ {
					checkX, checkY := x+i, y+j
					if checkX >= 0 && checkX < width && checkY >= 0 && checkY < height {
						idx := (checkY*width + checkX) * 4
						r += uint32(src[idx])
						g += uint32(src[idx+1])
						b += uint32(src[idx+2])
						a += uint32(src[idx+3])
						count++
					}
				}
			}

			dstIdx := (y*width + x) * 4
			dst[dstIdx] = byte(r / uint32(count))
			dst[dstIdx+1] = byte(g / uint32(count))
			dst[dstIdx+2] = byte(b / uint32(count))
			dst[dstIdx+3] = byte(a / uint32(count))
		}
	}
	endTime := js.Global().Get("performance").Call("now").Float()
	
	// 計算した処理時間と、処理結果のスライスを返す
	return dst, endTime - startTime
}

ポイント解説:

  • syscall/jsパッケージが、GoとJavaScriptの間の橋渡しをします
  • js.Global().Set(...)でGoの関数をJavaScriptの世界で使えるように公開します
  • JavaScriptとGoの間でデータをやり取りする際は、バイト配列([]byteUint8Array)を介して行い、js.CopyBytesToGo / js.CopyBytesToJS を使って相互にコピーします
  • boxBlur関数でぼかします。原理としてはradiusとしていますが、正方形領域±5ピクセルのRGBAの平均値にピクセルの値を変更しています

画像処理ロジックを呼び出すWebサーバを準備

こちらはまた別のgoのプロジェクトのディレクトリを用意します

pwsh

> cd ..
> mkdir wasmserver

    Directory: \work

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----          2025/09/03    16:12                wasmserver

> cd .\wasmserver\
> go mod init kccs.co.jp/wasmserver
go: creating new go.mod: module kccs.co.jp/wasmserver

/wasmserver/server.go

package main

import (
	"fmt"
	"log"
	"net/http"
	"strings"
)

func main() {
	// サーバーを起動するポートを指定
	port := "8080"

	// 静的ファイルを配信するためのハンドラを作成
	// http.Dir(".") は、このプログラムを実行しているカレントディレクトリを指す
	fs := http.FileServer(http.Dir("."))

	// すべてのリクエストをカスタムハンドラで処理
	http.Handle("/", addWasmHeader(fs))

	fmt.Printf("Starting server on http://localhost:%s\n", port)
	
	// サーバーを起動し、エラーがあればログに出力して終了
	if err := http.ListenAndServe(":"+port, nil); err != nil {
		log.Fatal(err)
	}
}

// addWasmHeader は、.wasmファイルに対して正しいContent-Typeヘッダを設定するミドルウェアです。
func addWasmHeader(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// リクエストされたパスの末尾が ".wasm" の場合
		if strings.HasSuffix(r.URL.Path, ".wasm") {
			// "application/wasm" というContent-Typeを設定
			w.Header().Set("Content-Type", "application/wasm")
		}
		// ファイルの配信を続行
		h.ServeHTTP(w, r)
	})
}

ポイント解説:

  • ディレクトリに存在するファイルをレスポンスします
  • .wasmのファイルについてはcontent-typeをapplication/wasmに設定します

コンパイルしてwasmファイルを作成

main.goをコンパイルします。
これで/wasmserver/main.wasmが作られます。

pwsh

wasmserver> $env:GOOS="js"; $env:GOARCH="wasm"; go build -o main.wasm ../wasmimg/main.go

ポイント解説:

  • 環境変数GOOSはコンパイル対象のOS、GOARCHはコンパイル対象のアーキテクチャを設定し、WebAssemblyのコンパイルをします

wasm_exec.jsをコピーします

wasm_exec.jsについて、公式WiKiにはCopy the JavaScript support fileと記載があり、それ以上の説明はありません。

$(go env GOROOT) はGoがインストールされているパスを返します

pwsh

wasmserver> cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" .

HTMLとJavaScriptを作成 (index.html, main.js)

次に、ユーザーが画像をアップロードし、結果を表示するためのフロントエンド部分を作成します。

/wasmserver/index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Go Wasm Image</title>
    <style>
        body { font-family: sans-serif; text-align: center; }
        canvas { border: 1px solid #ccc; margin: 10px; }
    </style>
</head>
<body>
    <h1>Go/Wasm vs JavaScript Speed Test 🚀</h1>
    <p>画像を選択した後、各ボタンで処理を実行して速度を比較します。</p>
    
    <input type="file" id="imageInput" accept="image/*" />
    <hr>
    <button id="processWasmButton" disabled>Process with Go (Wasm)</button>
    <button id="processJsButton" disabled>Process with JavaScript</button>
    
    <h3>Performance Results (ms):</h3>
    <p>
        <strong>Go/Wasm:</strong> <span id="wasmTime">N/A</span> |
        <strong>JavaScript:</strong> <span id="jsTime">N/A</span>
    </p>

    <div style="display: flex; justify-content: space-around; flex-wrap: wrap;">
        <div>
            <h2>Original</h2>
            <canvas id="originalCanvas"></canvas>
        </div>
        <div>
            <h2>Blurred (Go/Wasm)</h2>
            <canvas id="wasmCanvas"></canvas>
        </div>
        <div>
            <h2>Blurred (JavaScript)</h2>
            <canvas id="jsCanvas"></canvas>
        </div>
    </div>

    <script src="wasm_exec.js"></script>
    <script src="main.js"></script>
</body>
</html>

/wasmserver/main.js

// --- Wasmモジュールの読み込み (ここは同じ) ---
const go = new Go();
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then(
  (result) => {
    go.run(result.instance);
    console.log("Wasm module loaded.");
  }
);

// --- DOM要素の取得 ---
const imageInput = document.getElementById("imageInput");
const processWasmButton = document.getElementById("processWasmButton");
const processJsButton = document.getElementById("processJsButton");

const originalCanvas = document.getElementById("originalCanvas");
const wasmCanvas = document.getElementById("wasmCanvas");
const jsCanvas = document.getElementById("jsCanvas");

const wasmTimeEl = document.getElementById("wasmTime");
const jsTimeEl = document.getElementById("jsTime");

let originalImageData = null; // 元の画像データを保持する変数

// --- 画像が選択されたときの処理 ---
imageInput.addEventListener("change", async (e) => {
    const file = e.target.files[0];
    if (!file) return;

    // ボタンを無効化し、表示をリセット
    processWasmButton.disabled = true;
    processJsButton.disabled = true;
    wasmTimeEl.textContent = "Processing...";
    jsTimeEl.textContent = "Processing...";

    const img = new Image();
    img.src = URL.createObjectURL(file);
    img.onload = () => {
        // オリジナル画像をCanvasに描画
        const ctx = originalCanvas.getContext("2d");
        originalCanvas.width = img.width;
        originalCanvas.height = img.height;
        ctx.drawImage(img, 0, 0);
        
        // 元の画像データを取得して保持
        originalImageData = ctx.getImageData(0, 0, img.width, img.height);
        
        // 処理ボタンを有効化
        processWasmButton.disabled = false;
        processJsButton.disabled = false;
        wasmTimeEl.textContent = "Ready";
        jsTimeEl.textContent = "Ready";
    };
});

// --- Go/Wasmで処理するボタンの処理 ---
processWasmButton.addEventListener("click", async () => {
    if (!originalImageData) return;
    
    wasmTimeEl.textContent = "Processing...";
    
    // ウォームアップ実行(JITコンパイルとキャッシュの準備)
    for (let i = 0; i < 2; i++) {
        blurImgWithTiming(originalImageData);
    }
    
    // 複数回測定して平均値を取る(詳細なタイミング分析)
    const iterations = 5;
    const totalTimes = [];
    const processTimes = [];
    const copyTimes = [];
    
    for (let i = 0; i < iterations; i++) {
        // 少し待機してGCの影響を軽減
        await new Promise(resolve => setTimeout(resolve, 10));
        
        // 全体の時間測定(全てのオーバーヘッド込み)
        const totalStart = performance.now();
        const result = blurImgWithTiming(originalImageData);
        const totalEnd = performance.now();
        
        if (!result || !result.data) {
            wasmTimeEl.textContent = "Error";
            console.error("Wasm function returned no data.");
            return;
        }
        
        const totalTime = totalEnd - totalStart;
        const processTime = result.processTime; // Go側で測定された純粋な処理時間
        const copyAndOverheadTime = totalTime - processTime; // コピー時間 + JSオーバーヘッド
        
        totalTimes.push(totalTime);
        processTimes.push(processTime);
        copyTimes.push(copyAndOverheadTime);
    }
    
    // 平均値を計算
    const avgTotal = totalTimes.reduce((sum, time) => sum + time, 0) / totalTimes.length;
    const avgProcess = processTimes.reduce((sum, time) => sum + time, 0) / processTimes.length;
    const avgCopy = copyTimes.reduce((sum, time) => sum + time, 0) / copyTimes.length;
    
    wasmTimeEl.textContent = `Total: ${avgTotal.toFixed(2)}ms | Process: ${avgProcess.toFixed(2)}ms | Copy+Overhead: ${avgCopy.toFixed(2)}ms`;
    
    // 最後の結果を表示
    const finalResult = blurImgWithTiming(originalImageData);
    const resultImageData = new ImageData(finalResult.data, originalImageData.width);
    const ctx = wasmCanvas.getContext("2d");
    wasmCanvas.width = originalImageData.width;
    wasmCanvas.height = originalImageData.height;
    ctx.putImageData(resultImageData, 0, 0);
});

// --- JavaScriptで処理するボタンの処理 ---
processJsButton.addEventListener("click", async () => {
    if (!originalImageData) return;
    
    jsTimeEl.textContent = "Processing...";
    
    // ウォームアップ実行(JITコンパイルとキャッシュの準備)
    for (let i = 0; i < 2; i++) {
        boxBlurJSWithTiming(originalImageData, 5);
    }
    
    // 複数回測定して平均値を取る(詳細なタイミング分析)
    const iterations = 5;
    const totalTimes = [];
    const processTimes = [];
    const copyTimes = [];
    
    for (let i = 0; i < iterations; i++) {
        // 少し待機してGCの影響を軽減
        await new Promise(resolve => setTimeout(resolve, 10));
        
        // 全体の時間測定(全てのオーバーヘッド込み)
        const totalStart = performance.now();
        const result = boxBlurJSWithTiming(originalImageData, 5);
        const totalEnd = performance.now();
        
        const totalTime = totalEnd - totalStart;
        const processTime = result.processTime; // 純粋な処理時間
        const copyAndOverheadTime = totalTime - processTime; // コピー時間 + JSオーバーヘッド
        
        totalTimes.push(totalTime);
        processTimes.push(processTime);
        copyTimes.push(copyAndOverheadTime);
    }
    
    // 平均値を計算
    const avgTotal = totalTimes.reduce((sum, time) => sum + time, 0) / totalTimes.length;
    const avgProcess = processTimes.reduce((sum, time) => sum + time, 0) / processTimes.length;
    const avgCopy = copyTimes.reduce((sum, time) => sum + time, 0) / copyTimes.length;
    
    jsTimeEl.textContent = `Total: ${avgTotal.toFixed(2)}ms | Process: ${avgProcess.toFixed(2)}ms | Copy+Overhead: ${avgCopy.toFixed(2)}ms`;
    
    // 最後の結果を表示
    const finalResult = boxBlurJSWithTiming(originalImageData, 5);
    const ctx = jsCanvas.getContext("2d");
    jsCanvas.width = finalResult.imageData.width;
    jsCanvas.height = finalResult.imageData.height;
    ctx.putImageData(finalResult.imageData, 0, 0);
});


// --- JavaScript版のボックスブラー関数(タイミング情報付き) ---
function boxBlurJSWithTiming(imageData, radius) {
    const width = imageData.width;
    const height = imageData.height;
    const src = imageData.data; // 元のピクセルデータ (Uint8ClampedArray)
    const dst = new Uint8ClampedArray(src.length); // 結果を格納する配列

    // 純粋な処理時間を測定(Go版と同じ)
    const startTime = performance.now();
    
    for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
            let r = 0, g = 0, b = 0, a = 0;
            let count = 0;

            for (let j = -radius; j <= radius; j++) {
                for (let i = -radius; i <= radius; i++) {
                    const checkX = x + i;
                    const checkY = y + j;

                    if (checkX >= 0 && checkX < width && checkY >= 0 && checkY < height) {
                        const idx = (checkY * width + checkX) * 4;
                        r += src[idx];
                        g += src[idx + 1];
                        b += src[idx + 2];
                        a += src[idx + 3];
                        count++;
                    }
                }
            }
            
            const dstIdx = (y * width + x) * 4;
            dst[dstIdx] = r / count;
            dst[dstIdx + 1] = g / count;
            dst[dstIdx + 2] = b / count;
            dst[dstIdx + 3] = a / count;
        }
    }
    
    const endTime = performance.now();
    const processTime = endTime - startTime;
    
    return {
        imageData: new ImageData(dst, width, height),
        processTime: processTime
    };
}

ポイント解説:

  • wasm_exec.jsを先に読み込み、Goのインスタンスを生成します
  • WebAssembly.instantiateStreaming.wasmファイルを非同期で読み込み、実行します
  • Goから返ってきたバイト配列をBlobに変換し、URLを生成してImageオブジェクトとして読み込み、Canvasに描画します
  • 5回実施した平均時間を出力します
  • 純粋にぼかしの時間を出すようにしています

実行

ローカルサーバーを立ててブラウザで確認します。

pwsh

wasmserver> go build        
wasmserver> .\wasmserver.exe
Starting server on http://localhost:8080

ブラウザでhttp://localhost:8080にアクセスします。以下が表示されます。

Webアプリ起動


次にファイルを選択し、「Process with Go」と「Process with Javascript」を押します。

Webアプリresult

結果がでました。5回実施した平均になるのですが、Wasmの実行時間が12.00ms、Javascriptで9.62smsでWasmの方が遅いという意外な結果になりました。データ転送のオーバーヘッドは発生すると考えて除外した結果でそうなっています。

考察:なぜ純粋な計算速度でもJavaScriptが勝ったのか?

調べてみたのですが以下の要因がありそうなことは分かりました。

  • 現代のJavaScriptエンジンはコードを実行しながらリアルタイムで最適化を行うようになっており、高速になっている
  • 今回のぼかし処理のような、単純な数値計算の繰り返しでは実行時に最適化されたJavascriptが汎用的にコンパイルされたwasmに勝つことがある

追加検証:パフォーマンス差の要因を探る(少し難しい)

さらに一歩踏み込み、パフォーマンス差の直接的な要因となりうる具体的な技術について、仮説を立てて検証します。

仮説:SIMD命令の有無が実行速度を左右した

以下の仮説を立てました。

「JavaScriptエンジンは、JITコンパイラによる最適化の過程でSIMD命令を生成して処理を並列化しているが、GoからコンパイルされたWasmバイナリはSIMD命令を利用していない。この有無が、今回の性能差の主な要因である。」

SIMD(Single Instruction, Multiple Data) とは、単一の命令で複数のデータを同時に処理する技術です。今回の画像処理では、各ピクセルのRGBA(4つの数値)に対する計算が繰り返し発生します。SIMDを利用できれば、これらの計算をまとめて並列処理できるため、大幅な高速化が期待できます。

この仮説を裏付けるため、それぞれの実行コードが低レベルでどのように処理されているかを分析します。

検証1:JavaScript側の実行コード分析

まず、JavaScript側のコードがSIMD化されているかを確認します。Node.jsのV8エンジンがJITコンパイル時に生成するアセンブリコードを出力します

1. Javascriptの関数を切り出す

boxBlurJSWithTimingの関数を取り出し、以下のような別ファイルにします。

test_blur.js

// node環境にimageDataはないので、代替
if (typeof ImageData === 'undefined') {
  global.ImageData = class ImageData {
    constructor(data, width, height) {
      this.data = data;
      this.width = width;
      this.height = height;
    }
  };
}

function boxBlurJSWithTiming(imageData, radius) {
    const width = imageData.width;
    const height = imageData.height;
    const src = imageData.data; // 元のピクセルデータ (Uint8ClampedArray)
    const dst = new Uint8ClampedArray(src.length); // 結果を格納する配列

    // 純粋な処理時間を測定(Go版と同じ)
    const startTime = performance.now();
    
    for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
            let r = 0, g = 0, b = 0, a = 0;
            let count = 0;

            for (let j = -radius; j <= radius; j++) {
                for (let i = -radius; i <= radius; i++) {
                    const checkX = x + i;
                    const checkY = y + j;

                    if (checkX >= 0 && checkX < width && checkY >= 0 && checkY < height) {
                        const idx = (checkY * width + checkX) * 4;
                        r += src[idx];
                        g += src[idx + 1];
                        b += src[idx + 2];
                        a += src[idx + 3];
                        count++;
                    }
                }
            }
            
            const dstIdx = (y * width + x) * 4;
            dst[dstIdx] = r / count;
            dst[dstIdx + 1] = g / count;
            dst[dstIdx + 2] = b / count;
            dst[dstIdx + 3] = a / count;
        }
    }
    
    const endTime = performance.now();
    const processTime = endTime - startTime;
    
    return {
        imageData: new ImageData(dst, width, height),
        processTime: processTime
    };
}

// --- ここからテスト用のコード ---

// ダミーの画像データを作成
console.log("Preparing mock image data...");
const width = 500;
const height = 500;
const mockImageData = {
    width: width,
    height: height,
    // 500x500x4 (RGBA) のサイズのダミー配列を作成
    data: new Uint8ClampedArray(width * height * 4) 
};
// 配列を適当な値で埋める
for (let i = 0; i < mockImageData.data.length; i++) {
    mockImageData.data[i] = i % 256;
}

// JITコンパイラが最適化するのに十分な回数、関数を実行する
console.log("Running blur function to trigger JIT optimization...");
for (let i = 0; i < 100; i++) {
    boxBlurJSWithTiming(mockImageData, 5);
}

console.log("Test finished.");

2. 以下のコマンドでアセンプリコードを取得します

pwsh

wasmserver> node --print-opt-code test_blur.js > output.txt

output.txtは大きいのでこちらには提示しません。xmm0xmm1といったXMMレジスタの使用が多数見られました。XMMレジスタはSIMD命令セットで利用される専用のレジスタであり、これが使用されていることは、V8エンジンがコードをSIMD命令に変換して実行していることを示しています。

検証2:WebAssembly側のバイナリ分析

次に、Goから生成されたmain.wasmファイルを分析します。wabtツールキットに含まれるwasm2watコマンドを使用し、バイナリを人間が読めるテキスト形式(WAT)に変換します。

1. 公式ページからツールをダウンロードします

2. wabt-x.x.x-xxx.tar.gzをダウンロードして解凍します

3. wasm2wat.exeというファイルが得られるので、それをwasmファイルのディレクトリに置きます

4. 以下のコマンドを実行し、main.watが以下で得ます。これがwat形式にしたファイルでこちらを確認します

pwsh

wasmserver> .\wasm2wat.exe .\main.wasm -o main.wat

main.watファイルも非常に大きいので、貼れません。WebAssemblyのSIMD仕様で定義されているv128(128ビットSIMDデータ型)やi32x4.add(32ビット整数4つの並列加算)といった命令を検索しました。
結果として、これらのSIMD関連命令は一切見つかりませんでした。
これは、Goのコンパイラに生成されたWasmコードが、SIMDを利用せず、データを一つずつ順次処理する命令で構成されていることを意味します。

検証結果と結論

結論として、JavaScript側では実行時にSIMDによる最適化が行われた一方、Go/Wasm側ではSIMDが利用されていないようでした。今回のWasmとJavaScriptの性能差は、それが直接的な要因である可能性が高いと言えます。
これはWebAssemblyの能力的な限界ではなく、私の環境のGoコンパイラがSIMD命令にならなかったことに起因しています。WebAssemblyが絶対的に遅いということではありません。

まとめ

今回の結果は、技術選定において「銀の弾丸はない」ということを示す好例となりました。常にトレードオフを意識し、実際のユースケースで計測・判断することの重要性を再認識できました。
最後までお読みいただき、ありがとうございました。

所属部署について

私の所属するAIシステム開発課は、AIとシステム開発の両方に精通した技術部門です。私たちは、これらの技術を組み合わせることで、新たな付加価値を持つ製品やサービスを生み出すことを目指しています。
私が働いている東京オフィスは新しく快適で、部署内は良い刺激に満ちた最高の環境です。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?