ウェブで GPU コンピューティングを使ってみる

本投稿では、試験運用版の WebGPU API について、例を通して説明するとともに、 GPU を使用したデータの並列計算を始めましょう。

François Beaufort
François Beaufort

背景

ご存じのとおり、画像処理装置(GPU)は電子的な コンピュータ内のサブシステム です。 です。しかし過去 10 年の間に、 デベロッパーが多様なアルゴリズムを実装できるように 独自のアーキテクチャを活用して 3D グラフィックをレンダリングできます。 使用できます。これらの機能は GPU コンピューティングと呼ばれ、GPU を 汎用科学コンピューティングのためのコプロセッサは、 GPU(GPGPU)プログラミング

GPU コンピューティングは最近の ML ブームに大きく貢献しており、 畳み込みニューラル ネットワークやその他のモデルでは、 GPU でより効率的に実行できます現在のウェブ プラットフォーム GPU コンピューティング機能、W3C の「GPU for the Web」コミュニティ グループ ほとんどのプラットフォームで使用可能な最新の GPU API を公開する API を設計しています。 できます。この API は WebGPU と呼ばれます。

WebGPU は、WebGL などの低レベル API です。非常に強力で非常に冗長です。 表示されますでも大丈夫です。ここで重視しているのはパフォーマンスです。

この記事では、WebGPU の GPU コンピューティング部分に焦点を当てます。 簡単にご説明したにすぎません あります。WebGPU のレンダリング(キャンバス、テクスチャ、 今後の記事に記載しています。

GPU にアクセスする

WebGPU では GPU へのアクセスが簡単です。navigator.gpu.requestAdapter() に発信中 GPU で非同期に解決される JavaScript Promise を返します。 設定してください。このアダプターはグラフィック カードのようなものです。統合することも ディスクリート(通常は CPU と同じチップ上)の PCIe カード、 消費します。

GPU アダプターを取得したら、adapter.requestDevice() を呼び出して Promise を取得します。 GPU デバイスを使って解決する問題です。

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) { return; }
const device = await adapter.requestDevice();

どちらの関数にも、データの種類を指定するオプションが用意されています。 必要なアダプター(電源設定)とデバイス(拡張機能、制限)を指定します。対象: わかりやすくするため、この記事ではデフォルトのオプションを使用します。

書き込みバッファメモリ

JavaScript を使用して GPU のメモリにデータを書き込む方法を見てみましょう。この 単純なプロセスではないのは、現代のウェブで使用されているサンドボックス モデルが できます。

以下の例は、アクセス可能なバッファメモリに 4 バイトを書き込む方法を示しています。 内部 IP アドレスを使用して通信できますこのメソッドは device.createBuffer() を呼び出します。 その使用状況を確認できます。使用フラグ GPUBufferUsage.MAP_WRITE が この呼び出しでは必要ないので、ここで指定した 渡します。これにより、GPU バッファ オブジェクトが作成時にマッピングされるようになり、 mappedAtCreation を true に設定。その後、関連する生バイナリデータ バッファが GPU バッファ メソッド getMappedRange() を呼び出して取得します。

バイトの書き込みは、すでに ArrayBuffer でプレイしたことがある場合は見覚えがあるでしょう。使用 TypedArray を実行し、値をそこにコピーします。

// Get a GPU buffer in a mapped state and an arrayBuffer for writing.
const gpuBuffer = device.createBuffer({
  mappedAtCreation: true,
  size: 4,
  usage: GPUBufferUsage.MAP_WRITE
});
const arrayBuffer = gpuBuffer.getMappedRange();

// Write bytes to buffer.
new Uint8Array(arrayBuffer).set([0, 1, 2, 3]);

この時点で GPU バッファはマッピングされます。つまり、GPU バッファは CPU によって所有されます。 JavaScript から読み書きでアクセスできますGPU がアクセスできるように マッピングを解除する必要があります。これは、gpuBuffer.unmap() を呼び出すだけで済みます。

マッピングされていない/マッピングされていないというコンセプトは、GPU が 同時に使用できます。

読み取りバッファメモリ

次に、GPU バッファを別の GPU バッファにコピーして、読み戻す方法を見てみましょう。

1 つ目の GPU バッファに書き込んで、2 つ目の GPU バッファにも GPU バッファの場合、新しい使用状況フラグ GPUBufferUsage.COPY_SRC が必要です。2 つ目の 今回は、GPU バッファがマッピングされていない状態で作成されます。 device.createBuffer()。最初の GPU の宛先として使用されるため、用途フラグは GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ です。 GPU コピーコマンドの実行後に JavaScript で読み取られます。

// Get a GPU buffer in a mapped state and an arrayBuffer for writing.
const gpuWriteBuffer = device.createBuffer({
  mappedAtCreation: true,
  size: 4,
  usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC
});
const arrayBuffer = gpuWriteBuffer.getMappedRange();

// Write bytes to buffer.
new Uint8Array(arrayBuffer).set([0, 1, 2, 3]);

// Unmap buffer so that it can be used later for copy.
gpuWriteBuffer.unmap();

// Get a GPU buffer for reading in an unmapped state.
const gpuReadBuffer = device.createBuffer({
  size: 4,
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
});

GPU は独立したコプロセッサであるため、すべての GPU コマンドが実行されます。 使用できます。作成されて送信された GPU コマンドのリストがあるのはそのためです。 できます。WebGPU の場合、GPU コマンド エンコーダは、 device.createCommandEncoder() は、バッチで作成する JavaScript オブジェクトです。 「buffered」特定の時点で GPU に送信されるコマンドを 処理することもできますメソッドを 一方、GPUBuffer は「バッファなし」であり、アトミックに実行されます 設定されます。

GPU コマンド エンコーダを入手したら、copyEncoder.copyBufferToBuffer() を呼び出します。 このコマンドをコマンドキューに追加して、後で実行できるようにします。 最後に、copyEncoder.finish() を呼び出してコマンドのエンコードを終了し、送信します。 GPU デバイスのコマンドキューに 移動できますキューはこれらのタスクを GPU コマンドを引数として、device.queue.submit() で実行された送信内容。 これにより、配列に保存されているすべてのコマンドがアトミックに実行されます。

// Encode commands for copying buffer to buffer.
const copyEncoder = device.createCommandEncoder();
copyEncoder.copyBufferToBuffer(
  gpuWriteBuffer /* source buffer */,
  0 /* source offset */,
  gpuReadBuffer /* destination buffer */,
  0 /* destination offset */,
  4 /* size */
);

// Submit copy commands.
const copyCommands = copyEncoder.finish();
device.queue.submit([copyCommands]);

この時点で GPU キュー コマンドが送信されましたが、必ずしも実行されているとは限りません。 2 つ目の GPU バッファを読み取るには、次のように gpuReadBuffer.mapAsync() を呼び出します。 GPUMapMode.READ。GPU バッファが解放されると解決される Promise を返します。 マッピングされます。次に、gpuReadBuffer.getMappedRange() を使用してマッピングされた範囲を取得します。 キューに追加されたすべての GPU コマンドが、最初の GPU バッファと同じ値を格納する 実行されます。

// Read buffer.
await gpuReadBuffer.mapAsync(GPUMapMode.READ);
const copyArrayBuffer = gpuReadBuffer.getMappedRange();
console.log(new Uint8Array(copyArrayBuffer));

こちらのサンプルをお試しください。

簡単に言うと、バッファメモリの操作に関して覚えておくべきことは次の通りです。

  • デバイスキューの送信で使用するには、GPU バッファのマッピングを解除する必要があります。
  • GPU バッファをマッピングすると、JavaScript で読み取りと書き込みが可能になります。
  • mapAsync()createBuffer() の場合、GPU バッファがマッピングされます。 true に設定されている mappedAtCreation が呼び出されます。

シェーダー プログラミング

GPU 上で実行され、計算のみを行う(描画は行わない)プログラム コンピューティング シェーダーと呼ばれます。これらは数百のプロバイダによって並列に 高速に動作するために連携して動作する GPU コア(CPU コアよりも小さい)の数 分析できますこれらの入力と出力は、WebGPU ではバッファです。

WebGPU でのコンピューティング シェーダーの使用方法を説明するために、 ML における一般的なアルゴリズムのひとつです。

<ph type="x-smartling-placeholder">
</ph> 行列乗算の図
行列乗算の図

簡潔に言うと、次の手順を実行します。

  1. 3 つの GPU バッファを作成する(2 つは乗算する行列、もう 1 つは 結果マトリックス)
  2. コンピューティング シェーダーの入力と出力について説明する
  3. コンピューティング シェーダーのコードをコンパイルする
  4. コンピューティング パイプラインを設定する
  5. エンコードされたコマンドを一括して GPU に送信する
  6. 結果マトリックスの GPU バッファを読み取る

GPU バッファの作成

わかりやすくするため、行列は浮動小数点型のリストとして 表します。1 つ目の要素は行数、2 つ目の要素は 列数、残りは行列の実際の数です。

<ph type="x-smartling-placeholder">
</ph> JavaScript での行列の単純な表現とそれと同等の数学的表記
JavaScript でのマトリックスの単純な表現と同等の数学的表記法

3 つの GPU バッファは、ストレージ バッファであり、 コンピューティング シェーダーです。このため、GPU バッファ使用量フラグに次の内容が含まれます。 それらすべてに対して GPUBufferUsage.STORAGE。結果マトリックスの用途フラグも GPUBufferUsage.COPY_SRC。別のバッファにコピーされるため、 すべての GPU キュー コマンドが実行されたら読み取りを行うようになります。

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) { return; }
const device = await adapter.requestDevice();


// First Matrix

const firstMatrix = new Float32Array([
  2 /* rows */, 4 /* columns */,
  1, 2, 3, 4,
  5, 6, 7, 8
]);

const gpuBufferFirstMatrix = device.createBuffer({
  mappedAtCreation: true,
  size: firstMatrix.byteLength,
  usage: GPUBufferUsage.STORAGE,
});
const arrayBufferFirstMatrix = gpuBufferFirstMatrix.getMappedRange();
new Float32Array(arrayBufferFirstMatrix).set(firstMatrix);
gpuBufferFirstMatrix.unmap();


// Second Matrix

const secondMatrix = new Float32Array([
  4 /* rows */, 2 /* columns */,
  1, 2,
  3, 4,
  5, 6,
  7, 8
]);

const gpuBufferSecondMatrix = device.createBuffer({
  mappedAtCreation: true,
  size: secondMatrix.byteLength,
  usage: GPUBufferUsage.STORAGE,
});
const arrayBufferSecondMatrix = gpuBufferSecondMatrix.getMappedRange();
new Float32Array(arrayBufferSecondMatrix).set(secondMatrix);
gpuBufferSecondMatrix.unmap();


// Result Matrix

const resultMatrixBufferSize = Float32Array.BYTES_PER_ELEMENT * (2 + firstMatrix[0] * secondMatrix[1]);
const resultMatrixBuffer = device.createBuffer({
  size: resultMatrixBufferSize,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
});

バインド グループのレイアウトとバインド グループ

バインド グループのレイアウトとバインド グループの概念は、WebGPU に固有のものです。バインド グループ レイアウトは、シェーダーが期待する入出力インターフェースを定義します。 バインド グループは、シェーダーの実際の入出力データを表します。

以下の例では、バインド グループのレイアウトで、次の場所にある 2 つの読み取り専用ストレージ バッファを想定しています。 番号付きのエントリ バインディング 01、コンピューティング シェーダー用のストレージ バッファ(2)。 一方、このバインド グループ レイアウトに対して定義されたバインド グループは、 GPU バッファをエントリ(gpuBufferFirstMatrix)からバインディング 0 へ。 gpuBufferSecondMatrix をバインディングに1resultMatrixBuffer を バインディング 2

const bindGroupLayout = device.createBindGroupLayout({
  entries: [
    {
      binding: 0,
      visibility: GPUShaderStage.COMPUTE,
      buffer: {
        type: "read-only-storage"
      }
    },
    {
      binding: 1,
      visibility: GPUShaderStage.COMPUTE,
      buffer: {
        type: "read-only-storage"
      }
    },
    {
      binding: 2,
      visibility: GPUShaderStage.COMPUTE,
      buffer: {
        type: "storage"
      }
    }
  ]
});

const bindGroup = device.createBindGroup({
  layout: bindGroupLayout,
  entries: [
    {
      binding: 0,
      resource: {
        buffer: gpuBufferFirstMatrix
      }
    },
    {
      binding: 1,
      resource: {
        buffer: gpuBufferSecondMatrix
      }
    },
    {
      binding: 2,
      resource: {
        buffer: resultMatrixBuffer
      }
    }
  ]
});

コンピューティング シェーダーのコード

行列を乗算するためのコンピューティング シェーダーのコードは、WGSL WebGPU シェーダー言語(SPIR-V に容易に翻訳可能)なし 詳しく見ると、以下の 3 つのストレージ バッファが特定され、 var<storage> で。プログラムは firstMatrixsecondMatrix を次のように使用します。 その出力は resultMatrix です。

各ストレージ バッファには、対応する binding 装飾が 上記で宣言されたバインド グループのレイアウトとバインド グループで定義されているのと同じインデックスを使用します。

const shaderModule = device.createShaderModule({
  code: `
    struct Matrix {
      size : vec2f,
      numbers: array<f32>,
    }

    @group(0) @binding(0) var<storage, read> firstMatrix : Matrix;
    @group(0) @binding(1) var<storage, read> secondMatrix : Matrix;
    @group(0) @binding(2) var<storage, read_write> resultMatrix : Matrix;

    @compute @workgroup_size(8, 8)
    fn main(@builtin(global_invocation_id) global_id : vec3u) {
      // Guard against out-of-bounds work group sizes
      if (global_id.x >= u32(firstMatrix.size.x) || global_id.y >= u32(secondMatrix.size.y)) {
        return;
      }

      resultMatrix.size = vec2(firstMatrix.size.x, secondMatrix.size.y);

      let resultCell = vec2(global_id.x, global_id.y);
      var result = 0.0;
      for (var i = 0u; i < u32(firstMatrix.size.y); i = i + 1u) {
        let a = i + resultCell.x * u32(firstMatrix.size.y);
        let b = resultCell.y + i * u32(secondMatrix.size.y);
        result = result + firstMatrix.numbers[a] * secondMatrix.numbers[b];
      }

      let index = resultCell.y + resultCell.x * u32(secondMatrix.size.y);
      resultMatrix.numbers[index] = result;
    }
  `
});

パイプラインの設定

コンピューティング パイプラインは、コンピューティング オペレーションを実際に記述するオブジェクト 説明します。device.createComputePipeline() を呼び出して作成します。 この関数は 2 つの引数を取ります。1 つは前に作成したバインド グループ レイアウト、もう 1 つはコンピューティング コンピューティング シェーダー(main WGSL 関数)のエントリ ポイントを定義するステージ device.createShaderModule() で作成された実際のコンピューティング シェーダー モジュールを示しています。

const computePipeline = device.createComputePipeline({
  layout: device.createPipelineLayout({
    bindGroupLayouts: [bindGroupLayout]
  }),
  compute: {
    module: shaderModule,
    entryPoint: "main"
  }
});

コマンドの送信

3 つの GPU バッファとコンピューティング リソースを使用してバインド グループをインスタンス化したら、 それらを使用してみましょう。

では、プログラマブル コンピューティング パス エンコーダを commandEncoder.beginComputePass()。これを使用して GPU コマンドを 行列乗算を行いますパイプラインを passEncoder.setPipeline(computePipeline) とそのインデックス 0 のバインド グループ。 passEncoder.setBindGroup(0, bindGroup)。インデックス 0 は WGSL コードの group(0) 装飾。

次に、このコンピューティング シェーダーが GPU で実行される仕組みについて説明します。Google 目標は、結果行列のセルごとにこのプログラムを並列に実行することです。 順を追って説明しますたとえば、サイズが 16 x 32 の結果行列の場合、 @workgroup_size(8, 8) で呼び出します。 passEncoder.dispatchWorkgroups(2, 4) または passEncoder.dispatchWorkgroups(16 / 8, 32 / 8)。 1 つ目の引数「x」は、1 つ目の次元、2 つ目の次元の「y」、2 つ目の次元は 最新の「z」は3 つ目のディメンションは、ここでは不要なためデフォルトで 1 に設定されます。 GPU のコンピューティングの世界では、データセットに対してカーネル関数を実行するコマンドをエンコードすることをディスパッチと呼びます。

<ph type="x-smartling-placeholder">
</ph> 結果マトリックスのセルごとに並列に実行
結果マトリックスのセルごとに並列に実行する

WGSL では、コンピューティング シェーダーのワークグループ グリッドのサイズは (8, 8) です。 できます。そのため、「x」はと「y」それぞれ 1 対 1 の会話の 最初の行列と 2 番目の行列の列数は除算される 8。これで、Compute Engine インスタンスから passEncoder.dispatchWorkgroups(firstMatrix[0] / 8, secondMatrix[1] / 8)。「 実行するワークグループ グリッドの数は dispatchWorkgroups() 引数です。

上の図に示されているように、各シェーダーは、各シェーダーに固有の 結果を確認するために使用される builtin(global_invocation_id) オブジェクト 行列セルを指定します。

const commandEncoder = device.createCommandEncoder();

const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(computePipeline);
passEncoder.setBindGroup(0, bindGroup);
const workgroupCountX = Math.ceil(firstMatrix[0] / 8);
const workgroupCountY = Math.ceil(secondMatrix[1] / 8);
passEncoder.dispatchWorkgroups(workgroupCountX, workgroupCountY);
passEncoder.end();

コンピューティング パス エンコーダを終了するには、passEncoder.end() を呼び出します。次に、 結果マトリックス バッファのコピー先として使用する GPU バッファ copyBufferToBuffer。最後に、Terraform でコマンドのエンコードを copyEncoder.finish() を実行し、以下を呼び出して GPU デバイスキューに送信します。 device.queue.submit() は、GPU コマンドに置き換えます。

// Get a GPU buffer for reading in an unmapped state.
const gpuReadBuffer = device.createBuffer({
  size: resultMatrixBufferSize,
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
});

// Encode commands for copying buffer to buffer.
commandEncoder.copyBufferToBuffer(
  resultMatrixBuffer /* source buffer */,
  0 /* source offset */,
  gpuReadBuffer /* destination buffer */,
  0 /* destination offset */,
  resultMatrixBufferSize /* size */
);

// Submit GPU commands.
const gpuCommands = commandEncoder.finish();
device.queue.submit([gpuCommands]);

結果マトリックスを読み取る

結果マトリックスを読み取るには、次のように gpuReadBuffer.mapAsync() を呼び出すだけです。 GPUMapMode.READ で、返される Promise が解決されるのを待機します。これは、 GPU バッファがマッピングされました。この時点で、マッピングされた gpuReadBuffer.getMappedRange() を使用して範囲を指定します。

<ph type="x-smartling-placeholder">
</ph> 行列乗算の結果
行列乗算の結果

私たちのコードでは、DevTools JavaScript コンソールに記録された結果が「2, 2, 50, 60, 114、140 インチ。

// Read buffer.
await gpuReadBuffer.mapAsync(GPUMapMode.READ);
const arrayBuffer = gpuReadBuffer.getMappedRange();
console.log(new Float32Array(arrayBuffer));

これで、お疲れさまでした。サンプルを試すことができます。

最後のポイント

コードを読みやすくする 1 つの便利な方法として、 コンピューティング パイプラインの getBindGroupLayout メソッド。バインド グループを推測します。 渡します。この手法により、ファイルを作成する手間が省けます。 カスタム バインド グループ レイアウトとコンピューティングでのパイプライン レイアウトの指定 見てみましょう

前のサンプルの getBindGroupLayout のイラストは利用可能です。

 const computePipeline = device.createComputePipeline({
-  layout: device.createPipelineLayout({
-    bindGroupLayouts: [bindGroupLayout]
-  }),
   compute: {
-// Bind group layout and bind group
- const bindGroupLayout = device.createBindGroupLayout({
-   entries: [
-     {
-       binding: 0,
-       visibility: GPUShaderStage.COMPUTE,
-       buffer: {
-         type: "read-only-storage"
-       }
-     },
-     {
-       binding: 1,
-       visibility: GPUShaderStage.COMPUTE,
-       buffer: {
-         type: "read-only-storage"
-       }
-     },
-     {
-       binding: 2,
-       visibility: GPUShaderStage.COMPUTE,
-       buffer: {
-         type: "storage"
-       }
-     }
-   ]
- });
+// Bind group
  const bindGroup = device.createBindGroup({
-  layout: bindGroupLayout,
+  layout: computePipeline.getBindGroupLayout(0 /* index */),
   entries: [

パフォーマンスに関する検出結果

では、GPU での行列乗算の実行と、GPU での実行と CPU は?それを調べるために、先ほど説明した CPU 用のプログラムを作成しました。できる限り 下のグラフからわかるように、GPU を最大限に活用するには 行列のサイズが 256 x 256 より大きいとき

<ph type="x-smartling-placeholder">
</ph> GPU と CPU のベンチマーク
GPU と CPU のベンチマーク

この記事は、私の WebGPU の探索の始まりにすぎません。期待以上の効果 GPU コンピューティングの詳細やレンダリング方法に関する (キャンバス、テクスチャ、サンプラー)は WebGPU で動作します。