过去十年,GPU 通用计算(GPGPU)一直被 CUDA 与 OpenCL 牢牢把持,前端开发者只能隔着 WebGL 的栅栏远远张望——而且 WebGL 本质上为渲染而生,做计算不过是“用纹理当数组”的 hack。2023 年 WebGPU 正式成为 W3C 候选标准,到 2026 年 Chrome、Edge、Firefox 与 Safari(含 iOS)已经全线落地,这件事的意味远比“又一个图形 API”深远:浏览器第一次拥有了媲美原生的一等公民级并行计算能力。本文带你从零写一个矩阵乘法 Compute Shader,并落到最实用的场景——把大模型的推理搬进浏览器本地运行。 WebGL 的 Compute 能力是缺失的。你想做一份大规模向量运算,只能把数据塞进纹理,用 fragment shader 在像素上“伪装”并行,再 readPixels 拷回来——带宽翻倍、代码扭曲、精度受限(浮点支持参差不齐)。WebGPU 则带来了真正的 compute pipeline、storage buffer、workgroup 共享内存 与 barrier 同步原语,抽象模型与 CUDA/OpenCL 几乎同构,迁移成本极低。 它的能力层次很清晰: 矩阵乘是 AI 推理的算力主体(线性层、注意力分数都归结为 GEMM),也是理解 GPGPU 内存层次的最佳入口。下面用 WGSL(WebGPU Shading Language)写一个 tiling(分块) 版本,利用 workgroup 共享内存复用数据,避免每个线程反复从全局显存取数。 这段代码的精髓在 WGSL 只负责“核函数”,真正的内存分配、绑定与派发由 JS 完成: 关键坑点: 写完手搓 GEMM,你大概已经发现——这整套抽象和 PyTorch 的 CUDA kernel 一模一样。事实也的确如此:2024 年起,transformers.js、ONNX Runtime Web、web-llm 等框架都已把 WebGPU 作为首选后端。这意味着一个 7B 量化的模型可以完全在用户浏览器里跑,无需任何服务器往返。 本地推理的三个硬核收益,是云端 API 永远给不了的: 但生产化前必须正视约束:显存上限(消费级 GPU 一般 2–8GB 可用,模型要 INT4/INT8 量化)、首次加载延迟(几百 MB 模型要流式下载 + WASM fallback)、iOS Safari 的设备丢失处理( WebGPU 不是又一个“画三角形”的 API,它是浏览器走向通用计算平台的里程碑。当手搓 GEMM 与跑通本地大模型之间只差一个框架封装时,前端工程师的技能边界被彻底重写——你写的不再是页面,而是跑在用户显卡上的并行程序。2026 年,离线和端侧智能已经不是 PPT 概念:从图像降噪、视频超分到端侧 LLM,WebGPU 正在把“本地优先(local-first)AI”变成默认选项。下一次有人问“前端能干什么”,你可以把这块 GPU 指给他看。一、为什么是 WebGPU,而不是 WebGL
storage(可读写)、uniform(只读常量)、read-only-storage。二、第一个 Compute Shader:分块矩阵乘法
// matmul.wgsl —— 分块矩阵乘法 C = A * B
// 每个 workgroup 计算一个 TILE×TILE 的输出块
const TILE : u32 = 16u;
struct Params {
M : u32,
N : u32,
K : u32,
};
@group(0) @binding(0) var<uniform> params : Params;
@group(0) @binding(1) var<storage, read> A : array<f32>;
@group(0) @binding(2) var<storage, read> B : array<f32>;
@group(0) @binding(3) var<storage, read_write> C : array<f32>;
var<workgroup> tileA : array<f32, 256>; // TILE * TILE = 256
var<workgroup> tileB : array<f32, 256>;
@compute @workgroup_size(16, 16)
fn main(@builtin(workgroup_id) wg : vec2<u32>,
@builtin(local_invocation_id) lid : vec2<u32>) {
let row = wg.y * TILE + lid.y;
let col = wg.x * TILE + lid.x;
var sum : f32 = 0.0;
let tiles = (params.K + TILE - 1u) / TILE;
for (var t : u32 = 0u; t < tiles; t = t + 1u) {
// 协同加载分块到共享内存
let aIdx = row * params.K + (t * TILE + lid.x);
let bIdx = (t * TILE + lid.y) * params.N + col;
tileA[lid.y * TILE + lid.x] = select(0.0, A[aIdx], row < params.M && (t*TILE+lid.x) < params.K);
tileB[lid.y * TILE + lid.x] = select(0.0, B[bIdx], col < params.N && (t*TILE+lid.y) < params.K);
workgroupBarrier(); // 等所有人加载完再算
for (var k : u32 = 0u; k < TILE; k = k + 1u) {
sum = sum + tileA[lid.y * TILE + k] * tileB[k * TILE + lid.x];
}
workgroupBarrier(); // 算完再加载下一块
}
if (row < params.M && col < params.N) {
C[row * params.N + col] = sum;
}
}
workgroupBarrier():它让 256 个线程先一起把 A、B 的一个分块搬进共享内存(速度比全局显存快一个数量级),再做乘加。没有这层复用,朴素实现的带宽会直接爆炸。三、用 JavaScript 驱动它
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const queue = device.queue;
// 假设 A 是 M×K,B 是 K×N
const M = 512, N = 512, K = 512;
const bytes = (n) => n * 4;
const bufA = device.createBuffer({ size: bytes(M*K), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
const bufB = device.createBuffer({ size: bytes(K*N), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
const bufC = device.createBuffer({ size: bytes(M*N), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
const bufP = device.createBuffer({ size: 12, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
queue.writeBuffer(bufA, 0, float32A);
queue.writeBuffer(bufB, 0, float32B);
queue.writeBuffer(bufP, 0, new Uint32Array([M, N, K]));
const module = device.createShaderModule({ code: wgslSource });
const pipeline = device.createComputePipeline({
layout: "auto",
compute: { module, entryPoint: "main" },
});
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: bufP } },
{ binding: 1, resource: { buffer: bufA } },
{ binding: 2, resource: { buffer: bufB } },
{ binding: 3, resource: { buffer: bufC } },
],
});
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(Math.ceil(N / 16), Math.ceil(M / 16)); // 注意是 workgroup 数量
pass.end();
queue.submit([encoder.finish()]);
// 读回结果
const out = device.createBuffer({ size: bytes(M*N), usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
// ... 一次 copyBufferToBuffer + mapAsync 即可拿到 C
dispatchWorkgroups 的参数是 workgroup 个数,不是线程数。一个 workgroup_size(16,16) 的块覆盖 16×16 输出,所以 512×512 要派发 32×32 个组。这个 off-by-one 是新手最常见的黑屏来源。四、落到真场景:浏览器端本地 AI 推理
import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@xenova/transformers";
// 强制走 WebGPU 后端
env.backends.onnx.ep = "webgpu"; // 而非 wasm / cpu
const generator = await pipeline("text-generation", "Xenova/Qwen2.5-0.5B-Instruct");
const out = await generator("用一句话解释 WebGPU 的优势:", { max_new_tokens: 64 });
console.log(out[0].generated_text);
device.lost 必须在代码里捕获并重建 pipeline,否则一锁屏就白屏)。五、性能与工程化的几个经验法则
copyExternalImageToTexture / mapped buffer 直写。device.pushErrorScope / popErrorScope 捕获验证错误,否则调试会怀疑人生。if (!navigator.gpu) fallbackToWasm(),别让不支持的浏览器直接崩。六、结语

发表评论 取消回复