Quazim0t0 commited on
Commit
4a8b60f
·
verified ·
1 Parent(s): 12e3142

web: B2B MLP chain (CUTLASS ex. 13 two-GEMM fusion + ex. 23 epilogue reduction), test_b2b.js, ten-suite npm test

Browse files
web/TEST_RESULTS.md CHANGED
@@ -108,6 +108,40 @@ width 32 (the shared operand is only 32 KB there — the saved quantize work
108
  scales quadratically with model width). Two-device live run: both replicas
109
  at step 71/300 with identical loss to the last digit, no sync-guard trips.
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  One bug was caught during the rework, by the bit-identity check itself: the
112
  fused q+k+v sum initially ran in f64 and rounded once, where the old code
113
  rounded to f32 after each add — a last-ulp fork that would have split
 
108
  scales quadratically with model width). Two-device live run: both replicas
109
  at step 71/300 with identical loss to the last digit, no sync-guard trips.
110
 
111
+ ## B2B MLP chain (CUTLASS ex. 13 two-GEMM fusion + ex. 23 epilogue reduction)
112
+
113
+ The MLP's two GEMMs now run back-to-back on the GPU: gemm1 (ReLU fused) and a
114
+ per-row |max| reduction share one command encoder, ~1 KB of absmax comes back
115
+ to JS (scale derivation needs division, which WGSL only guarantees to 2.5 ULP
116
+ — JS f64 division is exactly rounded and device-identical), then h1 is
117
+ quantized ON-DEVICE and fed straight to gemm2. h1 returns to JS only because
118
+ the STE backward needs it; it never goes up again.
119
+
120
+ This required respeccing the intermediate quantize from `round(x / scale)` to
121
+ `floor(f32(x * invScale) + 0.5)` — WGSL multiply/add are correctly rounded and
122
+ floor/clamp exact, so the GPU kernel and the fround-stepped JS mirror agree
123
+ bit-for-bit, and CPU-fallback devices run the mirror so mixed fleets stay
124
+ bit-identical. The respec is a real (bounded) math change: old and new builds
125
+ cannot co-train, and the per-step divergence guard stops such mixed groups.
126
+
127
+ `test_b2b.js` (Node):
128
+
129
+ ```
130
+ scales from the fused absmax bit-identical to quantizeRows (3958 rows incl. zero rows)
131
+ respec moves an int8 by at most 1 step (1/112363 = 0.001% of values moved)
132
+ chain gemm1 (hence h1 and the ReLU mask) byte-identical to the un-chained GEMM
133
+ chain output equals the manual composition of its stages; deterministic
134
+ convergence unchanged: old 2.0500 vs new 2.0512 final loss (0.1% apart, 40 steps)
135
+ ```
136
+
137
+ On GPU: both chain variants (LUT shader and DP4A) pass an exact `!==` init
138
+ gate against the mirror chain over ragged shapes including pack-tail padding.
139
+ Discriminating proof that the kernel implements the RESPEC and not the old
140
+ spec: a searched-for boundary input (int8 68→69 under the respec) run through
141
+ the GPU chain matches the new-spec mirror exactly and differs from the
142
+ old-spec composition. Two-device live run: step 141/300 with identical loss
143
+ (6.23958) on both replicas, per-step weight-hash divergence checks silent.
144
+
145
  One bug was caught during the rework, by the bit-identity check itself: the
146
  fused q+k+v sum initially ran in f64 and rounded once, where the old code
147
  rounded to f32 after each add — a last-ulp fork that would have split
web/package.json CHANGED
@@ -6,7 +6,7 @@
6
  "author": "Dean Byrne (Quazim0t0) / DaisyChainAI",
7
  "scripts": {
8
  "start": "node server.js",
9
- "test": "node test_core.js && node test_verified.js && node test_ieee.js && node test_gates.js && node test_metamorphic.js && node test_corpus.js && node test_optimizer.js && node test_transformer.js && node test_unit_backward.js"
10
  },
11
  "dependencies": {
12
  "hyparquet": "^1.26.2",
 
6
  "author": "Dean Byrne (Quazim0t0) / DaisyChainAI",
7
  "scripts": {
8
  "start": "node server.js",
9
+ "test": "node test_core.js && node test_verified.js && node test_ieee.js && node test_gates.js && node test_metamorphic.js && node test_corpus.js && node test_b2b.js && node test_optimizer.js && node test_transformer.js && node test_unit_backward.js"
10
  },
11
  "dependencies": {
12
  "hyparquet": "^1.26.2",
web/public/transformer.js CHANGED
@@ -182,6 +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
  audit: audit || null, unitBackward: !!cfg.unitBackward },
186
  emb: add("emb", mk(vocabSize() * c, 0.08)),
187
  pos: add("pos", mk(cfg.t * c, 0.02)),
@@ -292,11 +293,16 @@
292
  for (let i = 0; i < x2.length; i++) x2[i] = x[i] + attnOut[i];
293
  cb.x2 = x2;
294
  const l2 = lnFwd(x2, BT, C); cb.ln2 = l2;
295
- const h1 = await vmm(l2.y, bl.W1, BT, C, hidden, ctx, true); // ReLU fused in the epilogue
 
 
 
 
 
 
296
  const mask = new Uint8Array(h1.length);
297
  for (let i = 0; i < h1.length; i++) if (h1[i] > 0) mask[i] = 1;
298
  cb.h1 = h1; cb.mask = mask;
299
- const mlpOut = await vmm(h1, bl.W2, BT, hidden, C, ctx);
300
  x = new Float32Array(BT * C);
301
  for (let i = 0; i < x.length; i++) x[i] = x2[i] + mlpOut[i];
302
  cache.blocks.push(cb);
 
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
+ mlp: (engine && engine.mlp) || null,
186
  audit: audit || null, unitBackward: !!cfg.unitBackward },
187
  emb: add("emb", mk(vocabSize() * c, 0.08)),
188
  pos: add("pos", mk(cfg.t * c, 0.02)),
 
293
  for (let i = 0; i < x2.length; i++) x2[i] = x[i] + attnOut[i];
294
  cb.x2 = x2;
295
  const l2 = lnFwd(x2, BT, C); cb.ln2 = l2;
296
+ // CUTLASS ex. 13 + 23: both MLP GEMMs run back-to-back on the GPU. The
297
+ // intermediate h1 is quantized ON-DEVICE (exact-gated respec — see
298
+ // vmlpBlock in verified_core.js) and only its per-row absmax (~1KB)
299
+ // visits JS between the GEMMs; h1 itself comes back solely because the
300
+ // STE backward needs it. CPU devices run the bit-identical mirror chain.
301
+ const { h1, out: mlpOut } = await V.vmlpBlock(l2.y, bl.W1, bl.W2,
302
+ { m: BT, k: C, h: hidden, n: C }, ctx.L, ctx.mlp, ctx.audit);
303
  const mask = new Uint8Array(h1.length);
304
  for (let i = 0; i < h1.length; i++) if (h1[i] > 0) mask[i] = 1;
305
  cb.h1 = h1; cb.mask = mask;
 
306
  x = new Float32Array(BT * C);
307
  for (let i = 0; i < x.length; i++) x[i] = x2[i] + mlpOut[i];
308
  cache.blocks.push(cb);
web/public/verified_core.js CHANGED
@@ -100,6 +100,82 @@
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
@@ -307,7 +383,8 @@
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);
 
100
  return { q, s };
101
  }
102
 
103
+ // ---- B2B MLP chain (CUTLASS ex. 13 two-GEMM fusion + ex. 23 epilogue
104
+ // reduction), respecced for cross-device exactness ---------------------------
105
+ // The MLP is the one back-to-back GEMM pair with no layernorm/softmax between
106
+ // (ReLU is already fused in the epilogue), so the intermediate h1 can be
107
+ // quantized ON the GPU and fed straight to the second GEMM. Two rules make
108
+ // that fleet-safe:
109
+ // 1. The per-row |max| (ex. 23) uses only comparisons — exact on any
110
+ // hardware, order-independent — and comes back to JS as ~1KB.
111
+ // 2. Scale DERIVATION (two divisions) stays in JS f64, which IEEE requires
112
+ // to be exactly rounded and is therefore identical on every device.
113
+ // WGSL division is only 2.5 ULP — a fork waiting to happen — but WGSL
114
+ // multiply/add are correctly rounded and floor/clamp are exact. So the
115
+ // quantize step is respecced from round(x / scale) to
116
+ // floor(f32(x * invScale) + 0.5) — floor(x+0.5) IS Math.round's tie
117
+ // rule — and the fround-stepped mirror below is bit-identical to the
118
+ // GPU kernel, which is exact-gated against it at init.
119
+ // NOTE this changes which int8 a value on a rounding boundary lands on
120
+ // (≤1 step) vs quantizeRows, so old and new builds cannot co-train — the
121
+ // divergence guard stops such mixed groups by design.
122
+ function rowAbsMax(X, rows, cols) {
123
+ const mx = new Float32Array(rows);
124
+ for (let r = 0; r < rows; r++) {
125
+ let m = 0;
126
+ for (let c = 0; c < cols; c++) { const a = Math.abs(X[r * cols + c]); if (a > m) m = a; }
127
+ mx[r] = m;
128
+ }
129
+ return mx;
130
+ }
131
+ function scalesFromAbsMax(mx) { // f64 divisions: exactly rounded, device-identical
132
+ const scale = new Float32Array(mx.length), inv = new Float32Array(mx.length);
133
+ for (let i = 0; i < mx.length; i++) {
134
+ scale[i] = Math.max(mx[i] / 127, 1e-8);
135
+ inv[i] = 1 / scale[i]; // recip of the STORED f32 scale
136
+ }
137
+ return { scale, inv };
138
+ }
139
+ function quantizeRowsInv(X, rows, cols, inv) { // bit-exact mirror of the GPU quantize kernel
140
+ const q = new Int8Array(rows * cols);
141
+ for (let r = 0; r < rows; r++) {
142
+ const iv = inv[r];
143
+ for (let c = 0; c < cols; c++) {
144
+ const n = Math.floor(f32(f32(X[r * cols + c] * iv) + 0.5));
145
+ q[r * cols + c] = n < -128 ? -128 : n > 127 ? 127 : n;
146
+ }
147
+ }
148
+ return q;
149
+ }
150
+ // the chained MLP: X @ W1 -> ReLU (fused) -> absmax -> quantize -> @ W2.
151
+ // d = { m, k, h, n }; gpuMlp (from webgpu.js) runs both GEMMs + the
152
+ // on-GPU quantize with one tiny absmax readback between; without it the CPU
153
+ // mirror chain runs — SAME math, so mixed GPU/CPU fleets stay bit-identical.
154
+ async function vmlpBlock(Xf, W1f, W2f, d, L, gpuMlp, audit) {
155
+ const x = quantizeRows(Xf, d.m, d.k);
156
+ const w1 = quantizeCols(W1f, d.k, d.h);
157
+ const w2 = quantizeCols(W2f, d.h, d.n);
158
+ if (gpuMlp) {
159
+ const r = await gpuMlp(x.q, w1.q, w2.q, x.s, w1.s, w2.s, d);
160
+ if (audit && audit.due()) {
161
+ // audit BOTH live GEMMs: gemm1 against the units directly; gemm2 by
162
+ // reconstructing its exact operand through the proven quantize mirror
163
+ const bad1 = auditTile(x.q, w1.q, x.s, w1.s, { m: d.m, k: d.k, n: d.h, relu: true }, r.h1, L, audit.cells);
164
+ if (bad1) { audit.fail("mlp gemm1: " + bad1); return r; }
165
+ const sc = scalesFromAbsMax(rowAbsMax(r.h1, d.m, d.h));
166
+ const hq = quantizeRowsInv(r.h1, d.m, d.h, sc.inv);
167
+ const bad2 = auditTile(hq, w2.q, sc.scale, w2.s, { m: d.m, k: d.h, n: d.n }, r.out, L, audit.cells);
168
+ if (bad2) audit.fail("mlp gemm2: " + bad2);
169
+ }
170
+ return r;
171
+ }
172
+ const h1 = bgemmJS(x.q, w1.q, x.s, w1.s, { m: d.m, k: d.k, n: d.h, batch: 1, relu: true }, L);
173
+ const sc = scalesFromAbsMax(rowAbsMax(h1, d.m, d.h));
174
+ const hq = quantizeRowsInv(h1, d.m, d.h, sc.inv);
175
+ const out = bgemmJS(hq, w2.q, sc.scale, w2.s, { m: d.m, k: d.h, n: d.n, batch: 1 }, L);
176
+ return { h1, out };
177
+ }
178
+
179
  // ---- epilogue mirror -------------------------------------------------------
180
  // BIT-EXACT mirror of the WGSL epilogue `f32(s) * a * b`. WGSL rounds to f32
181
  // after the int->float conversion and after EACH multiply; plain JS would do
 
383
  }
384
 
385
  const api = { quantize, quantize2, quantizeRows, quantizeCols, quantizeHeadCols, lutMatmulJS, lutMatmul3JS, lutMatmul3,
386
+ bgemmJS, vgemmBlock, auditTile, epi, attScoresJS, attCtxJS, linearFwd, forward, backward, splitApply,
387
+ rowAbsMax, scalesFromAbsMax, quantizeRowsInv, vmlpBlock };
388
  if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); module.exports = api; }
389
  else { TC = root.TrainCore; root.Verified = api; }
390
  })(typeof self !== "undefined" ? self : this);
web/public/webgpu.js CHANGED
@@ -35,6 +35,57 @@
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
@@ -252,6 +303,11 @@
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);
@@ -342,9 +398,34 @@
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
@@ -357,8 +438,12 @@
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
 
@@ -515,6 +600,85 @@
515
  return r.out;
516
  }
517
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
  function mk(device, size, usage) { return device.createBuffer({ size, usage }); }
519
  function up(device, arr, usage) {
520
  const b = mk(device, Math.max(16, arr.byteLength), usage);
 
35
  C[row * n + col] = s;
36
  }`;
37
 
38
+ // ---- B2B MLP chain kernels (CUTLASS ex. 13 + 23) ---------------------------
39
+ // ROWMAX: per-row |max| of a GEMM's f32 output, fused into the same command
40
+ // encoder (ex. 23 epilogue reduction). Non-negative f32 bit patterns order
41
+ // like u32, so atomicMax on bitcast(abs(v)) computes an EXACT max, in any
42
+ // execution order, on any hardware — nothing here can round.
43
+ const WGSL_ROWMAX = `
44
+ @group(0) @binding(0) var<storage, read> O : array<f32>;
45
+ @group(0) @binding(1) var<storage, read_write> MX : array<atomic<u32>>;
46
+ @group(0) @binding(2) var<uniform> dims : vec4<u32>; // m, n, _, _
47
+ @compute @workgroup_size(8, 8, 1)
48
+ fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
49
+ let m = dims.x; let n = dims.y;
50
+ let row = gid.x; let col = gid.y;
51
+ if (row >= m || col >= n) { return; }
52
+ atomicMax(&MX[row], bitcast<u32>(abs(O[row * n + col])));
53
+ }`;
54
+ // QUANT: h1 (f32, still on the GPU) -> int8 by MULTIPLY with a JS-computed
55
+ // inverse scale. floor(f32(x*inv)+0.5) uses only ops WGSL guarantees exact
56
+ // or correctly rounded (mul, add, floor, clamp) — division is 2.5 ULP and
57
+ // never runs on the GPU. Bit-identical to Verified.quantizeRowsInv, and
58
+ // exact-gated against it at init. pack=true emits 4 bytes per u32 for the
59
+ // DP4A kernel; pack=false emits one i32 per element for the LUT kernel.
60
+ const WGSL_QUANT = (pack) => `
61
+ @group(0) @binding(0) var<storage, read> H : array<f32>;
62
+ @group(0) @binding(1) var<storage, read> inv : array<f32>; // per row
63
+ @group(0) @binding(2) var<storage, read_write> Q : array<${pack ? "u32" : "i32"}>;
64
+ @group(0) @binding(3) var<uniform> dims : vec4<u32>; // m, k, kw, _
65
+ @compute @workgroup_size(64)
66
+ fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
67
+ let m = dims.x; let k = dims.y; let kw = dims.z;
68
+ let idx = gid.x;
69
+ ${pack ? `
70
+ if (idx >= m * kw) { return; }
71
+ let row = idx / kw;
72
+ var acc : u32 = 0u;
73
+ for (var b = 0u; b < 4u; b = b + 1u) {
74
+ let c = (idx % kw) * 4u + b;
75
+ var q : i32 = 0;
76
+ if (c < k) {
77
+ let v = clamp(floor(H[row * k + c] * inv[row] + 0.5), -128.0, 127.0);
78
+ q = i32(v);
79
+ }
80
+ acc = acc | ((u32(q) & 255u) << (8u * b));
81
+ }
82
+ Q[idx] = acc;` : `
83
+ if (idx >= m * k) { return; }
84
+ let row = idx / k;
85
+ let v = clamp(floor(H[idx] * inv[row] + 0.5), -128.0, 127.0);
86
+ Q[idx] = i32(v);`}
87
+ }`;
88
+
89
  // NOTE: the un-batched DP4A matmul that used to live here was removed. It was
90
  // the only kernel with an exact gate, but the transformer stopped calling it
91
  // when the block-scaled path landed — so it sat here passing its own gate
 
303
  const bgLutLayout = mkLayout(["r", "r", "r", "r", "r", "rw", "u"]);
304
  const bgDp4Layout = mkLayout(["r", "r", "r", "r", "rw", "u"]);
305
  const attLayout = mkLayout(["r", "r", "r", "r", "rw", "u"]);
306
+ const rowmaxLayout = mkLayout(["r", "rw", "u"]);
307
+ const quantLayout = mkLayout(["r", "r", "rw", "u"]);
308
+ const rowmaxPipe = mkPipeL(WGSL_ROWMAX, rowmaxLayout);
309
+ const quantI32Pipe = mkPipeL(WGSL_QUANT(false), quantLayout);
310
+ const quantPackPipe = mkPipeL(WGSL_QUANT(true), quantLayout);
311
  // live + verify variants, compiled from the same source (see WGSL_* above)
312
  const bgLutPipe = mkPipeL(WGSL_BG_LUT(false), bgLutLayout), bgLutVPipe = mkPipeL(WGSL_BG_LUT(true), bgLutLayout);
313
  const scoresPipe = mkPipeL(WGSL_ATT_SCORES(false), attLayout), scoresVPipe = mkPipeL(WGSL_ATT_SCORES(true), attLayout);
 
398
  // the LUT bgemm is the fallback AND the oracle's shader twin — gate it too
399
  const lutBad = await gateBgemm(bgLut);
400
  if (lutBad) { console.warn("LUT bgemm shader failed verification — CPU mirrors only:", lutBad); return cpu; }
401
+
402
+ // B2B MLP chain gate (CUTLASS ex. 13+23): run the WHOLE chain — gemm1 +
403
+ // ReLU + on-GPU rowmax + on-GPU quantize + gemm2 — against the pure-JS
404
+ // mirror chain, exact `!==` on both h1 and the final output. Sweeps
405
+ // ragged shapes; h not a multiple of 4 exercises the pack-tail padding.
406
+ async function gateMlp(mlpFn) {
407
+ for (const d0 of [{ m: 6, k: 8, h: 12, n: 5 }, { m: 5, k: 16, h: 6, n: 3 },
408
+ { m: 17, k: 33, h: 10, n: 9 }, { m: 32, k: 64, h: 128, n: 32 }]) {
409
+ const rnd = (len) => Float32Array.from({ length: len }, () => Math.random() * 2 - 1);
410
+ const Xf = rnd(d0.m * d0.k), W1 = rnd(d0.k * d0.h), W2 = rnd(d0.h * d0.n);
411
+ const hw = await root.Verified.vmlpBlock(Xf, W1, W2, d0, L, mlpFn, null);
412
+ const ref = await root.Verified.vmlpBlock(Xf, W1, W2, d0, L, null, null);
413
+ const shape = `${d0.m}x${d0.k}x${d0.h}x${d0.n}`;
414
+ for (let i = 0; i < ref.h1.length; i++)
415
+ if (hw.h1[i] !== ref.h1[i]) return `h1 mismatch @${i} (${shape}): ${hw.h1[i]} vs ${ref.h1[i]}`;
416
+ for (let i = 0; i < ref.out.length; i++)
417
+ if (hw.out[i] !== ref.out[i]) return `out mismatch @${i} (${shape}): ${hw.out[i]} vs ${ref.out[i]}`;
418
+ }
419
+ return null;
420
+ }
421
+ const lutMlpEnv = { dp4: false, gemm: bgLutPipe, rowmax: rowmaxPipe, quant: quantI32Pipe, lutBuf };
422
+ let mlpLut = (xq, w1q, w2q, xs, w1s, w2s, d) => gpuMlpChain(device, lutMlpEnv, xq, w1q, w2q, xs, w1s, w2s, d);
423
+ const mlpLutBad = await gateMlp(mlpLut);
424
+ if (mlpLutBad) { console.warn("B2B MLP chain (LUT) failed verification — MLP stays on the CPU mirror chain:", mlpLutBad); mlpLut = null; }
425
+
426
  const viaLUT = { backend: "webgpu", label: `${gpuName} (LUT shader · exact-gated)`,
427
  matmulInt8: (Xq, Wq, m, k, n) => gpuMatmulLUT(device, lutPipe, lutBuf, Xq, Wq, m, k, n),
428
+ bgemm: bgLut, att, fgemm, mlp: mlpLut };
429
 
430
  // DP4A pipeline — only if the WGSL feature exists AND its batched kernel
431
  // reproduces the verified units exactly across the shape sweep
 
438
  console.warn("batched DP4A disagreed with the verified units — using LUT bgemm:", dp4Bad);
439
  return viaLUT;
440
  }
441
+ const dp4MlpEnv = { dp4: true, gemm: bgDp4Pipe, rowmax: rowmaxPipe, quant: quantPackPipe };
442
+ let mlpDp4 = (xq, w1q, w2q, xs, w1s, w2s, d) => gpuMlpChain(device, dp4MlpEnv, xq, w1q, w2q, xs, w1s, w2s, d);
443
+ const mlpDp4Bad = await gateMlp(mlpDp4);
444
+ if (mlpDp4Bad) { console.warn("B2B MLP chain (DP4A) failed verification — using the LUT chain:", mlpDp4Bad); mlpDp4 = mlpLut; }
445
  return { backend: "webgpu", label: `${gpuName} (DP4A int8 dot · exact-gated vs units)`,
446
+ bgemm: bg, att, fgemm, mlp: mlpDp4 };
447
  } catch (e) { console.warn("WebGPU init failed, CPU fallback:", e); return cpu; }
448
  }
449
 
 
600
  return r.out;
601
  }
602
 
603
+ // B2B MLP chain: gemm1 (ReLU fused) + rowmax in one encoder, a 4·m-byte
604
+ // absmax readback, then quantize + gemm2 in a second encoder. h1 comes back
605
+ // because the STE backward needs it, but it never goes UP again — gemm2's
606
+ // left operand is produced and consumed entirely on the GPU.
607
+ async function gpuMlpChain(device, env, xq, w1q, w2q, xs, w1s, w2s, d) {
608
+ const { m, k, h, n } = d, dp4 = !!env.dp4;
609
+ const SU = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST;
610
+ let bufX, bufW1, bufW2, kw1, hw;
611
+ if (dp4) {
612
+ kw1 = Math.ceil(k / 4); hw = Math.ceil(h / 4);
613
+ bufX = up(device, packRows(xq, m, k, kw1), SU);
614
+ bufW1 = up(device, packRows(transposeI8(w1q, k, h), h, k, kw1), SU);
615
+ bufW2 = up(device, packRows(transposeI8(w2q, h, n), n, h, hw), SU);
616
+ } else {
617
+ kw1 = k; hw = h;
618
+ bufX = up(device, Int32Array.from(xq), SU);
619
+ bufW1 = up(device, Int32Array.from(w1q), SU);
620
+ bufW2 = up(device, Int32Array.from(w2q), SU);
621
+ }
622
+ const bufRs = up(device, xs, SU), bufCs1 = up(device, w1s, SU), bufCs2 = up(device, w2s, SU);
623
+ const bufH = mk(device, m * h * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
624
+ const bufMX = mk(device, m * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC); // zero-initialized
625
+ const UU = GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST;
626
+ const bufD1 = up(device, new Uint32Array([m, kw1, h, 1]), UU); // flags=1: fused ReLU
627
+ const bufDM = up(device, new Uint32Array([m, h, 0, 0]), UU);
628
+ const gemmBind = (bufA, bufB, bufR, bufC, bufO, bufD) => device.createBindGroup({
629
+ layout: env.gemm.getBindGroupLayout(0),
630
+ entries: (dp4 ? [bufA, bufB, bufR, bufC, bufO, bufD]
631
+ : [bufA, bufB, env.lutBuf, bufR, bufC, bufO, bufD])
632
+ .map((b, i) => ({ binding: i, resource: { buffer: b } })) });
633
+ const enc1 = device.createCommandEncoder();
634
+ let pass = enc1.beginComputePass();
635
+ pass.setPipeline(env.gemm);
636
+ pass.setBindGroup(0, gemmBind(bufX, bufW1, bufRs, bufCs1, bufH, bufD1));
637
+ pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(h / 8), 1); pass.end();
638
+ pass = enc1.beginComputePass();
639
+ pass.setPipeline(env.rowmax);
640
+ pass.setBindGroup(0, device.createBindGroup({ layout: env.rowmax.getBindGroupLayout(0), entries: [
641
+ { binding: 0, resource: { buffer: bufH } }, { binding: 1, resource: { buffer: bufMX } },
642
+ { binding: 2, resource: { buffer: bufDM } } ] }));
643
+ pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(h / 8), 1); pass.end();
644
+ const readH = mk(device, m * h * 4, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
645
+ const readM = mk(device, m * 4, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
646
+ enc1.copyBufferToBuffer(bufH, 0, readH, 0, m * h * 4);
647
+ enc1.copyBufferToBuffer(bufMX, 0, readM, 0, m * 4);
648
+ device.queue.submit([enc1.finish()]);
649
+ await Promise.all([readH.mapAsync(GPUMapMode.READ), readM.mapAsync(GPUMapMode.READ)]);
650
+ const h1 = new Float32Array(readH.getMappedRange().slice(0)); readH.unmap();
651
+ // the atomicMax'ed u32 bit patterns ARE the f32 |max| values
652
+ const mx = new Float32Array(readM.getMappedRange().slice(0)); readM.unmap();
653
+ // scale derivation in JS f64 — exactly rounded, identical on every device
654
+ // (WGSL division is 2.5 ULP, which is why it never runs on the GPU)
655
+ const sc = root.Verified.scalesFromAbsMax(mx);
656
+ const bufInv = up(device, sc.inv, SU), bufHs = up(device, sc.scale, SU);
657
+ const bufQ = mk(device, m * hw * 4, GPUBufferUsage.STORAGE);
658
+ const bufDQ = up(device, new Uint32Array([m, h, hw, 0]), UU);
659
+ const bufD2 = up(device, new Uint32Array([m, hw, n, 0]), UU);
660
+ const bufO = mk(device, m * n * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
661
+ const enc2 = device.createCommandEncoder();
662
+ pass = enc2.beginComputePass();
663
+ pass.setPipeline(env.quant);
664
+ pass.setBindGroup(0, device.createBindGroup({ layout: env.quant.getBindGroupLayout(0), entries: [
665
+ { binding: 0, resource: { buffer: bufH } }, { binding: 1, resource: { buffer: bufInv } },
666
+ { binding: 2, resource: { buffer: bufQ } }, { binding: 3, resource: { buffer: bufDQ } } ] }));
667
+ pass.dispatchWorkgroups(Math.ceil((m * (dp4 ? hw : h)) / 64)); pass.end();
668
+ pass = enc2.beginComputePass();
669
+ pass.setPipeline(env.gemm);
670
+ pass.setBindGroup(0, gemmBind(bufQ, bufW2, bufHs, bufCs2, bufO, bufD2));
671
+ pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(n / 8), 1); pass.end();
672
+ const readO = mk(device, m * n * 4, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
673
+ enc2.copyBufferToBuffer(bufO, 0, readO, 0, m * n * 4);
674
+ device.queue.submit([enc2.finish()]);
675
+ await readO.mapAsync(GPUMapMode.READ);
676
+ const out = new Float32Array(readO.getMappedRange().slice(0)); readO.unmap();
677
+ [bufX, bufW1, bufW2, bufRs, bufCs1, bufCs2, bufH, bufMX, bufD1, bufDM,
678
+ bufInv, bufHs, bufQ, bufDQ, bufD2, bufO, readH, readM, readO].forEach(b => b.destroy());
679
+ return { h1, out };
680
+ }
681
+
682
  function mk(device, size, usage) { return device.createBuffer({ size, usage }); }
683
  function up(device, arr, usage) {
684
  const b = mk(device, Math.max(16, arr.byteLength), usage);
web/test_b2b.js ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // B2B MLP chain (CUTLASS ex. 13 + 23): tests for the respecced quantize and
2
+ // the chained forward.
3
+ //
4
+ // What must hold EXACTLY:
5
+ // - the per-row absmax + scale derivation matches quantizeRows' internals
6
+ // bit for bit (ex. 23's reduction changes where max runs, not what it is)
7
+ // - the chain is deterministic and structurally equal to its parts
8
+ // - gemm1 (hence h1, hence the ReLU mask) is byte-identical to the old path
9
+ // What changes BY DESIGN (and must stay bounded):
10
+ // - quantizeRowsInv rounds via floor(f32(x*inv)+0.5) instead of
11
+ // round(x_f64/scale): every int8 moves by AT MOST one step, only on
12
+ // values that sit within float noise of a rounding boundary
13
+ // The GPU side of the same claims is enforced at init by gateMlp in webgpu.js
14
+ // (exact !== against the mirror chain, ragged shapes, pack-tail padding).
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+ const p = (f) => path.join(__dirname, "public", f);
18
+ const V = require("./public/verified_core.js");
19
+ const T = require("./public/traincore.js");
20
+ const OLD = require("./public/_transformer_prefusion.js");
21
+ const NEW = require("./public/transformer.js");
22
+ const L = { mul: new Int16Array(fs.readFileSync(p("mul_lut.bin")).buffer.slice(0)) };
23
+
24
+ const rnd = (len) => Float32Array.from({ length: len }, () => Math.random() * 4 - 2);
25
+ let pass = true;
26
+ const ok = (c, msg) => { console.log(`${c ? " ok " : " FAIL"} ${msg}`); if (!c) pass = false; };
27
+
28
+ (async () => {
29
+ // 1) ex. 23 reduction: absmax + scales identical to quantizeRows' own
30
+ {
31
+ let bad = 0, n = 0;
32
+ for (let t = 0; t < 200; t++) {
33
+ const rows = 1 + (Math.random() * 40 | 0), cols = 1 + (Math.random() * 90 | 0);
34
+ const X = rnd(rows * cols);
35
+ if (Math.random() < 0.1) for (let c = 0; c < cols; c++) X[c] = 0; // zero row -> 1e-8 floor
36
+ const sOld = V.quantizeRows(X, rows, cols).s;
37
+ const sNew = V.scalesFromAbsMax(V.rowAbsMax(X, rows, cols)).scale;
38
+ for (let i = 0; i < rows; i++) { n++; if (sOld[i] !== sNew[i]) bad++; }
39
+ }
40
+ ok(bad === 0, `scales from the fused absmax are bit-identical to quantizeRows (${n} rows incl. zero rows)`);
41
+ }
42
+ // 2) the respec moves an int8 by at most ONE step
43
+ {
44
+ let maxd = 0, diff = 0, n = 0;
45
+ for (let t = 0; t < 200; t++) {
46
+ const rows = 1 + (Math.random() * 30 | 0), cols = 1 + (Math.random() * 70 | 0);
47
+ const X = rnd(rows * cols);
48
+ const old = V.quantizeRows(X, rows, cols);
49
+ const inv = V.scalesFromAbsMax(V.rowAbsMax(X, rows, cols)).inv;
50
+ const q2 = V.quantizeRowsInv(X, rows, cols, inv);
51
+ for (let i = 0; i < q2.length; i++) {
52
+ n++;
53
+ const d = Math.abs(q2[i] - old.q[i]);
54
+ if (d) diff++;
55
+ if (d > maxd) maxd = d;
56
+ }
57
+ }
58
+ ok(maxd <= 1, `respecced quantize differs by at most 1 step (max ${maxd}; ${diff}/${n} = ${(100 * diff / n).toFixed(3)}% of values moved)`);
59
+ }
60
+ // 3) chain == its parts, and gemm1 is byte-identical to the un-chained GEMM
61
+ {
62
+ const d = { m: 17, k: 32, h: 20, n: 9 };
63
+ const X = rnd(d.m * d.k), W1 = rnd(d.k * d.h), W2 = rnd(d.h * d.n);
64
+ const r = await V.vmlpBlock(X, W1, W2, d, L, null, null);
65
+ const x = V.quantizeRows(X, d.m, d.k), w1 = V.quantizeCols(W1, d.k, d.h), w2 = V.quantizeCols(W2, d.h, d.n);
66
+ const h1ref = V.bgemmJS(x.q, w1.q, x.s, w1.s, { m: d.m, k: d.k, n: d.h, batch: 1, relu: true }, L);
67
+ let same = true;
68
+ for (let i = 0; i < h1ref.length; i++) if (r.h1[i] !== h1ref[i]) { same = false; break; }
69
+ ok(same, "chain gemm1 (hence h1 and the ReLU mask) is byte-identical to the un-chained GEMM");
70
+ const sc = V.scalesFromAbsMax(V.rowAbsMax(r.h1, d.m, d.h));
71
+ const outref = V.bgemmJS(V.quantizeRowsInv(r.h1, d.m, d.h, sc.inv), w2.q, sc.scale, w2.s, { m: d.m, k: d.h, n: d.n, batch: 1 }, L);
72
+ let same2 = true;
73
+ for (let i = 0; i < outref.length; i++) if (r.out[i] !== outref[i]) { same2 = false; break; }
74
+ ok(same2, "chain output equals the manual composition of its stages");
75
+ const r2 = await V.vmlpBlock(X, W1, W2, d, L, null, null);
76
+ ok(r.out.every((v, i) => v === r2.out[i]), "chain is deterministic (two runs bitwise equal)");
77
+ }
78
+ // 4) the transformer trains through the chain, and the spec change does not
79
+ // hurt convergence: same seeds, old forward vs chained forward
80
+ {
81
+ const cfg = { c: 32, t: 32, b: 8, layers: 2, heads: 2, steps: 40, lr: 0.01 };
82
+ const run = async (X) => {
83
+ const m = X.init(cfg, L, null);
84
+ const opt = T.makeAdam(m.nParams, { lr: cfg.lr });
85
+ let seed = 31337;
86
+ const orig = Math.random;
87
+ Math.random = () => { seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
88
+ let first = 0, last = 0;
89
+ for (let s = 0; s < cfg.steps; s++) {
90
+ const r = await X.trainStep(m);
91
+ if (s === 0) first = r.loss;
92
+ last = r.loss;
93
+ X.applyUpdate(m, opt.step(r.grad));
94
+ }
95
+ Math.random = orig;
96
+ return { first, last };
97
+ };
98
+ const a = await run(OLD), b = await run(NEW);
99
+ ok(a.first === b.first, `step-0 loss identical before any quantize divergence compounds (${a.first.toFixed(6)})`);
100
+ ok(b.last < b.first * 0.75, `chained transformer converges (${b.first.toFixed(4)} -> ${b.last.toFixed(4)})`);
101
+ const rel = Math.abs(a.last - b.last) / a.last;
102
+ ok(rel < 0.10, `convergence unchanged by the respec: old ${a.last.toFixed(4)} vs new ${b.last.toFixed(4)} (${(100 * rel).toFixed(1)}% apart)`);
103
+ console.log(" note the runs are NOT bit-identical past step 0 — the quantize respec is a real (bounded) spec change, which is why old and new builds must not co-train");
104
+ }
105
+ console.log(pass ? "\nB2B MLP CHAIN TEST PASSED" : "\nB2B MLP CHAIN TEST FAILED");
106
+ process.exit(pass ? 0 : 1);
107
+ })();