先说一段我的真实经历。去年做内部工具的时候产品提了个需求用户上传图片后在浏览器本地完成OCR和图像分类图片不能出浏览器。听起来挺合理我一开始想图省事直接用WebGL写矩阵运算结果被精度丢失、纹理格式限制、readPixels回读卡了一周多。后来WebGPU 1.0在Chromium默认开放我把推理管线完整重写了一遍两天时间跑通实测速度反超WebGL版本好几倍。这篇文章就是把我在浏览器里跑AI推理的完整经验整理出来WebGPU和WebGL的本质差异在哪里、现成推理引擎怎么选、手写compute shader推理管线要过哪些坎、真实性能数据怎么样、工程落地有哪些坑。给想在前端做AI推理的同行当个参考。1. 为什么我要在浏览器里跑AI推理1.1 一个被WebGL折磨的真实场景那个OCR工具的场景是这样的用户上传合同照片前端要做文字检测和方向分类再配合后端的识别服务。需求方的原始要求是图片不要上传服务器能本地处理的都在本地处理。我当时天真地以为这不难因为TensorFlow.js早就支持WebGL后端了浏览器里跑个MobileNet级别的模型应该很轻松。真正动手才发现坑比想象中深。我遇到的第一个问题是TF.js WebGL后端的精度。模型前向推理时大量中间结果需要经过纹理的RGBA通道存储WebGL的浮点纹理在很多Android机器上会落到mediump低精度一个epoch训练得好好的分类模型在浏览器里直接多出十几个点的错误率。第二个痛点是WebGL本质是渲染API如果你想做卷积运算得把数据编码成纹理、用fragment shader去“画”一张图来实现计算这叫GPGPU通用GPU计算的纹理乒乓技巧。多写几层卷积之后每层都要经历一次纹理绑定、绘制、读回的循环GPU一直在做无意义的隐式同步。最让我崩溃的是性能回读。某些场景我需要把中间特征图拿回CPU做后处理readPixels一调用整条管线就像被卡住一样帧率掉到个位数。后来我在Chrome的Performance面板里看到大量的GPU stall标记才明白WebGL隐藏的同步点太多了它压根不是为“计算”设计的。1.2 浏览器端推理到底解决了什么被WebGL折磨完之后我反而更确定“浏览器内推理”这个方向是对的只是工具选错了。回头看业务上为什么非要跑到浏览器端隐私与合规用户数据不出本地设备合同、身份证、医疗影像这类敏感图片完全不需要经过服务器。这在金融、医疗场景几乎是硬性要求。延迟省掉了图片上传的往返时间。在弱网环境这一项能省掉几百毫秒甚至几秒。成本推理走用户设备的GPU服务器只需要处理训练和模型下发CDN流量和GPU实例费用都省了。离线可用模型缓存在浏览器本地断网时基础功能还能用。所以问题不是“要不要在浏览器里推理”而是“用什么技术把推理这件事跑得又快又稳”。WebGPU 1.0推出之后这个问题有了一个比较理想的答案它自带compute shader计算着色器能直接做通用计算不需要再去画三角形变魔术。2. WebGPU与WebGL的底层差异这不只是换个API很多文章说WebGPU是WebGL的替代品这话对了一半。两者虽然都跑在GPU上但设计目标完全不同。搞清楚这层差异你才知道为什么WebGL跑AI推理那么别扭、WebGPU怎么就能跑起来。2.1 目标不同渲染API还是通用计算APIWebGL从诞生起就是为渲染服务的。它的管线是固定的顶点着色器处理顶点片段着色器处理像素中间的光栅化、深度测试、混合都是硬件固定管线。你要拿它做计算只能打“擦边球”把数据塞到纹理里然后通过渲染到纹理Render to Texture来实现一次计算。WebGPU不是这么设计的。它把GPU抽象成两条独立的管线渲染管线Render Pipeline继续承担传统的图形渲染工作。计算管线Compute Pipeline直接执行compute shader数据以Buffer缓冲区形式存在GPU内存中可以自由读写。计算管线意味着GPU不再被“渲染”这件事绑架。你可以把一批数据丢进去让GPU按照你定义的方式并行处理然后读回结果。这个架构和CUDA、Metal Compute、DirectCompute是同一个思路等于把原来只有原生客户端才能用的通用计算能力搬到了浏览器里。2.2 WGSL与GLSLShader语言的变化有多大WebGL用的是GLSLWebGPU用的是WGSL。WGSL是WebGPU官方设计的着色器语言语法看起来更现代但真正重要的不是语法而是执行模型GLSL里你写的一个函数会被逐个调用到每个像素上但你无法控制调用顺序、无法在像素之间通信。WGSL里compute shader以workgroup工作组为单位运行同一个workgroup内部的线程可以通过shared memory共享内存直接交换数据还支持barrier同步和原子操作。共享内存和同步机制是深度学习算子落地的基础。卷积运算里一个输出tile往往需要读取输入数据的多个相邻位置如果每次计算都去全局内存重复读取带宽会成为瓶颈。有了共享内存你可以先把一个tile的输入数据加载到共享内存里让工作组内的线程反复复用再配合矩阵分块tiling技巧把访存开销降一个数量级。这在WebGL的fragment shader里完全做不到。2.3 内存模型从黑盒到显式管理WebGL里纹理数据对JS来说基本是黑盒。你看得到一张纹理但里面的数据格式、存储布局、什么时候上传到显存浏览器都在帮你兜底。兜底的问题在于你没法预测性能波动很玄学。WebGPU把所有缓冲区的创建、绑定、读写都暴露给你。你创建一个GPUBuffer时要显式声明用途STORAGE、VERTEX、UNIFORM等上传数据要用queue.writeBufferGPU算完要读回还得再创建一个MAP_READ用途的buffer并调用mapAsync。听起来繁琐但这正是可控性的来源。拿AI推理来说你可以提前把模型权重一次性上传到GPU buffer之后每帧推理都用同一份权重完全不需要反复上传。你可以把输入输出分别用不同的buffer绑定让计算和数据搬运可以并行调度。你能精确知道哪一步在访存、哪一步在计算。这种可预测性是工程优化的基础。3. 选型在浏览器里跑AI推理有哪些现成方案搞清楚原理之后下一步是选型。你当然可以从零手写算子但95%的场景应该先用现成的推理引擎。我来梳理一下2026年的真实情况。3.1 主流推理引擎的WebGPU后端现状我实测过的几个方案列在下面都是可以直接用的引擎WebGPU支持状态适用场景备注ONNX Runtime Web稳定EP可选通用ONNX模型CV/NLP都能跑生态最完整算子覆盖率高Transformers.js v3生产可用纯前端跑Transformer系列模型基于ONNX RuntimeAPI友好WebLLM可用但要关注显存浏览器里跑小规模LLM针对大模型做了量化优化TensorFlow.jsWebGPU后端仍在推进老项目迁移主力仍是WebGLMediaPipe Tasks部分任务支持GPU姿态、人脸、手势检测封装度高定制性差ONNX Runtime Web简称ORT-W是我目前的主力方案。它提供了WebAssembly、WebGL、WebGPU三种执行后端WebGPU后端目前支持ONNX标准算子集中的大部分浮点精度也靠谱。Transformers.js则是NLP场景的首选你只要把模型导出成ONNX格式官方工具链会帮你做在浏览器里几行代码就能跑BERT或T5的推理。3.2 浏览器兼容性的真实情况WebGPU 1.0已经发布但兼容性还是要看具体的浏览器环境Chrome/Edge从113版本开始默认支持到现在已经很稳定。桌面端Chrome 130的WebGPU实现基本覆盖了全部核心特性。SafariSafari 18macOS Sequoia和iOS 18支持WebGPU但需要注意苹果的Metal后端在部分算子上有细微差别。Firefox默认仍处于开启实验特性状态生产环境部署要慎重。我的建议是面向内部工具、对浏览器版本可控的场景可以放开用WebGPU面向公网用户必须做降级方案后面会细讲。3.3 我的选型逻辑我最终的项目选了ONNX Runtime Web的WebGPU后端理由有三点模型生态我们已有的模型是PyTorch训练后转ONNX的ORT-W原生支持ONNX格式不需要额外转换。精度可控ORT-W的WebGPU EP默认用float32做中间计算比我之前WebGL的mediump精度问题省心太多。团队成本团队里没人有图形学背景从零写WGSL算子学习成本太高站在ORT-W的封装上能快速投产。如果你的模型是自定义的特殊结构或需要极致性能那再考虑自研WGSL。但从业务价值角度先用现成引擎跑通再用profiler去看瓶颈在哪里这才是我推荐的路径。4. 手写一个WebGPU推理管线以图像分类为例虽然大部分场景可以直接用推理引擎但理解手写管线的完整流程还是很有必要的。一方面是面试要被问另一方面是为了以后遇到自定义算子时能快速动手。我拿一个最小化的图像分类模型为例走一遍从模型到WGSL再到JS调度的完整链路。4.1 模型准备与权重布局假设我要部署一个两层全连接网络输入是28x28的灰度图像MNIST隐藏层128个节点输出10个类别。权重规模是784x128 128 128x10 10大约1万多个浮点数非常小适合当示例。第一步是把权重从PyTorch里导成原生二进制文件。ONNX格式虽然有官方解析库但为了极致性能我会把权重直接拍平成float32的ArrayBuffer去掉所有封装格式。布局方式按照[权重、偏置、权重、偏置]的顺序拼接方便一次性上传到GPU。// 以fetch的方式加载权重文件 const weightBuffer await fetch(./model.bin).then(r r.arrayBuffer()); // float32视图 const weights new Float32Array(weightBuffer);这里有个经验浏览器端推理的IO瓶颈往往不在GPU计算而在权重文件的下载。1MB的模型文件在慢网下会让页面加载体验很糟。所以权重文件要做gzip压缩或者用WebLLM那种流式加载量化方案把权重先压到4bit/8bit。4.2 Compute Shader的实现与调度两层全连接的核心是矩阵乘法。我用一个简单的WGSL compute shader来实现第一层的计算// 输入为784个浮点数的图像向量 // 权重矩阵为128x784按行主序存储 // 输出为128个浮点数的隐藏层激活值 struct Params { inputDim: u32, hiddenDim: u32, }; group(0) binding(0) varstorage, read input: arrayf32; group(0) binding(1) varstorage, read weight: arrayf32; group(0) binding(2) varstorage, read bias: arrayf32; group(0) binding(3) varstorage, read_write output: arrayf32; group(0) binding(4) varuniform params: Params; compute workgroup_size(64) fn main(builtin(global_invocation_id) gid: vec3u32) { let row gid.x; if (row params.hiddenDim) { return; } var sum bias[row]; for (var k: u32 0u; k params.inputDim; k k 1u) { sum sum input[k] * weight[row * params.inputDim k]; } // ReLU激活 output[row] max(sum, 0.0); }这段shader的逻辑很直观每个GPU线程负责计算输出向量的一个元素也就是权重矩阵的一行和内积。workgroup_size(64)表示每个工作组有64个线程对于128个隐藏单元两个工作组就够了。JS侧对应的调度代码是这样const adapter await navigator.gpu.requestAdapter(); const device await adapter.requestDevice(); // 创建缓冲区的代码省略关键在绑定组和管线的创建 const bindGroup device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [ { binding: 0, resource: { buffer: inputBuffer } }, { binding: 1, resource: { buffer: weightBuffer } }, { binding: 2, resource: { buffer: biasBuffer } }, { binding: 3, resource: { buffer: outputBuffer } }, { binding: 4, resource: { buffer: paramsBuffer } }, ], }); // 命令编码 const encoder device.createCommandEncoder(); const pass encoder.beginComputePass(); pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.dispatchWorkgroups(Math.ceil(128 / 64)); pass.end(); device.queue.submit([encoder.finish()]);注意两个细节。第一dispatchWorkgroups的参数是工作组数量不是线程数量。我隐藏层128个单元每个工作组64线程所以Math.ceil(128 / 64) 2。第二如果某个输出单元的索引越界shader里要用if (row params.hiddenDim) return;提前退出这是WGSL里做边界保护的常规写法。4.3 数据搬运与回读的坑shader算完之后输出还在GPU buffer里。你没法直接读它必须经过一次拷贝和map操作// 创建一个可映射的缓冲对象 const readbackBuffer device.createBuffer({ size: outputSize * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, }); // 在命令编码器里把计算结果拷贝过去 encoder.copyBufferToBuffer(outputBuffer, 0, readbackBuffer, 0, size); device.queue.submit([encoder.finish()]); // 异步映射读取 await readbackBuffer.mapAsync(GPUMapMode.READ); const result new Float32Array(readbackBuffer.getMappedRange());这里最大的坑是mapAsync会阻塞JS线程等待GPU完成所有排队的操作。如果GPU计算量大或者管线里排了太多命令mapAsync的等待时间可能达到几十毫秒。解决思路是不要每帧都回读尽量把多个计算步骤连成一条命令序列再一次性回读减少GPU和CPU之间的往返同步。还有一个容易被忽视的点getMappedRange()返回的是映射后的ArrayBuffer视图用完必须调用readbackBuffer.unmap()否则下一次创建buffer会报错。我最初就是忘了unmap导致连续推理几次后抛出Buffer is already mapped异常。这类资源生命周期的问题在WebGPU里比WebGL要敏感得多。5. 实测性能与踩坑记录理论讲了一堆还是拿真实项目的数据说话。我后来在同一个OCR项目里把WebGL方案和WebGPU方案都做了完整对比数据是这样的5.1 我用真实数据测出来的性能对比测试环境MacBook Pro M1 ProChrome 131测试模型一个轻量OCR检测模型ONNX格式约4.8MB输入分辨率640x640。指标WebGL方案WebGPU方案单次前向推理耗时约180ms约42ms首帧预热耗时约900ms约650ms内存占用峰值约350MB峰值约220MB精度偏差 vs CPU部分层有可感知误差与CPU结果基本一致代码量自己写多层纹理乒乓约1200行ORT-W封装后约80行推理速度提升确实明显但更让我惊喜的是精度的回归。WebGL方案因为部分Android设备浮点精度低识别结果偶发偏差WebGPU全程float32计算这个问题彻底消失了。5.2 内存泄漏排查WebGPU方案上线内部试用两周后有同事反馈页面用久了会变卡。我一看device.buffer数量持续增长典型的资源泄漏。排查过程还挺曲折。Chrome的内存分析里GPU buffer不会像普通JS对象那样显示在堆快照里你在Memory面板根本看不到。我最终是给device对象封装了一层创建buffer的计数器每次创建GPUBuffer都打日志每次destroy也打日志对比数量才定位到问题我在每次推理循环里创建了新的bind group但旧的没释放。正确的做法是推理管线是复用的bind group和pipeline应该作为单例缓存每帧只更新需要变化的buffer内容而不是重建整个bind group。改完之后内存曲线变得平稳。5.3 调试WebGPU的土办法WebGPU的调试工具链还在成熟期Chrome DevTools的GPU调试能力远不如原生图形API的RenderDoc。我调试WGSL shader时靠三个土办法输出中间结果把某个中间buffer的内容copy到readback buffer在JS侧用console打印。虽然慢但定位逻辑错误非常有效。最小化复现把shader简化到只做output[gid.x] input[gid.x] * 2.0验证数据通路没问题之后再加逻辑。利用错误信息WebGPU的校验器非常严格。desc里少写一个usage、buffer size对不上绑定组声明都会直接抛编译错误。看错误信息能省很多时间。其中第二条最重要。WGSL没有断点调试你只能靠“从一个能跑的最小程序开始逐步增加复杂度”来隔离问题。我见过同事把整个模型搬进去出错了完全不知道是哪一层的问题这种时候一定要回头做最小实验。6. 工程落地经验谈最后聊几个工程化层面的经验。技术demo和线上产品之间差的往往就是这些细节。6.1 兼容性降级WebGPU → WebGL → CPU我前面说过公网产品必须做降级。我在项目里的降级策略分三级WebGPU优先能拿到adapter就用WebGPU后端。WebAssembly/WebGL兜底拿不到WebGPU就退到ONNX Runtime的WASM后端CPU执行或WebGL后端。WASM虽然慢但胜在兼容性最广。功能裁剪如果设备实在跑不动降级到只做检测、不做方向分类的轻量模型。降级的关键是抽象一个统一的推理接口把不同后端的调用细节封装起来。这样业务层代码不用感知后端差异只需要关心infer(imageTensor)返回什么。class InferenceEngine { static async create() { if (await isWebGPUSupported()) { return new OrtWebGPUBackend(); } return new OrtWasmBackend(); } }6.2 什么时候该用WebGPU什么时候不该用WebGPU不是万灵药。我总结的判断标准该用模型不大几十MB以内、对隐私和延迟有硬性要求、用户设备的GPU能力相对可预测比如内部统一配发的办公电脑。不该用模型超过几百MB浏览器加载成本太高不如在服务器推理用户设备是老旧的手机或低端Windows机器WebGPU很可能不可用强行上反而体验更差团队完全没有图形学基础、业务周期又短不如用WASM先把功能跑起来。另外要提醒的是WebGPU推理的速度优势主要在中高端GPU上明显。低端核显上GPU推理相对CPU的优势会被PCIe带宽浪费和驱动开销抵消一部分实测数据不一定好看。所以我建议任何WebGPU方案上线前都要在真实目标设备上跑一轮压测不要拿开发机数据直接下结论。7. 最后的几点私人建议按我个人经验如果你正在考虑把AI推理放到浏览器里有几点可以先想清楚。第一先用现成的ONNX Runtime Web或Transformers.js把完整链路跑通再去碰WGSL自定义算子。第二模型量化和裁剪往往比折腾GPU调度更划算把模型从float32压到int8推理速度可能翻倍部署体积也大幅下降。第三别迷信“全浏览器一致”的兼容性提前设计好降级路径比上线后救火舒服得多。第四如果你做的是面向内部的工具Chrome版本统一之后WebGPU的实际体验会比公网场景好很多不用因为公共网络兼容性而自我设限。我在这个项目里最明显的感觉是WebGPU让浏览器里的AI推理从一个勉强能跑的demo变成了可以踏实上生产的工程方案。它把原生开发才有的通用计算能力放到了前端手里剩下的就是看我们怎么用好它了。
