WebGPU renderer allocates per draw call: for-in over null-prototype maps in the encoder hot path
Version: 8.19.0 (the loops are still present on dev)
GpuEncoderSystem._setShaderBindGroups, _syncBindGroup, setGeometry, and BindGroup._touch iterate objects created with Object.create(null) (BindGroup.resources, shader.groups, the map returned by getBufferNamesToBind) using for-in, once per draw call. Null-prototype objects with numeric keys are dictionary-mode in V8, so every for-in allocates a fresh key enumeration.
In my game (Electron, WebGPU renderer), Chrome's allocation sampler attributes roughly 40 MB per 10 seconds of garbage to these frames — about 2x the total garbage of the same scene on the WebGL renderer — with _setShaderBindGroups, setBindGroup, and setGeometry as the top allocation sites:
11.4 MB _setShaderBindGroups @ GpuEncoderSystem <- draw <- execute <- executeInstructions
9.1 MB setBindGroup @ GpuEncoderSystem <- _setShaderBindGroups <- draw <- execute
5.2 MB setGeometry @ GpuEncoderSystem <- draw <- execute <- executeInstructions
Fix that worked for me: cache the key list on the object (Object.keys once), iterate by index, and invalidate the cache in BindGroup.setResource / Shader.addResource; setGeometry can cache numeric keys on the getBufferNamesToBind result (which also avoids the per-draw parseInt and passes numbers rather than strings to GPURenderPassEncoder.setBindGroup).
Patching just these four methods took our per-frame GC from ~108 MB/10s to ~46 MB/10s with identical rendering.
If it helps, this is the temporary solution that I am using now (calling apply on game create):
import * as Pixi from 'pixi.js';
interface TouchedResource {
_gcLastUsed: number;
_touched: number;
isUniformGroup?: boolean;
}
interface KeyCachedBindGroup {
resources: Record<string, TouchedResource>;
_keyCache?: string[] | undefined;
}
interface GroupKeyCachedShader {
groups: Record<number, Pixi.BindGroup>;
gpuProgram: Pixi.GpuProgram;
_groupKeyCache?: number[] | undefined;
}
interface KeyCachedBufferNames {
[index: number]: string;
_keyCache?: number[];
}
/**
* PixiJS 8.19 iterates `Object.create(null)` maps with `for-in` on every WebGPU draw call (bind
* group touch/sync, shader groups, vertex buffer binding). Such maps are dictionary-mode objects,
* so every loop allocates a fresh key enumeration, which dominates the renderer's per-frame
* garbage. These patches replace the enumerations with key arrays cached on the objects,
* invalidated where the maps can change.
*/
export class PixiGcPatches {
static apply(): void {
PixiGcPatches._patchBindGroup();
PixiGcPatches._patchEncoder();
PixiGcPatches._patchShader();
}
private static _patchBindGroup(): void {
let prototype = Pixi.BindGroup.prototype as unknown as KeyCachedBindGroup & {
_touch(now: number, tick: number): void;
setResource(resource: unknown, index: number): void;
};
prototype._touch = function (now: number, tick: number): void {
let keys = PixiGcPatches._bindGroupKeys(this);
for (let i = 0; i < keys.length; i++) {
let resource = this.resources[keys[i]!]!;
resource._gcLastUsed = now;
resource._touched = tick;
}
};
let originalSetResource = prototype.setResource;
prototype.setResource = function (resource: unknown, index: number): void {
this._keyCache = undefined;
originalSetResource.call(this, resource, index);
};
}
private static _patchEncoder(): void {
let prototype = Pixi.GpuEncoderSystem.prototype as unknown as {
_renderer: {
ubo: { updateUniformGroup(group: TouchedResource): void };
pipeline: { getBufferNamesToBind(geometry: Pixi.Geometry, program: Pixi.GpuProgram): KeyCachedBufferNames };
};
_setVertexBuffer(index: number, buffer: unknown): void;
_setIndexBuffer(buffer: unknown): void;
setBindGroup(index: number, bindGroup: Pixi.BindGroup, program: Pixi.GpuProgram): void;
_syncBindGroup(bindGroup: KeyCachedBindGroup): void;
_setShaderBindGroups(shader: GroupKeyCachedShader, skipSync?: boolean): void;
setGeometry(geometry: Pixi.Geometry, program: Pixi.GpuProgram): void;
};
prototype._syncBindGroup = function (bindGroup: KeyCachedBindGroup): void {
let keys = PixiGcPatches._bindGroupKeys(bindGroup);
for (let i = 0; i < keys.length; i++) {
let resource = bindGroup.resources[keys[i]!]!;
if (resource.isUniformGroup) {
this._renderer.ubo.updateUniformGroup(resource);
}
}
};
prototype._setShaderBindGroups = function (shader: GroupKeyCachedShader, skipSync?: boolean): void {
let keys = shader._groupKeyCache ?? (shader._groupKeyCache = PixiGcPatches._numberKeys(shader.groups));
for (let i = 0; i < keys.length; i++) {
let index = keys[i]!;
let bindGroup = shader.groups[index]!;
if (!skipSync) {
this._syncBindGroup(bindGroup as unknown as KeyCachedBindGroup);
}
this.setBindGroup(index, bindGroup, shader.gpuProgram);
}
};
prototype.setGeometry = function (geometry: Pixi.Geometry, program: Pixi.GpuProgram): void {
let buffersToBind = this._renderer.pipeline.getBufferNamesToBind(geometry, program);
let keys = buffersToBind._keyCache ?? PixiGcPatches._cacheBufferKeys(buffersToBind);
for (let i = 0; i < keys.length; i++) {
let index = keys[i]!;
this._setVertexBuffer(index, geometry.attributes[buffersToBind[index]!]!.buffer);
}
if (geometry.indexBuffer) {
this._setIndexBuffer(geometry.indexBuffer);
}
};
}
private static _patchShader(): void {
let prototype = Pixi.Shader.prototype as unknown as GroupKeyCachedShader & {
addResource(name: string, groupIndex: number, bindIndex: number): void;
};
let originalAddResource = prototype.addResource;
prototype.addResource = function (name: string, groupIndex: number, bindIndex: number): void {
this._groupKeyCache = undefined;
originalAddResource.call(this, name, groupIndex, bindIndex);
};
}
private static _bindGroupKeys(bindGroup: KeyCachedBindGroup): string[] {
return (bindGroup._keyCache ??= Object.keys(bindGroup.resources));
}
private static _cacheBufferKeys(buffersToBind: KeyCachedBufferNames): number[] {
let keys = PixiGcPatches._numberKeys(buffersToBind);
Object.defineProperty(buffersToBind, '_keyCache', { value: keys });
return keys;
}
private static _numberKeys(map: object): number[] {
let names = Object.keys(map);
let keys: number[] = [];
for (let i = 0; i < names.length; i++) {
keys.push(Number(names[i]));
}
return keys;
}
}
Source: pixijs/pixijs