04
動作原理:なぜバックエンドで速さが変わるか
3バックエンドの違いは「どこで・どうやってテンソル演算を実行するか」です。
データの転送コストと並列度のバランスが、速さを決定します。
CPU BACKEND
転送ゼロ・逐次処理
JavaScriptエンジン上で直接実行。GPUへのデータ転送がないため、小さい演算・少ないデータ量では最速。並列度は低いため大規模演算では遅い。
WEBGL BACKEND
テクスチャ経由のGPU
テンソルをテクスチャとしてGPUに転送し、GLSL シェーダで計算。転送のオーバーヘッドがあるが、中〜大規模の行列演算では並列処理が効く。
WEBGPU BACKEND
Storage Buffer直接アクセス
テクスチャ不要でStorage BufferにデータをGPUと共有。WebGLより転送効率が高く、大規模・バッチ処理で最も有利。ただし小規模では転送コストが勝る。
⚖️ 転送コスト vs 並列効果
GPU バックエンドを使う場合、CPU↔GPU のデータ転送が必ず発生します。
演算量が少ないと「転送コスト > 並列効果」となり
CPUより遅くなります。
演算量が増えると「並列効果 > 転送コスト」に逆転します。この境界がデバイスごとに異なり、
実測してみないとわからない点がベンチマークの価値です。
05
コードのポイント解説
TensorFlow.js でバックエンドを切り替えながら計測する実装のポイントを解説します。
バックエンドの切替と初期化
// バックエンドを切り替えるたびに setBackend → ready を呼ぶ
async function switchBackend(name) {
// WebGPU は navigator.gpu で事前チェック
if (name === 'webgpu' && !navigator.gpu) {
return false;
}
try {
await tf.setBackend(name);
await tf.ready();
// 実際に切り替わったか確認(フォールバック検出)
return tf.getBackend() === name;
} catch (e) {
return false;
}
}
setBackend() だけでは実際に切り替わらない場合があります。
tf.getBackend() で確認するのが確実です。
ウォームアップ込みの計測関数
async function measure(fn, warmupRuns, measureRuns) {
// ウォームアップ: JIT・シェーダコンパイルを済ませる
for (let i = 0; i < warmupRuns; i++) {
const t = fn();
await t.data(); // GPU 完了を待つ
t.dispose();
}
// 本計測
const times = [];
for (let i = 0; i < measureRuns; i++) {
const t0 = performance.now();
const result = fn();
await result.data(); // GPU 同期(これがないと未完了のまま時刻を取る)
const t1 = performance.now();
result.dispose();
times.push(t1 - t0);
}
// 最小値(最速スコア)と中央値を返す
times.sort((a, b) => a - b);
return {
min: times[0],
median: times[Math.floor(times.length / 2)],
};
}
ハイライトの await result.data() が計測精度の核心です。
TensorFlow.js の演算は非同期でGPUにオフロードされるため、
data() でデータを取り出すまで実際の完了時刻が確定しません。
このウェイトがないと、全バックエンドがほぼ同じ(= 非常に小さい)時間を返してしまいます。
各演算のベンチマーク定義
const OPS = [
{
name: 'Dense (MatMul)',
fn: (sz) => () => {
const x = tf.randomNormal([sz.batch, sz.in]);
const w = tf.randomNormal([sz.in, sz.out]);
return tf.matMul(x, w);
},
},
{
name: 'LSTM Cell',
fn: (sz) => () => {
const model = tf.sequential();
model.add(tf.layers.lstm({ units: sz.units, inputShape: [sz.seq, sz.feat] }));
const x = tf.randomNormal([sz.batch, sz.seq, sz.feat]);
return model.predict(x);
},
},
{
name: 'Conv2D',
fn: (sz) => () => {
const x = tf.randomNormal([sz.batch, sz.h, sz.w, sz.ch]);
const f = tf.randomNormal([3, 3, sz.ch, sz.filters]);
return tf.conv2d(x, f, 1, 'same');
},
},
{
name: 'Softmax',
fn: (sz) => () => tf.softmax(tf.randomNormal([sz.batch, sz.classes])),
},
];
各演算を関数として定義しておき、バックエンドを切り替えながら同じ関数を呼ぶ設計にすることで、
公平な比較が可能になります。演算ごとに「サイズパラメータ」を注入できる構造です。
メモリリークを防ぐ dispose 管理
// tf.tidy() でスコープ内のテンソルを自動解放
const result = tf.tidy(() => {
const x = tf.randomNormal([batch, n]);
const w = tf.randomNormal([n, n]);
return tf.matMul(x, w); // x, w は tidy 終了時に自動 dispose
});
// result だけ手動 dispose(tidy の外に出ているため)
await result.data();
result.dispose();
// テンソル数の確認(ベンチマーク中はこれで監視する)
console.log(tf.memory().numTensors);
ベンチマークは同じ演算を何十回も繰り返すため、テンソルの dispose() 忘れが
メモリリークとなり後の計測を汚染します。tf.tidy() で
スコープ管理するか、明示的に dispose() するのを徹底してください。