WebGPU 通用计算:GPU 并行计算模式与实战
从理论到实践,系统掌握 GPU 通用计算中的核心并行模式,附带完整 WGSL 实现与性能分析
引言:为什么是 WebGPU GPGPU?
GPU 已经从图形渲染的专用加速器演变为通用的并行计算引擎。传统上,CUDA 和 OpenCL 是 GPGPU 的主流选择,但它们各有局限:CUDA 被 NVIDIA 锁定,OpenCL 已被各大厂商逐渐放弃。
WebGPU 的出现改变了这一局面。作为现代 GPU 编程的继任者,它统一了 Vulkan、Metal、Direct3D 12 三大底层图形 API,不仅可以用于图形渲染,更提供了完整的原生计算管线(compute pipeline)支持。虽然在硬件控制精度上不如原生 Vulkan,但 WebGPU 在可访问性、安全性和跨平台能力上有着无可比拟的优势。
本文不讨论图形管线,而是聚焦于 纯计算着色器(pure compute shader) 中的核心并行模式。掌握这些模式,是真正理解 GPU 计算的第一步。
并行计算的思维转换
在向 GPU 移植算法之前,必须理解一个根本性的差异:GPU 是为吞吐量设计的,而非延迟优化。
CPU 有数个强大的核心,每个核心擅长单线程顺序执行,有巨大的缓存和复杂的分支预测。GPU 则拥有数千个流处理器(CUDA cores / execution groups),每个核心相对简单,但协同工作时能爆发出惊人的吞吐量。
这意味着:
- 不要试图在 GPU 上执行串行逻辑
- 数据并行是核心:同一函数作用于大量数据元素的不同部分
- 分支 divergence 是性能杀手(同一 warp/wavefront 内的线程走不同分支时串行化)
- 内存访问模式至关重要(紧邻线程访问紧邻内存地址时效率最高)
有了这个基础认知,我们来看具体的并行模式。
模式一:并行归约(Parallel Reduction)
归约是最基础的并行模式,将一个数组缩减为单个值。看似简单,实则蕴含了 GPU 优化的核心思想。
朴素方法的问题
最直观的想法是:线程 0 把所有元素加起来。这在 GPU 上万线程的情况下,线程 0 成了瓶颈,其他线程空闲等待,效率极低。
树形归约
经典做法是构建一棵归约树。以数组求和为例,每个线程先加载两个元素相加,结果写入共享内存;然后每轮迭代,活跃线程数减半,直到只剩一个值。
// 并行归约求和 - 使用 workgroup 共享内存
@compute @workgroup_size(256)
fn reduce_sum(
@builtin(local_invocation_id) lid: vec3<u32>,
@builtin(workgroup_id) wid: vec3<u32>
) {
// 每个线程加载元素到共享内存
var shared: array<f32, 256>;
let tid = lid.x;
let globalIdx = wid.x * 256u + tid;
shared[tid] = input_data[globalIdx];
workgroupBarrier();
// 树形归约:每轮活跃线程减半
var stride: u32 = 128u; // workgroup_size / 2
while (stride > 0u) {
if (tid < stride) {
shared[tid] = shared[tid] + shared[tid + stride];
}
workgroupBarrier();
stride = stride >> 1u;
}
// 线程 0 将 workgroup 结果写入全局内存
if (tid == 0u) {
output_data[wid.x] = shared[0];
}
}
关键陷阱:Bank Conflict
GPU 的共享内存(WebGPU 中的 shared 变量)被组织为多个 bank。如果同一 warp 内的多个线程访问同一 bank 的不同地址,会发生 bank conflict,导致串行访问。
早期的树形归约(stride 从 workgroup_size/2 开始)在 stride > 16 时会产生大量 bank conflict。优化方法是使用顺序寻址:
// 优化后的归约:避免 bank conflict
var index = (tid * 2u) + (tid / 16u); // 交错索引
if (tid < activeCount) {
shared[tid] = shared[index] + shared[index + 1u];
}
实战性能数据
在我的测试中(M2 MacBook Pro,GPU 约 2.6 TFLOPS),对 1000 万元素的 f32 数组求和:
| 方法 | 耗时 | 带宽利用率 |
|---|---|---|
| CPU 单线程 | 11.2 ms | ~14% |
| 朴素 GPU(原子操作) | 8.4 ms | ~2% |
| 树形归约 | 0.31 ms | ~52% |
| Bank-conflict-free 归约 | 0.24 ms | ~67% |
注意:即使优化到极致,GPU 的峰值带宽也很少被完全利用。这通常是由于内存控制器调度、访存延迟隐藏不充分等系统性因素导致的。
模式二:前缀和(Prefix Sum / Scan)
前缀和看似简单:output[i] = input[0] + input[1] + ... + input[i]。但实际上它是众多更复杂算法的基石——如快速排序的分区操作、流压缩(stream compaction)、基数排序等。
Blelloch 扫描算法
GPU 上最经典的前缀和实现是 Blelloch 提出的两阶段算法:
第一阶段:上行扫描(Reduce phase)——构建部分和的归约树 第二阶段:下行扫描(Downsweep phase)——从根节点向下传播前缀结果
上行阶段: 下行阶段:
o o
/ \ / \
o o o o
/ \ / \ / \ / \
o o o o o o o o
什么情况下需要前缀和?
- 流式压缩:从数组中过滤元素时,先计算布尔掩码的前缀和,再将有效元素紧凑排列
- 基数排序:统计直方图后,前缀和给出每个元素的最终位置
- 内存分配:并行分配器中,计算 offset 的前缀和可以无冲突地分配空间
模式三:直方图(Histogram)
直方图是统计分布的基础工具,但在 GPU 上是出了名的难优化——因为多个线程可能同时要更新同一个 bin。
方法一:原子操作(简单但慢)
@group(0) @binding(0) var<storage, read> input_data: array<f32>;
@group(0) @binding(1) var<storage, read_write> histogram: atomic<u32>;
@compute @workgroup_size(256)
fn histogram_atomic(@builtin(global_invocation_id) gid: vec3<u32>) {
let idx = gid.x;
if (idx >= arrayLength(&input_data)) { return; }
let bin = u32(input_data[idx] * f32(NUM_BINS));
atomicAdd(&histogram[min(bin, NUM_BINS - 1u)], 1u);
}
原子操作简单,但当 bin 数较少或数据分布不均时,大量原子冲突会导致严重串行化。
方法二:Shared Memory + 私有化(实战推荐)
const NUM_BINS: u32 = 256u;
const WG_SIZE: u32 = 128u;
var<workgroup> shared_hist: array<atomic<u32>, NUM_BINS>;
@compute @workgroup_size(WG_SIZE)
fn histogram_optimized(@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_id) lid: vec3<u32>,
@builtin(workgroup_id) wid: vec3<u32>) {
let tid = lid.x;
let globalIdx = gid.x;
// 1. 初始化共享内存直方图
for (var i = tid; i < NUM_BINS; i = i + WG_SIZE) {
atomicStore(&shared_hist[i], 0u);
}
workgroupBarrier();
// 2. 每个 workgroup 处理一部分数据
let items_per_thread = (arrayLength(&input_data) + dispatchCount * WG_SIZE - 1u) / (dispatchCount * WG_SIZE);
for (var i: u32 = 0u; i < items_per_thread; i = i + 1u) {
let idx = globalIdx + i * WG_SIZE * dispatchCount;
if (idx < arrayLength(&input_data)) {
let bin = u32(input_data[idx] * f32(NUM_BINS));
atomicAdd(&shared_hist[min(bin, NUM_BINS - 1u)], 1u);
}
}
workgroupBarrier();
// 3. 将本地直方图合并到全局内存
for (var i = tid; i < NUM_BINS; i = i + WG_SIZE) {
let localCount = atomicLoad(&shared_hist[i]);
if (localCount > 0u) {
atomicAdd(&global_histogram[i], localCount);
}
}
}
这个方法的核心思想是:分而治之。每个 workgroup 先在共享内存中构建自己的子直方图,避免不同 workgroup 之间的冲突,最后再合并到全局内存。
方法三:私有化(最高效)
更进一步,可以为每个线程分配私有直方图:
// 每个线程维护私有直方图(寄存器/本地内存)
var<private> private_hist: array<u32, 64>; // 假设 64 个 bin
// 处理 N 个元素后,再合并到共享内存(而非每元素原子操作)
for (var i: u32 = 0u; i < LOCAL_SIZE; i = i + 1u) {
let bin = compute_bin(input_data[globalIdx + i]);
private_hist[bin] = private_hist[bin] + 1u;
}
workgroupBarrier();
// 合并到 shared memory...
这种私有化方法的缺点是:bin 的数量必须很小(受限于寄存器/本地内存空间),但对于常见的 256-bin 直方图完全可以胜任。
模式四:基数排序(Radix Sort)
当需要对数百万个元素排序时,比较排序 O(n log n) 的理论复杂度不如基数排序的 O(n · k)(k 为位数),而基数排序天然适合 GPU 并行。
核心思想
基数排序按位/按 nibble(4 位)对整数排序。每轮排序一个 nibble 的取值(0-15,共 16 个桶),共需 8 轮(32 位整数 × 每 nibble 4 位)。
原始数据: [25, 17, 32, 12]
按最低 nibble 排序:
25(0x19)→nibble=9, 17(0x11)→1, 32(0x20)→0, 12(0x0C)→C(12)
按 nibble 值排序后: [32, 17, 25, 12]
按下一个 nibble:
32(0x20)→2, 17(0x11)→1, 25(0x19)→1, 12(0x0C)→0
排序后: [12, 17, 32, 25] ✓
GPU 实现
const NUM_BUCKETS: u32 = 16u; // 一个 nibble = 16 个桶
@group(0) @binding(0) var<storage, read_write> data: array<u32>;
@group(0) @binding(1) var<storage, read_write> temp: array<u32>;
var<workgroup> shared_offsets: array<u32, NUM_BUCKETS>;
var<workgroup> shared_hist: array<atomic<u32>, NUM_BUCKETS>;
@compute @workgroup_size(128)
fn radix_sort_pass(@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_id) lid: vec3<u32>,
@builtin(workgroup_id) wid: vec3<u32>,
@builtin(num_workgroups) num_wgs: vec3<u32>,
@builtin(workgroup_size_x) wg_size: u32) {
let tid = lid.x;
let wg_idx = wid.x;
let total_wgs = num_wgs.x;
let n = arrayLength(&data);
let items_per_wg = (n + total_wgs - 1u) / total_wgs;
let wg_start = wg_idx * items_per_wg;
// Step 1: 统计本地 nibble 分布
for (var i = tid; i < NUM_BUCKETS; i = i + wg_size) {
atomicStore(&shared_hist[i], 0u);
}
workgroupBarrier();
for (var i: u32 = 0u; i < items_per_wg; i = i + 1u) {
let idx = wg_start + i;
if (idx < n) {
let nibble = (data[idx] >> (PASS * 4u)) & 0xFu;
atomicAdd(&shared_hist[nibble], 1u);
}
}
workgroupBarrier();
// Step 2: 计算本 workgroup 内各桶的偏移(本地前缀和)
if (tid < NUM_BUCKETS) {
var prefix: u32 = 0u;
for (var b: u32 = 0u; b < tid; b = b + 1u) {
prefix = prefix + atomicLoad(&shared_hist[b]);
}
shared_offsets[tid] = prefix;
}
workgroupBarrier();
// Step 4: 将元素写到正确位置
for (var i: u32 = 0u; i < items_per_wg; i = i + wg_size) {
let idx = wg_start + i + tid;
if (idx < n) {
let val = data[idx];
let nibble = (val >> (PASS * 4u)) & 0xFu;
let pos = atomicAdd(&shared_offsets[nibble], 1u);
temp[global_offset[wg_idx * NUM_BUCKETS + nibble] + pos] = val;
}
}
}
为什么基数排序适合 GPU?
- 无分支/少分支:每轮只按固定桶索引写入,warp 内发散极小
- 规则访存:每轮中所有线程按元素顺序读取输入,写入有序桶中
- O(n) 复杂度:对于固定位宽的 key(如 32 位整数),只需 8 轮 nibble pass
实测性能(M2 Pro,1000 万个 u32 排序):
| 算法 | 耗时 | 吞吐量 |
|---|---|---|
| CPU std::sort | 820 ms | 12.2 M/s |
| GPU Radix Sort | 38 ms | 263 M/s |
GPU Radix Sort 比 std::sort 快了约 20 倍吞吐量。
模式五:稀疏矩阵-向量乘(SpMV)
稀疏矩阵-向量乘是科学计算的核心运算(CG 求解器、PageRank 等),也是 GPGPU 的经典难点——因为不规则的内存访问模式。
稀疏矩阵的 GPU 友好存储:ELLPACK 格式
CSR(Compressed Sparse Row)是最常见的稀疏格式,但在 GPU 上行长度不规则会导致负载不均。ELLPACK 格式将每行填充为相同长度:
原始矩阵: CSR 表示: ELLPACK 表示:
[1 2 0 0] row_ptr: [0 2 3 6] values: [[1,2,* ,* ],
[3 0 0 4] cols: [0 1 0 2 3] [3,4,* ,* ],
[0 5 6 7] values: [1 2 3 4 5 6 7] [5,6,7 ,* ]]
cols: [[ 0,1,-1,-1],
[ 0,2,-1,-1],
[ 1, 2, 3,-1]]
* 和 -1 表示填充的无意义值。
Ellpack SpMV 实现
struct Params {
max_nnz: u32,
num_rows: u32,
};
@group(0) @binding(0) var<storage, read> matrix_values: array<f32>;
@group(0) @binding(1) var<storage, read> col_indices: array<u32>;
@group(0) @binding(2) var<storage, read> vector_x: array<f32>;
@group(0) @binding(3) var<storage, read_write> result_y: array<f32>;
@group(0) @binding(4) var<uniform> params: Params;
@compute @workgroup_size(256)
fn spmv_ellpack(@builtin(global_invocation_id) gid: vec3<u32>) {
let row = gid.x;
if (row >= params.num_rows) { return; }
var sum: f32 = 0.0;
let row_offset = row * params.max_nnz;
for (var j: u32 = 0u; j < params.max_nnz; j = j + 1u) {
let idx = row_offset + j;
let col = col_indices[idx];
let val = matrix_values[idx];
if (col != 0xFFFFFFFFu && val != 0.0) {
sum = sum + val * vector_x[col];
}
}
result_y[row] = sum;
}
什么时候用 ELLPACK,什么时候用 CSR?
- ELLPACK:适合各行非零元素数量差异不大的矩阵(如结构化网格的有限差分矩阵)
- CSR:通用但 GPU 效率低,需要配合 merge-based 或 CSR-Adaptive 等优化算法
- Block-ELL (BELLPACK):适合有固定 block 结构的稀疏矩阵(如 FEM 单元矩阵)
WebGPU 计算管线的工程陷阱
陷阱一:shared memory 大小限制
WebGPU 规范要求最小 16KB 共享内存,但不同设备的实际支持差异很大:
// 查询设备限制
const maxWGSize = device.limits.maxComputeWorkgroupSizeX;
const sharedMemSize = device.limits.maxComputeWorkgroupStorageSize;
很多移动 GPU 只有 16KB shared memory。这意味着一个 256 workgroup,如果每个线程缓存一个 f32 大小只有 16 threads。需要根据设备限制动态调整 workgroup 大小。
陷阱二:Dispatch 的 barrier 限制
WebGPU 中,不同 dispatch 之间是不保证顺序的(除非有显式的 storage buffer barrier)。这意味着多 pass 算法中,pass 之间必须使用 device.queue.submit() 分开发送,或插入 device.queue.writeBuffer 的同步点。
陷阱三:精度问题
WGSL 的 f32 在大部分设备上遵循 IEEE 754,但某些设备可能在非规格化数(subnormal numbers)上有截然不同的行为。对于科学计算,务必检查是否需要特殊处理非规格化数。
注:WebGPU 中没有 -ffast-math 类选项,需要自己在 shader 中控制精度行为。可以对输入值做 clamp 或将极小值 flush 为零。
陷阱四:调试困难
没有 printf 输出。调试 WebGPU compute shader 的方法:
- 写入 storage buffer:将要调试的变量写入特定位置,然后从 CPU 读取
- 分层调试:先用极小数据量跑一个 workgroup,确认逻辑正确
- 使用 Chrome DevTools:目前 WebGPU 调试支持有限,但可以捕获帧
总结与展望
本文介绍了五个 GPU 并行计算的核心模式:
| 模式 | 复杂度 | 核心思想 | 适用场景 |
|---|---|---|---|
| 归约 | O(log n) | 树形规约 + shared memory | 求和、求极值、内积 |
| 前缀和 | O(log n) | Blelloch 两相扫描 | 排序分区、流压缩、分配 |
| 直方图 | O(1) 每元素 | 私有化 + 局部合并 | 统计分布、直方均衡 |
| 基数排序 | O(n · k) | 按位分桶 + 前缀和 | 大规模整数/浮点排序 |
| SpMV | O(nnz) | ELLPACK 负载均衡 | 科学计算、图算法 |
掌握这些模式后,你会发现许多看似不同的计算问题,底层都可以归结为这几个基本模式的组合。
WebGPU 计算仍是一个快速发展的领域。随着 WebGPU 规范的演进和浏览器实现的成熟,我们有望看到更多高级特性(如 subgroup operations、FP16 支持、更灵活的 address modes)的加入。彼时,Web 平台上的通用 GPU 计算将真正达到与原生 API 同等的表达能力。
通过 WebGPU,高性能 GPU 计算不再被原生 SDK 垄断,Web 开发者也能在浏览器中释放 GPU 的全部潜力。

发表评论 取消回复