Bring Infer onto the live Train cores and add ring scheduling.
Browse filesThe verified units now refuse non-finite activations and match DaisyChain-Train at a28437c. The head can queue a busy ring, drop a disagreeing kernel probe, estimate a lap, keep a prefix index, overlap long prefills, score a prompt-lookup draft in one lap, and replay the prompt once if a peer drops.
- public/app.js +200 -26
- public/hf.js +19 -9
- public/infer.js +167 -13
- public/shard.js +158 -1
- public/traincore.js +17 -1
- public/verified_core.js +84 -2
- public/webgpu.js +74 -7
- test_pipeline.js +81 -0
public/app.js
CHANGED
|
@@ -418,6 +418,7 @@ function setupChannel(peerId, dc) {
|
|
| 418 |
dc.onclose = () => { chans.delete(peerId); updatePeers(); wake(); scheduleReconnect(peerId); };
|
| 419 |
dc.onmessage = (e) => onWire(peerId, e.data);
|
| 420 |
}
|
|
|
|
| 421 |
function cleanupPeer(id) {
|
| 422 |
clearWatchdog(id);
|
| 423 |
const pc = pcs.get(id); if (pc) pc.close();
|
|
@@ -425,6 +426,8 @@ function cleanupPeer(id) {
|
|
| 425 |
const dc = chans.get(id);
|
| 426 |
if (dc && !dc.isRelay) chans.delete(id); // keep a working relay channel
|
| 427 |
for (const k of fragIn.keys()) if (k.startsWith(id + ":")) fragIn.delete(k);
|
|
|
|
|
|
|
| 428 |
wake();
|
| 429 |
}
|
| 430 |
|
|
@@ -555,14 +558,27 @@ async function loadRepo() {
|
|
| 555 |
ui.loadRepo.disabled = false;
|
| 556 |
}
|
| 557 |
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
for (const id of [...chans.keys()].sort((a, b) => +a.slice(1) - +b.slice(1))) {
|
| 562 |
const c = caps.get(id);
|
| 563 |
-
if (c)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
}
|
| 565 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
}
|
| 567 |
function renderPlan() {
|
| 568 |
if (!plan) {
|
|
@@ -676,10 +692,49 @@ async function onAct(peerId, buf) {
|
|
| 676 |
const kind = Wire.classifyAct(nextIndex, me ? me.index : -99);
|
| 677 |
if (kind === "return") { actIn.set(`${seq}:${tokenIdx}`, hidden); wake(); return; }
|
| 678 |
if (kind === "other" || !me || !stageObj) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 679 |
const t0 = performance.now();
|
| 680 |
let x;
|
| 681 |
try { x = await Infer.runLayers(stageObj, hidden); }
|
| 682 |
-
catch (e) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
if (auditFailure) { log("KERNEL AUDIT FAILED mid-hop — refusing to pass on a value I cannot vouch for"); return; }
|
| 684 |
const hop = Wire.routeAfter(plan, me.index);
|
| 685 |
ui.ringState.textContent = `layers ${me.lo}–${me.hi - 1} in ${(performance.now() - t0).toFixed(0)} ms → ` +
|
|
@@ -687,8 +742,15 @@ async function onAct(peerId, buf) {
|
|
| 687 |
await sendTo(hop.to, packAct(seq, tokenIdx, hop.address, x));
|
| 688 |
}
|
| 689 |
|
| 690 |
-
|
| 691 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
if (!repoInfo) { log("load a model first — the device that loads it drives the ring"); return; }
|
| 693 |
if (!tok) { log("no tokenizer loaded"); return; }
|
| 694 |
running = true; abortRun = false; auditFailure = null;
|
|
@@ -698,6 +760,28 @@ async function generate() {
|
|
| 698 |
const T = Math.min(ctxLen(), repoInfo.spec.maxPos);
|
| 699 |
plan = buildPlan(); modelHash = repoInfo.fingerprint; spec = repoInfo.spec;
|
| 700 |
renderPlan();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 701 |
if (plan.length > 1) {
|
| 702 |
log(`plan: ${spec.layers} layers over ${plan.length} device(s) — ` +
|
| 703 |
plan.map(s => `${s.id === myId ? "me" : nmeOf(s.id)}:${s.hi - s.lo}`).join(", "));
|
|
@@ -719,37 +803,110 @@ async function generate() {
|
|
| 719 |
const seq = ++runSeq;
|
| 720 |
const nTok = +ui.ntok.value, temp = +ui.temp.value / 100;
|
| 721 |
const opts = { temperature: temp, topK: 40, rng: Infer.mulberry32(+ui.seed.value | 0) };
|
| 722 |
-
let ids =
|
| 723 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 724 |
const promptLen = ids.length;
|
| 725 |
ui.out.textContent = ui.prompt.value;
|
| 726 |
const tStart = performance.now();
|
| 727 |
let hops = 0;
|
| 728 |
|
| 729 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 730 |
const win = new Int32Array(T);
|
| 731 |
-
|
| 732 |
-
for (let i = 0; i < tail.length; i++) win[T - tail.length + i] = tail[i];
|
| 733 |
let x = Infer.embed(stageObj, win);
|
| 734 |
x = await Infer.runLayers(stageObj, x);
|
| 735 |
if (auditFailure) { log(`KERNEL AUDIT FAILED: ${auditFailure} — stopping`); break; }
|
| 736 |
if (plan.length > 1) {
|
| 737 |
-
await sendTo(plan[1].id, packAct(seq,
|
| 738 |
hops += plan.length;
|
| 739 |
-
const key = `${seq}:${
|
| 740 |
-
const ok = await waitFor(() => actIn.has(key), 60000);
|
| 741 |
-
if (
|
|
|
|
| 742 |
x = actIn.get(key); actIn.delete(key);
|
| 743 |
}
|
| 744 |
-
const
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
await new Promise(r => setTimeout(r, 0));
|
| 752 |
}
|
|
|
|
| 753 |
// The differential check. In a pipeline nothing recomputes anything, so
|
| 754 |
// this is the only instrument that can show a distributed answer is RIGHT
|
| 755 |
// rather than merely self-consistent. It costs a full local run, which is
|
|
@@ -759,8 +916,12 @@ async function generate() {
|
|
| 759 |
log("verifying: fetching the whole model here and re-running the same prompt…");
|
| 760 |
const all = { id: myId, index: 0, lo: 0, hi: spec.layers, head: true };
|
| 761 |
const solo = await loadMyStage({ repo: repoInfo.repo, revision: repoInfo.revision, spec, ctx: T }, all);
|
| 762 |
-
const
|
| 763 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 764 |
const same = ref.length === ids.length && ref.every((v, i) => v === ids[i]);
|
| 765 |
log(same ? "VERIFIED: the distributed run and the single-device run produced identical token ids."
|
| 766 |
: `MISMATCH: the ring and this device disagree. Distributed "${Tokenizer.decode(tok, ids).slice(0, 60)}" ` +
|
|
@@ -770,12 +931,25 @@ async function generate() {
|
|
| 770 |
`over ${plan.length} stage(s)`);
|
| 771 |
broadcast(Wire.packDone(seq));
|
| 772 |
} catch (e) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 773 |
log(`run failed: ${redact(e.message)}`);
|
| 774 |
console.error(e);
|
| 775 |
}
|
| 776 |
running = false;
|
| 777 |
ui.genBtn.disabled = false; ui.stopBtn.disabled = true;
|
| 778 |
ui.ringState.textContent = "idle";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 779 |
}
|
| 780 |
|
| 781 |
function onToken(peerId, buf) {
|
|
|
|
| 418 |
dc.onclose = () => { chans.delete(peerId); updatePeers(); wake(); scheduleReconnect(peerId); };
|
| 419 |
dc.onmessage = (e) => onWire(peerId, e.data);
|
| 420 |
}
|
| 421 |
+
let peerDropped = false;
|
| 422 |
function cleanupPeer(id) {
|
| 423 |
clearWatchdog(id);
|
| 424 |
const pc = pcs.get(id); if (pc) pc.close();
|
|
|
|
| 426 |
const dc = chans.get(id);
|
| 427 |
if (dc && !dc.isRelay) chans.delete(id); // keep a working relay channel
|
| 428 |
for (const k of fragIn.keys()) if (k.startsWith(id + ":")) fragIn.delete(k);
|
| 429 |
+
if (running) peerDropped = true;
|
| 430 |
+
caps.delete(id);
|
| 431 |
wake();
|
| 432 |
}
|
| 433 |
|
|
|
|
| 558 |
ui.loadRepo.disabled = false;
|
| 559 |
}
|
| 560 |
|
| 561 |
+
const prefixIndex = Shard.makePrefixIndex(64);
|
| 562 |
+
const kvSpill = Shard.makeKvSpill(32 * 1048576);
|
| 563 |
+
const CHAT = "chat";
|
| 564 |
+
const LAP_BUDGET_MS = 120000;
|
| 565 |
+
|
| 566 |
+
function memberList() {
|
| 567 |
+
const list = [{ id: myId, capacity: myCapacity, backend: compute.backend, probe: probeHash }];
|
| 568 |
for (const id of [...chans.keys()].sort((a, b) => +a.slice(1) - +b.slice(1))) {
|
| 569 |
const c = caps.get(id);
|
| 570 |
+
if (!c) continue;
|
| 571 |
+
if (probeHash && c.probe && c.probe !== probeHash) {
|
| 572 |
+
log(`${nmeOf(id)} stays out of the plan: kernel probe ${c.probe} != ${probeHash}.`);
|
| 573 |
+
continue;
|
| 574 |
+
}
|
| 575 |
+
list.push({ id, capacity: c.capacity, backend: c.backend, probe: c.probe });
|
| 576 |
}
|
| 577 |
+
return list;
|
| 578 |
+
}
|
| 579 |
+
function buildPlan() {
|
| 580 |
+
if (!repoInfo) return null;
|
| 581 |
+
return Shard.planStages(repoInfo.spec, Shard.ringMembers(memberList(), probeHash));
|
| 582 |
}
|
| 583 |
function renderPlan() {
|
| 584 |
if (!plan) {
|
|
|
|
| 692 |
const kind = Wire.classifyAct(nextIndex, me ? me.index : -99);
|
| 693 |
if (kind === "return") { actIn.set(`${seq}:${tokenIdx}`, hidden); wake(); return; }
|
| 694 |
if (kind === "other" || !me || !stageObj) return;
|
| 695 |
+
const rows = hidden.length / spec.hidden;
|
| 696 |
+
const chunked = Number.isInteger(rows) && rows !== stageObj.T;
|
| 697 |
+
if (chunked) {
|
| 698 |
+
if (!stageObj.inbox || stageObj.boxSeq !== seq) {
|
| 699 |
+
stageObj.inbox = new Map();
|
| 700 |
+
stageObj.boxSeq = seq;
|
| 701 |
+
stageObj.nextChunk = 0;
|
| 702 |
+
stageObj.posChunk = 0;
|
| 703 |
+
stageObj.span = { cache: [], pos0: 0, li: 0 };
|
| 704 |
+
}
|
| 705 |
+
stageObj.inbox.set(tokenIdx, hidden);
|
| 706 |
+
while (stageObj.inbox.has(stageObj.nextChunk)) {
|
| 707 |
+
const h = stageObj.inbox.get(stageObj.nextChunk);
|
| 708 |
+
stageObj.inbox.delete(stageObj.nextChunk);
|
| 709 |
+
const t0 = performance.now();
|
| 710 |
+
let x;
|
| 711 |
+
try {
|
| 712 |
+
stageObj.span.pos0 = stageObj.posChunk;
|
| 713 |
+
x = await Infer.runLayersChunk(stageObj, h, stageObj.span);
|
| 714 |
+
} catch (e) {
|
| 715 |
+
log(e.name === "NonFiniteError"
|
| 716 |
+
? `REFUSED activation from ${nmeOf(peerId)}: ${e.message}. A non-finite hop is not forwarded.`
|
| 717 |
+
: `stage failed: ${redact(e.message)}`);
|
| 718 |
+
return;
|
| 719 |
+
}
|
| 720 |
+
if (auditFailure) { log("KERNEL AUDIT FAILED mid-hop — refusing to pass on a value I cannot vouch for"); return; }
|
| 721 |
+
stageObj.posChunk += h.length / spec.hidden;
|
| 722 |
+
stageObj.nextChunk++;
|
| 723 |
+
const hop = Wire.routeAfter(plan, me.index);
|
| 724 |
+
ui.ringState.textContent = `chunk ${stageObj.nextChunk - 1} in ${(performance.now() - t0).toFixed(0)} ms`;
|
| 725 |
+
await sendTo(hop.to, packAct(seq, stageObj.nextChunk - 1, hop.address, x));
|
| 726 |
+
}
|
| 727 |
+
return;
|
| 728 |
+
}
|
| 729 |
const t0 = performance.now();
|
| 730 |
let x;
|
| 731 |
try { x = await Infer.runLayers(stageObj, hidden); }
|
| 732 |
+
catch (e) {
|
| 733 |
+
log(e.name === "NonFiniteError"
|
| 734 |
+
? `REFUSED activation from ${nmeOf(peerId)}: ${e.message}. A non-finite hop is not forwarded.`
|
| 735 |
+
: `stage failed: ${redact(e.message)}`);
|
| 736 |
+
return;
|
| 737 |
+
}
|
| 738 |
if (auditFailure) { log("KERNEL AUDIT FAILED mid-hop — refusing to pass on a value I cannot vouch for"); return; }
|
| 739 |
const hop = Wire.routeAfter(plan, me.index);
|
| 740 |
ui.ringState.textContent = `layers ${me.lo}–${me.hi - 1} in ${(performance.now() - t0).toFixed(0)} ms → ` +
|
|
|
|
| 742 |
await sendTo(hop.to, packAct(seq, tokenIdx, hop.address, x));
|
| 743 |
}
|
| 744 |
|
| 745 |
+
const promptQueue = [];
|
| 746 |
+
|
| 747 |
+
async function generate(isReplay) {
|
| 748 |
+
if (running) {
|
| 749 |
+
const waiting = (ui.prompt.value || "").slice(0, 80);
|
| 750 |
+
promptQueue.push(ui.prompt.value);
|
| 751 |
+
log(`ring busy — queued (${promptQueue.length} waiting): "${waiting}". The turn already generating keeps the ring.`);
|
| 752 |
+
return;
|
| 753 |
+
}
|
| 754 |
if (!repoInfo) { log("load a model first — the device that loads it drives the ring"); return; }
|
| 755 |
if (!tok) { log("no tokenizer loaded"); return; }
|
| 756 |
running = true; abortRun = false; auditFailure = null;
|
|
|
|
| 760 |
const T = Math.min(ctxLen(), repoInfo.spec.maxPos);
|
| 761 |
plan = buildPlan(); modelHash = repoInfo.fingerprint; spec = repoInfo.spec;
|
| 762 |
renderPlan();
|
| 763 |
+
const promptText = ui.prompt.value || "The";
|
| 764 |
+
let idsPreview = [...Tokenizer.encode(tok, promptText)];
|
| 765 |
+
if (!idsPreview.length) idsPreview = [0];
|
| 766 |
+
const lapMs = Shard.estimateLapMs(spec, T, myCapacity);
|
| 767 |
+
log(`lap estimate ${Math.round(lapMs)} ms/token on this device's capacity (budget ${LAP_BUDGET_MS} ms).`);
|
| 768 |
+
if (lapMs > LAP_BUDGET_MS) {
|
| 769 |
+
throw new Error(`refusing the run: estimated ${Math.round(lapMs)} ms per token exceeds ${LAP_BUDGET_MS} ms. ` +
|
| 770 |
+
`Add a faster device or lower the context length.`);
|
| 771 |
+
}
|
| 772 |
+
const fits = Shard.headCanHold(spec, repoInfo.tensors, navigator.deviceMemory || 0);
|
| 773 |
+
const local = Shard.preferLocal(idsPreview.length, plan.length - 1, fits);
|
| 774 |
+
const matched = prefixIndex.matchedBlocks(CHAT, idsPreview);
|
| 775 |
+
if (matched) {
|
| 776 |
+
log(`prefix index: ${matched} block(s) of 64 tokens match this chat. ` +
|
| 777 |
+
`The window is still recomputed so the token ids stay bit-identical to a full pass.`);
|
| 778 |
+
}
|
| 779 |
+
if (local) {
|
| 780 |
+
log(`short prompt (${idsPreview.length} tokens) fits on this device ` +
|
| 781 |
+
`(${(navigator.deviceMemory)} GB reported). Running here. The ring is not used for this turn.`);
|
| 782 |
+
plan = [{ id: myId, index: 0, lo: 0, hi: spec.layers, head: true, capacity: myCapacity, backend: compute.backend }];
|
| 783 |
+
renderPlan();
|
| 784 |
+
}
|
| 785 |
if (plan.length > 1) {
|
| 786 |
log(`plan: ${spec.layers} layers over ${plan.length} device(s) — ` +
|
| 787 |
plan.map(s => `${s.id === myId ? "me" : nmeOf(s.id)}:${s.hi - s.lo}`).join(", "));
|
|
|
|
| 803 |
const seq = ++runSeq;
|
| 804 |
const nTok = +ui.ntok.value, temp = +ui.temp.value / 100;
|
| 805 |
const opts = { temperature: temp, topK: 40, rng: Infer.mulberry32(+ui.seed.value | 0) };
|
| 806 |
+
let ids = idsPreview.slice();
|
| 807 |
+
prefixIndex.note(CHAT, ids);
|
| 808 |
+
// The bytes kept here are the prefix-block hashes, not layer KV. Reusing
|
| 809 |
+
// K and V would change per-column int8 scales, so the pass still recomputes.
|
| 810 |
+
const hashBytes = new Uint8Array(new Uint32Array(prefixIndex.blocks(ids)).buffer);
|
| 811 |
+
if (!kvSpill.put(CHAT, hashBytes)) log("prefix record is larger than the spill cap; this chat is not retained.");
|
| 812 |
+
if (typeof indexedDB !== "undefined") {
|
| 813 |
+
const open = indexedDB.open("daisychain-infer", 1);
|
| 814 |
+
open.onupgradeneeded = () => open.result.createObjectStore("prefix");
|
| 815 |
+
open.onsuccess = () => {
|
| 816 |
+
const db = open.result;
|
| 817 |
+
db.transaction("prefix", "readwrite").objectStore("prefix").put(hashBytes, CHAT);
|
| 818 |
+
};
|
| 819 |
+
}
|
| 820 |
const promptLen = ids.length;
|
| 821 |
ui.out.textContent = ui.prompt.value;
|
| 822 |
const tStart = performance.now();
|
| 823 |
let hops = 0;
|
| 824 |
|
| 825 |
+
const useChunks = plan.length > 1 && ids.length > 32;
|
| 826 |
+
let chunkedIds = null;
|
| 827 |
+
if (useChunks) {
|
| 828 |
+
log(`overlap prefill: ${ids.length} prompt tokens, chunks of 16, 2 in flight. ` +
|
| 829 |
+
`Each stage keeps KV for its own layers. This is not the full-window GEMM.`);
|
| 830 |
+
const chunks = Shard.prefillChunks(ids.length, 16, 2);
|
| 831 |
+
const span = { cache: [], pos0: 0, li: 0 };
|
| 832 |
+
const hop = async (index, hidden) => {
|
| 833 |
+
await sendTo(plan[1].id, packAct(seq, index, plan[1].index, hidden));
|
| 834 |
+
hops += plan.length;
|
| 835 |
+
const key = `${seq}:${index}`;
|
| 836 |
+
const ok = await waitFor(() => actIn.has(key) || peerDropped, 60000);
|
| 837 |
+
if (peerDropped) throw new Error("peer dropped");
|
| 838 |
+
if (!ok) throw new Error("stalled");
|
| 839 |
+
const h = actIn.get(key); actIn.delete(key);
|
| 840 |
+
return h;
|
| 841 |
+
};
|
| 842 |
+
const flights = [];
|
| 843 |
+
let last = null;
|
| 844 |
+
let nextIdx = 0;
|
| 845 |
+
for (const ch of chunks.chunks) {
|
| 846 |
+
if (abortRun) break;
|
| 847 |
+
const piece = ids.slice(ch.lo, ch.hi);
|
| 848 |
+
let x = Infer.embedRows(stageObj, piece, ch.lo);
|
| 849 |
+
span.pos0 = ch.lo;
|
| 850 |
+
x = await Infer.runLayersChunk(stageObj, x, span);
|
| 851 |
+
if (auditFailure) throw new Error("kernel audit failed");
|
| 852 |
+
flights.push(hop(nextIdx++, x));
|
| 853 |
+
if (flights.length >= chunks.window) last = await flights.shift();
|
| 854 |
+
}
|
| 855 |
+
while (flights.length) last = await flights.shift();
|
| 856 |
+
let produced = 0;
|
| 857 |
+
while (produced < nTok && !abortRun && last) {
|
| 858 |
+
const row = (last.length / spec.hidden) - 1;
|
| 859 |
+
const id = Infer.pickToken((await Infer.logitsAt(stageObj, last, [row]))[0], opts);
|
| 860 |
+
ids.push(id); produced++;
|
| 861 |
+
ui.out.textContent = Tokenizer.decode(tok, ids);
|
| 862 |
+
ui.tokcount.textContent = String(produced);
|
| 863 |
+
broadcast(Wire.packToken(seq, id, ids.length));
|
| 864 |
+
const pos = ids.length - 1;
|
| 865 |
+
let x = Infer.embedRows(stageObj, [id], pos);
|
| 866 |
+
span.pos0 = pos;
|
| 867 |
+
x = await Infer.runLayersChunk(stageObj, x, span);
|
| 868 |
+
last = await hop(nextIdx++, x);
|
| 869 |
+
}
|
| 870 |
+
chunkedIds = ids.slice();
|
| 871 |
+
}
|
| 872 |
+
|
| 873 |
+
if (!useChunks) {
|
| 874 |
+
for (let produced = 0; produced < nTok && !abortRun; ) {
|
| 875 |
+
let draft = Shard.promptLookupDraft(ids, 4);
|
| 876 |
+
const seqIds = ids.concat(draft);
|
| 877 |
+
const shown = seqIds.slice(-T);
|
| 878 |
+
const shownDraft = Math.min(draft.length, Math.max(0, shown.length - 1));
|
| 879 |
+
draft = draft.slice(draft.length - shownDraft);
|
| 880 |
const win = new Int32Array(T);
|
| 881 |
+
for (let i = 0; i < shown.length; i++) win[T - shown.length + i] = shown[i];
|
|
|
|
| 882 |
let x = Infer.embed(stageObj, win);
|
| 883 |
x = await Infer.runLayers(stageObj, x);
|
| 884 |
if (auditFailure) { log(`KERNEL AUDIT FAILED: ${auditFailure} — stopping`); break; }
|
| 885 |
if (plan.length > 1) {
|
| 886 |
+
await sendTo(plan[1].id, packAct(seq, produced, plan[1].index, x));
|
| 887 |
hops += plan.length;
|
| 888 |
+
const key = `${seq}:${produced}`;
|
| 889 |
+
const ok = await waitFor(() => actIn.has(key) || peerDropped, 60000);
|
| 890 |
+
if (peerDropped) throw new Error("peer dropped");
|
| 891 |
+
if (!ok) throw new Error("stalled");
|
| 892 |
x = actIn.get(key); actIn.delete(key);
|
| 893 |
}
|
| 894 |
+
const rows = Infer.draftRows(T, shown.length, draft.length);
|
| 895 |
+
const picked = (await Infer.logitsAt(stageObj, x, rows)).map(l => Infer.pickToken(l, opts));
|
| 896 |
+
const got = Shard.acceptDraft(picked, draft);
|
| 897 |
+
if (draft.length) log(`draft span ${draft.length}, accepted ${Math.max(0, got.length - 1)} before the bonus token. One lap.`);
|
| 898 |
+
for (const id of got) {
|
| 899 |
+
if (produced >= nTok) break;
|
| 900 |
+
ids.push(id); produced++;
|
| 901 |
+
ui.out.textContent = Tokenizer.decode(tok, ids);
|
| 902 |
+
ui.tokcount.textContent = String(produced);
|
| 903 |
+
ui.hops.textContent = String(hops);
|
| 904 |
+
ui.tps.textContent = ((produced) / ((performance.now() - tStart) / 1000)).toFixed(2);
|
| 905 |
+
broadcast(Wire.packToken(seq, id, ids.length));
|
| 906 |
+
}
|
| 907 |
await new Promise(r => setTimeout(r, 0));
|
| 908 |
}
|
| 909 |
+
}
|
| 910 |
// The differential check. In a pipeline nothing recomputes anything, so
|
| 911 |
// this is the only instrument that can show a distributed answer is RIGHT
|
| 912 |
// rather than merely self-consistent. It costs a full local run, which is
|
|
|
|
| 916 |
log("verifying: fetching the whole model here and re-running the same prompt…");
|
| 917 |
const all = { id: myId, index: 0, lo: 0, hi: spec.layers, head: true };
|
| 918 |
const solo = await loadMyStage({ repo: repoInfo.repo, revision: repoInfo.revision, spec, ctx: T }, all);
|
| 919 |
+
const promptIds = Tokenizer.encode(tok, ui.prompt.value || "The");
|
| 920 |
+
const ref = useChunks
|
| 921 |
+
? await Infer.generateChunked([solo], promptIds.length ? promptIds : [0], nTok, 16,
|
| 922 |
+
{ temperature: temp, topK: 40, rng: Infer.mulberry32(+ui.seed.value | 0) })
|
| 923 |
+
: await Infer.generateLookahead([solo], promptIds.length ? promptIds : [0], nTok, T,
|
| 924 |
+
{ temperature: temp, topK: 40, rng: Infer.mulberry32(+ui.seed.value | 0), maxDraft: 4 });
|
| 925 |
const same = ref.length === ids.length && ref.every((v, i) => v === ids[i]);
|
| 926 |
log(same ? "VERIFIED: the distributed run and the single-device run produced identical token ids."
|
| 927 |
: `MISMATCH: the ring and this device disagree. Distributed "${Tokenizer.decode(tok, ids).slice(0, 60)}" ` +
|
|
|
|
| 931 |
`over ${plan.length} stage(s)`);
|
| 932 |
broadcast(Wire.packDone(seq));
|
| 933 |
} catch (e) {
|
| 934 |
+
if (!isReplay && /peer dropped|stalled/.test(String(e.message))) {
|
| 935 |
+
log("a peer left the ring. Replanning the remaining devices and replaying the prompt from the start.");
|
| 936 |
+
peerDropped = false;
|
| 937 |
+
running = false;
|
| 938 |
+
ui.genBtn.disabled = false; ui.stopBtn.disabled = true;
|
| 939 |
+
ui.ringState.textContent = "idle";
|
| 940 |
+
return generate(true);
|
| 941 |
+
}
|
| 942 |
log(`run failed: ${redact(e.message)}`);
|
| 943 |
console.error(e);
|
| 944 |
}
|
| 945 |
running = false;
|
| 946 |
ui.genBtn.disabled = false; ui.stopBtn.disabled = true;
|
| 947 |
ui.ringState.textContent = "idle";
|
| 948 |
+
if (promptQueue.length && !abortRun) {
|
| 949 |
+
ui.prompt.value = promptQueue.shift();
|
| 950 |
+
log(`starting queued prompt (${promptQueue.length} still waiting).`);
|
| 951 |
+
generate();
|
| 952 |
+
}
|
| 953 |
}
|
| 954 |
|
| 955 |
function onToken(peerId, buf) {
|
public/hf.js
CHANGED
|
@@ -125,17 +125,27 @@
|
|
| 125 |
}
|
| 126 |
let done = 0;
|
| 127 |
const total = tensors.reduce((a, t) => a + t.bytes, 0);
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
}
|
|
|
|
| 139 |
return out;
|
| 140 |
}
|
| 141 |
|
|
|
|
| 125 |
}
|
| 126 |
let done = 0;
|
| 127 |
const total = tensors.reduce((a, t) => a + t.bytes, 0);
|
| 128 |
+
// One range stays in flight while the previous body is widened to f32.
|
| 129 |
+
// The bytes are unchanged. The next layer's read hides behind this one.
|
| 130 |
+
const runs = [];
|
| 131 |
+
for (const [file, list] of byFile)
|
| 132 |
+
for (const run of ST.coalesce(list)) runs.push({ file, run });
|
| 133 |
+
let pending = null;
|
| 134 |
+
const take = async (slot) => {
|
| 135 |
+
const buf = await slot.p;
|
| 136 |
+
for (const t of slot.run.tensors) {
|
| 137 |
+
const off = t.start - slot.run.start;
|
| 138 |
+
out.set(t.name, ST.toF32(t.dtype, new Uint8Array(buf, off, t.bytes)));
|
| 139 |
+
done += t.bytes;
|
| 140 |
+
if (onProgress) onProgress(`${(done / 1048576).toFixed(1)} / ${(total / 1048576).toFixed(1)} MB`);
|
| 141 |
}
|
| 142 |
+
};
|
| 143 |
+
for (const slot of runs) {
|
| 144 |
+
const p = getRange(repo, slot.file, revision, token, slot.run.start, slot.run.end);
|
| 145 |
+
if (pending) await take(pending);
|
| 146 |
+
pending = { p, run: slot.run };
|
| 147 |
}
|
| 148 |
+
if (pending) await take(pending);
|
| 149 |
return out;
|
| 150 |
}
|
| 151 |
|
public/infer.js
CHANGED
|
@@ -106,8 +106,14 @@
|
|
| 106 |
}
|
| 107 |
|
| 108 |
// ---- one layer --------------------------------------------------------------
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
const hd = spec.headDim, nH = spec.heads, nKV = spec.kvHeads;
|
| 112 |
const qDim = nH * hd, kvDim = nKV * hd;
|
| 113 |
|
|
@@ -130,7 +136,14 @@
|
|
| 130 |
vmm(h1, ly.Wv, T, C, kvDim, ctx, ly.bv),
|
| 131 |
]);
|
| 132 |
}
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
// Grouped-query attention: several query heads share one kv head. Rather
|
| 136 |
// than materialising repeated kv (memory this project does not have to
|
|
@@ -138,6 +151,18 @@
|
|
| 138 |
const group = nH / nKV;
|
| 139 |
const scale = 1 / Math.sqrt(hd);
|
| 140 |
const ctxOut = new Float32Array(T * qDim);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
// Scores and context are the two attention GEMMs. They are small per head
|
| 142 |
// (T x hd and T x T), and the fused kernels in webgpu.js expect the
|
| 143 |
// trainer's uniform-head layout, so at general head geometry they are run
|
|
@@ -145,23 +170,25 @@
|
|
| 145 |
// through the units, and the CPU mirror and GPU kernel agree bit-for-bit.
|
| 146 |
for (let h = 0; h < nH; h++) {
|
| 147 |
const kvh = Math.floor(h / group);
|
| 148 |
-
const qh = new Float32Array(T * hd), kh = new Float32Array(
|
| 149 |
-
for (let t = 0; t < T; t++)
|
| 150 |
qh.set(q.subarray(t * qDim + h * hd, t * qDim + h * hd + hd), t * hd);
|
|
|
|
| 151 |
kh.set(k.subarray(t * kvDim + kvh * hd, t * kvDim + kvh * hd + hd), t * hd);
|
| 152 |
vh.set(v.subarray(t * kvDim + kvh * hd, t * kvDim + kvh * hd + hd), t * hd);
|
| 153 |
}
|
| 154 |
-
const khT = TC.transpose(kh,
|
| 155 |
-
const scores = await V.vgemmBlock(qh, khT, { m: T, k: hd, n:
|
| 156 |
-
const a = new Float32Array(T *
|
| 157 |
for (let i = 0; i < T; i++) {
|
|
|
|
| 158 |
let mx = -Infinity;
|
| 159 |
-
for (let j = 0; j <=
|
| 160 |
let z = 0;
|
| 161 |
-
for (let j = 0; j <=
|
| 162 |
-
for (let j = 0; j <=
|
| 163 |
}
|
| 164 |
-
const oh = await V.vgemmBlock(a, vh, { m: T, k:
|
| 165 |
for (let t = 0; t < T; t++) ctxOut.set(oh.subarray(t * hd, t * hd + hd), t * qDim + h * hd);
|
| 166 |
}
|
| 167 |
const attn = await vmm(ctxOut, ly.Wo, T, qDim, C, ctx, ly.bo);
|
|
@@ -210,7 +237,42 @@
|
|
| 210 |
}
|
| 211 |
|
| 212 |
async function runLayers(S, x) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
for (const ly of S.w.layers) x = await layerForward(S, x, ly);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
return x;
|
| 215 |
}
|
| 216 |
|
|
@@ -224,6 +286,65 @@
|
|
| 224 |
return vmm(last, S.w.lmHead, 1, C, spec.vocab, ctx, null);
|
| 225 |
}
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
// ---- sampling ---------------------------------------------------------------
|
| 228 |
function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
|
| 229 |
function pickToken(logits, opts) {
|
|
@@ -259,7 +380,40 @@
|
|
| 259 |
return out;
|
| 260 |
}
|
| 261 |
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
rmsNorm, layerNorm, norm, ropeTables, applyRope, silu, gelu, mulberry32 };
|
| 264 |
if (typeof module !== "undefined" && module.exports) {
|
| 265 |
TC = require("./traincore.js"); V = require("./verified_core.js"); SH = require("./shard.js");
|
|
|
|
| 106 |
}
|
| 107 |
|
| 108 |
// ---- one layer --------------------------------------------------------------
|
| 109 |
+
// `span` is the chunked-prefill path. Absent, the layer is the verified
|
| 110 |
+
// full-window GEMM (one scale set for the whole window). Present, this stage
|
| 111 |
+
// keeps K and V for its own layers and attends the new rows to that cache.
|
| 112 |
+
// Those two paths are not bit-identical: per-column scales move when the
|
| 113 |
+
// matrix grows. Callers must not mix them in one comparison.
|
| 114 |
+
async function layerForward(S, x, ly, span) {
|
| 115 |
+
const { spec, ctx } = S, C = spec.hidden;
|
| 116 |
+
const T = span ? (x.length / C) | 0 : S.T;
|
| 117 |
const hd = spec.headDim, nH = spec.heads, nKV = spec.kvHeads;
|
| 118 |
const qDim = nH * hd, kvDim = nKV * hd;
|
| 119 |
|
|
|
|
| 136 |
vmm(h1, ly.Wv, T, C, kvDim, ctx, ly.bv),
|
| 137 |
]);
|
| 138 |
}
|
| 139 |
+
const pos0 = span ? span.pos0 : 0;
|
| 140 |
+
if (spec.rope) {
|
| 141 |
+
const need = pos0 + T + 1;
|
| 142 |
+
if (!S.rope || S.rope.cos.length < need * (hd >> 1))
|
| 143 |
+
S.rope = ropeTables(hd, spec.ropeTheta, Math.max(need, spec.maxPos || need));
|
| 144 |
+
applyRope(q, T, nH, hd, S.rope, pos0);
|
| 145 |
+
applyRope(k, T, nKV, hd, S.rope, pos0);
|
| 146 |
+
}
|
| 147 |
|
| 148 |
// Grouped-query attention: several query heads share one kv head. Rather
|
| 149 |
// than materialising repeated kv (memory this project does not have to
|
|
|
|
| 151 |
const group = nH / nKV;
|
| 152 |
const scale = 1 / Math.sqrt(hd);
|
| 153 |
const ctxOut = new Float32Array(T * qDim);
|
| 154 |
+
let cache = null, n0 = 0, N = T;
|
| 155 |
+
if (span) {
|
| 156 |
+
if (!span.cache) span.cache = [];
|
| 157 |
+
cache = span.cache[span.li] || (span.cache[span.li] = { k: null, v: null, n: 0 });
|
| 158 |
+
n0 = cache.n;
|
| 159 |
+
N = n0 + T;
|
| 160 |
+
const nk = new Float32Array(N * kvDim), nv = new Float32Array(N * kvDim);
|
| 161 |
+
if (cache.k) { nk.set(cache.k); nv.set(cache.v); }
|
| 162 |
+
nk.set(k, n0 * kvDim); nv.set(v, n0 * kvDim);
|
| 163 |
+
cache.k = nk; cache.v = nv; cache.n = N;
|
| 164 |
+
k = nk; v = nv;
|
| 165 |
+
}
|
| 166 |
// Scores and context are the two attention GEMMs. They are small per head
|
| 167 |
// (T x hd and T x T), and the fused kernels in webgpu.js expect the
|
| 168 |
// trainer's uniform-head layout, so at general head geometry they are run
|
|
|
|
| 170 |
// through the units, and the CPU mirror and GPU kernel agree bit-for-bit.
|
| 171 |
for (let h = 0; h < nH; h++) {
|
| 172 |
const kvh = Math.floor(h / group);
|
| 173 |
+
const qh = new Float32Array(T * hd), kh = new Float32Array(N * hd), vh = new Float32Array(N * hd);
|
| 174 |
+
for (let t = 0; t < T; t++)
|
| 175 |
qh.set(q.subarray(t * qDim + h * hd, t * qDim + h * hd + hd), t * hd);
|
| 176 |
+
for (let t = 0; t < N; t++) {
|
| 177 |
kh.set(k.subarray(t * kvDim + kvh * hd, t * kvDim + kvh * hd + hd), t * hd);
|
| 178 |
vh.set(v.subarray(t * kvDim + kvh * hd, t * kvDim + kvh * hd + hd), t * hd);
|
| 179 |
}
|
| 180 |
+
const khT = TC.transpose(kh, N, hd); // hd x N
|
| 181 |
+
const scores = await V.vgemmBlock(qh, khT, { m: T, k: hd, n: N, batch: 1 }, ctx.L, ctx.bgemm, ctx.audit);
|
| 182 |
+
const a = new Float32Array(T * N); // causal softmax
|
| 183 |
for (let i = 0; i < T; i++) {
|
| 184 |
+
const see = n0 + i; // this query's own position in the cache
|
| 185 |
let mx = -Infinity;
|
| 186 |
+
for (let j = 0; j <= see; j++) mx = Math.max(mx, scores[i * N + j] * scale);
|
| 187 |
let z = 0;
|
| 188 |
+
for (let j = 0; j <= see; j++) { const e = Math.exp(scores[i * N + j] * scale - mx); a[i * N + j] = e; z += e; }
|
| 189 |
+
for (let j = 0; j <= see; j++) a[i * N + j] /= z;
|
| 190 |
}
|
| 191 |
+
const oh = await V.vgemmBlock(a, vh, { m: T, k: N, n: hd, batch: 1 }, ctx.L, ctx.bgemm, ctx.audit);
|
| 192 |
for (let t = 0; t < T; t++) ctxOut.set(oh.subarray(t * hd, t * hd + hd), t * qDim + h * hd);
|
| 193 |
}
|
| 194 |
const attn = await vmm(ctxOut, ly.Wo, T, qDim, C, ctx, ly.bo);
|
|
|
|
| 237 |
}
|
| 238 |
|
| 239 |
async function runLayers(S, x) {
|
| 240 |
+
// A non-finite activation used to quantize to a silent zero and keep
|
| 241 |
+
// travelling. Train refuses it (NonFiniteError). A hop must too: the
|
| 242 |
+
// next stage would otherwise agree on a wrong sum.
|
| 243 |
+
V.assertFinite(x, "activation");
|
| 244 |
for (const ly of S.w.layers) x = await layerForward(S, x, ly);
|
| 245 |
+
V.assertFinite(x, "activation");
|
| 246 |
+
return x;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// One chunk of a pipelined prefill. `span.cache` is owned by the caller and
|
| 250 |
+
// lives for the whole prompt; each call appends this chunk's K and V.
|
| 251 |
+
// `span.pos0` is the first absolute position in `x`.
|
| 252 |
+
async function runLayersChunk(S, x, span) {
|
| 253 |
+
V.assertFinite(x, "activation");
|
| 254 |
+
const C = S.spec.hidden;
|
| 255 |
+
const T = (x.length / C) | 0;
|
| 256 |
+
if (T < 1 || T * C !== x.length) throw new Error("chunk length is not a whole number of rows");
|
| 257 |
+
for (let li = 0; li < S.w.layers.length; li++) {
|
| 258 |
+
span.li = li;
|
| 259 |
+
x = await layerForward(S, x, S.w.layers[li], span);
|
| 260 |
+
}
|
| 261 |
+
V.assertFinite(x, "activation");
|
| 262 |
+
return x;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
function embedRows(S, ids, pos0) {
|
| 266 |
+
const { spec } = S, C = spec.hidden, T = ids.length;
|
| 267 |
+
const x = new Float32Array(T * C);
|
| 268 |
+
for (let i = 0; i < T; i++) {
|
| 269 |
+
const id = ids[i];
|
| 270 |
+
for (let j = 0; j < C; j++) x[i * C + j] = S.w.emb[id * C + j];
|
| 271 |
+
if (S.w.pos) {
|
| 272 |
+
const p = pos0 + i;
|
| 273 |
+
for (let j = 0; j < C; j++) x[i * C + j] += S.w.pos[p * C + j];
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
return x;
|
| 277 |
}
|
| 278 |
|
|
|
|
| 286 |
return vmm(last, S.w.lmHead, 1, C, spec.vocab, ctx, null);
|
| 287 |
}
|
| 288 |
|
| 289 |
+
// Logits at several rows of one window, so one lap can score a draft span.
|
| 290 |
+
// Row indexes are into the fixed context window the verified path uses.
|
| 291 |
+
async function logitsAt(S, x, rows) {
|
| 292 |
+
const { spec, ctx } = S, C = spec.hidden;
|
| 293 |
+
const T = (x.length / C) | 0;
|
| 294 |
+
const y = norm(spec, x, T, C, S.w.nrmF, S.w.nrmFb);
|
| 295 |
+
const out = [];
|
| 296 |
+
for (const r of rows) {
|
| 297 |
+
if (r < 0 || r >= T) throw new Error("readout row outside the window");
|
| 298 |
+
out.push(await vmm(y.subarray(r * C, (r + 1) * C), S.w.lmHead, 1, C, spec.vocab, ctx, null));
|
| 299 |
+
}
|
| 300 |
+
return out;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
// Chunked prefill, then one new token per lap on the same KV. Both the
|
| 304 |
+
// ring and the differential check call this so they compare like with like.
|
| 305 |
+
// It is not the full-window path.
|
| 306 |
+
async function generateChunked(stages, ids0, nNew, chunkSize, opts) {
|
| 307 |
+
const ids = [...ids0];
|
| 308 |
+
const spans = stages.map(() => ({ cache: [], pos0: 0, li: 0 }));
|
| 309 |
+
const size = Math.max(1, chunkSize | 0);
|
| 310 |
+
let last = null;
|
| 311 |
+
for (let lo = 0; lo < ids.length; lo += size) {
|
| 312 |
+
const piece = ids.slice(lo, Math.min(ids.length, lo + size));
|
| 313 |
+
let x = embedRows(stages[0], piece, lo);
|
| 314 |
+
for (let s = 0; s < stages.length; s++) {
|
| 315 |
+
spans[s].pos0 = lo;
|
| 316 |
+
x = await runLayersChunk(stages[s], x, spans[s]);
|
| 317 |
+
}
|
| 318 |
+
last = x;
|
| 319 |
+
}
|
| 320 |
+
for (let n = 0; n < nNew; n++) {
|
| 321 |
+
const row = (last.length / stages[0].spec.hidden) - 1;
|
| 322 |
+
const id = pickToken((await logitsAt(stages[0], last, [row]))[0], opts);
|
| 323 |
+
ids.push(id);
|
| 324 |
+
const pos = ids.length - 1;
|
| 325 |
+
let x = embedRows(stages[0], [id], pos);
|
| 326 |
+
for (let s = 0; s < stages.length; s++) {
|
| 327 |
+
spans[s].pos0 = pos;
|
| 328 |
+
x = await runLayersChunk(stages[s], x, spans[s]);
|
| 329 |
+
}
|
| 330 |
+
last = x;
|
| 331 |
+
}
|
| 332 |
+
return ids;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
// Where, in a left-padded window of length T, the logits sit that propose
|
| 336 |
+
// each draft token plus the one token after the first rejection.
|
| 337 |
+
function draftRows(T, seqLen, draftLen) {
|
| 338 |
+
const rows = [];
|
| 339 |
+
const first = seqLen - draftLen - 1;
|
| 340 |
+
for (let i = 0; i <= draftLen; i++) {
|
| 341 |
+
const tok = first + i;
|
| 342 |
+
if (tok < 0 || tok >= seqLen) break;
|
| 343 |
+
rows.push(T - seqLen + tok);
|
| 344 |
+
}
|
| 345 |
+
return rows;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
// ---- sampling ---------------------------------------------------------------
|
| 349 |
function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
|
| 350 |
function pickToken(logits, opts) {
|
|
|
|
| 380 |
return out;
|
| 381 |
}
|
| 382 |
|
| 383 |
+
// Verified full-window path, with a prompt-lookup draft scored in the same lap.
|
| 384 |
+
// An empty draft is exactly one new token, the same window as generateLocal.
|
| 385 |
+
async function generateLookahead(stages, ids0, nNew, T, opts) {
|
| 386 |
+
const ids = [...ids0];
|
| 387 |
+
const head = stages[0];
|
| 388 |
+
const maxDraft = opts && opts.maxDraft != null ? opts.maxDraft : 4;
|
| 389 |
+
let produced = 0;
|
| 390 |
+
while (produced < nNew) {
|
| 391 |
+
let draft = SH.promptLookupDraft(ids, maxDraft);
|
| 392 |
+
const seqIds = ids.concat(draft);
|
| 393 |
+
const shown = seqIds.slice(-T);
|
| 394 |
+
const shownDraft = Math.min(draft.length, Math.max(0, shown.length - 1));
|
| 395 |
+
draft = draft.slice(draft.length - shownDraft);
|
| 396 |
+
const win = new Int32Array(T);
|
| 397 |
+
for (let i = 0; i < shown.length; i++) win[T - shown.length + i] = shown[i];
|
| 398 |
+
let x = embed(head, win);
|
| 399 |
+
for (const S of stages) x = await runLayers(S, x);
|
| 400 |
+
const rows = draftRows(T, shown.length, draft.length);
|
| 401 |
+
const picked = [];
|
| 402 |
+
for (const logits of await logitsAt(head, x, rows)) picked.push(pickToken(logits, opts));
|
| 403 |
+
const got = SH.acceptDraft(picked, draft);
|
| 404 |
+
if (!got.length) break;
|
| 405 |
+
for (const id of got) {
|
| 406 |
+
ids.push(id);
|
| 407 |
+
produced++;
|
| 408 |
+
if (produced >= nNew) break;
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
return ids;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
const api = { makeStage, embed, embedRows, runLayers, runLayersChunk, readout, logitsAt, draftRows,
|
| 415 |
+
generateLookahead,
|
| 416 |
+
pickToken, generateLocal, generateChunked,
|
| 417 |
rmsNorm, layerNorm, norm, ropeTables, applyRope, silu, gelu, mulberry32 };
|
| 418 |
if (typeof module !== "undefined" && module.exports) {
|
| 419 |
TC = require("./traincore.js"); V = require("./verified_core.js"); SH = require("./shard.js");
|
public/shard.js
CHANGED
|
@@ -148,8 +148,165 @@
|
|
| 148 |
return fnv1a(new TextEncoder().encode(parts.join("|")));
|
| 149 |
}
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
const api = { planStages, stageBytes, stageWeights, toKN, transposeEmb,
|
| 152 |
-
fnv1a, hashF32, modelFingerprint
|
|
|
|
|
|
|
| 153 |
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
| 154 |
else root.Shard = api;
|
| 155 |
})(typeof self !== "undefined" ? self : this);
|
|
|
|
| 148 |
return fnv1a(new TextEncoder().encode(parts.join("|")));
|
| 149 |
}
|
| 150 |
|
| 151 |
+
// Peers whose kernel probe disagrees with the head are not in the ring.
|
| 152 |
+
// The head (index 0) is always kept. A missing probe is not a disagreement:
|
| 153 |
+
// the hello has not arrived yet, and buildPlan only lists peers that sent one.
|
| 154 |
+
function ringMembers(caps, probeHash) {
|
| 155 |
+
if (!caps.length) return [];
|
| 156 |
+
const head = caps[0];
|
| 157 |
+
const rest = caps.slice(1).filter(d => !probeHash || !d.probe || d.probe === probeHash);
|
| 158 |
+
return [head, ...rest];
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// Budget check, not a latency model. `gemmPerSec` is the capacity probe:
|
| 162 |
+
// how many 32x64x64 verified GEMMs this device finishes per second. The
|
| 163 |
+
// count below is the MAC volume of one window through every layer, divided
|
| 164 |
+
// by that unit. It is there to refuse a split that cannot produce a token
|
| 165 |
+
// in `budgetMs`, before any weight download.
|
| 166 |
+
function estimateLapMs(spec, T, gemmPerSec) {
|
| 167 |
+
const unit = 32 * 64 * 64;
|
| 168 |
+
const C = spec.hidden;
|
| 169 |
+
const inter = spec.inter || (C * 4);
|
| 170 |
+
const hd = spec.headDim || Math.floor(C / spec.heads);
|
| 171 |
+
const q = spec.heads * hd;
|
| 172 |
+
const kv = (spec.kvHeads || spec.heads) * hd;
|
| 173 |
+
const macPerLayer = T * C * q + 2 * T * C * kv + T * q * C + 3 * T * C * inter
|
| 174 |
+
+ spec.heads * (T * hd * T + T * T * hd);
|
| 175 |
+
const gemms = (macPerLayer * spec.layers) / unit;
|
| 176 |
+
return gemms / Math.max(gemmPerSec, 1e-6) * 1000;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// Whole-model f32 bytes versus a fraction of reported device RAM.
|
| 180 |
+
// `deviceGB` comes from navigator.deviceMemory when the browser provides it.
|
| 181 |
+
// Unknown memory returns false: the ring is used, rather than guessing.
|
| 182 |
+
function headCanHold(spec, tensors, deviceGB) {
|
| 183 |
+
if (!deviceGB || deviceGB <= 0) return false;
|
| 184 |
+
let bytes = 0;
|
| 185 |
+
for (const t of tensors.values()) bytes += t.elems * 4;
|
| 186 |
+
return bytes < deviceGB * 0.25 * 1073741824;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// A short prompt stays on the head when the head can hold every layer.
|
| 190 |
+
// The ring still exists for prompts that do not fit that rule.
|
| 191 |
+
function preferLocal(promptTokens, peerCount, fits) {
|
| 192 |
+
return !!(fits && peerCount > 0 && promptTokens > 0 && promptTokens <= 64);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// Rolling FNV blocks over token ids. This is the approximate prefix index:
|
| 196 |
+
// it records which prefix this chat last ran. The forward pass still
|
| 197 |
+
// recomputes the window. Per-column value scales depend on every token in
|
| 198 |
+
// the window, so skipping a matched block would not be bit-identical to the
|
| 199 |
+
// full pass, and this project does not ship a second answer.
|
| 200 |
+
function makePrefixIndex(blockSize) {
|
| 201 |
+
const B = blockSize || 64;
|
| 202 |
+
const sessions = new Map();
|
| 203 |
+
function blocks(ids) {
|
| 204 |
+
const out = [];
|
| 205 |
+
for (let i = 0; i < ids.length; i += B) {
|
| 206 |
+
const slice = ids.slice(i, Math.min(ids.length, i + B));
|
| 207 |
+
out.push(fnv1a(new TextEncoder().encode(slice.join(","))));
|
| 208 |
+
}
|
| 209 |
+
return out;
|
| 210 |
+
}
|
| 211 |
+
return {
|
| 212 |
+
blockSize: B,
|
| 213 |
+
blocks,
|
| 214 |
+
note(session, ids) { sessions.set(session, blocks(ids)); },
|
| 215 |
+
matchedBlocks(session, ids) {
|
| 216 |
+
const prev = sessions.get(session);
|
| 217 |
+
if (!prev) return 0;
|
| 218 |
+
const now = blocks(ids);
|
| 219 |
+
let n = 0;
|
| 220 |
+
while (n < now.length && n < prev.length && now[n] === prev[n]) n++;
|
| 221 |
+
return n;
|
| 222 |
+
},
|
| 223 |
+
};
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
// In-memory KV spill. When the byte cap is hit, the oldest block is dropped
|
| 227 |
+
// and the caller recomputes that span. `held` is what remained.
|
| 228 |
+
function makeKvSpill(maxBytes) {
|
| 229 |
+
let used = 0;
|
| 230 |
+
const order = [];
|
| 231 |
+
const map = new Map();
|
| 232 |
+
function drop() {
|
| 233 |
+
const k = order.shift();
|
| 234 |
+
const old = map.get(k);
|
| 235 |
+
if (!old) return;
|
| 236 |
+
map.delete(k);
|
| 237 |
+
used -= old.byteLength;
|
| 238 |
+
}
|
| 239 |
+
return {
|
| 240 |
+
get used() { return used; },
|
| 241 |
+
get size() { return map.size; },
|
| 242 |
+
put(key, bytes) {
|
| 243 |
+
const n = bytes.byteLength;
|
| 244 |
+
if (map.has(key)) {
|
| 245 |
+
used -= map.get(key).byteLength;
|
| 246 |
+
const i = order.indexOf(key);
|
| 247 |
+
if (i >= 0) order.splice(i, 1);
|
| 248 |
+
}
|
| 249 |
+
while (order.length && used + n > maxBytes) drop();
|
| 250 |
+
if (n > maxBytes) return false;
|
| 251 |
+
map.set(key, bytes);
|
| 252 |
+
order.push(key);
|
| 253 |
+
used += n;
|
| 254 |
+
return true;
|
| 255 |
+
},
|
| 256 |
+
get(key) { return map.get(key) || null; },
|
| 257 |
+
};
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
// Prompt chunks for a pipelined prefill. `window` is how many chunks may be
|
| 261 |
+
// in flight across the ring at once. Decode stays one token, one lap:
|
| 262 |
+
// the next token is not known until the lap returns.
|
| 263 |
+
function prefillChunks(nTokens, chunkSize, inFlight) {
|
| 264 |
+
const size = Math.max(1, chunkSize | 0);
|
| 265 |
+
const window = Math.max(1, inFlight | 0);
|
| 266 |
+
const chunks = [];
|
| 267 |
+
for (let lo = 0; lo < nTokens; lo += size)
|
| 268 |
+
chunks.push({ index: chunks.length, lo, hi: Math.min(nTokens, lo + size) });
|
| 269 |
+
return { chunks, window, chunkSize: size };
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
// Prompt-lookup draft: the longest suffix of the token ids that already
|
| 273 |
+
// occurred earlier, and the tokens that followed it. No second model.
|
| 274 |
+
// Empty when nothing repeats. The ring then scores that span in one lap.
|
| 275 |
+
function promptLookupDraft(ids, maxDraft) {
|
| 276 |
+
const cap = Math.max(0, maxDraft | 0);
|
| 277 |
+
const n = ids.length;
|
| 278 |
+
if (cap === 0 || n < 3) return [];
|
| 279 |
+
let best = [], bestLen = 0;
|
| 280 |
+
const maxLen = Math.min(n - 1, 8);
|
| 281 |
+
for (let len = 2; len <= maxLen; len++) {
|
| 282 |
+
for (let i = 0; i + len < n; i++) {
|
| 283 |
+
let match = true;
|
| 284 |
+
for (let j = 0; j < len; j++) if (ids[i + j] !== ids[n - len + j]) { match = false; break; }
|
| 285 |
+
if (!match) continue;
|
| 286 |
+
const draft = [];
|
| 287 |
+
// Tokens after the earlier copy, and before the suffix we just matched.
|
| 288 |
+
for (let k = 0; k < cap && i + len + k < n - len; k++) draft.push(ids[i + len + k]);
|
| 289 |
+
if (!draft.length) continue;
|
| 290 |
+
if (len > bestLen) { best = draft; bestLen = len; }
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
return best;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
// picked[i] proposes draft[i]. picked has one extra entry: the token at the
|
| 297 |
+
// first position that rejected, which is always kept.
|
| 298 |
+
function acceptDraft(picked, draft) {
|
| 299 |
+
const out = [];
|
| 300 |
+
let i = 0;
|
| 301 |
+
while (i < draft.length && i < picked.length && picked[i] === draft[i]) out.push(draft[i]), i++;
|
| 302 |
+
if (i < picked.length) out.push(picked[i]);
|
| 303 |
+
return out;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
const api = { planStages, stageBytes, stageWeights, toKN, transposeEmb,
|
| 307 |
+
fnv1a, hashF32, modelFingerprint, ringMembers, estimateLapMs,
|
| 308 |
+
headCanHold, preferLocal, makePrefixIndex, makeKvSpill,
|
| 309 |
+
prefillChunks, promptLookupDraft, acceptDraft };
|
| 310 |
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
| 311 |
else root.Shard = api;
|
| 312 |
})(typeof self !== "undefined" ? self : this);
|
public/traincore.js
CHANGED
|
@@ -54,13 +54,29 @@
|
|
| 54 |
// ORDER MATTERS: float addition is not associative, so every replica MUST
|
| 55 |
// pass the gradients in the same order (the leader's roster order) or their
|
| 56 |
// averages differ in the last bits and the weights fork. Never self-first.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
function averageGrads(grads) {
|
| 58 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
for (const g of grads) for (let i = 0; i < g.length; i++) out[i] += g[i];
|
| 60 |
for (let i = 0; i < out.length; i++) out[i] /= grads.length;
|
| 61 |
return out;
|
| 62 |
}
|
| 63 |
|
|
|
|
|
|
|
| 64 |
// DaisyAdam — Adam with bias correction, applied to the cluster-averaged
|
| 65 |
// gradient. State is a pure function of the gradient sequence, so every peer
|
| 66 |
// that averages the same gradients keeps bit-identical moments: no optimizer
|
|
|
|
| 54 |
// ORDER MATTERS: float addition is not associative, so every replica MUST
|
| 55 |
// pass the gradients in the same order (the leader's roster order) or their
|
| 56 |
// averages differ in the last bits and the weights fork. Never self-first.
|
| 57 |
+
//
|
| 58 |
+
// REFUSES (throws GradientError) rather than averaging when (a) the gradients differ in
|
| 59 |
+
// length -- typed arrays silently ignore out-of-range writes, so a truncated gradient used
|
| 60 |
+
// to be averaged over a prefix with no error -- or (b) any value is non-finite: one NaN
|
| 61 |
+
// would enter DaisyAdam's m/v on EVERY peer (and every later checkpoint) identically, so no
|
| 62 |
+
// fork check could see it. Refusing BEFORE the optimizer keeps weights and moments clean.
|
| 63 |
function averageGrads(grads) {
|
| 64 |
+
const len = grads[0].length;
|
| 65 |
+
for (let r = 0; r < grads.length; r++) {
|
| 66 |
+
const g = grads[r];
|
| 67 |
+
if (!g || g.length !== len) throw gradError("gradient " + r + " has " +
|
| 68 |
+
(g ? g.length : "no") + " values, gradient 0 has " + len);
|
| 69 |
+
for (let i = 0; i < len; i++) if (!Number.isFinite(g[i]))
|
| 70 |
+
throw gradError("gradient " + r + " is non-finite at index " + i + " (" + g[i] + ")");
|
| 71 |
+
}
|
| 72 |
+
const out = new Float32Array(len);
|
| 73 |
for (const g of grads) for (let i = 0; i < g.length; i++) out[i] += g[i];
|
| 74 |
for (let i = 0; i < out.length; i++) out[i] /= grads.length;
|
| 75 |
return out;
|
| 76 |
}
|
| 77 |
|
| 78 |
+
function gradError(msg) { const e = new Error(msg); e.name = "GradientError"; return e; }
|
| 79 |
+
|
| 80 |
// DaisyAdam — Adam with bias correction, applied to the cluster-averaged
|
| 81 |
// gradient. State is a pure function of the gradient sequence, so every peer
|
| 82 |
// that averages the same gradients keeps bit-identical moments: no optimizer
|
public/verified_core.js
CHANGED
|
@@ -8,7 +8,26 @@
|
|
| 8 |
|
| 9 |
let TC; // TrainCore (matmul/transpose) — resolved per environment at the end
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
function quantize(X) {
|
|
|
|
| 12 |
let mx = 0; for (let i = 0; i < X.length; i++) { const a = Math.abs(X[i]); if (a > mx) mx = a; }
|
| 13 |
const scale = Math.max(mx / 127, 1e-8);
|
| 14 |
const q = new Int8Array(X.length);
|
|
@@ -16,8 +35,22 @@
|
|
| 16 |
return { q, scale };
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
// int8 matmul via the verified multiply LUT: acc(m×n) = sum_k mulLUT[Xq,Wq]
|
| 20 |
function lutMatmulJS(Xq, Wq, m, k, n, L) {
|
|
|
|
| 21 |
const C = new Int32Array(m * n), mul = L.mul;
|
| 22 |
for (let i = 0; i < m; i++) {
|
| 23 |
for (let p = 0; p < k; p++) {
|
|
@@ -73,6 +106,7 @@
|
|
| 73 |
// outlier no longer crushes the quantization resolution of every other
|
| 74 |
// row/column. One LUT pass at this granularity beats the per-tensor 3-pass.
|
| 75 |
function quantizeRows(X, rows, cols) {
|
|
|
|
| 76 |
const q = new Int8Array(rows * cols), s = new Float32Array(rows);
|
| 77 |
for (let r = 0; r < rows; r++) {
|
| 78 |
let mx = 0;
|
|
@@ -86,6 +120,7 @@
|
|
| 86 |
return { q, s };
|
| 87 |
}
|
| 88 |
function quantizeCols(W, rows, cols) {
|
|
|
|
| 89 |
const q = new Int8Array(rows * cols), s = new Float32Array(cols);
|
| 90 |
for (let c = 0; c < cols; c++) {
|
| 91 |
let mx = 0;
|
|
@@ -120,6 +155,7 @@
|
|
| 120 |
// (≤1 step) vs quantizeRows, so old and new builds cannot co-train — the
|
| 121 |
// divergence guard stops such mixed groups by design.
|
| 122 |
function rowAbsMax(X, rows, cols) {
|
|
|
|
| 123 |
const mx = new Float32Array(rows);
|
| 124 |
for (let r = 0; r < rows; r++) {
|
| 125 |
let m = 0;
|
|
@@ -129,6 +165,12 @@
|
|
| 129 |
return mx;
|
| 130 |
}
|
| 131 |
function scalesFromAbsMax(mx) { // f64 divisions: exactly rounded, device-identical
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
const scale = new Float32Array(mx.length), inv = new Float32Array(mx.length);
|
| 133 |
for (let i = 0; i < mx.length; i++) {
|
| 134 |
scale[i] = Math.max(mx[i] / 127, 1e-8);
|
|
@@ -199,6 +241,7 @@
|
|
| 199 |
function bgemmJS(Xq, Wq, rs, cs, d, L) {
|
| 200 |
const { m, k, n } = d, batch = d.batch || 1, relu = !!d.relu, mul = L.mul;
|
| 201 |
const raw = !!d.acc;
|
|
|
|
| 202 |
const out = raw ? new Int32Array(batch * m * n) : new Float32Array(batch * m * n);
|
| 203 |
const acc = new Int32Array(n);
|
| 204 |
for (let bz = 0; bz < batch; bz++) {
|
|
@@ -291,6 +334,40 @@
|
|
| 291 |
return out;
|
| 292 |
}
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
// ---- live audits for the fused attention kernels ---------------------------
|
| 295 |
// The attention kernels had exact INIT gates but nothing at live shapes —
|
| 296 |
// the exact gap the GEMM audit exists to close, left open on the kernels with
|
|
@@ -379,6 +456,7 @@
|
|
| 379 |
// The (BT·heads)×hd row view of q/k IS the contiguous buffer, so
|
| 380 |
// quantizeRows(q, BT·heads, hd) gives per-(token,head) scales for free.
|
| 381 |
function quantizeHeadCols(v, B, T, heads, hd) { // per (batch,head,channel) column
|
|
|
|
| 382 |
const C = heads * hd;
|
| 383 |
const q = new Int8Array(B * T * C), s = new Float32Array(B * heads * hd);
|
| 384 |
for (let bi = 0; bi < B; bi++)
|
|
@@ -403,6 +481,9 @@
|
|
| 403 |
// d.acc=true returns the raw int32 accumulator (exact oracle for the kernel gate)
|
| 404 |
function attScoresJS(qq, kq, qs, ks, d, L) {
|
| 405 |
const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul, raw = !!d.acc;
|
|
|
|
|
|
|
|
|
|
| 406 |
const out = raw ? new Int32Array(B * heads * T * T) : new Float32Array(B * heads * T * T);
|
| 407 |
for (let bi = 0; bi < B; bi++) for (let h = 0; h < heads; h++) {
|
| 408 |
const bz = bi * heads + h;
|
|
@@ -421,6 +502,7 @@
|
|
| 421 |
// ctx[(bi,ti),(h,j)] = Σ_tj a[bz,ti,tj]·v[(bi,tj),(h,j)] — scatter fused into BT×C
|
| 422 |
function attCtxJS(aq, vq, as, vs, d, L) {
|
| 423 |
const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul, raw = !!d.acc;
|
|
|
|
| 424 |
const out = raw ? new Int32Array(B * T * C) : new Float32Array(B * T * C);
|
| 425 |
for (let bi = 0; bi < B; bi++) for (let h = 0; h < heads; h++) {
|
| 426 |
const bz = bi * heads + h;
|
|
@@ -487,10 +569,10 @@
|
|
| 487 |
for (let j = 0; j < W2.length; j++) W2[j] -= lr * gAvg[W1.length + j];
|
| 488 |
}
|
| 489 |
|
| 490 |
-
const api = { quantize, quantize2, quantizeRows, quantizeCols, quantizeHeadCols, lutMatmulJS, lutMatmul3JS, lutMatmul3,
|
| 491 |
bgemmJS, vgemmBlock, auditTile, epi, attScoresJS, attCtxJS, linearFwd, forward, backward, splitApply,
|
| 492 |
rowAbsMax, scalesFromAbsMax, quantizeRowsInv, vmlpBlock, bitDiff,
|
| 493 |
-
auditAttScores, auditAttCtx, fgemmMirror };
|
| 494 |
if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); module.exports = api; }
|
| 495 |
else { TC = root.TrainCore; root.Verified = api; }
|
| 496 |
})(typeof self !== "undefined" ? self : this);
|
|
|
|
| 8 |
|
| 9 |
let TC; // TrainCore (matmul/transpose) — resolved per environment at the end
|
| 10 |
|
| 11 |
+
// A float -> int8 conversion has no answer for NaN/Inf, and the quantizers below give a
|
| 12 |
+
// silent wrong one: an Inf makes the |max| scale Infinity and EVERY value quantizes to 0
|
| 13 |
+
// ([0.5, Inf, 3] -> [0, 0, 0]); a NaN is skipped by `a > mx` and then stored as 0 by the
|
| 14 |
+
// Int8Array. The Python trainer refuses the same input (qat.py); so does this. One pass,
|
| 15 |
+
// no effect on finite inputs, so builds with and without it still co-train.
|
| 16 |
+
function assertFinite(X, where) {
|
| 17 |
+
for (let i = 0; i < X.length; i++) {
|
| 18 |
+
if (!Number.isFinite(X[i])) {
|
| 19 |
+
let bad = 0;
|
| 20 |
+
for (let j = i; j < X.length; j++) if (!Number.isFinite(X[j])) bad++;
|
| 21 |
+
const e = new Error("non-finite input to " + where + " (" + bad + " of " + X.length +
|
| 22 |
+
" values): the int8 quantize would turn it into 0");
|
| 23 |
+
e.name = "NonFiniteError";
|
| 24 |
+
throw e;
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
function quantize(X) {
|
| 30 |
+
assertFinite(X, "quantize");
|
| 31 |
let mx = 0; for (let i = 0; i < X.length; i++) { const a = Math.abs(X[i]); if (a > mx) mx = a; }
|
| 32 |
const scale = Math.max(mx / 127, 1e-8);
|
| 33 |
const q = new Int8Array(X.length);
|
|
|
|
| 35 |
return { q, scale };
|
| 36 |
}
|
| 37 |
|
| 38 |
+
// int32 accumulator headroom: |a*b| <= 128*128, so K*16384 must fit in int32. Same bound as
|
| 39 |
+
// the Python kernel (kernel.MAX_INT32_K). The Int32Array accumulators here WRAP exactly like
|
| 40 |
+
// WGSL i32, so CPU and GPU peers would agree -- on a silently wrong sum. Refuse instead.
|
| 41 |
+
const MAX_INT32_K = Math.floor((2 ** 31 - 1) / (128 * 128)); // 131071
|
| 42 |
+
function accBound(k, where) {
|
| 43 |
+
if (k > MAX_INT32_K) {
|
| 44 |
+
const e = new Error(where + ": contraction length " + k + " exceeds the int32 accumulator " +
|
| 45 |
+
"headroom " + MAX_INT32_K + " (the sum would wrap)");
|
| 46 |
+
e.name = "AccumulatorBoundError";
|
| 47 |
+
throw e;
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
// int8 matmul via the verified multiply LUT: acc(m×n) = sum_k mulLUT[Xq,Wq]
|
| 52 |
function lutMatmulJS(Xq, Wq, m, k, n, L) {
|
| 53 |
+
accBound(k, "lutMatmulJS");
|
| 54 |
const C = new Int32Array(m * n), mul = L.mul;
|
| 55 |
for (let i = 0; i < m; i++) {
|
| 56 |
for (let p = 0; p < k; p++) {
|
|
|
|
| 106 |
// outlier no longer crushes the quantization resolution of every other
|
| 107 |
// row/column. One LUT pass at this granularity beats the per-tensor 3-pass.
|
| 108 |
function quantizeRows(X, rows, cols) {
|
| 109 |
+
assertFinite(X, "quantizeRows");
|
| 110 |
const q = new Int8Array(rows * cols), s = new Float32Array(rows);
|
| 111 |
for (let r = 0; r < rows; r++) {
|
| 112 |
let mx = 0;
|
|
|
|
| 120 |
return { q, s };
|
| 121 |
}
|
| 122 |
function quantizeCols(W, rows, cols) {
|
| 123 |
+
assertFinite(W, "quantizeCols");
|
| 124 |
const q = new Int8Array(rows * cols), s = new Float32Array(cols);
|
| 125 |
for (let c = 0; c < cols; c++) {
|
| 126 |
let mx = 0;
|
|
|
|
| 155 |
// (≤1 step) vs quantizeRows, so old and new builds cannot co-train — the
|
| 156 |
// divergence guard stops such mixed groups by design.
|
| 157 |
function rowAbsMax(X, rows, cols) {
|
| 158 |
+
assertFinite(X, "rowAbsMax");
|
| 159 |
const mx = new Float32Array(rows);
|
| 160 |
for (let r = 0; r < rows; r++) {
|
| 161 |
let m = 0;
|
|
|
|
| 165 |
return mx;
|
| 166 |
}
|
| 167 |
function scalesFromAbsMax(mx) { // f64 divisions: exactly rounded, device-identical
|
| 168 |
+
// The GPU chain gets mx from an atomicMax over bitcast(abs(h)) and never passes through
|
| 169 |
+
// rowAbsMax, so this is where ITS non-finite guard lives: NaN bits (0x7FC00000) and Inf
|
| 170 |
+
// (0x7F800000) both order above every finite |h| as u32, so the row max is non-finite iff
|
| 171 |
+
// some element is. Before 2026-09-30 a GPU peer turned that into inv = 0/NaN and quantized
|
| 172 |
+
// floor(NaN) on the device (implementation-defined) where a CPU peer threw.
|
| 173 |
+
assertFinite(mx, "scalesFromAbsMax");
|
| 174 |
const scale = new Float32Array(mx.length), inv = new Float32Array(mx.length);
|
| 175 |
for (let i = 0; i < mx.length; i++) {
|
| 176 |
scale[i] = Math.max(mx[i] / 127, 1e-8);
|
|
|
|
| 241 |
function bgemmJS(Xq, Wq, rs, cs, d, L) {
|
| 242 |
const { m, k, n } = d, batch = d.batch || 1, relu = !!d.relu, mul = L.mul;
|
| 243 |
const raw = !!d.acc;
|
| 244 |
+
accBound(k, "bgemmJS");
|
| 245 |
const out = raw ? new Int32Array(batch * m * n) : new Float32Array(batch * m * n);
|
| 246 |
const acc = new Int32Array(n);
|
| 247 |
for (let bz = 0; bz < batch; bz++) {
|
|
|
|
| 334 |
return out;
|
| 335 |
}
|
| 336 |
|
| 337 |
+
// ---- live audit for the split-K f32 GEMM -----------------------------------
|
| 338 |
+
// Every int8 kernel had a live-shape audit; the f32 backward GEMM -- whose
|
| 339 |
+
// output IS the embedding gradient -- had only its init gate at 7x4500x5 and
|
| 340 |
+
// 9x300x6, while training runs it at vocab x BT x C. Same remedy as auditTile:
|
| 341 |
+
// recompute a few cells (structural corners first) with the mirror's exact
|
| 342 |
+
// partition and rounding schedule, compare bit-for-bit (2026-10-14 review).
|
| 343 |
+
function fgemmCell(A, Bm, d, fma, row, col) {
|
| 344 |
+
const { m, k, n } = d, transA = !!d.transA;
|
| 345 |
+
const S = k > 4096 ? Math.min(16, Math.ceil(k / 2048)) : 1;
|
| 346 |
+
const ks = Math.ceil(k / S);
|
| 347 |
+
let acc = 0;
|
| 348 |
+
for (let z = 0; z < S; z++) {
|
| 349 |
+
const p0 = z * ks, p1 = Math.min(k, p0 + ks);
|
| 350 |
+
let s = 0;
|
| 351 |
+
for (let p = p0; p < p1; p++) {
|
| 352 |
+
const a = transA ? A[p * m + row] : A[row * k + p];
|
| 353 |
+
s = fma ? f32(s + a * Bm[p * n + col]) : f32(s + f32(a * Bm[p * n + col]));
|
| 354 |
+
}
|
| 355 |
+
acc = f32(acc + s);
|
| 356 |
+
}
|
| 357 |
+
return acc;
|
| 358 |
+
}
|
| 359 |
+
function auditFgemm(A, Bm, d, got, fma, nCells) {
|
| 360 |
+
const { m, n } = d, N = nCells || 8;
|
| 361 |
+
const edges = [[m - 1, n - 1], [0, n - 1], [m - 1, 0], [0, 0]];
|
| 362 |
+
for (let t = 0; t < N; t++) {
|
| 363 |
+
const [i, j] = t < edges.length ? edges[t] : [(Math.random() * m) | 0, (Math.random() * n) | 0];
|
| 364 |
+
const v = fgemmCell(A, Bm, d, fma, i, j);
|
| 365 |
+
if (bitDiff(got[i * n + j], v))
|
| 366 |
+
return `f32 GEMM audit failed at [${i},${j}] shape ${m}x${d.k}x${n}${d.transA ? " transA" : ""}: kernel ${got[i * n + j]} vs mirror ${v}`;
|
| 367 |
+
}
|
| 368 |
+
return null;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
// ---- live audits for the fused attention kernels ---------------------------
|
| 372 |
// The attention kernels had exact INIT gates but nothing at live shapes —
|
| 373 |
// the exact gap the GEMM audit exists to close, left open on the kernels with
|
|
|
|
| 456 |
// The (BT·heads)×hd row view of q/k IS the contiguous buffer, so
|
| 457 |
// quantizeRows(q, BT·heads, hd) gives per-(token,head) scales for free.
|
| 458 |
function quantizeHeadCols(v, B, T, heads, hd) { // per (batch,head,channel) column
|
| 459 |
+
assertFinite(v, "quantizeHeadCols");
|
| 460 |
const C = heads * hd;
|
| 461 |
const q = new Int8Array(B * T * C), s = new Float32Array(B * heads * hd);
|
| 462 |
for (let bi = 0; bi < B; bi++)
|
|
|
|
| 481 |
// d.acc=true returns the raw int32 accumulator (exact oracle for the kernel gate)
|
| 482 |
function attScoresJS(qq, kq, qs, ks, d, L) {
|
| 483 |
const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul, raw = !!d.acc;
|
| 484 |
+
// the WGSL kernel sums these in i32; this mirror sums in doubles. Bound both identically so
|
| 485 |
+
// a CPU peer and a GPU peer refuse the same shapes instead of one halting alone.
|
| 486 |
+
accBound(hd, "attScoresJS");
|
| 487 |
const out = raw ? new Int32Array(B * heads * T * T) : new Float32Array(B * heads * T * T);
|
| 488 |
for (let bi = 0; bi < B; bi++) for (let h = 0; h < heads; h++) {
|
| 489 |
const bz = bi * heads + h;
|
|
|
|
| 502 |
// ctx[(bi,ti),(h,j)] = Σ_tj a[bz,ti,tj]·v[(bi,tj),(h,j)] — scatter fused into BT×C
|
| 503 |
function attCtxJS(aq, vq, as, vs, d, L) {
|
| 504 |
const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul, raw = !!d.acc;
|
| 505 |
+
accBound(T, "attCtxJS"); // contracts over keys, like gpuAttCtx
|
| 506 |
const out = raw ? new Int32Array(B * T * C) : new Float32Array(B * T * C);
|
| 507 |
for (let bi = 0; bi < B; bi++) for (let h = 0; h < heads; h++) {
|
| 508 |
const bz = bi * heads + h;
|
|
|
|
| 569 |
for (let j = 0; j < W2.length; j++) W2[j] -= lr * gAvg[W1.length + j];
|
| 570 |
}
|
| 571 |
|
| 572 |
+
const api = { assertFinite, accBound, MAX_INT32_K, quantize, quantize2, quantizeRows, quantizeCols, quantizeHeadCols, lutMatmulJS, lutMatmul3JS, lutMatmul3,
|
| 573 |
bgemmJS, vgemmBlock, auditTile, epi, attScoresJS, attCtxJS, linearFwd, forward, backward, splitApply,
|
| 574 |
rowAbsMax, scalesFromAbsMax, quantizeRowsInv, vmlpBlock, bitDiff,
|
| 575 |
+
auditAttScores, auditAttCtx, fgemmMirror, auditFgemm };
|
| 576 |
if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); module.exports = api; }
|
| 577 |
else { TC = root.TrainCore; root.Verified = api; }
|
| 578 |
})(typeof self !== "undefined" ? self : this);
|
public/webgpu.js
CHANGED
|
@@ -286,7 +286,11 @@
|
|
| 286 |
matmulInt8: (Xq, Wq, m, k, n, LL) => root.Verified.lutMatmulJS(Xq, Wq, m, k, n, LL) };
|
| 287 |
if (!(root.navigator && navigator.gpu)) return cpu;
|
| 288 |
try {
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
if (!adapter) return cpu;
|
| 291 |
const device = await adapter.requestDevice();
|
| 292 |
const info = adapter.info || {};
|
|
@@ -330,9 +334,27 @@
|
|
| 330 |
// because head-strided addressing is where these kernels can go wrong.
|
| 331 |
let att = { scores: (qq, kq, qs, ks, d) => gpuAttScores(device, d.acc ? scoresVPipe : scoresPipe, qq, kq, qs, ks, d),
|
| 332 |
ctx: (aq, vq, as, vs, d) => gpuAttCtx(device, d.acc ? ctxVPipe : ctxPipe, aq, vq, as, vs, d) };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
try {
|
| 334 |
-
for (const
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
const nQ = d0.B * d0.T * d0.heads * d0.hd;
|
| 337 |
const qq = new Int8Array(nQ), kq = new Int8Array(nQ), vq = new Int8Array(nQ);
|
| 338 |
for (let i = 0; i < nQ; i++) { qq[i] = (Math.random() * 256 - 128) | 0; kq[i] = (Math.random() * 256 - 128) | 0; vq[i] = (Math.random() * 256 - 128) | 0; }
|
|
@@ -353,7 +375,8 @@
|
|
| 353 |
const refS = root.Verified.attScoresJS(qq, kq, qs, ks, d0, L);
|
| 354 |
const refC = root.Verified.attCtxJS(aq, vq, as, vs, d0, L);
|
| 355 |
for (let i = 0; i < refS.length; i++) if (bitDiff(hwS[i], refS[i])) throw new Error(`scores epilogue mismatch @${i}`);
|
| 356 |
-
for (let i = 0; i < refC.length; i++) if (bitDiff(hwC[i], refC[i])) throw new Error(`ctx epilogue mismatch @${i}`);
|
|
|
|
| 357 |
}
|
| 358 |
} catch (e) {
|
| 359 |
console.warn("fused attention kernels failed verification — using CPU LUT mirrors:", e.message);
|
|
@@ -398,6 +421,39 @@
|
|
| 398 |
const Bt = Float32Array.from({ length: mT * nT }, () => Math.random() - 0.5);
|
| 399 |
const Bn = Float32Array.from({ length: kT * nT }, () => Math.random() - 0.5);
|
| 400 |
const dT = { m: kT, k: mT, n: nT, transA: true }, dN = { m: mT, k: kT, n: nT };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
const [f1, f2] = await fgemm2(A2, Bt, dT, Bn, dN);
|
| 402 |
const [r1, r2] = [await fgemm(A2, Bt, dT), await fgemm(A2, Bn, dN)];
|
| 403 |
for (let i = 0; i < r1.length; i++) if (bitDiff(f1[i], r1[i])) throw new Error("fgemm2 transA leg differs");
|
|
@@ -559,7 +615,7 @@
|
|
| 559 |
|
| 560 |
const viaLUT = { backend: "webgpu", label: `${gpuName} (LUT shader · exact-gated)`,
|
| 561 |
matmulInt8: (Xq, Wq, m, k, n) => gpuMatmulLUT(device, lutPipe, lutBuf, Xq, Wq, m, k, n),
|
| 562 |
-
bgemm: bgLut, att, fgemm, fgemm2, mlp: mlpLut };
|
| 563 |
|
| 564 |
// DP4A pipeline — only if the WGSL feature exists AND its batched kernel
|
| 565 |
// reproduces the verified units exactly across the shape sweep
|
|
@@ -587,7 +643,7 @@
|
|
| 587 |
// worse than a fixed choice. DP4A ships: it wins the int8-backward mode
|
| 588 |
// by ~12% and ties elsewhere.
|
| 589 |
return { backend: "webgpu", label: `${gpuName} (DP4A int8 dot · exact-gated vs units)`,
|
| 590 |
-
bgemm: bg, att, fgemm, fgemm2, mlp: mlpDp4 };
|
| 591 |
} catch (e) { console.warn("WebGPU init failed, CPU fallback:", e); return cpu; }
|
| 592 |
}
|
| 593 |
|
|
@@ -611,6 +667,7 @@
|
|
| 611 |
}
|
| 612 |
|
| 613 |
async function gpuMatmulLUT(device, pipeline, lutBuf, Xq, Wq, m, k, n) {
|
|
|
|
| 614 |
const X32 = Int32Array.from(Xq), W32 = Int32Array.from(Wq); // byte -> i32
|
| 615 |
const bufX = up(device, X32, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 616 |
const bufW = up(device, W32, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
|
@@ -626,6 +683,7 @@
|
|
| 626 |
// attention kernels: int8 (widened i32) in, f32 out, strided head indexing
|
| 627 |
async function gpuAttScores(device, pipeline, qq, kq, qs, ks, d) {
|
| 628 |
const { B, T, heads } = d;
|
|
|
|
| 629 |
const bufQ = up(device, Int32Array.from(qq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 630 |
const bufK = up(device, Int32Array.from(kq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 631 |
const bufQs = up(device, qs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
|
@@ -640,6 +698,7 @@
|
|
| 640 |
}
|
| 641 |
async function gpuAttCtx(device, pipeline, aq, vq, as, vs, d) {
|
| 642 |
const { B, T, heads, hd } = d;
|
|
|
|
| 643 |
const bufA = up(device, Int32Array.from(aq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 644 |
const bufV = up(device, Int32Array.from(vq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 645 |
const bufAs = up(device, as, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
|
@@ -759,6 +818,7 @@
|
|
| 759 |
|
| 760 |
async function gpuBgemmLUT(device, pipeline, lutBuf, Xq, Wq, rs, cs, d) {
|
| 761 |
const { m, k, n } = d, batch = d.batch || 1, flags = d.relu ? 1 : 0;
|
|
|
|
| 762 |
const bufX = up(device, Int32Array.from(Xq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 763 |
const bufW = up(device, Int32Array.from(Wq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 764 |
const bufR = up(device, rs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
|
@@ -775,6 +835,7 @@
|
|
| 775 |
|
| 776 |
async function gpuBgemmDP4(device, pipeline, Xq, Wq, rs, cs, d) {
|
| 777 |
const { m, k, n } = d, batch = d.batch || 1, flags = d.relu ? 1 : 0;
|
|
|
|
| 778 |
const kw = Math.ceil(k / 4);
|
| 779 |
const Xp = packRows(Xq, batch * m, k, kw); // rows are (batch·m)
|
| 780 |
const Wp = new Uint32Array(batch * n * kw); // per-batch Wᵀ, packed
|
|
@@ -801,6 +862,7 @@
|
|
| 801 |
// left operand is produced and consumed entirely on the GPU.
|
| 802 |
async function gpuMlpChain(device, env, xq, w1q, w2q, xs, w1s, w2s, d) {
|
| 803 |
const { m, k, h, n } = d, dp4 = !!env.dp4;
|
|
|
|
| 804 |
const SU = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST;
|
| 805 |
let bufX, bufW1, bufW2, kw1, hw;
|
| 806 |
if (dp4) {
|
|
@@ -850,7 +912,12 @@
|
|
| 850 |
const mx = new Float32Array(readM.getMappedRange(0, m * 4).slice(0)); readM.unmap();
|
| 851 |
// scale derivation in JS f64 — exactly rounded, identical on every device
|
| 852 |
// (WGSL division is 2.5 ULP, which is why it never runs on the GPU)
|
| 853 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 854 |
const bufInv = up(device, sc.inv, SU), bufHs = up(device, sc.scale, SU);
|
| 855 |
const bufQ = mk(device, m * hw * 4, GPUBufferUsage.STORAGE);
|
| 856 |
const bufDQ = up(device, new Uint32Array([m, h, hw, 0]), UU);
|
|
|
|
| 286 |
matmulInt8: (Xq, Wq, m, k, n, LL) => root.Verified.lutMatmulJS(Xq, Wq, m, k, n, LL) };
|
| 287 |
if (!(root.navigator && navigator.gpu)) return cpu;
|
| 288 |
try {
|
| 289 |
+
// Ask for the discrete GPU: the default adapter on a laptop is the iGPU
|
| 290 |
+
// (measured 2026-10-14: Intel gen-9 picked while an RTX sat idle). The
|
| 291 |
+
// browser may still hand back the iGPU; the gates decide what runs.
|
| 292 |
+
const adapter = (await navigator.gpu.requestAdapter({ powerPreference: "high-performance" })) ||
|
| 293 |
+
(await navigator.gpu.requestAdapter());
|
| 294 |
if (!adapter) return cpu;
|
| 295 |
const device = await adapter.requestDevice();
|
| 296 |
const info = adapter.info || {};
|
|
|
|
| 334 |
// because head-strided addressing is where these kernels can go wrong.
|
| 335 |
let att = { scores: (qq, kq, qs, ks, d) => gpuAttScores(device, d.acc ? scoresVPipe : scoresPipe, qq, kq, qs, ks, d),
|
| 336 |
ctx: (aq, vq, as, vs, d) => gpuAttCtx(device, d.acc ? ctxVPipe : ctxPipe, aq, vq, as, vs, d) };
|
| 337 |
+
// Two sweeps: the second runs on RECYCLED pool buffers left full of
|
| 338 |
+
// large-magnitude residue (same shapes => same buckets, LIFO free list),
|
| 339 |
+
// the dirty-buffer gate the bgemm and MLP kernels already had. Without it
|
| 340 |
+
// a kernel that skips writing some output cell would pass on fresh
|
| 341 |
+
// buffers and corrupt live steps (2026-10-14 review).
|
| 342 |
+
const ATT_SHAPES = [{ B: 2, T: 8, heads: 2, hd: 8 }, { B: 1, T: 32, heads: 2, hd: 16 },
|
| 343 |
+
{ B: 3, T: 7, heads: 3, hd: 5 }, { B: 2, T: 33, heads: 4, hd: 8 }];
|
| 344 |
try {
|
| 345 |
+
for (const dirty of [false, true]) {
|
| 346 |
+
if (dirty) for (const d0 of ATT_SHAPES) {
|
| 347 |
+
const nQ = d0.B * d0.T * d0.heads * d0.hd, nA = d0.B * d0.heads * d0.T * d0.T;
|
| 348 |
+
const big = (n) => new Float32Array(n).fill(1e4);
|
| 349 |
+
for (const acc of [false, true]) {
|
| 350 |
+
const dv = { ...d0, acc };
|
| 351 |
+
await att.scores(new Int8Array(nQ).fill(127), new Int8Array(nQ).fill(127),
|
| 352 |
+
big(d0.B * d0.T * d0.heads), big(d0.B * d0.T * d0.heads), dv);
|
| 353 |
+
await att.ctx(new Int8Array(nA).fill(127), new Int8Array(nQ).fill(127),
|
| 354 |
+
big(d0.B * d0.heads * d0.T), big(d0.B * d0.heads * d0.hd), dv);
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
for (const d0 of ATT_SHAPES) {
|
| 358 |
const nQ = d0.B * d0.T * d0.heads * d0.hd;
|
| 359 |
const qq = new Int8Array(nQ), kq = new Int8Array(nQ), vq = new Int8Array(nQ);
|
| 360 |
for (let i = 0; i < nQ; i++) { qq[i] = (Math.random() * 256 - 128) | 0; kq[i] = (Math.random() * 256 - 128) | 0; vq[i] = (Math.random() * 256 - 128) | 0; }
|
|
|
|
| 375 |
const refS = root.Verified.attScoresJS(qq, kq, qs, ks, d0, L);
|
| 376 |
const refC = root.Verified.attCtxJS(aq, vq, as, vs, d0, L);
|
| 377 |
for (let i = 0; i < refS.length; i++) if (bitDiff(hwS[i], refS[i])) throw new Error(`scores epilogue mismatch @${i}`);
|
| 378 |
+
for (let i = 0; i < refC.length; i++) if (bitDiff(hwC[i], refC[i])) throw new Error(`ctx epilogue mismatch @${i}${dirty ? " (dirty pool)" : ""}`);
|
| 379 |
+
}
|
| 380 |
}
|
| 381 |
} catch (e) {
|
| 382 |
console.warn("fused attention kernels failed verification — using CPU LUT mirrors:", e.message);
|
|
|
|
| 421 |
const Bt = Float32Array.from({ length: mT * nT }, () => Math.random() - 0.5);
|
| 422 |
const Bn = Float32Array.from({ length: kT * nT }, () => Math.random() - 0.5);
|
| 423 |
const dT = { m: kT, k: mT, n: nT, transA: true }, dN = { m: mT, k: kT, n: nT };
|
| 424 |
+
// transA against the MIRROR, not just against fgemm: before 2026-10-14
|
| 425 |
+
// the transA index pattern was only ever compared with itself (fgemm2
|
| 426 |
+
// leg vs fgemm call, same shader), so a wrong A[p*m+row] passed. The
|
| 427 |
+
// second shape puts transA through split-K (k > 4096) as well.
|
| 428 |
+
const fma = fgemmFma;
|
| 429 |
+
for (const [mA, kA, nA] of [[kT, mT, nT], [6, 4500, 3]]) {
|
| 430 |
+
const At = Float32Array.from({ length: mA * kA }, () => Math.random() - 0.5);
|
| 431 |
+
const Bx = Float32Array.from({ length: kA * nA }, () => Math.random() - 0.5);
|
| 432 |
+
const dX = { m: mA, k: kA, n: nA, transA: true };
|
| 433 |
+
const got = await fgemm(At, Bx, dX), want = root.Verified.fgemmMirror(At, Bx, dX, fma);
|
| 434 |
+
for (let i = 0; i < want.length; i++) if (bitDiff(got[i], want[i])) throw new Error(`fgemm transA differs from the mirror @${i} (${mA}x${kA}x${nA})`);
|
| 435 |
+
}
|
| 436 |
+
// SUBNORMALS: WGSL permits an implementation to flush f32 subnormals to
|
| 437 |
+
// zero, and the JS mirror never flushes. The random inputs above are
|
| 438 |
+
// ~0.1 in magnitude and never produce one, so a flushing GPU passed this
|
| 439 |
+
// gate and then forked from non-flushing peers on tiny gradients
|
| 440 |
+
// (2026-10-14; the emulator's FTZ/DAZ lesson). Products here are ~1e-40.
|
| 441 |
+
{
|
| 442 |
+
const dS = { m: 3, k: 64, n: 2 };
|
| 443 |
+
const As = Float32Array.from({ length: dS.m * dS.k }, (_, i) => (i % 2 ? 1 : -1) * (1 + (i % 7)) * 1e-20);
|
| 444 |
+
const Bs = Float32Array.from({ length: dS.k * dS.n }, (_, i) => (1 + (i % 5)) * 1e-20);
|
| 445 |
+
const got = await fgemm(As, Bs, dS), want = root.Verified.fgemmMirror(As, Bs, dS, fma);
|
| 446 |
+
let sub = 0;
|
| 447 |
+
for (let i = 0; i < want.length; i++) if (want[i] !== 0 && Math.abs(want[i]) < 1.1754943508222875e-38) sub++;
|
| 448 |
+
if (!sub) throw new Error("subnormal gate case produced no subnormal (test is vacuous)");
|
| 449 |
+
for (let i = 0; i < want.length; i++) if (bitDiff(got[i], want[i]))
|
| 450 |
+
throw new Error(`fgemm flushes or rounds subnormals differently @${i}: ${got[i]} vs mirror ${want[i]}`);
|
| 451 |
+
}
|
| 452 |
+
// dirty re-gate: poison the partial/output buckets at the gate's shapes, then re-check
|
| 453 |
+
await fgemm(new Float32Array(m0 * k0).fill(1e4), new Float32Array(k0 * n0).fill(1e4), dG);
|
| 454 |
+
const hw2 = await fgemm(A, Bm, dG);
|
| 455 |
+
const mRef = fma ? mFma : mStep;
|
| 456 |
+
for (let i = 0; i < hw2.length; i++) if (bitDiff(hw2[i], mRef[i])) throw new Error(`fgemm differs on a recycled (dirty) pool @${i}`);
|
| 457 |
const [f1, f2] = await fgemm2(A2, Bt, dT, Bn, dN);
|
| 458 |
const [r1, r2] = [await fgemm(A2, Bt, dT), await fgemm(A2, Bn, dN)];
|
| 459 |
for (let i = 0; i < r1.length; i++) if (bitDiff(f1[i], r1[i])) throw new Error("fgemm2 transA leg differs");
|
|
|
|
| 615 |
|
| 616 |
const viaLUT = { backend: "webgpu", label: `${gpuName} (LUT shader · exact-gated)`,
|
| 617 |
matmulInt8: (Xq, Wq, m, k, n) => gpuMatmulLUT(device, lutPipe, lutBuf, Xq, Wq, m, k, n),
|
| 618 |
+
bgemm: bgLut, att, fgemm, fgemm2, fgemmFma, mlp: mlpLut };
|
| 619 |
|
| 620 |
// DP4A pipeline — only if the WGSL feature exists AND its batched kernel
|
| 621 |
// reproduces the verified units exactly across the shape sweep
|
|
|
|
| 643 |
// worse than a fixed choice. DP4A ships: it wins the int8-backward mode
|
| 644 |
// by ~12% and ties elsewhere.
|
| 645 |
return { backend: "webgpu", label: `${gpuName} (DP4A int8 dot · exact-gated vs units)`,
|
| 646 |
+
bgemm: bg, att, fgemm, fgemm2, fgemmFma, mlp: mlpDp4 };
|
| 647 |
} catch (e) { console.warn("WebGPU init failed, CPU fallback:", e); return cpu; }
|
| 648 |
}
|
| 649 |
|
|
|
|
| 667 |
}
|
| 668 |
|
| 669 |
async function gpuMatmulLUT(device, pipeline, lutBuf, Xq, Wq, m, k, n) {
|
| 670 |
+
root.Verified.accBound(k, "gpuMatmulLUT"); // WGSL `var s : i32` wraps past this
|
| 671 |
const X32 = Int32Array.from(Xq), W32 = Int32Array.from(Wq); // byte -> i32
|
| 672 |
const bufX = up(device, X32, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 673 |
const bufW = up(device, W32, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
|
|
|
| 683 |
// attention kernels: int8 (widened i32) in, f32 out, strided head indexing
|
| 684 |
async function gpuAttScores(device, pipeline, qq, kq, qs, ks, d) {
|
| 685 |
const { B, T, heads } = d;
|
| 686 |
+
root.Verified.accBound(d.hd, "gpuAttScores");
|
| 687 |
const bufQ = up(device, Int32Array.from(qq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 688 |
const bufK = up(device, Int32Array.from(kq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 689 |
const bufQs = up(device, qs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
|
|
|
| 698 |
}
|
| 699 |
async function gpuAttCtx(device, pipeline, aq, vq, as, vs, d) {
|
| 700 |
const { B, T, heads, hd } = d;
|
| 701 |
+
root.Verified.accBound(T, "gpuAttCtx");
|
| 702 |
const bufA = up(device, Int32Array.from(aq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 703 |
const bufV = up(device, Int32Array.from(vq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 704 |
const bufAs = up(device, as, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
|
|
|
| 818 |
|
| 819 |
async function gpuBgemmLUT(device, pipeline, lutBuf, Xq, Wq, rs, cs, d) {
|
| 820 |
const { m, k, n } = d, batch = d.batch || 1, flags = d.relu ? 1 : 0;
|
| 821 |
+
root.Verified.accBound(k, "gpuBgemmLUT");
|
| 822 |
const bufX = up(device, Int32Array.from(Xq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 823 |
const bufW = up(device, Int32Array.from(Wq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 824 |
const bufR = up(device, rs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
|
|
|
| 835 |
|
| 836 |
async function gpuBgemmDP4(device, pipeline, Xq, Wq, rs, cs, d) {
|
| 837 |
const { m, k, n } = d, batch = d.batch || 1, flags = d.relu ? 1 : 0;
|
| 838 |
+
root.Verified.accBound(k, "gpuBgemmDP4");
|
| 839 |
const kw = Math.ceil(k / 4);
|
| 840 |
const Xp = packRows(Xq, batch * m, k, kw); // rows are (batch·m)
|
| 841 |
const Wp = new Uint32Array(batch * n * kw); // per-batch Wᵀ, packed
|
|
|
|
| 862 |
// left operand is produced and consumed entirely on the GPU.
|
| 863 |
async function gpuMlpChain(device, env, xq, w1q, w2q, xs, w1s, w2s, d) {
|
| 864 |
const { m, k, h, n } = d, dp4 = !!env.dp4;
|
| 865 |
+
root.Verified.accBound(k, "gpuMlpChain gemm1"); root.Verified.accBound(h, "gpuMlpChain gemm2");
|
| 866 |
const SU = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST;
|
| 867 |
let bufX, bufW1, bufW2, kw1, hw;
|
| 868 |
if (dp4) {
|
|
|
|
| 912 |
const mx = new Float32Array(readM.getMappedRange(0, m * 4).slice(0)); readM.unmap();
|
| 913 |
// scale derivation in JS f64 — exactly rounded, identical on every device
|
| 914 |
// (WGSL division is 2.5 ULP, which is why it never runs on the GPU)
|
| 915 |
+
let sc;
|
| 916 |
+
try { sc = root.Verified.scalesFromAbsMax(mx); } // refuses a non-finite row max
|
| 917 |
+
catch (e) { // hand the pooled buffers back first
|
| 918 |
+
release(device, [bufX, bufW1, bufW2, bufRs, bufCs1, bufCs2, bufH, bufMX, bufD1, bufDM, readH, readM]);
|
| 919 |
+
throw e;
|
| 920 |
+
}
|
| 921 |
const bufInv = up(device, sc.inv, SU), bufHs = up(device, sc.scale, SU);
|
| 922 |
const bufQ = mk(device, m * hw * 4, GPUBufferUsage.STORAGE);
|
| 923 |
const bufDQ = up(device, new Uint32Array([m, h, hw, 0]), UU);
|
test_pipeline.js
CHANGED
|
@@ -188,6 +188,87 @@ const GPT2 = Arch.fromConfig({
|
|
| 188 |
assert.ok(a[0].head, "stage 0 must be the head");
|
| 189 |
});
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
console.log(failed ? `\n${failed} failure(s)\n` : "\nall pipeline checks passed\n");
|
| 192 |
process.exit(failed ? 1 : 0);
|
| 193 |
})();
|
|
|
|
| 188 |
assert.ok(a[0].head, "stage 0 must be the head");
|
| 189 |
});
|
| 190 |
|
| 191 |
+
await t("a disagreeing kernel probe is left out of the ring", async () => {
|
| 192 |
+
const caps = [
|
| 193 |
+
{ id: "head", capacity: 100, probe: 1 },
|
| 194 |
+
{ id: "ok", capacity: 100, probe: 1 },
|
| 195 |
+
{ id: "bad", capacity: 900, probe: 2 },
|
| 196 |
+
];
|
| 197 |
+
const members = Shard.ringMembers(caps, 1);
|
| 198 |
+
assert.deepStrictEqual(members.map(d => d.id), ["head", "ok"]);
|
| 199 |
+
const plan = Shard.planStages(LLAMA, members);
|
| 200 |
+
assert.ok(plan.every(s => s.id !== "bad"));
|
| 201 |
+
assert.strictEqual(plan.reduce((s, x) => s + (x.hi - x.lo), 0), LLAMA.layers);
|
| 202 |
+
});
|
| 203 |
+
|
| 204 |
+
await t("lap estimate grows with context and refuses an idle device", async () => {
|
| 205 |
+
const spec = Object.assign({}, LLAMA, { inter: 64, headDim: 8 });
|
| 206 |
+
const slow = Shard.estimateLapMs(spec, 128, 0.01);
|
| 207 |
+
const fast = Shard.estimateLapMs(spec, 8, 1000);
|
| 208 |
+
assert.ok(slow > fast, "a longer window on a slower device must cost more");
|
| 209 |
+
assert.ok(fast > 0);
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
await t("a short prompt stays local only when the head can hold the model", async () => {
|
| 213 |
+
const tensors = new Map([["w", { elems: 1000 }]]);
|
| 214 |
+
const spec = { hidden: 8 };
|
| 215 |
+
assert.strictEqual(Shard.headCanHold(spec, tensors, 0), false);
|
| 216 |
+
assert.strictEqual(Shard.headCanHold(spec, tensors, 8), true);
|
| 217 |
+
assert.strictEqual(Shard.preferLocal(16, 2, true), true);
|
| 218 |
+
assert.strictEqual(Shard.preferLocal(128, 2, true), false);
|
| 219 |
+
assert.strictEqual(Shard.preferLocal(16, 2, false), false);
|
| 220 |
+
assert.strictEqual(Shard.preferLocal(16, 0, true), false);
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
await t("prefix blocks match a continuation and the spill evicts the oldest block", async () => {
|
| 224 |
+
const idx = Shard.makePrefixIndex(4);
|
| 225 |
+
const a = [1, 2, 3, 4, 5, 6, 7, 8];
|
| 226 |
+
idx.note("chat", a);
|
| 227 |
+
assert.strictEqual(idx.matchedBlocks("chat", a.concat([9])), 2);
|
| 228 |
+
assert.strictEqual(idx.matchedBlocks("chat", [9, 9, 9, 9]), 0);
|
| 229 |
+
const spill = Shard.makeKvSpill(12);
|
| 230 |
+
assert.strictEqual(spill.put("a", new Uint8Array(8)), true);
|
| 231 |
+
assert.strictEqual(spill.put("b", new Uint8Array(8)), true);
|
| 232 |
+
assert.strictEqual(spill.get("a"), null, "the oldest block must be spilled");
|
| 233 |
+
assert.ok(spill.get("b"));
|
| 234 |
+
assert.strictEqual(spill.put("huge", new Uint8Array(13)), false);
|
| 235 |
+
});
|
| 236 |
+
|
| 237 |
+
await t("prefill chunks, prompt-lookup drafts, and accept keep a bonus token", async () => {
|
| 238 |
+
const c = Shard.prefillChunks(40, 16, 2);
|
| 239 |
+
assert.strictEqual(c.chunks.length, 3);
|
| 240 |
+
assert.deepStrictEqual([c.chunks[2].lo, c.chunks[2].hi], [32, 40]);
|
| 241 |
+
assert.strictEqual(c.window, 2);
|
| 242 |
+
const ids = [1, 2, 3, 9, 1, 2, 3];
|
| 243 |
+
assert.deepStrictEqual(Shard.promptLookupDraft(ids, 4), [9]);
|
| 244 |
+
assert.deepStrictEqual(Shard.acceptDraft([9, 4], [9]), [9, 4]);
|
| 245 |
+
assert.deepStrictEqual(Shard.acceptDraft([8, 4], [9]), [8]);
|
| 246 |
+
});
|
| 247 |
+
|
| 248 |
+
await t("chunked prefill matches across a stage split", async () => {
|
| 249 |
+
const got = makeWeights(LLAMA);
|
| 250 |
+
const prompt = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8];
|
| 251 |
+
const whole = await Infer.generateChunked(stagesFor(LLAMA, got, [LLAMA.layers], 16), prompt, 3, 4, { temperature: 0 });
|
| 252 |
+
const split = await Infer.generateChunked(stagesFor(LLAMA, got, [2, 2, 2], 16), prompt, 3, 4, { temperature: 0 });
|
| 253 |
+
assert.deepStrictEqual(split, whole);
|
| 254 |
+
});
|
| 255 |
+
|
| 256 |
+
await t("a lookahead draft scores in one window and survives a split", async () => {
|
| 257 |
+
const got = makeWeights(LLAMA);
|
| 258 |
+
const prompt = [4, 4, 4, 7, 4, 4, 4];
|
| 259 |
+
const opts = { temperature: 0, maxDraft: 4 };
|
| 260 |
+
const whole = await Infer.generateLookahead(stagesFor(LLAMA, got, [LLAMA.layers], 16), prompt, 4, 16, opts);
|
| 261 |
+
const split = await Infer.generateLookahead(stagesFor(LLAMA, got, [1, 2, 3], 16), prompt, 4, 16, opts);
|
| 262 |
+
assert.deepStrictEqual(split, whole);
|
| 263 |
+
assert.strictEqual(whole.length, prompt.length + 4);
|
| 264 |
+
});
|
| 265 |
+
|
| 266 |
+
await t("a non-finite activation is refused before any layer runs", async () => {
|
| 267 |
+
const S = { w: { layers: [] }, spec: LLAMA, T: 2 };
|
| 268 |
+
const bad = new Float32Array([0, 1, NaN, 2]);
|
| 269 |
+
await assert.rejects(() => Infer.runLayers(S, bad), (e) => e.name === "NonFiniteError");
|
| 270 |
+
});
|
| 271 |
+
|
| 272 |
console.log(failed ? `\n${failed} failure(s)\n` : "\nall pipeline checks passed\n");
|
| 273 |
process.exit(failed ? 1 : 0);
|
| 274 |
})();
|