Closed6

WebGPUに入門する

nissy-devnissy-dev

WebGL の歴史

WebGLは、OpenGLのAPIの薄いラッパーで、Webでの互換性を持たせたものとして開発された。OpenGL自体のAPIは古くからあり、中央集権的な状態管理など扱いにくい部分も多かった。そのため、ほとんどの人は WebGL を直接使うことはせず、ThreeJS を使うのが一般的であった。

最近の GPU については、従来の3D レンダリング以外にも深層学習や暗号通過などへの応用が広がり、General-Purpose GPU (GPGPU) と呼ばれるようになった。一方で、3Dレンダリング以外での GPU の用途を行おうとすると、WebGL は扱いにくいことが多かった。特に、GPUで計算させるまでに複数のデータの変換を行う必要があって大変だった。

nissy-devnissy-dev

WebGPU

WebGPU は、Web から Vulkan・Metal・DirectX などの次世代 GPU API にアクセスするための手段を提供している。WebGLのようなただの GPU API のラッパーではなく、独自の抽象化レイヤーを用意して API を提供している。

抽象化は以下のようになっている。

GPU ⇄ OS API ⇄ WebGPU

  • GPU : 物理レイヤー
  • Driver : OS.が GPU の機能にアクセスできるようにするブリッジレイヤー
  • OS API : Vulkan・Metal・DirectX などがアプリケーション向けに提供する API
  • Adapter : OS API を WebGPU へ変換するレイヤー
  • WebGPU : Web 向けに提供する GPU を操作するための API
nissy-devnissy-dev

shader

GPU 上で実行される任意のプログラムを指す大ざっぱな用語として「シェーダー」が使われている。

以前は、各ピクセルの色を計算するために GPU 上で実行される小さなコードを指し、レンダリングされているオブジェクトに陰影を付けるために存在していた。

Pipeline

WebGPU は、目的に応じてパイプラインと呼ばれるものを作成する。現状作成できるのは、数値計算のための Compute Pipeline と 2D 画像を作成するための Rendering Pipeline の2つがある。

各パイプラインは1 つ(またはそれ以上)のステージで構成され、各ステージはシェーダーとエントリポイントよって定義される。

const module = device.createShaderModule({
  code: `
 // @compute: コンピュートステージのエントリーポイントとして使うことを意味する
   // @workgroup_size(64): 並列数に関する設定 (詳しくは Workgroups を参考)
    @compute @workgroup_size(64)
    fn main() {
      // WGSL と呼ばれる言語でGPU上で行う処理を記述
    }
  `,
});

const pipeline = device.createComputePipeline({
  compute: {
    module,
    entryPoint: "main",
  },
});

Pipeline の実行方法は次の通り。commandEncoder を利用して passEncoder を生成し、Pipeline のセットアップ、呼び出しを行っている。commandEncoderは、passEncoder を生成するだけではなく、GPUバッファから別のバッファにデータをコピーしたりするメソッド、textureを操作するメソッドなども提供している。

texture: 3Dグラフィックスにおいて物体の質感を表現するために使われる画像のこと

const commandEncoder = device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(pipeline);
passEncoder.dispatchWorkgroups(1);
passEncoder.end();
const commands = commandEncoder.finish();
device.queue.submit([commands]);
nissy-devnissy-dev

実際の例

シミュレーションパラメータと初期状態をGPUにプッシュし、GPU上でシミュレーションを実行し、GPUからシミュレーション結果を読み取るサンプルを実装してみる。

まず、GPU とデータのやり取りをするために、bindGroupLayout と呼ばれるもので PipelIne を拡張する。
バインドグループとはパイプラインを実行している間にアクセス可能となる GPU 要素 (メモリバッファ、テクスチャ、サンプラーなど) のことであり、バインドグループレイアウトはそういった GPU 要素の型、用途、使い方を事前に定義したもの。

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

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

実際の BindGroup の定義は次のようになる。GPU での計算結果を保持する Buffer (output) を用意し、BindGroupに設定する。stagingBuffer は、GPU のホストからデータを取り出すために使う Buffer である。
データを読み込む際には、ステージングバッファをホストマシンへマップし、それからメインメモリにデータを読み込むことになる。マップを行う API は GPUBufferUsage.MAP_READ あるいは GPUBufferUsage.MAP_WRITE を指定したバッファに対してしか呼び出せない。

const BUFFER_SIZE = 1000;

// ArrayBuffer ではなく GPUBuffer を返す。ホストからの読み込みや書き込みはまだ行えない。
const output = device.createBuffer({
  size: BUFFER_SIZE,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
});

const stagingBuffer = device.createBuffer({
  size: BUFFER_SIZE,
  usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});

const bindGroup = device.createBindGroup({
  layout: bindGroupLayout,
  entries: [{
    binding: 1,
    resource: {
      buffer: output,
    },
  }],
});

BindGroup を定義したら、最終的なコードは次のようになる。

const commandEncoder = device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(pipeline);
// バインドグループを指定する
passEncoder.setBindGroup(0, bindGroup);
passEncoder.dispatch(Math.ceil(BUFFER_SIZE / 64));
passEncoder.end();
// コンピュートシェーダーの出力バッファからステージングバッファへデータをコピーする
commandEncoder.copyBufferToBuffer(
  output,
  0, // Source offset
  stagingBuffer,
  0, // Destination offset
  BUFFER_SIZE
);
const commands = commandEncoder.finish();
device.queue.submit([commands]);

// stagingBuffer をマップする
// この処理ははコマンドキューが完全に処理されてから実行される
await stagingBuffer.mapAsync(
  GPUMapMode.READ,
  0, // Offset
  BUFFER_SIZE // Length
 );
// この関数が返すのはマップされた実際のメモリを指す ArrayBuffer
const copyArrayBuffer =
  stagingBuffer.getMappedRange(0, BUFFER_SIZE);
// stagingBuffer がアンマップされると copyArrayBuffer は消えてしまうので slice で複製する
const data = copyArrayBuffer.slice();
stagingBuffer.unmap();
console.log(new Float32Array(data));
nissy-devnissy-dev

これ以降は、WSGL の細かい内容だったのでざっと読んで終わりにした。
具体的には、JSで作ったパラメータを GPU のホストに渡す方法が説明されていた。(bindGroup を追加して実現する。)

読んだ感想としては、WebGL の歴史から WebGPU を理解できたし、ざっくり WebGPU で計算する流れがわかったので良かった。WebGPU を使ってなんか作ってみるといいのかな。

このスクラップは2022/10/03にクローズされました