Spaces:
Running
Running
Probe failure halts, fragment cleanup, subnormal fgemm gate, live f32 audit, high-performance adapter (2026-10-14)
Browse files- public/app.js +30 -6
- public/transformer.js +9 -1
- public/verified_core.js +35 -1
- public/webgpu.js +62 -6
public/app.js
CHANGED
|
@@ -268,15 +268,22 @@ function setupChannel(peerId, dc) {
|
|
| 268 |
// the next step boundary instead of making them wait for the next run
|
| 269 |
if (training && leaderId === null) { pendingJoins.add(peerId); log(`${nmeOf(peerId)} will be synced into the running training`); }
|
| 270 |
};
|
| 271 |
-
dc.onclose = () => { chans.delete(peerId); updatePeers(); wake(); scheduleReconnect(peerId); };
|
| 272 |
dc.onmessage = (e) => onGrad(peerId, e.data);
|
| 273 |
}
|
| 274 |
function cleanupPeer(id) {
|
| 275 |
const pc = pcs.get(id); if (pc) pc.close();
|
| 276 |
pcs.delete(id); chans.delete(id); pendingCand.delete(id); pendingJoins.delete(id);
|
| 277 |
-
|
| 278 |
wake();
|
| 279 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
// ---- checkpoints ------------------------------------------------------------
|
| 282 |
// File layout (also the broadcast payload after the sentinel):
|
|
@@ -555,8 +562,13 @@ function onFragment(peerId, buf) { // returns full message when co
|
|
| 555 |
const key = peerId + ":" + id;
|
| 556 |
let st = fragIn.get(key);
|
| 557 |
if (!st) { st = { id, parts: [], got: 0, total }; fragIn.set(key, st); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
st.parts[seq] = new Uint8Array(buf, 16).slice(0);
|
| 559 |
-
st.got++;
|
| 560 |
if (st.got < st.total) return null;
|
| 561 |
fragIn.delete(key);
|
| 562 |
let len = 0; for (const p of st.parts) len += p.length;
|
|
@@ -612,8 +624,12 @@ async function refreshProbe() {
|
|
| 612 |
mathHash = Compute.mathProbe();
|
| 613 |
log(`JS math library hash: ${mathHash.toString(16)} (peers must match; ECMA-262 does not require exp/log/cos/sin to be correctly rounded)`);
|
| 614 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
try { probeHash = await Compute.kernelProbe(compute, L); }
|
| 616 |
-
catch (e) { log(`kernel probe failed to run: ${e.message}`); }
|
| 617 |
return probeHash;
|
| 618 |
}
|
| 619 |
function hashWeights() { // FNV-1a over the flat param bytes
|
|
@@ -898,8 +914,16 @@ async function train(cfg, resume) {
|
|
| 898 |
if (bad) { halted = `weights diverged from ${nmeOf(bad)} (detected at step ${s + 1})`; break; }
|
| 899 |
// kernel disagreement: same seeded probe, same exact integer arithmetic
|
| 900 |
// expected on every backend. This is the check the weight hash can't make.
|
| 901 |
-
|
| 902 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 903 |
if (badK) {
|
| 904 |
halted = `kernel disagreement with ${nmeOf(badK)} at step ${s + 1} — their verified-unit probe ` +
|
| 905 |
`hashes ${hs.get(badK).phash} vs mine ${probeHash}. One of us is computing different ` +
|
|
|
|
| 268 |
// the next step boundary instead of making them wait for the next run
|
| 269 |
if (training && leaderId === null) { pendingJoins.add(peerId); log(`${nmeOf(peerId)} will be synced into the running training`); }
|
| 270 |
};
|
| 271 |
+
dc.onclose = () => { chans.delete(peerId); dropFragments(peerId); updatePeers(); wake(); scheduleReconnect(peerId); };
|
| 272 |
dc.onmessage = (e) => onGrad(peerId, e.data);
|
| 273 |
}
|
| 274 |
function cleanupPeer(id) {
|
| 275 |
const pc = pcs.get(id); if (pc) pc.close();
|
| 276 |
pcs.delete(id); chans.delete(id); pendingCand.delete(id); pendingJoins.delete(id);
|
| 277 |
+
dropFragments(id);
|
| 278 |
wake();
|
| 279 |
}
|
| 280 |
+
// A message cut off by a closed channel is never completed (the sender does not
|
| 281 |
+
// resume it), so its parts must go when the channel does. Before 2026-10-14 only
|
| 282 |
+
// cleanupPeer dropped them, and only the reconnect INITIATOR calls that: the
|
| 283 |
+
// other side kept up to a resume bundle's worth of dead parts per drop.
|
| 284 |
+
function dropFragments(id) {
|
| 285 |
+
for (const k of [...fragIn.keys()]) if (k.startsWith(id + ":")) fragIn.delete(k);
|
| 286 |
+
}
|
| 287 |
|
| 288 |
// ---- checkpoints ------------------------------------------------------------
|
| 289 |
// File layout (also the broadcast payload after the sentinel):
|
|
|
|
| 562 |
const key = peerId + ":" + id;
|
| 563 |
let st = fragIn.get(key);
|
| 564 |
if (!st) { st = { id, parts: [], got: 0, total }; fragIn.set(key, st); }
|
| 565 |
+
if (seq < 0 || seq >= st.total || total !== st.total) { // malformed: drop the whole message, loudly
|
| 566 |
+
log(`dropped malformed message ${id} from ${nmeOf(peerId)} (fragment ${seq}/${total}, expected /${st.total})`);
|
| 567 |
+
fragIn.delete(key);
|
| 568 |
+
return null;
|
| 569 |
+
}
|
| 570 |
+
if (!st.parts[seq]) st.got++; // a repeated part must not count twice
|
| 571 |
st.parts[seq] = new Uint8Array(buf, 16).slice(0);
|
|
|
|
| 572 |
if (st.got < st.total) return null;
|
| 573 |
fragIn.delete(key);
|
| 574 |
let len = 0; for (const p of st.parts) len += p.length;
|
|
|
|
| 624 |
mathHash = Compute.mathProbe();
|
| 625 |
log(`JS math library hash: ${mathHash.toString(16)} (peers must match; ECMA-262 does not require exp/log/cos/sin to be correctly rounded)`);
|
| 626 |
}
|
| 627 |
+
// A failed probe must not leave the PREVIOUS hash in place: that hash would
|
| 628 |
+
// go out with every gradient as a proof of kernels this run never re-checked.
|
| 629 |
+
// 0 means "unproven", and the roster check halts on it (2026-10-14; the same
|
| 630 |
+
// shape as an emulator call that reported success without doing the work).
|
| 631 |
try { probeHash = await Compute.kernelProbe(compute, L); }
|
| 632 |
+
catch (e) { probeHash = 0; log(`kernel probe failed to run: ${e.message} — this device's kernels are UNPROVEN`); }
|
| 633 |
return probeHash;
|
| 634 |
}
|
| 635 |
function hashWeights() { // FNV-1a over the flat param bytes
|
|
|
|
| 914 |
if (bad) { halted = `weights diverged from ${nmeOf(bad)} (detected at step ${s + 1})`; break; }
|
| 915 |
// kernel disagreement: same seeded probe, same exact integer arithmetic
|
| 916 |
// expected on every backend. This is the check the weight hash can't make.
|
| 917 |
+
// phash 0 = that device's probe did not run. It used to make this
|
| 918 |
+
// comparison skip the peer (`phash && ...`), so a device whose kernels
|
| 919 |
+
// could not be proven trained on unchecked; now it stops the run.
|
| 920 |
+
const unproven = roster.find(id => hs.has(id) && !hs.get(id).phash);
|
| 921 |
+
if (unproven) {
|
| 922 |
+
halted = `${unproven === myId ? "this device's" : nmeOf(unproven) + "'s"} kernel probe failed to run ` +
|
| 923 |
+
`at step ${s + 1} — its arithmetic is unproven, so its gradients cannot be trusted`;
|
| 924 |
+
break;
|
| 925 |
+
}
|
| 926 |
+
const badK = roster.find(id => id !== myId && hs.has(id) && hs.get(id).phash !== probeHash);
|
| 927 |
if (badK) {
|
| 928 |
halted = `kernel disagreement with ${nmeOf(badK)} at step ${s + 1} — their verified-unit probe ` +
|
| 929 |
`hashes ${hs.get(badK).phash} vs mine ${probeHash}. One of us is computing different ` +
|
public/transformer.js
CHANGED
|
@@ -182,7 +182,7 @@
|
|
| 182 |
cfg: { ...cfg, layers, heads, hidden, vocab: vocabSize() },
|
| 183 |
ctx: { L, bgemm: (engine && engine.bgemm) || null,
|
| 184 |
att: (engine && engine.att) || null, fgemm: (engine && engine.fgemm) || null,
|
| 185 |
-
fgemm2: (engine && engine.fgemm2) || null,
|
| 186 |
mlp: (engine && engine.mlp) || null,
|
| 187 |
audit: audit || null, unitBackward: !!cfg.unitBackward },
|
| 188 |
emb: add("emb", mk(vocabSize() * c, 0.08)),
|
|
@@ -425,6 +425,14 @@
|
|
| 425 |
bmm(cache.dlogits, m.emb, BT, vocab, C),
|
| 426 |
]);
|
| 427 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
let dx = lnBwd(dlnfIn, cache.lnf.y, cache.lnf.sig, BT, C);
|
| 429 |
const scale = 1 / Math.sqrt(hd);
|
| 430 |
// concat helper for fusing sibling GEMMs into one batched dispatch
|
|
|
|
| 182 |
cfg: { ...cfg, layers, heads, hidden, vocab: vocabSize() },
|
| 183 |
ctx: { L, bgemm: (engine && engine.bgemm) || null,
|
| 184 |
att: (engine && engine.att) || null, fgemm: (engine && engine.fgemm) || null,
|
| 185 |
+
fgemm2: (engine && engine.fgemm2) || null, fgemmFma: !!(engine && engine.fgemmFma),
|
| 186 |
mlp: (engine && engine.mlp) || null,
|
| 187 |
audit: audit || null, unitBackward: !!cfg.unitBackward },
|
| 188 |
emb: add("emb", mk(vocabSize() * c, 0.08)),
|
|
|
|
| 425 |
bmm(cache.dlogits, m.emb, BT, vocab, C),
|
| 426 |
]);
|
| 427 |
}
|
| 428 |
+
// live-shape audit of the f32 GEMM pair (both legs; gated only at small shapes)
|
| 429 |
+
if ((m.ctx.fgemm2 || m.ctx.fgemm) && !units && m.ctx.audit && m.ctx.audit.due()) {
|
| 430 |
+
const bad = V.auditFgemm(cache.dlogits, cache.lnf.y, { m: vocab, k: BT, n: C, transA: true },
|
| 431 |
+
g[gi.emb], m.ctx.fgemmFma, m.ctx.audit.cells) ||
|
| 432 |
+
V.auditFgemm(cache.dlogits, m.emb, { m: BT, k: vocab, n: C },
|
| 433 |
+
dlnfIn, m.ctx.fgemmFma, m.ctx.audit.cells);
|
| 434 |
+
if (bad) m.ctx.audit.fail(bad);
|
| 435 |
+
}
|
| 436 |
let dx = lnBwd(dlnfIn, cache.lnf.y, cache.lnf.sig, BT, C);
|
| 437 |
const scale = 1 / Math.sqrt(hd);
|
| 438 |
// concat helper for fusing sibling GEMMs into one batched dispatch
|
public/verified_core.js
CHANGED
|
@@ -334,6 +334,40 @@
|
|
| 334 |
return out;
|
| 335 |
}
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
// ---- live audits for the fused attention kernels ---------------------------
|
| 338 |
// The attention kernels had exact INIT gates but nothing at live shapes —
|
| 339 |
// the exact gap the GEMM audit exists to close, left open on the kernels with
|
|
@@ -538,7 +572,7 @@
|
|
| 538 |
const api = { assertFinite, accBound, MAX_INT32_K, quantize, quantize2, quantizeRows, quantizeCols, quantizeHeadCols, lutMatmulJS, lutMatmul3JS, lutMatmul3,
|
| 539 |
bgemmJS, vgemmBlock, auditTile, epi, attScoresJS, attCtxJS, linearFwd, forward, backward, splitApply,
|
| 540 |
rowAbsMax, scalesFromAbsMax, quantizeRowsInv, vmlpBlock, bitDiff,
|
| 541 |
-
auditAttScores, auditAttCtx, fgemmMirror };
|
| 542 |
if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); module.exports = api; }
|
| 543 |
else { TC = root.TrainCore; root.Verified = api; }
|
| 544 |
})(typeof self !== "undefined" ? self : this);
|
|
|
|
| 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
|
|
|
|
| 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 |
|
|
|
|
| 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 |
|