作为常年泡在图形学和前端交叉领域的老兵,看到WebGPU 1.0正式落地,说实话心里挺感慨的。从最早在Chrome实验室里摸WICG草案,到如今各大主流浏览器默认开启,这条路走了快六年。更让人激动的是,它把“在浏览器里跑AI推理”从实验室玩具变成了生产环境可用的正菜。
过去我们聊浏览器端AI,绕不开三个痛点:要么用WebGL硬凹Compute Shader,被各种平台差异折磨得死去活来;要么走WASM + SIMD路线,性能天花板肉眼可见;要么干脆把数据丢到服务端,牺牲用户体验和隐私。WebGPU 1.0发布之后,局面彻底变了。它原生支持通用计算管线,直接对接Vulkan、Metal、D3D12这些现代图形API,让前端开发者第一次能用一套标准化接口,吃到GPU通用计算的红利。
这篇文章我就根据自己的实际踩坑经验,从原理到落地方案,把“浏览器内跑AI推理”这件事掰开揉碎了讲清楚。不管是刚入门的前端新人,还是已经在WebGL里挣扎许久的图形老手,应该都能从中找到自己能用的东西。
1. 为什么WebGL跑AI推理是一种折磨
1.1 WebGL的定位就不是干这个的
WebGL从诞生那天起,设计目标就是绘制三角形。它本质上是对OpenGL ES 2.0/3.0的封装,而OpenGL ES的核心抽象是“渲染管线”——顶点着色器、片元着色器、光栅化、深度测试,这套流程是为图形输出服务的。
但AI推理需要的是什么?是大规模并行矩阵运算。卷积、矩阵乘法、激活函数,这些操作压根不需要“绘制像素”,它们需要的是“把数据塞进Buffer,做数学运算,再把结果读回来”。
WebGL社区为了曲线救国,搞出了各种奇技淫巧。最常见的是把计算任务伪装成渲染任务:把数据编码成纹理,丢给片元着色器去算,再用Framebuffer把结果接住。听着挺聪明对吧?实际跑起来全是泪。纹理的格式限制多,浮点精度飘忽不定;纹理宽高限制导致索引计算绕来绕去;最致命的是,数据从CPU到GPU的每一趟拷贝都走的是为渲染优化的路径,带宽浪费严重,还带同步阻塞。
1.2 WebGL时代“曲线救国”方案的真实成本
我2020年做web端手势识别模型加速的时候,用WebGL把MobileNet的卷积层改写在片元着色器里。当时测下来,iPhone 12上能跑到40ms一帧,看起来还能用,但问题一大堆。
第一个坑是Framebuffer读取回读。GPU算完的Feature Map得用readPixels拿回CPU才能做后续的Non-Maximum Suppression,这一步是同步阻塞的,帧率直接腰斩。第二个坑是纹理坐标精度,在部分移动端GPU上,高维张量的坐标换算会出现偏移,结果就是数据错位,推理完全不可用。第三个坑是内存爆炸,中间Feature Map必须频繁地在纹理和Buffer之间搬运,一张1080P的输入图,光中间张量就吃掉几百MB显存。
我当时在技术方案评审里写过一句话:WebGL跑AI不是不行,是性价比太低——80%的精力耗在对抗平台差异上,只有20%在真正解决问题。
1.3 为什么大家宁愿用WASM兜底
在WebGPU成熟之前,另一个主流的方案是WebAssembly + SIMD。TensorFlow.js的WebAssembly后端就是典型代表。这个方案的好处是确定性:WASM在执行逻辑层面是确定性的,不管什么设备,同样的代码同样的流程,结果偏差极小,特别适合对数值精度敏感的模型。
但坏处也明显:WASM跑在CPU上。哪怕有SIMD指令集加持,它利用的也只是CPU的并行能力,和GPU动辄几千个核心的并行规模完全不是一个量级。我实测过在16核的M系列芯片MacBook Pro上跑ResNet50,WASM后端大概100-150ms,WebGPU后端可以做到20ms以内。五倍差距,在某些实时交互场景就是能用和不能用的区别。
2. WebGPU 1.0凭什么能扛起AI推理
2.1 Compute Shader是灵魂
WebGPU和WebGL最本质的区别,就是它引入了计算管线(Compute Pass)。如果你是第一次接触这个概念,可以把它理解为“一个不提‘绘制’这回事的GPU工作流”。
在渲染管线里,你得定义顶点怎么来、像素怎么输出,GPU才肯干活。而计算管线简单粗暴:你给我一堆线程,我给每个线程配一个编号,你用这个编号自己去数据堆里找活儿干。这个“按编号取数”的模式,恰恰就是张量运算最自然的打开方式。
拿矩阵乘法举例:一个1024x1024的矩阵乘,在WebGPU里你开一个1024x1024的线程网格,每个线程负责算输出矩阵的一个元素,把对应行列的数据取出来做点积,写入结果Buffer。一个shader程序搞定,没有纹理对齐,没有坐标换算,没有Framebuffer回读的破事。
2.2 Storage Buffer + Binding Group:为数据搬运而生
WebGL里Buffer的角色很尴尬——要绑顶点属性,要绑纹理坐标,绕来绕去都得经过渲染管线的固定入口。WebGPU另一个革命性的设计是Storage Buffer,它是纯粹的、可读可写的GPU内存大块头。
配合Binding Group机制,你能像拼乐高一样自由组合资源绑定:权重Buffer放一组,输入张量放一组,输出Buffer放一组。Shader里直接用数组下标访问,想怎么写就怎么写,没有任何图形学语义的绑架。
而且Storage Buffer天然支持分散写入。这意味着多个线程可以并行处理数据切片,然后各写各的输出区段,不用像WebGL片元着色器那样费劲地做原子操作和归约来避免冲突。
2.3 与Vulkan/Metal/D3D12同源的底层设计
WebGPU不是凭空造出来的新玩意儿。它的着色器语言WGSL在设计阶段就参考了SPIR-V的字节码生态,底层实现直接对接各平台的原生GPU驱动。
Chrome的实现路径是Dawn,Firefox是wgpu,Safari的WebKit也有自己的WebGPU实现。它们的共同逻辑是:把WGSL翻译成目标平台的着色器IR,再交给驱动执行。这套结构意味着,WebGPU的性能天花板就是原生图形API的天花板。
你不需要去学Vulkan那套繁琐的实例-设备-队列-命令缓冲区初始化,也不需要面对Metal的ARC自动引用计数玄学,写一份JS代码,它自己会在底层给你适配平台差异。
3. 用WebGPU跑一个真实的AI推理Demo
3.1 手写矩阵乘法:理解Compute Shader的起点
讲再多原理不如看代码。我带着你写一个最简单的矩阵乘法计算管线,感受一下WebGPU的完整工作流。别急着跑ONNX模型,先把基础设施摸熟。
const adapter = await navigator.gpu.requestAdapter(); const device = await adapter.requestDevice(); // 定义WGSL计算着色器 const shaderCode = ` struct Matrix { size: vec2<u32>, data: array<f32> } @group(0) @binding(0) var<storage, read> a: Matrix; @group(0) @binding(1) var<storage, read> b: Matrix; @group(0) @binding(2) var<storage, read_write> result: Matrix; @compute @workgroup_size(8, 8, 1) fn main(@builtin(global_invocation_id) gid: vec3<u32>) { let m = a.size.x; let k = a.size.y; let n = b.size.y; let row = gid.x; let col = gid.y; if (row >= m || col >= n) { return; } var acc: f32 = 0.0; for (var i: u32 = 0u; i < k; i = i + 1u) { acc = acc + a.data[row * k + i] * b.data[i * n + col]; } result.data[row * n + col] = acc; } `;注意@workgroup_size(8, 8, 1)这个装饰符。它定义了一个工作组里线程的组织方式,这里是8x8,一共64个线程。GPU调度以工作组为粒度,不同的硬件对工作组大小有不同偏好,一般来说16、32、64是安全区间。
// 创建计算管线 const pipeline = device.createComputePipeline({ layout: 'auto', compute: { module: device.createShaderModule({ code: shaderCode }) } }); // 创建Buffer并填入数据 const aData = new Float32Array([1, 2, 3, 4, 5, 6, 7, 8, 9]); const bData = new Float32Array([9, 8, 7, 6, 5, 4, 3, 2, 1]); // 两个输入矩阵都是3x3,结果也是3x3 const aBuffer = device.createBuffer({ size: aData.byteLength + 8, // 8字节存size字段 usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });这段代码有个容易漏掉的细节:我在结构体里放了一个size: vec2<u32>,所以Buffer的size需要预留8字节给这两个u32。实际写数据时,得先写尺寸,再偏移8字节写矩阵数据。很多人第一次跑出来结果全零,十有八九就是这里的内存布局没对齐。
// 用ArrayBuffer对Buffer做初始化 const writeArray = new ArrayBuffer(aData.byteLength + 8); const sizeView = new Uint32Array(writeArray, 0, 2); sizeView[0] = 3; sizeView[1] = 3; const dataView = new Float32Array(writeArray, 8); dataView.set(aData); device.queue.writeBuffer(aBuffer, 0, writeArray);然后就是编码指令、提交执行。
const bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [ { binding: 0, resource: { buffer: aBuffer } }, { binding: 1, resource: { buffer: bBuffer } }, { binding: 2, resource: { buffer: resultBuffer } } ] }); const encoder = device.createCommandEncoder(); const pass = encoder.beginComputePass(); pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.dispatchWorkgroups(3, 3, 1); // 3x3个工作组,每个8x8线程 pass.end(); device.queue.submit([encoder.finish()]);这里dispatchWorkgroups(3, 3, 1)和shader里的workgroup_size(8, 8, 1)配合起来算一下:整个GPU的总线程数是(3x8) x (3x8) = 24x24,多出来的线程会在shader入口的if (row >= m || col >= n)判断里直接退出。
这一步纯CPU代码可能有十行左右,但GPU上它是几百上千个线程并行在跑。跑完怎么读结果?用mapAsync。
await resultBuffer.mapAsync(GPUMapMode.READ); const result = new Float32Array(resultBuffer.getMappedRange(8)); console.log(result); // 9, 8, 7 带上一堆0 resultBuffer.unmap();3.2 用ONNX Runtime Web一行代码切到WebGPU后端
手写矩阵乘只是热身,真实项目里谁会用手搓卷积?直接上ONNX Runtime Web就行。它已经把WebGPU的计算后端封装好了,你要做的只是选对执行路径。
先装依赖:
npm install onnxruntime-web然后在代码里导入并指定后端:
import * as ort from 'onnxruntime-web/backend'; // 关键就这一行:让ORT走WebGPU执行提供方 ort.env.wasm.wasmPaths = '/static/wasm/'; ort.env.wasm.numThreads = navigator.hardwareConcurrency; const session = await ort.InferenceSession.create('/models/resnet50.onnx', { executionProviders: ['webgpu'] });接着正常跑推理:
const inputTensor = new ort.Tensor('float32', inputData, [1, 3, 224, 224]); const outputs = await session.run({ 'input': inputTensor }); const outputData = outputs['output'].data;就这么简单,一个38层的ResNet50,在支持WebGPU的Chrome上,推理速度从WebGL后端的85ms降到了20ms以内。executionProviders: ['webgpu']这行配置,就是我前面说“扔掉WebGL”的底气所在。
3.3 WebGPU后端推理的完整参数调优
但ORT WebGPU也不是开了就能用的,里面有几个关键的调优点。
显卡适配器的获取策略。默认的navigator.gpu.requestAdapter()会返回系统默认的GPU,通常是性能较好的独显。如果你在集成显卡的笔记本上调试,可以指定powerPreference: 'high-performance'。
const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });Buffer分配策略。ONNX Runtime在WebGPU后端内部会维护一个Buffer池来复用显存。但它没法知道你的应用场景是长时间驻留还是短时推理。如果你知道推理间隔较长,可以在两次推理之间调用session.hardwareRelease(),把GPU资源让出去,避免占着其他WebGL应用的内存不放。
不要反复创建Session。每个Session创建时都会做一次完整的模型分析和编译优化,耗时可能在几百毫秒到几秒不等。务必要把Session对象缓存起来复用。
4. 浏览器端AI推理的完整应用场景拆解
4.1 实时视频分析:在摄像头帧里做手势识别
我自己做得最多的场景就是浏览器实时视频流分析。过去做姿态估计,MediaPipe的WebGL方案在低端机上帧率只有十几帧,交互体验很差。换成WebGPU之后,同样的模型能稳定跑满30fps。
这里的关键是不用把视频帧走texImage2D的旧路,而是用VideoFrame+external texture机制低成本送入GPU。在WebGPU里,device.importExternalTexture()能直接拿到视频帧的GPU侧引用,省掉了从CPU回读再上传的损耗。
流程大概是:
getUserMedia拿到摄像头的MediaStream- 从
requestVideoFrameCallback里取最新帧 - 用
device.importExternalTexture({ source: video })导入成外部纹理 - 在计算管线里把纹理采样结果写入Storage Buffer,直接交给AI推理模块
这套链路走下来,摄像头一帧从进入到模型输出结果,端到端延迟大概30-40ms,基本感觉不到画面和处理结果之间的错位。
4.2 实时语音降噪与超分:不只是视觉的天下
很多人以为WebGPU只能处理图像,其实它对音频处理也很香。实时音频的短时傅里叶变换(STFT)、频域滤波这些操作天然是矩阵运算,非常适合GPU并行。
我尝试过把一个离线训练的语音降噪模型封装成WebGPU计算管线,在浏览器里对麦克风输入做实时处理。模型本身是卷积结构的TinyLSTM,权重加起来不到1MB,32位浮点精度完全够用。
处理延迟方面,浏览器音频处理块通常是128帧,约2.7ms一帧。模型推理在这个时间预算内完成绰绰有余——WebGPU跑两个卷积加一个LSTM层,每帧耗时不到0.5ms。做出来之后在团队内部演示,同事第一反应是“这音频处理是原生应用做的吧?”
这个场景的核心好处是隐私。音频数据从头到尾不出浏览器,用户的麦克风内容只在本地GPU里走了一圈。对在线会议、语音输入等场景来说,这是巨大的卖点。
4.3 轻量级智能体与大模型推理的局部加速
最近大模型火起来之后,小参数模型在端侧推理的需求也在涨。像1.5B参数的Qwen这类小型模型,如果整体跑在CPU上依然吃力,但把注意力机制里的矩阵乘法用WebGPU加速,性能会有质的飞跃。
我用Transformers.js的实验分支做过测试,在3090显卡上的Chrome里跑Llama-style模型,WebGPU后端和WASM后端相比,token生成速度提升约4.2倍——从每秒3个token提升到每秒13个token左右。这个速度虽然还比不上本地原生程序,但对于“直接在浏览器里聊天”这个场景,体验已经从“等半天”变成了“勉强可对话”。
不过要提醒的是,大模型的KV Cache、多头注意力这些操作在WGSL里写起来并不简单,内存布局稍微错一点,性能就直线下降。如果你想在这条路深挖,建议直接使用现成的推理工程框架,比如Transformers.js的WebGPU分支,而不是从零手写。
5. 常见问题与排查技巧实录
5.1 “A WebGL context could not be created” 这类报错怎么处理
如果你在页面上做过混合渲染,可能会遇到一个经典报错:“A WebGL context could not be created. Reason: Web page”。这个报错本质上是GPU上下文资源耗尽,不是WebGPU的锅,但WebGPU的引入加剧了这个可能性。
浏览器对GPU上下文数量有限制(通常每个域最多十几个),每个Canvas、每个OffscreenCanvas都要占用一个上下文。大量使用WebGL的广告SDK、地图SDK、可视化库,会把这配额吃满。
排查步骤我一般这么走:
- 打开Chrome的
chrome://gpu,看Graphics Feature Status列表里的WebGL和WebGPU是否都处于Hardware accelerated状态。 - 检查是否有多个WebGL上下文没有释放。
canvas.getContext('webgl2')每次调用都创建新上下文,调用WEBGL_lose_context.loseContext()可以释放。 - 如果实在排查不出来,在调用WebGPU初始化之前,主动强制释放已知的WebGL上下文。
5.2 不同平台的兼容性与降级策略
WebGPU 1.0虽已发布,但实际的支持范围仍有差异。桌面端Chrome、Edge、Firefox的支持很稳定;Safari在macOS Sonoma和iOS 17之后也有了WebGPU实现,但功能集略少于Chrome;Android上的WebView支持度则参差不齐。
生产环境的稳健策略必须是三级降级:
- 第一级:原生WebGPU。能用就用,体验最好。
- 第二级:WebGL2。通过ONNX Runtime Web的WebGL EP跑推理,虽然性能差一些,但功能不缺失。
- 第三级:WASM。实在不行CPU顶上,保证功能可用,只是慢。
判断是否支持WebGPU,别用UA嗅探,直接看接口:
if (navigator.gpu) { try { const adapter = await navigator.gpu.requestAdapter(); if (adapter) return 'webgpu'; } catch (e) {} } return 'fallback';5.3 私有的WGSL陷阱清单
写了这么多WGSL,踩过的坑能列一长串,挑几个容易害死人的:
整数除法不是你想的那回事。WGSL里整数除法是截断式整除,/运算符对整数类型直接做整除,不会自动转浮点。需要保留小数时,必须显式写成f32(a) / f32(b)。
数组边界检查。在WebGPU的Naive实现里,越界访问有时不会崩溃,而是返回0或undefined,这会让模型输出变得莫名奇妙。调试时可以先写成防越界版本,确认逻辑正确后再放开边界优化。
线程同步。同一个工作组内部可以用workgroupBarrier()做同步,但工作组之间没有任何同步原语。如果你的卷积实现依赖跨工作组的全局归约,跑出来的结果会时好时坏,这大概率就是同步写错了。
浮点精度。移动端部分GPU在mediump精度下会有较大误差,卷积网络深了以后可能输出误差累积到不可接受。建议统一用f32精度的Storage Buffer,存储和计算都别降精度。
5.4 实测:WebGPU在低端设备上的表现与预期管理
我拿一台4年前的旧安卓手机做了实测,那台手机用的是Adreno 618集成GPU。ResNet50实际推理耗时约120ms,虽然比桌面端的20ms差很多,但比WASM的500ms强了四倍多。
对低端设备用户来说,合理预期不是“实时镜头追踪”,而是“能跑的动轻量级模型”。如果你要部署的目标设备是低端移动设备,我建议要么用轻量模型(MobileNetV3-small、EfficientNet-lite这类),要么把输入分辨率降到192x192。与其在WebGPU代码上死磕优化,不如从模型侧瘦身来得效果明显。
6. 写在最后的经验之谈
做WebGPU AI推理这一路踩了不少坑,最大的体会是:技术选型的前瞻性比编码技巧重要得多。两年前我在评估要不要把项目从WebGL迁移到WebGPU时,团队里有不少反对声音,理由是浏览器支持不成熟、标准没定稿。但现在回头看,当初停留在WebGL的同事,过去一年都在做迁移工作,而我们早迁移半年已经把稳定性打磨好了。
另一个深刻体会是知识的底层逻辑是相通的。如果你熟悉Vulkan的Command Buffer、Metal的Compute Encoder,看WGSL的计算管线几乎是零成本入门。反过来,如果你只熟悉Three.js这一类封装好的库,不太懂GPU底层流程,那我建议学WebGPU时先别碰渲染,专注把计算管线的数据流转吃透。
最后分享一个实操小技巧:调试WebGPU计算着色器时,输出结果不好看不要急着改代码,先在渲染管线的片元着色器里把计算Buffer的内容可视化打出来,用“眼睛看GPU内存”的方式追踪数据。这个办法比console.log高效一百倍,因为GPU侧的数据搬回CPU打印一次就够耗的了。
WebGPU 1.0只是开始,图形学后端在前端的意义远不止AI推理。趁现在生态还没被封装得面目全非,多写点底层WGSL,这个基础能力在未来五年里只会越来越值钱。