《游戏引擎实现原理》4.3 GPU 内存池与预算

资源能传上去之后,一帧里反复创建销毁 buffer/texture 会把驱动拖垮、把显存切碎。本节复用卷一的大小类思路做一个 GPU 内存池,配显式预算与超限策略,统计复用率与峰值占用,并在真实 WebGPU 设备上跑三帧验证第 2/3 帧确实复用了第 1 帧创建的 buffer。

4.3 GPU 内存池与预算

上传路径打通之后,问题从「怎么传」变成了「传多少、留多久」。一个真实帧里会创建和销毁几十上百个资源:这一帧的粒子 buffer、下一帧的临时后处理纹理、切场景时整批换掉的模型顶点。如果每个都现用现 createBuffer、用完立刻 destroy,就会踩两个坑——第一,反复向驱动申请/归还显存很慢,驱动内部要做地址映射、可能触发显存碎片整理;第二,显存碎片化,大块资源申请不到,明明总量够却分配失败。

解法和小对象一样:池化。上一卷我们在字节层做过大小类分配器 PoolAllocator,这一节把它搬到 GPU 资源上——按大小类复用 buffer/texture,再配一个显式预算和超限策略,把「显存是不是要爆」变成可以提前判断的事。

为什么 GPU 资源尤其需要池

CPU 侧的分配器可以容忍一定碎片,因为操作系统有虚拟内存兜底。GPU 不行:显存是实打实的物理资源,池一旦增长就不会缩回去(除非主动 trim),碎片也无法靠 swap 缓解。而且 GPU 资源的创建是同步的、昂贵的——createBuffer 会向驱动要一块真实的显存,在移动端尤其明显。

所以 GPU 池的目标很明确:最大化复用、把峰值占用压住、并在超预算时明确地失败或回收,而不是让驱动在背后悄悄 OOM。三条设计要点:

  • 按大小类复用:同尺寸的请求复用同一块。这里沿用卷一的 2 的幂大小类,只是最小粒度从 16 B 提到 256 B(GPU 资源的最小粒度)。
  • 显式预算:池知道自己最多能占多少显存,超过就按策略处理,而不是无限增长。
  • 超限策略:throw(直接拒绝,让上层决定降级)或 evict(先回收空闲块腾地方)。

复用卷一的大小类:GpuMemoryPool

下面这个文件是 helio 的 GPU 内存池实现,纯 TS、Node 直跑。它把每个逻辑资源映射到一个大小类,release 后块回到该类的空闲栈,acquire 时优先复用。

// file: src/render/memory/gpu-pool.ts
// tier: L1
// verify: npx vitest run src/render/memory/gpu-pool.test.ts
//
// helio 的 GPU 内存池:复用卷一「大小类」思路,但管的是 GPU 资源。
// acquire 一个逻辑资源时池按 2 的幂大小类发一块;release 后块回到该类的
// 空闲栈,下次同尺寸请求直接复用同一块,避免反复 createBuffer 造成驱动
// 抖动与显存碎片。池有显式预算(budgetBytes)与超限策略(throw / evict)。

export type OverLimitStrategy = 'throw' | 'evict';

/** GPU 资源尺寸最小粒度 256 B;大小类 = 256 * 2^k,k = 0..20。 */
export const GPU_MIN_CLASS = 256;
export const GPU_MAX_CLASS = 256 << 20; // 256 MiB

export interface PoolStats {
  readonly acquires: number;
  readonly releases: number;
  /** 从空闲栈拿到已有块的次数。 */
  readonly reuses: number;
  /** 新开块的次数(≈ createBuffer)。 */
  readonly reserves: number;
  /** reuses / acquires,越高说明池越有效。 */
  readonly reuseRate: number;
  readonly reservedBytes: number;
  readonly peakReservedBytes: number;
  readonly liveBytes: number;
}

export interface Handle {
  readonly id: number;
  readonly sizeClass: number;
}

/** 向上取到 256 * 2^k 的最小类。 */
export function gpuSizeClass(sizeBytes: number): number {
  if (!Number.isInteger(sizeBytes) || sizeBytes <= 0) {
    throw new RangeError(`size 必须是正整数,得到 ${sizeBytes}`);
  }
  if (sizeBytes <= GPU_MIN_CLASS) return GPU_MIN_CLASS;
  if (sizeBytes > GPU_MAX_CLASS) throw new RangeError(`size ${sizeBytes} 超过最大类 ${GPU_MAX_CLASS}`);
  return 2 ** (32 - Math.clz32(sizeBytes - 1));
}

export class GpuMemoryPool {
  readonly #budget: number;
  readonly #strategy: OverLimitStrategy;
  readonly #free = new Map<number, number[]>();
  readonly #live = new Map<number, number>();
  #nextId = 1;
  #acquires = 0;
  #releases = 0;
  #reuses = 0;
  #reserves = 0;
  #reservedBytes = 0;
  #peakReservedBytes = 0;
  #liveBytes = 0;

  constructor(budgetBytes: number, strategy: OverLimitStrategy = 'throw') {
    if (!Number.isInteger(budgetBytes) || budgetBytes < GPU_MIN_CLASS) {
      throw new RangeError(`budget 至少 ${GPU_MIN_CLASS} 字节`);
    }
    this.#budget = budgetBytes;
    this.#strategy = strategy;
  }

  get budgetBytes(): number {
    return this.#budget;
  }

  acquire(sizeBytes: number): Handle {
    const sizeClass = gpuSizeClass(sizeBytes);
    this.#acquires++;
    const stack = this.#free.get(sizeClass);
    if (stack && stack.length > 0) {
      const id = stack.pop()!;
      this.#live.set(id, sizeClass);
      this.#reuses++;
      this.#liveBytes += sizeClass;
      return { id, sizeClass };
    }
    // 需要新开块:先看预算,超限时按策略处理。
    if (this.#reservedBytes + sizeClass > this.#budget) {
      if (this.#strategy !== 'evict' || !this.#trimTo(sizeClass)) {
        this.#acquires--;
        throw new RangeError(`GPU 池超预算:需要 ${sizeClass} B,余 ${this.#budget - this.#reservedBytes} B`);
      }
    }
    const id = this.#nextId++;
    this.#live.set(id, sizeClass);
    this.#reserves++;
    this.#reservedBytes += sizeClass;
    this.#liveBytes += sizeClass;
    if (this.#reservedBytes > this.#peakReservedBytes) this.#peakReservedBytes = this.#reservedBytes;
    return { id, sizeClass };
  }

  release(handle: Handle): void {
    const sizeClass = this.#live.get(handle.id);
    if (sizeClass === undefined) throw new RangeError(`handle ${handle.id} 不存在或已释放`);
    this.#live.delete(handle.id);
    this.#releases++;
    this.#liveBytes -= sizeClass;
    const stack = this.#free.get(sizeClass) ?? [];
    stack.push(handle.id);
    this.#free.set(sizeClass, stack);
  }

  /** evict:释放空闲块直到能腾出 needBytes;成功返回 true。 */
  #trimTo(needBytes: number): boolean {
    for (const cls of [...this.#free.keys()].sort((a, b) => b - a)) {
      const stack = this.#free.get(cls)!;
      while (stack.length > 0 && this.#reservedBytes + needBytes > this.#budget) {
        stack.pop();
        this.#reservedBytes -= cls;
      }
      if (this.#reservedBytes + needBytes <= this.#budget) return true;
    }
    return this.#reservedBytes + needBytes <= this.#budget;
  }

  /** 主动释放全部空闲块(如切场景时)。 */
  trim(): void {
    for (const [cls, stack] of this.#free) this.#reservedBytes -= cls * stack.length;
    this.#free.clear();
  }

  get stats(): PoolStats {
    return {
      acquires: this.#acquires,
      releases: this.#releases,
      reuses: this.#reuses,
      reserves: this.#reserves,
      reuseRate: this.#acquires === 0 ? 0 : this.#reuses / this.#acquires,
      reservedBytes: this.#reservedBytes,
      peakReservedBytes: this.#peakReservedBytes,
      liveBytes: this.#liveBytes,
    };
  }
}

几个要点。第一,reuseRate = reuses / acquires 是衡量池是否有效的核心指标——它告诉你多少次申请是从空闲栈里捡现成的、多少次是真正新开块。第二,reservedBytes 统计的是「已开块」占用的字节,包含空闲但保留的块;liveBytes 才是当前存活资源占用的。两者之差就是池为了复用而「养着」的空闲块。第三,超限时若策略是 evict,#trimTo 会先回收空闲块(从大到小),腾出空间再新开;腾不出来才抛错。失败的那次申请会被回滚(#acquires--),不会污染统计。

本机实测 npx vitest run src/render/memory/gpu-pool.test.ts:Test Files 1 passed (1)、Tests 7 passed (7)。关键断言:释放后再申请同尺寸拿到同一块 id,reuseRate 恰为 0.5;预算 2560、evict 策略下,三个 512 块里释放两个后申请一个 2048 块,池回收两个空闲块后成功,peakReservedBytes 记为 2560。

可运行产物:真在设备上跑一个 buffer 池

L1 的池管的是抽象句柄,还得证明它在真实 GPU 上能发真实的 buffer。下面这个 L2 模块连跑 3 帧、每帧申请 4 个 buffer 再释放:第 1 帧创建真实 GPUBuffer,第 2/3 帧直接复用,最后用池里发出的一个 buffer 做一次 writeBuffer + 拷回。

// file: src/render/memory/webgpu-buffer-pool.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/memory/webgpu-buffer-pool.ts
//
// GPU 缓冲池在真实设备上跑:连跑 3 帧、每帧申请 4 个 buffer 再释放。
// 第 1 帧创建真实 GPUBuffer,第 2/3 帧直接复用,统计复用率与峰值占用;
// 最后用池里发出的一个 buffer 做 writeBuffer + 拷回,证明它是真能用的 GPU 缓冲。

export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string; }
export interface L2Result { readonly checks: L2Check[]; readonly data: Record<string, unknown>; }

/** 与 L1 的 gpuSizeClass 同源:向上取到 ≥256 的 2 的幂。 */
function sizeClass(n: number): number {
  return Math.max(256, 2 ** (32 - Math.clz32(n - 1)));
}

export async function runL2(): Promise<L2Result> {
  const checks: L2Check[] = [];
  const data: Record<string, unknown> = {};
  const push = (name: string, ok: boolean, detail: unknown): void => {
    checks.push({ name, ok, detail: String(detail) });
  };
  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) throw new Error('requestAdapter() returned null');
  const device = await adapter.requestDevice();

  const usage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC;
  const budget = 1 << 20;
  const free = new Map<string, GPUBuffer[]>();
  const live = new Map<GPUBuffer, { cls: number; key: string }>();
  let created = 0, reuses = 0, acquires = 0, reservedBytes = 0, liveBytes = 0, peakBytes = 0;

  const acquire = (n: number): GPUBuffer => {
    const cls = sizeClass(n);
    const key = `${cls}`;
    acquires++;
    const stack = free.get(key);
    if (stack && stack.length > 0) {
      const b = stack.pop()!;
      live.set(b, { cls, key });
      reuses++;
      liveBytes += cls;
      return b;
    }
    if (reservedBytes + cls > budget) throw new RangeError(`池超预算:需 ${cls},余 ${budget - reservedBytes}`);
    const b = device.createBuffer({ size: cls, usage });
    created++;
    reservedBytes += cls;
    liveBytes += cls;
    peakBytes = Math.max(peakBytes, reservedBytes);
    live.set(b, { cls, key });
    return b;
  };
  const release = (b: GPUBuffer): void => {
    const meta = live.get(b)!;
    live.delete(b);
    liveBytes -= meta.cls;
    const stack = free.get(meta.key) ?? [];
    stack.push(b);
    free.set(meta.key, stack);
  };

  // 3 帧 × 4 个 buffer(尺寸各异,映射到 4 个大小类)。
  const sizes = [1000, 3000, 5000, 200];
  let first: GPUBuffer | null = null;
  for (let frame = 0; frame < 3; frame++) {
    const bufs = sizes.map((s) => acquire(s));
    if (frame === 0) first = bufs[0]!;
    for (const b of bufs) release(b);
  }
  data.created = created;
  data.reuses = reuses;
  data.acquires = acquires;
  data.reuseRate = reuses / acquires;
  data.peakBytes = peakBytes;
  push('第 1 帧创建 4 个真实 buffer', created === 4, `created=${created}`);
  push('复用 8 次(第 2/3 帧各 4 次)', reuses === 8, `reuses=${reuses}`);
  push('复用率 = 8/12', Math.abs(reuses / acquires - 8 / 12) < 1e-9, `${reuses}/${acquires}`);

  // 用池里发出的 buffer 真做一次写入 + 拷回。
  const src = first!;
  device.queue.writeBuffer(src, 0, new Float32Array([11, 22, 33, 44]));
  const rb = device.createBuffer({ size: 16, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
  const enc = device.createCommandEncoder();
  enc.copyBufferToBuffer(src, 0, rb, 0, 16);
  device.queue.submit([enc.finish()]);
  await rb.mapAsync(GPUMapMode.READ);
  const got = Array.from(new Float32Array(rb.getMappedRange().slice(0)));
  rb.unmap(); rb.destroy();
  data.readback = got;
  push('池 buffer 可写可拷回 [11,22,33,44]', JSON.stringify(got) === JSON.stringify([11, 22, 33, 44]), JSON.stringify(got));

  for (const [b] of live) b.destroy();
  for (const stack of free.values()) for (const b of stack) b.destroy();
  device.destroy();
  return { checks, data };
}

本机实测 node tools/run-l2.mjs src/render/memory/webgpu-buffer-pool.ts,4 条检查全部 PASS:created = 4(第 1 帧创建 4 个真实 GPUBuffer)、reuses = 8(第 2/3 帧各复用 4 次)、reuseRate = 8/12 ≈ 0.667、peakBytes = 13568(四个大小类 1024 + 4096 + 8192 + 256 之和)。最后池里发出的 buffer 写回 [11,22,33,44] 并拷回成功,证明它不是抽象句柄,而是真能参与 GPU 命令的缓冲。

小结

  • GPU 资源池化解决两件事:反复 createBuffer/destroy 的驱动开销,以及显存碎片。
  • helio 的 GpuMemoryPool 复用卷一的大小类(最小粒度提到 256 B),release 回空闲栈、acquire 优先复用。
  • 三个统计量分工:reuseRate 衡量池的有效性,reservedBytes 含保留的空闲块,liveBytes 是真正存活的部分。
  • 预算 + 超限策略把「显存会不会爆」变成可判断的事:throw 直接拒绝,evict 先回收空闲块。
  • 真机验证:3 帧 12 次申请里 8 次复用,峰值 13568 字节,池 buffer 可写可拷回。

到这里,资源从「怎么绑」「怎么传」到「怎么管」这条链就闭环了。但它一直建立在一个假设上:我们有一个可用的 WebGPU 设备。下一节开始进入后端实现——helio 的 RHI 是怎么把 WebGPU 这套 API 包成一个后端、又怎么和 WebGL2、NullRHI 共用同一套接口的。

阅读导航:上一节:4.2 上传、暂存与 mipmap 生成 · 下一节:5.1 WebGPU 后端 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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