antvis / layout

Layout algorithms for graphs.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

WebWorker 方案讨论

xiaoiver opened this issue · comments

问题背景

在 graphpology 中只有 noverlap / force / forceatlas2 支持在 WebWorker 中运行。
但我们希望所有布局算法都可以,无论是否是 iterative,至少可以做到不阻塞主线程运行。

此外我们希望在工程、使用体验上作出以下改进:

  • 算法主体部分不出现 WebWorker 相关代码,保持纯粹
  • 提供易用的 WebWorker 相关控制逻辑,例如启动、停止等
  • 开发调试友好,不需要发线上 UMD

设计思路

提供一个统一的监视器(这个名字也是从 graphology 中看来的),主线程和 WebWorker 线程协作过程如下:

  • 主线程 创建 Supervisor,接收一个图模型和算法作为参数
  • 主线程 创建 WebWorker 并与之通信。在 payload 中带上当前执行的算法、参数,图节点/边数据(转换成 ArrayBuffer,通过 Transferables 共享控制权),通过 postMessage 向 WebWorker 发送消息
  • WebWorker 接收到事件上携带的 payload 后,同步创建一个对应的算法并执行,返回计算结果(可以是 Transferables)
  • 主线程 接收计算结果,触发对应生命周期事件(例如 tick layoutend 等)

API

创建一个“监视器”,接收一个图模型和算法作为参数:

const graph = new Graph();
const layout = new CircularLayout();

const supervisor = new Supervisor(graph, layout, { auto: true });

流程控制:

supervisor.start();
supervisor.stop();
supervisor.kill();

事件。既然是一个异步的计算过程,就需要通知主线程当前的计算状态,例如单次迭代完成、全部计算完成等:

supervisor.on('tick', (positions) => {
});
supervisor.on('layoutend', (positions) => {
});

实现

之前创建 Worker 是在 G6 代码中完成的,会存在有一些局限性:
https://github.com/antvis/G6/blob/master/packages/pc/src/layout/worker/work.ts#L5

  • 如果想单独使用 @antv/layout,需要重复实现一遍 WebWorker 相关的逻辑
  • 目前在 Worker 代码中使用 importScripts 直接引用线上的 layout UMD 版本,无网络环境下就无法使用了,也不便于调试(需要把新版本发到线上)

目前使用 Webpack + workerize-loader 的方案,比照最初设想的 template 替换可以说是好处多多:

  • 可以将代码内联到 WebWorker 中
  • 走 Webpack 编译,因此可以使用正常的高级语法,开发调试时也可以被 watch
  • workerize-loader 封装了 Worker 的创建与通信代码,还可以通过 @naoak/workerize-transferable 使用 Transferables
import worker from "workerize-loader?inline!./worker";

主线程和 WebWorker 通信时,需要告知所需的信息,便于在 Worker 中同步创建 Graph 和 Layout 算法并执行,目前携带的数据如下:

  • layout 包含算法名称、参数。Worker 据此可以同步创建对应的 Layout 对象
  • nodes/edges 目前是直接把 Graph 上的原始节点边数据带过去,是这么获取的 nodes: this.graph.getAllNodes()。但考虑到 Transferables,需要将数据转换成线性存储,例如:const arraybufferWithNodesEdges = graphToByteArrays(this.graph); // Float32Array
export interface Payload {
  layout: {
    id: string;
    options: any;
  };
  nodes: Node<any>[];
  edges: Edge<any>[];
}

目前的实现:

目前存在的问题

体积问题

相比之前运行时通过 importScripts 的方式加载 Layout UMD,目前由于是内联的,体积会变大,可以近似认为是原来的两倍。

自定义布局

目前 Worker 代码是构建时产生的,所以运行时的自定义布局即使可以“注册”,也无法添加到已经生成好的 Worker 代码里,也就无法使用 Supervisor 的功能了。

Transferables

我看到 graphology 的三个 Worker 实现中都使用了这个特性,简单来说就是主线程和 Worker 共享同一块线性内存的控制权:
https://github.com/graphology/graphology/blob/master/src/layout-forceatlas2/webworker.tpl.js#L29-L34

NODES = new Float32Array(data.nodes);

self.postMessage(
  {
    nodes: NODES.buffer
  },
  [NODES.buffer]
);

在我们目前的方案中要使用这个特性也很简单,借助 @naoak/workerize-transferable 就可以完成,例如在 Worker 侧的代码如下:

import { setupTransferableMethodsOnWorker } from "@naoak/workerize-transferable";
setupTransferableMethodsOnWorker({
  // The name of function which use some transferables.
  calculateLayout: {
    // Specify an instance of the function
    fn: calculateLayout,
    // Pick a transferable object from the result which is an instance of Float32Array
    pickTransferablesFromResult: (result) => [result[1].buffer],
  },
});

剩下的问题就是如何设计线性内存的结构,合理存储节点和边的数据。
在 graphology 中不同的算法有不同的结构,例如 forceatlas2:
https://github.com/graphology/graphology/blob/master/src/layout-forceatlas2/helpers.js#L117

var NodeMatrix = new Float32Array(order * PPN);
var EdgeMatrix = new Float32Array(size * PPE);

graph.forEachNode(function (node, attr) {
  // Node index
  index[node] = j;

  // Populating byte array
  NodeMatrix[j] = attr.x;
  NodeMatrix[j + 1] = attr.y;
  NodeMatrix[j + 2] = 0; // dx
  NodeMatrix[j + 3] = 0; // dy
  NodeMatrix[j + 4] = 0; // old_dx
  NodeMatrix[j + 5] = 0; // old_dy
  NodeMatrix[j + 6] = 1; // mass
  NodeMatrix[j + 7] = 1; // convergence
  NodeMatrix[j + 8] = attr.size || 1;
  NodeMatrix[j + 9] = attr.fixed ? 1 : 0;
  j += PPN;
});

因此每个 Layout 实现需要实现自己的 graphToByteArray 方法。

ESM

目前用 Webpack 生成的是 UMD + 类型文件,那 ESM 应该咋生成呢?如果是用 tsc 的话,肯定不认识这样带 webpack loader 的语法:

import worker from "workerize-loader?inline!./worker";

截屏2023-01-06 上午10 10 34

因为webworker需要提供一个URL,ESM 版本能用的话,要么使用提前打包好的内联代码,要么使用在线url文件。除非用户也要安装类似workerize-loader的插件...

使用 Webpack ESM bundle 的问题

因为webworker需要提供一个URL,ESM 版本能用的话,要么使用提前打包好的内联代码,要么使用在线url文件。除非用户也要安装类似workerize-loader的插件...

我验证了一下之前的 ESM 产物,由于是用 webpack + workerize-loader 生成的 bundle,无法被 treeshaking:

import { ForceAtlas2Layout } from "@antv/layout";
export function useForceAtlas2Layout() {
  return new ForceAtlas2Layout();
}

可以看出只引用了一个布局,但是整个 bundle 都被打进来了:

截屏2023-05-23 上午10 29 48

因此 ESM 使用 tsc 构建,放弃 webpack loader 语法。

WebWorker 交给用户的构建工具处理

移除了对于 Webpack workerize-loader 的依赖,在 ESM 产物中保留如下代码:

this.worker = Comlink.wrap(
// 创建 Worker
  new Worker(new URL("./worker.js", import.meta.url), {
    type: "module",
  })
);

这也是 vite 推荐的 WebWorker 使用方式:https://vitejs.dev/guide/features.html#web-workers
webpack 5 同样推荐这种方式,而不是 worker-loader:https://webpack.js.org/guides/web-workers/

这样用户使用 ESM 时,可以根据自己的构建工具例如 vite、webpack 配置:

UMD 使用方式

除了 ESM,如果希望用户通过 UMD 方式使用,最好的方式就是把 WebWorker 代码通过 Blob 内联进来,因此我们依然保留了一个 bundle-entry.ts,其中依然包含了 workerize-loader 语法供 webpack 消费。webpack/webpack#14198

我写了一个 Codepen 例子,在 WebWorker 中使用同心圆布局:
https://codepen.io/xiaoiver/pen/LYgqEbN

兼容性问题

不是所有浏览器都支持 type: 'module':

new Worker(new URL("./worker.js", import.meta.url), {
  type: "module",
})

因此一种兼容方式是通过 Blob 创建 URL:

const workerBlob = new Blob([workerString]);
const workerUrl = URL.createObjectURL(workerBlob);
const worker = new Worker(workerUrl);

https://justinribeiro.com/chronicle/2020/07/17/building-module-web-workers-for-cross-browser-compatibility-with-rollup/