ONNX Runtime WebのWASM・WebGPU・WebNNを比較|自動フォールバックを実装

ONNX Runtime Webで動く実行基盤を選ぶ:WASM・WebGPU・WebNNの比較 ブラウザAI
カテゴリー
ブラウザAI
公開日
2026.09.22

はじめに

ONNX Runtime Webでは、同じONNXモデルを複数の実行基盤で動かせます。この実行基盤をExecution Provider(EP)と呼びます。

ブラウザで主に検討するEPは次の3つです。

  • wasm:WebAssemblyによるCPU実行
  • webgpu:WebGPUによるGPU実行
  • webnn:WebNNを通した推論

しかし、特定のEPを指定すれば、すべての端末で動くわけではありません。ブラウザ、OS、GPU、モデルが使う演算子によって対応状況が変わります。

この記事では、値を2乗する小さなONNXモデルを使い、次のアプリを作ります。

  1. モデルを1回だけダウンロードする
  2. WebNN、WebGPU、WASMの順にセッション作成を試す
  3. 各EPで同じ入力を実行する
  4. 出力が期待値と一致するか検証する
  5. ウォームアップ後の平均処理時間を表示する
  6. 利用できないEPはエラー理由とともにスキップする

AIモデルの精度比較ではなく、EPを安全に選ぶための最小構成を学びます。

ONNXとONNX Runtime Web

ONNXは機械学習モデルを表現するためのオープンな形式です。ONNX RuntimeはONNXモデルを読み込み、利用可能なハードウェア向け実行基盤へ演算を割り当てます。

ONNX Runtime WebはそのWeb向けパッケージです。

import * as ort from "onnxruntime-web/all";

/allエントリーポイントは、WASMだけでなくWebGPUやWebNNを含む構成を利用するときに使います。WASMだけを使用する場合よりバンドルが大きくなる可能性があるため、本番では必要なEPに応じたエントリーポイントを選択してください。

Execution Providerは速度ランキングではない

「WebNN、WebGPU、WASMの順に速い」と固定したランキングを作ることはできません。

処理時間には次が影響します。

  • モデルの大きさと演算子
  • CPU、GPU、NPU
  • GPUへのデータ転送量
  • 初期化とシェーダーコンパイル
  • ブラウザとドライバー
  • 量子化とテンソルのデータ型
  • Worker、WASMスレッド、Cross-Origin Isolation

小さなモデルでは、GPUへ処理を渡す準備の方が計算そのものより大きくなる場合があります。必ず利用予定のモデルと端末で測ります。

テストモデル

今回はONNX Runtime公式リポジトリにあるmul_1.onnxを使います。入力名はX、出力名はYで、入力値を自分自身と掛け合わせます。

入力: [1, 2, 3, 4, 5, 6]
出力: [1, 4, 9, 16, 25, 36]

ファイルサイズが非常に小さく、画像の前処理などが不要なので、EPの初期化と出力検証に集中できます。ただし、このモデルの測定値を大規模AIモデルの性能予測に使うことはできません。

プロジェクトを準備する

Node.js 22.12以上の22系、または24系と、同梱のnpmを用意します。未導入ならNode.js公式サイトから導入し、ターミナルを開き直してください。node --versionnpm --versionでバージョンが表示されることを確認します。HTML・JavaScriptのファイルを保存できるエディターと、ChromeやEdgeなどのブラウザも必要です。

以下は空の作業フォルダーを作る手順です。Viteは開発用サーバーで、この記事では版を固定します。モデルとWASM本体の取得にインターネット接続が必要です。

mkdir ort-ep-comparison
cd ort-ep-comparison
npm init -y
npm install onnxruntime-web@1.27.0
npm install --save-dev --save-exact vite@7.3.6

作成したort-ep-comparisonフォルダー内に、index.htmlmain.jsを作ります。package.jsonと同じ階層へ置き、拡張子が.txtになっていないことを確認してください。

HTMLを作成する

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ONNX Runtime Web EP比較</title>
    <style>
      body { width: min(900px, calc(100% - 32px)); margin: 40px auto; font-family: system-ui; }
      table { width: 100%; border-collapse: collapse; margin-top: 16px; }
      th, td { border: 1px solid #cbd5e1; padding: 8px; text-align: left; }
      .pass { color: #166534; }
      .skip { color: #9a3412; }
    </style>
  </head>
  <body>
    <main>
      <h1>ONNX Runtime Web Execution Provider比較</h1>
      <button id="run" type="button">比較を開始する</button>
      <p id="status" role="status" aria-live="polite"></p>
      <table>
        <thead>
          <tr><th>EP</th><th>セッション</th><th>出力</th><th>平均時間</th><th>詳細</th></tr>
        </thead>
        <tbody id="results"></tbody>
      </table>
    </main>
    <script type="module" src="/main.js"></script>
  </body>
</html>

比較処理を実装する

main.jsへ次を記述します。

import * as ort from "onnxruntime-web/all";

const MODEL_URL =
  "https://raw.githubusercontent.com/microsoft/onnxruntime/main/onnxruntime/test/testdata/mul_1.onnx";
const EXPECTED = [1, 4, 9, 16, 25, 36];
const PROVIDERS = ["webnn", "webgpu", "wasm"];

const runButton = document.querySelector("#run");
const status = document.querySelector("#status");
const results = document.querySelector("#results");

ort.env.wasm.numThreads = 1;
// ViteのJavaScriptバンドルとWASM本体の配置先が異なるため、
// インストールした版と同じ1.27.0の配信先を明示する
ort.env.wasm.wasmPaths =
  "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/";

function createInput() {
  return new ort.Tensor(
    "float32",
    Float32Array.from([1, 2, 3, 4, 5, 6]),
    [3, 2],
  );
}

function arraysAreClose(actual, expected, tolerance = 1e-5) {
  return (
    actual.length === expected.length &&
    actual.every((value, index) =>
      Math.abs(Number(value) - expected[index]) <= tolerance,
    )
  );
}

function addRow(provider, state, output, average, detail) {
  const row = document.createElement("tr");
  const values = [provider, state, output, average, detail];

  values.forEach((value, index) => {
    const cell = document.createElement(index === 0 ? "th" : "td");
    if (index === 0) cell.scope = "row";
    cell.textContent = value;
    row.append(cell);
  });

  row.className = state === "成功" ? "pass" : "skip";
  results.append(row);
}

async function benchmark(session, count = 20) {
  // 初回実行には初期化やコンパイルの影響が含まれやすいため測定から外す
  await session.run({ X: createInput() });

  const startedAt = performance.now();
  let latestOutput;

  for (let index = 0; index < count; index += 1) {
    latestOutput = await session.run({ X: createInput() });
  }

  const elapsed = performance.now() - startedAt;
  return {
    averageMs: elapsed / count,
    output: Array.from(latestOutput.Y.data, Number),
  };
}

async function loadModel() {
  const response = await fetch(MODEL_URL);
  if (!response.ok) {
    throw new Error(`モデル取得失敗: HTTP ${response.status}`);
  }
  return new Uint8Array(await response.arrayBuffer());
}

runButton.addEventListener("click", async () => {
  runButton.disabled = true;
  results.replaceChildren();
  status.textContent = "モデルを取得しています。";

  try {
    const modelBytes = await loadModel();

    for (const provider of PROVIDERS) {
      status.textContent = `${provider}を確認しています。`;
      let session;

      try {
        session = await ort.InferenceSession.create(modelBytes, {
          executionProviders: [provider],
        });

        const { averageMs, output } = await benchmark(session);
        const valid = arraysAreClose(output, EXPECTED);

        addRow(
          provider,
          valid ? "成功" : "出力不一致",
          JSON.stringify(output),
          `${averageMs.toFixed(3)} ms`,
          valid ? "期待値と一致" : "期待値と一致しません",
        );
      } catch (error) {
        addRow(provider, "利用不可", "-", "-", String(error));
      } finally {
        await session?.release();
      }
    }

    status.textContent = "比較が完了しました。";
  } catch (error) {
    console.error(error);
    status.textContent = `比較を開始できませんでした: ${String(error)}`;
  } finally {
    runButton.disabled = false;
  }
});

コードのポイント

起動して結果を確認する

2つのファイルを保存したら、ort-ep-comparisonフォルダーで次を実行します。

npx vite --host 127.0.0.1

ターミナルに表示されるLocalのURL(通常はhttp://127.0.0.1:5173)をブラウザで開き、「比較を開始する」を押します。ポートが使用中の場合は別の番号になるため、表示されたURLを使ってください。HTMLファイルのダブルクリックではnpmパッケージを読み込めません。

「比較が完了しました。」と表示され、少なくともWASM行が「成功」、出力が[1,4,9,16,25,36]、詳細が「期待値と一致」になることを確認します。WebNNが「利用不可」でも、WASMなど残りの行が成功していれば、非対応時の処理は正常です。平均時間は端末ごとに異なります。終了時はターミナルでCtrl+Cを押します。

画面が開かない場合はサーバーの起動状態とURLを確認します。npmが見つからない場合はNode.jsの導入を、PowerShellでnpm.ps1の実行を拒否された場合はnpm.cmdnpx.cmdへの置き換えを試します。セキュリティ設定を変更する必要はありません。

モデルをEPごとに再ダウンロードしない

fetch()でモデルをUint8Arrayへ読み込み、同じバイト列から各セッションを作成します。これにより、EPごとのネットワーク時間を測定へ混ぜません。

出力を必ず検証する

セッションを作れただけでは正常とは判断しません。既知の入力に対して期待値が返ることを確認します。浮動小数点計算では微小な差があり得るため、許容誤差を設けています。

初回実行を測定から外す

初回のrun()にはシェーダーコンパイルなど一度だけの処理が含まれる可能性があります。今回のコードは1回ウォームアップした後、20回の平均を計算します。

初回表示速度が重要なアプリでは、平均推論時間とは別にセッション作成時間と初回推論時間も記録してください。

セッションを解放する

比較後はsession.release()を呼びます。複数EPのセッションを同時に保持すると、WASMメモリやGPUリソースを余分に使用します。

自動フォールバックへ変更する

本番アプリで最初に成功したEPを採用する場合は、次の関数を利用できます。

async function createSessionWithFallback(modelBytes) {
  const errors = [];

  for (const provider of ["webnn", "webgpu", "wasm"]) {
    try {
      const session = await ort.InferenceSession.create(modelBytes, {
        executionProviders: [provider],
      });
      return { session, provider };
    } catch (error) {
      errors.push({ provider, error: String(error) });
    }
  }

  throw new AggregateError(
    errors.map((item) => new Error(`${item.provider}: ${item.error}`)),
    "利用できるExecution Providerがありません。",
  );
}

onnxruntime-webは、JavaScriptとは別にWASM本体を読み込みます。ViteがWASMファイルを期待する場所へ配置していないと、both async and sync fetching of the wasm failedなどの初期化エラーになります。この例では再現しやすさを優先して、インストールした版と同じ1.27.0の公式npm配布物をjsDelivr経由で参照します。

本番環境ではCDNへ固定せず、node_modules/onnxruntime-web/dist/から必要なWASMファイルを同一オリジンへコピーし、そのディレクトリをwasmPathsへ設定する方法もあります。CDNを使う場合は、初回読み込み時に外部通信が発生する点と、Content Security Policyで許可が必要になる点に注意してください。

ただし、「セッションを作成できた」だけで選択を確定せず、必要に応じて小さな推論を実行し、出力まで確認します。

WebNNのフォールバックに関する注意

ONNX Runtime WebのWebNN EPでは、WebNNが対応していない演算をWASM EPへフォールバックできる場合があります。そのため、EP名をwebnnと指定したことと、モデル内のすべての演算が特定のNPUやGPUだけで処理されたことは同じではありません。

実際の割り当てや性能を判断するときは、ONNX Runtimeのログ、プロファイル、対応演算子、対象ブラウザの実装情報を確認します。

動作確認

正常系

  • 130バイトのテストモデルを取得できる
  • 少なくともWASMでセッションを作れる
  • 出力が[1,4,9,16,25,36]と一致する
  • 利用可能なEPで平均時間が表示される
  • セッションを比較後に解放する

エラー系

  • オフライン時にネットワーク取得エラーを表示する(HTTP応答自体が返らない場合もあります)
  • WASM本体の配置先が誤っている場合、初期化エラーとして利用不可行に表示する
  • WebGPU非対応環境ではWebGPU行だけが利用不可になる
  • WebNN非対応環境ではWebNN行だけが利用不可になる
  • 1つのEPが失敗しても残りの比較を続ける
  • 出力名や形状が想定と異なるモデルでは正常扱いしない

ベンチマークの限界

今回のモデルは小さすぎるため、実用AIモデルの速度比較には使えません。性能記事を作る場合は、実際に採用するモデルで次を分けて測定します。

  • JavaScriptバンドル取得時間
  • モデル取得時間と容量
  • セッション作成時間
  • 初回推論時間
  • 2回目以降の推論時間
  • CPU・GPUメモリ使用量
  • 出力の一致または精度

測定値にはOS、ブラウザバージョン、CPU、GPU、電源設定、測定回数を添えます。

関連記事

まとめ

ONNX Runtime Webでは、WASM、WebGPU、WebNNをExecution Providerとして切り替えられます。

実際のアプリではAPIの有無だけで判断せず、対象モデルのセッション作成と推論結果まで確認してから採用します。また、性能値は実行環境に依存するため、同一端末、同一モデル、同一入力で条件をそろえて比較してください。

参考リンク

コメント

タイトルとURLをコピーしました