3.2 队列提交、帧同步与 fence
上一节我们录好了一条命令缓冲。device.queue.submit([cmd]) 一调用,命令就进了 GPU 的队列——但进队不等于执行完。submit 是异步的:它把命令缓冲塞进队列就立刻返回,CPU 继续往下跑,GPU 在后面慢慢消化。真正「这一帧画完了」的时刻,要由一条 fence 通知 CPU。
WebGPU 把这条 fence 暴露成 queue.onSubmittedWorkDone(): Promise<void>。它 resolve 的含义是:所有已提交的命令缓冲都已完成。这是 CPU 唯一能可靠知道「GPU 追上了」的同步点。
这一节要回答两个问题:同步点该放在哪,以及为什么不能每帧都 await。
提交是入队,完成要靠 fence
先厘清时间轴。CPU 与 GPU 是两条并行的时间线:
- CPU 时间线:录制命令缓冲 →
submit入队 → 继续录制下一帧。 - GPU 时间线:从队列取命令缓冲 → 执行 → 完成,然后 fence 置位。
submit 只发生在 CPU 时间线上,它做的唯一一件事是把缓冲挂到队列尾部。GPU 什么时候开始跑、跑多久,CPU 完全不知道。onSubmittedWorkDone() 返回的 Promise 在 GPU 侧全部完成后 resolve,于是 CPU 在这条 Promise 上 await 就等于「等 GPU 追上我的队列」。
关键点:队列是深度不限的,submit 永不阻塞。你可以一口气提交十帧,CPU 一点不卡;但它们会排在 GPU 后面依次执行。所以「同步点」本质上是一个策略选择——你愿意让 CPU 超前 GPU 多少帧。
为什么不能每帧 await
最直觉的写法是每帧录完就 await queue.onSubmittedWorkDone()。这样 CPU 与 GPU 严格交替:CPU 录一帧 → GPU 跑一帧 → CPU 录下一帧……两条时间线完全串行,总帧时 = 录制耗时 + GPU 耗时,谁也不藏谁。
把 await 拿掉、允许最多 K 帧在飞,两条时间线就重叠了:CPU 在录帧 N+1 时,GPU 正在跑帧 N。稳态下每帧墙钟只受二者中较慢的一方约束,快的那个被藏起来。K 通常取 2~3——再多会拉高输入延迟、也多占资源。
这个「最多 K 帧在飞」的约束,用一个环形登记表就能表达。下面 src/render/frame/frame-sync.ts 是完整可编译文件,层级 L1:FrameRing 记录哪些帧还在 GPU 上飞,槽位满员时 acquire() 直接抛错——那一刻正是真实循环里「必须等 fence」的时刻。文件后半给出两种策略的确定性时间线模拟。
// file: src/render/frame/frame-sync.ts
// tier: L1
// verify: npx vitest run src/render/frame/frame-sync.test.ts
//
// helio 渲染帧循环:GPU 与 CPU 的同步点。
// FrameRing 记录「哪些帧还在 GPU 上飞」,容量 = 最大在飞帧数;
// 用确定性时间线模拟「每帧 await」与「流水线」两种策略的吞吐差异。
export interface FrameCosts {
readonly cpuRecordMs: number;
readonly gpuMs: number;
}
export interface FrameTiming {
readonly frame: number;
readonly cpuStart: number;
readonly cpuEnd: number;
readonly gpuStart: number;
readonly gpuEnd: number;
}
export class FrameSyncError extends Error {}
/**
* 在飞帧的环形登记表。容量 = 允许同时「已提交但未完成」的帧数。
* acquire 返回可复用的槽位;若所有槽位都在飞,抛错——这正对应
* 真实循环里「必须等 onSubmittedWorkDone 才能继续录制」的时刻。
*/
export class FrameRing {
readonly #capacity: number;
readonly #slotFrame: number[];
#nextFrame = 0;
constructor(capacity: number) {
if (capacity < 1) throw new FrameSyncError('capacity 至少为 1');
this.#capacity = capacity;
this.#slotFrame = new Array<number>(capacity).fill(-1);
}
/** 申请一个新帧的槽位;满员则抛错(调用方应在此处等待)。 */
acquire(): number {
const slot = this.#nextFrame % this.#capacity;
const occupant = this.#slotFrame[slot];
if (occupant !== -1) {
throw new FrameSyncError(`槽位 ${slot} 仍被帧 ${occupant} 占用,需等待 onSubmittedWorkDone`);
}
const frame = this.#nextFrame;
this.#slotFrame[slot] = frame;
this.#nextFrame += 1;
return slot;
}
/** 该帧的工作已提交到队列,进入「在飞」。 */
markSubmitted(frame: number): void {
const slot = frame % this.#capacity;
if (this.#slotFrame[slot] !== frame) {
throw new FrameSyncError(`帧 ${frame} 未占用槽位 ${slot}`);
}
}
/** onSubmittedWorkDone 触发:该帧的 GPU 工作完成,槽位可复用。 */
markRetired(frame: number): void {
const slot = frame % this.#capacity;
if (this.#slotFrame[slot] !== frame) {
throw new FrameSyncError(`帧 ${frame} 不在槽位 ${slot} 上,无法回收`);
}
this.#slotFrame[slot] = -1;
}
get inFlight(): number {
return this.#slotFrame.filter((f) => f !== -1).length;
}
}
/**
* 策略 A:每帧 await onSubmittedWorkDone。CPU 录完一帧后阻塞,
* 等 GPU 跑完才录下一帧,CPU 与 GPU 完全串行。
*/
export function simulateSerial(costs: FrameCosts, frames: number): FrameTiming[] {
const out: FrameTiming[] = [];
let clock = 0;
for (let f = 0; f < frames; f++) {
const cpuStart = clock;
const cpuEnd = cpuStart + costs.cpuRecordMs;
const gpuStart = cpuEnd;
const gpuEnd = gpuStart + costs.gpuMs;
out.push({ frame: f, cpuStart, cpuEnd, gpuStart, gpuEnd });
clock = gpuEnd;
}
return out;
}
/**
* 策略 B:最多 maxInFlight 帧在飞,不逐帧 await。CPU 录下一帧的同时
* GPU 仍在跑上一帧,两级流水线重叠。
*/
export function simulatePipelined(
costs: FrameCosts,
frames: number,
maxInFlight: number,
): FrameTiming[] {
if (maxInFlight < 2) throw new FrameSyncError('流水线需要 maxInFlight >= 2');
const out: FrameTiming[] = [];
let cpuFree = 0;
let gpuFree = 0;
for (let f = 0; f < frames; f++) {
// CPU 是单线程资源:上一帧录完才能录这一帧;若在飞帧数达到上限,
// 还要等最老的帧在 GPU 上退役(f - maxInFlight 的 gpuEnd)。
const gate = f >= maxInFlight ? out[f - maxInFlight].gpuEnd : 0;
const cpuStart = Math.max(cpuFree, gate);
const cpuEnd = cpuStart + costs.cpuRecordMs;
const gpuStart = Math.max(cpuEnd, gpuFree);
const gpuEnd = gpuStart + costs.gpuMs;
out.push({ frame: f, cpuStart, cpuEnd, gpuStart, gpuEnd });
cpuFree = cpuEnd;
gpuFree = gpuEnd;
}
return out;
}
/** 整条时间线的墙钟耗时(最后一帧 GPU 结束时刻)。 */
export function wallClock(timings: readonly FrameTiming[]): number {
return timings.length === 0 ? 0 : timings[timings.length - 1].gpuEnd;
}
FrameRing 与模拟函数刻意分开:前者是运行时真正要用的「在飞登记表」,后者是拿来算账的确定性模型。两者共用同一套「帧槽位」心智模型,所以从模型里得到的结论能直接指导运行时代码里 K 该取几。
可运行产物:把两种策略的时间线跑出来
下面 frame-sync.test.ts 既验证 FrameRing 的槽位回收,也把两种策略的墙钟耗时断言下来。
// file: src/render/frame/frame-sync.test.ts
// tier: L1
// verify: npx vitest run src/render/frame/frame-sync.test.ts
import { describe, expect, it } from 'vitest';
import {
FrameRing,
FrameSyncError,
simulatePipelined,
simulateSerial,
wallClock,
} from './frame-sync.ts';
describe('FrameRing', () => {
it('容量内可连续 acquire', () => {
const ring = new FrameRing(3);
expect(ring.acquire()).toBe(0);
expect(ring.acquire()).toBe(1);
expect(ring.acquire()).toBe(2);
expect(ring.inFlight).toBe(3);
});
it('满员时 acquire 抛错(这就是同步点)', () => {
const ring = new FrameRing(2);
ring.acquire();
ring.acquire();
expect(() => ring.acquire()).toThrow(/需等待 onSubmittedWorkDone/);
});
it('退役后槽位可复用', () => {
const ring = new FrameRing(2);
ring.acquire(); // frame 0 -> slot 0
ring.acquire(); // frame 1 -> slot 1
ring.markRetired(0);
expect(ring.inFlight).toBe(1);
expect(ring.acquire()).toBe(0); // frame 2 复用 slot 0
expect(ring.inFlight).toBe(2);
});
it('回收未占用的帧会报错', () => {
const ring = new FrameRing(2);
expect(() => ring.markRetired(5)).toThrow(FrameSyncError);
});
it('容量必须为正', () => {
expect(() => new FrameRing(0)).toThrow(/至少为 1/);
});
});
describe('同步策略吞吐对比', () => {
const costs = { cpuRecordMs: 2, gpuMs: 8 };
it('每帧 await 时 CPU 与 GPU 完全串行', () => {
const t = simulateSerial(costs, 5);
expect(wallClock(t)).toBe(5 * (2 + 8)); // 50
// 相邻帧不重叠:下一帧 cpuStart 等于上一帧 gpuEnd。
expect(t[1].cpuStart).toBe(t[0].gpuEnd);
});
it('流水线把 CPU 录制藏进 GPU 执行', () => {
const t = simulatePipelined(costs, 5, 3);
// 理想两级流水线:首帧满额 (cpu+gpu),其后每帧只加 gpu。
expect(wallClock(t)).toBe(2 + 8 + 4 * 8); // 42
expect(wallClock(t)).toBeLessThan(wallClock(simulateSerial(costs, 5)));
// 帧 1 的 GPU 起点不早于帧 0 的 GPU 终点(GPU 串行)。
expect(t[1].gpuStart).toBeGreaterThanOrEqual(t[0].gpuEnd);
});
it('在飞上限小于 2 时流水线不成立', () => {
expect(() => simulatePipelined(costs, 3, 1)).toThrow(/maxInFlight >= 2/);
});
});
本机实跑:npx vitest run src/render/frame/frame-sync.test.ts 全绿,贡献 8 条断言,npx tsc --noEmit 零错误。以 cpuRecord=2ms、gpu=8ms 的确定性模型跑不同帧数,墙钟耗时如下(数字来自本机 node bench/render-framepipe.ts 的输出,不是估的):
| 帧数 | 每帧 await | 流水线 K=3 | 加速比 |
|---|---|---|---|
| 5 | 50 ms | 42 ms | 1.190× |
| 8 | 80 ms | 66 ms | 1.212× |
| 100 | 1000 ms | 802 ms | 1.247× |
流水线 K=3 跑 5 帧的完整时间线(毫秒):
| 帧 | CPU 录制 | GPU 执行 |
|---|---|---|
| 0 | 0–2 | 2–10 |
| 1 | 2–4 | 10–18 |
| 2 | 4–6 | 18–26 |
| 3 | 10–12 | 26–34 |
| 4 | 18–20 | 34–42 |
看第 3 帧:CPU 到 6 ms 就录完了,却要等到 10 ms 才开工——因为 K=3 的闸门要求最老的在飞帧(帧 0)在 10 ms 退役。这正是「在飞上限」的代价,也是它存在的意义:K 越小,CPU 越早被 fence 卡住;K 越大,越接近理论加速比,但输入延迟也越大。稳态下每帧墙钟趋近 max(cpu, gpu),本例是 8 ms。
同步点该放在哪
真实帧循环里,onSubmittedWorkDone 只该在两个地方等:一是帧槽位用尽时(FrameRing.acquire() 抛错那一支),二是要读回 GPU 结果时(截图、拾取、以及下一节要讲的 uniform 回读)。其它时候每帧都 await 是纯粹的浪费——它把本可重叠的 CPU 录制时间白白还给了空闲的 GPU。
工程上更常见的做法是「三缓冲 + 每帧只处理已完成的 fence」:维护 K 个槽位逐帧轮转,只有当轮转回到某个槽位、而它的 fence 还没触发时,才真正 await 那一条。这样绝大多数帧根本不阻塞,只在 GPU 偶发卡顿(编译管线、上传大纹理)时才短暂等一下。至于 queue.submit 本身,永远不要等——它本来就不阻塞。
小结
submit只入队,不阻塞;「GPU 做完了」只能靠onSubmittedWorkDone()这条 fence 知道。- 每帧 await 会让 CPU 与 GPU 串行,帧时 = 录制 + 执行;允许 K 帧在飞才能重叠两条时间线。
FrameRing把「最多 K 帧在飞」写成容量约束,满员即抛错,对应该等 fence 的时刻。
阅读导航:上一节:3.1 命令编码器与渲染通道 · 下一节:3.3 三缓冲与资源生命周期 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。