沧东 - 如何在图可视化场景下进行性能优化
共 14098字,需浏览 29分钟
·
2021-07-08 14:21
点击上方蓝字关注我们
前端早早聊大会,前端成长的新起点,与掘金联合举办。加微信 codingdreamer 进大会专属周边群,赢在新的起跑线。
第二十九届|前端可视化专场,了解数据可视化/时空可视化/大屏/搭建/画布/等等的可能性,7-17 全天直播,10 位讲师(贝壳/奇安信/预策科技/蚂蚁/小米/阿里/阿里云/数字冰雹等等),点我上车👉 (报名地址):
所有往期都有全程录播,可以购买年票一次性解锁全部
正文如下
本文是第十八届 - 前端早早聊性能优化专场,也是早早聊第 128 场,来自 阿里 蚂蚁-沧东 的分享。
开场
今天带来的分享和可视化场景有关,是关于图的可视化,如果有对图可视化这些场景可能不太了解的,我待会会举一些例子,会给大家简单描述一下我们的场景大概是长什么样子,以及我们之前在业务里在这些场景下都遇到了哪些性能问题,我们有一些对应的优化手段,这些大概今天会分享的内容。
这里面可能会涉及到前端同学可能会较少接触到的工具或者场景,我们的优化手段也不会局限在 Web-Worker 等等这些常见的手段,可能更多的会借助 GPU 的能力去做一些针对性的性能优化方案。
自我介绍
我的花名叫沧东,来自蚂蚁金服的体验技术部,下面是我的知乎的个人主页,可能会在上面不定期的分享一些和可视化相关的一些文章,或者一些其他技术方面。如果大家有兴趣的话可也可以关注一下。
首先我们来举一个图可视化场景例子。
图可视化场景
在这样一个例子里面,如果忽略掉这些和业务非常相关的操作,可以看到视频里有很多的节点,它们之间会有关系,我们会把节点和节点之间的关系用这个边来表示,它们会有很多种不同的布局方案。
布局就是指把每个节点和边放在哪个位置,在不同的布局之间,可以进行任意的切换。常见的布局有力导布局 ,可以在不同布局之间互相切换。这个就是一个常见的图可视化场景。我们需要有一些性能非常高的手段,帮助用户在这样的场景里做一些图相关的分析工作。
性能优化方案
在图可视化场景下,遇到的性能问题,可以分成三种类型。抛开图可视化场景,在大多数可视化场景下,遇到的性能问题分别是计算的问题,渲染的问题以及交互的问题。
高性能计算
图领域中的常见算法
在计算里会遇到什么样的性能问题呢?在这个图可视化领域中,经常会用到一些常见的算法,大致可以分成两类:
第一类叫 布局算法,就刚刚在演示视频里看到的,数据里面会包含很多的节点以及它们的关系。要做的就是根据节点和边的关系,算出每个节点的位置,并把它画出来。常见的是力导布局。 第二类叫分析算法,常见的一个计算任务可能是在一个图里,需要查找一些最短路径,或者还有一些配置 Rank 等等,有大量的分析算法。
布局算法介绍
在布局算法中,我们会遇到什么问题?力导布局大概的表现就是这个节点和节点之间会有不同种类,不同非常多种力的作用下,会计算得到每个节点的位置。看右边这张图,以中间这个节点为例,它会受到三种类型的力的作用:
第一种,它会受到 重力 的影响,在这个场景里它可能是指向我们的坐标原点,在 Gravity 还会受到重力的影响。 第二种,它会受到 吸引力 的影响,以中间这个节点为例,它会受到左边这个点对它的吸引力,把它往左边进行拉伸。 第三种,它会受到 排斥力的影响,它会受到下方这个节点给它往上顶的这样一个排斥力的作用。
布局算法
结合这三种不同类型的力的共同作用下,计算出每个阶段的位置,计算每一个节点都需要计算这么多种力,跟它周围这么多节点进行计算,它的算法复杂度是非常高的,运算规模也是很大,自然性能也会不好。
以具体的一个例子来看, Fruchterman 是力道布局中的一种。它的算法复杂度是相对比较高的, V 是一个节点的数目,E 就是边的数目,它的算法复杂度大概是节点的数目乘以边的数目,再乘以一个迭代次数。
迭代次数是为了使布局达到稳定,需要重复的运算一定的数量,通常这个值可能会在比如 1000 、2000 都有可能,视不同的业务来定。
这个算法复杂度其实已经相当高了。左边这张图就实际的效果,它的节点可能会有 200 多,边可能会有 1000 多,迭代次数可能会有 3000 左右。在这样的算法复杂度下,如果我们在 CPU 端进行运算,会造成一个什么样的结果呢?
它的整个耗时会达到 30 秒,大家可以直接访问上面的 demo 链接,它的体验是很不佳的。
这还是在已经进行了一定的优化的基础上,仍然需要 30 秒。我们不希望用户在这 30 秒之内什么都做不了,只能在这傻等。因此首先会尝试把这堆复杂的运算丢到 WebWorker 里。
把这些计算放在 Worker 里,会有一个好处,虽然还是需要等 30 秒,但在这个过程中主线程的一些交互,比如页面的滚动可以不受影响,用户至少还能浏览页面的其他内容。但是这 30 秒也确实很长,这就涉及到我们今天的主题了。
使用 GPU 进行通用计算
我们可以跳出 CPU 的限制,尝试用 GPU 去解决一些高复杂度的计算问题,用 GPU 进行计算这个概念,其实早在 20 年前就有科学家提出了,最初 GPU 发明出来肯定是用于渲染图形或网页,但是当时就有叫 Mark Harris 的科学家,就提出说 GPU 的计算能力这么强大,是不是可以不仅仅用它来做渲染,也可以用它来做一些通用计算。
他当时就提出了用 GPU 进行通用计算,简称 GPGPU 的概念。他提出这样一个概念之后,后面的很多年,有大量这方面的实践,例如一些视频的编解码、仿真等等。
GPU 和 CPU 的性能对比
GPU 和 CPU 之间的性能到底差多少呢?
这张图是非常直观的。它表达了 GPU 和 CPU 在浮点数的运算效率上差距会越来越大。
适合并行计算的 GPU
发展到今天,GPU 其实是更强了。比如 NVidia 或者 AMD 的 GPU,尤其是 NVidia ,这个行业 GPU 行业的龙头老大,为了在 GPU 中去做更多的计算,甚至把 TENSOR CORE 专门用来做光线追踪的硬件。这样的 GPU 处理,比如说张量的计算,可以简单理解为一些矩阵的运算,以及做光线追踪的时候,由于硬件加速的加持,它会比 CPU 更适合做这样的任务,效率是更高的。
可并行的矩阵乘法
到底什么是可并行的计算 ?到底什么是可并行 ?
举一个简单例子,假如说现在我们想实现两个矩阵相乘或者两个矩阵相加。用 JS 去写,一种最朴素的写法,大概就是这样:我们可能会拿到这两个矩阵,分别去遍历它们里面的元素,依次相乘。大家都会写这样的两层嵌套的循环来完成这样一个矩阵乘法。
但是大家仔细想一想,当这个算法在 CPU 中按顺序执行的时候,它其实会有一个性能问题,就在于什么呢?
当我在计算第一个矩阵和第二个矩阵,它们中的第一个元素相乘的时候,其实同时就可以计算它们第二个元素相乘。
大家可以想一下这个问题,就是我后续的矩阵中每个元素的运算,其实它是可以并行计算,我并不需要像我们写的这样一个串行算法一样,必须要等到第一个元素算完了之后,再去算第二个元素,再算第三个元素。
当我们的矩阵的维度非常高的时候,我们计算时间他不就显然会拉长了吗?
但是很幸运的是矩阵乘法它本身是可并行的,是什么意思?
如果从 GPU 的视角来看,或者从线程可并行的思路,去尝试解决这样的问题,就会写出类似这样的代码。
给每一个线程分配一个任务,让每一个线程只负责去计算这两个矩阵中的某一个元素,比如第一个线程你就去计算第一个,第二个线程的你就去计算第二个,你们这么多个线程的,你们可以并行同时的去计算,最后再把结果汇总起来。这样就是一个线程可并行的概念。这种思路如果总结下来,就是可以让多个线程去执行相同的程序。
如果这么多线程全去处理一样的数据,它是没有意义的,必须要每个线程能够处理各自的数据。这样的程序在 CUDA 中会叫核函数。
GPGPU
分配很多任务给每一个线程,让这么多线程一起并行的去计算,它的速运算速度效率可能会高。
在当今并行计算这个领域的绝对王者,可能是 Media。它其实也在 GPGPU 概念提出之后不久,它就推出了自家的这样一个叫 CUDA 的通用的并行计算的平台,支持很多种语言,比如 Java、Python、C、C++,没有 JS ,因为它是需要在 GPU 中运行的,它提出 CUDA 的概念之后,后面就有很多在 CUDA 基础上的应用就应运而生。
在图领域中比较关心的一个叫 nvGRAPH 的应用,它也是基于 CUDA 实现的,它里面就会内置很多各种各样的图分析算法,最短路径的计算、PageRank 等等,它基于 GPU 实现的算法的执行效率是非常高。可以在它的官网上看到,它宣称自己是可以支持 20 亿条边,叫大规模的数据运算 。
在 Web 端使用 GPGPU
CUDA 有这么多优秀的实现,甚至它有很多高性能算法都已经实现好了。作为前端,在 Web 端如果也想使用 GPU 做并行计算的能力,我们有什么样的手段呢?
目前在 Web 端大概只能通过两类 API:
第一类,最常用的可能就是 WebGL ,它的好处就是相对来说它的浏览器的兼容性是比较好。不管是做渲染,像 Three.js 或者 Babylon.js 这样的渲染引擎,可以在手机上运行或者 tensorflow 等等。它们其实都是可以在手机端或 PC 端,但是它缺点就是能力是比较有限。 第二类,相对比较新的一类 API ,叫做 WebGPU 。相比 WebGL,它可能是更适合做 GPU 计算的一种新的规范。它目前是属于实验中,它的好处就是他相对外表他比较新,所以它的能力会比较丰富。
已有的实践
在 Web 端高性能计算比较有名的实现是 tensorflow.js。它相当于把关于这些模型或者张量的运算,可以让前端开发者在浏览器中也能实现了,就可以做一些基于面部的识别,会有你画我猜的应用,可以在手机上或浏览器上完成了。
tensorflow.js 提供给开发者的,它的 API 是相对比较简单的。作为使用者来说,你不需要去关心我背后的算法到底是跑在 GPU 上还是跑在 CPU 上?tensorflow.js 会自动帮你选择它的后端。
WebGL 的实现原理
WebGL 去做 GPGPU 它的实现原理大概是怎样的?
这张图就是在 CPU 和 GPU 它们之间做计算,有一个比较粗略的对位关系。
在 CPU 里面通常会把用于计算的数据放在一个数组里,刚刚举的矩阵乘法的例子,可能会用一个数组去表示一个矩阵,到了 GPU 这边,它是没有所谓的这些数组或者对象,它是没有这样的存储的这样一个概念,对 GPU 来说,它所能理解的就是纹理或者图片,可以把图片塞给 GPU 它也可以渲染出一张图片或者纹理输出,数组和图片是有这样一个对位关系的。
在 CPU 里面经常会写很多的循环,刚刚在矩阵乘法那个例子里也看到,我们会写嵌套的循环去做计算。到了 GPU 这边,就基本上不会去写循环这样一件事情。在 GPU 看来,它会去给每一个屏幕上每一个像素点去分配一个统一的计算任务,让这么多项数一起渲染出来,一起去执行这样一段计算逻辑,也是实现了类似于循环的类似的效果,它们是从思路上是一致的。
对于 CPU 来说,我们去调用一段写好的程序,在 JS 里面就能立刻拿到结果。对于 GPU 来说,它渲染了一张图形之后,会把结果渲染到纹理,我们可以从纹理中拿到每个像素点的数据。
关于计算调用,可能是在 CPU 这端,如果用 JS 去写,那就是 JS 的执行引擎会帮助我们做这样一件事情。
对于 GPU 来说,我们写的计算程序是通过光栅化去分配给每个像素点完成的。
布局算法
回到刚刚那个例子,在执行这个 F 打头的一个布局算法,我们可能在 CPU 这端需要将近 30 秒的时间。当我们把这个算法挪到 GPU 里去实现,大家可以看到它的耗时就来到了 0.8 秒,这基本上是将近几十倍的这样一个提升。
之所以可以把这样的算法放在 GPU 完成,是因为类似于这样的布局算法,它其实都是可并行的。
在计算第一个点位置的时候,其实可以同时上第二个线程去计算第二个点。如果这里面有 200 个节点,就可以分配 200 个线程给 GPU ,然后让它们同时的进行线程上的并行运算,最后把结果返回给我们。我们可以再用 Canvas 或者用 SVG ,一切你想用的渲染手段,我们只需要在指定的点去用这些渲染手段把点和边画出来就行了。
它的瓶颈,至少在布局算法这个场景里,性能的瓶颈通常并不来自于渲染,而是来自于计算。
实现细节
怎么在 GPU 里去做这样一个布局运算,以及一些简单的实现细节。这里不会涉及到具体的实现代码,会简单的讲一下思路,怎么去实现这样的算法。
图存储
邻接矩阵
在 GPU 里去对这张图做一些运算,就是怎么在 GPU 里去存储这样一张图,如果在 CPU 这边去写代码的话,对图的结构可以定义的很灵活,可以定义很多个节点,它可能是一个对象,那个节点是一个对象,每条边我也可以把它定义成一个对象。那节点和节点之间的关系,可以通过边距连接。
在 GPU 这边,它是不认识这些对象、数组,我们需要找到某种线性的结构去把这张图存在 CPU 里,然后才能进行后续的运算。首先,能想到的第一种比较常见的存储一张图的方式叫做邻接矩阵,这张图其实是来自于维基百科,每个节点会有一个编号,它们之间的关系是用这个边去把它连接在一起的,对应到右边这张连接矩阵,就可以这样表示,它和左边这张图是有一一对应关系。
邻接矩阵的好处就是比较直观,它用一个二维的矩阵,就可以表示出节点和边之间的关系。但缺点也很明显,这里面有很多为 0 的元素,0 就代表什么?
就代表这个节点和对应的另外的一个节点,它之间是没有关系的。如果图是比较稀疏的,矩阵里其实就会有大量空白的元素没有被充分的利用到,它其实是非常消耗存储的,非常消耗 GPU 内存的。
邻接表
在实际的使用当中,通常不会选择邻接矩阵矩阵来存储一张图,会采取一种叫做邻接表的结构,来存储这张图,它的好处相比邻接矩阵会更加的紧凑,相对来说定义也会比较灵活。
邻接表简单理解为一个数组。在数组的前半部分,用来存储节点,后半部分用来存储边,其中的每一个元素,每一个元素在 GPU 看来就是一个像素点,每一个像素点又有 RGBA 4 个通道,可以充分的利用这 4 个通道去存储内容。
对于节点来说,它的 R 通道,就是红色通道,可以用来存储节点的 X 坐标,绿色通道,可以用来存储它的 Y 坐标,蓝色通道可以存储偏移量,偏移量是什么呢?
节点和节点之间不是有边做连接,偏移量就表示 GPU 到时候来寻址的时候,在第几个元素就找到这个节点对应的界面,通过偏移量来表示这样的一个比较紧凑的连接表的存储,可以进一步压缩 GPU 内存。
性能对比
GPU 和 CPU 在布局算法下到底会有多大的性能差异?
这张表来自于 G6 里面,其实两里面对比了两种不同的布局算法,在右边第二列可以看出在大多数情况下或者在节点和边的数目来到一个比较大的规模的情况下,GPU 比 CPU 通常来说都是会有一些比较明显的优势的。
但是当你的节点数和边的数目不那么大,第一个 GFore 的一个布局算法里面,节点数可能三十几个边,边数可能六十条边,GPU 算的还没 CPU 快,反而更慢。
这张图表达的就是在选择不同的算法实现的时候,选 GPU 和 CPU 的时候,是要根据具体的业务场景,不是说一定就是 GPU 算的就一定比 CPU 快,它会有一些限制和要求。
当然在大规模图的计算场景下,GPU 的效果那是不言而喻的,甚至高的会达到百倍这样的一个性能差异。
WebGL 的局限性
WebGL 去做 GPU 计算它会有哪些局限性?
如果对 WebGL 比较有些了解的同学可能会听说过 WebGL 它本质是用来做渲染的,比如我们大家熟悉的 Three.js 等等其他的一些渲染引擎,都是用来做渲染的,这是它的本意。
但是我们却用它来做计算,这就会造成一些局限性:
第一个局限性,相比一些专用的用来做计算的管线,它的渲染管线实际上是会有很多冗余的。在做具体的计算任务的时候,很多阶段其实都是没有必要的。 第二个局限性,WebGL 在做计算的时候有很多的特性,没办法支持,这也就导致我们的很多可并行的算法没法实现。 第三个局限性,WebGL 的底层是 OpenGL,也有将近 20 多年的历史了,它的底层依赖的原生 API 是相对老旧的,不管是一些特性或者本身的执行效率,相比一些新推出的底层 API 也会有一定的劣势,这也是 WebGL 的一些局限性。
举两个例子,共享内存和同步 ,在进行线程间并行的时候,在大多数情况下,线程和线程之间是可以去独立的做计算的。
但也有些场景,我们希望在线程间做通信或者做同步。这种时候如果用 WebGL 去实现,其实就满足不了。
Web 端的下一代图形 API
Web 端下一代的图形 API 到底是什么?
从两年前开始, W3C 和 Chrome 等等浏览器厂商,他们就在主推 Web 端的下一代图形 API,叫 WebGPU,在他的描述里,可以直接看到 WebGPU 被提出一个很重要的特点,需要用 WebGPU 来做 GPU 的计算,它相比 WebGL 是更加合适的。
目前它的发展状态是什么样的?
现在基本上已经可以在 Chrome 或者它的预览版本、 Safari 的预览版本、Firefox 的预览版本等等这些实验版本中其实已经可以应用到 WebGPU 了。
WebGPU vs WebGL
简单的对比下 WebGPU 和 WebGL,我们其实只想用用 GPU 来做计算,可能并不关心渲染,这就要求你底层 API 需要提供给我专门的计算管线。
右边这张图是来自包垦,另外一个底层的渲染 API,可以简单对比一下,左边的是它的渲染管线,里面有很多个步骤非常长,而右边是它的对应的计算管线就非常短。
从管线的长度上直观的也能看出,如果这个 API 能直接提供给开发者一个专用的计算管线,它能跳过中间很多我们不需要的步骤,自然它的计算速度也会很快,这是一个比较直观的理解。
第二点就是 WebGPU 相比 WebGL ,它的底层的渲染或者是它底层的原生 API 是更加先进的。
这个表格里其实就对比的 WebGL 和 WebGPU 它的一些差异,它们当然有共同点了,都是浏览器提供的 API,前端开发者都可以直接拿来用,但是它们底层的原生 API 就完全不同,以 WebGL 举例,它底层的原生 API 可能是 20 年前的 OpenGL 或者是 DirectX11 等等比较老的。
对于 WebGPU 来说,它底层的原生 API 渲染可就非常先进了。在 MAC上面我们会使用 Metal,苹果上的 metal,在 Windows 下,就可能可以使用到微软最新的 D13、D12 等等,底层的 API 的性能差距,其实就会体现在上层浏浏览器 API 的渲染性能以及计算性能上面的差距。
图分析算法
单源最短路径
什么是图分析算法?
举一个例子,以右边这张图为例,可能有 A、B、C、D、E 等 5 个点,它们之间会有一个距离,有这样一个需求,从 A 点出发,到 C 点的最短距离是什么?
可以从 A 到 B 再到 C,也可以从 A 到 D 再到 C 一个距离是 3,一个距离是 4,我们很容易就计算出从 A 点到 C 点,最短距离是 3 ,从 A 点到 C 点途经 B 点,最短距离就是 3 了。对于这样的一个最短路径算法来说,它的复杂度其实也是蛮高的。
如果用一个比较简单的单元最短路径的算法来实现的话,它的复杂度也会是节点数乘以边数。这样的算法可以在 CPU 中进行,也有很多这样的库,包括 G6 本身它也已经实现了这样的最短路径的算法,但是相比 GPU 来说,它肯定会相对较慢。我们这个例子是比较简单的,只有 5 个点,可以想象一下,当这个图的规模非常大,有几百上千甚至上万个点的时候,我们去找一个点到另外一个点的最短路径可就没这么快了。
如果把这个算法放在 GPU 里,会多快的这样的一个效果。这个是在 GPU 里的实现。这个图里会有 1000 多个节点,2000 多条边,使用了刚刚讲过的 WebGPU 去实现在 Chrome 的实验板中去运行,可以发现它的速度是非常快的,这个例子里面大概只需要 62 毫秒,立刻就可以拿到从 3 号点到 2 号点的一个最短路径的效果。
高性能渲染
现在图里面肯定不光有计算,算完之后还得把节点、边都得画出来,这里面就涉及到一个渲染问题。当你要渲染的点或者边非常多的时候,也会遇到类似的性能问题。解决方法大概会有哪些?
使用底层渲染 API
举个简单的例子, WebGL、Canvas 2D、SVG 的一些渲染的性能对比,这个例子是比较直观的。
这个例子就是可以在 WebGL 和 Canvas 之间进行一个切换,这边会选举大概 3000 个节点,让他做某种运动的动画,去对比它的帧数,就 FPS 在 WebGL 下,基本上你看 65、70 相对都是可以满载运行。但我们一旦如果切换到 Canvas ,它的帧率会迅速来到,可能只有 5 或者 6,给人的直观感觉就是非常卡顿。无论是再去做画布的移动、拾取都会非常卡。
如果在渲染上遇到瓶颈,最直观的方法去使用更加底层的渲染 API 。如果使用 Canvas、SVG 去实现的,换成 WebGL、WebGPU 性能会更好。
按需渲染
当有 10 万条数据,我们一起把它渲染出来,那肯定是会非常卡顿的。但实际上用户在浏览器里又不会同时去看这 10 万条。我们会有一个虚拟列表,每次只会渲染出有线条,从这 10 万条里面选择可 100 条或 1000 条。
在渲染方面,其实也是有类似的思路。在场景里可能有成千上万个对象,右边这张图所示有成千上上万个立方体或者球体,但是我屏幕或者视口用户能看到的部分总归是有限的,就没有必要在每一帧都把这些成千上万的对象全都画出来。
可以在 CPU 端做一个计算,就把用户看不到的对象,都先把它剔除掉。待到在实际渲染的时候,只渲染用户能看到的去提交给 GPU 去渲染的,GPU 的压力就小了。
这个是一个比较朴素和直观的想法,基本上在所有的渲染引擎,不管是在 Web 端或者在桌面端 Three.js 或者 Unity 都会使用。
在渲染引擎里,这种手段叫做裁剪 。通过在 CPU 端去计算出一个最小的渲染集合,丢给 GPU去做渲染,这样一个过程叫裁剪 。其实不光是 3D 渲染引擎了,在 Canvas 2d 也可以做类似的事情。但是相比 3D 场景,我2D 做包围盒的计算肯定会更加简单,也会更加快。
GPU 粒子动画
在很多的可视化场景中是会需要用到动画效果。SANDDANCE 来自于微软的一个非常著名的产品,可以看到 SANDDANCE 基本上是可以在不同的布局之间进行切换,从一个柱状图立马切换到一个以地图为布局的场景,可以在不同产品之间进行切换。
右边也是可以在不同的维度非常流畅的切换,如果想实现这样的效果,使用 D3或者基于 SVG 或者 Canvas 的渲染引擎,去做粒子动画,当粒子数量非常多的时候,用 Canvas 或者 SVG, 显然会非常卡顿,达不到流畅的效果。在微软的 SANDDANCE 里面,他们是怎么做的呢?
他们基本上是会把整个动画以及整个粒子的位置放在 GPU 中运行,才能达到在每一帧都能看到流畅的布局切换的效果。
高性能交互
如何在渲染里面实现高性能,大概三个思路
第一个是使用底层的 API,不管是 WebGL 也好,WebGPU 也好。 第二个就是去尝试做按需渲染的工作,尽可能的减少 GPU 需要绘制的对象。 第三个就是当需要去实现一些非常复杂、炫酷的动画效果的时候,确认用 WebGL 或者 WebGPU 的底层去做。
解放主线程
在图可视化场景下的交互,用户经常需要去做一些交互,那常见的交互有哪些呢?
可能是需要对画布进行缩放,放大、缩小、平移、拖拽、选中某一个节点、选中某一条边。当节点和边的规模非常大的时候,是否还能保证交互的流畅性,这个是需要去思考的一个问题。
离屏渲染
JS 脚本可能通常都非常大,主线程是非常忙碌的,一方面需要去渲染 UI ,一方面又要去做计算,另外一方面还有具体的业务逻辑,主线程要做这么多事情,非常忙碌,忙于做计算的过程中,如果去滚动页面,可能就会感觉到页面非常卡顿。
其实这种卡顿就是由于主线程太忙碌了,忙不过来了造成。有一个比较普遍的解决方案叫做 OffscreenCanvas,即离屏渲染 。
把图的计算和渲染丢给一个离屏的 Canvas ,它是可以跑在 Web-Worker 当中的,交给他去做。等离屏的 Canvas 在 Worker 中完成计算和渲染任务的时候,再把结果同步给主线程,就能立刻拿到结果。主线程就不需要去等待计算和渲染的完成,就可以去处理用户的一些交互,在用户视角看来,整个的页面的滚动都是非常流畅的。
举个实际使用的例子,例子可以在 MDN 或者网上都能找到,可以搜索关键词,比如怎么使用离屏渲染,或者 OffscreenCanvas 就能搜索到类似的方案。
它的思路其实非常简单,对于前端开发者来说,有主线程和 Worker 线程两个视角。在主线程这边,和创建一个普通的 Canvas 不一样,先要去创建一个离屏 Canvas,通过 transfer control to off screen API 能够把 Canvas 交给一个离屏的线程;第二步,和其他创建 Worker 的时候的方法一样,需要去创建一个 Worker;它的差别就在这第三步,在创建完 Worker 之后,可以把离屏 Canvas 作为参数,或者作为一个 transferable 可转移的这样一个对象去传递给 Worker 。实际上在离屏渲染里面,可以看到它的参数,可以直接把整个离屏 Canvas 从主线程去传递给 Worker 线程。
回到 Worker 线程的视角,现在已经能够拿到从主线上传过来的 Canvas,就可以在主线程去调用 Canvas 的 API 一样,可以去创建 WebGL 的上下文,调用常规的绘制。这些和在主线上都是完全一样的,只不过在最后一步,在完成了渲染和计算之后,需要把结果再同步给主线程,需要调用一个可逆的方法,这样才能在主线上拿到了。
离屏渲染其实也不是多新的技术,在很多的 Web 端的 3D 引擎都可以看到。它们其中有一个特性就是支持离屏渲染,也有很多这样的例子。用 Three.js 去做渲染的对象非常多,可以把它丢给 Worker 去渲染,Worker 在每一阵完成渲染任务之后,通过 Commit 把确认结果同步给主线上,这样的主线程还能进行一些非常消耗性能的 UI 操作或交互。
在图可视化里面的例子中,基本它的效果是这样。比如说主线程可以去渲染 AntD 的一个 Loading 组件是完全不受影响的。非常消耗性能的布局运算,实际上是在 Worker 端计算完成的。我们的主线程可以在此过程中,你可以去滚动或者去渲染其他的 UI 组件,都是没有问题。如果我们没有采用这样的手段,大家可以想象我的主线程就会非常卡,因为我的计算结果还没完成,可能 Loading 就会一直卡在他的初始状态。
除了我们刚刚提到的离屏渲染,我们在这个图场景或者大部分的可视化场景里面,用户总归是需要去做一些交互的。
高效拾取
最常见的一个交互就是拾取,什么是拾取?
比如说大家可以看下面来自化点主要的例子,它这个场景里面大家看到有很多成千上万个立方体,用户的鼠标在移动的过程中,我们需要知道用户当前鼠标停留在哪个对象上,我们需要去确切的把它找出来,这个过程就叫做拾取。
如果找这个对象,根据坐标找对象这样的一个计算过程,我们如果是放在 CPU 端进行,我们需要去进行大量的叫包围和计算,需要在场景里面每一个对象全部都遍历一遍,才能判断出来你当前要拾取的是对象,可想而知,他的性能是非常差的,与之相对的我们的拾取其实也是可以放在 GPU 这边完成的。
在很多这样的 3D 引擎里面,会把这两种方法,一个叫做可能叫 Repeating,一个可能叫 Pixel picking,基于颜色编码或者叫基于像素的时序,会要在 GPU 端进行许的好处。
显然大家在刚刚这个例子也看到了,我鼠标可以任意的在场景中进行滑动,可以看到它的拾取基本上是比较实时的。他这个对象是能很好的跟随鼠标的移动,并且把它高亮出来。
我现在可以简单总结一下,我之前就以我的开发经历来讲的话,我觉得我们前端开发者在去用 GPU 不管是做计算还是做渲染的时候,经常会遇到的一些困难有哪些?
我总结下来可能包含这么两类,第一个就是当我们前端开发者想用 GPU 去做计算的时候,毕竟我们只是前端,我们可能缺少一些 GPU 的编程模型的理解或者是实现。
相比那些比如说用 CURD 去做编程的人来说,我们会缺少比如说线程的线程组他们是什么意思,GPU 的内存模型是什么样,更不要说一些更加高级的特性,比如共享内存和同步这些是什么?我们前端开发者因为很少接触,所以自然对这些概念也不是很了解。
所以即使我们知道计算任务是很适合在 GPU 这端完成的,我们也很难去写这样一个程序,或者很难实现这样一个算法。这是我觉得我们可能会遇到的第一个困难。
第二个困难,我们现在对编程模型也有理解了,算法我们也能实现了。
下一步就是在实现过程中,我们有这些图形渲染或者计算的 API 需要去学习,比如说我们可能前端需要去学习 WebGL ,或者是相对比较新的 WebGPU,他们的 API 我们需要去学,因为实际的算法我们是需要写在 Shader 里的,这边多提一嘴就 Shader 是什么?
实际上大家可以理解为就是写给 GPU 看的程序,只有 GPU 才看得懂的,所以它的语法和我们写的比如 JS 是会有很大的区别的。
他可能从语言风格上来讲,Shader 的语法更接近于 C 的语法。基本上是 C 的语法。我理解主要是有一些学习成本,我们后面也在想怎么去降低前端开发者,去写这样的或者是去和 GPU 打交道,我们想办法去降低这样的成本,我们也会有一些方案了。
大家如果感兴趣的话可以去访问,下面这个链接里面会有说我们目前大概做法,我们尽量想让前端开发者少去学习,尤其是 Shader 在这语法这方面,我们会让我们的前端直接去写 TS 的程序,通过一些编译器去把我们的 TS 程序直接编译成 Shader 的语法,然后放在 GPU 端进行,这样就减少大家的一些学习成本。
对于一些编程模型在我们的 WebGPU 也会有些介绍,如果大家感兴趣的话也会去学习一下。
总结
来到最后我们就简单总结一下今天内容。我觉得当我们在可视化或者说图可视化里面遇到一些性能瓶颈的时候,我觉得我们第一步还是要先定位他的问题,我们首先要分析一下,性能瓶颈到底来自于哪些方面。
比如它不一定就是说,我们第一感觉你这性能平均肯定是渲染有问题。我就无脑的去用一些更加底层的渲染 API 解决,其实我们在之前的布局算法例子里也看到了,其实就 200 多个节点,你用 Canvas 是画也很快,用 SVG 也很快,他的瓶颈压根就不是在渲染上面。
在我们遇到这样的问题的时候,我们第一步先要分析出它是来自于计算,来自于训练还是交互,我们其实都有对应的解决方案,当我们定位了问题之后,我们需要去依据我们具体的这些业务场景去选择方案,并不一定就是无脑说 WebGL 或者说 WebGPU 就一定以 Canvas 2D 或者 SVG 更加适合我们场景,这是不一定的。
我们刚才在性能分析里我们大家也看到了,可能在比如你的节点和边的数目下,就在图场景上,节点和边的数目还比较小的情况下,在 CPU 中去算,它甚至比 GPU 更快。大家可能很好奇,其实为什么?因为大家可以简单理解,我在 GPU虽然它计算是比较快的,但是它相对的你需要去创建纹理,去调度这个过程,以及最后我们会需要在 CPU 端你还要去读 GPU 的计算结果,这些步骤其实也是非常消耗性能的。
所以大家要根据自己的业务场景实际的去斟酌一下,去选择合适的方案。听完分享,大家即使对那些复杂的算法或者你的业务里不会遇到,或者说也没有这样的图可视化场景,也希望能给大家带来一点小小的启示。当我们发现我们的算法,它其实是一个可并行算法的时候,我们其实就已经可以尝试在 GPU 中去做这样一件事情了。通常它的效果尤其是当你的数据量达到一定规模的时候,通常它的效果都会比在 CPU 中来得好。
欢迎加入
我们来自蚂蚁金服,在可视化方面会有很多产品,它们组成了一个叫做 AntV 的产品矩阵。
推荐一门课
我们传统是给大家可以推荐一本书,这里我就推荐一门课,大家在 B 站上就可以直接免费看。
它是闫令琪博士的一门课:「计算机图形学入门」,里面不涉及比如 WebGL 或者 OpenGL 的 API 怎么用,而会讲这些 API 背后的一些思路。如果你能够坚持的把这样一门基础入门课看完,当你想用比如 Three.js 或者 WebGL 等等去做一些和 3D 有关的计算和渲染相关的工作的时候,你要做的可能就只是看 Three.js 的文档。因为已经有之前的一些基础知识做打底了,你在学习这些 API 的时候,速度也会更快,也更容易理解。
Thanks
别忘了 7-17(下周六) 的第二十九届|前端可视化专场,了解数据可视化/时空可视化/大屏/搭建/画布/等等的可能性,7-17 全天直播,10 位讲师(贝壳/奇安信/预策科技/蚂蚁/小米/阿里/阿里云/数字冰雹等等),点我上车👉 (报名地址):
所有往期都有全程录播,可以购买年票一次性解锁全部
扫码二维码
获取更多精彩
前端早早聊
点个在看你最好看