#489

0.6.0-rc.1: encoding a draw costs 2.5× more than rc.0 (bind-cache maintenance and per-encode preflight)

Openmatiasngfopened 1mo ago

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):

FunctionSelf 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 itself3.7 %
getSync2.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

  1. maintain() runs on every cache hit.
    • bind-cache.js getOrCreate calls state.lifetime.maintain() before returning an existing bind group.
    • binding-lifetime.js maintain() then walks up to MAINTENANCE_BUDGET (16) lifetime records, each with a records.get, a WeakRef.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.
  2. 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.
  3. 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 every pipelineFor(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.

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.1

Observed (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

  1. 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.
  2. 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.
  3. 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.
  4. 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 (maintain on 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

0

No activity yet.