#WebGPU计算着色器实战:用WGSL在浏览器里跑并行计算(矩阵乘法与工作组)去年我做了个前端小工具,要把一堆图片做一次高斯模糊再加个色彩矩阵变换,数据量不大但算起来CPU直接原地去世——主线程卡成PPT,风扇狂转,用户…
WebGPU 计算着色器实战:用 WGSL 在浏览器里跑并行计算(矩阵乘法与工作组)
WebGPU 计算着色器实战:用 WGSL 在浏览器里跑并行计算(矩阵乘法与工作组)
去年我做了个前端小工具,要把一堆图片做一次高斯模糊再加个色彩矩阵变换,数据量不大但算起来 CPU 直接原地去世——主线程卡成 PPT,风扇狂转,用户还以为网页崩了。我当时第一反应是把计算丢到 Web Worker 里,再不济上 WebAssembly 用 C 编译一遍,结果还是慢。直到一个做图形学的朋友丢过来一句:"你显卡是摆设吗?WebGPU 的计算着色器直接丢 GPU 上跑啊。"我才意识到,自己用了这么久的 WebGL,居然一直把 GPU 当成"只会画三角形的显示器驱动",压根不知道浏览器里早就原生支持通用计算(GPGPU)了。
说真的,WebGPU 的计算着色器(Compute Shader)刚接触时特别劝退:什么 @workgroup_size、什么global_invocation_id、什么绑定组布局,一堆新名词砸过来。我第一次照着文档写,dispatch 一下直接报错,对着红字发呆了半小时。这篇文章我就把这些坑一个一个摊开讲,从"给数组里每个数乘 2"这种玩具例子,一路摸到"用 WGSL 在浏览器里做矩阵乘法"这个标题里说的正经活,把工作组(workgroup)到底怎么算讲透。
顺便说一句,我并不是一开始就知道 WebGPU 能算东西的。更早的时候我以为 GPU 计算得靠 WebGL 的"渲染到纹理"黑魔法——把数据编码成像素颜色,用着色器算完再读回来。那套方案能跑,但丑得我想哭,调试全靠肉眼看像素,浮点精度还被打回 8 位。WebGPU 把这条路修成了正道,计算和渲染终于分家了。所以如果你还在用 WebGL 那套 trick 做计算,真的可以搬家了。我现在回想起来,当初那一下午的卡顿,有一半是认知债,另一半才是技术债。
一、先说清楚:WebGPU 的计算能力到底是什么
一句话理解:WebGPU 不止能画图,它还能让你写一段跑在 GPU 上的小程序,对一大坨数据做并行计算。画图是"把三角形光栅化到屏幕",计算是"把数据喂进显存,GPU 几万个核心同时算,再把结果吐出来"。
WGSL(WebGPU Shading Language)就是写这段小程序的语言,名字念起来像"wiggle",语法长得像 Rust 和 TypeScript 的私生子。别怕,它比 GLSL 好读多了,而且天然支持存储缓冲区、uniform、工作组这些现代概念。
我当初最大的认知误区,是以为"并行"就是开个 for 循环用多线程。错,而且错得离谱。GPU 的并行是 SIMT(单指令多线程):一个工作组里的几十上百个线程,执行的是同一段代码,只是各自拿到的"线程编号"不同。你不需要写循环去调度它们,你只要写"第 i 个线程该干嘛",剩下的 dispatch 交给驱动。这个思维转变,是我踩的第一个坑。
为什么非得用 GPU 而不多开几个 Web Worker?我拿 256×256 的矩阵乘亲自试过,纯 JS 单线程要两百多毫秒,开 8 个 Worker 分块也压不到 50 毫秒以下,而 GPU 同样的活个位数毫秒就完成了。差距不是线性,是量级。原因很简单:CPU 那几个核心再怎么超线程也是个位数,GPU 动辄几千上万个核心,专门为你这种"大量相同操作砸在不同数据上"的场景设计的。一句话——CPU 是几个教授逐题精算,GPU 是上万个小学生同时做同一道填空题,谁快不言而喻。
WebGPU 官方文档和 MDN 的 WebGPU 章节 是权威参考,但说实话文档偏 API 罗列,真要上手还是得自己跑一遍。顺带一提,WebGPU 的浏览器支持这几年才铺开,想确认你目标用户的环境能不能用,caniuse 上的 WebGPU 条目值得常备。
还有个常被忽略的点:WebGPU 的"计算"和"渲染"共用同一套设备和队列,但计算管线(GPUComputePipeline)不需要渲染管线的顶点/片元那一套,少了大量样板,反而比渲染更易上手。很多前端同学被"着色器"三个字吓退,其实计算着色器比图形着色器单纯得多——没有光栅化、没有混合、没有深度测试,就是纯纯粹粹的"输入数据→算→输出数据"。我带过不少后端同事入坑,他们普遍反馈:计算着色器比想象中好懂,因为心智模型就是函数式的数据变换,而不是图形学那一整套状态机。
二、热身:用计算着色器给数组翻倍(把整套流程跑通)
学任何新东西,我都习惯先写个最无聊的 hello world。这里我们写一个计算着色器:把一个浮点数组里每个元素乘 2。别小看它,WebGPU 计算管线的所有零件都在里面了。
为什么流程看着这么绕?因为 WebGPU 是显式 API,不像 three.js 那种高层封装帮你把脏活全包了。它要求你手动声明"这段数据是什么类型、交给着色器的哪个槽位、在 GPU 上以什么权限被访问"。好处是零黑盒、性能完全可控,坏处就是样板代码多。习惯就好——下面这十步里,除了绑定组布局那几行稍微反直觉,其余都是"建一个对象→把它和别的对象关联起来"的直白操作,谈不上难,只是啰嗦。
先写 WGSL,我习惯单独放一个字符串里(实际项目可以 .wgsl文件用?raw引入):
wgsl// ===== compute_double.wgsl =====// @group(0) @binding(0) 是 WebGPU 的资源寻址方式:// 第 0 组、第 0 个绑定点。WGSL 和 JS 两边要对上。// var<storage, read_write> 表示这是一个可读写的存储缓冲区@group(0) @binding(0) var<storage, read_write> data: array<f32>;// @compute 声明这是计算着色器入口// @workgroup_size(64) 表示每个工作组 64 个线程并行@compute @workgroup_size(64)fn main(@builtin(global_invocation_id) id: vec3<u32>) { // global_invocation_id 是全局线程编号,像 GPU 里的 i let i = id.x; data[i] = data[i] * 2.0;}
上面这段就是 WGSL 计算着色器的骨架。@group和@binding是资源槽位声明,@compute标记入口,@workgroup_size(64) 规定每个工作组 64 个线程。函数参数里那个 @builtin(global_invocation_id)很关键,它是驱动自动塞给每个线程的"我是第几号"的编号,类型是vec3<u32>,我们这里只用x分量。

接着是 JS 侧,把设备、缓冲区、管线、命令一股脑串起来。这一段代码看着长,其实就十步,每一步我都加了注释:
jsasync function doubleArray(input) { // 1) 拿适配器(adapter)和设备(device),这是 WebGPU 一切的起点 const adapter = await navigator.gpu.requestAdapter(); if (!adapter) throw new Error("这浏览器不支持 WebGPU,换个 Chrome/Edge 113+ 吧"); const device = await adapter.requestDevice(); // 2) 准备输入数据。注意必须是 TypedArray / ArrayBuffer, // 直接传普通 Array 给 writeBuffer 会原地翻脸 const data = new Float32Array(input); const buffer = device.createBuffer({ size: data.byteLength, // STORAGE = 给着色器当存储;COPY_DST = 允许写数据进去;COPY_SRC = 允许拷出来 usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, }); device.queue.writeBuffer(buffer, 0, data); // 把数据推到 GPU 显存 // 3) 编译 WGSL 着色器模块 const module = device.createShaderModule({ code: wgslDouble }); // 4) 绑定组布局:告诉管线 binding 0 是个 storage buffer const bgl = device.createBindGroupLayout({ entries: [{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type:"storage" } }], }); const layout = device.createPipelineLayout({ bindGroupLayouts: [bgl] }); // 5) 创建计算管线(指定入口函数 main) const pipeline = device.createComputePipeline({ layout, compute: { module, entryPoint:"main" } }); // 6) 把真实 buffer 绑到 binding 0 上 const bindGroup = device.createBindGroup({ layout: bgl, entries: [{ binding: 0, resource: { buffer } }], }); // 7) 编码命令:开计算通道 → 设管线 → 绑资源 → dispatch → 关通道 const encoder = device.createCommandEncoder(); const pass = encoder.beginComputePass(); pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); // 一共派多少个工作组 = 元素数 / 每工作组线程数,向上取整 pass.dispatchWorkgroups(Math.ceil(data.length / 64)); pass.end(); // 8) 结果要读回来,得先拷到一个可映射的 staging buffer(storage buffer 本身不能直接 map) const staging = device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); encoder.copyBufferToBuffer(buffer, 0, staging, 0, data.byteLength); device.queue.submit([encoder.finish()]); // 真正提交给 GPU 执行 // 9) 等执行完,映射出来读数据 await staging.mapAsync(GPUMapMode.READ); const out = new Float32Array(staging.getMappedRange()); staging.unmap(); return out;}
跑通这个,你就已经掌握了 WebGPU 计算的九成功力:建设备、建缓冲区、编译 WGSL、组绑定、建管线、dispatch、读回。后面无论算什么,都是这套骨架换汤不换药。
讲真,这套"缓冲区→绑定组→管线→命令编码器→提交队列"的流程,和做前端可视化时用的其他 GPU 操作是同一套心智模型,一旦摸清就一通百通;而如果你要把计算结果暂存到本地、做离线工具,那篇文章隔壁邻居 IndexedDB 前端数据库实战:离线应用与索引查询(增删改查与事务) 的存储思路反而更轻量,别啥都往 GPU 上怼。
三、踩坑现场:workgroup_size 和工作组到底怎么算
这是整篇文章最该划重点的地方,也是我当初卡最久的。
@workgroup_size(x, y, z)定义了"一个工作组里有几个线程"。上面热身例子里是(64),也就是一维 64 个线程。它也可以写成 (8, 8) 表示二维 8×8=64 个线程,甚至 (4, 4, 4)。但有个铁律:三个数的乘积不能超过设备的maxComputeInvocationsPerWorkgroup,绝大多数设备是 256。我第一次不信邪,写了个@workgroup_size(64, 64),心想 64×64=4096 个线程并行岂不美哉,结果直接报错 workgroup size exceeds limits,当场社死。
那dispatchWorkgroups(x, y, z) 又是什么?它是"派多少个工作组"。还是用翻倍数组举例:数组 1000 个元素,每个工作组 64 线程,那就 dispatch Math.ceil(1000/64) = 16 个工作组,总共 16×64=1024 个线程去干活。注意,1024 比 1000 多,多出来的 24 个线程怎么办?在翻倍这种"对每个 i 都安全"的运算里无所谓,但一旦涉及矩阵边界,越界写 storage 就会算错甚至崩。所以边界检查不是可选项,是保命符。
还有三个内置编号你得认识:
global_invocation_id:全局线程编号,跨所有工作组。比如第 3 个工作组、组内第 5 个线程,它的全局编号是连续的。local_invocation_id:组内编号,只在这个工作组内部从 0 数。workgroup_id:第几个工作组。
矩阵乘法里我们最常用global_invocation_id 来定位"我该算 C 的哪一行哪一列"。
光说概念太虚,我给你算一笔具体的账。假设 C 是 1000×1000 的矩阵,工作组设 (8, 8)。那么横向要派ceil(1000/8) = 125 个工作组,纵向同样 125 个,总共 125×125 = 15625 个工作组,每个 64 线程,加起来正好覆盖 1000×1000 = 100 万个格子,也就是 100 万个线程同时开算。你品,你细品——CPU 要写一个三层 for 循环逐个算,GPU 这边你只写"算一个格子"的指令,驱动自动帮你复制成 100 万份并行。这就是工作组存在的意义:它是 GPU 调度的最小单元,也是你控制"并行粒度"的旋钮。旋钮拧太大(线程太多)超过 256 上限会报错,拧太小(比如 workgroup_size(1))又浪费 GPU 的 SIMT 批处理优势,通常取 64~256 之间、且最好能被维度整除的数最舒服。我后来养成一个习惯:先用能被维度整除的 workgroup 尺寸,省去边界检查的心智负担,实在不行再上 if兜底。
四、正菜:用 WGSL 做矩阵乘法
好了,热身结束,上标题里说的硬菜:在浏览器里用 WGSL 做矩阵乘法。为什么选矩阵乘法当范例?因为它几乎是所有 GPU 计算教程的"Hello World"——既足够简单让人一眼看懂并行在哪,又足够经典能暴露工作组、边界、带宽这些核心矛盾。你把这个吃透,后面卷积、Transformer 的注意力打分、物理仿真里的力场积分,全是它的变体。
假设 A 是 M×K 矩阵,B 是 K×N 矩阵,结果 C 是 M×N。朴素算法里,C 的第 (row, col) 个元素等于 A 的第 row 行 和 B 的第 col 列 对应相乘再求和。瓶颈在哪儿?C 有 M×N 个元素,每个都要做 K 次乘加,全算下来是 O(M·N·K) 量级。CPU 上一个一个循环,大矩阵能算到你怀疑人生;GPU 上呢?让每个线程负责 C 里一个元素,几万个线程同时开工,瞬间的事。
先写 WGSL:
wgsl// ===== compute_matmul.wgsl =====// 注意:WGSL 里 storage buffer 的访问权限要写清楚// read = 只读(对应 JS 里 binding 的 type:"read-only-storage")// read_write = 可读写(对应 type:"storage")// A 矩阵:M 行 K 列,一维铺平存储(行优先)@group(0) @binding(0) var<storage, read> A: array<f32>;// B 矩阵:K 行 N 列@group(0) @binding(1) var<storage, read> B: array<f32>;// C 矩阵:M 行 N 列,结果写这里@group(0) @binding(2) var<storage, read_write> C: array<f32>;// uniform 里放矩阵维度。// 划重点:uniform 结构体要遵守 16 字节对齐规则,// 所以我们塞了 4 个 u32(刚好 16 字节),多出来的 _pad 是占位符struct Dims { m: u32, k: u32, n: u32, _pad: u32,};@group(0) @binding(3) var<uniform> dims: Dims;// @workgroup_size(8, 8) 表示每个工作组是 8×8 = 64 个线程// 两个数字乘积 64 ≤ 256,合规@compute @workgroup_size(8, 8)fn main(@builtin(global_invocation_id) gid: vec3<u32>) { let row = gid.x; // 当前线程负责 C 的第几行 let col = gid.y; // 第几列 // 边界检查!如果矩阵维度不是 8 的整数倍, // dispatch 会多派出线程,越界写 storage 会直接算错甚至崩 if (row >= dims.m || col >= dims.n) { return; } var sum = 0.0; // 朴素矩阵乘法:C[row][col] = Σ A[row][k] * B[k][col] for (var k: u32 = 0u; k < dims.k; k = k + 1u) { sum = sum + A[row * dims.k + k] * B[k * dims.n + col]; } C[row * dims.n + col] = sum;}
这里我故意把工作组设成二维(8, 8),因为矩阵天然是二维的——gid.x当行号,gid.y当列号,语义一下子就清楚了。相比一维(64),二维写法让"每个线程算 C 的一个格子"这件事读起来毫无心智负担。
然后是 JS 主流程,把四个 buffer(A、B、C、维度)、绑定组、管线、dispatch、读回串起来:
jsasync function runMatMul() { // 1) 拿设备,这是 WebGPU 一切的起点 const adapter = await navigator.gpu.requestAdapter(); if (!adapter) throw new Error("这浏览器不支持 WebGPU,换个 Chrome/Edge 113+ 吧"); const device = await adapter.requestDevice(); // 2) 准备数据(随手造两个小矩阵测试) const M = 256, K = 256, N = 256; const A = new Float32Array(M * K).map(() => Math.random()); const B = new Float32Array(K * N).map(() => Math.random()); const dimsData = new Uint32Array([M, K, N, 0]); // 16 字节,喂给 uniform // 3) 建 GPU 缓冲区。 // 划重点:输入 buffer 必须带 COPY_DST 才能 writeBuffer; // 输出 C 要带 COPY_SRC 才能拷出来读回;staging 要 MAP_READ。 const usageIn = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST; const usageOut = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC; const bufA = device.createBuffer({ size: A.byteLength, usage: usageIn }); const bufB = device.createBuffer({ size: B.byteLength, usage: usageIn }); const bufC = device.createBuffer({ size: M * N * 4, usage: usageOut }); const bufDims = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); const staging = device.createBuffer({ size: M * N * 4, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); // 4) 把数据推到 GPU device.queue.writeBuffer(bufA, 0, A); device.queue.writeBuffer(bufB, 0, B); device.queue.writeBuffer(bufDims, 0, dimsData); // 5) 编译 WGSL const module = device.createShaderModule({ code: wgslSource }); // 6) 描述资源布局:哪个 binding 是什么类型(read-only-storage / storage / uniform) const bgl = device.createBindGroupLayout({ entries: [ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type:"read-only-storage" } }, { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type:"read-only-storage" } }, { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type:"storage" } }, { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type:"uniform" } }, ], }); const layout = device.createPipelineLayout({ bindGroupLayouts: [bgl] }); // 7) 计算管线 const pipeline = device.createComputePipeline({ layout, compute: { module, entryPoint:"main" }, }); // 8) 把真实 buffer 绑到 binding 上 const bindGroup = device.createBindGroup({ layout: bgl, entries: [ { binding: 0, resource: { buffer: bufA } }, { binding: 1, resource: { buffer: bufB } }, { binding: 2, resource: { buffer: bufC } }, { binding: 3, resource: { buffer: bufDims } }, ], }); // 9) 编码命令并 dispatch const encoder = device.createCommandEncoder(); const pass = encoder.beginComputePass(); pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); // dispatch 的工作组数量 = 维度数 / 每个工作组线程数,向上取整 pass.dispatchWorkgroups(Math.ceil(M / 8), Math.ceil(N / 8)); pass.end(); // 把结果从 C 拷到可映射的 staging buffer encoder.copyBufferToBuffer(bufC, 0, staging, 0, M * N * 4); device.queue.submit([encoder.finish()]); // 10) 读回结果 await staging.mapAsync(GPUMapMode.READ); const result = new Float32Array(staging.getMappedRange()); staging.unmap(); return result;}
dispatchWorkgroups(Math.ceil(M / 8), Math.ceil(N / 8)) 这一步,就是在二维网格上铺工作组:横向铺满行,纵向铺满列,每个工作组覆盖 8×8 的格子,覆盖完整个 C 矩阵。非常直观。
五、WGSL 里的存储与 uniform:读写权限别写错
这块是我另一个重灾区。WGSL 的 storage buffer 必须声明读写权限,而且WGSL 侧和声明的 JS 绑定类型必须严格对应,否则建管线直接报错:
var<storage, read> X→ JS 里 binding 的buffer.type必须是"read-only-storage";var<storage, read_write> X→ JS 里必须是"storage";var<uniform> X→ JS 里必须是"uniform"。
我当初偷懒把 A、B 都写成 read_write,结果 A 明明只读,JS 却得按 storage 配,后面改维度时一不小心在其他地方写了 A,数据就脏了。能只读就只读,既是规范也是安全。
再说 uniform 的 16 字节对齐。我把维度塞进一个 struct Dims { m, k, n, _pad },四个u32 正好 16 字节。如果你只放三个 u32然后直接new Uint32Array([M, K, N])(12 字节),uniform 的最小对齐会让它实际占用 16 字节,读出来 n 可能是错的——这种 bug 不报错但结果静默错乱,最折磨人。要么补 _pad,要么干脆用vec4<u32>当容器。
六、读回结果 & 对拍验证(别迷信 GPU)
GPU 算得快,但快不代表对。我有个铁律:任何 GPU 计算第一次跑通,必须用 CPU 版结果对一遍。下面是 CPU 朴素矩阵乘法,当"标准答案":
js// CPU 版朴素矩阵乘法,用来给 GPU 结果当"标准答案"对拍function cpuMatMul(A, B, M, K, N) { const C = new Float32Array(M * N); for (let r = 0; r < M; r++) { for (let c = 0; c < N; c++) { let s = 0; for (let k = 0; k < K; k++) { s += A[r * K + k] * B[k * N + c]; } C[r * N + c] = s; } } return C;}// 对拍:允许浮点误差在 1e-4 内function assertClose(gpu, cpu) { for (let i = 0; i < gpu.length; i++) { if (Math.abs(gpu[i] - cpu[i]) > 1e-4) { throw new Error(`第 ${i} 个元素对不上:GPU=${gpu[i]} CPU=${cpu[i]}`); } } console.log("✅ GPU 与 CPU 结果一致");}
跑assertClose(result, cpuMatMul(A, B, 256, 256, 256)),对上了你再放心。我见过太多人 dispatch 完直接把结果当真理,结果工作组边界漏算了一行,渲染出来整张图偏色,排查了三天。对拍这步,五分钟,保命。
顺带提一嘴,如果你的计算结果是要跨标签页共享或者做实时大屏联动,BroadcastChannel 实战:同源多标签页实时通信(登录态同步与状态广播)那套同源广播机制可以省不少事;而纯前端动画展示这些计算结果,Web Animations API 实战:用 JS 精确控制动画(play/pause/.currentTime 与 finished) 比手写 requestAnimationFrame 循环优雅太多。
进阶:分块矩阵乘法与 workgroup 共享内存(性能再榨一层)
上面那版矩阵乘法是"朴素版",每个线程独立读 A、B,重复读了 K 次,显存带宽被白白浪费。真正上规模的项目,会做分块(tiling):利用工作组内的共享内存(var<workgroup>),让一个工作组的线程先把一小块 A、B 搬进共享内存,再一起算,大幅减少对全局显存的访问。
这块我点到为止,因为要把代码写对得多几十行,而且涉及workgroupBarrier() 同步——稍微写错,线程间就互相踩数据,是我踩过的另一个深坑。核心就一句:共享内存比全局 storage 快一个数量级,分块是把 GPU 喂饱的关键。等你把朴素版跑顺、对拍无误了,再回头加 tiling,循序渐进别一口气吃成胖子。我当初就是贪快直接上分块,结果 barrier 没放对位置,算出来的矩阵像被打码了一样,排查到怀疑人生。所以这一节的结论很朴素:先能跑对,再谈跑快,顺序不能乱。
七、什么时候该上计算着色器,什么时候是杀鸡用牛刀
别被"GPU 并行"冲昏头。我后来复盘,大概一半想上 WebGPU 计算的需求其实没必要:
值得上 GPU 的场景:
- 大规模数值计算:矩阵运算、物理仿真、粒子系统、图像/信号处理(卷积、滤波)。
- 机器学习推理的前处理/后处理在浏览器里跑。
- 任何"同一个操作要作用在百万级元素上"的活。
其实 CPU/JS 就够了甚至更好的场景:
- 数据量小(几千以内),CPU 循环根本不是瓶颈,多写一堆缓冲区绑定反而拖慢开发。
- 强依赖顺序、分支极多的逻辑(GPU 擅长规整的并行,讨厌 if 满天飞)。
- 需要频繁和 DOM 交互的。GPU 算完还得拷回 CPU 再渲染,来回搬运成本别忘了。
一句话:数据量够大、计算够规整,才值得为那套样板代码买单。别忘了,建立设备、编译着色器、dispatch 本身都有固定开销,小任务上 GPU 可能比纯 JS 还慢。
如果你正在做一个需要点对点实时传矩阵数据、又不想自己搭信令服务器的东西,WebRTC DataChannel 实战:不依赖服务器的点对点消息传输(ICE/STUN 与信令)里讲的不依赖中心服务器的通道思路值得看看;而界面布局上想让这些可视化组件自适应自己的容器而不是整个视口,CSS 容器查询实战:让组件响应自己的容器而非视口(@container 与 cqw)会给你另一种组件化思路。
八、我踩过的那些坑(划重点清单)
踩坑这件事,听别人说十遍不如自己炸一次。但炸一次就够了,下面这些是我用一下午的报错信息和三个通宵的排查换来的,建议你直接抄作业,把学费省下来喝杯咖啡:
- workgroup_size 乘积超 256:报错
workgroup size exceeds limits。常见安全值(8,8)=64、(16,16)=256,别贪。 - 忘了边界检查:矩阵维度不是工作组倍数的整数倍时,多派出的线程越界写 storage,结果静默错乱。永远写
if (row >= M || col >= N) { return; }。 - buffer 缺 usage 标志:
writeBuffer前要COPY_DST,读回要MAP_READ,storage 要STORAGE。少一个就报错,报错信息还贼 cryptic。 - 直接 map storage buffer:storage buffer 不能
mapAsync,必须先copyBufferToBuffer到一个MAP_READ的 staging buffer。 - uniform 没对齐 16 字节:维度用
struct补_pad,或换vec4<u32>。 - WGSL 读写权限和 JS 绑定类型不匹配:
read配read-only-storage,read_write配storage,对不上建管线就崩。 - 提交前没 await map:
mapAsync是异步的,必须await之后再读getMappedRange(),否则拿到空或旧数据。 - 非 8 倍数维度 dispatch 用整除:必须用
Math.ceil,否则最后若干行/列没被覆盖。 - 浏览器不支持还硬跑:先
navigator.gpu判空,再requestAdapter()判空,给用户兜底提示。 - dispatch 维度超过 65535:
maxComputeWorkgroupsPerDimension是 65535,意味着当 M > 65535×8 ≈ 52 万时,得用二维/三维 dispatch 拆分,别在一维上硬怼。
小结 / 最佳实践
WebGPU 计算着色器没那么神秘,本质就是:把数据搬上 GPU(缓冲区)→ 告诉管线资源长啥样(绑定组布局)→ 写一段 WGSL 让每个线程算自己那一份(计算着色器)→ 派工作组并发执行(dispatch)→ 把结果拷回 CPU(staging + map)。矩阵乘法是这个流程最经典的练手题,理解了一个元素一个线程、工作组就是线程块、dispatch 负责铺满网格,后面什么卷积、规约、物理仿真都是换皮。
最佳实践我总结三条:第一,先写 CPU 参考实现再上 GPU,永远对拍;第二,工作组大小取 64 或 256 的整数分解,边界检查雷打不动;第三,数据量没到万级别急着上 GPU,样板代码和搬运成本可能比收益还高。把这三点刻进肌肉记忆,WebGPU 计算就算真正入门了。
最后泼盆冷水:WebGPU 计算不是银弹。它带来的是"大规模规整并行"的能力,代价是样板代码、显存管理、WGSL 这门新语言的学习成本,以及跨浏览器那点仍不完美的兼容性。新手最容易犯的错就是"万物皆 GPU",结果小数据上 GPU 比 JS 还慢、还难调。我的建议始终是:先用最朴素的 JS 把逻辑跑通并当基准,再判断值不值得搬上 GPU。工具是为人服务的,别被"高性能"三个字绑架了工程判断——这行干久了你会明白,能跑对的慢代码,永远好过跑不对的快代码。
标签:WebGPU, 计算着色器, WGSL, GPU并行描述:手把手用 WGSL 在浏览器里写 WebGPU 计算着色器,从数组翻倍热身到矩阵乘法实战,讲透 @workgroup_size、dispatchWorkgroups、global_invocation_id 与存储/uniform 绑定的用法,并附 10 个真实踩坑清单与 GPU/CPU 对拍验证方法。

发表评论发布评论后审核成功,即可显示在列表中...