3.2 GC、内存预算与分配追踪
上一节把帧时间拆开看,发现长尾才是掉帧元凶。长尾最常见的来源之一,就是垃圾回收:一次 Stop-The-World 暂停,足以让一帧凭空多出十几毫秒。而要治 GC,先得把账本读出来——这一帧到底分配了多少、谁分配的、有多少变成了垃圾。
这是全书第 3 章第 2 节。代码用 TypeScript / Node 26 直跑,所有内存与 GC 数字都来自本机实跑(darwin/arm64,Node 26.5.0,node --expose-gc)。
内存预算:HeapSampler 与分配记账
process.memoryUsage() 给的是裸字节数,读起来累;我们把它包成 MB 快照并支持求差。但快照只能告诉你「堆涨了多少」,说不清「是谁涨的」。所以再加一块 AllocCounter:让调用方在每次分配时按子系统标签记一笔账,帧末汇总——JS 没有分配钩子,热点定位只能靠显式账本,这也是团队约定:所有会进热路径的分配都必须记账。
// file: src/perf/alloc-profiler.ts
// tier: L1
// verify: npx vitest run src/perf/alloc-profiler.test.ts
//
// helio 工程卷 3.2:内存预算与分配追踪。
// HeapSampler:把 process.memoryUsage() 的裸字节包成 MB 快照,支持求差;
// AllocCounter:按子系统标签统计「每帧分配了多少笔、多少字节」,把 GC 压力
// 定位到具体子系统。宿主 API 通过构造参数注入,故本模块纯逻辑、可跨平台复用。
import type { MemoryTag } from '../core/memory-tags.ts';
/** process.memoryUsage() 的裸字节数。 */
export interface RawMemoryUsage {
readonly heapUsed: number;
readonly heapTotal: number;
readonly external: number;
readonly arrayBuffers: number;
readonly rss: number;
}
export type MemoryReader = () => RawMemoryUsage;
export interface HeapSnapshot {
readonly heapUsedMB: number;
readonly heapTotalMB: number;
readonly externalMB: number;
readonly arrayBuffersMB: number;
readonly rssMB: number;
}
export interface HeapDelta {
readonly heapUsedMB: number;
readonly externalMB: number;
readonly rssMB: number;
}
const mb = (bytes: number): number => Math.round((bytes / 1048576) * 1000) / 1000;
export class HeapSampler {
readonly #read: MemoryReader;
constructor(read: MemoryReader) { this.#read = read; }
sample(): HeapSnapshot {
const m = this.#read();
return {
heapUsedMB: mb(m.heapUsed), heapTotalMB: mb(m.heapTotal), externalMB: mb(m.external),
arrayBuffersMB: mb(m.arrayBuffers), rssMB: mb(m.rss),
};
}
static deltaMB(before: HeapSnapshot, after: HeapSnapshot): HeapDelta {
return {
heapUsedMB: Math.round((after.heapUsedMB - before.heapUsedMB) * 1000) / 1000,
externalMB: Math.round((after.externalMB - before.externalMB) * 1000) / 1000,
rssMB: Math.round((after.rssMB - before.rssMB) * 1000) / 1000,
};
}
}
/** 单帧内某标签的分配汇总。 */
export interface FrameAllocStat { readonly tag: MemoryTag; readonly allocs: number; readonly bytes: number; }
/** 跨帧累计的标签汇总。 */
export interface TagAllocTotal {
readonly tag: MemoryTag; readonly allocs: number; readonly bytes: number;
readonly avgBytesPerFrame: number; readonly avgAllocsPerFrame: number;
}
/**
* 分配计数器:分配时 `record(tag, bytes)`,帧末 `endFrame()`。
* JS 没有分配钩子,它不真的拦截分配,而是把「热点定位」变成显式账本——
* 团队约定所有会进热路径的分配都必须记账。
*/
export class AllocCounter {
readonly #totals = new Map<MemoryTag, { allocs: number; bytes: number }>();
#frame = new Map<MemoryTag, { allocs: number; bytes: number }>();
#frames = 0;
beginFrame(): void { this.#frame = new Map(); }
record(tag: MemoryTag, bytes: number): void {
if (!Number.isFinite(bytes) || bytes < 0) throw new RangeError(`bytes 必须是非负有限数,收到 ${bytes}`);
const f = this.#frame.get(tag) ?? { allocs: 0, bytes: 0 };
f.allocs++; f.bytes += bytes; this.#frame.set(tag, f);
const t = this.#totals.get(tag) ?? { allocs: 0, bytes: 0 };
t.allocs++; t.bytes += bytes; this.#totals.set(tag, t);
}
/** 结束一帧:累加帧计数,返回本帧各标签分配,按字节降序。 */
endFrame(): FrameAllocStat[] {
this.#frames++;
const out: FrameAllocStat[] = [];
for (const [tag, v] of this.#frame) out.push({ tag, allocs: v.allocs, bytes: v.bytes });
return out.sort((a, b) => b.bytes - a.bytes);
}
get frames(): number { return this.#frames; }
totals(): TagAllocTotal[] {
const out: TagAllocTotal[] = [];
for (const [tag, v] of this.#totals) {
out.push({
tag, allocs: v.allocs, bytes: v.bytes,
avgBytesPerFrame: this.#frames === 0 ? 0 : Math.round(v.bytes / this.#frames),
avgAllocsPerFrame: this.#frames === 0 ? 0 : Math.round((v.allocs / this.#frames) * 100) / 100,
});
}
return out.sort((a, b) => b.bytes - a.bytes);
}
topTags(n: number): TagAllocTotal[] { return this.totals().slice(0, n); }
}
单测:npx vitest run src/perf/alloc-profiler.test.ts(7 个用例全过)。标签体系沿用卷一 helio-kernel-05-3-memory-tags-budget
定义的 MemoryTag。
可运行产物:对象池 vs 逐帧新建(B)
要证明池化到底值不值,就得把两种写法放在隔离的堆里各跑一遍。下面这个基准用 spawnSync 给每个模式开一个独立子进程(堆隔离,GC 计数不互相污染),用 PerformanceObserver 统计 GC 次数与停顿总时长;两种对象形状各测一遍,因为结论完全不同。
// file: tools/gc-bench.mjs
// tier: B
// verify: node --expose-gc tools/gc-bench.mjs
//
// helio 工程卷 3.2 基准:对象池 vs 逐帧新建,对 GC 压力的实测。
// 每模式跑在独立子进程(堆隔离),用 PerformanceObserver 统计 GC 次数与停顿总时长,
// 用 process.memoryUsage() 采堆,最后 global.gc() 后看存活集。两种对象形状各测一遍:
// small:6 个数字字段——V8 的 bump allocation 极快,池化不一定赢在吞吐;
// big :额外挂一个 Float32Array(16)——构造贵、GC 压力大,池化收益显著。
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { PerformanceObserver } from 'node:perf_hooks';
import { ObjectPool } from '../src/core/object-pool.ts';
import { AllocCounter } from '../src/perf/alloc-profiler.ts';
const PARTICLES = 20000;
const FRAMES = 2000;
const makeSmall = () => ({ x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0 });
const makeBig = () => ({ x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, m: new Float32Array(16), tag: 0 });
const resetSmall = (p) => { p.x = 0; p.y = 0; p.z = 0; p.vx = 0; p.vy = 0; p.vz = 0; };
const resetBig = (p) => { p.x = 0; p.m[0] = 0; };
function runNaive(make, P, F) {
const buf = new Array(P);
let sink = 0;
for (let f = 0; f < F; f++) {
for (let i = 0; i < P; i++) buf[i] = make();
for (let i = 0; i < P; i++) { const p = buf[i]; p.x += (i & 7); if (p.m) p.m[0] = p.x; sink += p.x; }
}
return sink;
}
function runPooled(make, reset, P, F) {
const pool = new ObjectPool({ create: make, reset, initial: P });
const buf = new Array(P);
let sink = 0;
for (let f = 0; f < F; f++) {
for (let i = 0; i < P; i++) { const p = pool.acquire(); p.vx = i & 7; buf[i] = p; }
for (let i = 0; i < P; i++) { const p = buf[i]; p.x += p.vx; if (p.m) p.m[0] = p.x; sink += p.x; }
for (let i = 0; i < P; i++) pool.release(buf[i]);
}
return { sink, stats: pool.stats };
}
const mode = process.argv[2];
if (mode && mode !== '--summary') {
const [strategy, shape] = mode.split('-');
const make = shape === 'big' ? makeBig : makeSmall;
const reset = shape === 'big' ? resetBig : resetSmall;
let gcCount = 0, gcPauseMs = 0;
const obs = new PerformanceObserver((list) => { for (const e of list.getEntries()) { gcCount++; gcPauseMs += e.duration; } });
obs.observe({ entryTypes: ['gc'] });
if (typeof globalThis.gc === 'function') globalThis.gc();
const before = process.memoryUsage().heapUsed;
const t0 = performance.now();
const result = strategy === 'naive' ? { sink: runNaive(make, PARTICLES, FRAMES) } : runPooled(make, reset, PARTICLES, FRAMES);
const wallMs = performance.now() - t0;
const after = process.memoryUsage().heapUsed;
await new Promise((r) => setTimeout(r, 60));
obs.disconnect();
let liveAfterGcMB = null;
if (typeof globalThis.gc === 'function') {
globalThis.gc(); await new Promise((r) => setTimeout(r, 20));
liveAfterGcMB = process.memoryUsage().heapUsed / 1048576;
}
console.log(JSON.stringify({
mode, particles: PARTICLES, frames: FRAMES, wallMs: +wallMs.toFixed(1), gcCount, gcPauseMs: +gcPauseMs.toFixed(1),
heapUsedAfterMB: +(after / 1048576).toFixed(1), liveAfterGcMB: liveAfterGcMB === null ? null : +liveAfterGcMB.toFixed(1),
pooled: result.stats ?? null,
}));
} else {
const self = fileURLToPath(import.meta.url);
const run = (m) => {
const r = spawnSync(process.execPath, ['--expose-gc', self, m], { encoding: 'utf8' });
if (r.status !== 0) throw new Error(`${m} 子进程失败: ${r.stderr}`);
return JSON.parse(r.stdout.trim().split('\n').pop());
};
console.log('=== GC 基准:对象池 vs 逐帧新建(每模式独立子进程)===');
console.log(`每帧粒子 ${PARTICLES},${FRAMES} 帧;node --expose-gc;${process.platform}/${process.arch} Node ${process.version}`);
console.log('mode wallMs gcCount gcPause(ms) heapUsedAfter(MB) liveAfterGc(MB)');
const rows = [];
for (const shape of ['small', 'big']) for (const strat of ['naive', 'pooled']) {
const r = run(`${strat}-${shape}`);
rows.push(r);
console.log(`${r.mode.padEnd(14)} ${String(r.wallMs).padStart(7)} ${String(r.gcCount).padStart(7)} ` +
`${String(r.gcPauseMs).padStart(11)} ${String(r.heapUsedAfterMB).padStart(17)} ${String(r.liveAfterGcMB).padStart(15)}`);
}
for (const shape of ['small', 'big']) {
const n = rows.find((r) => r.mode === `naive-${shape}`), p = rows.find((r) => r.mode === `pooled-${shape}`);
console.log(`\n[${shape}] 池化收益:耗时 ${(n.wallMs / p.wallMs).toFixed(2)}x(${n.wallMs}ms → ${p.wallMs}ms),` +
`GC ${n.gcCount} → ${p.gcCount} 次,停顿 ${n.gcPauseMs}ms → ${p.gcPauseMs}ms,池 created=${p.pooled.created} recycled=${p.pooled.recycled}`);
}
// 分配热点定位:用 AllocCounter 把每帧分配按子系统记账(模拟 120 帧)
console.log('\n=== 分配热点定位(AllocCounter,模拟 120 帧)===');
const counter = new AllocCounter();
for (let f = 0; f < 120; f++) {
counter.beginFrame();
for (let i = 0; i < 120; i++) counter.record('Rendering', 64);
for (let i = 0; i < 40; i++) counter.record('Gameplay', 32);
if (f % 10 === 0) for (let i = 0; i < 8; i++) counter.record('Audio', 4096);
if (f % 60 === 0) counter.record('Asset', 262144);
counter.endFrame();
}
console.log('tag avgBytes/frame avgAllocs/frame totalBytes');
for (const t of counter.totals()) console.log(
`${t.tag.padEnd(10)} ${String(t.avgBytesPerFrame).padStart(14)} ${String(t.avgAllocsPerFrame).padStart(15)} ${String(t.bytes).padStart(10)}`);
console.log('热点提示:Audio 单笔 4KB、每 10 帧 8 笔,单笔最大且节律固定,应改为池化复用。');
}
验证命令:node --expose-gc tools/gc-bench.mjs。对象池本体沿用卷一 helio-kernel-05-1-object-pool-freelist
。
真跑出来的 GC 账本与热点
本机实跑一次(每帧 20000 个粒子,2000 帧):
mode wallMs gcCount gcPause(ms) heapUsedAfter(MB) liveAfterGc(MB)
naive-small 427.6 109 68 20.2 8
pooled-small 360.4 3 2.3 10 8
naive-big 4042.5 974 2516.5 55.5 3.5
pooled-big 870.3 6 6.9 15.5 8
[small] 池化收益:耗时 1.19x(427.6ms → 360.4ms),GC 109 → 3 次,停顿 68ms → 2.3ms,池 created=20000 recycled=40000000
[big] 池化收益:耗时 4.64x(4042.5ms → 870.3ms),GC 974 → 6 次,停顿 2516.5ms → 6.9ms,池 created=20000 recycled=40000000
结论要分两种形状读,不能一概而论。small(6 个数字字段):V8 的 bump allocation 极快,池化在吞吐上并没有稳赢——同机多次实跑这个比值在 0.87x~1.19x 之间摆动,基本落在噪声内;但 GC 次数 109 → 3、停顿 68ms → 2.3ms 是稳定且数量级级别的下降。也就是说,小对象池化买到的主要是停顿确定性,不是原始吞吐。big(额外挂一个 Float32Array(16)):构造本身变贵(要再分配一个类型化数组),naive 的 GC 停顿飙到 2516.5ms、占总耗时一半以上,池化把耗时砍到 4.64x,停顿降到 6.9ms。对象越大、构造越贵,池化收益越显著——这是取舍的判据。
热点表同样有用:Rendering 每帧 120 笔、7680B 是绝对量最大;但真正该动手的是 Audio——单笔 4096B、每 10 帧 8 笔,单笔最大且节律固定,正是池化复用的最佳目标。若用命令行复核,node --expose-gc --trace-gc tools/gc-bench.mjs naive-small 会逐行打印 GC 事件,本机实测输出 109 行 GC 记录,而 pooled-small 只有 4 行——与 PerformanceObserver 的 109 vs 3 相互印证。下一节把镜头推到 GPU:CPU 侧的账算清了,还得看显卡上到底花了多少时间。
阅读导航:上一节:3.1 帧预算与性能剖析 · 下一节:3.3 GPU 计时与优化闭环 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。