《游戏引擎实现原理》3.1 帧预算与性能剖析

帧预算是性能工程的第一把尺。本节把一帧拆成 logic/submit/present 三阶段,从零实现可注入时钟的 FrameProfiler,在 Node 里真跑 600 帧粒子负载,读出中位、p95、p99 与超预算比例,再用 CPU/GPU 流水线模型说清一帧里谁在等谁。

3.1 帧预算与性能剖析

性能工程的第一步不是优化,而是测量。没有一把可信的尺子,所有「优化」都是猜。这一节先把一帧拆成可测量的阶段,再从零写一个帧剖析器,然后在 Node 里真跑 600 帧、读出分布,最后用 CPU/GPU 流水线模型说清楚:一帧里到底是 CPU 在等 GPU,还是反过来。

这是全书第 3 章第 1 节。代码用 TypeScript,跑在 Node 26 的 type-stripping 模式下,全部可复现。文中所有毫秒数字都来自本机实跑(darwin/arm64,Node 26.5.0,机器同时有其它进程在跑,故同机重跑会有抖动),测量条件写在每段输出旁。

本章与站内既有内容的分工

站内已有文章讲性能剖析的方法与工具,本卷不复述那些方法,只做一件事:把前三卷的 helio 引擎工程化跑起来,并给出真实数字。分工如下。

既有文章它负责什么本节只做什么
/graphics-gpu-profiling-debugging/GPU 剖析与调试的方法论只把「GPU 段」记成帧预算里的一段耗时
/posts/graphics/渲染管线与 GPU 执行模型不重推管线,只关心它在时间线上占多少
/posts/game/engine/引擎选型综述不选型,直接给本引擎加剖析器
/game-engine-architecture-ecs/一篇讲清 ECS 引擎骨架只测骨架主循环一圈花多少毫秒
/posts/books/引擎内核/卷一「帧、时间与帧预算」的原理原理不重复,本节做工程化的采样与分位数

外链用的是产物里真实存在的 URL:普通文章被 permalink 规则压到根级 /<slug>/,专题 section 页留在 /posts/<topic>/。

帧时间的分解与 CPU/GPU 时间线

把一帧按时间顺序切开,CPU 侧有两段、GPU 侧有一段:

  • logic:输入采样、ECS 系统、动画求值、物理积分,跑在主线程。
  • submit:把这一帧要画的东西整理成命令,压给图形 API,仍在 CPU 侧。
  • present:交换链 present 与垂直同步等待。

关键认知是:GPU 段与 CPU 段是并行流水线。CPU 在跑第 N+1 帧的 logic/submit 时,GPU 还在执行第 N 帧的命令。所以一帧的墙钟时间约等于 max(CPU 段之和, GPU 段),而不是各段相加。谁更大,谁就是瓶颈——这一点直接决定优化该往哪使劲。

卷一已经把这个模型讲清楚了(见 helio-kernel-02-1-frame-time-budget )。本节复用卷二的流水线模拟器 simulateTimeline,把三组 CPU/GPU 配比跑一遍,看稳态帧率与 CPU 阻塞时间(结果见下文)。它回答的是同一个问题:CPU 与 GPU,谁在等谁。

可运行产物:FrameProfiler 与 600 帧真实负载

先写剖析器。第一原则是时钟必须可注入:所有时间都从构造参数给的 NowFn 取,默认包 performance.now();测试传假时钟,就能把「采样逻辑」和「真实计时」彻底解耦,几毫秒跑完几百帧的断言。

// file: src/perf/frame-profiler.ts
// tier: L1
// verify: npx vitest run src/perf/frame-profiler.test.ts
//
// helio 工程卷 3.1:帧剖析器。
// 用 performance.now() 采样每帧各阶段(logic / submit / present …)耗时,给出
// 中位 / p95 / p99 / 峰值 与超预算统计。核心信条:平均值会骗人,长尾才决定掉帧,
// 所以永远同时给分位数。时钟通过构造参数注入,测试可传假时钟解耦「采样」与「计时」。

/** 取当前时刻(毫秒)。默认包 performance.now(),测试注入假时钟。 */
export type NowFn = () => number;

export interface PhaseSummary {
  readonly name: string;
  readonly samples: number;
  readonly totalMs: number;
  readonly avgMs: number;
  readonly p50Ms: number;
  readonly p95Ms: number;
  readonly p99Ms: number;
  readonly maxMs: number;
}

export interface FrameSummary {
  readonly frames: number;
  readonly budgetMs: number;
  readonly overBudget: number;
  readonly overBudgetRatio: number;
  readonly p50Ms: number;
  readonly p95Ms: number;
  readonly p99Ms: number;
  readonly maxMs: number;
}

/** 最近邻百分位(sorted 需升序,p ∈ [0,1])。 */
export function percentile(sorted: readonly number[], p: number): number {
  if (sorted.length === 0) return 0;
  return sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))]!;
}

const r4 = (x: number): number => Math.round(x * 10000) / 10000;

/** 帧剖析器:阶段计时 begin/end(可嵌套)与整帧计时 beginFrame/endFrame 混用。 */
export class FrameProfiler {
  readonly #now: NowFn;
  readonly #budgetMs: number;
  readonly #phases = new Map<string, number[]>();
  readonly #frameMs: number[] = [];
  readonly #stack: { name: string; start: number }[] = [];
  #frameStart = 0;
  #frameOpen = false;

  constructor(budgetMs = 1000 / 60, now: NowFn = () => performance.now()) {
    if (!(budgetMs > 0)) throw new RangeError(`budgetMs 必须为正,收到 ${budgetMs}`);
    this.#budgetMs = budgetMs;
    this.#now = now;
  }

  beginFrame(): void {
    if (this.#frameOpen) throw new Error('beginFrame 重复调用:上一帧还没 endFrame');
    this.#frameOpen = true;
    this.#frameStart = this.#now();
  }

  endFrame(): number {
    if (!this.#frameOpen) throw new Error('endFrame 之前必须先 beginFrame');
    const d = this.#now() - this.#frameStart;
    this.#frameOpen = false;
    this.#frameMs.push(d);
    return d;
  }

  begin(name: string): void {
    this.#stack.push({ name, start: this.#now() });
  }

  end(): number {
    const top = this.#stack.pop();
    if (top === undefined) throw new Error('end() 时阶段栈为空:begin/end 没有配对');
    const d = this.#now() - top.start;
    const bucket = this.#phases.get(top.name);
    if (bucket) bucket.push(d);
    else this.#phases.set(top.name, [d]);
    return d;
  }

  /** 计时一段同步代码;异常也会在 finally 里正确配对。 */
  scope<T>(name: string, fn: () => T): T {
    this.begin(name);
    try {
      return fn();
    } finally {
      this.end();
    }
  }

  phase(name: string): PhaseSummary {
    const raw = this.#phases.get(name) ?? [];
    const sorted = [...raw].sort((a, b) => a - b);
    let total = 0;
    for (const v of raw) total += v;
    return {
      name, samples: raw.length, totalMs: r4(total),
      avgMs: r4(raw.length === 0 ? 0 : total / raw.length),
      p50Ms: r4(percentile(sorted, 0.5)), p95Ms: r4(percentile(sorted, 0.95)),
      p99Ms: r4(percentile(sorted, 0.99)),
      maxMs: r4(sorted.length === 0 ? 0 : sorted[sorted.length - 1]!),
    };
  }

  frame(): FrameSummary {
    const sorted = [...this.#frameMs].sort((a, b) => a - b);
    let over = 0;
    for (const v of this.#frameMs) if (v > this.#budgetMs) over++;
    return {
      frames: this.#frameMs.length, budgetMs: this.#budgetMs, overBudget: over,
      overBudgetRatio: r4(this.#frameMs.length === 0 ? 0 : over / this.#frameMs.length),
      p50Ms: r4(percentile(sorted, 0.5)), p95Ms: r4(percentile(sorted, 0.95)),
      p99Ms: r4(percentile(sorted, 0.99)),
      maxMs: r4(sorted.length === 0 ? 0 : sorted[sorted.length - 1]!),
    };
  }

  /** 帧耗时直方图(稠密数组,空桶补 0),用于看长尾。 */
  histogram(bucketMs: number): number[] {
    if (!(bucketMs > 0)) throw new RangeError(`bucketMs 必须为正,收到 ${bucketMs}`);
    const counts: number[] = [];
    let maxBucket = -1;
    for (const v of this.#frameMs) {
      const b = Math.floor(v / bucketMs);
      counts[b] = (counts[b] ?? 0) + 1;
      if (b > maxBucket) maxBucket = b;
    }
    const out = new Array<number>(maxBucket + 1).fill(0);
    for (let b = 0; b <= maxBucket; b++) out[b] = counts[b] ?? 0;
    return out;
  }
}

类型检查:npx tsc --noEmit;单测:npx vitest run src/perf/frame-profiler.test.ts(10 个用例全过)。

光有工具不够,还得喂一个真会算的负载。下面这个基准(// tier: B)每帧真做 150 万个粒子的积分(Float32Array 上的 SoA 遍历),再构建一次绘制列表;每 97 帧来一次「重帧」,把积分多跑 3 遍——这是真实多算,不是往数据里注水,它制造出天然的长尾。

// file: tools/frame-profiler-bench.mjs
// tier: B
// verify: node tools/frame-profiler-bench.mjs
//
// helio 工程卷 3.1 基准:帧预算与性能剖析。
// 一个「每帧真算粒子积分 + 构建绘制列表」的负载,跑 600 帧,用 FrameProfiler 采样
// logic / submit / present 三阶段;再用 frame-timeline 的 CPU/GPU 流水线模型说明
// 「谁在等谁」。数字全部本机实跑(darwin/arm64,Node 26.5.0)。

import { FrameProfiler } from '../src/perf/frame-profiler.ts';
import { simulateTimeline } from '../src/render/frame-timeline.ts';

const FRAMES = 600;
const BASE = 1500000; // 每帧粒子数
const SPIKE_EVERY = 97; // 每 97 帧一次「重帧」:积分多跑 3 遍(真实多算,不是假数据)
const DT = 1 / 60;

function makeParticles(n) {
  const pos = new Float32Array(n * 3);
  const vel = new Float32Array(n * 3);
  let s = 0x9e3779b9 >>> 0;
  const rnd = () => ((s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 4294967296);
  for (let i = 0; i < n * 3; i++) { pos[i] = (rnd() * 2 - 1) * 100; vel[i] = (rnd() * 2 - 1) * 10; }
  return { pos, vel };
}

function integrate(pos, vel, n, dt) {
  for (let i = 0; i < n; i++) {
    const j = i * 3;
    pos[j] += vel[j] * dt; pos[j + 1] += vel[j + 1] * dt; pos[j + 2] += vel[j + 2] * dt;
    vel[j + 1] -= 9.8 * dt;
  }
}

const POOL = makeParticles(BASE);
const order = new Uint32Array(BASE);
function buildDrawList(pos, n) {
  let k = 0;
  for (let i = 0; i < n; i++) if (pos[i * 3 + 1] > -50) order[k++] = i;
  return k;
}

const prof = new FrameProfiler(1000 / 60);
for (let f = 0; f < FRAMES; f++) {
  const passes = f % SPIKE_EVERY === 0 ? 4 : 1;
  prof.beginFrame();
  prof.scope('logic', () => { for (let p = 0; p < passes; p++) integrate(POOL.pos, POOL.vel, BASE, DT); });
  prof.scope('submit', () => buildDrawList(POOL.pos, BASE));
  prof.scope('present', () => {});
  prof.endFrame();
}

console.log(`=== 阶段耗时(毫秒,${FRAMES} 帧,每 ${SPIKE_EVERY} 帧一次重帧)===`);
console.log('phase     n      avg      p50      p95      p99      max');
for (const name of ['logic', 'submit', 'present']) {
  const s = prof.phase(name);
  console.log(`${name.padEnd(8)}  ${String(s.samples).padEnd(5)}  ${s.avgMs.toFixed(3).padStart(8)}  ${s.p50Ms.toFixed(3).padStart(8)}  ` +
    `${s.p95Ms.toFixed(3).padStart(8)}  ${s.p99Ms.toFixed(3).padStart(8)}  ${s.maxMs.toFixed(3).padStart(8)}`);
}
const fr = prof.frame();
console.log(`\n整帧:frames=${fr.frames} budget=${fr.budgetMs.toFixed(2)}ms over=${fr.overBudget} ` +
  `(${(fr.overBudgetRatio * 100).toFixed(1)}%) p50=${fr.p50Ms}ms p95=${fr.p95Ms}ms p99=${fr.p99Ms}ms max=${fr.maxMs}ms`);
const hist = prof.histogram(2);
for (let b = 0; b < hist.length; b++) if (hist[b]) console.log(`  [${b * 2}-${b * 2 + 2}ms)  ${'#'.repeat(Math.ceil(hist[b] / 4))} ${hist[b]}`);

console.log('\n=== CPU/GPU 流水线模型(frame-timeline)===');
console.log('case        steadyFps  avgLatency(ms)  cpuStall(ms)');
for (const c of [{ l: 'CPU 超预算', cpuMs: 18, gpuMs: 6 }, { l: 'GPU 超预算', cpuMs: 6, gpuMs: 18 }, { l: '均衡留余量', cpuMs: 8, gpuMs: 8 }]) {
  const r = simulateTimeline({ cpuMs: c.cpuMs, gpuMs: c.gpuMs, buffers: 2, frames: 600 });
  console.log(`${c.l.padEnd(10)}  ${r.steadyFps.toFixed(1).padStart(8)}  ${r.avgLatencyMs.toFixed(2).padStart(13)}  ${r.cpuStallPerFrameMs.toFixed(2).padStart(10)}`);
}

验证命令:node tools/frame-profiler-bench.mjs。

真跑出来的分布与长尾

下面是本机实跑一次的原始输出(600 帧,60Hz 预算 16.67ms):

=== 阶段耗时(毫秒,600 帧,每 97 帧一次重帧)===
phase     n      avg      p50      p95      p99      max
logic     600     4.695    4.461    4.833   17.893   30.158
submit    600     4.564    3.520    8.468    8.644    9.300
present   600     0.001    0.001    0.001    0.003    0.022

整帧:frames=600 budget=16.67ms over=7 (1.2%) p50=8.093ms p95=12.9424ms p99=19.7706ms max=37.9115ms
  [6-8ms)   296
  [8-10ms)  41
  [10-12ms) 152
  [12-14ms) 103
  [14-16ms) 1
  [18-20ms) 2
  [20-22ms) 2
  [26-28ms) 1
  [28-30ms) 1
  [36-38ms) 1

读数有三个要点。第一,中位 8.09ms 远低于预算 16.67ms,但 p99 已经 19.77ms——如果只看平均值(logic 4.70 + submit 4.56 ≈ 9.3ms)会以为余量充足,实际每 100 帧就有一帧踩线。第二,logic 的 p50 是 4.46ms、p99 却是 17.89ms,差约 4 倍:这就是每 97 帧一次的「重帧」制造的长尾,直方图右端那一串孤立的 1 才是掉帧元凶。第三,超预算只有 7 帧(1.2%),与 600/97≈6.2 次重帧吻合——说明超预算完全由重帧驱动,而不是普遍性劣化,优化目标因此非常明确:削掉重帧的尖峰,而不是去抠 logic 的均速。

CPU/GPU 流水线模型这一半给出对照(同一台机器,纯模型计算,无抖动):

case        steadyFps  avgLatency(ms)  cpuStall(ms)
CPU 超预算         55.6          24.00        0.00
GPU 超预算         55.6          36.00       12.00
均衡留余量          125.0          16.00        0.00

两组「超预算」都掉到 55.6fps(1000/18),但形态不同:CPU 超预算时 CPU 自己忙到 18ms,cpuStall=0;GPU 超预算时 CPU 每帧要空转 12ms 等 GPU——同样是掉帧,成因不同,解法也完全不同。这正是剖析器要把 CPU 段与 GPU 段分开记的原因。下一节把镜头转向内存:帧时间稳住了,GC 的一次 Stop-The-World 照样能让它抖起来。

阅读导航:上一节:回到目录 · 下一节:3.2 GC、内存预算与分配追踪 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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