vp2vi / engine /arena.js
DanVP's picture
feat: publish vp2vi WebGPU browser app
c971a45 verified
Raw
History Blame Contribute Delete
4.86 kB
// Dumb-but-sufficient run-scoped GPU allocator. Encoder/decoder passes
// allocate their activations through an arena and destroy everything at once
// after readback / run end. No reuse, no sub-allocation — perf tasks replace
// this later.
// Requested-byte accounting across every arena (encoder scratch/retained,
// decode state, compaction). Weights, staging rings and uniform-pool banks are
// NOT arenas and are not counted. This is not an OS/Metal residency counter:
// `live` is JavaScript-owned arena storage, while `retired` conservatively
// keeps post-submit destroys charged until that submit drains.
// One module-wide ledger: a session drives one device, and tests that need
// isolation reset the peaks explicitly.
const stats = {
allocBytes: 0,
freedBytes: 0,
liveBytes: 0,
highWaterBytes: 0,
liveBuffers: 0,
retiredBytes: 0,
retiredBuffers: 0,
submittedHighWaterBytes: 0,
};
export function getArenaStats() {
return {
...stats,
submittedBytes: stats.liveBytes + stats.retiredBytes,
submittedBuffers: stats.liveBuffers + stats.retiredBuffers,
};
}
// Start a fresh peak window (e.g. per file batch): each high-water mark
// restarts from its CURRENT footprint, not from zero.
export function resetArenaPeak() {
stats.highWaterBytes = stats.liveBytes;
stats.submittedHighWaterBytes = stats.liveBytes + stats.retiredBytes;
}
function trackAlloc(size) {
stats.allocBytes += size;
stats.liveBytes += size;
stats.liveBuffers += 1;
if (stats.liveBytes > stats.highWaterBytes) stats.highWaterBytes = stats.liveBytes;
const submitted = stats.liveBytes + stats.retiredBytes;
if (submitted > stats.submittedHighWaterBytes) {
stats.submittedHighWaterBytes = submitted;
}
}
function trackFree(size) {
stats.freedBytes += size;
stats.liveBytes -= size;
stats.liveBuffers -= 1;
}
function trackRetire(size) {
stats.liveBytes -= size;
stats.liveBuffers -= 1;
stats.retiredBytes += size;
stats.retiredBuffers += 1;
}
function trackDrained(size) {
stats.retiredBytes -= size;
stats.retiredBuffers -= 1;
stats.freedBytes += size;
}
export function createArena(device) {
const buffers = []; // [buffer, size] — size recorded here so destroy() never
// depends on GPUBuffer.size existing (mock devices).
return {
// Plain allocation (byteLength is rounded up to a multiple of 4).
buf(byteLength, usage, label) {
const size = Math.ceil(byteLength / 4) * 4;
const buffer = device.createBuffer({ label, size, usage });
buffers.push([buffer, size]);
trackAlloc(size);
return buffer;
},
// Uniform buffer written once at creation from a TypedArray view.
uniform(view, label) {
const size = Math.max(16, Math.ceil(view.byteLength / 16) * 16);
const buffer = device.createBuffer({
label,
size,
usage: GPUBufferUsage.UNIFORM,
mappedAtCreation: true,
});
new Uint8Array(buffer.getMappedRange())
.set(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
buffer.unmap();
buffers.push([buffer, size]);
trackAlloc(size);
return buffer;
},
destroy() {
for (const [b, size] of buffers) {
b.destroy();
trackFree(size);
}
buffers.length = 0;
},
// Post-submit retirement: invalidate the handles immediately, but keep
// their requested bytes in the conservative submitted footprint until
// the queue work that references them settles. The rejection arm releases
// accounting too — a lost queue cannot keep useful submitted work alive.
destroyDeferred(drained) {
if (!drained || typeof drained.then !== 'function') {
throw new Error('arena.destroyDeferred needs a queue-drain promise');
}
if (buffers.length === 0) return Promise.resolve();
const retired = buffers.splice(0);
for (const [b, size] of retired) {
b.destroy();
trackRetire(size);
}
const release = () => {
for (const [, size] of retired) trackDrained(size);
};
return Promise.resolve(drained).then(release, release);
},
};
}
// Two ownership classes behind one compatible destroy handle. Encoder
// scratch can be retired immediately after submit while cross-KV/lens (and an
// optional inspectable output) remain alive for decode/readback. Both cleanup
// methods are idempotent because createArena.destroy() empties its list.
export function createSplitArena(device) {
const retained = createArena(device);
const scratch = createArena(device);
return {
retained,
scratch,
destroyScratch() { scratch.destroy(); },
retireScratch(drained) { return scratch.destroyDeferred(drained); },
destroy() {
scratch.destroy();
retained.destroy();
},
};
}