モバイル&ワイヤレスブロードバンドでインターネットへ

gwaw.jp
 
◈ GWAW.JP / WEBGPU EXPERIMENTS / MATMUL BENCHMARK
WEBGPU EXPERIMENT

WebGPU 行列積
限界を測る

行列サイズ別に CPU と WebGPU の性能を計測し、GPU が「効き始める閾値」と「最適化の効果」を数値で可視化します。GFLOPS による定量比較付き。

WebGPU Compute Shader ベンチマーク GFLOPS 行列積 GEMM
01

概要

行列積(Matrix Multiplication / GEMM)は機械学習・グラフィックス・科学計算の中核となる演算です。 このベンチマークでは CPU 実装WebGPU ナイーブ実装WebGPU タイル化実装の3種を、同一の入力に対して計測・比較します。

💡 計測の目的
「GPU は CPU より速い」と漠然と信じるのではなく、どのサイズから・どれだけ速くなるのかを実機で測ります。小さい行列では CPU が勝ち、大きい行列では GPU の並列性が活きる —— その境界を体感的に理解することが目標です。
⚡ GFLOPS(演算性能の単位)
「1秒あたり何ギガ回の浮動小数点演算ができるか」を表す数値。 N×N の行列積は 2N³ 回の浮動小数点演算を含むため、実行時間から GFLOPS = 2N³ / (実行時間[秒] × 10⁹) で算出します。
02

動作環境・注意事項

Chrome 113+✔ 推奨。デスクトップ環境が最も信頼できる結果を出す
Edge 113+✔ 動作します
Firefox✘ WebGPU 未対応のため CPU のみ計測可能
Safari 18+△ 実験的対応
モバイル△ Android Chrome では動作。GPU 性能はデバイス依存
計測精度△ 他タブ・他アプリの負荷により結果が変動します
大きいサイズ△ 1024 以上では数秒〜数十秒かかります
⚠️ 計測のばらつきについて
ブラウザの GPU 計測は、JIT 最適化のウォームアップサーマルスロットリング他プロセスの干渉によりばらつきます。 各サイズで複数回計測し平均値を取りますが、絶対値より傾向を見るのが正しい使い方です。

03

ベンチマーク・コンソール

▶ MATMUL BENCHMARK CONSOLE WebGPU: 確認中...
BACKENDS TO BENCHMARK
初期化中...
RUNS COMPLETED
PEAK GFLOPS
CPU vs GPU
最大スピードアップ
TIPPING POINT
GPU が CPU を超えるサイズ

EXECUTION TIME (LOWER IS BETTER, LOG SCALE)

THROUGHPUT (GFLOPS — HIGHER IS BETTER)

SIZE BACKEND TIME (ms) GFLOPS vs CPU
RUN BENCHMARK を押して計測を開始してください
04

動作原理と内部の仕組み

N×N 行列同士の積 C = A × B は、各要素 C[i][j] が「A の i 行目」と「B の j 列目」の内積で計算されます。 N が大きくなると計算量は O(N³) で爆発的に増えますが、 各要素の計算は完全に独立しているため並列化に向いています。

CPU 実装

3重ループ(逐次)

i, j, k の3重ループで 1 要素ずつ順番に計算。N×N 個の要素を直列に処理するため、N が大きいと急激に遅くなります。

WebGPU NAIVE

1 要素 1 スレッド

C[i][j] の計算を1スレッドが担当。N×N 個のスレッドが並列に走るが、A・B から毎回グローバルメモリを読むためメモリ帯域がボトルネック。

WebGPU TILED

共有メモリ最適化

16×16 のワークグループで「タイル」を共有メモリにロードし、グループ内のスレッドで再利用。グローバルメモリへのアクセスが約1/16になり、大幅に高速化します。

Tiled 実装の核心は workgroup shared memory です。 GPU は通常、各スレッドが独立したレジスタを持ちますが、ワークグループ内のスレッドは 共有メモリ(var<workgroup>)を通じてデータをやり取りできます。 これがタイル化アルゴリズムを成立させる鍵です。

A の 16×16 タイル
workgroup shared memory
B の 16×16 タイル

↑ ワークグループ内 256 スレッドが同じタイルを共有 → グローバルメモリアクセスを 256回 → 1回 に削減

05

コードのポイント解説

本ベンチマークの中核となる3つの実装を、初学者向けに解説します。

CPU 実装(基準値)
function matmulCPU(A, B, N) {
  const C = new Float32Array(N * N);
  for (let i = 0; i < N; i++) {
    for (let j = 0; j < N; j++) {
      let sum = 0;
      for (let k = 0; k < N; k++) {
        sum += A[i * N + k] * B[k * N + j];
      }
      C[i * N + j] = sum;
    }
  }
  return C;
}

最も基本的な3重ループ実装です。N=512 で約 1.34 億回の演算が必要となり、ブラウザの JavaScript エンジンでは数百ms〜数秒かかります。

WebGPU Naive Compute Shader
// 各スレッドが C[row][col] の 1 要素を担当する
@group(0) @binding(0) var<storage, read>       A: array<f32>;
@group(0) @binding(1) var<storage, read>       B: array<f32>;
@group(0) @binding(2) var<storage, read_write> C: array<f32>;
@group(0) @binding(3) var<uniform>             dim: u32;

@compute @workgroup_size(16, 16)
fn main(@builtin(global_invocation_id) gid: vec3u) {
  let row = gid.y;
  let col = gid.x;
  if (row >= dim || col >= dim) { return; }

  var sum = 0.0;
  for (var k: u32 = 0u; k < dim; k = k + 1u) {
    sum = sum + A[row * dim + k] * B[k * dim + col];
  }
  C[row * dim + col] = sum;
}

N×N スレッドが同時に走りますが、各スレッドは A・B から 2N 回グローバルメモリを読みます。 同じ行・列を別スレッドが何度も読むため、メモリ帯域が無駄遣いされるのが弱点です。

WebGPU Tiled Compute Shader(最適化版)
const TS: u32 = 16u;  // タイルサイズ

// ワークグループ全体で共有するメモリ(全スレッドが同じデータを参照可能)
var<workgroup> tileA: array<array<f32, TS>, TS>;
var<workgroup> tileB: array<array<f32, TS>, TS>;

@compute @workgroup_size(16, 16)
fn main(
  @builtin(global_invocation_id) gid: vec3u,
  @builtin(local_invocation_id)  lid: vec3u
) {
  let row = gid.y;
  let col = gid.x;
  var sum = 0.0;
  let numTiles = (dim + TS - 1u) / TS;

  for (var t: u32 = 0u; t < numTiles; t = t + 1u) {
    // ① タイル分のデータを共有メモリにロード(256スレッドが協力)
    tileA[lid.y][lid.x] = A[row * dim + (t * TS + lid.x)];
    tileB[lid.y][lid.x] = B[(t * TS + lid.y) * dim + col];

    // ② 全スレッドのロード完了を待つ
    workgroupBarrier();

    // ③ 共有メモリ上で内積計算(高速)
    for (var k: u32 = 0u; k < TS; k = k + 1u) {
      sum = sum + tileA[lid.y][k] * tileB[k][lid.x];
    }
    workgroupBarrier();
  }

  if (row < dim && col < dim) {
    C[row * dim + col] = sum;
  }
}

ハイライト部分が最適化の核心です。workgroupBarrier() で「ワークグループ内全スレッドのロード完了」を待ち合わせ、その後で共有メモリから内積計算を行います。 各タイルは1度だけグローバルメモリから読まれ、256スレッドで再利用されます。

計測:performance.now() と GPU 同期
// GPU の処理完了を確実に待つには readBuffer 経由が必要
async function measureGPU(N) {
  const A = new Float32Array(N * N);
  const B = new Float32Array(N * N);
  // ... A, B にランダム値をセット、GPU バッファ作成 ...

  const t0 = performance.now();

  const encoder = device.createCommandEncoder();
  const pass = encoder.beginComputePass();
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.dispatchWorkgroups(Math.ceil(N / 16), Math.ceil(N / 16));
  pass.end();

  // 結果バッファを CPU 側に読み戻す(= GPU の処理完了を確実に待つ)
  encoder.copyBufferToBuffer(cBuffer, 0, readBuffer, 0, byteSize);
  device.queue.submit([encoder.finish()]);
  await readBuffer.mapAsync(GPUMapMode.READ);

  const t1 = performance.now();
  readBuffer.unmap();

  const elapsedMs = t1 - t0;
  const gflops = (2 * N ** 3) / (elapsedMs / 1000) / 1e9;
  return { elapsedMs, gflops };
}

ハイライト部分が 計測精度の核心です。submit() は非同期で、単に呼ぶだけでは GPU はまだ計算中です。mapAsync() でバッファを CPU 側にマップする時点で初めて GPU の処理完了が保証され、正確な実行時間が測れます。

06

ベンチマーク実装のハマりどころ

PITFALL 01

非同期 API の同期

GPU 処理時間を測るには mapAsync または onSubmittedWorkDone を await する必要があります。submit 直後の performance.now() はコマンド送信時刻でしかありません。

PITFALL 02

ウォームアップの必要性

初回実行はシェーダコンパイル・JIT・キャッシュロードでオーバーヘッドが乗ります。1回捨てて2回目以降を計測するか、複数回平均が信頼できます。

PITFALL 03

workgroupBarrier の必須性

共有メモリへのロード後にバリアを忘れると、一部スレッドが他スレッドの未書き込みデータを読みに行きます。結果は壊れますがエラーは出ません。

PITFALL 04

境界外スレッド処理

N が workgroup_size で割り切れない時、余ったスレッドがバッファ外を読み書きします。if (row < dim && col < dim) でガードを忘れずに。

PITFALL 05

サーマルスロットリング

連続実行で GPU 温度が上がるとクロックが下がり、後の計測が遅くなります。極端な比較を避けるため計測間に少しウェイトを入れています。

PITFALL 06

ブラウザの最適化

同じシェーダを何度もコンパイルすると、ブラウザがキャッシュを返します。これは「速くなる」のではなく、初回実行が「公正でない」と見なすべきです。

『WebGPU 行列積ベンチマーク』を公開しました。