过去十年,GPU 通用计算(GPGPU)一直被 CUDA 与 OpenCL 牢牢把持,前端开发者只能隔着 WebGL 的栅栏远远张望——而且 WebGL 本质上为渲染而生,做计算不过是“用纹理当数组”的 hack。2023 年 WebGPU 正式成为 W3C 候选标准,到 2026 年 Chrome、Edge、Firefox 与 Safari(含 iOS)已经全线落地,这件事的意味远比“又一个图形 API”深远:浏览器第一次拥有了媲美原生的一等公民级并行计算能力。本文带你从零写一个矩阵乘法 Compute Shader,并落到最实用的场景——把大模型的推理搬进浏览器本地运行。

一、为什么是 WebGPU,而不是 WebGL

WebGL 的 Compute 能力是缺失的。你想做一份大规模向量运算,只能把数据塞进纹理,用 fragment shader 在像素上“伪装”并行,再 readPixels 拷回来——带宽翻倍、代码扭曲、精度受限(浮点支持参差不齐)。WebGPU 则带来了真正的 compute pipeline、storage buffer、workgroup 共享内存 与 barrier 同步原语,抽象模型与 CUDA/OpenCL 几乎同构,迁移成本极低。

它的能力层次很清晰:

  • Adapter / Device / Queue:物理 GPU 适配器 → 逻辑设备 → 命令提交队列,对应 CUDA 的 context 与 stream。
  • Buffer:显存中的一块内存,可声明为 storage(可读写)、uniform(只读常量)、read-only-storage。
  • BindGroup:把 buffers / textures 绑定到 shader 的“参数槽”,是 WebGPU 性能与可组合性的核心。
  • Pipeline:描述“怎么算”(compute)或“怎么画”(render),编译一次反复用。
  • Workgroup:线程调度的基本块,类似 CUDA 的 thread block,内部共享内存与同步。

二、第一个 Compute Shader:分块矩阵乘法

矩阵乘是 AI 推理的算力主体(线性层、注意力分数都归结为 GEMM),也是理解 GPGPU 内存层次的最佳入口。下面用 WGSL(WebGPU Shading Language)写一个 tiling(分块) 版本,利用 workgroup 共享内存复用数据,避免每个线程反复从全局显存取数。

// 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 驱动它

WGSL 只负责“核函数”,真正的内存分配、绑定与派发由 JS 完成:

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 推理

写完手搓 GEMM,你大概已经发现——这整套抽象和 PyTorch 的 CUDA kernel 一模一样。事实也的确如此:2024 年起,transformers.js、ONNX Runtime Web、web-llm 等框架都已把 WebGPU 作为首选后端。这意味着一个 7B 量化的模型可以完全在用户浏览器里跑,无需任何服务器往返。

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);

本地推理的三个硬核收益,是云端 API 永远给不了的:

  1. 隐私零泄露:医疗、金融、企业知识库类数据不出本机,合规压力骤降。
  2. 零边际成本:没有按 token 计费,服务一旦部署,十万次调用和一次调用成本相同。
  3. 离线可用:配合 PWA 与模型缓存,地铁里也能用。

但生产化前必须正视约束:显存上限(消费级 GPU 一般 2–8GB 可用,模型要 INT4/INT8 量化)、首次加载延迟(几百 MB 模型要流式下载 + WASM fallback)、iOS Safari 的设备丢失处理(device.lost 必须在代码里捕获并重建 pipeline,否则一锁屏就白屏)。

五、性能与工程化的几个经验法则

  • workgroup_size 选 64/256:多数 GPU 一个 warp/wavefront 是 32 线程,16×16=256 能较好隐藏访存延迟;过小则调度开销占比上升。
  • 优先用 read-only-storage 而非 uniform 传大数组:uniform 有 64KB 上限,storage 能到数百 MB。
  • 减少 writeBuffer 次数:批量打包上传,避免每次 tiny 拷贝触发队列抖动;大块数据考虑 copyExternalImageToTexture / mapped buffer 直写。
  • 错误必须显式处理:WebGPU 默认“静默失效”,要调用 device.pushErrorScope / popErrorScope 捕获验证错误,否则调试会怀疑人生。
  • 渐进增强:if (!navigator.gpu) fallbackToWasm(),别让不支持的浏览器直接崩。

六、结语

WebGPU 不是又一个“画三角形”的 API,它是浏览器走向通用计算平台的里程碑。当手搓 GEMM 与跑通本地大模型之间只差一个框架封装时,前端工程师的技能边界被彻底重写——你写的不再是页面,而是跑在用户显卡上的并行程序。2026 年,离线和端侧智能已经不是 PPT 概念:从图像降噪、视频超分到端侧 LLM,WebGPU 正在把“本地优先(local-first)AI”变成默认选项。下一次有人问“前端能干什么”,你可以把这块 GPU 指给他看。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.367850s