Quazim0t0 commited on
Commit
868b423
·
1 Parent(s): a7b6d2d

Bring Infer onto the live Train cores and add ring scheduling.

Browse files

The 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 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
- function buildPlan() {
559
- if (!repoInfo) return null;
560
- const list = [{ id: myId, capacity: myCapacity, backend: compute.backend }];
 
 
 
 
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) list.push({ id, capacity: c.capacity, backend: c.backend });
 
 
 
 
 
564
  }
565
- return Shard.planStages(repoInfo.spec, list);
 
 
 
 
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) { log(`stage failed: ${redact(e.message)}`); return; }
 
 
 
 
 
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
- async function generate() {
691
- if (running) return;
 
 
 
 
 
 
 
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 = [...Tokenizer.encode(tok, ui.prompt.value || "The")];
723
- if (!ids.length) ids = [0];
 
 
 
 
 
 
 
 
 
 
 
 
724
  const promptLen = ids.length;
725
  ui.out.textContent = ui.prompt.value;
726
  const tStart = performance.now();
727
  let hops = 0;
728
 
729
- for (let n = 0; n < nTok && !abortRun; n++) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730
  const win = new Int32Array(T);
731
- const tail = ids.slice(-T);
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, n, plan[1].index, x));
738
  hops += plan.length;
739
- const key = `${seq}:${n}`;
740
- const ok = await waitFor(() => actIn.has(key), 60000);
741
- if (!ok) { log(`the ring stalled at token ${n + 1}: no activation came back. Press Generate again to re-plan.`); break; }
 
742
  x = actIn.get(key); actIn.delete(key);
743
  }
744
- const id = Infer.pickToken(await Infer.readout(stageObj, x), opts);
745
- ids.push(id);
746
- ui.out.textContent = Tokenizer.decode(tok, ids);
747
- ui.tokcount.textContent = String(n + 1);
748
- ui.hops.textContent = String(hops);
749
- ui.tps.textContent = ((n + 1) / ((performance.now() - tStart) / 1000)).toFixed(2);
750
- broadcast(Wire.packToken(seq, id, ids.length));
 
 
 
 
 
 
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 ref = await Infer.generateLocal([solo], Tokenizer.encode(tok, ui.prompt.value || "The"), nTok,
763
- { temperature: temp, topK: 40, rng: Infer.mulberry32(+ui.seed.value | 0) });
 
 
 
 
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
- for (const [file, list] of byFile) {
129
- for (const run of ST.coalesce(list)) {
130
- const buf = await getRange(repo, file, revision, token, run.start, run.end);
131
- for (const t of run.tensors) {
132
- const off = t.start - run.start;
133
- out.set(t.name, ST.toF32(t.dtype, new Uint8Array(buf, off, t.bytes)));
134
- done += t.bytes;
135
- if (onProgress) onProgress(`${(done / 1048576).toFixed(1)} / ${(total / 1048576).toFixed(1)} MB`);
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
- async function layerForward(S, x, ly) {
110
- const { spec, ctx } = S, C = spec.hidden, T = S.T;
 
 
 
 
 
 
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
- if (spec.rope) { applyRope(q, T, nH, hd, S.rope, 0); applyRope(k, T, nKV, hd, S.rope, 0); }
 
 
 
 
 
 
 
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(T * hd), vh = new Float32Array(T * hd);
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, T, hd); // hd x T
155
- const scores = await V.vgemmBlock(qh, khT, { m: T, k: hd, n: T, batch: 1 }, ctx.L, ctx.bgemm, ctx.audit);
156
- const a = new Float32Array(T * T); // causal softmax
157
  for (let i = 0; i < T; i++) {
 
158
  let mx = -Infinity;
159
- for (let j = 0; j <= i; j++) mx = Math.max(mx, scores[i * T + j] * scale);
160
  let z = 0;
161
- for (let j = 0; j <= i; j++) { const e = Math.exp(scores[i * T + j] * scale - mx); a[i * T + j] = e; z += e; }
162
- for (let j = 0; j <= i; j++) a[i * T + j] /= z;
163
  }
164
- const oh = await V.vgemmBlock(a, vh, { m: T, k: T, n: hd, batch: 1 }, ctx.L, ctx.bgemm, ctx.audit);
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
- const api = { makeStage, embed, runLayers, readout, pickToken, generateLocal,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 out = new Float32Array(grads[0].length);
 
 
 
 
 
 
 
 
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
- const adapter = await navigator.gpu.requestAdapter();
 
 
 
 
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 d0 of [{ B: 2, T: 8, heads: 2, hd: 8 }, { B: 1, T: 32, heads: 2, hd: 16 },
335
- { B: 3, T: 7, heads: 3, hd: 5 }, { B: 2, T: 33, heads: 4, hd: 8 }]) {
 
 
 
 
 
 
 
 
 
 
 
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
- const sc = root.Verified.scalesFromAbsMax(mx);
 
 
 
 
 
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
  })();