Quazim0t0 commited on
Commit
eea7ffd
·
verified ·
1 Parent(s): 1c10bf4

Probe failure halts, fragment cleanup, subnormal fgemm gate, live f32 audit, high-performance adapter (2026-10-14)

Browse files
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
- for (const k of fragIn.keys()) if (k.startsWith(id + ":")) fragIn.delete(k);
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
- const badK = roster.find(id => id !== myId && hs.has(id) && hs.get(id).phash &&
902
- probeHash && hs.get(id).phash !== probeHash);
 
 
 
 
 
 
 
 
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
- 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
 
 
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