8.1 性能预算达标
前面七章各自解决了一件事:怎么打包(第 1 章)、怎么测(第 2 章)、怎么剖析性能(第 3 章)、怎么跨端降级(第 4 章)、怎么模块化裁剪(第 5 章)、怎么做调试工具(第 6 章)、怎么把《微光岛》做完(第 7 章)。但每个零件都合格,不等于整机合格。这一节把散落在前七章里的可量化验收点收敛成一张性能预算表,写一个门禁脚本在本机逐项实测,然后如实报告:哪几项达标,哪一项没达标,为什么。
这是第 8 章第 1 节,也是「综合项目(二)」的收口起点。文中所有数字都来自本机实跑(darwin/arm64,Node 26.5.0,机器上同时有其它进程,故同机重跑会有抖动,测量条件写在每段输出旁)。本节给出一份可复现的报告,而不是一组拍脑袋的漂亮数字——四项里有一项就是要如实标成未达标。
本章与站内既有内容的分工
站内已有文章讲性能剖析的方法与工具,本节不复述那些方法,只做一件事:给《微光岛》的交付物定一张可核对的预算表,并给出真实数字。分工如下。
| 站内既有内容 | 它讲什么 | 本节《工程与实战》做什么 |
|---|---|---|
/graphics-gpu-profiling-debugging/ | GPU 性能分析的方法论 | 在真实 helio 场景上量帧时间与绘制调用,不做方法论科普 |
/graphics-anti-aliasing-methods/ | 抗锯齿算法选型 | 不选算法,只把已选算法的开销计入帧预算 |
/game/engine/
顶层综述 | 引擎选型与架构综述 | 不选型,直接给已交付的引擎定门禁 |
/posts/typescript/ | TypeScript 语言与工程 | 用 Node 直跑的门禁脚本,产出真实字节与毫秒 |
换句话说:讲「怎么剖析 GPU」的去看 graphics-gpu-profiling-debugging;讲「该不该自己写引擎」的去看 game/engine;而「这一版《微光岛》的四项预算到底过没过」——这是本节的事。
有一个边界要再钉一次:本节不做优化。第 3 章已经走完了「测—改—复测」的优化闭环(3.1 帧剖析、3.2 GC 与内存、3.3 GPU 计时)。本节站在交付的位置上,只做一件事——把那些优化结果固化成可反复核对的数字。预算表的价值不在第一次跑,而在每一次回归都能自动重跑,把「是不是悄悄变慢了」这个问题从「凭感觉」变成「看报告」。
预算表:四个维度先定死
预算的第一原则是先定后测。测完再倒推预算,等于没有预算。所以先把四个维度、四个上限写进一个 L1 模块,它只做两件事:存预算值、逐项判定。
下面是 src/perf/budget.ts,L1,用 vitest 跑。
// file: src/perf/budget.ts
// tier: L1
// verify: npx vitest run src/perf/budget.test.ts
//
// helio《微光岛》性能预算:预算表 + 逐项判定。
// B 基准层:超预算只标记 WARN,不抛错——性能随机器抖动,硬失败会误伤 CI。
/** 四个维度的预算上限(项目决策值,不是实测值)。 */
export interface Budget {
readonly frameMs: number;
readonly memoryMiB: number;
readonly drawCalls: number;
readonly bundleKiB: number;
}
export const BUDGET: Budget = {
frameMs: 16.67, // 60Hz 一帧
memoryMiB: 64, // 运行时场景常驻堆
drawCalls: 1024, // 单帧绘制调用
bundleKiB: 96, // 首包 gzip 后
};
export interface BudgetLine {
readonly dim: string;
readonly value: number;
readonly budget: number;
}
export type Verdict = 'PASS' | 'WARN';
export interface BudgetResult extends BudgetLine {
readonly verdict: Verdict;
}
/** 逐项判定:value <= budget 即 PASS,否则 WARN(软告警,不抛错)。 */
export function evaluate(lines: readonly BudgetLine[]): BudgetResult[] {
return lines.map((l) => ({ ...l, verdict: l.value <= l.budget ? 'PASS' : 'WARN' }));
}
/** 最近邻百分位(与 FrameTimer.percentile 同口径)。 */
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))]!;
}
四个上限各自的来历:帧时间 16.67ms 是 60Hz 的硬指标,且用 p95 而不是均值——均值会被少数好帧拉平,p95 才反映卡顿;内存 64MiB 是移动端低端机的经验红线(第 4 章 4.2 给过降级路径);绘制调用 1024 来自第 3 章 3.3 的合批结论,合批后应稳定在百量级;包体 96KiB gzip 是首包在 4G 下的可接受加载量(第 1 章 1.3 讨论过分包)。
判定逻辑虽小,但它是整张预算表的「判决书」,必须有测试兜住。下面 src/perf/budget.test.ts 覆盖三点:边界判定、软告警不抛错、百分位口径。
BUDGET 里的四个值也不是随手写的,它们各自能追到前面的章节:帧时间追到第 3 章 3.1 的帧预算模型,内存追到 3.2 的 GC 追踪,绘制调用追到 3.3 的合批闭环,包体追到第 1 章 1.3 的分包。本节只是把它们并到一张表里统一判定,值本身不重新发明。
// file: src/perf/budget.test.ts
// tier: L1
// verify: npx vitest run src/perf/budget.test.ts
import { describe, it, expect } from 'vitest';
import { BUDGET, evaluate, percentile } from './budget.ts';
describe('budget', () => {
it('value <= budget 判 PASS,超预算判 WARN', () => {
const r = evaluate([
{ dim: 'frame', value: 3.6, budget: 16.67 },
{ dim: 'draws', value: 30000, budget: 1024 },
]);
expect(r.map((x) => x.verdict)).toEqual(['PASS', 'WARN']);
});
it('B 层软告警:超预算也返回结果而不抛错', () => {
expect(() => evaluate([{ dim: 'x', value: 999, budget: 1 }])).not.toThrow();
expect(evaluate([{ dim: 'x', value: 999, budget: 1 }])[0]!.verdict).toBe('WARN');
});
it('percentile 取最近邻,越界收敛到末位,空数组返回 0', () => {
expect(percentile([1, 2, 3, 4], 0.5)).toBe(3);
expect(percentile([1, 2, 3, 4], 1)).toBe(4);
expect(percentile([], 0.5)).toBe(0);
});
it('预算表四字段齐全', () => {
expect(Object.keys(BUDGET).sort()).toEqual(['bundleKiB', 'drawCalls', 'frameMs', 'memoryMiB']);
});
});
跑 npx vitest run src/perf/budget.test.ts,4 条全过。注意第三个用例特意锁死了「最近邻百分位」这个口径:percentile([1,2,3,4], 0.5) 返回 3 而不是 2.5,因为门禁报的是「一个真实发生过的帧有多慢」,插值出来的 2.5 是一个不存在的帧。
可运行产物:预算门禁
有了预算表,门禁脚本把四个维度分别量出来。帧时间是「逻辑 tick + 渲染装配」逐帧计时取分位;内存是持有一整个发布场景的堆增量;绘制调用是合批后的 draw 次数(另附未合批基线作反例);包体是生产构建 minify + gzip 后的字节数。
下面是 tools/budget-gate.mjs,B 基准层,Node 直跑。
// file: tools/budget-gate.mjs
// tier: B
// verify: node --expose-gc tools/budget-gate.mjs
//
// helio《微光岛》性能预算门禁(B 基准层,软告警不硬失败)。
// 帧时间 / 内存 / 绘制调用 / 包体四个维度各自实测,与 BUDGET 逐项对照,输出表格。
import { gzipSync } from 'node:zlib';
import { build } from 'vite';
import { BUDGET, evaluate, percentile as pct } from '../src/perf/budget.ts';
import { Kernel, MoveSubsystem, TimeSubsystem } from '../src/core/kernel.ts';
import { makeScene } from '../src/render/app/render-profiling.ts';
import { assembleFrame } from '../src/render/app/renderer-assembly.ts';
import { lookAt, multiply, perspective } from '../src/render/culling/frustum.ts';
import { buildBatches, makeBatchItems, packBatchKey, summarize } from '../src/render/sort/batch.ts';
import { radixSortKeys } from '../src/render/sort/sort-key.ts';
const N = 30000, MESHES = 64, FRAMES = 300, WARMUP = 40;
// 1) 帧时间:逻辑 tick + 渲染装配,逐帧计时取分位。
function frame() {
const vp = multiply(perspective((60 * Math.PI) / 180, 16 / 9, 0.1, 200), lookAt([0, 6, 24], [0, 0, 0], [0, 1, 0]));
const scene = makeScene(N, 7, 30);
const k = new Kernel({ frameArenaBytes: 32 * 1024, stepSeconds: 1 / 60, maxStepsPerFrame: 4 });
k.register(new TimeSubsystem());
k.register(new MoveSubsystem(k.world));
for (let i = 0; i < 512; i++) k.world.spawn(`e${i}`, 0, 0, 0);
k.boot();
const ms = [];
for (let i = 0; i < WARMUP + FRAMES; i++) {
const t = performance.now();
k.tick(1 / 60);
assembleFrame(scene, vp, [0, 6, 24], 200);
if (i >= WARMUP) ms.push(performance.now() - t);
}
k.shutdown();
const s = [...ms].sort((a, b) => a - b);
return { median: pct(s, 0.5), p95: pct(s, 0.95) };
}
// 2) 内存:持有一整个发布场景的堆增量(process.memoryUsage)。
function memory() {
globalThis.gc?.();
const before = process.memoryUsage().heapUsed;
const scene = makeScene(N, 7, 30);
const items = makeBatchItems(N, 21, MESHES, 0.08);
const order = radixSortKeys(Uint32Array.from(items, packBatchKey), N);
const after = process.memoryUsage().heapUsed;
return { heapDeltaMiB: (after - before) / 1048576, sink: scene.length + order.length };
}
// 3) 绘制调用:合批后 vs 未合批基线。
function draws() {
const items = makeBatchItems(N, 21, MESHES, 0.08);
const order = radixSortKeys(Uint32Array.from(items, packBatchKey), N);
return { batched: summarize(buildBatches(items, order)).drawCalls, naive: N };
}
// 4) 包体:与 tools/build-prod.mjs 同款 vite 生产配置(minify + 内容哈希)+ gzip。
async function bundle() {
const r = await build({
root: process.cwd(), logLevel: 'error',
build: { write: false, minify: true, rollupOptions: {
input: new URL('../src/apps/glimmer-isle-demo.ts', import.meta.url).pathname,
output: { entryFileNames: 'assets/[name]-[hash].js', format: 'es' } } },
});
const chunks = (Array.isArray(r) ? r[0] : r).output.filter((c) => c.type === 'chunk');
return chunks.reduce((a, c) => ({ raw: a.raw + Buffer.byteLength(c.code), gzip: a.gzip + gzipSync(Buffer.from(c.code)).byteLength }), { raw: 0, gzip: 0 });
}
const f = frame(), m = memory(), d = draws(), b = await bundle();
const rows = evaluate([
{ dim: '帧时间 p95', value: f.p95, budget: BUDGET.frameMs },
{ dim: '内存常驻', value: m.heapDeltaMiB, budget: BUDGET.memoryMiB },
{ dim: '绘制调用(合批)', value: d.batched, budget: BUDGET.drawCalls },
{ dim: '绘制调用(基线)', value: d.naive, budget: BUDGET.drawCalls },
{ dim: '首包 gzip', value: b.gzip / 1024, budget: BUDGET.bundleKiB },
]);
console.table(rows);
console.log(`frame median=${f.median.toFixed(2)}ms bundle raw=${b.raw}B gzip=${b.gzip}B`);
console.log('=== budget-gate: 软告警,exit 0 ===');
跑 node --expose-gc tools/budget-gate.mjs。加 --expose-gc 是为了在内存测量前主动触发一次 GC,把上一段的垃圾清掉,否则堆增量会把噪声算进去。
门禁把四个维度都做成了「可复现的纯计算 + 一次真实构建」:帧时间与绘制调用是确定性的(同一个场景、同一份输入),所以每次跑结果稳定;内存与包体是近确定性的,前者受 GC 时机影响会抖,后者由源码内容决定、逐字节稳定。这种「大部分确定、小部分抖动」的组合,正是 B 基准层要的形态——抖动的部分只告警,确定的部分才有资格当硬指标。
值得强调的一点是,门禁不 import 任何 GPU API。它量的是 CPU 侧的装配与逻辑,GPU 那一半(真实管线耗时、显存占用)需要 headless Chromium 甚至真机,属于 L2/L4。把能自动化的部分与不能自动化的部分在脚本层面就切开,是这套预算能长期跑下去的前提。
实测对照:三项达标,一项未达标
本机一次规范运行(darwin/arm64,Node 26.5.0)的结果:
| 维度 | 实测 | 预算 | 判定 |
|---|---|---|---|
| 帧时间 p95 | 3.49 ms(median 2.66 ms) | 16.67 ms | PASS |
| 内存常驻 | 12.21 MiB | 64 MiB | PASS |
| 绘制调用(合批后) | 128 次 | 1024 次 | PASS |
| 绘制调用(未合批基线) | 30000 次 | 1024 次 | WARN |
| 首包 gzip | 2502 B(2.44 KiB) | 96 KiB | PASS |
表中「实测」一列是同一台机器上一次运行的读数,不是多次平均;下一列的预算是写死在 BUDGET 里的常量,运行门禁不会改动它。这种「预算冻结、实测浮动」的安排,正是为了让人一眼看出哪一项在预算的哪个位置。
三项达标:帧时间 p95 只用了预算的两成——注意这是 CPU 侧的装配 + 逻辑耗时,真实 GPU 耗时是 L4(见下节说明),命令行量不出来。换句话说,p95 3.49ms 只能证明「CPU 侧给 GPU 留够了余量」,不能证明「GPU 侧不超时」。内存 12.21 MiB 远低于 64 MiB,同机重跑在 11.4–12.2 MiB 之间抖动。包体 2.44 KiB 更是小得反常,因为《微光岛》当前入口只是一个确定性 demo,真正的资源(贴图、网格)还没接进来——这个数字现在达标不代表上线达标。
一项未达标:未合批基线的 30000 次绘制调用,超预算近 30 倍。这不是缺陷,而是故意留在表里的反例:draws() 里的 naive: N 就是「每个对象一次 draw」的老路,发布配置走的是合批路径(128 次)。把两者并排放在预算表里,是为了守住一条纪律——合批不是可选项。哪一天有人为了图省事把合批关掉,基线这一行会立刻从 WARN 变成真正的失败。
这里有个取舍值得说清:为什么不干脆把这条基线从预算表里删掉,只留达标的三项?因为它记录的是一个真实的决策点。第 3 章 3.3 花了一整节证明「排序不省命令、合批才省」,如果交付时这张表里只有漂亮的 128,那段结论就失去了落点。让未达标项留在表里,是让读者(以及未来的自己)看得见「如果不做这件事会怎样」。
另外要诚实说明两点。其一,合批后的 128 = 64 opaque + 64 transparent,正好等于场景里的网格数 MESHES=64;透明项因逐实例按深度排序而无法跨网格合并(第 3 章 3.3 讲过)。其二,帧时间的绝对值不具跨机可比性,本节只关心「与预算的比值」,不宣称这是《微光岛》在任何机器上的帧率。
还要交代一个容易被误读的点:内存 12.21 MiB 是「增量」不是「总量」。memory() 记的是持有场景前后 heapUsed 的差,不含 Node 运行时自身的基础占用(那部分约 30–40 MiB,与游戏无关)。用增量而不是总量,是为了让这个数字只反映「场景数据本身有多大」,从而可跨机器比较。同理,帧时间 p95 用的是 300 帧窗口里的第 285 帧耗时,前 40 帧热身不计入,避免 JIT 预热阶段的慢帧污染分位。
把这两点合起来看,这份报告回答的其实是一个很窄的问题:《微光岛》的场景数据在 CPU 侧的固定开销,离预算还有多远。它不回答「上线后玩家手机上能不能跑满 60 帧」——那个问题只有真机能答,属于 L4。
B 层:为什么超预算只告警不失败
这个门禁被标为 B(基准)层,它的退出码恒为 0,超预算只打印 WARN。原因有三条。
第一,性能数字随机器和负载抖动。同一台机器上,内存项在 11.4–12.2 MiB 之间波动,帧时间 p95 在 3.5–3.7 ms 之间波动。把这种抖动接进 CI 当硬失败,只会得到一批 flaky 的红色构建。
第二,L4 的部分命令行量不出来。真实的 GPU 耗时、真机帧率、低端手机上的内存占用,都需要真机与平台 SDK,属于 L4——本节需在真实设备 / 浏览器中手动验证,命令行不可复现。能自动化的只有 CPU 侧的装配、堆增量与包体。
第三,预算的作用是趋势预警而非一次判决。正确的用法是把每次运行的报告存下来,看曲线是平的还是往上走的;一旦某一项持续逼近上限,才人工介入。
这三条合起来,定义了一个「诚实的门禁」该长什么样:它不假装自己能替代真机,也不假装性能是确定的。它只承诺一件事——把本机能确定地量出来的东西量准,并如实说清哪些没量。反过来,一个「什么都敢断言、什么都硬失败」的门禁,通常既跑不稳,也没人信。
所以 B 层的产出不是「过 / 不过」,而是一份带 WARN 的报告。把它接进流水线时,正确的做法是「存报告、看趋势、人工复核」,而不是「红了就拦」。这一点和第 2 章的测试金字塔是同一种思路:越靠近硬件的断言越不可靠,就越不该当硬门禁;真正该硬的是确定性回放(2.3)那种逐位可复现的检查。
到这里,性能这一维度收口了:三项达标、一项是刻意保留的反例、GPU 与真机的部分明确交给 L4。下一节把镜头从「量」转到「交付」——把这一版产物真正构建出来,生成带内容哈希的清单,并说清哪一步是本机可跑的、哪一步必须上真机。
阅读导航:上一节:回到目录 · 下一节:8.2 构建与部署 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。