想象你经营一家网红餐厅,总厨(CPU)一个人包揽所有菜。客人点了一百份相同的炒饭,他只能一份一份颠勺,锅气还没上来,等位的人已经走光了。有一天你雇了一支帮厨团队(GPU)——他们不负责创意摆盘,只负责按同一张工序卡机械地翻炒。只要工序卡写得清晰、备料摆位合理,一百份炒饭能同时出锅。这套“厨房管理体系”就是 WebGPU,而那张决定每个帮厨“做什么、按什么顺序做”的工序卡,就是计算着色器(Compute Shader)。在浏览器里跑 AI,从图片滤镜到大模型推理,本质上都是把海量重复计算交给这支帮厨团队。本文就用“厨房”当导游图,带你从零认识:工序卡怎么写(WGSL)、帮厨怎么站队(工作组)、备料怎么放(缓冲),以及怎么让后厨满负荷运转(性能优化)。
对 AI 从业者而言,掌握 WebGPU 计算着色器意味着能突破 WASM 的纯 CPU 算力瓶颈,在浏览器端直接驱动 GPU 完成模型推理与预处理,这是端侧 AI 工程化的重要加分项。
可视化演示
演示WebGPU计算着色器的核心机制:GPU计算管线流程、工作组网格并行调度,以及CPU与GPU计算性能对比分析。
本节配有交互动效(进入视口后自动加载,约 3KB)。
WebGPU计算管线流程 输入缓冲区数据经管线传输至GPU显存 计算着色器在GPU上并行执行WGSL代码 计算结果写回缓冲区供CPU读取 Pipeline=Buffer→Shader→Dispatch→Readback WebGPU释放浏览器GPU计算能力
计算着色器概念
总厨发现最耗时间的不是调味创新,而是给一百份牛排做同一种熟度处理。他决定把这部分外包给帮厨团队:只要在订单上写明“七分熟、每面三分钟”,每个帮厨拿着同一张工序卡,各自负责一块牛排。这张工序卡就是计算着色器的雏形——一段写给 GPU 的批处理程序,专业说法叫“计算着色器(Compute Shader)”。
在浏览器生态里,这张工序卡由 WebGPU 的 WGSL 语言书写。与负责“画画”的顶点/片元着色器不同,计算着色器不关心屏幕上的三角形或颜色,它只关心“一批数据 → 一批结果”的数学运算。它像一个专门处理重复劳动的通用车间:输入一堆数字,输出一堆数字,过程高度并行。
为什么 AI 离不开它?因为神经网络里最耗时的矩阵乘法、卷积、归一化,全是“同一套动作重复上万次”的体力活。在浏览器 ML 这条路上,WASM 与 WebGPU 是并行的两个引擎:WASM 请的是 CPU 打工人,WebGPU 请的是 GPU 帮厨团队。GPU 里有数千个计算核心,计算着色器正好把它们全部发动起来。
所以计算着色器等于把同一份工序卡印了一千份发给帮厨团队,每人动作相同、互不打扰,后厨产能瞬间翻倍。但它不是万能的:如果每道菜工序不同、前后依赖强,帮厨们反而会互相等待。这也是为什么我们把矩阵运算交给 GPU,而把逻辑判断留在 JavaScript 里。
- 串行时间 \(T_{串行}\)总厨一个人做完所有任务的时间,所有菜依次排队下锅。
- 并行时间 \(T_{并行}(p)\)交给 p 个帮厨同时处理后的耗时,理想情况下是 \(T_{串行} / p\)。
- 加速比 \(S\)串行与并行耗时的比值,理想时正比于帮厨人数,即 p 倍提速。
WGSL语言基础
工序卡要能被所有帮厨看懂,必须先统一写法。WGSL 就是 WebGPU 规定的“菜谱语言”:风格像 Rust,结构清晰、类型严格,写错了在编译阶段就会被厨师长拦下,不会等到上菜才发现问题。它的核心任务是告诉 GPU:“每个帮厨拿到这份卡,重复执行其中步骤。”
一份计算着色器代码的灵魂是“入口函数”,相当于工序卡上的“主流程”章。它用 @compute 属性标记,再用 @workgroup_size 声明每个工位站几个帮厨(工作组尺寸),例如 @workgroup_size(64) 表示每组 64 个线程。CPU 端则用 dispatch 决定这份工序卡被派发多少遍,两者相乘就是被召唤的总人数。
WGSL 还提供了一套基础语法积木:u32、f32 等标量类型,vec 向量、array 数组等容器类型,以及 global_invocation_id、local_invocation_id 等内置变量——它们相当于帮厨的“工牌编号”,让每个人知道自己在处理第几份食材。数据类型越严格,GPU 驱动的优化空间越大,这也是 WGSL 设计成强类型语言的原因。
把工序卡写得越规范,GPU 执行越不容易出错。WGSL 的严格类型就像餐厅的岗位责任制:每个帮厨只能做规定动作,虽然写起来约束多,却让编译器能提前发现错误,运行时也更少崩溃。
- 工作组尺寸 \(S_d\)工序卡上写的“每工位几人”,三个维度分别记为 \(S_x, S_y, S_z\)。
- 派发次数 \(D_d\)总厨下令“这套工序卡重复几次”,对应 dispatch 的 x/y/z 三个参数。
- 总线程数两者相乘就是被同时召唤的帮厨总数。
线程与工作组
帮厨团队不是乱哄哄挤在一个大厅,而是分成一个个“工位小组”(工作组,Workgroup)。每个小组有固定人数的帮厨(线程/调用),共享一口备料热锅(共享内存);小组之间各自独立,互不串门。这种“分组干活”的结构,是 GPU 高效调度的核心。
每个帮厨需要知道自己是“哪个组的第几个”。GPU 通过内置变量回答:workgroup_id 是组编号,local_invocation_id 是组内编号,两者合成 global_invocation_id——全球唯一工号。把“第 3 组第 5 个帮厨”换算成“全场第几号”,靠的就是下面这个公式。
工作组尺寸不是乱定的。WebGPU 规定 workgroup_size 每维上限通常为 256,总调用数受设备能力限制(常见 256/512/1024),同时共享内存容量也会卡住组的大小。工程上常用 64、128、256 等 2 的幂。组内共享内存就像小组的一口热锅:料放在锅里,大家伸手就能拿,比每次跑去大冷库(显存)快得多,这也是矩阵分块算法爱用它的原因。
- 组编号 workgroup_id当前线程属于第几个工作组,从 0 开始计数。
- 组内编号 local_id线程在组内的偏移,范围是 0 到 S-1。
- 合成全局编号先跳过前面的 workgroup_id 个组(每组 S 人),再叠加组内偏移,得到唯一工号。
缓冲与内存
帮厨再强,手里没料也做不出菜。CPU 要把数据交给 GPU,GPU 算完再把结果还回来,中间的“冰箱与货架”就是缓冲(Buffer)。WebGPU 中的存储缓冲(storage buffer)像一个可读可写的大冷库,AI 权重、张量数据都放这里;统一缓冲(uniform buffer)则像墙上贴的厨房规章,只读、小而快,每次调度所有线程共享同一份。
数据放进缓冲不是随手一扔,要遵守“货架对齐”规则。WGSL 的存储布局要求很多字段按 16 字节对齐——类似超市规定大件货物必须从格子的整数边界开始放,否则翻箱倒柜找料时会撞车。结构体成员的顺序和大小,直接决定这块缓冲最终占多大、每个字段从哪个偏移开始读。
CPU 与 GPU 之间的交流是异步的。GPU 算完后,JavaScript 用 mapAsync 把缓冲“映射”到内存地址,拿到结果再 unmap 归还——就像从冷库出货到前台:不是你一喊就到,而是按单出库、广播叫号。内存这块最容易出事故:结构体顺序写错、对齐不够、忘记 unmap,轻则数据错乱,重则管线报错。记住一句话:货架规则是 GPU 的礼貌,遵守它,整条流水线才顺畅。
- 字段占用 size一个数据成员占用的字节数,如 f32 占 4 字节、vec4 占 16 字节。
- 除以 16看看这个字段需要占多少个“标准货格”。
- 向上取整装不下时也要多占一整格,避免两个字段挤进同一格。
- 乘回 16得到下一个成员的起始偏移,保证始终落在整数边界上。
管线接入
有了工序卡、工位、备料,还差“下单—执行—上菜”的完整流程。在 WebGPU 里,这条流程叫计算管线(Compute Pipeline):把 WGSL 源码编译成 GPU 认识的机器指令,配置好缓冲,最后一声令下 dispatch,GPU 便按工作组批量开工。
步骤大致分三步。第一,createShaderModule 编译着色器,再用 createComputePipeline 建管线;第二,用 createBindGroupLayout 声明缓冲“插槽”,再用 createBindGroup 把实际缓冲插进去——相当于告诉帮厨“料在第 0 层第 3 格”;第三,computePass.dispatchWorkgroups(Dx, Dy, Dz) 正式派活。注意 dispatch 的参数是“每维派发多少个工作组”,不是线程数。
任务总元素数是 N,每工作组线程数是 S 时,所需的组数要向上取整,否则末尾多出来的几个元素没人处理——就像 100 张桌子每桌坐 6 人,最后一桌只有 4 人也得开一桌。
算完之后,数据还在 GPU 的冷库里。要“上菜”给 JavaScript:要么 mapAsync 映射回读拿到结果;要么把计算缓冲直接作为下一步渲染的输入,省去一次搬运。这就像后厨做好半成品,直接转到摆盘台,不必先端回前台再端回来。
- 元素总数 N要计算的数据条数,比如 1000 个浮点数。
- 工作组线程数 S每个工位同时作业的帮厨人数。
- 相除并向上取整1000 除以 64 得 15.625,不足 16 组装不下,所以取 16 组。
性能优化策略
支起整个后厨只是开始,客人多了,翻台率才是利润。计算着色器的性能优化,本质是让 GPU 成千上万个核心始终有活干,而不是空转等数据。四个常用策略是:负载均衡、内存合并、占用率优化、计算与渲染重叠。
工作负载均衡,是让每个线程的任务量均匀,如果数据末尾不满一组,很多线程会在条件判断里“下岗摸鱼”;合并内存访问,是让相邻线程访问相邻内存地址,GPU 一次事务就能搬回整段数据,像帮厨按货架顺序挨个取料,而不是满仓库乱跑;占用率优化,是提高同时活跃的工作组数量,让硬件调度器有足够线程来隐藏访存延迟——帮厨足够多,等料的时候还有人接着炒。
计算与渲染重叠,是在录制命令时把计算 pass 穿插在渲染 pass 之间,让 GPU 在画一帧的同时顺手算完下一帧的物理或 AI 数据,像两班厨师轮轴转。优化要有先后观:先看负载是否均衡,再看内存访问是否连续,然后查占用率,最后才谈重叠——先保证每个帮厨手里有活,再优化取料路线,本末倒置往往白忙一场。
- 活跃工作组数 \(A_{wg}\)当前正在运行的工作组数量,代表实际在干活的人数。
- 硬件最大容纳数 \(M_{wg}\)GPU 根据寄存器、共享内存等资源算出的上限。
- 占用率两者之比越高,核心越不空转,访存延迟越容易被并发任务隐藏。
AI 应用场景
网页端实时 AI 图像滤镜
阶段:数据预处理
解决的问题:传统 Canvas 2D 逐像素遍历跑在 CPU 上,处理 4K 图明显卡顿;计算着色器把像素级运算分给上万线程并行执行,像帮厨们一人一块牛排同时开煎。
具体案例:视频会议网页对摄像头帧做美颜和背景分割,人像分割模型跑在 WebGPU 上,计算着色器负责输入归一化与后处理 softmax,整条链路端到端稳定在 30fps 以上。
浏览器内大语言模型推理
阶段:推理部署
解决的问题:WASM 纯 CPU 跑量化大模型速度感人,而 Transformer 的矩阵乘法和注意力计算并行度极高,正是计算着色器的用武之地。
具体案例:开源的 WebLLM 项目用 WebGPU 计算着色器实现矩阵乘与注意力计算,让 7B 量化模型在普通笔记本的浏览器里流式生成文字,推理速度比纯 WASM 快一个数量级。
Web 端信号处理加速
阶段:特征工程
解决的问题:FFT、滤波等信号处理有大量重复蝶形运算,JavaScript 单线程难以满足实时性;把数据拆到多个工作组并行计算后,延迟大幅下降。
具体案例:语音识别前端的频谱提取,用计算着色器把 4096 点 FFT 分到多个工作组并行,处理延迟从 40ms 降到了 5ms 以内。
Python 演示
"""模拟 WebGPU 计算着色器的线程调度与向量加法""" import math WORKGROUP_SIZE = 4 def simulate_compute_shader(a, b, wg_size=WORKGROUP_SIZE): """用 Python 模拟 GPU 线程: 每个线程负责一个元素相加""" n = len(a) output = [0] * n wg_count = math.ceil(n / wg_size) trace = [] for wg in range(wg_count): # 派发工作组 for local_id in range(wg_size): # 组内线程并行执行 global_id = wg * wg_size + local_id if global_id < n: output[global_id] = a[global_id] + b[global_id] trace.append((wg, local_id, global_id)) return output, wg_count, trace def main(): a = [1, 2, 3, 4, 5, 6, 7, 8, 9] b = [10, 20, 30, 40, 50, 60, 70, 80, 90] output, wg_count, trace = simulate_compute_shader(a, b) print("数组长度:", len(a)) print("工作组大小:", WORKGROUP_SIZE) print("派发工作组数:", wg_count) print("执行轨迹(工作组, 组内线程, 全局编号):") for item in trace: print(" ", item) print("计算结果:", output) if __name__ == "__main__": main()
wg_count = math.ceil(n / wg_size)对应 dispatch 派发组数公式 \(D = \lceil N/S \rceil\),向上取整保证最后一个元素也有人处理。for wg in range(wg_count):模拟 CPU 逐个派发工作组,真实 GPU 中所有工作组由硬件调度。for local_id in range(wg_size):组内线程“并行”循环,真实 GPU 中这几条线程同时执行。global_id = wg * wg_size + local_id全局编号计算公式,把“第几组第几个”换算成全场的唯一工号。output[global_id] = a[global_id] + b[global_id]每个线程执行的核心任务:读两个数、相加、写回。trace.append((wg, local_id, global_id))记录执行轨迹,方便观察线程与数据的映射关系。
常见误区
误区我以为计算着色器必须跟随顶点着色器和片元着色器一起使用,没有渲染目标就运行不了。
正解计算着色器独立于渲染管线,与画图完全解耦。它不需要顶点缓冲、不需要渲染目标,只要建好计算管线、配好绑定组、调用 dispatch 就能运行。它和渲染着色器是 GPU 里两条平行的流水线,就像“炒菜车间”和“摆盘车间”各干各的,互不依赖。
为何常见:名字带“着色”(Shader)让人误以为只用于绘图,实际上 Shader 在 GPU 语境里泛指“可编程计算单元执行的程序”,计算着色器正是其中的通用工种。
误区每个工作组放 1024 个线程,一次处理更多数据,肯定比设 64 个线程快。
正解工作组尺寸受硬件硬上限约束(每维通常 ≤ 256,总数受 maxComputeInvocationsPerWorkgroup 限制)。超过限制直接编译失败;即使没超限,过大的工作组也会降低调度灵活性、挤占共享内存。工程上常用 64/128/256,并且要结合共享内存容量一起调优。
为何常见:把 CPU 里“线程越多越快”的直觉照搬到 GPU,忽略了 GPU 的调度单元是 warp/子组,以及共享内存和寄存器的容量瓶颈。
误区都是缓冲,把 AI 权重放进统一缓冲(uniform buffer)应该也一样能用。
正解统一缓冲有大小硬上限(约 64KB 量级)且只读,适合参数常量;存储缓冲(storage buffer)可读可写、容量可达 GB 级,适合权重和张量。两者的 usage 标志在创建时固定,互换要么报错,要么性能骤降。
为何常见:只看到“缓冲”两个字,没注意 WebGPU 对不同用途的缓冲设定了严格的大小、访问权和布局约束。
误区调用 dispatchWorkgroups 之后,马上读取缓冲就应该有数据。
正解GPU 执行是异步的,dispatch 只是把命令排进队列,命令还没执行完数据自然没就绪。必须用 mapAsync 并等待 Promise 回调,或用 fence 同步后才能安全回读;提前读可能拿到旧数据或触发校验错误。
为何常见:把 GPU 当成同步执行的普通函数,忘了它和 CPU 是两台隔着总线的设备,中间必然有一道“异步的墙”。
面试题
Q1. 计算着色器在 GPU 中的主要角色是什么?
- A.负责三维模型的绘制
- B.负责通用并行计算← 正确答案
- C.负责网络请求的发送
- D.负责页面样式的渲染
答案:B。计算着色器是 GPU 上“帮厨团队”执行通用并行计算的程序,与画图无关。A 是顶点着色器的职责,D 是浏览器渲染引擎的职责,C 是网络层的工作,都不属于计算着色器。
Q2. 在 WGSL 中,计算着色器入口函数使用哪个属性标记?
- A.使用 vertex 标记入口
- B.使用 fragment 标记入口
- C.使用 compute 标记入口← 正确答案
- D.使用 dispatch 标记入口
答案:C。WGSL 中计算着色器入口函数用 @compute 标记,相当于给工序卡盖上“批量加工”章。@vertex 和 @fragment 属于渲染着色器,@dispatch 不是 WGSL 属性,派发动作发生在 JavaScript 侧。
Q3. 全局线程编号 global_invocation_id 的计算关系是什么?
- A.组编号乘组内线程数加组内编号← 正确答案
- B.组编号加组内线程数乘组内编号
- C.组编号减组内编号再乘线程数
- D.组编号与组内编号取最大公约数
答案:A。全局编号 = 组编号 × 组内线程数 + 组内编号,像“第 3 组第 5 个帮厨”换算成全场第 3×S+5 号。B 颠倒了运算顺序,会把不同组的帮厨算到同一编号,C/D 是干扰项。
Q4. 创建 GPU 计算管线的 WebGPU API 是哪个?
- A.createComputePipeline← 正确答案
- B.createRenderPipeline
- C.createBindGroupLayout
- D.createShaderModule
答案:A。createComputePipeline 把编译好的着色器模块组装成可派发的计算管线。B 创建的是渲染管线,C 只描述绑定组布局,D 只完成着色器编译,都没有真正创建计算管线。
Q5. dispatchWorkgroups(x, y, z) 中的参数含义是什么?
- A.每个工作组内的线程数
- B.三个维度派发的工作组数← 正确答案
- C.缓冲区中的元素总个数
- D.顶点缓冲的顶点个数
答案:B。dispatchWorkgroups 的参数是每维派发的工作组数量,不是线程数;线程数由 @workgroup_size 决定,二者相乘才是总线程数。A 描述的正是 workgroup_size,C/D 与派发参数无关。
Q6. 从 GPU 缓冲回读计算结果到 CPU 时使用的 API 是?
- A.mapAsync 异步映射← 正确答案
- B.dispatch 派发任务
- C.createView 创建视图
- D.draw 绘制图形
答案:A。mapAsync 把 GPU 冷库里的缓冲映射到 CPU 可读的内存地址,异步回调中就绪后可安全取料。dispatch 只是派活命令,createView 创建纹理视图,draw 属于渲染流程,回读结果都不靠它们。
Q7. 相比统一缓冲,存储缓冲(storage buffer)的突出特点是什么?
- A.只能存放只读常量
- B.支持大块数据读写← 正确答案
- C.每帧必须重新上传
- D.只能存放顶点数据
答案:B。存储缓冲可读可写、容量可达 GB 级,适合存放 AI 权重等大块数据;统一缓冲只读且有约 64KB 硬上限。A 描述的是统一缓冲的特点,C 是常见误解,D 把缓冲和顶点属性输入混淆了。
Q8. 以下哪些属于 WGSL 计算着色器的内置变量?
- A.全局调用编号变量← 正确答案
- B.局部调用编号变量← 正确答案
- C.工作组编号变量← 正确答案
- D.顶点位置编号变量
答案:ABC。global_invocation_id(全局调用编号)、local_invocation_id(局部调用编号)、workgroup_id(工作组编号)都是 WGSL 发给线程的“工牌编号”,分别对应全局号、组内号、组号。顶点位置来源于渲染管线的顶点输入,不是计算着色器内置变量。
Q9. 下列哪几项能有效提升计算着色器性能?
- A.合并连续内存访问← 正确答案
- B.提升线程占用率← 正确答案
- C.计算与渲染重叠执行← 正确答案
- D.把工作组分得越来越大
答案:ABC。合并内存访问让相邻线程读相邻地址,一次取回整段数据;高占用率让核心不容易空转;计算与渲染重叠让 GPU 闲时也有活干。D 违背工作组尺寸的硬件上限,只会导致编译失败或调度效率下降。
Q10. 工作组共享内存的典型用途有哪些?
- A.组内线程快速交换中间数据← 正确答案
- B.减少访问全局显存的延迟← 正确答案
- C.用于矩阵分块计算的数据重用← 正确答案
- D.完全替代存储缓冲的功能
答案:ABC。共享内存像组内一口热锅,速度比全局显存快得多,适合组内线程换料和复用数据,矩阵分块计算正是靠它反复读取同一块数据而提速。但它的容量极小,无法替代存储缓冲,D 错。