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、内存预算与分配追踪 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。