《游戏引擎实现原理》4.2 线性 arena 与栈式分配器

本节从零实现 helio 的线性 arena 分配器:一块 ArrayBuffer + 一个只前进的偏移量,讲清 bump 分配、按 2 的幂对齐、highWaterMark 与 reset;再在其上搭出 ScratchArena 的 mark/release,为「帧内临时内存」提供一次性回收。全部代码经 tsc 与 vitest 真跑验证。

4.2 线性 arena 与栈式分配器

上一节我们用实测确认了一件事:ArrayBuffer 的内容不走 JS 堆,所以把数据放进去,就等于把 GC 从关键路径上拿掉。但光有一块 buffer 还不够——你得有一个分配器来决定「下一块数据放在 buffer 的哪个位置」。这一节就来写它,而且写到底。

线性 arena(linear arena,也叫 bump allocator)可能是所有分配器里最简单的一种:它只有一块连续内存和一个只前进的偏移量。 分配就是「把偏移量按对齐向上取整,再往后推 size 字节,返回旧偏移」;释放只有两种形态——要么整块 reset,要么回退到一个更早的位置。没有 free list,没有碎片整理,没有元数据头。正因为简单,它几乎是零开销的。

本节产出一个完整可跑的 src/core/arena.ts 与配套的 arena.test.ts。所有代码都在 Node 26.5.0 上跑过 tsc --noEmit 与 vitest,正文里的偏移量数字全部来自真实运行结果。

bump 分配:一个只前进的偏移量

先把核心机制说清楚。arena 内部维护一个 offset,初值 0:

  • 分配 size 字节:把 offset 按对齐要求向上取整得到 aligned,检查 aligned + size 有没有超出容量,没超就把 offset 推到 aligned + size,返回 aligned。
  • 释放:不做任何事。要么 reset() 把 offset 归零,要么 rewindTo(mark) 退回到某个更早的偏移。

这个设计带来三个直接后果。第一,分配是 O(1) 的,没有查找、没有分裂、没有链表遍历。第二,释放是 O(1) 的,而且只有「整块」这一种粒度。第三,内存天然连续,因为数据就是按分配顺序一个挨一个铺下去的。

代价也很明确:你不能单独释放中间某一块。 如果先分配 A 再分配 B,你没法只回收 A 而保留 B——因为 B 的偏移在 A 之后,回收 A 会让 B 变成悬垂。这听起来像缺点,但对「每帧临时数据」这种场景恰好合适:整帧的数据一起活、一起死,天然满足「整块释放」。

对齐:为什么偏移量不能直接加

如果只是 offset += size,那 arena 用两行就写完了。真正让它变得像分配器而不是字节计数器的,是对齐。

CPU 读内存是按「字」读的,GPU 更是有严格的对齐要求。一个 4 字节的 float 如果落在偏移 2 的位置,某些平台读它会直接报错,另一些平台能读但慢。所以分配器必须保证:每一块返回的内存,其起始偏移都是该类型对齐的整数倍。

对齐的实现只有一行位运算,前提是 alignment 是 2 的幂:

aligned = (offset + alignment - 1) & ~(alignment - 1)

以 alignment = 4 为例:~3 会把低两位清零。offset = 3 时,3 + 3 = 6,6 & ~3 = 4——正好把 3 向上对齐到 4。这也是为什么代码里会强制要求 alignment 是 2 的幂:不是 2 的幂就不能用这个位技巧。

代价是内部碎片:offset = 3 时分配一个 4 字节对齐的块,会浪费掉 1 个字节(偏移 3 那一个字节永远用不上)。arena 不回收这种浪费,这是它「简单」要付出的代价——但在实践中,只要对齐粒度不超过 16 字节,这点碎片可以忽略。

可运行产物:src/core/arena.ts

下面是完整的 src/core/arena.ts。它把底层 buffer 同时暴露成 Uint8Array / Float32Array / Uint32Array 三种视图,f32View / u32View / byteView 返回的是子视图(subarray),与 arena 共享同一块底层 buffer,因此是零拷贝的——写视图就是写 arena。

// file: src/core/arena.ts
// tier: L1
// verify: npx vitest run src/core/arena.test.ts
//
// helio 内核:线性 arena 分配器。
// 底层是一块 ArrayBuffer + typed array 视图——buffer 的内容不走 JS 堆,
// 这是本套书里「真手动内存」的立足点。

export type ByteOffset = number;

/** 线性 arena:只前进,不回收;`reset()` 一次性释放全部。 */
export class Arena {
  readonly #buffer: ArrayBuffer;
  readonly #bytes: Uint8Array;
  readonly #f32: Float32Array;
  readonly #u32: Uint32Array;
  #offset = 0;
  #highWater = 0;

  constructor(capacityBytes: number) {
    if (!Number.isInteger(capacityBytes) || capacityBytes <= 0) {
      throw new RangeError(`Arena capacity must be a positive integer, got ${capacityBytes}`);
    }
    this.#buffer = new ArrayBuffer(capacityBytes);
    this.#bytes = new Uint8Array(this.#buffer);
    this.#f32 = new Float32Array(this.#buffer);
    this.#u32 = new Uint32Array(this.#buffer);
  }

  get capacity(): number {
    return this.#buffer.byteLength;
  }

  get used(): number {
    return this.#offset;
  }

  /** 峰值占用,用于评估容量是否合理。 */
  get highWaterMark(): number {
    return this.#highWater;
  }

  get remaining(): number {
    return this.#buffer.byteLength - this.#offset;
  }

  /** 按 alignment(必须是 2 的幂)向上对齐当前偏移后推进。 */
  #bump(sizeBytes: number, alignment: number): ByteOffset {
    const aligned = (this.#offset + alignment - 1) & ~(alignment - 1);
    const end = aligned + sizeBytes;
    if (end > this.#buffer.byteLength) {
      throw new RangeError(
        `Arena out of memory: need ${sizeBytes} B @ align ${alignment}, ` +
          `have ${this.#buffer.byteLength - aligned} B left`,
      );
    }
    this.#offset = end;
    if (end > this.#highWater) this.#highWater = end;
    return aligned;
  }

  allocBytes(sizeBytes: number, alignment = 8): ByteOffset {
    return this.#bump(sizeBytes, alignment);
  }

  allocF32(count: number): ByteOffset {
    return this.#bump(count * 4, 4);
  }

  allocU32(count: number): ByteOffset {
    return this.#bump(count * 4, 4);
  }

  /** 返回可读写的 f32 视图(与 arena 共享底层 buffer,零拷贝)。 */
  f32View(byteOffset: ByteOffset, count: number): Float32Array {
    return this.#f32.subarray(byteOffset >> 2, (byteOffset >> 2) + count);
  }

  u32View(byteOffset: ByteOffset, count: number): Uint32Array {
    return this.#u32.subarray(byteOffset >> 2, (byteOffset >> 2) + count);
  }

  byteView(byteOffset: ByteOffset, count: number): Uint8Array {
    return this.#bytes.subarray(byteOffset, byteOffset + count);
  }

  /** 一次性归还全部内存(不回退 buffer,只是把偏移归零)。 */
  reset(): void {
    this.#offset = 0;
  }

  /**
   * 把偏移回退到更早的位置。仅供栈式分配器使用——
   * 回退后原先分配的区块视为失效,再次分配会覆盖它们。
   */
  rewindTo(byteOffset: ByteOffset): void {
    if (byteOffset < 0 || byteOffset > this.#offset) {
      throw new RangeError(`rewindTo(${byteOffset}) out of range [0, ${this.#offset}]`);
    }
    this.#offset = byteOffset;
  }
}

/** 栈式分配器:支持把偏移恢复到标记点,适合「帧内临时内存」。 */
export class ScratchArena {
  readonly arena: Arena;
  #mark = 0;

  constructor(capacityBytes: number) {
    this.arena = new Arena(capacityBytes);
  }

  mark(): ByteOffset {
    this.#mark = this.arena.used;
    return this.#mark;
  }

  release(): void {
    this.arena.rewindTo(this.#mark);
  }
}

几点值得单独指出。其一,容量校验在构造时就做,非法容量直接抛 RangeError,而不是等到第一次分配才炸——把错误尽早暴露。其二,越界检查带上了具体数字,报错信息里有「需要多少、对齐多少、还剩多少」,调试时不用再手算。其三,rewindTo 拒绝前向回退,byteOffset > this.#offset 会抛错,防止你把偏移推到已分配区域之外。

vitest 用例与实测输出

分配器最容易错的地方是边界:对齐算错一位、越界检查漏一个等号、reset 之后没有真正复用 buffer。下面这组用例就是冲着这些边界去的。

// file: src/core/arena.test.ts
// tier: L1
// verify: npx vitest run src/core/arena.test.ts

import { describe, expect, it } from 'vitest';
import { Arena, ScratchArena } from './arena.ts';

describe('Arena', () => {
  it('按对齐推进偏移', () => {
    const a = new Arena(1024);
    expect(a.allocBytes(3, 1)).toBe(0);
    expect(a.allocBytes(4, 4)).toBe(4); // 3 → 对齐到 4
    expect(a.used).toBe(8);
  });

  it('越界抛 RangeError', () => {
    const a = new Arena(16);
    expect(() => a.allocBytes(32)).toThrow(RangeError);
  });

  it('f32 视图零拷贝共享底层 buffer', () => {
    const a = new Arena(1024);
    const off = a.allocF32(4);
    const v = a.f32View(off, 4);
    v[0] = 1.5;
    v[3] = -2.25;
    expect(a.f32View(off, 4)[0]).toBeCloseTo(1.5);
    expect(a.f32View(off, 4)[3]).toBeCloseTo(-2.25);
  });

  it('highWaterMark 记录峰值', () => {
    const a = new Arena(1024);
    a.allocBytes(100);
    a.allocBytes(50);
    expect(a.highWaterMark).toBeGreaterThanOrEqual(150);
  });

  it('reset 后偏移归零但 buffer 复用', () => {
    const a = new Arena(1024);
    a.allocBytes(500);
    a.reset();
    expect(a.used).toBe(0);
    expect(a.capacity).toBe(1024);
  });
});

describe('ScratchArena', () => {
  it('mark/release 回退临时分配', () => {
    const s = new ScratchArena(1024);
    s.arena.allocF32(16);
    const before = s.arena.used;
    s.mark();
    s.arena.allocF32(64);
    expect(s.arena.used).toBeGreaterThan(before);
    s.release();
    expect(s.arena.used).toBe(before);
  });

  it('rewindTo 拒绝前向回退', () => {
    const s = new ScratchArena(1024);
    s.mark();
    s.arena.allocF32(16);
    expect(() => s.arena.rewindTo(9999)).toThrow(RangeError);
  });
});

在 /tmp/enginebook 里跑 npx vitest run src/core/arena.test.ts,7 个用例全部通过。为了把中间状态也看清楚,我另跑了一段脚本打印每一步的偏移量,原始输出如下(本机实测,绝对数值不可移植):

allocBytes(3,1) -> 0
allocBytes(4,4) -> 4        # 3 向上对齐到 4
used -> 8
highWaterMark -> 162        # 8 + 100 → 108,再 +50 对齐到 112 → 162
after reset used -> 0  capacity -> 1024
during scratch used -> 320  before -> 64   # 64 + 64*4 = 320
after release used -> 64

对照代码看这几行:allocBytes(3, 1) 返回 0,因为初始偏移就是 0;接着 allocBytes(4, 4) 先把偏移 3 对齐到 4,返回 4,然后把偏移推到 8。highWaterMark 到 162,是因为 allocBytes(50) 默认对齐 8,把 108 先对齐到 112,再加 50 得到 162。ScratchArena 那段更能说明问题:allocF32(16) 让偏移到 64,mark() 记下 64,allocF32(64) 再推 256 字节到 320,release() 之后精确回到 64——临时内存被整块归还,没有任何逐块释放。

highWaterMark、reset 与帧内 scratch

highWaterMark 记录的是 arena 生命周期里用过的最大偏移,它是容量调参的唯一依据。用法很直白:先用一个偏大的容量跑一遍完整场景,读 highWaterMark,然后把容量定在这个值向上取整到 2 的幂。这样既不会浪费内存,也不会在某个极限帧突然越界。注意 reset() 不会清零 highWaterMark——峰值是历史信息,重置数据不该抹掉它。

ScratchArena 则把 arena 包装成一个栈。典型用法是在帧的开始 mark() 一次,帧内的各种临时计算(可见性剔除的中间结果、临时的 draw call 列表、粒子更新缓冲)都从同一个 scratch arena 里分配;帧结束时 release() 一次,把整帧的临时数据一次性归还。由于下一帧会覆盖同样的字节,release() 之后不要再持有上一帧的视图——这和栈上变量出作用域后失效是同一个道理。

还有一个实战细节:arena 应该按用途分成几块,而不是共用一块巨大的。 比如「每帧临时」用一块 1 MB 的 scratch,「关卡常驻」用另一块大的,「持久资源」再一块。这样 reset() 的作用域才清晰——你不会因为重置临时区而误伤常驻数据。第 5 章会把这种「按标签分块 + 预算」的做法正式化。

小结

本节只做了「只前进、整块释放」这一种分配策略。它不解决「释放中间某一块」「按对象大小分桶复用」「统计每类内存占用」这些需求——那是第 5 章的对象池与 block allocator 要干的事。如果你现在就想要「能单独释放对象」的能力,正确的方向不是给 arena 加 free,而是换一种分配器(见 5.1 的 freelist 与 5.2 的 size class)。

下一节我们处理 arena 与 GPU 之间最后一道坎:对齐、padding,以及把一块 ArrayBuffer 手工切成顶点结构时,偏移量到底该落在哪里。

阅读导航:上一节:4.1 为什么要自己管内存 · 下一节:4.3 对齐、padding 与 TypedArray 上的真实布局 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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