vp2vi / engine /generate.js
DanVP's picture
feat: publish vp2vi WebGPU browser app
c971a45 verified
Raw
History Blame Contribute Delete
24.5 kB
// Full GPU-resident greedy decode: tokenize → encoder (one submit) → decode
// loop in groups of K=8 steps per submit. Within a group all 25·K dispatches
// are recorded into one compute pass — the argmax kernel writes each step's
// token into the ring and the next step's embed reads it, so the CPU never
// sits between steps. After each group ONE readback (ring slice + done flags)
// tells the CPU which tokens were produced and whether every row has hit eos.
//
// Decode-ahead (default): group g+1 is encoded and submitted BEFORE awaiting
// group g's readback, so the GPU never drains while the CPU maps/collects.
// GPU-side correctness is free — queue order guarantees group g's argmax
// writes the ring before group g+1's embed reads it. The cost is that EOS
// early-exit lags one group: when group g's flags show all rows done, group
// g+1 has already been submitted (≤ (inFlight-1)·GROUP_STEPS wasted steps per
// run; done rows just produce PAD, which never enters a sequence). inFlight
// staging buffers rotate: a buffer is copied into, mapped, read, unmapped —
// and only then reused (N groups in flight ⇒ N buffers is exactly enough).
// Pass {decodeAhead: false} for the sequential A/B path.
//
// Adaptive submit budget (submitBudgetMs, default 1000): group readback
// waits over the budget halve the steps-per-submit for the rest of the
// batch — slow mobile GPUs converge to submits the OS watchdog tolerates
// instead of dying with VK_ERROR_DEVICE_LOST; fast GPUs never trip it.
//
// Timing semantics (spike-grade, documented not perfect):
// encoderMs wall time from the runEncoder() call until
// queue.onSubmittedWorkDone() resolves for its submit —
// includes tokenized-batch upload + command recording overhead.
// decodeMs wall time of the whole decode loop (GPU work + per-group
// mapAsync readbacks + CPU collection).
// cpuEncodeMs Σ per-group CPU time recording commands + submitting.
// awaitMs Σ per-group time awaiting mapAsync + copying the readback.
import { tokenizeBatch } from './tokenizer.js';
import { runEncoder } from './encoder.js';
import {
createDecodeState, compactDecodeState, compactDecodeStateInPlace,
encodeDecodeStep, growDecodeKV,
} from './decoder.js';
import {
createUniformParamPool, getDispatchStats, shouldUseUniformParamPool,
} from './pipelines.js';
import { maxNewTokensFor, maxBatchForLimits } from './shapes.js';
import { EOS, DECODER_START, DECODE_CAP } from './constants.js';
const GROUP_STEPS = 8; // decode steps recorded per submit
const SUBMIT_BUDGET_MS = 1000; // adaptive per-submit ceiling (submitBudgetMs)
// One-way ratchet for the adaptive submit budget: halve steps-per-submit
// whenever a group's readback wait exceeded the budget. With the pipeline
// primed the CPU parks on mapAsync for almost exactly the GPU tail of that
// group, so the wait is a lower bound on the group's GPU time — overshooting
// means the submit kept the GPU busy past the budget, the regime where
// Android compositor fences start missing and the driver eventually kills
// the context (measured on Adreno 710: 3000ms fence misses, then
// vkQueueSubmit VK_ERROR_DEVICE_LOST once submits reach ~4-5s; ~1.5s
// submits survive a 286k-char run). Never grows back within a batch: file
// mode sorts long rows last so pressure only rises, a false shrink costs a
// few % submit overhead, a missed shrink costs the device.
export function nextGroupSteps(cur, groupAwaitMs, budgetMs) {
if (!budgetMs || cur <= 1 || !(groupAwaitMs > budgetMs)) return cur;
return Math.max(1, cur >> 1);
}
// translateBatch(ctx, weights, tok, sources,
// {maxNewTokens?, onProgress?, onPrimed?, decodeAhead?,
// inFlight?, compact?, overlapEnc?})
// ctx {device, limits?, ...} from initDevice()
// sources array of source strings (one batch)
// onPrimed called ONCE, right after the encoder and the first decode
// groups are submitted and the loop is about to park on the GPU —
// the spot where caller CPU work (pre-tokenizing the next batch)
// overlaps GPU execution instead of delaying submits
// overlapEnc (default true) skip the queue drain between encoder and
// decode: queue order already sequences crossKV before its
// readers, so the first decode groups are recorded while the GPU
// still encodes. encoderMs is then measured via a non-blocking
// onSubmittedWorkDone.then() and overlaps decodeMs by a few ms
// (stage sums read slightly high; wall is what drops). false
// restores the drained baseline (A/B arm).
// inFlight decode-ahead depth: 2 (default, double-buffered staging) or 3
// (triple-buffered). Measured on 64 file23k chunks at B=64
// (m5_inflight_ab, 2026-07-06): median 28737 vs 28724 tok/s — a
// wash; the readback gap is already hidden at depth 2, so 2 stays
// the default.
// compact EOS row compaction (default true): when ≥ max(8, B·compactFrac)
// rows of the current batch have emitted eos, stop submitting,
// drain the in-flight groups, and rebuild the decode context with
// only the live rows (compactDecodeState) — finished rows
// otherwise keep burning GEMM rows and attention workgroups until
// the whole group hits eos. Kernel routing is pinned across
// compactions, so output is token-exact vs {compact: false}
// (compact_equiv gate).
// compactFrac dead fraction of the CURRENT batch that triggers a compaction
// (default 0.25 — the original B>>2). Lower = more compactions:
// each costs a drain of the in-flight groups plus the live-row
// copies, each saves dead-row GEMM/attention work for every
// remaining step. Compaction count and step timings shift but
// tokens stay exact at ANY value. MEASURED INSENSITIVE on sorted
// file batches (dec_compact_sweep 2026-07-09: 0.25→0.03 identical
// wall/compactions — uniform rows die in one synchronized wave, so
// every threshold fires at the same group boundary).
// groupSteps decode steps recorded per submit (default 8). Smaller halves
// the EOS-detection latency (dead rows compute until their group's
// readback lands) at the cost of more submits; token-exact at any
// value — compaction timing shifts, row math doesn't
// (dec_group_sweep A/Bs this).
// submitBudgetMs adaptive submit ceiling (default 1000, null/0 disables):
// whenever one group's readback wait exceeds this, steps-per-submit
// halve for the REST of the batch (8→4→2→1, never back up). Fast
// GPUs never trip it (group waits are tens of ms); slow mobile
// GPUs converge within a few groups to submits the OS watchdog
// tolerates instead of VK_ERROR_DEVICE_LOST. Token-exact like any
// groupSteps value. Callers can seed the next batch with this
// batch's landing point via metrics.groupStepsFinal.
// Returns:
// rows [{ids, text, forcedEos, steps}] — ids = [0, ...tokens] trimmed at
// the first eos inclusive; rows that never emitted eos within the
// cap get one appended (forcedEos: true — HF max_length semantics)
// metrics {tokenizeMs, encoderMs, decodeMs, detokMs, cpuEncodeMs, awaitMs,
// submits, steps, tokensGenerated, tokPerSec}
// tokenizeMs/detokMs bracket the CPU tokenizer calls — the two
// stages the GPU timings can't see (the app-stage breakdown needs
// them to locate wall-clock loss on large files)
// tokensGenerated counts argmax-produced tokens across ALL rows
// (incl. an emitted eos, excl. a force-appended one); tokPerSec is
// total tokens over decodeMs.
export async function translateBatch(ctx, weights, tok, sources, {
maxNewTokens, onProgress, onPrimed = null, decodeAhead = true, inFlight = 2, compact = true, compactFrac = 0.25, inPlaceCompact = false, groupSteps = GROUP_STEPS, submitBudgetMs = SUBMIT_BUDGET_MS, overlapEnc = true,
kvCapacity = null,
// runEncoder passthrough (row-packing A/B; encSplitSubmits cuts the
// encoder into per-layer submits — the watchdog guard for the encoder
// side, see runEncoder splitSubmits)
encPacked = 'auto',
encSplitSubmits = false,
// createDecodeState passthrough ('q8' / fusion / layout A/B tests, and the
// per-batch options tunedOptions(tuned, B) resolves after an autotune run)
lmHead = 'auto', ffn = 'auto', lmHeadFuse = 'auto', fuseLn = 'auto', proj = 'auto', ffnSplitK = 'auto', projSplitK = 'auto', decodeMega = 'auto', sg = 'auto', encAttnSafe = false,
immediates = 'auto',
uniformPool = false,
tiledProj = 'auto',
} = {}) {
const { device } = ctx;
if (typeof inPlaceCompact !== 'boolean') {
throw new Error(`translateBatch: inPlaceCompact must be boolean, got ${inPlaceCompact}`);
}
const dispatchBefore = getDispatchStats(device);
if (inFlight !== 2 && inFlight !== 3) {
throw new Error(`translateBatch: inFlight must be 2 or 3, got ${inFlight}`);
}
if (!Number.isInteger(groupSteps) || groupSteps < 1) {
throw new Error(`translateBatch: groupSteps must be a positive integer, got ${groupSteps}`);
}
const tTok0 = performance.now();
const batch = await tokenizeBatch(tok, sources);
const tokenizeMs = performance.now() - tTok0;
const { B } = batch;
const srcTruncated = new Set(batch.truncated ?? []);
// Memory guard: the encoder ffnTmp [B·S, 1792] binding is the ceiling. No
// silent sub-batching — the bench/app layer owns batch-size policy, the
// engine stays explicit.
const maxB = maxBatchForLimits(ctx, batch.S, weights.dtype === 'f16' ? 2 : 4);
if (B > maxB) {
throw new Error(
`translateBatch: batch ${B} at S=${batch.S} exceeds ` +
`maxStorageBufferBindingSize=${ctx?.limits?.maxStorageBufferBindingSize ?? 'default 134217728'} ` +
`(encoder ffnTmp [B·S, 1792]); split into batches of ≤ ${maxB}`,
);
}
const cap = Math.min(
DECODE_CAP,
maxNewTokens ?? Math.max(...sources.map((s) => maxNewTokensFor(s.length))),
);
const tEnc0 = performance.now();
const encRun = await runEncoder(ctx, weights, batch, {
packed: encPacked, sg: sg === 'on', attnQbAlign8: encAttnSafe,
retainEncOut: false, splitSubmits: encSplitSubmits,
});
let cur = null;
const stagings = [];
let paramPool = null;
let cleaned = false;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
// Cleanup is best effort so an allocation/decode failure is never masked
// by a secondary destroy error. Every owner is idempotent.
for (const staging of stagings) {
try { staging.destroy(); } catch {}
}
try { paramPool?.destroy(); } catch {}
try { cur?.state?.destroy(); } catch {}
try { cur?.arena?.destroy(); } catch {}
try { encRun.arena.destroy(); } catch {}
};
try {
// Don't drain the queue between encoder and decode (overlapEnc, default):
// queue order already guarantees the decode dispatches see the finished
// crossKV, so the CPU can record/submit the first decode groups WHILE the
// GPU is still encoding — the old blocking await left the GPU idle for
// exactly the CPU-side recording time of those groups every batch.
// encoderMs still brackets submit → GPU-done via a non-blocking then()
// (±one macrotask); with overlap on, the decode loop starts inside that
// window, so encoderMs and decodeMs overlap by up to a few ms — the stage
// sums in app_stage/translateText read slightly high, wall clock is what
// dropped. {overlapEnc: false} restores the drained A/B baseline.
let encoderMs = 0;
const encDone = (encRun.submittedDone ?? device.queue.onSubmittedWorkDone()).then(
() => { encoderMs = performance.now() - tEnc0; },
() => {}, // measurement only — a lost device surfaces via the decode loop
);
if (!overlapEnc) await encDone;
// The mutable decode context: state + the cross-attention view. Replaced
// wholesale by each compaction; curMap maps current row -> original row.
cur = {
state: createDecodeState(ctx, weights, {
B, S: encRun.S, maxSteps: cap, lmHead, ffn, lmHeadFuse, fuseLn, proj,
tiledProj, ffnSplitK, projSplitK, decodeMega, sg, immediates, inPlaceCompact,
kvCapacity,
}),
crossKV: encRun.crossKV, lensBuf: encRun.lensBuf, S: encRun.S, arena: null,
};
let curB = B;
let curMap = Array.from({ length: B }, (_, i) => i);
const rowTokens = Array.from({ length: B }, () => []);
const rowDone = new Array(B).fill(false); // CPU mirror: saw eos in the ring
let submits = 0;
let steps = 0;
let compactions = 0;
const initialKvCapacity = cur.state.kvCapacity;
const kvCapacitySequence = [initialKvCapacity];
let kvGrows = 0;
let kvGrowsWithPending = 0;
let kvGrowBindGroupsPurged = 0;
let cpuEncodeMs = 0;
let awaitMs = 0;
// Adaptive submit budget state: curGroupSteps only ever shrinks (see
// nextGroupSteps); groupIndex rotates staging/pool banks by SUBMIT order,
// which g/groupSteps no longer encodes once the group size changes.
let curGroupSteps = groupSteps;
let groupIndex = 0;
let submitShrinks = 0;
let maxGroupAwaitMs = 0;
// inFlight staging buffers, rotated by submit index. Sized for a full
// group at the STARTING groupSteps (the adaptive path only shrinks);
// shorter groups copy less and read accordingly.
const stagingBytes = (groupSteps * B + B) * 4;
for (let i = 0; i < inFlight; i++) {
stagings.push(device.createBuffer({
label: `decode group staging ${i}`,
size: stagingBytes,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
}));
}
// Safari/WebKit currently lacks immediate_address_space. The selected pool
// keeps ordinary uniform bindings but gives each in-flight group a stable
// buffer bank, so parameter uploads collapse to one write and bind groups
// become reusable. Immediate-mode states keep their existing path.
paramPool = shouldUseUniformParamPool(uniformPool, cur.state)
? createUniformParamPool(device, { banks: inFlight })
: null;
// Record + submit one group [g, tEnd). Returns the in-flight descriptor —
// it carries the batch geometry (B, row map) it was submitted with, since a
// compaction may swap the context before its readback is processed.
const encodeGroup = (g, pendingGroups = 0) => {
const t0 = performance.now();
const tEnd = Math.min(g + curGroupSteps, cap);
if (tEnd > cur.state.kvCapacity) {
const grow = growDecodeKV(ctx, weights, cur.state, {
requiredCapacity: tEnd,
submittedSteps: g,
groupSteps: curGroupSteps,
});
if (grow.grown) {
kvGrows++;
if (pendingGroups > 0) kvGrowsWithPending++;
kvGrowBindGroupsPurged += grow.bindGroupsPurged;
kvCapacitySequence.push(grow.newCapacity);
}
}
const bank = groupIndex % stagings.length;
groupIndex++;
const staging = stagings[bank];
if (staging.mapState !== 'unmapped') {
throw new Error(`decode staging reused while ${staging.mapState}`);
}
const scratch = [];
let poolFrameActive = false;
try {
if (paramPool) {
paramPool.begin(bank);
poolFrameActive = true;
}
const encoder = device.createCommandEncoder({ label: `decode ${g}..${tEnd}` });
const pass = encoder.beginComputePass({ label: `decode ${g}..${tEnd}` });
for (let t = g; t < tEnd; t++) {
scratch.push(...encodeDecodeStep(ctx, weights, cur, cur.state, t, pass).scratch);
}
pass.end();
if (paramPool) {
paramPool.flush();
poolFrameActive = false;
}
// One readback per group: ring slice [g·B, tEnd·B) + done[B].
const nTok = (tEnd - g) * curB;
encoder.copyBufferToBuffer(cur.state.tokenRing, g * curB * 4, staging, 0, nTok * 4);
encoder.copyBufferToBuffer(cur.state.done, 0, staging, nTok * 4, curB * 4);
device.queue.submit([encoder.finish()]);
submits++;
for (const buf of scratch) buf.destroy(); // safe post-submit
cpuEncodeMs += performance.now() - t0;
return { g, tEnd, nTok, staging, B: curB, map: curMap, poolBank: paramPool ? bank : null };
} catch (err) {
if (poolFrameActive) paramPool.abort();
for (const buf of scratch) buf.destroy();
throw err;
}
};
// Compact away the finished rows: pays when a decent slice of the batch is
// dead AND there are steps left. The max(8, ·) floor keeps small batches
// (b1–b8 gates, latency runs) permanently on the no-compaction path.
const compactWanted = () => {
if (!compact || steps === 0) return false;
const live = curMap.reduce((n, orig) => n + (rowDone[orig] ? 0 : 1), 0);
return live > 0 && curB - live >= Math.max(8, Math.ceil(curB * compactFrac));
};
const doCompact = () => {
const liveIdx = [];
for (let i = 0; i < curB; i++) if (!rowDone[curMap[i]]) liveIdx.push(i);
const newMap = liveIdx.map((i) => curMap[i]);
const lens = new Uint32Array(newMap.map((orig) => batch.lens[orig]));
const lastTok = new Uint32Array(newMap.map((orig) => rowTokens[orig].at(-1)));
if (inPlaceCompact) {
compactDecodeStateInPlace(ctx, weights, cur, {
liveIdx, t0: steps, lens, lastTok,
});
} else {
const prev = cur;
// pending is empty here, so every pool bank has completed its readback
// and been released. Drop bind groups for the old resource generation
// BEFORE allocating the compacted state: on WebKit those bind groups
// keep the old KV/crossKV/arena backing memory alive after destroy().
paramPool?.invalidateBindings();
cur = compactDecodeState(ctx, weights, prev, {
liveIdx, t0: steps, cap, lens, lastTok,
});
// Old buffers are queue-retained by the just-submitted copies; destroy()
// only blocks future submissions. First compaction: the encoder arena
// (encOut + old crossKV/lens) is no longer referenced either.
prev.state.destroy();
if (prev.arena) prev.arena.destroy();
else encRun.arena.destroy();
}
curB = liveIdx.length;
curMap = newMap;
compactions++;
};
const tDec0 = performance.now();
try {
const depth = decodeAhead ? inFlight : 1; // groups in flight (submitted, unread)
const pending = []; // submitted-but-unread groups, oldest first
let nextG = 0;
let allDone = false;
for (;;) {
// A wanted compaction stalls new submits so the in-flight groups (built
// against the OLD layout) drain first — one pipeline bubble per compact.
const wantCompact = !allDone && nextG < cap && compactWanted();
if (wantCompact && pending.length === 0) {
doCompact();
continue;
}
while (!allDone && !wantCompact && nextG < cap && pending.length < depth) {
const grp = encodeGroup(nextG, pending.length);
pending.push(grp);
nextG = grp.tEnd; // curGroupSteps may shrink between submits
}
// The pipeline is primed: encoder + the first decode groups are all
// submitted, and the next await parks on the GPU. This is the one spot
// where a caller can burn CPU for free (e.g. pre-tokenizing the NEXT
// batch into the SPM LRU) — earlier would delay these submits, later
// (onProgress) the GPU is already half done.
if (onPrimed) {
const cb = onPrimed;
onPrimed = null;
cb();
}
const grp = pending.shift();
if (!grp) break;
if (allDone) continue; // submitted before all-done was seen; results are
// PAD-only for done rows — safe to ignore unread.
const tA0 = performance.now();
await grp.staging.mapAsync(GPUMapMode.READ, 0, (grp.nTok + grp.B) * 4);
const data = new Uint32Array(grp.staging.getMappedRange(0, (grp.nTok + grp.B) * 4).slice(0));
grp.staging.unmap(); // staging is now free for group g+2
if (grp.poolBank !== null) paramPool.release(grp.poolBank);
const groupAwait = performance.now() - tA0;
awaitMs += groupAwait;
if (groupAwait > maxGroupAwaitMs) maxGroupAwaitMs = groupAwait;
// Adaptive submit budget: a long wait here means the GPU chewed on one
// submit past the budget — shrink the groups still to be submitted.
const shrunk = nextGroupSteps(curGroupSteps, groupAwait, submitBudgetMs);
if (shrunk !== curGroupSteps) {
curGroupSteps = shrunk;
submitShrinks++;
}
// Collect per row (map current -> original), stopping at its first eos —
// done rows produce PAD afterwards, which must NOT enter the sequence.
for (let t = grp.g; t < grp.tEnd; t++) {
for (let b = 0; b < grp.B; b++) {
const orig = grp.map[b];
if (rowDone[orig]) continue;
const id = data[(t - grp.g) * grp.B + b];
rowTokens[orig].push(id);
if (id === EOS) rowDone[orig] = true;
}
}
steps = grp.tEnd;
onProgress?.({ step: grp.tEnd, cap, done: rowDone.filter(Boolean).length, B });
// GPU done flags (queue-ordered snapshot after step tEnd-1).
const doneFlags = data.subarray(grp.nTok, grp.nTok + grp.B);
if (doneFlags.every((d) => d === 1)) allDone = true;
}
} finally {
cleanup();
}
const decodeMs = performance.now() - tDec0;
// The decode loop's readbacks are queue-ordered after the encoder submit,
// so encDone has long resolved — this await only pins encoderMs before the
// metrics object is built.
await encDone;
const tDet0 = performance.now();
const rows = [];
let tokensGenerated = 0;
for (let b = 0; b < B; b++) {
const forcedEos = !rowDone[b];
tokensGenerated += rowTokens[b].length; // argmax-produced (incl. emitted eos)
const ids = [DECODER_START, ...rowTokens[b]];
if (forcedEos) ids.push(EOS);
const text = tok.decode(ids, { skip_special_tokens: true }).trim();
// srcTruncated: the ENCODER saw a cut-off source (tokenizeBatch hit
// SRC_CAP) — the decode itself is fine, but `text` only translates the
// prefix. Distinct from forcedEos, which is the TARGET ring cap.
rows.push({
ids, text, forcedEos, steps: rowTokens[b].length,
srcTruncated: srcTruncated.has(b),
});
}
const detokMs = performance.now() - tDet0;
const dispatchAfter = getDispatchStats(device);
const dispatch = {};
for (const field of [
'uniformBuffersCreated', 'uniformPoolBuffersCreated', 'uniformPoolBuffersDestroyed',
'uniformPoolFramesBegun', 'uniformPoolFramesFlushed', 'uniformPoolBlocks',
'uniformPoolBytes', 'uniformPoolBindGroupCacheHits',
'uniformPoolWarmBindGroupLookups', 'uniformPoolWarmBindGroupCacheHits',
'uniformPoolWarmBindGroupResets', 'uniformPoolGenerationInvalidations',
'uniformPoolCachePurges',
'dummyBuffersCreated', 'bindGroupsCreated', 'bindGroupCacheHits',
'bindGroupEvictions', 'bindGroupTargetedPurgeCalls',
'bindGroupTargetedPurges', 'immediateSets',
]) {
dispatch[field] = dispatchAfter[field] - dispatchBefore[field];
}
dispatch.bindGroupCacheSize = dispatchAfter.bindGroupCacheSize;
dispatch.bindGroupCacheLimit = dispatchAfter.bindGroupCacheLimit;
return {
rows,
metrics: {
tokenizeMs, encoderMs, decodeMs, detokMs, cpuEncodeMs, awaitMs,
B, S: encRun.S, cap,
submits, steps, compactions,
groupStepsStart: groupSteps, groupStepsFinal: curGroupSteps,
submitShrinks, maxGroupAwaitMs,
initialKvCapacity, finalKvCapacity: cur.state.kvCapacity,
kvGrows, kvGrowsWithPending, kvCapacitySequence, kvGrowBindGroupsPurged,
srcTruncated: srcTruncated.size,
compactMode: inPlaceCompact ? 'inplace' : 'realloc', tokensGenerated,
dispatch, uniformPool: paramPool?.snapshot() ?? null,
tokPerSec: decodeMs > 0 ? (tokensGenerated / decodeMs) * 1000 : 0,
},
};
} finally {
// Covers initialization failures before the decode loop's narrower
// finally is entered (decode-state, partial staging, or pool setup).
cleanup();
}
}