闲着没事,我把 WebGPU 的样板代码封成了一个库

1 阅读7分钟

最近是真的闲,正事一点不想干,就翻自己以前写的代码玩。

翻到一段以前用 WebGPU 写的图像处理,看着看着就乐了。你们知道 WebGPU 写一个最简单的计算有多啰嗦吗,我先给你数数。

算个矩阵乘,或者给图片做个滤波,你得先拿到设备。然后建 buffer——输入一个、输出一个,参数可能还得一个 uniform buffer。接着写 WGSL 的 shader 字符串,编译成 shader module。再建计算管线,这一堆还是异步的,得 await。

到这一步还没开始算。还得建绑定组布局、管线布局、绑定组,把 buffer 一个个绑上去。最后开编码器、开计算通道、设管线、设绑定组、下发工作组、结束、收尾,才拿到一个命令缓冲,才能提交。

算完想看一眼结果,还得再建一个能映射的 buffer,拷过去,映射,等 promise,读出来,取消映射。

这一套写下来两百多行,而真正的计算逻辑是 out = a * b + c。

我当时就觉得这不合理。WebGPU 明明是个好东西,性能摆在那,随便跑点东西都比 CPU 快几十倍,结果光是把数据搬进去再搬出来,就要写这么一大坨。写一次能记住,写十次就烦了。

于是我开始琢磨,能不能搞一层封装,让它用起来像用 numpy 那样。

改了好几版设计,中间推翻重来过,最后做出来一个东西,叫 moxwebgpu。

它的用法大概是这样:

import { gpu } from "moxwebgpu";

const r = await gpu.tensor([1, 2, 3, 4])
  .add(1)
  .relu()
  .mul(10)
  .sum()
  .item();   // 140

就这几行。建 buffer、写 shader、建管线、绑定、下发、读回,全在里面了。

说到这得解释一下,为什么它是懒的。上面那一串 add、relu、mul、sum,你写的时候它一次 GPU 都没碰,只是把操作记下来连成一张图。直到你调 .item() 或者 .toArray() 要结果的那一刻,它才把整张图从头到尾排一遍序,能合并的合并,然后一次性下发。

这么做有两个实实在在的好处。

一个是中间结果不用每一步都读回 CPU。GPU 和 CPU 之间传数据是很慢的,如果每算一步就读回来一次,那 GPU 再快也白搭。惰性图让中间数据一直待在显存里,只有最后要结果的时候才搬一次。

另一个是省下来的 buffer 能回收。算完一步,前一步的 buffer 就用不上了,我做了个引用计数,没人用的时候自动还回缓冲池,下一轮接着用,不用每次重新申请显存。

那个排序是这么写的:

// src/graph/lazy.ts
export function topoSort(root: LazyNode): LazyNode[] {
  const order: LazyNode[] = [];
  const visited = new Set<LazyNode>();
  const walk = (n: LazyNode): void => {
    if (visited.has(n)) return;
    visited.add(n);
    for (const input of n.inputs) {
      if (input.kind !== 'leaf') {
        input.consumers++;
        walk(input);
      }
    }
    order.push(n);
  };
  walk(root);
  return order;
}

逻辑不复杂,深度优先先把依赖处理完,再把自己放进去,保证下发的时候任何一个节点的输入都已经算好了。顺手在遍历的时候给每个输入加一次消费者计数,等这个节点执行完,计数归零了就把它的 buffer 还回池子。

接着说算子。现在一共三十多个:加减乘除、幂、取余、绝对值、开根号、指数、对数,三角函数的 sin、cos、tanh,激活的 relu、sigmoid,还有 sum、mean、max、min、argmax、argmin 这些归约,以及 slice、concat、transpose、reshape、cast 这些形状操作。

关键是每个算子都配了真写出来的 WGSL shader,不是空壳往里套。矩阵乘用了 16×16 分块,把大矩阵切成小方块,让每个线程块处理一块,减少对显存的重复访问。归约用了两阶段的树形,先在工作组内部折叠,再折叠跨工作组的结果。

性能上做了些工作。有个缓冲池,按 2 的幂次分桶。为什么要分桶,因为 WebGPU 对 buffer 尺寸有对齐要求,如果我按实际需求申请,会出现大量没法复用的小块,池子很快碎掉。按幂次分桶之后,十来个字节的请求和一百多个字节的请求会落到同一个桶里,复用率高很多。而且 storage 和 uniform 我严格分了两个池子不能混,这是 WebGPU 的规则,混用会直接报错。

还有管线缓存,同一段 WGSL 编译出来的管线用哈希缓存住,同样的 shader 不会重复编译第二次。

测试这块我想多说两句,我觉得它比功能重要。

现在有 27 个 GPU 端到端测试,21 个单元测试。端到端的意思是,真的把数据送到 GPU 上跑一遍,再把结果读回来跟 CPU 算的对照,看对不对。不是打的桩,不是跳过的。

问题来了,CI 的机器上没有独立显卡。怎么办。

我用了 Chrome 自带的 SwiftShader,这是个软件渲染的 Vulkan 实现,在没有显卡的机器上用 CPU 模拟 GPU 的调用。配上虚拟显示,Chrome 就能跑起来,WebGPU 也能用。

意思是,一台没有显卡的服务器,也能真的跑通这 27 个 GPU 测试。谁要在别的项目里复现,这套配方是能直接抄的。

但也得说清楚,因为是软件模拟,测出来的性能数字没有参考价值,它只能证明算得对,证明不了算得快。这个我写在 README 很前面的位置,免得有人看到吞吐量就兴奋。

再说几个能直接玩的东西,我做了四个。

一个是粒子吸引子,四十万个粒子,每个粒子每帧迭代好几步,计算量在 CPU 上是要卡的。跑一会儿粒子会把轨迹累加成一张密度图,还挺好看。

一个是曼德勃罗分形,每个像素做逃逸迭代,上限八百次,跟 CPU 版本对比差距非常明显,能把 CPU 卡成幻灯片。

一个是图像卷积,二维卷积,核最大到 31×31,你可以自己传一张图上去,随时换卷积核。

最后一个我最喜欢,视频实时滤镜。传个视频进去,每一帧都过一遍 GPU 滤镜,这个最能体现 GPU 的价值,因为视频一秒三十帧,每一帧都拖不得。

四个都能在线点,不用 clone 下来自己搭环境。

也说点不好听的,免得有人踩了坑骂我。

自动微分没做,现在只能前向推理,训练不行,这个在路线图第一位但确实还没写,别拿它去跑训练。没有卷积算子,上面那个图像卷积是我在 Demo 里直接写 WGSL 做的,不是库里的标准算子,真跑 CNN 还不行。归一化层也没有,想做 Transformer 现在还早。没有 WebGL 回退,WebGPU 是硬要求,浏览器不支持就用不了,这是取舍。纹理互操作没接,视频和图片进来还要走 CPU 拷一次,其实 WebGPU 有直接上传纹理的接口,我还没做。

技术栈上全部是 TypeScript,运行时依赖为零。打包出 ESM、CJS、IIFE 三份,IIFE 那个能用 script 标签直接引。整个库 2545 行,不算大。

这东西我前几天才写完,就是个自己用着顺手的工具,顺手做扎实了一点。写的时候没想过给谁看,现在放出来了,如果刚好有谁也在这个坑里,能省下几个小时,那就值了。

觉得有用的话,去仓库点个 Star,或者转给那个正在为 WebGPU 写样板代码的朋友。

在线 Demo:codecloud-dev.github.io/moxwebgpu/ 仓库:github.com/codecloud-d…