想象一座汽车工厂:厂长(CPU)一次只能处理一份文件,擅长做决策;车间里却站着成千上万名工人(GPU 核心),可以同时拧螺丝、装轮胎、焊车门。过去的前端世界只有一个“厂长”在 JavaScript 里干活,遇到大量重复计算就容易排队。WebGPU 就是新装的“厂区调度系统”,而 compute shader 是发给工人班组的工作卡——卡上写明每个线程从哪块货架取数、做什么运算、把结果放回哪个货位。用它做矩阵运算,就像把组装一辆车的任务拆给多个班组同时完成:一个班组负责结果矩阵的一个小方块。本文从 GPU 分工讲起,带你走通 WebGPU 的设备获取、缓冲区创建、WGSL 着色器编写、workgroup 调度,再到矩阵乘法的完整实现。
掌握 WebGPU compute shader,你就能在浏览器里直接调动 GPU 算力做 AI 推理和科学计算,从只会发请求的“前端搬运工”,变成能驾驭本机算力的“计算调度员”。
可视化演示
展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。
本节配有交互动效(进入视口后自动加载,约 3KB)。
系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T
CPU与GPU分工
汽车工厂里,厂长(CPU)擅长做决策:审批订单、安排流水线、处理突发情况。可他一次只能处理一件事。如果让厂长亲自给一万辆车拧螺丝,工厂就会瘫痪。GPU 更像车间里成千上万的工人:单个工人不聪明,但大家同时动手,产量惊人。矩阵运算恰好是“拧螺丝”型任务——输出矩阵的每个元素只依赖输入矩阵的若干数值,互相之间没有先后依赖。
浏览器里的 JavaScript 一直扮演厂长。每写一个 for 循环,都像是在让厂长亲自跑腿。WebGPU 改变了分工方式:CPU 负责组织计算任务,GPU 负责批量执行。compute shader 就是一张写满操作步骤的工单,GPU 工人照着工单同时开工。前端开发者需要知道什么任务适合交给 GPU。
适合 GPU 的任务有三个特征:大量独立计算、算术强度高、访存规则。矩阵乘法完美命中。现代 GPU 含有数千个小核心,以 SIMT(单指令多线程)方式运行:一个指令派发给很多核心,对不同数据同时执行。理想情况下,加速比取决于能并行执行的线程数。
当然,实际工厂还要考虑工人往返货架的路程、班组长协调的损耗。数据搬运和线程开销会让加速比低于理论值,但把矩阵乘法这种大批量、低依赖的活交给 GPU,方向完全正确。
- 串行总时间一个任务接一个任务做完全部工作所需的时间,等于任务数乘单个任务耗时。
- 线程数量GPU 能同时运行的线程数,由 workgroup 大小和调度数量共同决定。
- 并行总时间任务被均匀分给所有线程后,每个线程只做总任务量的一个分之一。
- 理想加速比把串行时间除以并行时间,得到线程数本身。
WebGPU编程模型
工厂光有工人不够,还要有管理制度。WebGPU 就是浏览器里的“厂区管理系统”。拿 WebGPU 做计算,先要通过 navigator.gpu.requestAdapter() 找到显卡(厂区),再用 requestDevice() 申请一个逻辑设备(生产许可证)。设备负责创建管线、缓冲区和纹理。
拿到设备后,需要把“工单内容”固定成可执行流程——compute pipeline(计算管线)。它由着色器模块(WGSL 代码)和管线布局组成:着色器模块描述工人动作,管线布局声明工人使用哪些货架。这是 GPU 能反复执行的标准工序。
实际调度的流程是:device.createCommandEncoder() 创建命令编码器(工单本),在编码器上 beginComputePass() 开启一轮计算,再在 pass 里依次 setPipeline、setBindGroup、dispatchWorkgroups,最后 end()。所有命令编码完毕后,queue.submit([encoder.finish()]) 把整个工单交给 GPU 队列。这个过程里 JavaScript 不参与逐元素计算,它只当调度员。
这套流程就像厂长把每道工序写进工单,再交给班组长分发。明白它,后续的缓冲区、着色器、调度才有安放位置。
- 网格维度
dispatchWorkgroups(X, Y, Z)指定三个方向上各放多少个班组。 - 班组大小
@workgroup_size(x, y, z)声明每个班组内有多少线程。 - 总线程数把两组维度相乘,就得到本次调度可用的线程总数。
缓冲区设计
工厂的原材料和成品不能放在厂长口袋里,要放在仓库货架上。WebGPU 中,GPU 能直接读写的是 GPUBuffer。矩阵 A、矩阵 B 是原料,矩阵 C 是成品,分别存放在不同的 storage buffer 里。
创建 GPUBuffer 时,要告诉 GPU 两个关键信息:大小和用途。大小用字节数表示;一个 f32(32 位浮点数)占 4 字节,所以 m×n 的矩阵需要 m*n*4 字节。用途用 usage 标志声明:STORAGE 表示着色器可读写,COPY_DST 表示可以从 CPU 写入数据,COPY_SRC 表示可以回读结果。
从 CPU 上传数据,常用 device.queue.writeBuffer(buffer, 0, data) 直接写入;想要 CPU 读取结果,则要 buffer.mapAsync() 把 GPU 内存临时映射到 CPU 地址空间。GPU 缓冲区有对齐要求,比如某些用法要求大小是 4 的倍数;当矩阵宽度不是 4 的倍数时,要在一行末尾补几个占位符。
货架空间备好,工人才能随时取料。缓冲区设计是 GPU 计算的第一个坑:空间不够或用途不对,后面的着色器寸步难行。
- 元素个数矩阵共有多少个数需要存储。
- 单元素字节数
f32类型占 4 个字节。 - 总字节数元素个数与单个元素字节数相乘。
Workgroup调度
车间里工人不能挤成一团,要分成班组。WebGPU 用 workgroup(工作组)组织线程:一个 workgroup 是 GPU 调度和共享内存的基本单位,相当于一个班;班组内部又包含若干个线程,相当于工人。
在 WGSL 中,@workgroup_size(8, 8, 1) 声明每个班组有 8×8 个线程。dispatchWorkgroups(4, 4, 1) 告诉 GPU 在 X、Y 方向各放 4 个班组。于是整个网格有 16 个班组、共 1024 个线程。每个线程需要知道自己算哪个位置,这就靠内置变量。
WGSL 提供 local_invocation_id(班组内编号)、workgroup_id(班组编号)和 global_invocation_id(全局线程编号)。矩阵运算中,通常用 global_invocation_id.x/y 映射到输出矩阵的行列。共享内存 var<workgroup> 允许班组内线程协作,把一块数据先搬进班组再反复使用,减少全局访存。
班组划分得好,工人少跑路;划分不好,班组之间还要互相等。矩阵乘法里的 tiling 就是把“班组”和“货架数据块”对齐。
- 班组编号
workgroup_id表示当前班组在整个网格中的位置。 - 班组尺寸
workgroup_size表示一个班组在每个方向上有多少个线程。 - 班组内偏移
local_id是线程在班组内部的局部坐标。 - 全局编号先跳过前面的班组,再加上班组内偏移,得到全局唯一线程编号。
着色器编写
工单上写什么,工人就干什么。WGSL(WebGPU Shading Language)就是 WebGPU 的工单语言。计算着色器用 @compute 标注入口函数,用 @workgroup_size 声明班组规模,用 @builtin(global_invocation_id) 拿到全局线程坐标。
数据从哪里读?通过绑定组(bind group)把 GPUBuffer 接到着色器上。例如 @group(0) @binding(0) var<storage, read> a: array<f32>; 表示第 0 组第 0 个资源是只读的存储缓冲区,里面是一串 f32。在 JS 端,用 setBindGroup 把实际缓冲区交给 GPU。
因为存储缓冲区是扁平的数组,二维矩阵在 GPU 眼中只是一段连续内存。我们要用线性索引 row * stride + col 找元素。stride 通常等于一行元素的个数(加上可能的 padding)。在矩阵乘法中,每个线程先算出自己的 row、col,再从绑定组里的 A、B 缓冲区取数。
工单写得清楚,工人就不会拿错零件。WGSL 的语法不复杂,关键是记住:着色器运行在 GPU 上,不能用 DOM,只有输入缓冲区和输出缓冲区。
row 个整行,再向右偏移 col 个元素。- 行号与列号线程从
gid.y和gid.x拿到输出矩阵的行列。 - 行距每行有多少个连续元素,可能包含 padding。
- 线性索引用行号乘行距,再加列号,得到元素在缓冲区中的位置。
@compute @workgroup_size(8, 8)fn main(@builtin(global_invocation_id) gid : vec3<u32>) { let row = gid.y; let col = gid.x; // 读取 A、B 并累加,写入 C}
矩阵乘法实现
现在开始真正的组装。假设要算 C = A × B,C 里每个格子都需要 A 的一行和 B 的一列做“点积”:把对应的数逐对相乘,再全部加起来。就像一个工人要跑到两个货架,分别取一排零件,按顺序配对拧紧。
朴素思路是每个线程负责 C 的一个元素:
这个代码正确,但每次循环都去全局内存取数,工人反复跑仓库,很慢。
优化的办法是分块(tiling):把 A、B 切成小方块,一个 workgroup 负责 C 的一个输出块。先把需要的数据块搬进 var<workgroup> 共享内存,班组内线程反复复用。这样每个元素只需从全局内存读一次,而不是每次乘法都读。实现时要处理边界:当矩阵维度不能被 tile 整除时,用条件判断避免越界。
班组把一块货直接搬到工位旁边,再也不用每次横穿仓库。这就是矩阵乘法在 GPU 上高效跑起来的核心秘密:并行计算 + 数据复用。
- 输出坐标线程决定
(i,j),准备写 C 的第 i 行第 j 列。 - 累加变量每一步取 A 的一个元素和 B 的一个元素相乘,再加到总和里。
- 求和区间k 从 0 遍历到 K-1,K 是 A 的列数、B 的行数。
- 完整公式所有 k 的乘积累加完,得到最终结果。
let sum = 0.0;for (var k = 0u; k < K; k = k + 1u) { sum = sum + a[row * K + k] * b[k * N + col];}c[row * N + col] = sum;
性能与应用
工厂好不好,不看工人数量,看单位时间产出多少合格车。GPU 矩阵运算的性能,也要看吞吐量:每秒完成多少浮点运算,或者完成一次矩阵乘法用多少毫秒。在浏览器里可以用 performance.now() 记录提交前后,再通过回读结果估算整体延迟,也可以用 Chrome 的 WebGPU 面板做更深入分析。
除了测速,还要控制资源生命期。重复创建缓冲区和管线会像频繁招工再裁员一样浪费。WebGPU 的优势之一是可复用:创建一次 pipeline,每帧只更新缓冲区和绑定组。
这些能力让浏览器里的 AI 推理成为现实:神经网络全连接层、卷积层底层都是矩阵运算。输入图片先转成矩阵,再交给 GPU compute shader 算,结果可直接画到 canvas。科学计算里的流体模拟、粒子系统、数值求解同样适用。
把工厂从“有人干活”升级到“高效运转”,靠的是性能分析和资源复用。WebGPU 让前端不再只是展示页面,而是真正拥有本机算力。
- 任务量矩阵乘法的浮点运算次数可以近似为两倍的 M×N×K。
- 总耗时实际墙钟时间包含数据搬运、GPU 计算和结果回读。
- 吞吐量任务量除以总耗时,得到每秒完成的任务数。
AI 应用场景
浏览器端 AI 推理
阶段:模型推理前的张量预处理与矩阵计算
解决的问题:传统方案把图片归一化、缩放、卷积等矩阵运算放在 JavaScript 里做,主线程被大量循环阻塞,界面掉帧;如果上传服务器又会增加隐私风险和网络延迟。WebGPU 可以把这些矩阵运算放到 GPU,让 CPU 专心处理交互。
具体案例:一个前端 OCR 应用,用户截屏后先由 compute shader 把截图转成灰度矩阵、缩放到模型输入尺寸,再在本地完成矩阵乘加。结果可以直接用于后续推理,整个流程不出浏览器。
科学计算可视化
阶段:仿真计算与数值更新
解决的问题:流体模拟每帧都要更新大量速度场、密度场,本质是矩阵/网格运算。用 CPU 模拟只能支撑很小的网格;用服务端计算则延迟高。WebGPU compute shader 可以在显卡上并行更新每个网格点。
具体案例:一个网页版流体交互装置,把画布分成 256×256 的网格,每个线程负责一个格点,根据周围格点速度计算下一帧状态,再直接渲染到 canvas。用户拖动鼠标就能看到实时流体反应。
Python 演示
import numpy as np def tiled_matmul(A, B, tile=4): M, K = A.shape K2, N = B.shape assert K == K2 C = np.zeros((M, N), dtype=np.float32) for i in range(0, M, tile): for j in range(0, N, tile): i_end = min(i + tile, M) j_end = min(j + tile, N) acc = np.zeros((i_end - i, j_end - j), dtype=np.float32) for k in range(0, K, tile): k_end = min(k + tile, K) a_block = A[i:i_end, k:k_end] b_block = B[k:k_end, j:j_end] acc += a_block @ b_block C[i:i_end, j:j_end] = acc return C def main(): M, N, K = 64, 64, 64 A = np.random.rand(M, K).astype(np.float32) B = np.random.rand(K, N).astype(np.float32) C = tiled_matmul(A, B, tile=8) C_ref = A @ B print("max error:", np.max(np.abs(C - C_ref))) if __name__ == "__main__": main()
C = np.zeros((M, N), dtype=np.float32)创建输出矩阵,相当于在 GPU 里分配结果缓冲区。for i in range(0, M, tile):模拟 dispatch 沿行方向划分的工作组网格。i_end = min(i + tile, M)边界处理,防止最后一组超出矩阵范围。acc = np.zeros((i_end - i, j_end - j), dtype=np.float32)每个工作组的局部累加器,相当于共享内存中的结果块。a_block = A[i:i_end, k:k_end]从全局“货架”上取 A 的一个数据块。acc += a_block @ b_block核心运算:分块累加,相当于 WGSL 里的循环累加。C[i:i_end, j:j_end] = acc把工作组算完的结果写回输出缓冲区。
常见误区
误区我一开始以为 WebGPU 就是 WebGL 的升级版,主要用来画更复杂的 3D 图形,跟矩阵运算关系不大。
正解WebGPU 确实包含渲染能力,但它同时提供独立的 compute pipeline,专门做通用并行计算。compute shader 不产出一个像素,而是直接读写缓冲区。你可以把 WebGL 想象成只负责“画画的班组”,而 WebGPU compute 是“不画画、只装配零件”的另一条生产线。
为何常见:GPU 历史上一开始就为图形服务,WebGL 的名字也让人默认新 API 只是图形增强。
误区我以为 compute shader 就是在 JavaScript 里写一个函数,然后让浏览器自动并行执行。
正解compute shader 必须用 WGSL 编写,编译后运行在 GPU 上,无法直接调用 DOM 或 console.log。JavaScript 只负责创建管线、设置绑定组、提交命令,真正的循环和算术发生在显卡里。就像厂长写工单,工人按工单干活,厂长不会自己去拧螺丝。
为何常见:前端开发者的日常语言是 JavaScript,看到“写一段逻辑”很容易套用 JS 的心智模型。
误区很多人觉得线程数越多越好,所以把 `@workgroup_size(256, 256)` 调到显卡能接受的上限。
正解workgroup 过大会消耗过多寄存器和工作组共享内存,导致 GPU 能同时驻留的班组变少,占用率反而下降。合适的 size 需要根据矩阵规模和硬件能力测试,通常 8×8、16×16 是常见起点。就像班组人太多会挤在工位前,反而降低产出。
为何常见:并行计算给人的直觉是“人越多越快”,忽略了硬件资源是有限的。
误区只要把矩阵运算放到 GPU,性能就一定提升,甚至可以无脑加速所有计算。
正解GPU 的优势在大量独立并行计算,但数据从 CPU 内存搬运到 GPU 显存、再把结果回读,需要时间。小矩阵、轻量任务可能完全被搬运成本吃掉。正确的做法是先估算任务规模和计算密度,再决定要不要上 compute shader。
为何常见:看到“几千个核心”很容易忽略数据搬运和调度开销,而前端环境里这些开销往往很明显。
面试题
Q1. 在 WebGPU 编程模型中,queue.submit() 的角色最像工厂里的哪一步?
- A.把写好的工单交给车间← 正确答案
- B.重新绘制工厂的图纸
- C.验收已经生产的零件
- D.给工人计算月薪奖金
答案:A。queue.submit() 把编码好的命令缓冲区提交给 GPU 队列,相当于厂长把写好的工单交给车间执行。它不是绘图、验收或后勤,而是正式派活。
Q2. WebGPU 的 compute shader 主要用来做什么?
- A.执行通用并行计算任务← 正确答案
- B.只负责绘制三角形图形
- C.代替 JavaScript 管理网页
- D.压缩浏览器网络请求
答案:A。compute shader 的核心定位是通用并行计算,它可以做矩阵乘法、物理模拟和 AI 推理。它不负责图形光栅化,更不管理网页或网络请求。
Q3. 下列哪个 WGSL 属性用于声明一个 workgroup 的线程规模?
- A.
@workgroup_size(8,8)← 正确答案 - B.
@binding(0) @compute - C.
@vertex @fragment - D.
@group(0) @binding(1)
答案:A。@workgroup_size 用来声明每个班组内的线程数,是 compute shader 的常用属性。B、D 是资源绑定相关,C 是渲染管线入口,都与班组线程规模无关。
Q4. 要将两个 4×4 的 f32 矩阵放入 storage buffer,至少需要多少字节?
- A.32 字节
- B.64 字节
- C.128 字节← 正确答案
- D.256 字节
答案:C。一个 4×4 矩阵有 16 个元素,每个 f32 占 4 字节,所以一个矩阵占 64 字节。两个矩阵共 128 字节。注意别漏掉“两个”或“4 字节”这两个条件。
Q5. global_invocation_id = workgroup_id * workgroup_size + local_invocation_id 中,local_invocation_id 表示什么?
- A.线程在班组内的编号← 正确答案
- B.班组在整个网格的编号
- C.需要写入的矩阵行数
- D.GPU 显卡的代号编号
答案:A。公式里 workgroup_id 表示班组编号,local_invocation_id 是班组内部偏移,组合后得到全局线程编号。它描述的是“工人自己在哪个工位”,不是矩阵维度或显卡型号。
Q6. 在分块矩阵乘法中,如果矩阵维度不是 tile 的整数倍,WGSL 代码必须做什么?
- A.添加边界检查防止越界← 正确答案
- B.把矩阵大小改成质数
- C.让 CPU 再算一次结果
- D.调用 mapAsync 同步修正
答案:A。当矩阵维度不能被 tile 整除时,最后一个 block 会越过矩阵边界,必须在着色器里判断是否仍在有效范围内,越界的值不参与计算。改矩阵、让 CPU 重算或同步映射都不是 GPU 分块算法的常规做法。
Q7. 下列哪种做法最可能让 GPU 矩阵乘法反而变慢?
- A.每帧都上传大矩阵并回读← 正确答案
- B.使用共享内存缓存分块数据
- C.复用已经创建好的管线对象
- D.用 dispatchWorkgroups 并行调度
答案:A。GPU 并行能力很强,但数据搬运是最大瓶颈。每帧上传大矩阵再回读,会让 CPU 和 GPU 频繁同步,搬运时间淹没计算时间。共享内存、复用管线和正确调度都是优化手段,不会让计算变慢。
Q8. 在创建用于矩阵乘法的 storage buffer 时,下面哪些 usage 组合是合理且常用的?
- A.
STORAGE | COPY_DST← 正确答案 - B.
STORAGE | COPY_SRC← 正确答案 - C.
VERTEX | INDEX | COPY_SRC - D.
COLOR_ATTACHMENT | STORAGE
答案:AB。矩阵乘法的输入缓冲区需要 STORAGE 供着色器读取,配合 COPY_DST 从 CPU 上传;输出缓冲区需要 STORAGE 配合 COPY_SRC 回读。C 用于渲染顶点数据,D 中的 COLOR_ATTACHMENT 是纹理用途,不适合 storage buffer。
Q9. 关于 WebGPU 的调度模型,正确的说法有?
- A.调度命令指定三个方向网格大小← 正确答案
- B.工作班组内部的线程可共享内存← 正确答案
- C.计算着色器每次只能运行一个线程
- D.提交队列会在 CPU 上同步完成计算
答案:AB。dispatchWorkgroups(X,Y,Z) 指定三维网格大小;一个 workgroup 内的线程可以通过 var<workgroup> 共享内存。计算着色器通常同时运行大量线程,queue.submit 只是把命令交给 GPU 队列,不表示 CPU 同步计算。
Q10. 在 WGSL 中读取矩阵 A 的 (row, col) 元素时,下面哪些是必要步骤?
- A.用行距和列号计算一维偏移← 正确答案
- B.从绑定组声明存储缓冲区读取← 正确答案
- C.在着色器里调用控制台打印日志
- D.在 JavaScript 中直接操作 DOM
答案:AB。GPU 缓冲区是一维数组,必须用 row * stride + col 计算下标;同时需要在绑定组中声明 var<storage, read> 缓冲区。着色器运行在 GPU 上,不能使用控制台或 DOM,这些操作只能由 JavaScript 在浏览器主线程完成。