《游戏引擎实现原理》3.2 队列提交、帧同步与 fence

queue.submit 只是把命令缓冲排进 GPU 的队列,真正「做完了」要靠 fence 通知 CPU。本节说清 GPU 与 CPU 的同步点在哪,用纯 TypeScript 实现一个记录在飞帧的 FrameRing,并写出「每帧 await」与「流水线」两种策略的确定性时间线,实测出两者的吞吐差异。

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加速比
550 ms42 ms1.190×
880 ms66 ms1.212×
1001000 ms802 ms1.247×

流水线 K=3 跑 5 帧的完整时间线(毫秒):

帧CPU 录制GPU 执行
00–22–10
12–410–18
24–618–26
310–1226–34
418–2034–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 三缓冲与资源生命周期 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径