WEBKT

别找 vkCmdPipelineBarrier 了:WebGPU 如何在多 Pass 间安全共享原子数据

6 0 0 0

如果你有 Vulkan 或 Direct3D 12 的开发背景,在刚接触 WebGPU 时,面对多 Pass 之间的资源同步,你可能会本能地去寻找类似 vkCmdPipelineBarrierResourceBarrier 这样的 API。

然而你会发现,WebGPU 的 API 里根本没有“显存屏障(Memory Barrier)”这个显式调用的概念。

这并不意味着 WebGPU 不需要同步。相反,WebGPU 作为现代图形 API,有着极为严格的资源屏障机制,只不过它将这一复杂的机制隐式化了。本文将深入探讨 WebGPU 如何通过“使用范围(Usage Scopes)”自动处理显存屏障,并展示如何在不同的 Pass(例如 Compute Pass 与 Render Pass)之间安全地共享和同步原子状态数据(Atomic Data)。


为什么 WebGPU 不需要显式屏障?

在 Vulkan 中,你需要手动指定源管线阶段(SrcStage)、目的管线阶段(DstStage)以及内存访问类型(AccessMask)。一旦指错,就会导致神秘的 GPU 挂起或画面闪烁(Data Hazard)。

WebGPU 为了保障 Web 端的安全性和可移植性,引入了**使用范围(Usage Scopes)资源状态追踪(Resource State Tracking)**机制:

  1. Pass 边界即是屏障:WebGPU 的 GPUCommandEncoder 在记录命令时,以 Pass(Compute Pass 或 Render Pass)为基本单元。
  2. 状态冲突检测:在一个 Pass 内部,一个资源(如 GPUBuffer)不能同时处于冲突的状态。例如,你不能在同一个 Render Pass 中既把一个 Buffer 当作 STORAGE(可写)绑定,又当作 INDEX(可读)绑定。
  3. 隐式屏障注入:当你在同一个 Command Encoder 中先后开启 Pass A 和 Pass B,且两个 Pass 都使用了同一个 Buffer 时,WebGPU 的浏览器底层实现(如 Dawn 或 wgpu)会检测到这个资源的使用转换(Transition)。在提交到 GPU 执行时,底层会自动在 Pass A 和 Pass B 之间插入对应的硬件级显存屏障(如 Vulkan 的 VkBufferMemoryBarrier 或 D3D12 的 ResourceBarrier)。

核心场景:Compute Pass 写入原子计数器,Render Pass 读取

我们通过一个经典的场景来演示这一机制:在 Compute Shader 中使用原子操作(Atomics)统计某种状态,并在随后的 Render Shader 中读取该原子计数器进行着色或顶点定位。

1. WGSL 声明与 Buffer 创建

首先,我们在 WGSL 中定义原子数据结构。注意,WGSL 要求原子类型(atomic<u32>atomic<i32>)必须包裹在结构体中,且只能存储在具有 read_write 访问权限的 storage 变量中。

// WGSL 结构体定义
struct AtomicCounter {
    value: atomic<u32>,
}

@group(0) @binding(0)
var<storage, read_write> counter: AtomicCounter;

在 JS/TS 端创建该 Buffer 时,必须赋予其 STORAGE 用途。如果后续还需要在 CPU 端读取,或者作为 Uniform 读取,需要复合相应的 usage

const atomicBuffer = device.createBuffer({
  size: 4, // 一个 u32 占 4 字节
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
  mappedAtCreation: true,
});

// 初始化值为 0
new Uint32Array(atomicBuffer.getMappedRange())[0] = 0;
atomicBuffer.unmap();

2. Pass 1:Compute Pass 执行原子自增

在 Compute Pass 中,多个工作组(Workgroups)会并发写入这个 Buffer。由于我们在着色器中使用了 atomicAdd,GPU 底层会保证硬件级的原子性,但我们仍然需要关注 Pass 间的可见性。

const commandEncoder = device.createCommandEncoder();

// 开启 Compute Pass
const computePass = commandEncoder.beginComputePass();
computePass.setPipeline(computePipeline);
computePass.setBindGroup(0, computeBindGroup); // 绑定了 atomicBuffer
computePass.dispatchWorkgroups(64);
computePass.end(); // 结束 Compute Pass

关键点:当 computePass.end() 被调用时,WebGPU 知道 atomicBuffer 在这个 Pass 中的生命周期结束了。此时,该 Buffer 处于“已被 Compute Shader 写入”的状态。

3. Pass 2:Render Pass 读取原子数据

接下来,我们在同一个 Command Encoder 中开启 Render Pass,并将同一个 Buffer 作为只读数据传入(例如,用来控制绘制实例的数量,或者作为 Storage Buffer 进行只读查询)。

// Render Shader 中的声明
struct RenderData {
    counter_value: u32, // 注意:在只读模式下,可以直接声明为普通 u32,不需要 atomic<u32>
}

@group(0) @binding(0)
var<storage, read> renderCounter: RenderData;
// 开启 Render Pass
const renderPass = commandEncoder.beginRenderPass(renderPassDescriptor);
renderPass.setPipeline(renderPipeline);
renderPass.setBindGroup(0, renderBindGroup); // 同样绑定 atomicBuffer,但布局为 read-only storage
// ... 执行绘制
renderPass.end();

// 提交队列
device.queue.submit([commandEncoder.finish()]);

为什么这是绝对安全的?

当底层的 WebGPU 驱动解析 commandEncoder.finish() 时,它会发现:

  1. atomicBufferComputePass 中被作为 read-write storage(写操作)使用。
  2. 随后在 RenderPass 中被作为 read storage(读操作)使用。
  3. 这构成了一个典型的 写后读(RAW - Read After Write) 依赖。

为了防止 Render Pass 读到未完成写入的脏数据,WebGPU 底层会在 ComputePass 结束和 RenderPass 开始之间,自动插入一个 GPU 内存屏障(Memory Barrier)和执行屏障。该屏障会刷空(Flush)L1/L2 缓存中关于该 Buffer 的写入,并确保 Compute Pass 的所有着色器执行完毕后,Render Pass 才会开始读取。


避坑指南:多 Pass 共享原子的常见错误

尽管 WebGPU 帮我们处理了最棘手的屏障注入,但在实际开发中,依然有几个高频踩坑点会导致同步失败或 Validation Error。

错误一:混淆工作组屏障(Workgroup Barrier)与显存屏障

在编写 WGSL 时,你可能会看到 workgroupBarrier()storageBarrier()

  • 它们的作用域极其有限:它们只能同步同一个工作组(Workgroup)内部的不同线程(Invocations)。
  • 不能跨 Pass 同步:你无法通过在 WGSL 中调用 storageBarrier() 来同步两个不同的 Dispatch 或者是不同的 Pass。跨 Pass 的数据安全必须完全依赖上述的 Pass 边界转换。

错误二:在同一个 Pass 中进行不安全的读写

如果你尝试在同一个 Pass 中,既把 Buffer 绑为可写,又绑为可读,WebGPU 验证层会直接报错:

// 错误示范:在同一个 Render Pass 中冲突绑定
const renderPass = commandEncoder.beginRenderPass({...});
renderPass.setBindGroup(0, writeBindGroup); // 内部包含 atomicBuffer (read_write)
renderPass.setBindGroup(1, readBindGroup);  // 内部也包含 atomicBuffer (read)

WebGPU 严格遵守**单写多读(Single Writer or Multiple Readers)**原则。在任何一个 Pass 内部,一个资源要么只能被写(且不能有其他未同步的读),要么只能被多个地方只读。如果非要在同一个 Pass 内进行复杂的读写交替,必须全部声明为 read_write,并在 Shader 内部使用 atomic 函数,或者拆分成多个 Pass 执行。

错误三:CPU 异步读取(MapAsync)的过早调用

如果你想在 CPU 端获取这个原子计数器的值,你需要使用 buffer.mapAsync()。一个常见的错误是提交了 Command Buffer 后,立刻去 map,而没有考虑 GPU 还在执行中。

// 正确的 CPU 读取流程
device.queue.submit([commandEncoder.finish()]);

// 必须等待 mapAsync 承兑的 Promise
await atomicBuffer.mapAsync(GPUMapMode.READ);
const copyArray = new Uint32Array(atomicBuffer.getDerivedValue().slice());
atomicBuffer.unmap();
console.log("当前原子计数器的值:", copyArray[0]);

WebGPU 的队列提交是异步的,mapAsync 会在 GPU 侧的所有前置命令(包括写入该 Buffer 的 Pass)彻底执行完毕、屏障释放后,才会真正触发 Promise 的 resolve,从而保证了 CPU 端读到的是最新的安全数据。


总结

WebGPU 采用的隐式资源状态追踪极大地简化了现代 GPU 开发的门槛。要在不同的 Pass 之间安全地共享原子状态数据,你只需要做到两点:

  1. 合理划分 Pass:将“写入原子”与“读取原子”拆分到不同的 Pass 中。
  2. 正确声明 BindGroupLayout:在写入 Pass 的布局中声明为 read_write,在读取 Pass 的布局中声明为 read(或 Uniform、Vertex 等)。

只要做到了这两点,WebGPU 就会在幕后为你打理好一切底层的显存屏障,让你的原子操作在保障极致性能的同时,绝对不发生数据竞争。

显卡搬砖工 WebGPU图形学GPU编程

评论点评