过去几年我接过好几个想把机器学习搬进浏览器的项目。早先大家关心的是怎么压缩模型、怎么加速推理,可等推理玩顺手了,新问题就冒出来了:用户的输入数据能不能不离开浏览器?模型能不能根据本地数据持续更新?于是“浏览器端机器学习训练”成了绕不开的话题。真做起来,第一个硬门槛是算力——纯 JavaScript 单线程去跑反向传播,几百个参数都要等半天,更别说上万的神经网络了。很多人因此直接把 WebGL 当作救星:它是浏览器里大规模调用 GPU 最成熟的通道,也是当前让神经网络在浏览器里真正跑起来的核心机制。这篇文章就把 WebGL 这条技术路径从头拆到尾,讲清楚它是怎么把训练算成图像、把矩阵存成纹理,以及如果你要从零实现一套训练链路,有哪些真实的坑等着你。
1. 从推理到训练:浏览器端机器学习训练的真实场景
1.1 训练为什么被看成“最后一座山头”
先理清一个概念:推理和训练,对算力的要求根本不在一个量级。
模型推理时,权重是固定的,神经网络只做一次前向传播,把输入变成输出。就算网络不小,对现代设备来说也只是“跑一次”的事。训练则完全相反:先前向算预测,再反向算梯度,最后更新所有参数,一个 batch 就要把全网络的数据从头到尾滚好几遍。训练的计算量通常比推理大一到两个数量级,而且每一步都依赖上一步的结果,串行依赖很强。
在浏览器环境下,这个差距被进一步放大。JavaScript 的浮点运算速度本来就不算快,哪怕是现代浏览器的 JIT 编译,面对几万、几十万个参数的梯度计算,依然力不从心。我曾经拿一个三层全连接网络做过测试,参数规模大概五万,训练数据一万条,纯 CPU 单线程跑一个 epoch 要十几秒。这种速度拿来做人机交互式的强化学习、参数微调,体验基本不可用。
那 GPU 呢?浏览器没法直接拿到显存地址,更没法像本地程序那样随便调用 CUDA 之类的计算接口。浏览器留给网页应用的通用 GPU 通道,长期以来只有 WebGL。也就是说,想在浏览器里做真正的机器学习训练,绕不开 WebGL。
1.2 三类真正适合发生在浏览器里的训练任务
说“适合”之前,先明确一个判断:不是所有模型都应该放在浏览器里训。
第一类,隐私敏感的场景。有些数据没法传上服务器:医疗影像、个人行为日志、本地文档内容,或者用户所在的敏感环境信息。把这些数据留在浏览器里完成模型微调,云端只接收加密后的模型参数或差分隐私噪声,隐私边界会清晰很多。这类需求正在越来越多地出现在边缘设备方案里。
第二类,实时个性化场景。模型需要跟着用户行为持续调整,比如输入法候选词、手势识别、浏览器插件里的个性化推荐。如果把每个新数据都传回服务器训练,一来一回的延迟足够用户失去耐心;直接在浏览器端做增量学习,数据不出本地,模型随用随更新,响应速度天然占优。
第三类是教学和可视化场景。我见过不少开发者用网页演示神经网络的训练过程,权重变化、损失曲线、隐藏层的激活值直接渲染在屏幕上。这种场景里,训练本身就是一个视觉产品,只能发生在浏览器里,而且训练速度不能太快也不能太慢,太慢是折磨,太快反而不利于观察。WebGL 的并行计算在这里既解决了性能问题,又能天然把权重映射成像素,可视化顺手到让人感叹当初设计纹理的那个人很有远见。
1.3 为什么偏偏是 WebGL 扛下了这个任务
如果只看计算能力,WebGPU 比 WebGL 更合适,但那是“未来”,不是“现在”。WebGL 从 2011 年进入浏览器以来,已经在几乎所有现代浏览器里稳定存在了十几年,移动端、桌面端全覆盖,没有任何插件依赖。对于想在今天就把训练搬到浏览器里的开发者来说,WebGL 是唯一一个不需要讨论兼容性就能直接开干的选择。
更关键的是,WebGL 虽然是个图形 API,但它把 GPU 最核心的能力——并行执行简单计算——完整地暴露出来了。它有可编程的着色器,有可离屏渲染的帧缓冲对象,有能把数据塞进显存的纹理接口。这些能力拼在一起,已经足够实现一套通用数值计算引擎。神经网络训练所需要的矩阵乘法、激活函数、链式求导,本质上都是大量独立的乘加运算,每一笔运算只要数据不互相耦合,就可以并行执行。WebGL 正好能接住这个需求。
2. 机制拆解:GPU 的并行逻辑与 WebGL 的数据通道
2.1 CPU 和 GPU 的分工差异:一次算一点,不如一次算一片
要理解 WebGL 为什么能训练神经网络,先得理解 GPU 的脑子是怎么长的。
CPU 是几个非常聪明的通用技师,单个核心能处理极其复杂的逻辑,但数量少,擅长串行任务。GPU 则是几千个只会做简单动作的临时工,单个工人不聪明,但胜在人多,任何同质化的批量任务都可以分成几千份同时做。神经网络恰恰是典型的同质化批量任务:矩阵乘法里,输出矩阵的每个元素都是“乘一批、加一批”的重复劳动;卷积神经网络里,每个输出特征图的像素都是相似的乘加组合。
GPU 的工作模式在底层叫 SIMT,单指令多线程:一个指令,驱动几千个线程同时执行。每个线程不知道自己“队友”在算什么,它只负责自己的那个输出元素。这种模式对分支逻辑敏感,但对纯数值计算来说效率极高。换句话说,GPU 不是把一次计算变快了,而是把“一万次计算”压缩成“一微批同时完成”,通量极高。
2.2 渲染管线如何“兼职”通用计算
WebGL 本身是画图形的,它的标准执行流程是:顶点着色器处理三角形顶点,光栅化器把三角形变成像素,片段着色器给每个像素算颜色,最后写入画布。通用计算要借用的,正是“片段着色器给每个像素算颜色”这一步。
具体思路很直接:把一个覆盖整个画布的三角形铺到屏幕上,那么画布上的每个像素都会执行一次片段着色器。你不需要真的画出什么图案,片段着色器里算的不再是颜色,而是你想要的数值结果。每个像素就对应输出数组中的一个元素:“画布宽 512 像素”就等于“一次性并行计算 512 个元素”。
顶点着色器在这里基本可以闲置,它只要输出一个全屏三角形就行。真正的计算全在片段着色器里。目标画布也不需要展示给用户,它被绑定到一个帧缓冲对象上,WebGL 把计算结果直接写进一张纹理,这张纹理就是“计算结果的存储区”。一次 WebGL 绘制调用,执行了一次完整的并行计算。这种把图形管线伪装成通用计算引擎的做法,虽然没有专业的计算着色器优雅,但在浏览器环境下足够实用。
2.3 神经网络运算和渲染操作之间的对应关系
现在把神经网络里的运算逐项翻译成 WebGL 操作。矩阵乘法是最核心的,输出矩阵的每个元素 Y[i][j] 等于输入矩阵的第 i 行与权重矩阵的第 j 列做点积。翻译成渲染操作:让一个像素负责一个输出元素,像素的坐标就是 (i, j),片段着色器在那个像素里循环读取输入纹理和权重纹理对应位置的值,累加得到点积,最后写入颜色。
其他运算的映射就更直接:激活函数是对每个元素独立操作,所以一个元素一个像素;批量归一化如果只是推理模式下的缩放和平移,也是逐元素操作;池化操作本质是局部区域内的取最大值或平均值,每个输出像素读取附近几个像素的值即可。
我把常用运算和 WebGL 实现方式整理成了一张速查表,参考价值在于建立心智模型。
| 神经网络运算 | WebGL 实现方式 |
|---|---|
| 矩阵乘法 Y = W * X | 每个输出元素一个像素,着色器内循环 texture2D 采样做点积 |
| 向量加法 / 偏置项 | 每个输出元素一个像素,采样偏置纹理并累加 |
| 激活函数(sigmoid/ReLU) | 每个输出元素一个像素,逐像素运算 |
| 反向传播误差回传 | 每个神经元的误差一个像素,按权重加权求和 |
| 权重更新 W = W - lr * g | 每个权重一个像素,读旧权重、算梯度、写新权重 |
这张表的本质是:神经网络里所有计算,最终都能落到“某个输出位置的像素,根据输入纹理若干位置的值,算出一个结果”。只要能做到这一步,神经网络的前向和反向就都能在 WebGL 里跑起来。
2.4 纹理就是显存:数据进出 GPU 的通道与 ping-pong 技巧
WebGL 没有直接暴露显存指针,网页应用能读写 GPU 上的数据,靠的只有一个结构:纹理。所以要训练神经网络,第一件事就是把权重矩阵和输入数据打包成纹理。
RGBA 四个通道是一个极好的突破口。一个纹素能存四个浮点值,也就是说一张宽度为 N 的纹理,理论上能承载一个尺寸为 N4 的向量。权重矩阵一般会被拉平成一张长条纹理,比如一个 6448 的权重矩阵,可以排成一张 64 像素宽、48 像素高的二维纹理,每个纹素对应一个权重元素;也可以压成一条 4096 像素宽的一维纹理。布局没有绝对标准,但 CPU 端排布要跟着色器里的坐标换算严格对应,否则数值会串位置。
数据进出 GPU 有两个关键动作:写入和读出。写入叫 texImage2D,把 CPU 数组上传成纹理;读出叫 readPixels,把渲染结果从 GPU 拉回 CPU。训练中最常用的中间结果不需要回 CPU,直接在 GPU 端作为下一轮计算的输入继续参与运算。
这里有个绕不开的规则:同一张纹理不能同时作为输入和渲染目标。你在片段着色器里还没算完结果之前,GPU 可能已经覆盖掉了上一轮的数据,读写冲突会导致未定义行为。标准解法是 ping-pong:准备两张纹理 A 和 B,第一次计算从 A 读、往 B 写,第二次从 B 读、往 A 写,如此交替。这个细节看似不起眼,却决定了整个训练循环的结构。
3. 手把手:用 WebGL 训练一个全连接网络
3.1 训练回路拆成四个 Pass
光讲概念还不够,真正动手做一遍才能把坑踩出来。我用一个简化版的全连接网络做示例:输入层 48 维,隐藏层 64 个神经元,输出层 2 维,激活函数全部用 sigmoid,损失函数用均方误差,批量大小为 1。网络足够小,但前向、反向、更新一个都不少。
整个训练流程被拆成四个渲染 Pass,每个 Pass 就是一次 draw call。把所有计算都留在 GPU 端,CPU 端只负责调度渲染流程。Pass 清单如下:
- 前向第一层:读取输入纹理和 W1 权重纹理,计算 hidden = W1 * input + b1,输出到中间纹理。
- 前向第二层:读取隐藏层激活值纹理和 W2 权重纹理,计算 output = W2 * hidden + b2,同时算 sigmoid 激活。
- 反向回传:分别计算输出层误差和隐藏层误差。输出层误差直接是 predict - target;隐藏层误差等于输出层误差经过 W2 的转置加权传到隐藏层,再乘以 sigmoid 导函数。
- 权重更新:分别更新 W1 和 W2,每个权重像素读取旧权重、算梯度,做一步梯度下降。
批量大小 1 看起来很简单,但它把数据流的逻辑暴露得最清楚。批量大于 1 时无非是把输入纹理多铺几个“横向位置”,每个 batch 样本分配一段像素区间,误差纹理也换成多行的排布方式,核心渲染逻辑不变。
3.2 前向传播着色器的完整实现
前向传播的关键在于把矩阵乘法写进片段着色器。下面这段 GLSL 是列举层前向传播的核心示意,逻辑可以直接套用,坐标换算部分需要结合自己的纹理布局调整。
precision highp float; uniform sampler2D uWeightTex; // 隐藏层权重纹理,宽 inputDim,高 hiddenDim uniform sampler2D uInputTex; // 输入纹理,宽 inputDim,高 1 uniform float uInputDim; // 输入维度 void main() { // gl_FragCoord.x 是当前像素的 x 坐标,代表隐藏层第几个神经元 float hiddenIndex = gl_FragCoord.x; float sum = 0.0; // 固定循环上界,GLSL ES 1.0 不支持动态循环,这里写一个足够大的常量 for (float i = 0.0; i < 128.0; i += 1.0) { if (i >= uInputDim) break; // 采样权重纹理,注意加 0.5 像素偏移,否则拿到的是边缘插值 vec2 weightUV = vec2((i + 0.5) / uInputDim, (hiddenIndex + 0.5) / uHiddenDim); vec4 weight = texture2D(uWeightTex, weightUV); // 采样输入纹理 vec2 inputUV = vec2((i + 0.5) / uInputDim, 0.5); vec4 inputVal = texture2D(uInputTex, inputUV); sum += weight.r * inputVal.r; } gl_FragColor = vec4(sum, 0.0, 0.0, 1.0); }这里面有三个非常容易踩的细节。第一,循环上界必须是常量表达式,GLSL ES 1.0 允许动态循环但很多移动设备驱动支持很差,最稳妥的办法是固定一个上限,在循环体内用 if 判断真实维度。第二,纹理过滤模式必须设置成 NEAREST,不能是 LINEAR,否则纹理采样会在相邻像素之间做线性插值,直接污染计算结果。第三,加 0.5 的偏移是为了让采样点落在纹素中心,很多第一次写 WebGL 计算的人在这一步翻车,数值会莫名其妙偏差零点几个像素。
3.3 反向传播与权重更新着色器
反向传播是训练里最难的部分,但拆分到像素级别后,每个像素只负责自己那一小片计算。以权重更新为例:W2 的某个权重连接的是隐藏层第 h 个神经元和输出层第 o 个神经元,它的梯度等于隐藏层激活值 hidden[h] 乘以输出层误差 delta[o]。权重更新就是在这个像素上执行一次“读旧权重、读隐藏层激活、读输出层误差、乘上学习率、写回新权重”。
下面这段 GLSL 示意了权重更新 Pass 的写法:
precision highp float; uniform sampler2D uWeightTex; // 当前层权重纹理 uniform sampler2D uDeltaTex; // 当前层误差纹理,误差按神经元一一对应 uniform sampler2D uActivationTex; // 上一层激活值纹理 uniform float uLearningRate; void main() { // 当前像素坐标代表一个权重元素 vec2 pixelPos = gl_FragCoord.xy; // 像素中心 UV vec2 weightUV = vec2((pixelPos.x + 0.5) / uWeightWidth, (pixelPos.y + 0.5) / uWeightHeight); vec4 oldWeight = texture2D(uWeightTex, weightUV); // colonExplanation: x 方向对应输入神经元,y 方向对应输出神经元 float activation = texture2D(uActivationTex, vec2((pixelPos.x + 0.5) / uActivationWidth, 0.5)).r; float delta = texture2D(uDeltaTex, vec2((pixelPos.y + 0.5) / uDeltaWidth, 0.5)).r; float gradient = activation * delta; float newWeight = oldWeight.r - uLearningRate * gradient; gl_FragColor = vec4(newWeight, 0.0, 0.0, 1.0); }反向传播的误差回传 Pass 也是同样的思路:隐藏层第 h 个神经元的误差等于所有输出层神经元误差乘以对应的 W2 权重的加权和,这本质上又是一个矩阵乘向量,只是输入换成了误差纹理。把误差层的每个像素当作“输出”,照着前向传播的矩阵乘 Pass 再写一遍即可,只是累加的是“输出层误差乘以权重”而不是“输入乘以权重”。
3.4 训练循环中的绑定与交换细节
有了各个 Pass 的着色器,训练循环就是一个接一个的渲染调用。伪代码长这样:
// 伪代码:训练循环主体 function trainStep(inputTexture, targetTexture) { // Pass 1: 前向隐藏层 bindFramebuffer(hiddenFBO); drawFullscreenTriangle(linear1Program, { inputTexture, weight1Texture }); // Pass 2: 前向输出层 bindFramebuffer(outputFBO); drawFullscreenTriangle(linear2Program, { hiddenTexture, weight2Texture }); // Pass 3: 输出层误差 bindFramebuffer(deltaOutFBO); drawFullscreenTriangle(deltaOutProgram, { outputTexture, targetTexture }); // Pass 4: 隐藏层误差 bindFramebuffer(deltaHiddenFBO); drawFullscreenTriangle(deltaHiddenProgram, { deltaOutTexture, weight2Texture }); // Pass 5: 更新 W2 bindFramebuffer(weight2NewFBO); drawFullscreenTriangle(updateWeightProgram, { oldWeight2Texture, hiddenTexture, deltaOutTexture }); // Pass 6: 更新 W1,同理 drawFullscreenTriangle(updateWeightProgram, { oldWeight1Texture, inputTexture, deltaHiddenTexture }); // 交换权重纹理引用,下一轮使用更新后的纹理 swap(weight1Texture, weight1NewTexture); swap(weight2Texture, weight2NewTexture); }WebGL 的渲染调用默认按顺序执行,前一个 draw call 结束前,后一个不会开始,所以这里不需要额外的同步机制,直接按顺序串联即可。真正麻烦的是权重纹理的“版本管理”:每一轮更新后,新的权重纹理是旧的“替代者”,但你不能原地覆盖,必须通过交换引用让下一轮读到新值。习惯上我会把两张新 FBO 的渲染目标命名为 weight1A / weight1B,每轮训练结束后互换角色。
坐标换算也是这里最容易乱的地方。每个 Pass 的着色器都假设纹理的布局是固定的,一旦改了布局,所有 UV 换算都要同步调整。建议在最开始就画一张布局表,把每张纹理的宽度、高度、实际数据维度,以及 UV 映射公式写清楚,否则调试时候数值错乱会找得你怀疑人生。
4. 实测与排坑:精度、同步与性能调优实录
4.1 浮点纹理的精度陷阱
WebGL 的纹理精度是个大坑,我第一次踩进去的时候差点把整条链路推翻重来。
默认情况下,WebGL 的颜色缓冲按 8 位整型存储,也就是每个通道只能存 0 到 255 的整数。这对图形渲染没问题,但训练需要浮点。WebGL1 需要加载 OES_texture_float 扩展,属性检查通过了才有一张真正的浮点纹理可用;WebGL2 默认支持浮点纹理,但渲染到浮点纹理还需要 EXT_color_buffer_float 这个扩展。不同品牌设备对扩展的支持情况差别很大,写代码时不能默认“有”,必须先用 getExtension 查询,再决定是否走浮点路径。
更隐蔽的是着色器内部的精度声明。片段着色器里有三种精度:highp、mediump、lowp。如果你只写了 precision highp float,那是最好;但有些移动端设备的顶点着色器支持 highp,片段着色器却不支持,只能退到 mediump。mediump 只有 16 位浮点精度,神经网络训练动辄上万的累加,误差累积到一定程度,梯度直接就消失或发散。我在某个移动端项目里遇到的症状就是:在桌面上训练 loss 平稳下降,拿到手机上 loss 从头到尾纹丝不动,查了半天才发现是精度不够,sigmoid 的梯度被 16 位浮点冲刷成了零。
保险做法是:在初始化阶段检查 WebGL 是否支持 highp 片段浮点;如果只支持 mediump,宁可降低网络规模、改用更稳定的优化算法,也别硬着头皮跑大网络。
4.2 别让 readPixels 毁掉并行优势
readPixels 是用来把 GPU 计算结果读取回 CPU 的唯一常规手段,但它的代价极其昂贵。调用 readPixels 时,CPU 会阻塞等待 GPU 完成当前渲染管线里堆积的所有命令,然后触发一次显存到内存的拷贝,最后才把数据返回给 JavaScript。整个过程同步发生,GPU 的并行计算优势在这一瞬间被打回原形。
我见过不少初学 WebGL 训练的开发者,每算一个 batch 就把 loss 回读一次,训练速度直接掉到 CPU 水平。正确的做法是:训练循环尽量完全留在 GPU 端,只有需要监控训练状态时,才隔十几个 batch 回读一次。如果要打日志,也别回读整个网络,准备一张极小尺寸的帧缓冲,只让 GLSL 里把 loss 算进一个像素的红色通道,然后 readPixels 读取这一块 1x1 的区域,数据量小,影响也小得多。
WebGL 的同步模型下没有真正的异步 readPixels,所以唯一的优化策略是减少次数、缩小范围。那些号称浏览器端高速训练的开源框架,看起来“实时”的损失曲线,底层也是每隔一段时间回读一次,而不是每一轮都读。
4.3 纹理尺寸、过滤器与坐标对齐的隐性规则
训练网络时纹理的尺寸选择,看起来是个琐碎问题,实际却直接影响性能和正确性。
先说尺寸上限。设备支持的纹理最大尺寸由 MAX_TEXTURE_SIZE 决定,桌面平台通常是 8192 或 16384,老手机可能只到 2048。如果你把一个 10000 维的权重向量排成一维纹理,宽度很可能超限。标准做法是排成二维方阵或接近方阵:比如 4096 个权重元素排成 64x64 的纹理,完全避开上限问题,还能减少单行纹理采样的缓存压力。
纹理过滤器的设置我在前面提过,这里再强调一次:计算场景必须用 gl.NEAREST。默认的 LINEAR 过滤会在纹素之间做双线性插值,图形场景里这是为了平滑,数值计算里这就是灾难。纹理环绕模式同样要改成 gl.CLAMP_TO_EDGE,否则采样坐标越过边界时会触发 REPEAT 或 MIRRORED_REPEAT,数值直接错乱。
坐标对齐是另一个容易出问题的地方。fragment 着色器里 gl_FragCoord 是以像素为单位的整数坐标,而 texture2D 的采样坐标是 0 到 1 的归一化浮点坐标。计算 UV 时,必须用 (像素序号 + 0.5) / 纹理宽度 这个公式,加 0.5 是为了让采样点对准纹素中心。如果直接拿整数坐标除以宽度,采样点就会落在两个纹素交界处,采样结果变成相邻像素的混合值,训练出的模型结果会含含糊糊、时好时坏。
还有一个值得注意的布局问题:尽量让权重纹理的查找模式符合 GPU 纹理缓存的局部性原理。同一层的权重,尽量放在同一张纹理里,不要让一次矩阵乘法的采样跨越十几张纹理,纹理切换的开销在某些设备上十分可观。
4.4 调试三板斧:可视化权重、检查编译日志、先跑小网络
WebGL 训练出问题,最大的痛点是 GPU 内部状态不透明。CPU 代码可以打断点、查变量,GPU 里几十万个线程同时运行,出了错你根本不知道是着色器写错,还是坐标错了,还是精度不够。我经过多次折腾后沉淀出一套三板斧。
第一斧,把中间的权重纹理渲染到 Canvas 上直接看。权重纹理本身是图像格式,天然适合可视化。权重变化正常时,画面会从一片灰暗逐渐出现条纹和结构;如果画面全程噪点,说明数值已经飘了;如果画面全黑或全白,说明梯度消失了或学习率没生效。可视化能在一秒内给出“网络是否在学”的直觉,比盯着 loss 数字更直接。
第二斧,善用 shader 编译日志。很多开发者写完 GLSL 从来不查编译状态,一旦出错,统一表现为“绘制调用失败,画布空白”。正确姿势是每次编译后都检查 getShaderParameter 和 getShaderInfoLog,把日志完整打印出来。GLSL 编译器的报错信息往往很简陋,但至少能定位到错误行号,比盲猜强太多。
第三斧,先跑一个超小网络验证链路。把输入维度降到 4,隐藏层维度过到 4,输出 2,参数总量只有几十个。用 CPU 实现同一个模型,每一步计算完成后把 GPU 的结果回读一次,跟 CPU 的中间结果逐元素对拍。对上了,再扩大到真实规模;对不上,就缩小问题范围。这一步虽然牺牲了一点性能,但能帮你避免在大规模网络里排查基础性错误,性价比极高。
5. 天花板与下一站:从 WebGL 走向 WebGPU
5.1 WebGL 做通用计算的三大硬伤
WebGL 把神经网络训练跑起来已经是很了不起的事了,但它毕竟是图形 API,被强行当成通用计算工具用,天花板在哪里也很清楚。
第一个硬伤是没有计算着色器。通用计算的标准形态是明确声明“这是一个计算任务”,给一个 kernel,指定线程布局,然后并行执行。WebGL 没有这个能力,只能把计算伪装成片段着色器,被迫围绕“每个像素计算一个颜色”来组织数据。矩阵乘法、卷积操作只能通过大循环里的纹理采样来实现,而纹理采样在 GPU 内部是一条相对昂贵的数据通路,带宽开销比直接访问寄存器或共享内存高得多。
第二个硬伤是没有共享内存和线程协作。卷积计算中,相邻输出像素需要读取同一块输入区域,理想方案是把这块输入读进共享内存,让一批线程反复使用。WebGL 的片段着色器无法在像素之间共享数据,每个像素都要独立去全局内存采样,结果就是同样的输入数据被反复读取,显存带宽利用率极低。
第三个硬伤是数据同步机制过于原始。前面提到的 ping-pong、readPixels 同步阻塞、纹理版本管理,都是 WebGL 通用计算的“补丁式”产物。它们增加了编程复杂度,也限制了部分优化手段的施展空间。在这些问题上,WebGL 怎么调都调不出现代计算标准的效率。
5.2 迁移到计算着色器后会带来什么变化
WebGPU 的计算着色器把上面这些补丁全部更新成了原生能力。它允许你声明一个明确的计算任务,设置 workgroup 大小,在线程组内部使用共享内存,让相邻线程协作完成一个更大的计算块。卷积运算可以直接做 tiling 优化,把输入分块读入共享内存,极大减少全局内存重复访问。权重更新也能直接用 buffer 的随机读写能力,不再需要纹理坐标换算和 ping-pong 交换。
精度控制也更灵活。WebGPU 对 float16、float32 的支持是显式声明的,你可以根据网络层的特点选择不同的精度策略:密集层用 float32,对精度不敏感的卷积层用 float16,内存减半、带宽翻倍。反观 WebGL,精度的天花板被八位整数缓冲和有限浮点纹理扩展卡得很死。
迁移还有一重额外收益:可读性。WebGPU 的 WGSL 是面向通用计算的,写出来的内核函数类似 CUDA,比把矩阵乘法藏在一个名为“绘制全屏三角形”的调用里清晰太多。算法和 API 之间不再隔着一层抽象,这对做训练这类复杂数值计算至关重要。
5.3 现状下的选型判断与建议
WebGPU 听起来很好,但它目前还没有覆盖到所有浏览器和设备,旧硬件、旧系统上依然只能退回 WebGL。所以在现阶段,我的建议分三档:
第一档,如果目标用户群是桌面现代浏览器,且要训练的模型以全连接网络、中小型 MLP 为主,WebGL 完全够用。投入时间理解本文讲的纹理布局和渲染 Pass 结构,就能稳定跑起来。
第二档,如果要做卷积神经网络或者 Transformer 这类计算密集型训练,WebGL 能跑但效率不高,建议优先考虑 WebGPU 计算着色器,别在 WebGL 的纹理采样上反复挣扎。真正落地时可以把 WebGPU 作为首要路径,WebGL 作为降级方案。
第三档,如果网络规模大到单张消费级显卡都吃力,那就应该把训练放在服务器端,浏览器只承担数据预处理、推理和展示,偶尔做一轮小规模联邦更新。强行压进制程只会让你在工程复杂度里越陷越深。
对普通前端开发者来说,我的建议是先老老实实走一遍 WebGL 的实现路径。不是为了“兼容性”,而是因为 WebGL 的约束恰恰迫使你把数据流、坐标换算、内存管理这些底层细节彻底想清楚。一旦想通了这套以纹理为内存、以渲染为计算的抽象,再去看 WebGPU 的计算着色器,你会发现很多概念已经在脑子里长好了,只是换了个更顺手的工具。
最后说点个人经验。最早被 WebGL 训练吸引进去的时候,我以为要自己写一堆线性代数库,后来发现核心思路就一句话:把矩阵铺成纹理,把每一条运算规则翻译成片段着色器。真正让我觉得难的不是 WebGL API 本身,而是接受一个观念转变——GPU 的并行不是帮你把循环写得更快,而是让你把一个循环完全消灭掉,换成几千个像素同时执行。踩过几次精度和同步的坑以后,再回头看浏览器端机器学习训练这整件事,我发现最迷人的恰恰是这种约束下的创造性:一个画图的接口,硬被握成了科学计算的通用工具,还真的跑起来了。等 WebGPU 全面普及,这套能力会被放得更大,但底层那个“把计算组织成并行像素流”的思想,会一直延续下去。