《游戏引擎实现原理》3.2 GC、内存预算与分配追踪

帧时间稳住了,GC 的一次 Stop-The-World 照样能把它抖起来。本节用 process.memoryUsage() 与 --trace-gc 把内存账本读出来,按子系统定位分配热点,并在独立子进程里实测对象池对 GC 次数、停顿与吞吐的真实收益。

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 计时与优化闭环 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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