HuldaGnodim 发表于 2021-12-13 18:19

移动AI系列-web前端利用GPU进行加速计算

在web端浏览器内运行神经网络是一件非常有趣的事情,能够实现很多创新交互的作品。但是,CPU计算密集型的事情性能问题也非常明显,所以会使用更快的GPU进行计算支撑。WebAI在webgl backend 的表现比cpu backend还要优秀很多。在web前端如何通过JavaScript调用GPU进行计算?一起通过这篇分享了解吧!作者:王群,爱民
GPU叫做图形处理器,CPU叫做中央处理器,所以GPU 与 CPU 擅长执行不同类型的计算任务。CPU 通过复杂的 Cache 设计实现低延迟,包含复杂的控制逻辑(分支预测),ALU 只占一小部分。而 GPU 为高吞吐量而生,包含大量 ALU。因此在单指令流多数据流(SIMD)场景下,GPU 的运算速度远超 CPU,并且这种差距还在不断拉大。可见,GPU与CPU相比拥有强大的内存访问带宽和并行单元运算能力。



图:CPU和GPU的区别

在web端通过JavaScript能够使用GPU进行计算的方式主要是利用WebGL或者WebGPU的计算特性,我在web前端智能推理库paddle.js的开发中为了提升神经网络在web端的计算速度,利用了WebGL调用GPU进行并行化计算的特性充分发挥GPU的并行计算能力,同时结合WebWorker多线程技术,大幅度提升了大数据量的结果输出速度,在一些情况下甚至GPU可以将时间缩短为原始时间的1/100以上。



图:matrices multiplication speed

1、适合利用GPU计算的场景

GPU 强大的计算能力早已不局限于渲染,General-purpose computing on graphics processing units 即 GPU 通用计算概念的提出将这种能力推向了更广阔的计算场景。通用计算领域的实践包括了视频解码、实时加解密、图片压缩、随机数生成、2/3D仿真、AI等等,这些都属于高性能的计算密集型任务。如果是web端,以目前可以利用的算力来看,用GPU进行计算。

[*]并行化,适合并行的数据密集型计算任务;
[*]多层次,面向数据类型统一且相互无依赖的多层计算;
[*]多维度,进行多维度的张量计算;
[*]多顶点,可视化图像、布局的计算;
具备以上四个特点中任意一个都可以考虑使用GPU进行计算加速,例如2D/3D图形展现最耗时的是计算并非渲染,其中大量计算就是依靠webGL的API触发各种GPU计算完成;著名的WebAI在线推理库TensorFlow.js也是利用GPU在并行化和多层次计算中的优势,将庞大的神经网络逐层进行多点计算保证实时性反馈输出。



图:利用GPU在web上进行手势计算

2、利用WebGL 实现GPU并行计算的原理

得益于NVIDIA(英伟达)提出的 CUDA(Compute Unified Device Architecture) 这一统一计算架构的实现,开发者可以使用 C、Java、Python 等语言编写自己的并行计算任务代码。



图:CUDA软件层次结构

在CUDA程序构架中,主程序还是由CPU 来执行,而当遇到数据并行处理的部分,CUDA 就会将程序编译成 GPU能执行的程序,并传送到GPU。而这个程序在CUDA里称做核(kernel)。所以在 Web 端我们依然可以根据这种思路使用GPU的计算能力。Host指的是CPU,Device指的是GPU。WebGPU和WebGL都是可以在JavaScript主程序中调用GPU进行计算方法,但是由于目前浏览器支持程度,虽然webGPU有很多好处,选择webGL实现这一环节的逻辑是更加可行的。
webGLwebGPUcompute shader不支持compute shade支持非标准,苹果不支持webgl2W3C标准,未来各大浏览器都会支持已发布比较成熟正式版未发布基于Vulkan、Metal和Direct3D 12更好的性能,支持多线程WebGL是OpenGL ES低级3D图形API的Web版本WebGPU是GPU硬件(显卡)向Web(浏览器)开放的低级应用程序接口(API),包括图形和计算两方面的接口
2.1 计算原理抽象

并行计算发生在光栅化阶段,我们将计算逻辑(核函数)写在 Fragment Shader 中,Vertex Shader 仅负责映射纹理坐标,所以整个计算过程可以抽象成多个纹理数据在计算容器中对于指定图形区域根据相应的shader计算程序进行并行像素点的计算,等待计算完成后从GPU中读取数据结果。



图:调用流程

通常来说图形渲染 API 最终的输出目标就是屏幕,显示渲染结果。但是在 GPGPU 场景中我们只是希望在 CPU 侧读取最终的计算结果。因此会使用到渲染 API 提供的离屏渲染功能,即渲染到纹理,其中的关键技术就是使用帧缓存对象(Framebuffer Object/FBO)作为渲染对象。纹理用来存储输入参数和计算结果,因此在创建时我们通常需要开启浮点数扩展OES_texture_float,该扩展在 WebGL2 中已经内置。



图:GPGPU流程

2.1 计算初始化

任何计算都需要一个承接载体,分配计算“空间”大小,在web上使用canvas。因为咱们主要是计算,所以对坐标相关的数据可以不用太多关注,直接画一个铺满画布的矩形就可以了。这样,我们就拥有了计算的环境,根据这个画布的大小(canvas的width和height)也决定了我们可以计算数据的数量。



图:坐标体系

纹理的坐标系和canvas中webgl坐标的是不一样的,纹理texture是以左下角为原点,水平向右为x正方向,垂直向上为y轴正方向;Vertex Shader中得到的坐标是以canvas中心为(0,0)水平向右为x轴正方向,垂直向上为y轴正方向,两轴的取值范围为[-1, 1]。所以,需要把canvas坐标进行转化成和纹理的坐标系一致。
   // vertexShaderSource 资源   
   // webGL2 这段代码其实是坐标转换,需要把canvas坐标进行转化成和纹理的坐标系一致
    #version 300 es
    in vec4 position; // 接受坐标
    out vec2 vCoord; // 传递坐标
    void main() {
      vCoord.x = (position.x + 1.0) / 2.0;
      vCoord.y = (position.y + 1.0) / 2.0;
      gl_Position = position;
    }根据webGL提供的方法可以传入4个顶点绘制一个铺满画布的矩形(2个三角面),方法如下所示:
// 传入坐标信息,8个数值实际标明了4个顶点坐标
const vShaderData = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vShaderData, gl.STATIC_DRAW);
canvas开始绘制的时候Vertex Shader中得到每个需要绘制的像素的坐标,根据需求可以对坐标进行各种转换最终得到一个最终位置,这个过程中可以将数据作为输出传入Fragment Shader参与下一步的计算。
2.2 计算数据上传GPU

想要通过顶点着色器和片元着色器拿到的信息必须要提供给GPU,着色器有四种方式可以获取到这些绘制所需的数据。

[*]Attributes and Buffers:Buffer通常是你上传到GPU的二进制格式的数组,通常Buffer包含位置、法线、纹理坐标、顶点颜色等信息,当然你可以给Buffer传入任何你想要的值。Attributes用于定义如何从Buffer中拉取数据并把它们提供给顶点着色器。比如你在Buffer中存放了一些位置信息,每个位置用一个三维的32位的浮点数表示。你需要告诉特定的attribute应该从哪个Buffer中去获取这些位置信息,以什么样的数据格式去拉取这些信息,这些位置信息的起始地址是什么,每个位置信息的大小又是多少。Buffer不是随机存取的。相反,一些顶点着色器执行特定的次数,每次执行时,都会从每个指定的缓冲区取出下一个顶点值值并赋给一个attribute。
[*]Uniforms:是在执行shader程序前设定的高效的全局变量,在单次绘制调用中对所有的顶点保持一致;比如平移变换时为所有顶点平移同样的距离;
[*]Textures:是shader程序中可以随机存取的数据信息,Textures中通常存储的是图像数据,但是只是不包括图像的颜色信息(2^mx2^n尺寸);
[*]Varyings:是顶点着色器向片元着色器传递数据的一种方式,比如在顶点着色器中定义与位置信息关联的颜色变量,然后将其传给片元着色器用于后期绘制。根据基元类型(点、线、三角形),在顶点着色器中由varying设定的变量值在片元着色器执行过程中会被插值;
一般情况下,将数据从CPU拷贝到GPU,在 WebGL 中会通过纹理texture绑定完成。每个像素的颜色可以有RGBA四个维度表示,每个维度范围为0-255(既8位)。把RGBA表示成数值的话,那每个像素可以存32位。这就是前端使用gpu计算最为核心的一点,每个像素可以存储一个32位的值,刚刚好就是一个int或者uint。JavaScript中的Number类型精度为double64类型,而一般GPU的图形接口api(如opengl、DirectX等)中浮点数精度为float32,数据在CPU传入GPU做渲染过程中会有精度损失,所以计算数据的精度必须限制在32位。(当然也有办法解决这个问题,比如Relative To Center方案或者使用2个float传高精度的数据。)



图:texture数据映射

利用纹理存储数据,通过纹理坐标可以在纹理图像上获取元素颜色,每个颜色可以存储32位数据,生成纹理参与下一步的计算。
/**
* 创建纹理,加载纹理图像,图像纹理数据处理并绑定program
* @param index: index
* @param tSampler: uniform sampler2D
* @param pixels: canvas
*/
initTexture(index, tSampler, pixels) {
    // 创建纹理对象
    const texture = this.gl.createTexture();
    this.gl.activeTexture(this.gl[`TEXTURE${index}`]);
    // 绑定纹理对象
    this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
    // 配置纹理参数
    this.gl.texParameteri(this.gl.TEXTURE_2D,
         this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
    // 配置纹理参数
    this.gl.texParameteri(this.gl.TEXTURE_2D,
         this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
    // 配置纹理图像
    this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA,
         this.dimension, this.dimension, 0, this.gl.RGBA,
         this.gl.UNSIGNED_BYTE, pixels, 0);
    // 获取tSampler的存储位置,将index号纹理图像传递给着色器   
    this.gl.uniform1i(this.getUniformLoc(tSampler), index);
}
// 获取Sampler的存储位置
getUniformLoc(name) {
    let loc = this.gl.getUniformLocation(this.program, name);
    if (loc == null) throw `getUniformLoc ${name} err`;
    return loc;
}
2.3 计算程序绑定

GLSL是可以在图形管道(graphic pipeline)中直接执行的OpenGL着色语言。片元着色器的功能就是计算出当前基元的每一个像素点的颜色信息,利用片元着色器可编程的特性可以对于每个像素点进行程序设计,这里也就对应着所谓的单元算法。通过例子中片元着色器代码可以看到输入数据vCoord经过一系列计算最终输出o_result参与下一步的计算。
    // fragmentShaderSource 资源
    #version 300 es
    // 使用precision关键字进行精度设置。
    // 声明变量精度高低的三个关键子lowp、mediump和highp。
    // 不同的shader里面有默认值,如果不指定或者指定错误,会导致编译报错。
    precision highp float;
    precision highp int;
    // 这里可以用replace做到CANVAS_SIZE替换成具体数值
    const int U_LENGTH = CANVAS_SIZE;
    // 全局变量
    uniform float i_matrixA;
    uniform float i_matrixB;
    // 输入单点数据
    in vec2 vCoord;
    // 输出单点数据
    out vec4 o_result;
    vec4 int2rgba(const int i) {
      vec4 v4;
      v4.r = float(i >> 24 & 0xFF) / 255.;
      v4.g = float(i >> 16 & 0xFF) / 255.;
      v4.b = float(i >>8 & 0xFF) / 255.;
      v4.a = float(i >>0 & 0xFF) / 255.;
      return v4;
    }
    vec4 reverse(const vec4 v){
      return v.abgr;
    }
    int getValue(float matrix, int x, int y){
      return int(matrix);
    }
    // 每个shader函数都必须有main函数
    void main() {
      // readPixels读取数值时次序与数组不一致,
      int curX = int(float(U_LENGTH) * v_pos.y);
      int curY = int(float(U_LENGTH) * v_pos.x);
      int sum = 0;
      for (int i = 0; i < U_LENGTH; i++) {
            int valA = getValue(i_matrixA, curX, i);
            int valB = getValue(i_matrixB, i, curY);
            sum += valA * valB;
      }
      o_result = reverse(int2rgba(sum));
    }
其次,要将各部分的计算程序进行初始化,每部分程序使用着色器语言(例如GLSL)实现,在程序中对像素点进行计算程序的实现,完成计算程序的编译。
/**
* 绑定并编译着色器程序
* @param code: shader code string
* @param code: gl.VERTEX_SHADER or gl.FRAGMENT_SHADER
*/
initShader(code, type) {
    const shader = this.gl.createShader(type);
    this.gl.shaderSource(shader, code);
    // 把GLSL shader编译为二进制数据供WebGL使用
    this.gl.compileShader(shader);
    // 将着色器对象与 WebGLProgram对象关联
    this.gl.attachShader(this.program, shader);
}
2.4 计算控制

我们通过两个与canvas区域等面积的纹理表示数据,这样能够保证每个数据在canvas上都拥有计算环境(对于每种设备可以计算的最大值,可以通过gl.MAX_TEXTURE_SIZE获取)。计算控制的核心思想就是加载计算数据,将计算数据引用链接到当前绑定的执行程序,通过gl.drawArrays触发GPU管线进行每个像素的计算,将计算结果存储在相应的显存中。


CPU 需要准备提交给 GPU 的指令和数据,在 WebGL 中通过调用一系列 API 实现。
// 加载shader code
const vshaderCode = await fetch(vertexShader).text();
const fshaderCode = await fetch(fragmentShader).text();
// 初始化shader program
this.initShader(vshaderCode, this.gl.VERTEX_SHADER);
this.initShader(fshaderCode, this.gl.FRAGMENT_SHADER);
// 链接一个给定的WebGLProgram,完成为程序片段和顶点着色器准备GPU代码的过程
this.gl.linkProgram(this.program);
// 为当前渲染阶段指定WebGLProgram对象
this.gl.useProgram(this.program);
// 初始化纹理
this.initTexture(0, "samplerA", matrixA)
this.initTexture(1, "samplerB", matrixB)
// 传入坐标信息
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.gl.createBuffer());
let vecPosArr = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);
// 为目标指定WebGLBuffer对象
this.gl.bufferData(this.gl.ARRAY_BUFFER, vecPosArr, this.gl.STATIC_DRAW);
// 获取指定WebGLProgram对象中的某个attribute变量的地址索引
let posAtrLoc = this.getAttribLoc("position");
this.gl.enableVertexAttribArray(posAtrLoc);
this.gl.vertexAttribPointer(posAtrLoc, 2, this.gl.FLOAT, false, 0, 0);
// 清理画布,将buffer重置为预设值
this.gl.clearColor(.0, .0, .0, 1);
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
// 根据数组对象绘制图元
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
2.5 结果读出

保证数据在GPU中计算完成后,最后把计算结果从 GPU 内存中拷贝回 CPU 内存,在 WebGL1 中通过读取纹理中像素值完成,如果使用了framebuffer也可以从framebuffer中读取。无论如何,不要有CPU和GPU中间太多的计算切换,因为从内存到显存或者从显存到内存会消耗很多时间。
/**
* 读取数据
*/
read() {
    // 分配等量的数据空间
    let picBuf = new ArrayBuffer(this.dimension * this.dimension * 4);
    let picU8 = new Uint8Array(picBuf);
    let picU32 = new Uint32Array(picBuf);
    this.gl.readPixels(0, 0, this.dimension, this.dimension,
         this.gl.RGBA, this.gl.UNSIGNED_BYTE, picU8);
    return picU32
}
4、小结

在web端进行大规模的计算确实不是一个好主意,但是有些场景却需要在小小的浏览器或者webview容器内进行快速的计算,高性能的计算虽然依赖于用户端的算力和算法的实现,如果不能与客户端协同,那么web工程师只能选择尽可能的利用用户端浏览器能提供的计算资源。目前,涉及图像、地图或者神经网络等复杂计算的场景,前端利用GPU的帮助能够有效提升计算速度。随着webXR,webNN等能力的发展,很多具有大量计算的事情将逐渐被浏览器支持,W3C正在推动这些能力,这样可以充分利用设备更合适地硬件来增强算力已达到更好的性能体验。

参考资料

[*]潘与其:如何玩转 WebGL 并行计算
[*]GPU工作原理及WebGL入门_落枫秋歌-CSDN博客_gpu工作原理
[*]CUDA与OpenCL架构 - xiuneng - 博客园
[*]paddlejs
页: [1]
查看完整版本: 移动AI系列-web前端利用GPU进行加速计算