两份都「达标」的性能报告
季度评审时,消消和方舟各交了一份性能报告。消消写着「Jank 率 2.1%,达标」;方舟写着「Jank 率 1.4%,优于消消」。两份报告都附了截图,也都由性能组签字。
问题出在第三页:消消跑在 30fps,方舟跑在 60fps。同样数量的长帧,在 60fps 下要除以约两倍的总帧数,算出来的 Jank 率天然更低。1.4% 并不比 2.1% 好,它只是分母更大。
这就是性能预算最容易失效的地方:数字都在,口径不对。本节把基线整理成四组可校验的量——帧预算、内存水位、启动时间、机型分档——并给出一个能自动检查口径的脚本。
预算的四个层次
| 层次 | 单位 | 由什么决定 | 超标的直接后果 |
|---|---|---|---|
| 帧预算 | 毫秒/帧 | 目标帧率 | 掉帧、操作延迟 |
| 内存水位 | MB | 机型分档与系统余量 | 被系统杀进程 |
| 启动预算 | 秒 | 首包体积与初始化链路 | 首启流失 |
| 机型分档 | RAM 区间 | 低端机占比 | 低端机大面积不可玩 |
四层之间存在硬约束:帧率越高,单帧预算越小;内存水位越接近系统上限,降级空间越小;首包越大,启动预算越难守住。不能四项同时取最优,这正是基线要写清楚取舍的原因。
把口径写成校验
下面的脚本不测性能,它只做一件事:检查一份基线表的口径是否自洽。包括帧预算换算、水位余量、Jank 率范围,以及跨产品比较是否成立。
// file: src/resource/perf-baseline.ts
// tier: L1
// verify: node --test test/resource/perf-baseline.test.ts
import { TIER_RULES } from "./memory-budget.ts";
import type { DeviceTier } from "./memory-budget.ts";
export type ProductId = "match3" | "ark";
export interface ProductBaseline {
readonly product: ProductId;
readonly targetFps: 30 | 60;
readonly steadyLowEndMb: number;
readonly alarmMb: number;
readonly jankRatePercent: number;
readonly lowEndSharePercent: number;
}
export const BASELINES: readonly ProductBaseline[] = [
{
product: "match3",
targetFps: 30,
steadyLowEndMb: 420,
alarmMb: 520,
jankRatePercent: 2.1,
lowEndSharePercent: 35,
},
{
product: "ark",
targetFps: 60,
steadyLowEndMb: 1150,
alarmMb: 1450,
jankRatePercent: 1.4,
lowEndSharePercent: 15,
},
];
export function frameBudgetMs(fps: number): number {
return Math.round((1000 / fps) * 10) / 10;
}
export function validateBaseline(baseline: ProductBaseline): readonly string[] {
const problems: string[] = [];
if (baseline.targetFps !== 30 && baseline.targetFps !== 60) {
problems.push(`${baseline.product}: target fps must be 30 or 60`);
}
if (baseline.alarmMb <= baseline.steadyLowEndMb) {
problems.push(`${baseline.product}: alarm must be above steady memory`);
}
if (baseline.alarmMb < baseline.steadyLowEndMb * 1.2) {
problems.push(`${baseline.product}: alarm headroom below 20 percent`);
}
if (baseline.jankRatePercent < 0 || baseline.jankRatePercent > 100) {
problems.push(`${baseline.product}: jank rate out of range`);
}
if (baseline.lowEndSharePercent < 0 || baseline.lowEndSharePercent > 100) {
problems.push(`${baseline.product}: low-end share out of range`);
}
return problems;
}
export interface Comparison {
readonly comparableJank: boolean;
readonly budgetRatio: number;
readonly note: string;
}
export function compareJank(a: ProductBaseline, b: ProductBaseline): Comparison {
const comparableJank = a.targetFps === b.targetFps;
const budgetRatio = Math.round((b.targetFps / a.targetFps) * 100) / 100;
const note = comparableJank
? "same target fps: jank rates share the same denominator rule"
: "different target fps: long frames are counted over different totals, do not compare directly";
return { comparableJank, budgetRatio, note };
}
export function deviceBudgetMb(deviceRamMb: number, tier: DeviceTier): number {
const rule = TIER_RULES.find((item) => item.tier === tier);
if (!rule) {
throw new Error(`unknown device tier: ${tier}`);
}
return Math.floor((deviceRamMb * rule.budgetPercent) / 100);
}
export function headroomAgainstTier(
baseline: ProductBaseline,
deviceRamMb: number,
tier: DeviceTier,
): number {
return deviceBudgetMb(deviceRamMb, tier) - baseline.steadyLowEndMb;
}
export function formatBaselineTable(baselines: readonly ProductBaseline[]): string {
const header = "product\tfps\tbudget_ms\tsteady_mb\talarm_mb\tjank_percent";
const rows = baselines.map(
(item) =>
`${item.product}\t${item.targetFps}\t${frameBudgetMs(item.targetFps)}\t${
item.steadyLowEndMb
}\t${item.alarmMb}\t${item.jankRatePercent}`,
);
return [header, ...rows].join("\n");
}
validateBaseline 的四条规则对应四种真实事故:帧率不在 30/60 上,说明预算没有对齐目标设备;水位等于或低于稳态内存,说明告警永远不会触发;水位余量不足 20%,说明任何一次场景切换都可能越线;比例越界,说明统计口径写错了。
compareJank 是专门为「方舟优于消消」这类误读准备的。它会直接返回 comparableJank: false,并给出 budgetRatio: 2——方舟的单帧预算只有消消的一半,两者的长帧计数不能放在同一张表里比大小。
双案例基线
把两份基线放进同一张表,结构差异一目了然:
| 指标 | 消消 | 方舟 | 读法 |
|---|---|---|---|
| 目标帧率 | 30fps | 60fps | 单帧预算 33.3ms vs 16.7ms |
| 低端机稳态内存 | 420MB | 1,150MB | 方舟是消消的 2.7 倍 |
| 水位告警 | 520MB | 1,450MB | 余量分别为 24% 与 26% |
| Jank 率 | 2.1% | 1.4% | 分母不同,不可直接横比 |
| 低端机占比 | 35% | 15% | 消消的低端机风险面更大 |
再看机型分档。按 4GB 设备、低端档 35% 的预算比例计算,可用预算约 1,433MB:
- 消消稳态 420MB,余量 1,013MB,属于宽松区间;
- 方舟稳态 1,150MB,余量 283MB,已经接近低端机上沿。
案例设定里方舟低端机只占 15%,这个余量勉强可接受;如果方舟的低端机占比涨到消消的水平,1,150MB 的稳态内存会在 4GB 设备上大面积触发强降。同一个 1,150MB,在 15% 占比下是基线,在 35% 占比下是事故。
消消的约束方向则相反:它的内存宽松,但 30fps 的帧预算只有 33.3ms,且低端机占比高达 35%。它的性能工作重点不在省内存,而在保证低端机的帧预算不被活动特效吃穿。
基线怎么用
基线有三类用法,顺序不能反:
- 验收:每个版本按
validateBaseline跑一遍,任何一条规则失败就不发版。 - 比较:只在
comparableJank为真时比较 Jank 率;否则比较「长帧数 ÷ 分钟」这类同分母指标。 - 预算分配:新功能立项时先领预算——占多少毫秒、多少 MB,再谈实现方案。没有预算的功能默认不做。
第三类最容易被跳过。团队常见的做法是先做完功能,再问「性能有没有问题」;这时预算已经花超,只能靠砍功能或降画质补。基线的作用是让「砍」发生在立项时,而不是上线前。
启动预算与包体的联动
启动不属于「每帧」预算,但它和包体、内存共用同一张基线表。原因是三者互相挤压:
| 阶段 | 消消 | 方舟 | 被谁挤压 |
|---|---|---|---|
| 冷启动 P50 | 1.8s | 3.5s | 首包体积、初始化链路 |
| 冷启动 P95 | 3.2s | 6.8s | 低端机 I/O、系统调度 |
| 首帧时间(低端机 P95) | 2.6s | 5.4s | 资源加载与首屏绘制 |
| 首包 | 120MB | 350MB | 资源拆分策略 |
方舟的冷启动 P95 是消消的两倍多,直接原因是首包 350MB 对 120MB。把首包压到 200MB 可能让启动变快,但会把压力推给首次下载 —— 这是第 6 章与卷二资源章节共同处理的取舍,本节只把它记进基线表,避免启动、内存、包体三张表各说各话。
一个实用规则:冷启动 P95 超过 5 秒的产品,必须把「首帧已上屏」和「可交互」分开验收。 前者决定玩家会不会等,后者决定玩家能不能玩;两者混成一个指标,优化方向会完全错。
三行输出,一次评审
formatBaselineTable 的输出只有三行:
product fps budget_ms steady_mb alarm_mb jank_percent
match3 30 33.3 420 520 2.1
ark 60 16.7 1150 1450 1.4
评审时按列读,不要按行读:先看 fps 是否与目标一致,再看 alarm_mb - steady_mb 的余量是否超过 20%,最后确认 jank_percent 是否在同帧率下比较。三行表放进版本周报,比十页性能报告更容易发现回归。
基线表怎么维护
基线不是发布时填一次的表,它随版本滚动:
- 每个大版本重跑一次,把新数值写进表,同时保留上一版,用来看趋势。
- 机型分布每季度更新一次,低端机占比变化会直接改变风险面;方舟低端机从 15% 涨到 25%,1,150MB 的结论就要重算。
- 任何一项变差都要写原因,不能只更新数字。原因可以是「新增活动特效」「资源包变大」「系统升级」,没有原因的变差通常意味着测量口径也变了。
- 跨产品比较前先对齐口径,尤其是帧率、分母与机型档位;对不齐就只做纵向比较,不做横向排名。
与相邻章节的关系
本节依赖第 6 章的内存水位、第 8 章的机型分档与第 9 章的帧预算。那三章分别给出单点规则,本节把它们合并成一张跨产品可复核的基线表。
上一节 13.1 回答「模块结构对不对」,本节回答「数值够不够」,下一节 13.3 把两者与评审清单合并成进入卷二的门禁。
小结
- 性能预算有四层:帧预算、内存水位、启动预算、机型分档,四者不能同时取最优。
- 水位必须高于稳态内存且留出至少 20% 余量,否则告警形同虚设。
- 30fps 与 60fps 的 Jank 率分母不同,跨产品比较前必须先判断
comparableJank。 - 低端机预算按 RAM 分档计算,方舟在 4GB 设备上的余量只有 283MB,远小于消消的 1,013MB。
- 基线最重要的用法是立项时分配预算,而不是上线前验收。
- 基线表随版本滚动,每个大版本重跑,任何一项变差都要写原因。
- 启动、内存、包体共处一张表,避免三张表各说各话。
阅读导航:上一节:13.1 模块矩阵与依赖报告 · 下一节:13.3 卷一评审与进入卷二 。
常见错误与边界
- 用平均帧率代替帧预算。 平均 55fps 可能由大量 60fps 和少量 20fps 拼成;预算看的是每一帧是否超时,不是平均值。
- 把水位设成系统上限。 水位是产品可接受的上限,必须给系统和其它应用留余量;贴着系统杀进程线设置,等于把崩溃交给操作系统决定。
- 跨帧率比较 Jank 率。 这是本节反复强调的错误:分母不同,结论相反。要么对齐帧率,要么改用同分母指标。
- 只按机型平均值分档。 低端机内部也有差异;按 RAM 区间分档后,还要看同一档内的 P95,而不是只看均值。
- 新功能不领预算。 立项时不写「占多少毫秒、多少 MB」,实现阶段就会无限扩张,最后只能靠全局降质兜底。
- 把模型值当实测值。 本节所有数字都是案例设定,用于演练口径;真机上的内存与帧率必须自己测,且要区分设备型号与系统版本。
- 只校验单个产品。 基线表的另一半价值在跨产品比较;只跑
validateBaseline而不跑compareJank,会漏掉口径不一致的问题。 - 把低端机预算当成固定值。 预算比例随 RAM 档位变化,4GB、6GB、8GB 设备的可用内存不同;用同一个数字套所有机型,低端机会被高估。
- 忽略系统版本差异。 同一机型升级系统后,内存回收与调度策略都会变;基线表应记录系统版本区间,否则趋势会失真。
- 只看一次快照。 单次报告只能说明当时状态;没有历史曲线,就无法区分「一直如此」和「本版本刚变差」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。