WebGPU renderer recreates GPU buffers every frame (GpuBufferSystem has no pooling): ~30 createBuffer/frame in a steady-state scene
Current Behavior
The WebGPU renderer allocates fresh GPU buffers with device.createBuffer every frame in any workload where display objects or geometries are (re)created over time — GpuBufferSystem has no pooling/reuse: createGPUBuffer always allocates, and released buffers are destroyed (or, in 8.3.4, retained forever — see below) instead of being recycled.
Measured with a steady-state census (wrapping GPUDevice.prototype.createBuffer) on a headless Node.js server-side rendering workload (each job builds a scene, renders it plus two render-to-texture passes, tears it down; caches warm, single renderer, pixi 8.3.4):
- 32
createBuffercalls/frame, ~2.9 MB/frame, of which- ~31 via
GpuBufferSystem.createGPUBuffer, reached fromupdateBuffer— 12/frame at ~627 KB + 8/frame at ~919 KB: batch/geometry buffers with identical sizes frame over frame being re-allocated, and - 11/frame tiny (~1 KB) uniform buffers via
getGPUBuffer.
- ~31 via
- CPU profile shares:
createGPUBuffer7.0% self time, batcherbuild8.1%, GC 8.6% (allocation-fed). - On Vulkan each
createBuffercosts ~5× what it does on Metal, and buffer uploads contend device-wide when several processes share a discrete GPU — so the churn is doubly expensive there.
I checked src/rendering/renderers/gpu/buffer/GpuBufferSystem.ts on current main (v8.20.x): the structure changed (unload now goes through GCManagedHash), but createGPUBuffer still unconditionally calls device.createBuffer and unload destroys — no reuse, so the churn stands.
Two related observations from the same investigation:
- 8.3.4 retention: in the repro below,
GPUBuffer.destroyis called zero times across 16 frames whileGpuBufferSystem._managedBuffersgrows to 258 entries andBatcherPipe._batchesto 144 — per-frame batch geometries are retained until renderer destroy. Main's GC-managed unload presumably fixes the retention, but destroy-then-recreate is exactly the pattern pooling should absorb. - Companion (one-liner scale):
GpuUniformBatchPipe._uploadBindGroupscreates its own command encoder and issues a separatequeue.submitperrender()call. Batching[uploadCB, frameCB]into one submit is order-safe (runner orderrenderEnd→postrender); we have run that fused form under a production-scale load with byte-identical output.
Expected Behavior
Steady-state rendering of a scene whose buffer sizes don't change should perform ~0 device.createBuffer calls per frame: released GPUBuffers should be pooled by size(+usage) class in GpuBufferSystem and reused, and batch geometry buffers reused across frames/instruction sets.
As validation that this is safe and sufficient, I have a small candidate patch to GpuBufferSystem (pool released buffers in a Map keyed by ${size}:${usage}, reuse in createGPUBuffer with queue.writeBuffer for initial contents, never pool MAP_READ/MAP_WRITE, byte-budget cap, drained on destroyAll/destroy). On the repro below it takes steady state from 24 createBuffer/frame → 0/frame, with the final render-target readback byte-identical (SHA-256) to pristine. Happy to open a PR against main if the direction is agreeable.
Steps to Reproduce
Self-contained headless repro (Node 22 + the webgpu npm package, npm i [email protected] webgpu) — the mechanism is renderer-side, so the same census reproduces in a browser by wrapping GPUDevice.prototype.createBuffer in devtools:
// Repro: PixiJS v8 (8.3.4) WebGPU renderer re-creates GPU buffers on every frame
// for any scene whose display objects are (re)built per frame — GpuBufferSystem
// destroys eagerly and pools nothing, so identical-size buffers are re-allocated
// via device.createBuffer each frame.
//
// Headless Node via the `webgpu` (Dawn) package: npm i [email protected] webgpu
// (Counts reproduce in a browser too — just wrap GPUDevice.prototype.createBuffer.)
import { create, globals } from "webgpu";
Object.assign(globalThis, globals);
const gpu = create([]);
// ---- census: wrap createBuffer, attribute each call to the frame that made it
let frame = -1;
const perFrame = [];
const origCreate = globals.GPUDevice.prototype.createBuffer;
globals.GPUDevice.prototype.createBuffer = function (desc) {
if (frame >= 0) {
const f = (perFrame[frame] ??= { calls: 0, bytes: 0, sizes: new Map() });
f.calls++; f.bytes += desc.size;
f.sizes.set(desc.size, (f.sizes.get(desc.size) ?? 0) + 1);
}
return origCreate.call(this, desc);
};
// ---- minimal browser-env shims so the WebGPU renderer initializes in Node
Object.defineProperty(globalThis, "navigator", { value: { userAgent: "node", gpu }, configurable: true });
globalThis.self = globalThis; // + WorkerGlobalScope -> pixi picks its webworker env (no `document` needed)
globalThis.WorkerGlobalScope = function WorkerGlobalScope() {};
globalThis.location = new URL("http://localhost/");
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16);
globalThis.cancelAnimationFrame = clearTimeout;
const glStub = { // satisfies the WebGL probe in maxRecommendedTextures/checkMaxIfStatementsInShader
getParameter: () => 16, createShader: () => ({}), shaderSource() {}, compileShader() {},
getShaderParameter: () => true, deleteShader() {}, isContextLost: () => false, getExtension: () => null,
getShaderPrecisionFormat: () => ({ precision: 23, rangeMin: 127, rangeMax: 127 }),
FRAGMENT_SHADER: 0x8b30, COMPILE_STATUS: 0x8b81, MAX_TEXTURE_IMAGE_UNITS: 0x8872,
};
let device; // captured so the canvas-context stub can allocate its backing texture
class StubCanvas {
constructor(w = 1, h = 1) { this.width = w; this.height = h; }
getContext(kind) {
if (kind !== "webgpu") return glStub;
let tex = null;
return {
canvas: this, configure() {}, unconfigure() {},
getCurrentTexture: () => (tex ??= device.createTexture({
size: { width: this.width || 1, height: this.height || 1 }, format: "bgra8unorm",
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
})),
};
}
addEventListener() {} removeEventListener() {}
}
globalThis.OffscreenCanvas = StubCanvas;
const rd = globals.GPUAdapter.prototype.requestDevice;
globals.GPUAdapter.prototype.requestDevice = async function (...a) { return (device = await rd.apply(this, a)); };
// Dawn's WebIDL is stricter than Chrome's: pixi passes string indices to these.
for (const m of ["setVertexBuffer", "setBindGroup"]) {
const o = globals.GPURenderPassEncoder.prototype[m];
globals.GPURenderPassEncoder.prototype[m] = function (i, ...r) { return o.call(this, Number(i), ...r); };
}
// ---- scene: 8 render groups x 200 sprites, rebuilt each frame (as a server-side
// per-request workload, or any UI that recreates display objects), plus two
// render-to-texture passes. All buffer sizes are IDENTICAL frame over frame.
const { WebGPURenderer, Container, Sprite, Texture, RenderTexture, BufferImageSource, DOMAdapter, WebWorkerAdapter } =
await import("pixi.js");
DOMAdapter.set(WebWorkerAdapter);
const renderer = new WebGPURenderer();
await renderer.init({ width: 1024, height: 1024, canvas: new StubCanvas(1024, 1024) });
const pixels = new Uint8Array(64 * 64 * 4).map((_, i) => (i * 7) % 256);
const tex = new Texture({ source: new BufferImageSource({ resource: pixels, width: 64, height: 64 }) });
const rt1 = RenderTexture.create({ width: 512, height: 512 });
const rt2 = RenderTexture.create({ width: 512, height: 512 });
function buildStage() {
const stage = new Container();
for (let g = 0; g < 8; g++) {
const group = new Container({ isRenderGroup: true });
for (let i = 0; i < 200; i++) {
const s = new Sprite(tex);
s.position.set((i % 20) * 50, ((i / 20) | 0) * 50 + g * 5);
s.rotation = (g * 200 + i) * 0.01;
group.addChild(s);
}
stage.addChild(group);
}
return stage;
}
for (let i = 0; i < 32; i++) {
frame = i;
const stage = buildStage();
renderer.render({ container: stage, target: rt1 });
renderer.render({ container: stage, target: rt2 });
renderer.render({ container: stage }); // main pass
stage.destroy({ children: true });
}
for (const [i, f] of perFrame.entries()) {
const sizes = [...f.sizes].sort((a, b) => b[0] * b[1] - a[0] * a[1]).slice(0, 4)
.map(([sz, n]) => `${n}x ${(sz / 1024).toFixed(1)}KB`).join(", ");
if (i < 3 || i >= perFrame.length - 3) console.log(`frame ${String(i).padStart(2)}: ${String(f.calls).padStart(3)} createBuffer, ${(f.bytes / 1048576).toFixed(2)} MB [${sizes}]`);
else if (i === 3) console.log(" ...");
}
process.exit(0);
Output on 8.3.4 (macOS/Metal and Linux/Vulkan agree on counts):
frame 0: 18 createBuffer, 0.66 MB [2x 256.0KB, 8x 18.8KB, 8x 2.3KB]
frame 1: 16 createBuffer, 0.16 MB [8x 18.8KB, 8x 2.3KB]
frame 2: 16 createBuffer, 0.16 MB [8x 18.8KB, 8x 2.3KB]
...
frame 31: 16 createBuffer, 0.16 MB [8x 18.8KB, 8x 2.3KB]
Every steady-state frame re-allocates the same 8×18.8 KB attribute + 8×2.3 KB index buffers (one pair per render group). Scene complexity scales the number linearly (the production-scale workload above sits at ~32/frame, ~2.9 MB/frame).
Environment
- pixi.js: 8.3.4 (measured);
GpuBufferSystemonmain(v8.20.1) confirmed to still allocate per call - Runtime: Node.js 22 headless via
[email protected](Dawn); also applies in Chrome — the allocation pattern is pixi-side - OS/GPU: reproduced on macOS (Apple M4, Metal) and Linux (NVIDIA RTX 4060 Ti / RTX 4090, Vulkan)
Source: pixijs/pixijs