目前Web中主要有两种方式可以显式的调用GPU来执行计算任务:

  1. GPU.js - 第三方工具。使用JS编写计算任务,内部转换成GLSL交由GPU(OpenGL ES)处理
  2. WebGPU - w3c规范(19-11-12时处于Editor’s Draft阶段),提供通用的GPU API,抹平不同底层图形API之间的差异(Vulkan, D3D, Metal)

首先了解下CPU与GPU具有的不同特点:

  • GPU具有大量内核,可以同时执行相同计算任务,如将一张图片的所有像素转为灰度图像素
  • CPU需要多级缓存,GPU需要较少的缓存空间
  • CPU适合序列型结构的任务,GPU很适合进行并行结构的任务
  • CPU具有低时延的特点,GPU具有高吞吐量的特点

GPU.js

GPU.js的亮点是通过编写JS函数和调用高度封装的接口来方便的使用GPU执行并行计算。

  • 在Web与Node环境下使用GPU进行GPGPU运算,当GPU不可用时,使用JS执行函数
  • 原理: 将JS函数转换为WebGL程序中的着色器代码并执行

基础操作

以经典的矩阵乘法计算为例

const { GPU } = require('gpu.js'); // In Node.js
// 1. 创建gpu实例
const gpu = new GPU();
let dimension = 2;
// 2. 构建kernel实例,根据环境与配置自动选择kernel类型
const multiplyMatrix = gpu.createKernel(function(a, b) {
  // 3. kernel处理函数
  let sum = 0;
  for (let i = 0; i < this.constants.dimensions; i++) {
    // thread中可以取得当前各维度中的索引值,在output中配置尺寸,可以使用其作为参数参与计算
    sum += a[this.thread.y][i] * b[i][this.thread.x];
  }
  return sum;
}, {
  // 4. kernel的参数配置,包括函数内使用的常量与输出尺寸等
  constants: { dimensions },
  output: { x: dimensions, y: dimensions }
})

const m1 = [[1,2],[3,4]];
const m2 = [[4,3],[3,3]];
const m = multiplyMatrix(m1, m2);
// m: [ Float32Array [ 10, 9 ], Float32Array [ 24, 21 ] ]

输入与输出类型

  • 输入: Number | Array (1~3维的Number元素,数组为多种类型化数组) | 扁平化数组+input函数 | HTML Image/Video
  • 输出: 类型化数组 | canvas

多线程

支持在work线程中使用,此时内部操作的画布对象为OffscreenCanvas类型

图形化输出

不仅可以使用GPU.js执行一些运算任务,也可以直接处理图像数据。

实现该操作需要两步:

  1. 将kernel的graphical属性设为true并在内部函数中操作this.color(r,g,b)像素数据
  2. 执行回调函数,导出canvas元素(HTMLCanvasElement|OffscreenCanvas)
const render = gpu.createKernel(function() {
    this.color(0, 0, 0, 1);
})
  .setOutput([20, 20])
  .setGraphical(true);
// 执行回调
render();
// 取得canvas DOM元素
const canvas = render.canvas;
document.getElementsByTagName('body')[0].appendChild(canvas);

性能对比

GPU.js官方的benchmark测试:

  • 硬件: Xeon Gold 5217 + 8 x RTX 2080ti
  • 操作系统: Ubuntu 18.04
  • 环境: NodeJS v8.10.0 + GPU.js v2.2.0

可以看出,在大规模矩阵乘法的计算中,GPU的并行计算优势很明显,不过在代码中的具体效果还得看在什么样的硬件与软件环境下。

WebGPU

  • 最早由苹果提出,目前的google的dawn完成度较高
  • 越来越多的人使用GPU进行GPGPU编程(general-purpose GPU),而不是仅仅画一个三角形
  • W3C的GPU for the Web社区组根据目前大部分设备的现代GPU接口设计暴露的通用API,即WebGPU
  • WebGL的后继者,底层API

特点:

  • 为Web暴露GPU硬件的能力,提供映射到Vulkan、Direct3D 12与Metal图形API的接口
  • 与WebGL不同的是,它不以OpenGL ES为底层的目标图形API,并且提供能直接操作计算着色器的能力

使用WebGPU的一些示例

下面在chrome(78版本)上展示一些基础操作与示例

提示: Chrome 78 for macOS中WebGPU为实验新功能,默认禁用,可以在Canary版本的如下位置修改可用性进行测试:chrome://flags/#enable-unsafe-webgpu。

注意:由于WebGPU的规范和实现还在制订与开发阶段,下面的内容很可能会有较大改动。

基础操作 - 缓冲区与内存

由于WebGPU提供的是比较细粒度的API,要使用WebGPU实现一个类似GPU.js开头的矩阵乘法功能还是比较繁琐的,稍安勿躁。

支持的输入格式:

  • Number
  • 一维、二维或三维的数组(Array与Float32Array等其他类型化数组)
  • 扁平化数组
  • HTML Image

访问GPU

  1. 使用navigator.gpu.requestAdapter()访问GPU,将返回一个Promise对象,resolve中包含一个GPU适配器。将适配器想象成显卡,集显或独显。
  2. 得到适配器后就可以通过使用adapter.requestDevice()来获取GPU设备。
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();

缓冲区内存操作

由于现代浏览器的沙箱机制,这个操作并不能直接进行,需要结合map/unmap。(目前的GPU进程还未实现sandbox)

写入缓冲区内存

  1. 使用device.createBufferMappedAsync()得到指定尺寸和用法标记的缓冲区对象。resolve会返回一个GPU buffer对象,与原始二进制buffer关联。
  2. 写入字节操作类似于将一个类型化数组的值拷贝进一个已获得的ArrayBuffer。
// 获取GPU buffer与用于写入的arrayBuffer
// 成功后GPU buffer会进入映射状态(使用返回的引用对象访问)
const [gpuBuffer, arrayBuffer] = await device.createBufferMappedAsync({
  size: 4,
  usage: GPUBufferUsage.MAP_WRITE
});

// 将字节写入缓冲区
new Uint8Array(arrayBuffer).set([0, 1, 2, 3]);
  • 此时,GPU buffer是一个指针,意味着它是属于GPU的,可以使用JS对其进行写入与读取。也可以使用gpuBuffer.unmap()来取消映射
  • map/unmap概念是为了避免CPU与GPU同时访问内存时竞态条件的产生而出现的。在目标宿宿主环境得到一个内存数据的指针,使其可以进行编辑,无需设置两块存储区域并进行移动操作。

读取缓冲区内存

尝试将的一个GPU buffer内容拷贝到另一个GPU buffer中。

  1. 第一块GPU buffer除了要写入外还存在作为拷贝源操作,因此在usage标记中需要添加一个GPUBufferUsage.COPY_SRC
  2. 第二块GPU buffer会以unmapped的状态使用同步的device.createBuffer()创建,其usage标记为GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ意味着它将作为拷贝目标并会被JS读取一次。
const [gpuWriteBuffer, arrayBuffer] = await device.createBufferMappedAsync({
  size: 4,
  usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC
});

// 将字节写入buffer
new Uint8Array(arrayBuffer).set([0, 1, 2, 3]);

// 解除buffer映射,以便之后用于拷贝
gpuWriteBuffer.unmap();

// 取得一个GPU buffer用于在解绑状态下读取它的值
const gpuReadBuffer = device.createBuffer({
  size: 4,
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
});

指令处理:

  • 由于GPU是一种独立的协处理器,所有GPU指令的执行均为异步的。也就是为何会构建一个指令列表,在需要时打包发送
  • 在WebGPU中,使用device.createCommandEncoder()得到的GPU指令编码器是一个JS对象,该对象打包一些"缓存的"指令并在某一时间发送给GPU。
  • GPUBuffer上的方法是另一种情况,它们是非缓存的,当调用时就会自动执行。

函数调用:

  • 当创建好GPU指令编码器后,调用copyEncoder.copyBufferToBuffer()即可将这个指令添加到稍后执行的指令队列中。
  • 最后,调用copyEncoder.finish()来完成指令编码,并将其提交到GPU设备的指令队列中,队列会处理device.getQueue().submit()中传入的指令来完成提交。之后将会自动按顺序执行数组中存放的指令。
// 编码用于拷贝buffer的指令
const copyEncoder = device.createCommandEncoder();
copyEncoder.copyBufferToBuffer(
  gpuWriteBuffer /* source buffer */,
  0 /* source offset */,
  gpuReadBuffer /* destination buffer */,
  0 /* destination offset */,
  4 /* size */
);

// 提交拷贝指令
const copyCommands = copyEncoder.finish();
device.getQueue().submit([copyCommands]);

此时已经发送了GPU队列指令,但还没必要执行。为了读取第二块GPU buffer,调用gpuReadBuffer.mapReadAsync(),当队列中所有的GPU指令执行完成后,会返回一个包含与第一块GPU buffer相同数据的ArrayBuffer。

// 读取buffer.
const copyArrayBuffer = await gpuReadBuffer.mapReadAsync();
console.log(new Uint8Array(copyArrayBuffer));

完成。

计算着色器 - compute shader

计算着色器是GPU中特殊的一种着色器,与图形着色器不同,常用它进行大吞吐量的计算任务,一般不直接用于三角形与像素的绘制。目前Vulkan、DirectX 3D和OpenGL等主要图形API均提供了计算着色器的能力。

同样以矩阵相乘为例,使用计算着色器执行通常需要以下几步:

  1. 创建三块GPU buffers(两个矩阵用于相乘,一个用于保存结果)
  2. 描述计算着色器的输入和输出
  3. 编译着色器代码
  4. 创建计算管道
  5. 将打包的编码指令提交给GPU
  6. 读取GPU buffer中的结果矩阵

其中包含了几步前面介绍的基本操作,对于WebGPU中特殊的shader操作做简要介绍。

绑定数据与缓冲区 - bindGroup & bindGroupLayout

对应上面流程的第2步。

bindGroupLayout定义一个着色器期望的输入/输出接口,bindGroup则表示一个着色器实际的输入/输出数据。

在下面的示例中可以看到,bindGroupLayout中定义了计算着色器中期望的存储buffer,并对应其绑定的序号(如binding: 0)。

另一方面bindGroup中则根据bindGroupLayout来定义,将GPU buffer与其binding序号绑定起来。

const bindGroupLayout = device.createBindGroupLayout({
  bindings: [
    {
      binding: 0,
      visibility: GPUShaderStage.COMPUTE,
      type: "storage-buffer"
    }
    ...
  ]
});
const bindGroup = device.createBindGroup({
  layout: bindGroupLayout,
  bindings: [
    {
      binding: 0,
      resource: {
        buffer: gpuBufferFirstMatrix
      }
    },
    ...
  ]
})

计算管道 - compute piepieline

对应上面流程的第3和第4步。

Chrome中的WebGPU使用的是二进制码而不是原始的GLSL代码,因此在执行着色器代码前需要先进行编译。 @webgpu/glslang提供了这个能力,可以将computeShaderCode编译成WebGPU接受的格式。这种二进制码格式是基于SPIR-V的一个子集。

import glslangModule from 'https://unpkg.com/@webgpu/glslang@0.0.8/dist/web-devel/glslang.js';

计算流水线是一个可以描述我们实际执行的计算操作的对象,通过调用device.createComputePipeline()创建。接收两个参数:之前的bindGroupLayout对象和一个计算阶段(包含定义的计算着色器入口(主GLSL函数)及使用glslang.compileGLSL()编译的实际计算着色器模块)。

图形着色器的硬件侧的阶段一般包含光栅化等步骤,而计算着色器则会直接进入计算单元进行处理。

const glslang = await glslangModule();

const computePipeline = device.createComputePipeline({
  layout: device.createPipelineLayout({
    bindGroupLayouts: [bindGroupLayout]
  }),
  computeStage: {
    module: device.createShaderModule({
      code: glslang.compileGLSL(computeShaderCode, "compute")
    }),
    entryPoint: "main"
  }
});

关于WebGPU API使用的内容主要参考的google developer中的这篇文章。

其他

安全性

Chrome中的渲染进程是在sandbox中,隔离了环境,降低了一些恶意代码执行带来的影响。WebGPU也会在GPU进程中做一些类似的工作,并且随着渲染流水线的不断改进(比如Viz的出现与绘制-合成阶段的步骤调整),个人猜测或多或少也会影响WebGPU的实现。

除此之外,在规范中也提出了一些安全性问题:

  • 基于CPU和GPU的未定义行为
  • 着色器中的越界访问与硬件的拒绝访问
  • 由于是基于Web Worker的多线程设计,存在类似SharedArrayBuffer所引起的时序攻击的隐患

性能对比

benchmark测试结果

应用

目前Babylon.js提供了实验性的WebGPU能力,对应的分支,在4.1会全面支持WebGPU。

未来计划同时提供WebGLWebGPU两种创建渲染引擎的方式。

所支持的相关特性进展列表演示实例

GPU.js原理浅析

我们已经知道在GPU.js的内部会将JS函数解析为WebGL程序中的着色器代码并执行,那么第一眼可能会想到解析js代码、验证配置与数据类型、生成GLSL代码等步骤,当这些也是主要的步骤,下面来看看具体它是怎么做的。

创建gpu实例后,会根据配置或自动选择kernel,接着调用createKernel来构建执行实际计算任务的回调函数。

下面介绍kernel中几个关键模块,主要是用于:解析js代码、转换成GLSL代码和添加WebGL处理代码

Kernel

在Kernel中主要做了(以WebGLKernel为例):

  • 构建与处理WebGL程序中的各个阶段代码(创建主程序,加载着色器,处理坐标,绑定缓冲区,处理纹理等等)

  • 将片元着色器中各阶段所用到的GLSL代码块(artifacts)存储在一个Map中,用于生成最终的着色器

    如在shader中获取常量

    _getFragShaderArtifactMap(args) {
      return {
        ...
        CONSTANTS: this._getConstantsString(),
        ...
      };
    }
    _getConstantsString() {
      const result = [];
      const { threadDim, texSize } = this;
      if (this.dynamicOutput) {
        result.push(
          'uniform ivec3 uOutputDim',
          'uniform ivec2 uTexSize'
        );
      } else {
        result.push(
          `ivec3 uOutputDim = ivec3(${threadDim[0]}, ${threadDim[1]}, ${threadDim[2]})`,
          `ivec2 uTexSize = ivec2(${texSize[0]}, ${texSize[1]})`
        );
      }
      return utils.linesToString(result);
    }
    

    着色器中的预置位

    const fragmentShader = `#version 300 es
    ...
    __CONSTANTS__;
    ...
    `
    
  • 解析JS生成AST,验证所用的数据类型与语句(由于最终会转换成GLSL,仅支持部分语句和类型)

kernel-value-maps

对不同数据类型的变量,提供一些函数用于数据的:

  1. 声明:生成片元着色器中变量声明等语句的字符串
  2. 更新:WebGL程序中对着色器纹理等数据的更新操作

以HTMLImage类型为例

// 生成在片元着色器中声明变量的代码,与WebGL中的名称保持一致以获取数据
getSource() {
  return utils.linesToString([
    `uniform sampler2D ${this.id}`,
    `ivec2 ${this.sizeId} = ivec2(${this.textureSize[0]}, ${this.textureSize[1]})`,
    `ivec3 ${this.dimensionsId} = ivec3(${this.dimensions[0]}, ${this.dimensions[1]}, ${this.dimensions[2]})`,
  ]);
}

// 将更新的纹理图像传递给着色器
updateValue(inputImage) {
  ...
  const { context: gl } = this;
  gl.bindTexture(gl.TEXTURE_2D, this.texture);
  ...
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.uploadValue = inputImage);
  this.kernel.setUniform1i(this.id, this.index);
}

FunctionNode

插入处理不同数据类型、操作符及表达式所需要的函数,生成字符串,这些函数为在片元着色器中预置的函数。

...
case 'ArrayTexture(4)':
case 'HTMLImage':
case 'HTMLVideo':
  retArr.push(`getVec4FromSampler2D(${ markupName }, ${ markupName }Size, ${ markupName }Dim, `);
  this.memberExpressionXYZ(xProperty, yProperty, zProperty, retArr);
  retArr.push(')');
  break;
...

着色器

顶点着色器

在顶点着色器中只做了基础操作:计算变换后的顶点位置传递纹理坐标

in vec2 aPos;
in vec2 aTexCoord;
out vec2 vTexCoord;
uniform vec2 ratio;
void main(void) {
  gl_Position = vec4((aPos + vec2(1)) * ratio + vec2(-1), 0, 1);
  vTexCoord = aTexCoord;
}

片元着色器

在片元着色器中存在多个阶段操作的预置位和对于不同类型变量的预处理代码。

__HEADER__;
__PLUGINS__;
__CONSTANTS__;
varying vec2 vTexCoord;
...
vec4 getVec4FromSampler2D(sampler2D tex, ivec2 texSize, ivec3 texDim, int z, int y, int x) {
  return getImage2D(tex, texSize, texDim, z, y, x);
}
...
vec4 getImage2D(sampler2D tex, ivec2 texSize, ivec3 texDim, int z, int y, int x) {
  int index = x + texDim.x * (y + texDim.y * z);
  int w = texSize.x;
  vec2 st = vec2(float(integerMod(index, w)), float(index / w)) + 0.5;
  return texture2D(tex, st / vec2(texSize));
}
...
void main(void) {
  index = int(vTexCoord.s * float(uTexSize.x)) + int(vTexCoord.t * float(uTexSize.y)) * uTexSize.x;
  __MAIN_RESULT__;
}

最后

Web端的GPU计算在一般的业务场景中用处甚少,不过在Web端游戏、图形化相关产品和并行计算相关领域上还是有很大发挥空间的。

如WebGPU一样,诸如Native File System API这类API的出现,未来Web端的本地化能力只会越来越强,不过也会越来越臃肿,这可能是浏览器开发团队需要考虑的问题之一吧。

参考