WebGPU Timing Performance(WebGPU 性能计时)

英文原作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-timing.html


本文旨在介绍如何测量 WebGPU 应用的性能指标,主要包括三项:

  • 帧率(frames per second, fps)
  • 每帧 JavaScript 耗时(time spent in JavaScript per frame)
  • 每帧 GPU 耗时(time spent on the GPU per frame)

我们将从基础的顶点缓冲区示例入手,将其修改为动画以便观察耗时变化,随后引入 WebGPU 的 timestamp-query 可选特性来获取 GPU 时间戳。


原文配图

1. 从静态圆点到动画圆点

原文示例包含三个顶点缓冲区:一个存储圆点的位置与亮度(逐顶点),一个存储实例静态数据(偏移量和颜色),一个存储每实例变化的数据(缩放比例,用于保持宽高比)。我们将其改为动画移动圆点,把偏移量移到与缩放比例同一缓冲区中。

核心修改:调整渲染管线中缓冲区的布局,使偏移量与缩放量合并到同一个实例缓冲区。

// 渲染管线部分(简化)
const pipeline = device.createRenderPipeline({
  label: 'per vertex color',
  layout: 'auto',
  vertex: {
    module,
    buffers: [
      {
        arrayStride: 2 * 4 + 4, // 2 floats, 4 bytes each + 4 bytes
        attributes: [
          {shaderLocation: 0, offset: 0, format: 'float32x2'},  // position
          {shaderLocation: 4, offset: 8, format: 'unorm8x4'},   // perVertexColor
        ],
      },
      {
        arrayStride: 4, // 4 bytes (color)
        stepMode: 'instance',
        attributes: [
          {shaderLocation: 1, offset: 0, format: 'unorm8x4'},   // color
        ],
      },
      {
        arrayStride: 4 * 4, // 4 floats: offset(2) + scale(2)
        stepMode: 'instance',
        attributes: [
          {shaderLocation: 2, offset: 0, format: 'float32x2'},  // offset
          {shaderLocation: 3, offset: 8, format: 'float32x2'},  // scale
        ],
      },
    ],
  },
  fragment: {
    module,
    targets: [{ format: presentationFormat }],
  },
});

更新动画逻辑时,使用 euclideanModulo 使圆点在屏幕内循环移动:

const euclideanModulo = (x, a) => x - a * Math.floor(x / a);

let then = 0;
function render(now) {
  now *= 0.001; // 转为秒
  const deltaTime = now - then;
  then = now;

  for (let ndx = 0; ndx < settings.numObjects; ++ndx) {
    const {scale, offset, velocity} = objectInfos[ndx];
    offset[0] = euclideanModulo(offset[0] + velocity[0] * deltaTime + 1.5, 3) - 1.5;
    offset[1] = euclideanModulo(offset[1] + velocity[1] * deltaTime + 1.5, 3) - 1.5;

    const off = ndx * (changingUnitSize / 4);
    vertexValues.set(offset, off + kOffsetOffset);
    vertexValues.set([scale / aspect, scale], off + kScaleOffset);
  }

  device.queue.writeBuffer(
    changingVertexBuffer, 0,
    vertexValues, 0, settings.numObjects * changingUnitSize / 4
  );

  pass.draw(numVertices, settings.numObjects);
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

原文配图

2. 测量帧率与 JavaScript 耗时

在网页上添加一个 <pre> 元素用于显示实时数据:

<pre id="info"></pre>
#info {
  position: absolute;
  top: 0;
  left: 0;
  margin: 0;
  padding: 0.5em;
  background-color: rgba(0, 0, 0, 0.8);
  color: white;
}

在渲染循环中记录 JavaScript 执行时间,并计算帧率:

let then = 0;
function render(now) {
  now *= 0.001;
  const deltaTime = now - then;
  then = now;

  const startTime = performance.now();

  // ... 执行所有渲染命令 ...

  const jsTime = performance.now() - startTime;

  infoElem.textContent = `\
fps: ${(1 / deltaTime).toFixed(1)}
js: ${jsTime.toFixed(1)}ms
`;

  requestAnimationFrame(render);
}

3. 使用 timestamp-query 测量 GPU 耗时

WebGPU 提供了可选的 'timestamp-query' 特性,用于查询 GPU 操作耗时。首先需要检测并请求该特性:

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  const canTimestamp = adapter.features.has('timestamp-query');
  const device = await adapter?.requestDevice({
    requiredFeatures: [
      ...(canTimestamp ? ['timestamp-query'] : []),
    ],
  });
  if (!device) {
    fail('need a browser that supports WebGPU');
    return;
  }
  // ...
}

创建 GPUQuerySet、解析缓冲区(resolve buffer)和结果缓冲区(result buffer):

const { querySet, resolveBuffer, resultBuffer } = (() => {
  if (!canTimestamp) {
    return {};
  }

  const querySet = device.createQuerySet({
    type: 'timestamp',
    count: 2,
  });
  const resolveBuffer = device.createBuffer({
    size: querySet.count * 8,
    usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
  });
  const resultBuffer = device.createBuffer({
    size: resolveBuffer.size,
    usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
  });
  return { querySet, resolveBuffer, resultBuffer };
})();

在渲染通道描述符中添加 timestampWrites 字段,指定起止时间戳写入查询集的索引:

const renderPassDescriptor = {
  colorAttachments: [
    {
      clearValue: [0.3, 0.3, 0.3, 1],
      loadOp: 'clear',
      storeOp: 'store',
    },
  ],
  ...(canTimestamp && {
    timestampWrites: {
      querySet,
      beginningOfPassWriteIndex: 0,
      endOfPassWriteIndex: 1,
    },
  }),
};

通道结束后,调用 resolveQuerySet 将时间戳解析到缓冲区,并拷贝到可映射的结果缓冲区:

pass.end();

if (canTimestamp) {
  encoder.resolveQuerySet(querySet, 0, querySet.count, resolveBuffer, 0);
  if (resultBuffer.mapState === 'unmapped') {
    encoder.copyBufferToBuffer(resolveBuffer, 0, resultBuffer, 0, resultBuffer.size);
  }
}

提交命令缓冲区后,异步映射结果缓冲区并读取时间差(单位:纳秒):

let gpuTime = 0;

function render(now) {
  // ...
  const commandBuffer = encoder.finish();
  device.queue.submit([commandBuffer]);

  if (canTimestamp && resultBuffer.mapState === 'unmapped') {
    resultBuffer.mapAsync(GPUMapMode.READ).then(() => {
      const times = new BigUint64Array(resultBuffer.getMappedRange());
      gpuTime = Number(times[1] - times[0]);
      resultBuffer.unmap();
    });
  }
}

注意:查询结果以 64 位整数纳秒存储,JavaScript 中需使用 BigUint64Array 读取,再转为 Number 进行运算,注意精度限制。


4. 封装 TimingHelper 辅助类

为了简化大量样板代码,教程提供了一个 TimingHelper 类,自动处理时间戳查询的生命周期、状态检查和错误断言。该类的核心逻辑:

  • 构造时自动创建 GPUQuerySet 和解析缓冲区(如果特性可用)。
  • beginRenderPass / beginComputePass 自动将 timestampWrites 注入通道描述符。
  • 通道 end() 后自动调用 resolveQuerySet 并拷贝结果。
  • getResult() 返回 Promise,异步映射缓冲区并读取纳秒差值。
  • 内部维护状态机,防止误用(例如未提交命令缓冲区就读取结果)。

核心代码片段:

export default class TimingHelper {
  #canTimestamp;
  #device;
  #querySet;
  #resolveBuffer;
  #resultBuffer;
  #commandBuffer;
  #resultBuffers = [];
  #state = 'free';

  constructor(device) {
    this.#device = device;
    this.#canTimestamp = device.features.has('timestamp-query');
    if (this.#canTimestamp) {
      this.#querySet = device.createQuerySet({ type: 'timestamp', count: 2 });
      this.#resolveBuffer = device.createBuffer({
        size: this.#querySet.count * 8,
        usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
      });
    }
  }

  beginRenderPass(encoder, descriptor = {}) {
    return this.#beginTimestampPass(encoder, 'beginRenderPass', descriptor);
  }

  beginComputePass(encoder, descriptor = {}) {
    return this.#beginTimestampPass(encoder, 'beginComputePass', descriptor);
  }

  async getResult() {
    if (!this.#canTimestamp) return 0;
    // 断言状态为 'wait for result',且命令缓冲区已提交
    // 映射 resultBuffer,读取时间差并返回
  }
  // ... 其他辅助方法
}

使用示例:

const timingHelper = new TimingHelper(device);
const pass = timingHelper.beginRenderPass(encoder, renderPassDescriptor);
// ... 渲染命令 ...
pass.end();

const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);

timingHelper.getResult().then(gpuTime => {
  gpuAverage.addSample(gpuTime / 1000);
});

5. 重要注意事项

⚠️ timestamp-query 结果是“实现定义”的

不同 GPU 的调度方式、缓存行为、驱动优化不同,导致相同代码在不同设备上的时间戳结果可能大相径庭。结果只能用于调试和相对比较技术方案,不能作为绝对性能指标,甚至不能假设不同设备上的相对差距一致。

⚠️ timestamp-query 不是衡量整体性能的好指标

教程作者举了一个实际例子:使用渲染通道生成 mipmap 与使用计算通道生成 mipmap。时间戳查询显示计算通道方法快 5 倍,但改用吞吐量测试(逐步增加纹理数量直到帧率低于 60fps)后发现,渲染通道方法反而快 20%(不同机器有差异)。
结论:应结合吞吐量测试、真实负载测试等方法,而非孤立地依赖时间戳查询。


核心优化要点提炼

  1. 精确测量帧率与 JavaScript 耗时
    利用 requestAnimationFrame 计算 deltaTime 得到 fps,并使用 performance.now 包裹渲染逻辑以测量 JS 耗时。为减少数据波动,建议引入滚动平均类(如 NonNegativeRollingAverage)平滑显示值。

  2. 合理使用 timestamp-query 测量 GPU 耗时
    通过可选特性创建 GPUQuerySet,在渲染/计算通道中记录起止时间戳,解析并映射到 CPU 可读缓冲区,使用 BigUint64Array 读取纳秒级差值。注意默认量化到 100µs,且结果与实现相关,仅适合相对比较。

  3. 封装 TimingHelper 类降低重复代码与出错率
    将查询集创建、通道包裹、解析、缓冲区映射等步骤封装为辅助类,并通过状态机和断言防止常见误用(如未提交命令缓冲区就读取结果)。该类当前仅支持单通道,多通道场景需自行扩展或设计更通用的管理方案。


专业术语检查(WebGPU 相关)

文中涉及的专业术语对照如下,确保中文表达准确:

英文术语中文翻译
timestamp-query时间戳查询
GPUQuerySetGPU 查询集
resolveQuerySet解析查询集
render pass渲染通道
compute pass计算通道
command encoder命令编码器
command buffer命令缓冲区
mappable buffer可映射缓冲区
mapAsync异步映射
BigUint64Array大整数无符号 64 位类型化数组
implementation-defined实现定义(的)
frames per second (fps)每秒帧数
requestAnimationFrame (rAF)请求动画帧
vertex buffer顶点缓冲区
instance step mode实例步进模式
array stride数组步长
uniform buffer统一缓冲区(文章未直接出现,但可作对比)
quantitative量化(的)

这些术语均为 WebGPU / GPU 编程领域的标准翻译。注意当前讨论的是 WebGPU,并非 WebGL/GLSL,但时间戳查询、通道等概念在两种 API 中较为接近。