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 后端 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。