Package: vgpu@0.6.0-rc.0 (dist paths below are relative to node_modules/vgpu/dist)
Area: set-core.js (bindGroupFor), frame-uniforms.js (FrameUniforms), bind-cache.js, uniforms.js (SharedUniforms)
Severity: performance. CPU cost grows with the number of draws on every frame, even when no uniform value changed.
Summary
Any draw that binds a JS-owned uniform gets a new bind-group identity on every frame. That covers a uniforms(gpu, …) / SharedUniforms block and a plain struct passed to draw.set({ name: { … } }). The same happens if no value changed since the previous frame. Because the identity is new, bindGroupFor misses the bind-group cache and calls device.createBindGroup for every draw, for every group holding such a uniform, on every frame. When the frame ends, FrameUniforms.release() evicts every captured identity through BindGroupCache.evictIdentity, which scans the whole cache once per identity. That makes the release step O(captures × cache entries).
In a real scene (~1–2k draws per frame across 3 passes: two shadow maps plus the main pass, each draw binding a shared per-view uniform and a per-object struct), this was the main CPU cost. The CPU profile showed:
| Function | Share of main-thread time |
|---|---|
evictIdentity | 15 % |
createBindGroup | 3.9 % |
set | 2.1 % |
capture | 1.6 % |
On a 120 Hz display the app ran at ~56 fps (p95 frame 49 ms, ~850 frames over 1.5 refresh intervals in a 33 s run). After the workaround below, it holds 120 fps (p95 9.2 ms) with the GPU work unchanged.
How it happens (code walk)
-
set-core.jsbindGroupFor(around lines 167–188). For every binding in the group:const value = state.uniformValue?.() ?? (state.bytes && binding.addressSpace === "uniform" ? { owner: state, revision: state.revision, bytes: new Uint8Array(state.bytes) } : undefined); const captured = capture && value ? capture.capture(value, options.cache) : undefined; ... identity: captured?.identity ?? state.identitySharedUniformsalways providesuniformValue(uniforms.jsaround line 56).- Lib-owned structs always have
state.bytes. - So inside
frame()every such binding is captured. The lib-owned branch also allocates anew Uint8Array(state.bytes)copy per draw per frame.
-
frame-uniforms.jsFrameUniforms.capture.- A value is de-duplicated only within one frame's arena (
#valueskeyed by owner and revision). - Its identity is
`uniform-capture:${this.#id}:${this.#pages.length}:${offset}`.#idcomes fromnextArena++, and every frame gets a newFrameUniforms. - So the identity is new every frame, even when
revisiondid not change.
- A value is de-duplicated only within one frame's arena (
-
bind-cache.jsgetOrCreate.- The key is
`${drawId}:${group}:${identities.join("|")}`, which builds a new string per draw per group per frame. - With a new identity it always misses, so
createBindGroupruns.
- The key is
-
FrameUniforms.release()callscache.evictIdentity(identity)for every identity captured in the frame. -
bind-cache.jsevictIdentitywalks every entry and runsentry.identities.includes(needle)on each. That is O(N_captures × N_entries) per frame, which is why it dominated the profile.
Reproduction (Node, vgpu/mock)
The full repro-uniform-capture.mjs script is at the end of this issue.
- It counts
createBindGroupcalls by wrappinggpu.gpu.createBindGroup. - It renders N draws in 2 passes per frame. Each draw binds
frame(shared camera) andobject(per-draw world matrix), and the values never change.
node repro-uniform-capture.mjs struct # idiomatic: uniforms() + draw.set({ object: { world } })
node repro-uniform-capture.mjs buffer # workaround: vgpu Buffers bound by identity
N=3000 node repro-uniform-capture.mjs struct
N=3000 node repro-uniform-capture.mjs bufferObserved (Apple M4 Pro, Node 24):
struct: 4000 createBindGroup per frame, 9.83 ms CPU per frame (1000 draws x 2 passes, vgpu/mock)
buffer: 0 createBindGroup per frame, 6.69 ms CPU per frame (1000 draws x 2 passes, vgpu/mock)
struct: 12000 createBindGroup per frame, 39.23 ms CPU per frame (3000 draws x 2 passes, vgpu/mock)
buffer: 0 createBindGroup per frame, 23.42 ms CPU per frame (3000 draws x 2 passes, vgpu/mock)- Bind groups per frame = draws × uniform groups × passes, every frame.
- The struct mode grows faster than linearly. The mock adds a constant per draw to both modes, and in a real browser the gap is much larger:
createBindGroupandGPUBindGroupGC are expensive there.
Workaround we used (application side)
We stopped handing vgpu JS-owned uniform values on hot paths:
- One vgpu
Bufferper uniform struct (gpu.device.createBuffer({ size, usage: ['uniform', 'copy_dst'] })).- We pack it ourselves with the layout from
reflectSource(wgsl).bindings[i].layout(@vgpu/wgsl/reflect-source). - We write it in place with
queue.writeBuffer, skipping the write when the packed bytes did not change. - The
Bufferis bound by identity (set({ name: buffer })), so its bind group is created once and reused.
- We pack it ourselves with the layout from
- Per-pass values (camera matrices for each shadow map, the main view and a mirror view) get one buffer per view, with one draw per (object, view).
writeBufferlands before the submit, so the last write wins for every pass of the submission. A single buffer cannot hold different values for two passes of one frame; capture semantics currently exist to make that work. - Per-object world matrices live in one 64-byte buffer per object. It is rewritten only when the matrix changes.
- Effects drawn every frame (post-processing composite, present) bind their parameter struct as a
Buffertoo.
Result: zero createBindGroup calls in steady state, and evictIdentity / capture disappear from the profile.
Rough edges we hit while doing this:
{ buffer: GPUBuffer }cannot be used for a uniform binding.set-core.jsownershipForasksisPlainValuefirst, and a plain{ buffer }object counts as a plain value. vgpu then tries to pack it as the struct and fails withVGPU-SET-VALUE-INVALID: field 'buffer' is not declared by the layout. So theisGPUBufferBindingbranch inset-resources.jsnormalizeBufferResourceis unreachable for uniform bindings, and we had to use vgpuBuffers. The rawGPUBuffer/GPUBufferBindingpath, documented as the cheap identity-stable option, doesn't work for the common case.- The application now owns packing. That duplicates
writeLayoutValue(set-packing.js), which is not exported, including its validation of unknown fields, vector lengths and integer scalars. UniformPoolwithdraw.group()and dynamic offsets is the documented "many objects" answer. But it means claiming groups and building layouts by hand, so it doesn't help users who just calldraw.set({ object: { world } }).
Suggested fixes (any one helps; 1 and 3 together remove the problem)
-
Keep identities stable across frames when the bytes didn't change.
- Only capture into the frame arena when the owner's
revisionchanged since it was last bound by this draw, or when the same owner is bound with different revisions within one submission (the case snapshot semantics exist for). - Otherwise bind the owner's persistent buffer through the existing
prepareUniformpath (state.identitystays the same, the bind group comes from the cache). SharedUniformsalready owns a buffer, so this is mostly bookkeeping: remember the last revision and submission per binding.
- Only capture into the frame arena when the owner's
-
Or bind captured uniforms through dynamic offsets.
- Create the captured binding's layout with
hasDynamicOffset: true. - Make the bind group depend only on the page buffer, which is pooled and stable (
poolsalready recycles pages per device). - Pass the capture offset in
setBindGroup(group, bindGroup, [offset]). One bind group per (draw, page) then lasts for the life of the page, and capture stays correct per pass.
- Create the captured binding's layout with
-
Make
evictIdentityO(1).- Keep an inverted index
Map<identityKey, Set<cacheKey>>increateBindGroupCache, and delete only those keys. - Also stop building per-draw string keys from
identities.join("|")every frame. For example, nest maps by drawId, then group, then identity tuple, or hash the identity ids.
- Keep an inverted index
-
Avoid the per-draw copy
new Uint8Array(state.bytes)inbindGroupForfor lib-owned structs. Capture can copy fromstate.bytesdirectly, since the page copy already snapshots it. -
Let
{ buffer, offset?, size? }reachnormalizeBufferResourcefor uniform bindings. CheckisGPUBufferBinding/isRawGPUBufferbeforeisPlainValueinownershipFor, or require structs to carry nobufferkey that holds aGPUBuffer. -
Document the cost.
performance-patterns/performance-playbookcurrently recommenduniforms(gpu, …)plusdraw.set({ value })for animated uniforms.- With many draws, that recommendation is the slow path. The docs should say when capture happens and what it costs.
Acceptance criteria
- With the repro script below in
structmode and unchanged values, steady-state frames make 0createBindGroupcalls (or ≤ 1 per draw per page under option 2). CPU per frame is within ~10 % ofbuffermode. - When a
SharedUniformsblock changes between two passes of oneframe(), each pass still sees its own value. A test should cover this: setviewProjectionto A, encode pass 1, set it to B, encode pass 2, read back both targets. - Frame-end cost no longer grows with cache size × captures. A benchmark test with 3k draws should scale linearly.
{ buffer: gpuBuffer }works forvar<uniform>bindings, or fails with an error that says to pass a vgpuBuffer.- No regression in bundles:
watchDrawResourcesand bundle replay keep captured buffers alive (set-core.jscomment around line 141).
Reproduction script: repro-uniform-capture.mjs
Run it from a project that has vgpu@0.6.0-rc.0 installed.
import { init, draw, target, frame, uniforms } from 'vgpu/mock';
const gpu = await init();
let bindGroups = 0;
const create = gpu.gpu.createBindGroup.bind(gpu.gpu);
gpu.gpu.createBindGroup = (d) => { bindGroups++; return create(d); };
const shader = `struct Frame { viewProjection: mat4x4f }
struct Object { world: mat4x4f }
@group(0) @binding(0) var<uniform> frame: Frame;
@group(1) @binding(0) var<uniform> object: Object;
@vertex fn vs_main() -> @builtin(position) vec4f { return frame.viewProjection * object.world * vec4f(0, 0, 0, 1); }
@fragment fn fs_main() -> @location(0) vec4f { return vec4f(1); }`;
const identity = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]);
const mode = process.argv[2] ?? 'struct';
const N = Number(process.env.N ?? 1000), out = target(gpu, { size: [8, 8] });
const buffer = () => gpu.device.createBuffer({ size: 64, usage: ['uniform', 'copy_dst'] });
// struct: idiomatic vgpu (shared `uniforms()` for the camera, plain struct per draw).
// buffer: the workaround (one camera buffer per pass, one object buffer per draw, vgpu Buffers bound by identity).
const shared = uniforms(gpu, { viewProjection: identity });
const cameras = [buffer(), buffer()];
const draws = Array.from({ length: N }, () => mode === 'struct'
? { passes: [draw(gpu, { shader, vertices: 3, set: { frame: shared, object: { world: identity } } })] }
: (() => { const object = buffer(); return { object, passes: cameras.map(c => draw(gpu, { shader, vertices: 3, set: { frame: c, object } })) }; })());
const run = () => frame(gpu, f => {
for (const [pass, camera] of cameras.entries()) {
if (mode === 'struct') shared.set({ viewProjection: identity }); else gpu.gpu.queue.writeBuffer(camera.gpu, 0, identity);
f.pass({ target: out }, p => { for (const d of draws) {
if (mode === 'struct') { d.passes[0].set({ object: { world: identity } }); p.draw(d.passes[0]); }
else p.draw(d.passes[pass]);
} });
}
});
run(); run();
const before = bindGroups, t = performance.now();
for (let i = 0; i < 20; i++) run();
console.log(`${mode}: ${(bindGroups - before) / 20} createBindGroup per frame, ${((performance.now() - t) / 20).toFixed(2)} ms CPU per frame (${N} draws x 2 passes, vgpu/mock)`);
Verified on
0.6.0-rc.1: bind groups are now stable. Once frames complete (as in a rendering loop), the repro creates 0createBindGroupper frame in every variant I tried: static values, values re-set with identical bytes, a camera re-set per pass, and one object changing per frame. Thanks!One acceptance criterion is still not met: "CPU per frame is within ~10 % of
buffermode". Captured managed uniforms are re-copied and re-uploaded every frame even when their bytes did not change. Each frame gets a newFrameUniformsarena, so every bound owner is captured again into a page, andflush()writes the used pages.Updated repro (rc.1: prepared shader, frames settle between iterations, counts
writeBufferbytes, times only the encoding). 1000 draws × 2 passes, values never change:That is ~+38 % CPU and ~250 KB/frame of uploads for data that is constant. 250 KB is about 1000 per-draw object structs, each 256-byte aligned. A scene with ~2000 draws and a few managed structs per draw would upload ~0.5–1 MB per frame, 60–120 MB/s at 120 Hz, which is why we still bind identity
Buffers written in place.Suggestion (fix 1 from the issue): when an owner's
revisionhas not changed since its last capture, and it is not set to different values within the submission, reuse its previous range. Equivalently, bind the owner's persistent buffer through theprepareUniformpath instead of capturing it again. Then only values that change cost a copy and an upload. The per-operation snapshot semantics stay intact, because a value set between two operations bumps the revision and is captured as today.Note: part of the remaining per-frame CPU in both modes is a separate rc.1 encode regression, reported in #489 (bind-cache
maintain()on every hit, per-encode preflight, key building). The struct/buffer gap above comes on top of that.repro-uniform-capture-rc1.mjs