#483

Per-frame uniform capture rebuilds every bind group on every frame

Openmatiasngfopened 1mo ago

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:

FunctionShare of main-thread time
evictIdentity15 %
createBindGroup3.9 %
set2.1 %
capture1.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)

  1. set-core.js bindGroupFor (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.identity
    • SharedUniforms always provides uniformValue (uniforms.js around line 56).
    • Lib-owned structs always have state.bytes.
    • So inside frame() every such binding is captured. The lib-owned branch also allocates a new Uint8Array(state.bytes) copy per draw per frame.
  2. frame-uniforms.js FrameUniforms.capture.

    • A value is de-duplicated only within one frame's arena (#values keyed by owner and revision).
    • Its identity is `uniform-capture:${this.#id}:${this.#pages.length}:${offset}`. #id comes from nextArena++, and every frame gets a new FrameUniforms.
    • So the identity is new every frame, even when revision did not change.
  3. bind-cache.js getOrCreate.

    • 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 createBindGroup runs.
  4. FrameUniforms.release() calls cache.evictIdentity(identity) for every identity captured in the frame.

  5. bind-cache.js evictIdentity walks every entry and runs entry.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 createBindGroup calls by wrapping gpu.gpu.createBindGroup.
  • It renders N draws in 2 passes per frame. Each draw binds frame (shared camera) and object (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 buffer

Observed (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: createBindGroup and GPUBindGroup GC are expensive there.

Workaround we used (application side)

We stopped handing vgpu JS-owned uniform values on hot paths:

  • One vgpu Buffer per 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 Buffer is bound by identity (set({ name: buffer })), so its bind group is created once and reused.
  • 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). writeBuffer lands 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 Buffer too.

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.js ownershipFor asks isPlainValue first, and a plain { buffer } object counts as a plain value. vgpu then tries to pack it as the struct and fails with VGPU-SET-VALUE-INVALID: field 'buffer' is not declared by the layout. So the isGPUBufferBinding branch in set-resources.js normalizeBufferResource is unreachable for uniform bindings, and we had to use vgpu Buffers. The raw GPUBuffer / GPUBufferBinding path, 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.
  • UniformPool with draw.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 call draw.set({ object: { world } }).

Suggested fixes (any one helps; 1 and 3 together remove the problem)

  1. Keep identities stable across frames when the bytes didn't change.

    • Only capture into the frame arena when the owner's revision changed 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 prepareUniform path (state.identity stays the same, the bind group comes from the cache).
    • SharedUniforms already owns a buffer, so this is mostly bookkeeping: remember the last revision and submission per binding.
  2. 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 (pools already 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.
  3. Make evictIdentity O(1).

    • Keep an inverted index Map<identityKey, Set<cacheKey>> in createBindGroupCache, 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.
  4. Avoid the per-draw copy new Uint8Array(state.bytes) in bindGroupFor for lib-owned structs. Capture can copy from state.bytes directly, since the page copy already snapshots it.

  5. Let { buffer, offset?, size? } reach normalizeBufferResource for uniform bindings. Check isGPUBufferBinding / isRawGPUBuffer before isPlainValue in ownershipFor, or require structs to carry no buffer key that holds a GPUBuffer.

  6. Document the cost.

    • performance-patterns / performance-playbook currently recommend uniforms(gpu, …) plus draw.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 struct mode and unchanged values, steady-state frames make 0 createBindGroup calls (or ≤ 1 per draw per page under option 2). CPU per frame is within ~10 % of buffer mode.
  • When a SharedUniforms block changes between two passes of one frame(), each pass still sees its own value. A test should cover this: set viewProjection to 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 for var<uniform> bindings, or fails with an error that says to pass a vgpu Buffer.
  • No regression in bundles: watchDrawResources and bundle replay keep captured buffers alive (set-core.js comment 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)`);

Activity

1
matiasngf1mo ago

Verified on 0.6.0-rc.1: bind groups are now stable. Once frames complete (as in a rendering loop), the repro creates 0 createBindGroup per 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 buffer mode". Captured managed uniforms are re-copied and re-uploaded every frame even when their bytes did not change. Each frame gets a new FrameUniforms arena, so every bound owner is captured again into a page, and flush() writes the used pages.

Updated repro (rc.1: prepared shader, frames settle between iterations, counts writeBuffer bytes, times only the encoding). 1000 draws × 2 passes, values never change:

struct: 0 createBindGroup, 250 KB uploaded, 10.80 ms to encode per frame (1000 draws x 2 passes, vgpu/mock)
buffer: 0 createBindGroup, 0 KB uploaded,  7.63 ms to encode per frame (1000 draws x 2 passes, vgpu/mock)
struct: 0 createBindGroup, 250 KB uploaded, 10.58 ms to encode per frame (1000 draws x 2 passes, vgpu/mock)
buffer: 0 createBindGroup, 0 KB uploaded,  7.81 ms to encode per frame (1000 draws x 2 passes, vgpu/mock)

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 revision has 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 the prepareUniform path 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
import { init, draw, target, frame, uniforms } from 'vgpu/mock';
import { prepareShader } from '@vgpu/wgsl/prepare';
const gpu = await init();
let bindGroups = 0, uploaded = 0;
const write = gpu.gpu.queue.writeBuffer.bind(gpu.gpu.queue);
gpu.gpu.queue.writeBuffer = (b, o, d, off, size) => { uploaded += size ?? (d.byteLength - (off ?? 0)); return write(b, o, d, off, size); };
const create = gpu.gpu.createBindGroup.bind(gpu.gpu);
gpu.gpu.createBindGroup = (d) => { bindGroups++; return create(d); };
const shader = prepareShader(`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); }`, 'shader.wgsl');
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'] });
// Frames settle between iterations, as a rendering loop with frames completing does.
// 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(); await gpu.settled(); run(); await gpu.settled();
const before = bindGroups, u0 = uploaded; let encode = 0;
for (let i = 0; i < 20; i++) { const t = performance.now(); run(); encode += performance.now() - t; await gpu.settled(); }
console.log(`${mode}: ${(bindGroups - before) / 20} createBindGroup, ${((uploaded - u0) / 20 / 1024).toFixed(0)} KB uploaded, ${(encode / 20).toFixed(2)} ms to encode per frame (${N} draws x 2 passes, vgpu/mock)`);