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 +34 -0
- web/package.json +1 -1
- web/public/transformer.js +8 -2
- web/public/verified_core.js +78 -1
- web/public/webgpu.js +166 -2
- web/test_b2b.js +107 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
})();
|