Web demo: exact kernel gates + continuous cross-device kernel verification
Browse files- web/public/app.js +60 -17
- web/public/transformer.js +5 -3
- web/public/verified_core.js +57 -13
- web/public/webgpu.js +165 -115
- web/test_gates.js +118 -0
web/public/app.js
CHANGED
|
@@ -565,19 +565,40 @@ function onFragment(peerId, buf) { // returns full message when co
|
|
| 565 |
return out.buffer;
|
| 566 |
}
|
| 567 |
|
| 568 |
-
// ---- gradient wire
|
| 569 |
-
// whash = FNV-1a of the sender's weights BEFORE this step's update
|
| 570 |
-
//
|
| 571 |
-
//
|
| 572 |
-
//
|
| 573 |
-
|
| 574 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
new Int32Array(buf, 0, 1)[0] = step;
|
| 576 |
-
new Uint32Array(buf, 4,
|
| 577 |
-
new Float32Array(buf,
|
| 578 |
-
new Float32Array(buf,
|
| 579 |
return buf;
|
| 580 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
function hashWeights() { // FNV-1a over the flat param bytes
|
| 582 |
const f = Transformer.getFlatParams(model);
|
| 583 |
const b = new Uint8Array(f.buffer, f.byteOffset, f.byteLength);
|
|
@@ -618,16 +639,16 @@ function onGrad(peerId, buf) {
|
|
| 618 |
rosters.set(iv[1], [...iv.slice(3, 3 + iv[2])].map(n => "p" + n));
|
| 619 |
wake(); return;
|
| 620 |
}
|
| 621 |
-
const whash = new Uint32Array(buf, 4,
|
| 622 |
-
const loss = new Float32Array(buf,
|
| 623 |
-
const grad = new Float32Array(buf.slice(
|
| 624 |
if (!incoming.has(step)) incoming.set(step, new Map());
|
| 625 |
incoming.get(step).set(peerId, grad);
|
| 626 |
if (!peerHashes.has(step)) peerHashes.set(step, new Map());
|
| 627 |
-
peerHashes.get(step).set(peerId, { hash: whash, loss });
|
| 628 |
wake(); // resolve waits immediately (no polling)
|
| 629 |
}
|
| 630 |
-
function broadcastGrad(step, whash, loss, grad) { return broadcast(packGrad(step, whash, loss, grad)); }
|
| 631 |
// Event-driven: re-checked on every gradient arrival and peer departure, plus a
|
| 632 |
// coarse fallback timer (background tabs throttle timers to ~1s, so the old
|
| 633 |
// 15ms poll was the bottleneck there). Peers that left are dropped from the
|
|
@@ -680,6 +701,8 @@ async function train(cfg, resume) {
|
|
| 680 |
const opt = TrainCore.makeAdam(model.nParams, { lr: cfg.lr });
|
| 681 |
if (resume) opt.setState(resume.adam); // bit-identical moments from the leader
|
| 682 |
pendingJoins.clear();
|
|
|
|
|
|
|
| 683 |
log(`training started — cohort ${cohort.length} peer(s), world ${cohort.length + 1}, ` +
|
| 684 |
`width=${cfg.c} seq=${cfg.t} batch=${cfg.b}×${cohort.length + 1}, optimizer ${opt.name}` +
|
| 685 |
(cohort.length ? `, sync ${iLead ? "led by me" : "led by " + nmeOf(leaderId)}` : "") +
|
|
@@ -712,9 +735,14 @@ async function train(cfg, resume) {
|
|
| 712 |
if (lastLocal && lastLocal.s === s) {
|
| 713 |
({ loss, grad } = lastLocal);
|
| 714 |
} else {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 715 |
({ loss, grad } = await localStep());
|
|
|
|
| 716 |
lastLocal = { s, loss, grad };
|
| 717 |
-
await broadcastGrad(s, whash, loss, grad);
|
| 718 |
}
|
| 719 |
}
|
| 720 |
let all;
|
|
@@ -782,6 +810,16 @@ async function train(cfg, resume) {
|
|
| 782 |
if (hs) {
|
| 783 |
const bad = roster.find(id => id !== myId && hs.has(id) && hs.get(id).hash !== whash);
|
| 784 |
if (bad) { halted = `weights diverged from ${nmeOf(bad)} (detected at step ${s + 1})`; break; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
}
|
| 786 |
// cluster-average loss over this step's actual contributors
|
| 787 |
let lossSum = 0, lossN = 0;
|
|
@@ -842,7 +880,7 @@ function cfgSliders() { return [ui.cfgC, ui.cfgT, ui.cfgB, ui.cfgSteps, ui.cfgLr
|
|
| 842 |
// (re)build the mini transformer — deterministic shared init (same seeds on
|
| 843 |
// every peer); each device samples its own batch windows from the shared corpus
|
| 844 |
function buildModel(cfg) {
|
| 845 |
-
model = Transformer.init(cfg, L, compute);
|
| 846 |
trainedSteps = 0;
|
| 847 |
}
|
| 848 |
|
|
@@ -894,6 +932,11 @@ function buildModel(cfg) {
|
|
| 894 |
return; // no signaling, no training
|
| 895 |
}
|
| 896 |
ui.backend.textContent = `${compute.backend.toUpperCase()} — ${compute.label} · block-scaled INT8, batched + fused epilogue, through verified units`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 897 |
// tokenizer must load BEFORE the model is built (it sets the vocab size)
|
| 898 |
try {
|
| 899 |
const name = await Transformer.loadTokenizer();
|
|
|
|
| 565 |
return out.buffer;
|
| 566 |
}
|
| 567 |
|
| 568 |
+
// ---- gradient wire: [i32 step][u32 whash][u32 phash][f32 loss][f32 grad...]
|
| 569 |
+
// whash = FNV-1a of the sender's weights BEFORE this step's update — proves the
|
| 570 |
+
// whole group is still applying the same updates to the same model.
|
| 571 |
+
// phash = FNV-1a of the sender's canonical kernel probe — proves the sender's
|
| 572 |
+
// arithmetic still agrees with the fleet's. whash CANNOT do this: a device
|
| 573 |
+
// with a broken kernel broadcasts a bad gradient, everyone averages the same
|
| 574 |
+
// bad bytes, and every replica stays bit-identical. Replica agreement is not
|
| 575 |
+
// kernel correctness, so the two hashes check genuinely different things.
|
| 576 |
+
// loss = the sender's local batch loss, for the true cluster-average display.
|
| 577 |
+
function packGrad(step, whash, phash, loss, grad) {
|
| 578 |
+
const buf = new ArrayBuffer(16 + grad.byteLength);
|
| 579 |
new Int32Array(buf, 0, 1)[0] = step;
|
| 580 |
+
new Uint32Array(buf, 4, 2).set([whash, phash]);
|
| 581 |
+
new Float32Array(buf, 12, 1)[0] = loss;
|
| 582 |
+
new Float32Array(buf, 16).set(grad);
|
| 583 |
return buf;
|
| 584 |
}
|
| 585 |
+
// ---- continuous kernel verification ----------------------------------------
|
| 586 |
+
// The boot gate only ever sees toy shapes at one moment. These two keep proving
|
| 587 |
+
// it while the run is live: a random-cell audit against the units at the REAL
|
| 588 |
+
// shapes, and the canonical probe re-run on a cadence so thermal/driver drift
|
| 589 |
+
// shows up mid-run rather than never.
|
| 590 |
+
const PROBE_EVERY = 25; // steps between probe refreshes
|
| 591 |
+
let probeHash = 0, auditFailure = null;
|
| 592 |
+
const gemmAudit = {
|
| 593 |
+
cells: 6,
|
| 594 |
+
due: () => Math.random() < 0.02, // ~2% of GEMMs, a few cells each
|
| 595 |
+
fail: (msg) => { if (!auditFailure) { auditFailure = msg; log(`KERNEL AUDIT FAILED: ${msg}`); } },
|
| 596 |
+
};
|
| 597 |
+
async function refreshProbe() {
|
| 598 |
+
try { probeHash = await Compute.kernelProbe(compute, L); }
|
| 599 |
+
catch (e) { log(`kernel probe failed to run: ${e.message}`); }
|
| 600 |
+
return probeHash;
|
| 601 |
+
}
|
| 602 |
function hashWeights() { // FNV-1a over the flat param bytes
|
| 603 |
const f = Transformer.getFlatParams(model);
|
| 604 |
const b = new Uint8Array(f.buffer, f.byteOffset, f.byteLength);
|
|
|
|
| 639 |
rosters.set(iv[1], [...iv.slice(3, 3 + iv[2])].map(n => "p" + n));
|
| 640 |
wake(); return;
|
| 641 |
}
|
| 642 |
+
const [whash, phash] = new Uint32Array(buf, 4, 2);
|
| 643 |
+
const loss = new Float32Array(buf, 12, 1)[0];
|
| 644 |
+
const grad = new Float32Array(buf.slice(16));
|
| 645 |
if (!incoming.has(step)) incoming.set(step, new Map());
|
| 646 |
incoming.get(step).set(peerId, grad);
|
| 647 |
if (!peerHashes.has(step)) peerHashes.set(step, new Map());
|
| 648 |
+
peerHashes.get(step).set(peerId, { hash: whash, phash, loss });
|
| 649 |
wake(); // resolve waits immediately (no polling)
|
| 650 |
}
|
| 651 |
+
function broadcastGrad(step, whash, phash, loss, grad) { return broadcast(packGrad(step, whash, phash, loss, grad)); }
|
| 652 |
// Event-driven: re-checked on every gradient arrival and peer departure, plus a
|
| 653 |
// coarse fallback timer (background tabs throttle timers to ~1s, so the old
|
| 654 |
// 15ms poll was the bottleneck there). Peers that left are dropped from the
|
|
|
|
| 701 |
const opt = TrainCore.makeAdam(model.nParams, { lr: cfg.lr });
|
| 702 |
if (resume) opt.setState(resume.adam); // bit-identical moments from the leader
|
| 703 |
pendingJoins.clear();
|
| 704 |
+
auditFailure = null;
|
| 705 |
+
await refreshProbe(); // fresh kernel proof for this run
|
| 706 |
log(`training started — cohort ${cohort.length} peer(s), world ${cohort.length + 1}, ` +
|
| 707 |
`width=${cfg.c} seq=${cfg.t} batch=${cfg.b}×${cohort.length + 1}, optimizer ${opt.name}` +
|
| 708 |
(cohort.length ? `, sync ${iLead ? "led by me" : "led by " + nmeOf(leaderId)}` : "") +
|
|
|
|
| 735 |
if (lastLocal && lastLocal.s === s) {
|
| 736 |
({ loss, grad } = lastLocal);
|
| 737 |
} else {
|
| 738 |
+
// re-prove this device's kernel against the units on a cadence, so
|
| 739 |
+
// drift that appears mid-run (thermal, driver, shape) is caught while
|
| 740 |
+
// it runs rather than only at boot on toy inputs
|
| 741 |
+
if (s % PROBE_EVERY === 0) await refreshProbe();
|
| 742 |
({ loss, grad } = await localStep());
|
| 743 |
+
if (auditFailure) { halted = auditFailure; break; }
|
| 744 |
lastLocal = { s, loss, grad };
|
| 745 |
+
await broadcastGrad(s, whash, probeHash, loss, grad);
|
| 746 |
}
|
| 747 |
}
|
| 748 |
let all;
|
|
|
|
| 810 |
if (hs) {
|
| 811 |
const bad = roster.find(id => id !== myId && hs.has(id) && hs.get(id).hash !== whash);
|
| 812 |
if (bad) { halted = `weights diverged from ${nmeOf(bad)} (detected at step ${s + 1})`; break; }
|
| 813 |
+
// kernel disagreement: same seeded probe, same exact integer arithmetic
|
| 814 |
+
// expected on every backend. This is the check the weight hash can't make.
|
| 815 |
+
const badK = roster.find(id => id !== myId && hs.has(id) && hs.get(id).phash &&
|
| 816 |
+
probeHash && hs.get(id).phash !== probeHash);
|
| 817 |
+
if (badK) {
|
| 818 |
+
halted = `kernel disagreement with ${nmeOf(badK)} at step ${s + 1} — their verified-unit probe ` +
|
| 819 |
+
`hashes ${hs.get(badK).phash} vs mine ${probeHash}. One of us is computing different ` +
|
| 820 |
+
`arithmetic; the shared weights would look fine either way, so stopping.`;
|
| 821 |
+
break;
|
| 822 |
+
}
|
| 823 |
}
|
| 824 |
// cluster-average loss over this step's actual contributors
|
| 825 |
let lossSum = 0, lossN = 0;
|
|
|
|
| 880 |
// (re)build the mini transformer — deterministic shared init (same seeds on
|
| 881 |
// every peer); each device samples its own batch windows from the shared corpus
|
| 882 |
function buildModel(cfg) {
|
| 883 |
+
model = Transformer.init(cfg, L, compute, gemmAudit); // compute.bgemm = fused batched GPU path
|
| 884 |
trainedSteps = 0;
|
| 885 |
}
|
| 886 |
|
|
|
|
| 932 |
return; // no signaling, no training
|
| 933 |
}
|
| 934 |
ui.backend.textContent = `${compute.backend.toUpperCase()} — ${compute.label} · block-scaled INT8, batched + fused epilogue, through verified units`;
|
| 935 |
+
// canonical kernel probe: a fixed seeded GEMM through this device's live
|
| 936 |
+
// kernel. Every device that agrees with the units produces the same number,
|
| 937 |
+
// GPU or CPU — so it is comparable across the fleet, and re-run mid-training.
|
| 938 |
+
await refreshProbe();
|
| 939 |
+
log(`kernel probe: ${probeHash} (exact int32 through the units — every honest device gets this same number)`);
|
| 940 |
// tokenizer must load BEFORE the model is built (it sets the vocab size)
|
| 941 |
try {
|
| 942 |
const name = await Transformer.loadTokenizer();
|
web/public/transformer.js
CHANGED
|
@@ -117,7 +117,8 @@
|
|
| 117 |
// the kernel epilogue (ex. 12). Replaces the per-tensor 3-pass: same outlier
|
| 118 |
// robustness at one third of the unit ops.
|
| 119 |
async function vmm(Xf, Wf, m, k, n, ctx, relu) {
|
| 120 |
-
|
|
|
|
| 121 |
}
|
| 122 |
|
| 123 |
// ---- layernorm (no affine) -------------------------------------------------
|
|
@@ -146,7 +147,7 @@
|
|
| 146 |
// cfg: { c: width, t: seq len, b: batch/device, layers, heads, steps, lr }
|
| 147 |
// engine: the Compute backend object ({bgemm} for the fused WebGPU path) or a
|
| 148 |
// legacy matmulInt8 function (Node tests, inference kit) -> CPU LUT mirror
|
| 149 |
-
function init(cfg, L, engine) {
|
| 150 |
const c = cfg.c, layers = cfg.layers || 2, heads = cfg.heads || 2, hidden = 2 * c;
|
| 151 |
let seed = 100;
|
| 152 |
const mk = (nEl, scale) => { const w = randn(nEl, mulberry32(seed++)); for (let i = 0; i < nEl; i++) w[i] *= scale; return w; };
|
|
@@ -155,7 +156,8 @@
|
|
| 155 |
const m = {
|
| 156 |
cfg: { ...cfg, layers, heads, hidden, vocab: vocabSize() },
|
| 157 |
ctx: { L, bgemm: (engine && engine.bgemm) || null,
|
| 158 |
-
att: (engine && engine.att) || null, fgemm: (engine && engine.fgemm) || null
|
|
|
|
| 159 |
emb: add("emb", mk(vocabSize() * c, 0.08)),
|
| 160 |
pos: add("pos", mk(cfg.t * c, 0.02)),
|
| 161 |
blocks: [], params, names,
|
|
|
|
| 117 |
// the kernel epilogue (ex. 12). Replaces the per-tensor 3-pass: same outlier
|
| 118 |
// robustness at one third of the unit ops.
|
| 119 |
async function vmm(Xf, Wf, m, k, n, ctx, relu) {
|
| 120 |
+
// ctx.audit re-checks random cells of this LIVE GEMM against the units
|
| 121 |
+
return V.vgemmBlock(Xf, Wf, { m, k, n, batch: 1, relu: !!relu }, ctx.L, ctx.bgemm, ctx.audit);
|
| 122 |
}
|
| 123 |
|
| 124 |
// ---- layernorm (no affine) -------------------------------------------------
|
|
|
|
| 147 |
// cfg: { c: width, t: seq len, b: batch/device, layers, heads, steps, lr }
|
| 148 |
// engine: the Compute backend object ({bgemm} for the fused WebGPU path) or a
|
| 149 |
// legacy matmulInt8 function (Node tests, inference kit) -> CPU LUT mirror
|
| 150 |
+
function init(cfg, L, engine, audit) {
|
| 151 |
const c = cfg.c, layers = cfg.layers || 2, heads = cfg.heads || 2, hidden = 2 * c;
|
| 152 |
let seed = 100;
|
| 153 |
const mk = (nEl, scale) => { const w = randn(nEl, mulberry32(seed++)); for (let i = 0; i < nEl; i++) w[i] *= scale; return w; };
|
|
|
|
| 156 |
const m = {
|
| 157 |
cfg: { ...cfg, layers, heads, hidden, vocab: vocabSize() },
|
| 158 |
ctx: { L, bgemm: (engine && engine.bgemm) || null,
|
| 159 |
+
att: (engine && engine.att) || null, fgemm: (engine && engine.fgemm) || null,
|
| 160 |
+
audit: audit || null },
|
| 161 |
emb: add("emb", mk(vocabSize() * c, 0.08)),
|
| 162 |
pos: add("pos", mk(cfg.t * c, 0.02)),
|
| 163 |
blocks: [], params, names,
|
web/public/verified_core.js
CHANGED
|
@@ -100,12 +100,23 @@
|
|
| 100 |
return { q, s };
|
| 101 |
}
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
// CPU mirror of the fused GPU kernel: batched int8 GEMM through the LUT with
|
| 104 |
// the epilogue (block dequant + optional ReLU) applied before returning —
|
| 105 |
-
// exactly what the WGSL kernel does on-device.
|
|
|
|
| 106 |
function bgemmJS(Xq, Wq, rs, cs, d, L) {
|
| 107 |
const { m, k, n } = d, batch = d.batch || 1, relu = !!d.relu, mul = L.mul;
|
| 108 |
-
const
|
|
|
|
| 109 |
const acc = new Int32Array(n);
|
| 110 |
for (let bz = 0; bz < batch; bz++) {
|
| 111 |
const xo = bz * m * k, wo = bz * k * n, oo = bz * m * n, co = bz * n;
|
|
@@ -116,9 +127,11 @@
|
|
| 116 |
const au = (Xq[xrow + p] & 0xFF) * 256, wrow = wo + p * n;
|
| 117 |
for (let j = 0; j < n; j++) acc[j] += mul[au + (Wq[wrow + j] & 0xFF)];
|
| 118 |
}
|
| 119 |
-
const
|
|
|
|
|
|
|
| 120 |
for (let j = 0; j < n; j++) {
|
| 121 |
-
const v = acc[j]
|
| 122 |
out[orow + j] = relu && v < 0 ? 0 : v;
|
| 123 |
}
|
| 124 |
}
|
|
@@ -126,11 +139,32 @@
|
|
| 126 |
return out;
|
| 127 |
}
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
// block-scaled verified GEMM, float in → float out.
|
| 130 |
// d = { m, k, n, batch=1, relu=false }; X is (batch·m)×k, W is batch×(k×n)
|
| 131 |
// gpuBgemm (from webgpu.js) runs the batched kernel with the fused epilogue;
|
| 132 |
// without it the CPU LUT mirror runs. Every product goes through the units.
|
| 133 |
-
async function vgemmBlock(Xf, Wf, d, L, gpuBgemm) {
|
| 134 |
const { m, k, n } = d, batch = d.batch || 1;
|
| 135 |
const x = quantizeRows(Xf, batch * m, k);
|
| 136 |
let wq, ws;
|
|
@@ -143,7 +177,16 @@
|
|
| 143 |
wq.set(w.q, bz * k * n); ws.set(w.s, bz * n);
|
| 144 |
}
|
| 145 |
}
|
| 146 |
-
if (gpuBgemm)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
return bgemmJS(x.q, wq, x.s, ws, d, L);
|
| 148 |
}
|
| 149 |
|
|
@@ -176,9 +219,10 @@
|
|
| 176 |
return { q, s };
|
| 177 |
}
|
| 178 |
// scores S[bz,ti,tj] = q_row(bi,ti,h) · k_row(bi,tj,h), every product via the LUT
|
|
|
|
| 179 |
function attScoresJS(qq, kq, qs, ks, d, L) {
|
| 180 |
-
const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul;
|
| 181 |
-
const out = new Float32Array(B * heads * T * T);
|
| 182 |
for (let bi = 0; bi < B; bi++) for (let h = 0; h < heads; h++) {
|
| 183 |
const bz = bi * heads + h;
|
| 184 |
for (let ti = 0; ti < T; ti++) {
|
|
@@ -187,7 +231,7 @@
|
|
| 187 |
const ko = (bi * T + tj) * C + h * hd;
|
| 188 |
let acc = 0;
|
| 189 |
for (let p = 0; p < hd; p++) acc += mul[(qq[qo + p] & 0xFF) * 256 + (kq[ko + p] & 0xFF)];
|
| 190 |
-
out[(bz * T + ti) * T + tj] = acc
|
| 191 |
}
|
| 192 |
}
|
| 193 |
}
|
|
@@ -195,8 +239,8 @@
|
|
| 195 |
}
|
| 196 |
// ctx[(bi,ti),(h,j)] = Σ_tj a[bz,ti,tj]·v[(bi,tj),(h,j)] — scatter fused into BT×C
|
| 197 |
function attCtxJS(aq, vq, as, vs, d, L) {
|
| 198 |
-
const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul;
|
| 199 |
-
const out = new Float32Array(B * T * C);
|
| 200 |
for (let bi = 0; bi < B; bi++) for (let h = 0; h < heads; h++) {
|
| 201 |
const bz = bi * heads + h;
|
| 202 |
for (let ti = 0; ti < T; ti++) {
|
|
@@ -205,7 +249,7 @@
|
|
| 205 |
let acc = 0;
|
| 206 |
for (let tj = 0; tj < T; tj++)
|
| 207 |
acc += mul[(aq[ao + tj] & 0xFF) * 256 + (vq[(bi * T + tj) * C + h * hd + j] & 0xFF)];
|
| 208 |
-
out[(bi * T + ti) * C + h * hd + j] = acc
|
| 209 |
}
|
| 210 |
}
|
| 211 |
}
|
|
@@ -263,7 +307,7 @@
|
|
| 263 |
}
|
| 264 |
|
| 265 |
const api = { quantize, quantize2, quantizeRows, quantizeCols, quantizeHeadCols, lutMatmulJS, lutMatmul3JS, lutMatmul3,
|
| 266 |
-
bgemmJS, vgemmBlock, attScoresJS, attCtxJS, linearFwd, forward, backward, splitApply };
|
| 267 |
if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); module.exports = api; }
|
| 268 |
else { TC = root.TrainCore; root.Verified = api; }
|
| 269 |
})(typeof self !== "undefined" ? self : this);
|
|
|
|
| 100 |
return { q, s };
|
| 101 |
}
|
| 102 |
|
| 103 |
+
// ---- epilogue mirror -------------------------------------------------------
|
| 104 |
+
// BIT-EXACT mirror of the WGSL epilogue `f32(s) * a * b`. WGSL rounds to f32
|
| 105 |
+
// after the int->float conversion and after EACH multiply; plain JS would do
|
| 106 |
+
// the whole chain in f64 and round once, which differs in the last ulp. That
|
| 107 |
+
// last-ulp gap is what used to force a tolerance into the kernel gates —
|
| 108 |
+
// mirroring the rounding exactly is what lets the gates compare with `!==`.
|
| 109 |
+
const f32 = Math.fround;
|
| 110 |
+
function epi(s, a, b) { return f32(f32(f32(s) * a) * b); }
|
| 111 |
+
|
| 112 |
// CPU mirror of the fused GPU kernel: batched int8 GEMM through the LUT with
|
| 113 |
// the epilogue (block dequant + optional ReLU) applied before returning —
|
| 114 |
+
// exactly what the WGSL kernel does on-device. d.acc=true returns the raw
|
| 115 |
+
// int32 accumulator instead (the exact oracle the fused kernel normally hides).
|
| 116 |
function bgemmJS(Xq, Wq, rs, cs, d, L) {
|
| 117 |
const { m, k, n } = d, batch = d.batch || 1, relu = !!d.relu, mul = L.mul;
|
| 118 |
+
const raw = !!d.acc;
|
| 119 |
+
const out = raw ? new Int32Array(batch * m * n) : new Float32Array(batch * m * n);
|
| 120 |
const acc = new Int32Array(n);
|
| 121 |
for (let bz = 0; bz < batch; bz++) {
|
| 122 |
const xo = bz * m * k, wo = bz * k * n, oo = bz * m * n, co = bz * n;
|
|
|
|
| 127 |
const au = (Xq[xrow + p] & 0xFF) * 256, wrow = wo + p * n;
|
| 128 |
for (let j = 0; j < n; j++) acc[j] += mul[au + (Wq[wrow + j] & 0xFF)];
|
| 129 |
}
|
| 130 |
+
const orow = oo + i * n;
|
| 131 |
+
if (raw) { for (let j = 0; j < n; j++) out[orow + j] = acc[j]; continue; }
|
| 132 |
+
const rscale = rs[bz * m + i];
|
| 133 |
for (let j = 0; j < n; j++) {
|
| 134 |
+
const v = epi(acc[j], rscale, cs[co + j]);
|
| 135 |
out[orow + j] = relu && v < 0 ? 0 : v;
|
| 136 |
}
|
| 137 |
}
|
|
|
|
| 139 |
return out;
|
| 140 |
}
|
| 141 |
|
| 142 |
+
// Recompute a handful of RANDOM output cells of a live GEMM through the LUT
|
| 143 |
+
// mirror and compare against what the kernel produced. Sampling cells instead
|
| 144 |
+
// of whole matrices makes this cheap enough to run continuously, at the real
|
| 145 |
+
// shapes training uses — not once at boot on toy inputs.
|
| 146 |
+
function auditTile(Xq, Wq, rs, cs, d, got, L, nCells) {
|
| 147 |
+
const { m, k, n } = d, batch = d.batch || 1, relu = !!d.relu, mul = L.mul;
|
| 148 |
+
for (let t = 0; t < (nCells || 8); t++) {
|
| 149 |
+
const bz = (Math.random() * batch) | 0;
|
| 150 |
+
const i = (Math.random() * m) | 0, j = (Math.random() * n) | 0;
|
| 151 |
+
let acc = 0;
|
| 152 |
+
const xrow = bz * m * k + i * k, wo = bz * k * n;
|
| 153 |
+
for (let p = 0; p < k; p++) acc += mul[(Xq[xrow + p] & 0xFF) * 256 + (Wq[wo + p * n + j] & 0xFF)];
|
| 154 |
+
let v = epi(acc, rs[bz * m + i], cs[bz * n + j]);
|
| 155 |
+
if (relu && v < 0) v = 0;
|
| 156 |
+
const idx = (bz * m + i) * n + j;
|
| 157 |
+
if (got[idx] !== v)
|
| 158 |
+
return `GEMM audit failed at [b${bz},${i},${j}] shape ${m}x${k}x${n}: kernel ${got[idx]} vs units ${v}`;
|
| 159 |
+
}
|
| 160 |
+
return null;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
// block-scaled verified GEMM, float in → float out.
|
| 164 |
// d = { m, k, n, batch=1, relu=false }; X is (batch·m)×k, W is batch×(k×n)
|
| 165 |
// gpuBgemm (from webgpu.js) runs the batched kernel with the fused epilogue;
|
| 166 |
// without it the CPU LUT mirror runs. Every product goes through the units.
|
| 167 |
+
async function vgemmBlock(Xf, Wf, d, L, gpuBgemm, audit) {
|
| 168 |
const { m, k, n } = d, batch = d.batch || 1;
|
| 169 |
const x = quantizeRows(Xf, batch * m, k);
|
| 170 |
let wq, ws;
|
|
|
|
| 177 |
wq.set(w.q, bz * k * n); ws.set(w.s, bz * n);
|
| 178 |
}
|
| 179 |
}
|
| 180 |
+
if (gpuBgemm) {
|
| 181 |
+
const out = await gpuBgemm(x.q, wq, x.s, ws, d);
|
| 182 |
+
// continuous re-verification at LIVE shapes: the boot gate only ever saw
|
| 183 |
+
// toy inputs, so sample a few real cells against the units as we go
|
| 184 |
+
if (audit && audit.due()) {
|
| 185 |
+
const bad = auditTile(x.q, wq, x.s, ws, d, out, L, audit.cells);
|
| 186 |
+
if (bad) audit.fail(bad);
|
| 187 |
+
}
|
| 188 |
+
return out;
|
| 189 |
+
}
|
| 190 |
return bgemmJS(x.q, wq, x.s, ws, d, L);
|
| 191 |
}
|
| 192 |
|
|
|
|
| 219 |
return { q, s };
|
| 220 |
}
|
| 221 |
// scores S[bz,ti,tj] = q_row(bi,ti,h) · k_row(bi,tj,h), every product via the LUT
|
| 222 |
+
// d.acc=true returns the raw int32 accumulator (exact oracle for the kernel gate)
|
| 223 |
function attScoresJS(qq, kq, qs, ks, d, L) {
|
| 224 |
+
const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul, raw = !!d.acc;
|
| 225 |
+
const out = raw ? new Int32Array(B * heads * T * T) : new Float32Array(B * heads * T * T);
|
| 226 |
for (let bi = 0; bi < B; bi++) for (let h = 0; h < heads; h++) {
|
| 227 |
const bz = bi * heads + h;
|
| 228 |
for (let ti = 0; ti < T; ti++) {
|
|
|
|
| 231 |
const ko = (bi * T + tj) * C + h * hd;
|
| 232 |
let acc = 0;
|
| 233 |
for (let p = 0; p < hd; p++) acc += mul[(qq[qo + p] & 0xFF) * 256 + (kq[ko + p] & 0xFF)];
|
| 234 |
+
out[(bz * T + ti) * T + tj] = raw ? acc : epi(acc, rscale, ks[(bi * T + tj) * heads + h]);
|
| 235 |
}
|
| 236 |
}
|
| 237 |
}
|
|
|
|
| 239 |
}
|
| 240 |
// ctx[(bi,ti),(h,j)] = Σ_tj a[bz,ti,tj]·v[(bi,tj),(h,j)] — scatter fused into BT×C
|
| 241 |
function attCtxJS(aq, vq, as, vs, d, L) {
|
| 242 |
+
const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul, raw = !!d.acc;
|
| 243 |
+
const out = raw ? new Int32Array(B * T * C) : new Float32Array(B * T * C);
|
| 244 |
for (let bi = 0; bi < B; bi++) for (let h = 0; h < heads; h++) {
|
| 245 |
const bz = bi * heads + h;
|
| 246 |
for (let ti = 0; ti < T; ti++) {
|
|
|
|
| 249 |
let acc = 0;
|
| 250 |
for (let tj = 0; tj < T; tj++)
|
| 251 |
acc += mul[(aq[ao + tj] & 0xFF) * 256 + (vq[(bi * T + tj) * C + h * hd + j] & 0xFF)];
|
| 252 |
+
out[(bi * T + ti) * C + h * hd + j] = raw ? acc : epi(acc, rscale, vs[(bi * heads + h) * hd + j]);
|
| 253 |
}
|
| 254 |
}
|
| 255 |
}
|
|
|
|
| 307 |
}
|
| 308 |
|
| 309 |
const api = { quantize, quantize2, quantizeRows, quantizeCols, quantizeHeadCols, lutMatmulJS, lutMatmul3JS, lutMatmul3,
|
| 310 |
+
bgemmJS, vgemmBlock, auditTile, epi, attScoresJS, attCtxJS, linearFwd, forward, backward, splitApply };
|
| 311 |
if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); module.exports = api; }
|
| 312 |
else { TC = root.TrainCore; root.Verified = api; }
|
| 313 |
})(typeof self !== "undefined" ? self : this);
|
web/public/webgpu.js
CHANGED
|
@@ -35,36 +35,32 @@
|
|
| 35 |
C[row * n + col] = s;
|
| 36 |
}`;
|
| 37 |
|
| 38 |
-
//
|
| 39 |
-
//
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
@group(0) @binding(3) var<uniform> dims : vec3<u32>; // m, kw=ceil(k/4), n
|
| 45 |
-
@compute @workgroup_size(8, 8)
|
| 46 |
-
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
| 47 |
-
let m = dims.x; let kw = dims.y; let n = dims.z;
|
| 48 |
-
let row = gid.x; let col = gid.y;
|
| 49 |
-
if (row >= m || col >= n) { return; }
|
| 50 |
-
var s : i32 = 0;
|
| 51 |
-
for (var p = 0u; p < kw; p = p + 1u) {
|
| 52 |
-
s = s + dot4I8Packed(Xp[row * kw + p], Wp[col * kw + p]);
|
| 53 |
-
}
|
| 54 |
-
C[row * n + col] = s;
|
| 55 |
-
}`;
|
| 56 |
|
| 57 |
// Batched block-scaled GEMM with a FUSED EPILOGUE (CUTLASS ex. 05/24 + 12):
|
| 58 |
// grid z = batch index, so ALL attention heads run in ONE dispatch, and the
|
| 59 |
// epilogue (block dequant rs·cs + optional ReLU) happens before the data
|
| 60 |
// leaves the GPU — f32 out, no int32 readback, no second pass in JS.
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
@group(0) @binding(0) var<storage, read> Xq : array<i32>; // int8 byte per elem
|
| 63 |
@group(0) @binding(1) var<storage, read> Wq : array<i32>;
|
| 64 |
@group(0) @binding(2) var<storage, read> lut : array<i32>;
|
| 65 |
@group(0) @binding(3) var<storage, read> rs : array<f32>; // per (batch,row)
|
| 66 |
@group(0) @binding(4) var<storage, read> cs : array<f32>; // per (batch,col)
|
| 67 |
-
|
| 68 |
@group(0) @binding(6) var<uniform> dims : vec4<u32>; // m, k, n, flags(1=relu)
|
| 69 |
@compute @workgroup_size(8, 8, 1)
|
| 70 |
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
|
@@ -79,18 +75,19 @@
|
|
| 79 |
let bu = u32(Wq[wo + p * n] & 255);
|
| 80 |
s = s + lut[au * 256u + bu];
|
| 81 |
}
|
|
|
|
| 82 |
var v = f32(s) * rs[bz * m + row] * cs[bz * n + col];
|
| 83 |
if ((dims.w & 1u) == 1u && v < 0.0) { v = 0.0; }
|
| 84 |
-
O[(bz * m + row) * n + col] = v;
|
| 85 |
}`;
|
| 86 |
|
| 87 |
// same fused/batched kernel on the DP4A hardware path (Wᵀ packed per batch)
|
| 88 |
-
const WGSL_BG_DP4 = `
|
| 89 |
@group(0) @binding(0) var<storage, read> Xp : array<u32>;
|
| 90 |
@group(0) @binding(1) var<storage, read> Wp : array<u32>; // per-batch Wᵀ, packed
|
| 91 |
@group(0) @binding(2) var<storage, read> rs : array<f32>;
|
| 92 |
@group(0) @binding(3) var<storage, read> cs : array<f32>;
|
| 93 |
-
|
| 94 |
@group(0) @binding(5) var<uniform> dims : vec4<u32>; // m, kw, n, flags
|
| 95 |
@compute @workgroup_size(8, 8, 1)
|
| 96 |
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
|
@@ -103,21 +100,22 @@
|
|
| 103 |
for (var p = 0u; p < kw; p = p + 1u) {
|
| 104 |
s = s + dot4I8Packed(Xp[xo + p], Wp[wo + p]);
|
| 105 |
}
|
|
|
|
| 106 |
var v = f32(s) * rs[bz * m + row] * cs[bz * n + col];
|
| 107 |
if ((dims.w & 1u) == 1u && v < 0.0) { v = 0.0; }
|
| 108 |
-
O[(bz * m + row) * n + col] = v;
|
| 109 |
}`;
|
| 110 |
|
| 111 |
// Gather-fused attention (CUTLASS ex. 36/52): kernels index q/k/v directly in
|
| 112 |
// their BT×C layout (head-strided) — no gather copies, no kᵀ transpose — and
|
| 113 |
// the ctx kernel scatters straight back into BT×C. int8×int8→i32 is exact, so
|
| 114 |
// these are bit-identical to the LUT mirrors (proved at init before use).
|
| 115 |
-
const WGSL_ATT_SCORES = `
|
| 116 |
@group(0) @binding(0) var<storage, read> Q : array<i32>; // int8 per elem, BT×C
|
| 117 |
@group(0) @binding(1) var<storage, read> K : array<i32>;
|
| 118 |
@group(0) @binding(2) var<storage, read> qs : array<f32>; // per (token,head)
|
| 119 |
@group(0) @binding(3) var<storage, read> ks : array<f32>;
|
| 120 |
-
|
| 121 |
@group(0) @binding(5) var<uniform> dims : vec4<u32>; // T, heads, hd, _
|
| 122 |
@compute @workgroup_size(8, 8, 1)
|
| 123 |
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
|
@@ -130,14 +128,15 @@
|
|
| 130 |
let ko = (bi * T + tj) * C + h * hd;
|
| 131 |
var s : i32 = 0;
|
| 132 |
for (var p = 0u; p < hd; p = p + 1u) { s = s + Q[qo + p] * K[ko + p]; }
|
| 133 |
-
O[(bz * T + ti) * T + tj] =
|
|
|
|
| 134 |
}`;
|
| 135 |
-
const WGSL_ATT_CTX = `
|
| 136 |
@group(0) @binding(0) var<storage, read> A : array<i32>; // int8, BH×T×T
|
| 137 |
@group(0) @binding(1) var<storage, read> V : array<i32>; // int8, BT×C
|
| 138 |
@group(0) @binding(2) var<storage, read> as_ : array<f32>; // per (bz,row)
|
| 139 |
@group(0) @binding(3) var<storage, read> vs : array<f32>; // per (batch,head,chan)
|
| 140 |
-
|
| 141 |
@group(0) @binding(5) var<uniform> dims : vec4<u32>; // T, heads, hd, _
|
| 142 |
@compute @workgroup_size(8, 8, 1)
|
| 143 |
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
|
@@ -149,7 +148,8 @@
|
|
| 149 |
let ao = (bz * T + ti) * T;
|
| 150 |
var s : i32 = 0;
|
| 151 |
for (var tj = 0u; tj < T; tj = tj + 1u) { s = s + A[ao + tj] * V[(bi * T + tj) * C + h * hd + j]; }
|
| 152 |
-
O[(bi * T + ti) * C + h * hd + j] =
|
|
|
|
| 153 |
}`;
|
| 154 |
|
| 155 |
// Split-K f32 GEMM (CUTLASS ex. 06) for the STE BACKWARD only — the backward
|
|
@@ -237,32 +237,58 @@
|
|
| 237 |
const lut32 = new Int32Array(L.mul); // widen int16 -> int32
|
| 238 |
const lutBuf = device.createBuffer({ size: lut32.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
| 239 |
device.queue.writeBuffer(lutBuf, 0, lut32);
|
| 240 |
-
const bgLutModule = device.createShaderModule({ code: WGSL_BG_LUT });
|
| 241 |
-
const bgLutPipe = device.createComputePipeline({ layout: "auto", compute: { module: bgLutModule, entryPoint: "main" } });
|
| 242 |
-
|
| 243 |
-
// gather-fused attention kernels — must reproduce the LUT mirrors exactly
|
| 244 |
const mkPipe = (code) => device.createComputePipeline({ layout: "auto",
|
| 245 |
compute: { module: device.createShaderModule({ code }), entryPoint: "main" } });
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
try {
|
| 250 |
-
const d0
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
} catch (e) {
|
| 267 |
console.warn("fused attention kernels failed verification — using CPU LUT mirrors:", e.message);
|
| 268 |
att = null;
|
|
@@ -284,51 +310,55 @@
|
|
| 284 |
fgemm = null;
|
| 285 |
}
|
| 286 |
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
matmulInt8: (Xq, Wq, m, k, n) => gpuMatmulLUT(device, lutPipe, lutBuf, Xq, Wq, m, k, n),
|
| 289 |
-
bgemm:
|
| 290 |
-
att, fgemm };
|
| 291 |
|
| 292 |
-
// DP4A pipeline — only if the WGSL feature exists AND
|
| 293 |
-
// verified units exactly
|
| 294 |
if (!(navigator.gpu.wgslLanguageFeatures && navigator.gpu.wgslLanguageFeatures.has("packed_4x8_integer_dot_product")))
|
| 295 |
return viaLUT;
|
| 296 |
-
const
|
| 297 |
-
const
|
| 298 |
-
const
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
for (let i = 0; i < Xq.length; i++) Xq[i] = (Math.random() * 256 - 128) | 0;
|
| 303 |
-
for (let i = 0; i < Wq.length; i++) Wq[i] = (Math.random() * 256 - 128) | 0;
|
| 304 |
-
const hw = await dp4mm(Xq, Wq, m0, k0, n0);
|
| 305 |
-
const ref = root.Verified.lutMatmulJS(Xq, Wq, m0, k0, n0, L);
|
| 306 |
-
for (let i = 0; i < ref.length; i++)
|
| 307 |
-
if (hw[i] !== ref[i]) { console.warn("DP4A disagreed with the verified units — using LUT shader"); return viaLUT; }
|
| 308 |
-
}
|
| 309 |
-
// fused/batched DP4A kernel, gated by the same verification: it must
|
| 310 |
-
// reproduce the CPU LUT mirror bit-for-bit on the integer accumulate
|
| 311 |
-
const bgDp4Module = device.createShaderModule({ code: WGSL_BG_DP4 });
|
| 312 |
-
const bgDp4Pipe = device.createComputePipeline({ layout: "auto", compute: { module: bgDp4Module, entryPoint: "main" } });
|
| 313 |
-
const bg = (Xq, Wq, rs, cs, d) => gpuBgemmDP4(device, bgDp4Pipe, Xq, Wq, rs, cs, d);
|
| 314 |
-
{
|
| 315 |
-
const d0 = { m: 5, k: 9, n: 6, batch: 3, relu: true };
|
| 316 |
-
const Xq = new Int8Array(d0.batch * d0.m * d0.k), Wq = new Int8Array(d0.batch * d0.k * d0.n);
|
| 317 |
-
for (let i = 0; i < Xq.length; i++) Xq[i] = (Math.random() * 256 - 128) | 0;
|
| 318 |
-
for (let i = 0; i < Wq.length; i++) Wq[i] = (Math.random() * 256 - 128) | 0;
|
| 319 |
-
const rs = new Float32Array(d0.batch * d0.m).fill(0).map(() => Math.random() + 0.5);
|
| 320 |
-
const cs = new Float32Array(d0.batch * d0.n).fill(0).map(() => Math.random() + 0.5);
|
| 321 |
-
const hw = await bg(Xq, Wq, rs, cs, d0);
|
| 322 |
-
const ref = root.Verified.bgemmJS(Xq, Wq, rs, cs, d0, L);
|
| 323 |
-
for (let i = 0; i < ref.length; i++)
|
| 324 |
-
if (Math.abs(hw[i] - ref[i]) > Math.abs(ref[i]) * 1e-6 + 1e-6) {
|
| 325 |
-
console.warn("batched DP4A disagreed with the verified units — using LUT bgemm");
|
| 326 |
-
return { backend: "webgpu", label: `${gpuName} (DP4A int8 dot — HW verified vs units)`,
|
| 327 |
-
matmulInt8: dp4mm, bgemm: viaLUT.bgemm, att, fgemm };
|
| 328 |
-
}
|
| 329 |
}
|
| 330 |
-
return { backend: "webgpu", label: `${gpuName} (DP4A int8 dot
|
| 331 |
-
|
| 332 |
} catch (e) { console.warn("WebGPU init failed, CPU fallback:", e); return cpu; }
|
| 333 |
}
|
| 334 |
|
|
@@ -364,20 +394,6 @@
|
|
| 364 |
return r.out;
|
| 365 |
}
|
| 366 |
|
| 367 |
-
async function gpuMatmulDP4(device, pipeline, Xq, Wq, m, k, n) {
|
| 368 |
-
const kw = Math.ceil(k / 4);
|
| 369 |
-
const Xp = packRows(Xq, m, k, kw);
|
| 370 |
-
const Wp = packRows(transposeI8(Wq, k, n), n, k, kw); // Wᵀ so col j is a row
|
| 371 |
-
const bufX = up(device, Xp, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 372 |
-
const bufW = up(device, Wp, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 373 |
-
const bufD = up(device, new Uint32Array([m, kw, n, 0]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
|
| 374 |
-
const r = await runPass(device, pipeline, (bufC) => [
|
| 375 |
-
{ binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
|
| 376 |
-
{ binding: 2, resource: { buffer: bufC } }, { binding: 3, resource: { buffer: bufD } } ], m, n);
|
| 377 |
-
[bufX, bufW, bufD, r.bufC, r.read].forEach(b => b.destroy());
|
| 378 |
-
return r.out;
|
| 379 |
-
}
|
| 380 |
-
|
| 381 |
// attention kernels: int8 (widened i32) in, f32 out, strided head indexing
|
| 382 |
async function gpuAttScores(device, pipeline, qq, kq, qs, ks, d) {
|
| 383 |
const { B, T, heads } = d;
|
|
@@ -389,7 +405,7 @@
|
|
| 389 |
const r = await runBgPass(device, pipeline, (bufO) => [
|
| 390 |
{ binding: 0, resource: { buffer: bufQ } }, { binding: 1, resource: { buffer: bufK } },
|
| 391 |
{ binding: 2, resource: { buffer: bufQs } }, { binding: 3, resource: { buffer: bufKs } },
|
| 392 |
-
{ binding: 4, resource: { buffer: bufO } }, { binding: 5, resource: { buffer: bufD } } ], T, T, B * heads);
|
| 393 |
[bufQ, bufK, bufQs, bufKs, bufD, r.bufO, r.read].forEach(b => b.destroy());
|
| 394 |
return r.out;
|
| 395 |
}
|
|
@@ -403,7 +419,7 @@
|
|
| 403 |
const r = await runBgPass(device, pipeline, (bufO) => [
|
| 404 |
{ binding: 0, resource: { buffer: bufA } }, { binding: 1, resource: { buffer: bufV } },
|
| 405 |
{ binding: 2, resource: { buffer: bufAs } }, { binding: 3, resource: { buffer: bufVs } },
|
| 406 |
-
{ binding: 4, resource: { buffer: bufO } }, { binding: 5, resource: { buffer: bufD } } ], T, hd, B * heads);
|
| 407 |
[bufA, bufV, bufAs, bufVs, bufD, r.bufO, r.read].forEach(b => b.destroy());
|
| 408 |
return r.out;
|
| 409 |
}
|
|
@@ -441,8 +457,9 @@
|
|
| 441 |
return out;
|
| 442 |
}
|
| 443 |
|
| 444 |
-
// fused batched dispatch: f32 out, epilogue done on-device
|
| 445 |
-
|
|
|
|
| 446 |
const bytesO = batch * m * n * 4;
|
| 447 |
const bufO = mk(device, bytesO, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
|
| 448 |
const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: entries(bufO) });
|
|
@@ -454,7 +471,8 @@
|
|
| 454 |
enc.copyBufferToBuffer(bufO, 0, read, 0, bytesO);
|
| 455 |
device.queue.submit([enc.finish()]);
|
| 456 |
await read.mapAsync(GPUMapMode.READ);
|
| 457 |
-
const
|
|
|
|
| 458 |
read.unmap();
|
| 459 |
return { out, bufO, read };
|
| 460 |
}
|
|
@@ -470,7 +488,7 @@
|
|
| 470 |
{ binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
|
| 471 |
{ binding: 2, resource: { buffer: lutBuf } }, { binding: 3, resource: { buffer: bufR } },
|
| 472 |
{ binding: 4, resource: { buffer: bufS } }, { binding: 5, resource: { buffer: bufO } },
|
| 473 |
-
{ binding: 6, resource: { buffer: bufD } } ], m, n, batch);
|
| 474 |
[bufX, bufW, bufR, bufS, bufD, r.bufO, r.read].forEach(b => b.destroy());
|
| 475 |
return r.out;
|
| 476 |
}
|
|
@@ -492,7 +510,7 @@
|
|
| 492 |
const r = await runBgPass(device, pipeline, (bufO) => [
|
| 493 |
{ binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
|
| 494 |
{ binding: 2, resource: { buffer: bufR } }, { binding: 3, resource: { buffer: bufS } },
|
| 495 |
-
{ binding: 4, resource: { buffer: bufO } }, { binding: 5, resource: { buffer: bufD } } ], m, n, batch);
|
| 496 |
[bufX, bufW, bufR, bufS, bufD, r.bufO, r.read].forEach(b => b.destroy());
|
| 497 |
return r.out;
|
| 498 |
}
|
|
@@ -504,5 +522,37 @@
|
|
| 504 |
return b;
|
| 505 |
}
|
| 506 |
|
| 507 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
})(self);
|
|
|
|
| 35 |
C[row * n + col] = s;
|
| 36 |
}`;
|
| 37 |
|
| 38 |
+
// NOTE: the un-batched DP4A matmul that used to live here was removed. It was
|
| 39 |
+
// the only kernel with an exact gate, but the transformer stopped calling it
|
| 40 |
+
// when the block-scaled path landed — so it sat here passing its own gate
|
| 41 |
+
// while verifying nothing that ran. A gate on a kernel nobody calls is worse
|
| 42 |
+
// than no gate: it reads like coverage. The batched kernels below are the
|
| 43 |
+
// ones training uses, and they now carry that exact gate instead.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
// Batched block-scaled GEMM with a FUSED EPILOGUE (CUTLASS ex. 05/24 + 12):
|
| 46 |
// grid z = batch index, so ALL attention heads run in ONE dispatch, and the
|
| 47 |
// epilogue (block dequant rs·cs + optional ReLU) happens before the data
|
| 48 |
// leaves the GPU — f32 out, no int32 readback, no second pass in JS.
|
| 49 |
+
// Each kernel is emitted in two variants from ONE source string: the live one
|
| 50 |
+
// (fused epilogue, f32 out) and a `verify` one that writes the raw int32
|
| 51 |
+
// accumulator instead. The indexing — the part that actually goes wrong — is
|
| 52 |
+
// textually identical, so gating the verify variant genuinely gates the live
|
| 53 |
+
// kernel, and the comparison is EXACT (int8xint8->int32 has no rounding to
|
| 54 |
+
// hide in) instead of an allclose that whole bug classes walk through.
|
| 55 |
+
const OUT_DECL = (v, b) => `@group(0) @binding(${b}) var<storage, read_write> O : array<${v ? "i32" : "f32"}>;`;
|
| 56 |
+
|
| 57 |
+
const WGSL_BG_LUT = (verify) => `
|
| 58 |
@group(0) @binding(0) var<storage, read> Xq : array<i32>; // int8 byte per elem
|
| 59 |
@group(0) @binding(1) var<storage, read> Wq : array<i32>;
|
| 60 |
@group(0) @binding(2) var<storage, read> lut : array<i32>;
|
| 61 |
@group(0) @binding(3) var<storage, read> rs : array<f32>; // per (batch,row)
|
| 62 |
@group(0) @binding(4) var<storage, read> cs : array<f32>; // per (batch,col)
|
| 63 |
+
${OUT_DECL(verify, 5)}
|
| 64 |
@group(0) @binding(6) var<uniform> dims : vec4<u32>; // m, k, n, flags(1=relu)
|
| 65 |
@compute @workgroup_size(8, 8, 1)
|
| 66 |
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
|
|
|
| 75 |
let bu = u32(Wq[wo + p * n] & 255);
|
| 76 |
s = s + lut[au * 256u + bu];
|
| 77 |
}
|
| 78 |
+
${verify ? `O[(bz * m + row) * n + col] = s;` : `
|
| 79 |
var v = f32(s) * rs[bz * m + row] * cs[bz * n + col];
|
| 80 |
if ((dims.w & 1u) == 1u && v < 0.0) { v = 0.0; }
|
| 81 |
+
O[(bz * m + row) * n + col] = v;`}
|
| 82 |
}`;
|
| 83 |
|
| 84 |
// same fused/batched kernel on the DP4A hardware path (Wᵀ packed per batch)
|
| 85 |
+
const WGSL_BG_DP4 = (verify) => `
|
| 86 |
@group(0) @binding(0) var<storage, read> Xp : array<u32>;
|
| 87 |
@group(0) @binding(1) var<storage, read> Wp : array<u32>; // per-batch Wᵀ, packed
|
| 88 |
@group(0) @binding(2) var<storage, read> rs : array<f32>;
|
| 89 |
@group(0) @binding(3) var<storage, read> cs : array<f32>;
|
| 90 |
+
${OUT_DECL(verify, 4)}
|
| 91 |
@group(0) @binding(5) var<uniform> dims : vec4<u32>; // m, kw, n, flags
|
| 92 |
@compute @workgroup_size(8, 8, 1)
|
| 93 |
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
|
|
|
| 100 |
for (var p = 0u; p < kw; p = p + 1u) {
|
| 101 |
s = s + dot4I8Packed(Xp[xo + p], Wp[wo + p]);
|
| 102 |
}
|
| 103 |
+
${verify ? `O[(bz * m + row) * n + col] = s;` : `
|
| 104 |
var v = f32(s) * rs[bz * m + row] * cs[bz * n + col];
|
| 105 |
if ((dims.w & 1u) == 1u && v < 0.0) { v = 0.0; }
|
| 106 |
+
O[(bz * m + row) * n + col] = v;`}
|
| 107 |
}`;
|
| 108 |
|
| 109 |
// Gather-fused attention (CUTLASS ex. 36/52): kernels index q/k/v directly in
|
| 110 |
// their BT×C layout (head-strided) — no gather copies, no kᵀ transpose — and
|
| 111 |
// the ctx kernel scatters straight back into BT×C. int8×int8→i32 is exact, so
|
| 112 |
// these are bit-identical to the LUT mirrors (proved at init before use).
|
| 113 |
+
const WGSL_ATT_SCORES = (verify) => `
|
| 114 |
@group(0) @binding(0) var<storage, read> Q : array<i32>; // int8 per elem, BT×C
|
| 115 |
@group(0) @binding(1) var<storage, read> K : array<i32>;
|
| 116 |
@group(0) @binding(2) var<storage, read> qs : array<f32>; // per (token,head)
|
| 117 |
@group(0) @binding(3) var<storage, read> ks : array<f32>;
|
| 118 |
+
${OUT_DECL(verify, 4)}
|
| 119 |
@group(0) @binding(5) var<uniform> dims : vec4<u32>; // T, heads, hd, _
|
| 120 |
@compute @workgroup_size(8, 8, 1)
|
| 121 |
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
|
|
|
| 128 |
let ko = (bi * T + tj) * C + h * hd;
|
| 129 |
var s : i32 = 0;
|
| 130 |
for (var p = 0u; p < hd; p = p + 1u) { s = s + Q[qo + p] * K[ko + p]; }
|
| 131 |
+
${verify ? `O[(bz * T + ti) * T + tj] = s;`
|
| 132 |
+
: `O[(bz * T + ti) * T + tj] = f32(s) * qs[(bi * T + ti) * heads + h] * ks[(bi * T + tj) * heads + h];`}
|
| 133 |
}`;
|
| 134 |
+
const WGSL_ATT_CTX = (verify) => `
|
| 135 |
@group(0) @binding(0) var<storage, read> A : array<i32>; // int8, BH×T×T
|
| 136 |
@group(0) @binding(1) var<storage, read> V : array<i32>; // int8, BT×C
|
| 137 |
@group(0) @binding(2) var<storage, read> as_ : array<f32>; // per (bz,row)
|
| 138 |
@group(0) @binding(3) var<storage, read> vs : array<f32>; // per (batch,head,chan)
|
| 139 |
+
${OUT_DECL(verify, 4)} // BT×C (scatter fused)
|
| 140 |
@group(0) @binding(5) var<uniform> dims : vec4<u32>; // T, heads, hd, _
|
| 141 |
@compute @workgroup_size(8, 8, 1)
|
| 142 |
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
|
|
|
| 148 |
let ao = (bz * T + ti) * T;
|
| 149 |
var s : i32 = 0;
|
| 150 |
for (var tj = 0u; tj < T; tj = tj + 1u) { s = s + A[ao + tj] * V[(bi * T + tj) * C + h * hd + j]; }
|
| 151 |
+
${verify ? `O[(bi * T + ti) * C + h * hd + j] = s;`
|
| 152 |
+
: `O[(bi * T + ti) * C + h * hd + j] = f32(s) * as_[bz * T + ti] * vs[(bi * heads + h) * hd + j];`}
|
| 153 |
}`;
|
| 154 |
|
| 155 |
// Split-K f32 GEMM (CUTLASS ex. 06) for the STE BACKWARD only — the backward
|
|
|
|
| 237 |
const lut32 = new Int32Array(L.mul); // widen int16 -> int32
|
| 238 |
const lutBuf = device.createBuffer({ size: lut32.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
| 239 |
device.queue.writeBuffer(lutBuf, 0, lut32);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
const mkPipe = (code) => device.createComputePipeline({ layout: "auto",
|
| 241 |
compute: { module: device.createShaderModule({ code }), entryPoint: "main" } });
|
| 242 |
+
// The verify variant doesn't reference the scale buffers, so `layout:auto`
|
| 243 |
+
// would strip those bindings and the bind group would silently mismatch.
|
| 244 |
+
// An EXPLICIT layout keeps both variants binding-compatible — which is the
|
| 245 |
+
// point: they must differ only in the final write, nothing else.
|
| 246 |
+
const mkLayout = (spec) => device.createBindGroupLayout({
|
| 247 |
+
entries: spec.map((t, i) => ({ binding: i, visibility: GPUShaderStage.COMPUTE,
|
| 248 |
+
buffer: { type: t === "u" ? "uniform" : t === "rw" ? "storage" : "read-only-storage" } })) });
|
| 249 |
+
const mkPipeL = (code, layout) => device.createComputePipeline({
|
| 250 |
+
layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
|
| 251 |
+
compute: { module: device.createShaderModule({ code }), entryPoint: "main" } });
|
| 252 |
+
const bgLutLayout = mkLayout(["r", "r", "r", "r", "r", "rw", "u"]);
|
| 253 |
+
const bgDp4Layout = mkLayout(["r", "r", "r", "r", "rw", "u"]);
|
| 254 |
+
const attLayout = mkLayout(["r", "r", "r", "r", "rw", "u"]);
|
| 255 |
+
// live + verify variants, compiled from the same source (see WGSL_* above)
|
| 256 |
+
const bgLutPipe = mkPipeL(WGSL_BG_LUT(false), bgLutLayout), bgLutVPipe = mkPipeL(WGSL_BG_LUT(true), bgLutLayout);
|
| 257 |
+
const scoresPipe = mkPipeL(WGSL_ATT_SCORES(false), attLayout), scoresVPipe = mkPipeL(WGSL_ATT_SCORES(true), attLayout);
|
| 258 |
+
const ctxPipe = mkPipeL(WGSL_ATT_CTX(false), attLayout), ctxVPipe = mkPipeL(WGSL_ATT_CTX(true), attLayout);
|
| 259 |
+
|
| 260 |
+
// gather-fused attention kernels. The gate runs the VERIFY variant of the
|
| 261 |
+
// same source and compares the int32 accumulator with `!==` — exact, no
|
| 262 |
+
// tolerance — then checks the fused epilogue against a bit-exact JS mirror
|
| 263 |
+
// of the WGSL rounding. Swept over several shapes, incl. odd/ragged ones,
|
| 264 |
+
// because head-strided addressing is where these kernels can go wrong.
|
| 265 |
+
let att = { scores: (qq, kq, qs, ks, d) => gpuAttScores(device, d.acc ? scoresVPipe : scoresPipe, qq, kq, qs, ks, d),
|
| 266 |
+
ctx: (aq, vq, as, vs, d) => gpuAttCtx(device, d.acc ? ctxVPipe : ctxPipe, aq, vq, as, vs, d) };
|
| 267 |
try {
|
| 268 |
+
for (const d0 of [{ B: 2, T: 8, heads: 2, hd: 8 }, { B: 1, T: 32, heads: 2, hd: 16 },
|
| 269 |
+
{ B: 3, T: 7, heads: 3, hd: 5 }, { B: 2, T: 33, heads: 4, hd: 8 }]) {
|
| 270 |
+
const nQ = d0.B * d0.T * d0.heads * d0.hd;
|
| 271 |
+
const qq = new Int8Array(nQ), kq = new Int8Array(nQ), vq = new Int8Array(nQ);
|
| 272 |
+
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; }
|
| 273 |
+
const qs = Float32Array.from({ length: d0.B * d0.T * d0.heads }, () => Math.random() + 0.5);
|
| 274 |
+
const ks = Float32Array.from({ length: d0.B * d0.T * d0.heads }, () => Math.random() + 0.5);
|
| 275 |
+
const aq = new Int8Array(d0.B * d0.heads * d0.T * d0.T);
|
| 276 |
+
for (let i = 0; i < aq.length; i++) aq[i] = (Math.random() * 127) | 0;
|
| 277 |
+
const as = Float32Array.from({ length: d0.B * d0.heads * d0.T }, () => Math.random() + 0.5);
|
| 278 |
+
const vs = Float32Array.from({ length: d0.B * d0.heads * d0.hd }, () => Math.random() + 0.5);
|
| 279 |
+
const dv = { ...d0, acc: true };
|
| 280 |
+
const [accS, accC, hwS, hwC] = await Promise.all([
|
| 281 |
+
att.scores(qq, kq, qs, ks, dv), att.ctx(aq, vq, as, vs, dv),
|
| 282 |
+
att.scores(qq, kq, qs, ks, d0), att.ctx(aq, vq, as, vs, d0)]);
|
| 283 |
+
const refAccS = root.Verified.attScoresJS(qq, kq, qs, ks, dv, L);
|
| 284 |
+
const refAccC = root.Verified.attCtxJS(aq, vq, as, vs, dv, L);
|
| 285 |
+
for (let i = 0; i < refAccS.length; i++) if (accS[i] !== refAccS[i]) throw new Error(`scores accumulator mismatch @${i} shape ${JSON.stringify(d0)}`);
|
| 286 |
+
for (let i = 0; i < refAccC.length; i++) if (accC[i] !== refAccC[i]) throw new Error(`ctx accumulator mismatch @${i} shape ${JSON.stringify(d0)}`);
|
| 287 |
+
const refS = root.Verified.attScoresJS(qq, kq, qs, ks, d0, L);
|
| 288 |
+
const refC = root.Verified.attCtxJS(aq, vq, as, vs, d0, L);
|
| 289 |
+
for (let i = 0; i < refS.length; i++) if (hwS[i] !== refS[i]) throw new Error(`scores epilogue mismatch @${i}`);
|
| 290 |
+
for (let i = 0; i < refC.length; i++) if (hwC[i] !== refC[i]) throw new Error(`ctx epilogue mismatch @${i}`);
|
| 291 |
+
}
|
| 292 |
} catch (e) {
|
| 293 |
console.warn("fused attention kernels failed verification — using CPU LUT mirrors:", e.message);
|
| 294 |
att = null;
|
|
|
|
| 310 |
fgemm = null;
|
| 311 |
}
|
| 312 |
|
| 313 |
+
// Shared exact gate for a bgemm implementation. Sweeps shapes (including
|
| 314 |
+
// ragged ones and a k long enough to matter), compares the int32
|
| 315 |
+
// accumulator from the verify variant with `!==`, then compares the fused
|
| 316 |
+
// f32 epilogue against the bit-exact JS mirror. Returns null if clean.
|
| 317 |
+
async function gateBgemm(bgFn) {
|
| 318 |
+
for (const d0 of [{ m: 5, k: 9, n: 6, batch: 3, relu: true },
|
| 319 |
+
{ m: 32, k: 64, n: 32, batch: 1, relu: false },
|
| 320 |
+
{ m: 7, k: 253, n: 5, batch: 2, relu: true },
|
| 321 |
+
{ m: 1, k: 4, n: 1, batch: 1, relu: false },
|
| 322 |
+
{ m: 17, k: 33, n: 9, batch: 1, relu: true }]) {
|
| 323 |
+
const Xq = new Int8Array(d0.batch * d0.m * d0.k), Wq = new Int8Array(d0.batch * d0.k * d0.n);
|
| 324 |
+
for (let i = 0; i < Xq.length; i++) Xq[i] = (Math.random() * 256 - 128) | 0;
|
| 325 |
+
for (let i = 0; i < Wq.length; i++) Wq[i] = (Math.random() * 256 - 128) | 0;
|
| 326 |
+
const rs = Float32Array.from({ length: d0.batch * d0.m }, () => Math.random() + 0.5);
|
| 327 |
+
const cs = Float32Array.from({ length: d0.batch * d0.n }, () => Math.random() + 0.5);
|
| 328 |
+
const shape = `${d0.m}x${d0.k}x${d0.n}b${d0.batch}`;
|
| 329 |
+
const accHw = await bgFn(Xq, Wq, rs, cs, { ...d0, acc: true });
|
| 330 |
+
const accRef = root.Verified.bgemmJS(Xq, Wq, rs, cs, { ...d0, acc: true }, L);
|
| 331 |
+
for (let i = 0; i < accRef.length; i++)
|
| 332 |
+
if (accHw[i] !== accRef[i]) return `accumulator mismatch @${i} (${shape}): ${accHw[i]} vs ${accRef[i]}`;
|
| 333 |
+
const hw = await bgFn(Xq, Wq, rs, cs, d0);
|
| 334 |
+
const ref = root.Verified.bgemmJS(Xq, Wq, rs, cs, d0, L);
|
| 335 |
+
for (let i = 0; i < ref.length; i++)
|
| 336 |
+
if (hw[i] !== ref[i]) return `epilogue mismatch @${i} (${shape}): ${hw[i]} vs ${ref[i]}`;
|
| 337 |
+
}
|
| 338 |
+
return null;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
const bgLut = (Xq, Wq, rs, cs, d) => gpuBgemmLUT(device, d.acc ? bgLutVPipe : bgLutPipe, lutBuf, Xq, Wq, rs, cs, d);
|
| 342 |
+
// the LUT bgemm is the fallback AND the oracle's shader twin — gate it too
|
| 343 |
+
const lutBad = await gateBgemm(bgLut);
|
| 344 |
+
if (lutBad) { console.warn("LUT bgemm shader failed verification — CPU mirrors only:", lutBad); return cpu; }
|
| 345 |
+
const viaLUT = { backend: "webgpu", label: `${gpuName} (LUT shader · exact-gated)`,
|
| 346 |
matmulInt8: (Xq, Wq, m, k, n) => gpuMatmulLUT(device, lutPipe, lutBuf, Xq, Wq, m, k, n),
|
| 347 |
+
bgemm: bgLut, att, fgemm };
|
|
|
|
| 348 |
|
| 349 |
+
// DP4A pipeline — only if the WGSL feature exists AND its batched kernel
|
| 350 |
+
// reproduces the verified units exactly across the shape sweep
|
| 351 |
if (!(navigator.gpu.wgslLanguageFeatures && navigator.gpu.wgslLanguageFeatures.has("packed_4x8_integer_dot_product")))
|
| 352 |
return viaLUT;
|
| 353 |
+
const bgDp4Pipe = mkPipeL(WGSL_BG_DP4(false), bgDp4Layout), bgDp4VPipe = mkPipeL(WGSL_BG_DP4(true), bgDp4Layout);
|
| 354 |
+
const bg = (Xq, Wq, rs, cs, d) => gpuBgemmDP4(device, d.acc ? bgDp4VPipe : bgDp4Pipe, Xq, Wq, rs, cs, d);
|
| 355 |
+
const dp4Bad = await gateBgemm(bg);
|
| 356 |
+
if (dp4Bad) {
|
| 357 |
+
console.warn("batched DP4A disagreed with the verified units — using LUT bgemm:", dp4Bad);
|
| 358 |
+
return viaLUT;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
}
|
| 360 |
+
return { backend: "webgpu", label: `${gpuName} (DP4A int8 dot · exact-gated vs units)`,
|
| 361 |
+
bgemm: bg, att, fgemm };
|
| 362 |
} catch (e) { console.warn("WebGPU init failed, CPU fallback:", e); return cpu; }
|
| 363 |
}
|
| 364 |
|
|
|
|
| 394 |
return r.out;
|
| 395 |
}
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
// attention kernels: int8 (widened i32) in, f32 out, strided head indexing
|
| 398 |
async function gpuAttScores(device, pipeline, qq, kq, qs, ks, d) {
|
| 399 |
const { B, T, heads } = d;
|
|
|
|
| 405 |
const r = await runBgPass(device, pipeline, (bufO) => [
|
| 406 |
{ binding: 0, resource: { buffer: bufQ } }, { binding: 1, resource: { buffer: bufK } },
|
| 407 |
{ binding: 2, resource: { buffer: bufQs } }, { binding: 3, resource: { buffer: bufKs } },
|
| 408 |
+
{ binding: 4, resource: { buffer: bufO } }, { binding: 5, resource: { buffer: bufD } } ], T, T, B * heads, d.acc);
|
| 409 |
[bufQ, bufK, bufQs, bufKs, bufD, r.bufO, r.read].forEach(b => b.destroy());
|
| 410 |
return r.out;
|
| 411 |
}
|
|
|
|
| 419 |
const r = await runBgPass(device, pipeline, (bufO) => [
|
| 420 |
{ binding: 0, resource: { buffer: bufA } }, { binding: 1, resource: { buffer: bufV } },
|
| 421 |
{ binding: 2, resource: { buffer: bufAs } }, { binding: 3, resource: { buffer: bufVs } },
|
| 422 |
+
{ binding: 4, resource: { buffer: bufO } }, { binding: 5, resource: { buffer: bufD } } ], T, hd, B * heads, d.acc);
|
| 423 |
[bufA, bufV, bufAs, bufVs, bufD, r.bufO, r.read].forEach(b => b.destroy());
|
| 424 |
return r.out;
|
| 425 |
}
|
|
|
|
| 457 |
return out;
|
| 458 |
}
|
| 459 |
|
| 460 |
+
// fused batched dispatch: f32 out, epilogue done on-device (raw=true reads the
|
| 461 |
+
// verify variant's int32 accumulator instead)
|
| 462 |
+
async function runBgPass(device, pipeline, entries, m, n, batch, raw) {
|
| 463 |
const bytesO = batch * m * n * 4;
|
| 464 |
const bufO = mk(device, bytesO, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
|
| 465 |
const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: entries(bufO) });
|
|
|
|
| 471 |
enc.copyBufferToBuffer(bufO, 0, read, 0, bytesO);
|
| 472 |
device.queue.submit([enc.finish()]);
|
| 473 |
await read.mapAsync(GPUMapMode.READ);
|
| 474 |
+
const buf = read.getMappedRange().slice(0);
|
| 475 |
+
const out = raw ? new Int32Array(buf) : new Float32Array(buf);
|
| 476 |
read.unmap();
|
| 477 |
return { out, bufO, read };
|
| 478 |
}
|
|
|
|
| 488 |
{ binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
|
| 489 |
{ binding: 2, resource: { buffer: lutBuf } }, { binding: 3, resource: { buffer: bufR } },
|
| 490 |
{ binding: 4, resource: { buffer: bufS } }, { binding: 5, resource: { buffer: bufO } },
|
| 491 |
+
{ binding: 6, resource: { buffer: bufD } } ], m, n, batch, d.acc);
|
| 492 |
[bufX, bufW, bufR, bufS, bufD, r.bufO, r.read].forEach(b => b.destroy());
|
| 493 |
return r.out;
|
| 494 |
}
|
|
|
|
| 510 |
const r = await runBgPass(device, pipeline, (bufO) => [
|
| 511 |
{ binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
|
| 512 |
{ binding: 2, resource: { buffer: bufR } }, { binding: 3, resource: { buffer: bufS } },
|
| 513 |
+
{ binding: 4, resource: { buffer: bufO } }, { binding: 5, resource: { buffer: bufD } } ], m, n, batch, d.acc);
|
| 514 |
[bufX, bufW, bufR, bufS, bufD, r.bufO, r.read].forEach(b => b.destroy());
|
| 515 |
return r.out;
|
| 516 |
}
|
|
|
|
| 522 |
return b;
|
| 523 |
}
|
| 524 |
|
| 525 |
+
// ---- canonical kernel probe -------------------------------------------------
|
| 526 |
+
// The weight hash CANNOT catch a device whose kernel is wrong: weights only
|
| 527 |
+
// depend on the gradient bytes everyone receives, so a fleet averaging one
|
| 528 |
+
// device's bad gradient stays bit-identical and perfectly happy. This is the
|
| 529 |
+
// check that can. Every device runs the SAME seeded int8 GEMM through its own
|
| 530 |
+
// live kernel (verify variant -> raw int32 accumulator, exact on every
|
| 531 |
+
// backend — GPU DP4A, GPU LUT, CPU mirror alike) and hashes the result. Same
|
| 532 |
+
// input + correct kernels => same hash, regardless of hardware. A device that
|
| 533 |
+
// disagrees is computing different arithmetic than the rest of the fleet.
|
| 534 |
+
const PROBE = { m: 24, k: 96, n: 24, batch: 2 };
|
| 535 |
+
function probeInputs() {
|
| 536 |
+
let seed = 0x5EED; // fixed: identical on every device
|
| 537 |
+
const rnd = () => { seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
| 538 |
+
const { m, k, n, batch } = PROBE;
|
| 539 |
+
const Xq = new Int8Array(batch * m * k), Wq = new Int8Array(batch * k * n);
|
| 540 |
+
for (let i = 0; i < Xq.length; i++) Xq[i] = Math.round(rnd() * 254 - 127);
|
| 541 |
+
for (let i = 0; i < Wq.length; i++) Wq[i] = Math.round(rnd() * 254 - 127);
|
| 542 |
+
const rs = Float32Array.from({ length: batch * m }, () => 1);
|
| 543 |
+
const cs = Float32Array.from({ length: batch * n }, () => 1);
|
| 544 |
+
return { Xq, Wq, rs, cs, d: { ...PROBE, acc: true } };
|
| 545 |
+
}
|
| 546 |
+
async function kernelProbe(compute, L) {
|
| 547 |
+
const { Xq, Wq, rs, cs, d } = probeInputs();
|
| 548 |
+
const out = compute && compute.bgemm
|
| 549 |
+
? await compute.bgemm(Xq, Wq, rs, cs, d)
|
| 550 |
+
: root.Verified.bgemmJS(Xq, Wq, rs, cs, d, L);
|
| 551 |
+
let h = 0x811c9dc5; // FNV-1a over the exact int32 results
|
| 552 |
+
const b = new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
|
| 553 |
+
for (let i = 0; i < b.length; i++) { h ^= b[i]; h = Math.imul(h, 0x01000193); }
|
| 554 |
+
return h >>> 0;
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
root.Compute = { initCompute, loadLUTs, kernelProbe };
|
| 558 |
})(self);
|
web/test_gates.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Mutation test for the kernel admission gate.
|
| 2 |
+
//
|
| 3 |
+
// A gate that has never rejected anything is decoration. This injects deliberately
|
| 4 |
+
// buggy "kernels" and asserts the gate rejects each one — and, separately, shows
|
| 5 |
+
// two things the OLD gate (single tiny shape, allclose) let through.
|
| 6 |
+
//
|
| 7 |
+
// Runs in Node against the CPU mirrors, so the buggy kernels stand in for what a
|
| 8 |
+
// wrong GPU shader would produce.
|
| 9 |
+
const fs = require("fs");
|
| 10 |
+
const path = require("path");
|
| 11 |
+
const V = require("./public/verified_core.js");
|
| 12 |
+
|
| 13 |
+
const mul = new Int16Array(fs.readFileSync(path.join(__dirname, "public", "mul_lut.bin")).buffer.slice(0));
|
| 14 |
+
const L = { mul };
|
| 15 |
+
|
| 16 |
+
// ---- the gate under test (mirrors webgpu.js gateBgemm) ----------------------
|
| 17 |
+
const SWEEP = [
|
| 18 |
+
{ m: 5, k: 9, n: 6, batch: 3, relu: true },
|
| 19 |
+
{ m: 32, k: 64, n: 32, batch: 1, relu: false },
|
| 20 |
+
{ m: 7, k: 253, n: 5, batch: 2, relu: true },
|
| 21 |
+
{ m: 1, k: 4, n: 1, batch: 1, relu: false },
|
| 22 |
+
{ m: 17, k: 33, n: 9, batch: 1, relu: true },
|
| 23 |
+
];
|
| 24 |
+
const OLD_SHAPE = [{ m: 5, k: 9, n: 6, batch: 3, relu: true }]; // what we used to test
|
| 25 |
+
const rnd = (n, f) => { const a = new Int8Array(n); for (let i = 0; i < n; i++) a[i] = f(); return a; };
|
| 26 |
+
|
| 27 |
+
function inputs(d) {
|
| 28 |
+
const Xq = rnd(d.batch * d.m * d.k, () => (Math.random() * 256 - 128) | 0);
|
| 29 |
+
const Wq = rnd(d.batch * d.k * d.n, () => (Math.random() * 256 - 128) | 0);
|
| 30 |
+
const rs = Float32Array.from({ length: d.batch * d.m }, () => Math.random() + 0.5);
|
| 31 |
+
const cs = Float32Array.from({ length: d.batch * d.n }, () => Math.random() + 0.5);
|
| 32 |
+
return { Xq, Wq, rs, cs };
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// exact gate: int32 accumulator with !==, then f32 epilogue with !==
|
| 36 |
+
function gateExact(kernel, shapes) {
|
| 37 |
+
for (const d of shapes) {
|
| 38 |
+
const { Xq, Wq, rs, cs } = inputs(d);
|
| 39 |
+
const accHw = kernel(Xq, Wq, rs, cs, { ...d, acc: true }, L);
|
| 40 |
+
const accRef = V.bgemmJS(Xq, Wq, rs, cs, { ...d, acc: true }, L);
|
| 41 |
+
for (let i = 0; i < accRef.length; i++) if (accHw[i] !== accRef[i]) return `acc @${i}`;
|
| 42 |
+
const hw = kernel(Xq, Wq, rs, cs, d, L);
|
| 43 |
+
const ref = V.bgemmJS(Xq, Wq, rs, cs, d, L);
|
| 44 |
+
for (let i = 0; i < ref.length; i++) if (hw[i] !== ref[i]) return `epilogue @${i}`;
|
| 45 |
+
}
|
| 46 |
+
return null;
|
| 47 |
+
}
|
| 48 |
+
// the old gate: one shape, allclose on the fused f32 output only
|
| 49 |
+
function gateOld(kernel) {
|
| 50 |
+
for (const d of OLD_SHAPE) {
|
| 51 |
+
const { Xq, Wq, rs, cs } = inputs(d);
|
| 52 |
+
const hw = kernel(Xq, Wq, rs, cs, d, L);
|
| 53 |
+
const ref = V.bgemmJS(Xq, Wq, rs, cs, d, L);
|
| 54 |
+
for (let i = 0; i < ref.length; i++)
|
| 55 |
+
if (Math.abs(hw[i] - ref[i]) > Math.abs(ref[i]) * 1e-6 + 1e-6) return `allclose @${i}`;
|
| 56 |
+
}
|
| 57 |
+
return null;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// ---- buggy kernels ----------------------------------------------------------
|
| 61 |
+
// Each is bgemmJS with exactly one realistic defect injected.
|
| 62 |
+
function mutant(bug) {
|
| 63 |
+
return function (Xq, Wq, rs, cs, d, LL) {
|
| 64 |
+
const { m, k, n } = d, batch = d.batch || 1, relu = !!d.relu, mulT = LL.mul;
|
| 65 |
+
const raw = !!d.acc;
|
| 66 |
+
const out = raw ? new Int32Array(batch * m * n) : new Float32Array(batch * m * n);
|
| 67 |
+
const acc = new Int32Array(n);
|
| 68 |
+
for (let bz = 0; bz < batch; bz++) {
|
| 69 |
+
const xo = bz * m * k, oo = bz * m * n, co = bz * n;
|
| 70 |
+
// BUG: batch stride dropped on W — invisible unless batch > 1
|
| 71 |
+
const wo = bug === "batchStride" ? 0 : bz * k * n;
|
| 72 |
+
for (let i = 0; i < m; i++) {
|
| 73 |
+
acc.fill(0);
|
| 74 |
+
const xrow = xo + i * k;
|
| 75 |
+
// BUG: last element of the dot product skipped
|
| 76 |
+
const kEnd = bug === "shortK" ? k - 1 : k;
|
| 77 |
+
for (let p = 0; p < kEnd; p++) {
|
| 78 |
+
const au = (Xq[xrow + p] & 0xFF) * 256, wrow = wo + p * n;
|
| 79 |
+
for (let j = 0; j < n; j++) acc[j] += mulT[au + (Wq[wrow + j] & 0xFF)];
|
| 80 |
+
}
|
| 81 |
+
const orow = oo + i * n;
|
| 82 |
+
if (raw) { for (let j = 0; j < n; j++) out[orow + j] = acc[j]; continue; }
|
| 83 |
+
const rscale = rs[bz * m + i];
|
| 84 |
+
for (let j = 0; j < n; j++) {
|
| 85 |
+
// BUG: column scale ignored / ReLU dropped / f64 rounding (the real one)
|
| 86 |
+
let v;
|
| 87 |
+
if (bug === "missingColScale") v = V.epi(acc[j], rscale, 1);
|
| 88 |
+
else if (bug === "f64Rounding") v = acc[j] * rscale * cs[co + j]; // rounds once, not thrice
|
| 89 |
+
else v = V.epi(acc[j], rscale, cs[co + j]);
|
| 90 |
+
out[orow + j] = (relu && bug !== "noRelu" && v < 0) ? 0 : v;
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
return out;
|
| 95 |
+
};
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
let pass = true;
|
| 99 |
+
const ok = (cond, msg) => { console.log(`${cond ? " ok " : " FAIL"} ${msg}`); if (!cond) pass = false; };
|
| 100 |
+
|
| 101 |
+
console.log("\nthe gate must reject every injected bug:");
|
| 102 |
+
for (const bug of ["batchStride", "shortK", "missingColScale", "noRelu", "f64Rounding"])
|
| 103 |
+
ok(gateExact(mutant(bug), SWEEP) !== null, `rejects ${bug} (${gateExact(mutant(bug), SWEEP) || "NOT CAUGHT"})`);
|
| 104 |
+
|
| 105 |
+
console.log("\nthe gate must accept the correct kernel:");
|
| 106 |
+
ok(gateExact(V.bgemmJS, SWEEP) === null, "accepts the real mirror across the shape sweep");
|
| 107 |
+
|
| 108 |
+
console.log("\nwhat the OLD gate (one tiny shape, allclose) let through:");
|
| 109 |
+
const oldMissedRounding = gateOld(mutant("f64Rounding")) === null;
|
| 110 |
+
const newCatchesRounding = gateExact(mutant("f64Rounding"), SWEEP) !== null;
|
| 111 |
+
ok(oldMissedRounding && newCatchesRounding,
|
| 112 |
+
"f64-vs-f32 epilogue rounding: allclose PASSES it, exact gate catches it");
|
| 113 |
+
const oldMissedStride = gateOld(mutant("batchStride")) === null;
|
| 114 |
+
console.log(` note batchStride under the old gate: ${oldMissedStride ? "MISSED" : "caught (batch>1 was in the shape)"}`);
|
| 115 |
+
|
| 116 |
+
console.log(pass ? "\nGATE TEST PASSED — the gate rejects real bugs and accepts the real kernel."
|
| 117 |
+
: "\nGATE TEST FAILED");
|
| 118 |
+
process.exit(pass ? 0 : 1);
|