vp2vi / guarded-engine.js
DanVP's picture
feat: publish vp2vi WebGPU browser app
c971a45 verified
Raw
History Blame Contribute Delete
17 kB
// APE A2 WebGPU AR ship engine — guarded decode loop over the vendored webMT
// engine. Design: docs/plans/2026-08-10-ape-a2-webgpu-ar-ship-engine-design.md
// (§3.4). Zero vendor modifications:
// - encoder: stock runEncoder over caller-tokenized ids (the Python
// contract: add_special_tokens=False — NO trailing </s>).
// - decode: stock encodeDecodeStep with lmHeadFuse 'off' so the f32 logits
// buffer materializes (COPY_SRC); one step per submit, logits copied to a
// staging buffer and mapped. The vendor argmax_penalty still runs but its
// ring write is OVERWRITTEN each step by the CPU-guarded pick
// (queue-ordered writeBuffer — the compactDecodeState precedent); its
// done/bitmask side effects are never read on this path.
// - Python-semantics note: the reference project() ADDS final_logits_bias
// to the logits; the engine leaves the bias to the argmax kernel — so
// this loop adds the bias on CPU after readback (readTensorF32 once).
// The per-step token decision is decode-guard.js's GuardedRow — the exact
// stack P1 proved equivalent (21,348/21,348 steps) to the frozen Python loop.
import { runEncoder } from './engine/encoder.js';
import { createDecodeState, encodeDecodeStep } from './engine/decoder.js';
import { VOCAB, PAD, SRC_CAP } from './engine/constants.js';
import { GuardedRow, EOS_ID, MAX_NEW_TOKENS, NEG, hasNewRepeat } from './decode-guard.js';
import { createGuardedLogitRanker } from './guarded-logits.js';
import { createGuardedTopK, createGuardedTopKWindow } from './guarded-topk.js';
// Read one tensor back from the uploaded weights mega-buffer as f32.
export async function readTensorF32(device, weights, name) {
const meta = weights.tensors.get(name);
if (!meta) throw new Error(`readTensorF32: unknown tensor ${name}`);
if (meta.dtype !== 'f32') throw new Error(`readTensorF32: ${name} is ${meta.dtype}, not f32`);
const bind = weights.bindingFor(name);
const buffer = bind.buffer ?? bind;
const offset = bind.offset ?? 0;
const staging = device.createBuffer({
size: meta.byteLength,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});
const enc = device.createCommandEncoder({ label: `read ${name}` });
enc.copyBufferToBuffer(buffer, offset, staging, 0, meta.byteLength);
device.queue.submit([enc.finish()]);
await staging.mapAsync(GPUMapMode.READ);
const out = new Float32Array(staging.getMappedRange().slice(0));
staging.unmap();
staging.destroy();
return out;
}
function tierHasAcceptable(guard, ids) {
for (const token of ids) {
if (token === EOS_ID) {
if (guard.name.broken || guard.name.eosOk()) return true;
continue;
}
const text = guard.pieceTable.pieceText[token];
if (!text) continue;
if (!(guard.name.broken || guard.name.trial(text))) continue;
if (hasNewRepeat(guard.sim + text, guard.draftPatterns)) continue;
return true;
}
return false;
}
// Decode one batch with the guarded ship stack.
// ctx initDevice() result
// weights loadWeights() result (f32 oracle or f16 ship build)
// pieceTable {pieceText, digitIds, bannedBytes} (piece_table.json)
// bias Float32Array[VOCAB] — final_logits_bias (readTensorF32)
// rows [{id, draft, srcIds: number[] (NO trailing eos)}]
// opts {specWindow}: >= 2 selects the speculative-window executor
// (design addendum 2026-08-11); default/1 keeps the per-step
// path. Both must produce bit-identical outputs (receipt _v2).
// Returns {rows: [{id, outputs, sim, stats, finished, steps}], timing}.
export async function guardedDecodeBatch(ctx, weights, pieceTable, bias, rows, opts = {}) {
const { device } = ctx;
const B = rows.length;
if (!B) throw new Error('guardedDecodeBatch: empty batch');
let S = 0;
for (const row of rows) {
if (!row.srcIds.length || row.srcIds.length > SRC_CAP) {
throw new Error(`guardedDecodeBatch: row ${row.id} has ${row.srcIds.length} src tokens (cap ${SRC_CAP})`);
}
if (row.srcIds[row.srcIds.length - 1] === EOS_ID) {
throw new Error(`guardedDecodeBatch: row ${row.id} ends with EOS — the contract is add_special_tokens=False`);
}
S = Math.max(S, row.srcIds.length);
}
if (bias.length !== VOCAB) throw new Error('guardedDecodeBatch: bias length != VOCAB');
const ids = new Uint32Array(B * S).fill(PAD);
const lens = new Uint32Array(B);
rows.forEach((row, i) => {
ids.set(row.srcIds, i * S);
lens[i] = row.srcIds.length;
});
const specWindow = Math.trunc(opts.specWindow ?? 0);
const useWindow = specWindow >= 2;
const t0 = performance.now();
const encRun = await runEncoder(ctx, weights, { ids, lens, B, S });
const state = createDecodeState(ctx, weights, { B, S, maxSteps: MAX_NEW_TOKENS, lmHeadFuse: 'off' });
const gpuTopK = useWindow ? null : await createGuardedTopK(device, weights, state.logits, B, VOCAB);
const win = useWindow
? await createGuardedTopKWindow(device, weights, state.logits, state.tokenRing, B, VOCAB, specWindow)
: null;
const t1 = performance.now();
const memo = new Map();
const guards = rows.map((row) => new GuardedRow(row.draft, pieceTable, memo));
const stepsPerRow = new Array(B).fill(0);
const ranker = createGuardedLogitRanker(VOCAB, NEG);
let stepsRun = 0;
const diag = { windows: 0, corrections: 0, deferrals: 0, rescues: 0 };
try {
if (useWindow) {
// Speculative-window executor. Per-row frontier = accepted steps; the
// GuardedRow is always exactly at its frontier (stepped forward once
// per accepted step, never re-stepped, never rolled back).
const FINISHED = 0xffffffff;
const frontier = new Uint32Array(B);
const needsFull = new Array(B).fill(false);
while (true) {
let t0w = Infinity;
for (let i = 0; i < B; i += 1) {
if (!guards[i].finished) t0w = Math.min(t0w, frontier[i]);
}
if (t0w === Infinity || t0w >= MAX_NEW_TOKENS) break;
// A row whose frontier step needs full logits forces a 1-step window
// once it defines t0w — state.logits then holds exactly that step.
const forceSingle = guards.some((g, i) => !g.finished && needsFull[i] && frontier[i] === t0w);
// Scheduling-only tail clamp (round-2 design §2.2): any Kp yields the
// same outputs; this just trims window overshoot past row finishes.
// Polish outputs track source length, so a source-token estimate with
// slack sizes the tail window; when a row outruns its estimate the
// clamp simply stops applying to it. Never used for token decisions.
let maxSpan = 1;
for (let i = 0; i < B; i += 1) {
if (guards[i].finished) continue;
const est = Math.ceil(1.2 * rows[i].srcIds.length) + 4;
const remaining = est > frontier[i] ? est - frontier[i] : specWindow;
maxSpan = Math.max(maxSpan, frontier[i] - t0w + remaining);
}
const Kp = forceSingle ? 1 : Math.min(specWindow, MAX_NEW_TOKENS - t0w, maxSpan);
const requests = guards.map((guard) => (guard.finished
? { mode: 'none', ids: [] }
: guard.maskRequest()));
const frontCpu = new Uint32Array(B);
for (let i = 0; i < B; i += 1) frontCpu[i] = guards[i].finished ? FINISHED : frontier[i];
const hist = new Uint32Array(Kp * B).fill(EOS_ID);
for (let i = 0; i < B; i += 1) {
for (let k = 0; k < Kp; k += 1) {
if (t0w + k < frontCpu[i]) {
const tok = guards[i].outputs[t0w + k];
if (tok !== undefined) hist[k * B + i] = tok;
}
}
}
win.prepare(requests, frontCpu, hist, t0w, Kp);
const cmd = device.createCommandEncoder({ label: `guarded window ${t0w}+${Kp}` });
const scratchAll = [];
for (let k = 0; k < Kp; k += 1) {
const pass = cmd.beginComputePass({ label: `guarded window step ${t0w + k}` });
const { scratch } = encodeDecodeStep(ctx, weights, encRun, state, t0w + k, pass);
pass.end();
win.record(cmd, k);
scratchAll.push(...scratch);
}
win.copyOut(cmd, Kp);
device.queue.submit([cmd.finish()]);
for (const buf of scratchAll) buf.destroy();
stepsRun += Kp;
diag.windows += 1;
const top = await win.read(Kp);
const corrections = [];
const fullRows = [];
for (let i = 0; i < B; i += 1) {
const guard = guards[i];
if (guard.finished) continue;
let f = frontier[i];
for (let j = Math.max(t0w, f); j < t0w + Kp; j += 1) {
const base = ((j - t0w) * B + i) * 18;
const atFrontier = j === f;
if (atFrontier && needsFull[i]) {
if (Kp === 1 && f === t0w) fullRows.push(i);
break;
}
const visible = [];
for (let k = 0; k < 16; k += 1) {
const id = top[base + k];
if (id === win.emptyId) break;
visible.push(id);
}
const rawTop = top[base + 17];
let tier;
let maskedTop;
if (atFrontier) {
// The kernel applied the true mask here — per-step semantics.
maskedTop = top[base + 16];
if (!tierHasAcceptable(guard, visible)) {
needsFull[i] = true;
diag.rescues += 1;
break;
}
tier = visible;
} else {
// Free-run step: the readback is unmasked. The permitted
// sub-sequence of the unmasked top-16 is an exact prefix of
// the masked descending order (unbanned/allowed values keep
// their raw value; everything hidden ranks below them), so
// the guard decision is exact whenever it lands inside it.
const req = guard.maskRequest();
let prefix = visible;
if (req.mode === 'ban') {
const banned = new Set(req.ids);
prefix = visible.filter((id) => !banned.has(id));
} else if (req.mode === 'allow') {
const allowed = new Set(req.ids);
prefix = visible.filter((id) => allowed.has(id));
}
if (!prefix.length || !tierHasAcceptable(guard, prefix)) {
f = j; // next window applies the true mask at j
diag.deferrals += 1;
break;
}
maskedTop = prefix[0];
tier = prefix;
}
const ringVal = atFrontier ? top[base + 16] : rawTop;
const chosen = guard.step(rawTop, maskedTop, (tierIndex) => {
if (tierIndex !== 0) throw new Error('guarded window tier escalation past preflight');
return { ids: tier, validCount: tier.length };
});
stepsPerRow[i] += 1;
f = j + 1;
if (chosen !== ringVal) {
// The free-run trajectory diverged: pin the true token and
// recompute later steps next window (unaffected rows below
// their own frontier restore identical history/KV).
corrections.push([j, i, chosen]);
break;
}
if (guard.finished) break;
}
frontier[i] = f;
}
if (fullRows.length) {
// Rescue: tier-16 had no acceptable candidate at a frontier step
// (P1 frequency: 0). Kp == 1, so state.logits holds this step.
const rowBytes = VOCAB * 4;
const rescueStaging = device.createBuffer({
label: 'guarded window rescue logits',
size: fullRows.length * rowBytes,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});
const rescueCmd = device.createCommandEncoder({ label: 'guarded window rescue readback' });
fullRows.forEach((row, index) => {
rescueCmd.copyBufferToBuffer(state.logits, row * rowBytes, rescueStaging, index * rowBytes, rowBytes);
});
device.queue.submit([rescueCmd.finish()]);
await rescueStaging.mapAsync(GPUMapMode.READ);
const view = new Float32Array(rescueStaging.getMappedRange());
fullRows.forEach((row, index) => {
const guard = guards[row];
const ranked = ranker.rank(view, index * VOCAB, bias, requests[row]);
const chosen = guard.step(ranked.rawTop, ranked.maskedTop, ranked.getTier);
stepsPerRow[row] += 1;
needsFull[row] = false;
frontier[row] = t0w + 1;
const ringVal = top[row * 18 + 16];
if (chosen !== ringVal) corrections.push([t0w, row, chosen]);
});
rescueStaging.unmap();
rescueStaging.destroy();
}
for (const [t, rowIdx, token] of corrections) {
device.queue.writeBuffer(state.tokenRing, (t * B + rowIdx) * 4, new Uint32Array([token]));
}
diag.corrections += corrections.length;
}
} else {
for (let t = 0; t < MAX_NEW_TOKENS; t += 1) {
const requests = guards.map((guard) => (guard.finished
? { mode: 'none', ids: [] }
: guard.maskRequest()));
gpuTopK.prepare(requests);
const cmd = device.createCommandEncoder({ label: `guarded step ${t}` });
const pass = cmd.beginComputePass({ label: `guarded step ${t}` });
const { scratch } = encodeDecodeStep(ctx, weights, encRun, state, t, pass);
pass.end();
gpuTopK.record(cmd);
device.queue.submit([cmd.finish()]);
for (const buf of scratch) buf.destroy();
stepsRun += 1;
const top = await gpuTopK.read();
const picks = new Uint32Array(B).fill(EOS_ID);
const fallbackRows = [];
const gpuTiers = new Array(B);
for (let i = 0; i < B; i += 1) {
if (guards[i].finished) continue;
const base = i * gpuTopK.outputStride;
const ids = [];
for (let k = 0; k < 16; k += 1) {
if (top[base + k] === gpuTopK.emptyId) break;
ids.push(top[base + k]);
}
gpuTiers[i] = ids;
if (!tierHasAcceptable(guards[i], ids)) fallbackRows.push(i);
}
let fallbackStaging = null;
let fallbackView = null;
if (fallbackRows.length) {
const rowBytes = VOCAB * 4;
fallbackStaging = device.createBuffer({
label: 'guarded fallback logits',
size: fallbackRows.length * rowBytes,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});
const fallbackCmd = device.createCommandEncoder({ label: 'guarded fallback readback' });
fallbackRows.forEach((row, index) => {
fallbackCmd.copyBufferToBuffer(state.logits, row * rowBytes, fallbackStaging, index * rowBytes, rowBytes);
});
device.queue.submit([fallbackCmd.finish()]);
await fallbackStaging.mapAsync(GPUMapMode.READ);
fallbackView = new Float32Array(fallbackStaging.getMappedRange());
}
const fallbackIndex = new Map(fallbackRows.map((row, index) => [row, index]));
let live = 0;
for (let i = 0; i < B; i += 1) {
const guard = guards[i];
if (guard.finished) continue;
const compactIndex = fallbackIndex.get(i);
let chosen;
if (compactIndex !== undefined) {
const ranked = ranker.rank(fallbackView, compactIndex * VOCAB, bias, requests[i]);
chosen = guard.step(ranked.rawTop, ranked.maskedTop, ranked.getTier);
} else {
const base = i * gpuTopK.outputStride;
const tier = { ids: gpuTiers[i], validCount: gpuTiers[i].length };
chosen = guard.step(top[base + 17], top[base + 16], (tierIndex) => {
if (tierIndex !== 0) throw new Error('guarded top-16 preflight disagreement');
return tier;
});
}
picks[i] = chosen;
stepsPerRow[i] += 1;
if (!guard.finished) live += 1;
}
if (fallbackStaging) {
fallbackStaging.unmap();
fallbackStaging.destroy();
}
device.queue.writeBuffer(state.tokenRing, t * B * 4, picks);
if (live === 0) break;
}
}
} finally {
(useWindow ? win : gpuTopK).destroy();
state.destroy();
encRun.arena.destroy();
}
const t2 = performance.now();
return {
rows: rows.map((row, i) => ({
id: row.id,
outputs: guards[i].outputs,
sim: guards[i].sim,
stats: guards[i].stats,
finished: guards[i].finished,
steps: stepsPerRow[i],
})),
timing: {
encoderMs: Math.round(t1 - t0),
decodeMs: Math.round(t2 - t1),
stepsRun,
spec: useWindow ? specWindow : 0,
...(useWindow ? diag : {}),
},
};
}