← 返回实验室

什么都没算的 500 倍加速:WebGPU 模拟中的六个无声故障

webgpusimulationdebugging

这次运行以每步 0.04 ms 结束,比这个规模的模拟应有的速度快了大约 500 倍。健康报告很干净:没有 NaN,没有无穷大,最大的力是零,每个粒子的邻居数都是零。之所以干净,是因为什么都没算。

这件事发生在 protocell-genesis 里。这是我做的一个 WebGPU 模拟,用来检验粗粒化的“原始汤”能否自己组装成一个封闭的囊泡,也就是把水包在里面的双层膜。在这个模型里它做不到,仓库里有测量出的原因。最大的一次运行有 483,268 个粒子。整个项目中,有六个 bug 的表现和上面那个一样:没有异常,页面上没有报错,数字看起来可信,甚至很好。其中三个是 WebGPU 特有的。下面逐个列出,每个看起来是什么样,以及改了什么。

1. 超出设备允许大小的缓冲区

盒子大小为 85 σ 时,Verlet 邻居列表需要 5,484,474,528 字节。设备的 maxStorageBufferBindingSize 是 4,294,967,292。

createBuffer 校验失败时,WebGPU 不会抛出异常。它在控制台打印一条警告,返回一个无效的缓冲区,之后所有用到它的 bind group 上的 dispatch 都会被丢弃。模拟循环照样一步步往下走,照样读回零:max|F| = 0,0 个非有限值,0 个邻居,每步 0.0405 ms。

修复办法是在分配之前检查。createSoup 计算每个大小随粒子数增长的缓冲区,并与设备自身的限制比较:

const bindingLimit = device.limits.maxStorageBufferBindingSize
const bufferLimit = device.limits.maxBufferSize
for (const [what, bytes] of candidates) {
  if (bytes > bindingLimit || bytes > bufferLimit) {
    throw new Error(
      `buffer ${what} needs ${bytes} bytes at N=${capacityN}; ` +
        `maxStorageBufferBindingSize=${bindingLimit}, ` +
        `maxBufferSize=${bufferLimit}`,
    )
  }
}

2. 读回没有 COPY_SRC 的缓冲区

从 GPU 读数据,意味着把它复制到一个可映射的暂存缓冲区。如果源缓冲区创建时没有 GPUBufferUsage.COPY_SRC,这次复制就是校验错误。它会让命令缓冲区失效,于是 submit 什么也不做。暂存缓冲区刚刚创建,WebGPU 会把新缓冲区初始化为零,mapAsync 也正常 resolve。你得到的是看起来像数据的零。

两个随机数缓冲区缺了这个标志。磁盘上全部 72 个检查点保存的随机数状态都是全零,所以每次恢复的运行,都给每个粒子的恒温器种下了 0。每个粒子的朗之万噪声完全相同,整个体系就像一个整体一样漂移:七种粒子的均方位移全都是 62.32 σ²,而让聚集体彼此相遇的相对扩散消失了。修复后,第 90,000 步时最大的聚集体从 268 增长到了 588。

usage 是 GPUBuffer 的一个可读属性,所以整类 bug 只需一次按位检测:

if ((src.usage & GPUBufferUsage.COPY_SRC) === 0) {
  throw new Error(
    'readBack: the buffer was created without ' +
      `GPUBufferUsage.COPY_SRC (usage=${src.usage})`,
  )
}

3. 没有 requiredLimits 的 requestDevice()

不带 requiredLimits 请求的设备,拿到的是默认限制,而不是适配器支持的限制。默认每个着色器阶段只允许 8 个存储缓冲区。成键的内核超过了这个数,用 'auto' 布局创建管线时只在控制台留下一条警告就失败了,每个受影响的 pass 都什么也没做。一个键都没形成。

大小限制也以同样的方式失败,只不过触发它的是粒子数,而不是代码改动。默认的 maxStorageBufferBindingSize 是 128 MiB。每个粒子 1,000 个邻居槽位时,列表在 28,400 个粒子(113.6 MB)时放得下,在 36,400 个(145.6 MB)时则悄无声息地什么也不做。

现在设备会请求适配器自己的最大值:

const { limits } = adapter
const device = await adapter.requestDevice({
  requiredLimits: {
    maxStorageBuffersPerShaderStage:
      limits.maxStorageBuffersPerShaderStage,
    maxStorageBufferBindingSize: limits.maxStorageBufferBindingSize,
    maxBufferSize: limits.maxBufferSize,
  },
})

4. 永远不会触发的 NaN 防护

一个安全检查会在粒子漂移超过 Verlet skin 的一半时抛出异常,用的是 sqrt(maxDriftSq) > skin / 2。任何与 NaN 的比较都是 false。位置一旦变成 NaN,漂移量也是 NaN,于是检查在每个时间步都通过。这个防护一直在运行,却看不见它本来要防的那种失败。

修复办法是在同一个时间步、在旧比较之前,扫描每个位置的 IEEE-754 指数位。代价是:每块 1,000 步的计算耗时 331.68 ms,其中多花 0.212 ms,即 0.064%。在那个一直悄悄发散的配方上,运行现在会在第 1,000 步抛出异常,并指出 60,750 个分量中有 6,957 个非有限。

5. 盒子改变大小之前的力

干燥和再水化会改变盒子大小。代码缩放了坐标,重建了邻居网格和 Verlet 列表,然后用缩放之前算出的力走了下一步。结果是一个错误的数字,这比零更难发现。

现在有一个测试从两边把它钉住。修复后,在力的量级为 513 时,GPU 上的力缓冲区与重新计算的结果相差不超过 1.5e-4。没有修复时,缓冲区与旧的力逐位相等,偏差达 313.7。

它改变了一个我已经写成文字的结果。在盒子 30 时,力正确之后,最大的聚集体小了 2.28 倍(两边各三次运行,范围不重叠),而我报告的干燥循环效应从 12.5–14.5× 缩小到 8.1–8.4×。一个原本在 216 次中触发 9 次的防护,修复后在 36 次中触发 0 次。

6. 按名字识别输入的判定关卡

这个和 GPU 无关。渗流判定关卡负责把测量结果变成通过或不通过的结论,它们靠写死在代码里的实验批次标签 zfB54 找到输入。第一批用了不同标签的实验,让关卡在没有任何报错的情况下变成了“未证实”。现在每行数据带有一个 role 字段,标签只作为后备。

一条按名字识别输入的流水线,会在有人跑新实验时沉默下来,而那恰恰是它的结论最要紧的时候。

第七个:测量工具读到的是零

检查页面是否在绘制的测试,通过 drawImage 把 WebGPU 画布读进一个 2D 画布。它报告 30,000 个像素中有 0 个发生了变化,而这个页面刚刚用 661 个实例画了 119 帧。现在这个测试使用场景自己的实例计数器,以及真正的 page.screenshot()。

我现在把什么当作失败

力为零、邻居为零、键为零,在有东西证明物理确实能产生它们之前,都是失败。没人做过的加速也是。从未触发过的防护同样如此,直到我亲眼看到它被故意触发。

WebGPU 会把校验错误报告到控制台和设备的 uncapturederror 事件,而错误作用域(device.pushErrorScope('validation'))可以让你围绕一段工作等待这些错误。这个项目用的是显式检查:抛出异常,并写明是哪个缓冲区、多少字节。无论你选哪种,默认情况都是一条控制台警告,加上一个看起来没问题的结果。

代码、判定报告和完整结论都在仓库里,快照画廊可以在任何现代浏览器里渲染真实的检查点。