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%(不同机器有差异)。
结论:应结合吞吐量测试、真实负载测试等方法,而非孤立地依赖时间戳查询。
核心优化要点提炼
-
精确测量帧率与 JavaScript 耗时
利用requestAnimationFrame计算deltaTime得到 fps,并使用performance.now包裹渲染逻辑以测量 JS 耗时。为减少数据波动,建议引入滚动平均类(如NonNegativeRollingAverage)平滑显示值。 -
合理使用 timestamp-query 测量 GPU 耗时
通过可选特性创建GPUQuerySet,在渲染/计算通道中记录起止时间戳,解析并映射到 CPU 可读缓冲区,使用BigUint64Array读取纳秒级差值。注意默认量化到 100µs,且结果与实现相关,仅适合相对比较。 -
封装 TimingHelper 类降低重复代码与出错率
将查询集创建、通道包裹、解析、缓冲区映射等步骤封装为辅助类,并通过状态机和断言防止常见误用(如未提交命令缓冲区就读取结果)。该类当前仅支持单通道,多通道场景需自行扩展或设计更通用的管理方案。
专业术语检查(WebGPU 相关)
文中涉及的专业术语对照如下,确保中文表达准确:
| 英文术语 | 中文翻译 |
|---|---|
| timestamp-query | 时间戳查询 |
| GPUQuerySet | GPU 查询集 |
| 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 中较为接近。
