Package: vgpu@0.6.0-rc.1 (dist paths relative to node_modules/vgpu/dist)
Area: bind-cache.js (getOrCreate), binding-lifetime.js (maintain), the draw bindGroups() preflight, the pipeline key in pipeline-store
Severity: performance regression. CPU time per encoded draw, every frame, with nothing changing.
Summary
The same scene encodes 2.5× slower on rc.1 than on rc.0:
- 4.5 µs per draw on rc.1, against 1.8 µs on rc.0;
- the scene: 2000 draws × 3 passes, each draw binding three uniform
Buffers by identity, with no managed values and no rebinding; - nothing in it changes between frames, and every bind group is a cache hit.
In a real scene (~2000 draws, 3 passes, vsync off), rc.1 renders ~430 fps where a comparable three.js renderer renders ~545 fps. On rc.0 the vgpu build was the faster of the two.
Self time in a Chrome CPU profile of that scene (production bundle, 10 s while rendering):
| Function | Self time |
|---|---|
binding-lifetime.js maintain (called from bind-cache.js getOrCreate on every hit) | 18.2 % |
draw bindGroups() preflight (p("preflight"), revalidating every binding on every encode) | 9.8 % |
the per-group closure in bindGroups() (building the binding list and key) | 6.1 % |
pipeline key (JSON.stringify of the vertex buffer layouts, per pipelineFor) | 4.5 % |
getOrCreate itself | 3.7 % |
getSync | 2.3 % |
getOrCreate is 24 % inclusive and bindGroups() 34 % inclusive. The GPU work and native calls (setBindGroup, setVertexBuffer) are small next to that.
Causes, in the code
maintain()runs on every cache hit.bind-cache.jsgetOrCreatecallsstate.lifetime.maintain()before returning an existing bind group.binding-lifetime.jsmaintain()then walks up toMAINTENANCE_BUDGET(16) lifetime records, each with arecords.get, aWeakRef.deref()and a removal check.- A frame of 2000 draws × 3 groups × 3 passes does ~18 000 hits, so ~290 000 record visits per frame on a cache that is not changing. The incremental sweep (added for #484) is paid per lookup instead of per frame.
- Preflight on every encode.
bindGroups()first runs a preflight over every reflected binding (h(e) && !claimed.has(group) && I(e.info)), destroyed-resource checks included, even when no binding changed since the previous encode. - Keys rebuilt per lookup.
- The binding key is a new string built per group per encode (
bindingKey: sort check, then${binding}:${key.length}:${key}concatenation). - The pipeline key
JSON.stringifys the vertex buffer layouts on everypipelineFor(target)call. - Both also add garbage. In our runs of the same scene, the JS heap peak while rendering rose from ~78 MB on rc.0 to ~117 MB on rc.1 (measured in separate sessions), all of it collected.
- The binding key is a new string built per group per encode (
Reproduction (Node, vgpu/mock)
The script repro-encode-cost.mjs (at the end) builds 2000 draws, each with three identity-bound uniform Buffers and a one-triangle geometry. It encodes 3 passes per frame and waits for each frame to settle. It times only the frame() encoding. It takes the path of a vgpu/dist/mock.js, so the same script runs against both versions; on rc.1 it prepares the shader with @vgpu/wgsl/prepare.
node repro-encode-cost.mjs <rc.0>/node_modules/vgpu/dist/mock.js rc.0
node repro-encode-cost.mjs <rc.1>/node_modules/vgpu/dist/mock.js rc.1Observed (Apple M4 Pro, Node 25, two runs each):
rc.0: 10.81 ms to encode 2000 draws × 3 passes (1.80 µs per draw)
rc.1: 28.38 ms to encode 2000 draws × 3 passes (4.73 µs per draw)
rc.0: 10.84 ms to encode 2000 draws × 3 passes (1.81 µs per draw)
rc.1: 26.08 ms to encode 2000 draws × 3 passes (4.35 µs per draw)Suggested fixes
- Move lifetime maintenance off the hit path. Run
maintain()once per frame (at submit or frame release), or only on misses and registrations. Alternatively, keep a per-frame budget that resets once per frame instead of 16 visits per lookup. A cache hit should be a map lookup plus the LRU touch. - Skip the preflight for a draw whose bindings have not changed. Keep a per-draw generation that
set(), resource destruction (the lifetime markers already exist) and layout replacement bump. Re-run the preflight only when the generation changed since the draw's last successful encode. - Cache the keys.
- Store the binding key per (draw, group) and rebuild it only when that group's bindings change.
- Store the pipeline key per (draw, target signature) instead of re-stringifying the vertex layouts per call. The layouts are fixed at draw creation.
- Add a benchmark that encodes N unchanged identity-bound draws per frame and fails when the time per draw regresses against the previous release.
Acceptance criteria
- The repro shows ≤ 2 µs per draw on rc.1-next on the mock, which is the rc.0 level, with the #484 guarantees intact. The draw-retention repro from #484 must still report 0 live draws after GC.
- No
maintain()work, and no binding or pipeline key building, on an encode whose draw and resources did not change. Verify with a test spy or counters. - Destroying a bound resource still fails the next encode of a draw that binds it with
VGPU-R1-BINDING-DESTROYED(or the existing destroyed-binding error).
Related
- #483, #484, #485 (fixed in rc.1). This regression comes from the #484 lifetime tracking (
maintainon hits) and from per-encode validation.
Reproduction script: repro-encode-cost.mjs
// node encode-cost.mjs <path to vgpu/dist/mock.js> <label>
// Encode cost per draw with identity-bound uniform buffers (no managed values): N draws, 3 passes per frame.
const { init, draw, target, frame, geometry } = await import(process.argv[2]);
let prepare = (s) => s;
try { ({ prepareShader: prepare } = await import(new URL('../../@vgpu/wgsl/dist/prepare.js', `file://${process.argv[2]}`).href)); } catch { /* rc.0 takes strings */ }
const gpu = await init();
const src = `struct Frame { viewProjection: mat4x4f }
struct Object { world: mat4x4f }
struct Material { color: vec4f }
@group(0) @binding(0) var<uniform> frame: Frame;
@group(1) @binding(0) var<uniform> object: Object;
@group(2) @binding(0) var<uniform> material: Material;
@vertex fn vs_main(@location(0) position: vec3f) -> @builtin(position) vec4f { return frame.viewProjection * object.world * vec4f(position, 1); }
@fragment fn fs_main() -> @location(0) vec4f { return material.color; }`;
const shader = prepare(src, 'encode.wgsl');
const buffer = (size) => gpu.device.createBuffer({ size, usage: ['uniform', 'copy_dst'] });
const frameBuffer = buffer(64), materialBuffer = buffer(16);
const mesh = geometry(gpu, { buffers: [{ data: new Float32Array(9), stride: 12, attributes: { position: { format: 'float32x3', offset: 0, location: 0 } } }], vertexCount: 3 });
const out = target(gpu, { size: [8, 8], depth: true });
const N = Number(process.env.N ?? 2000);
const draws = Array.from({ length: N }, () => draw(gpu, { shader, geometry: mesh, set: { frame: frameBuffer, object: buffer(64), material: materialBuffer } }));
const run = () => frame(gpu, f => { for (let p = 0; p < 3; p++) f.pass({ target: out }, pass => { for (const d of draws) pass.draw(d); }); });
for (let i = 0; i < 5; i++) { run(); await gpu.settled(); }
let encode = 0;
for (let i = 0; i < 20; i++) { const t = performance.now(); run(); encode += performance.now() - t; await gpu.settled(); }
const perFrame = encode / 20;
console.log(`${process.argv[3]}: ${perFrame.toFixed(2)} ms to encode ${N} draws × 3 passes (${(perFrame * 1000 / (N * 3)).toFixed(2)} µs per draw)`);Activity
0No activity yet.