File size: 8,323 Bytes
4a8b60f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b004b80
 
 
 
 
 
4a8b60f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54e1054
 
 
 
 
 
4a8b60f
 
54e1054
4a8b60f
 
 
 
 
 
 
 
 
 
 
 
54e1054
 
4a8b60f
0517008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a8b60f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b004b80
4a8b60f
b004b80
 
 
 
 
 
 
 
 
4a8b60f
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
// B2B MLP chain (CUTLASS ex. 13 + 23): tests for the respecced quantize and
// the chained forward.
//
// What must hold EXACTLY:
//   - the per-row absmax + scale derivation matches quantizeRows' internals
//     bit for bit (ex. 23's reduction changes where max runs, not what it is)
//   - the chain is deterministic and structurally equal to its parts
//   - gemm1 (hence h1, hence the ReLU mask) is byte-identical to the old path
// What changes BY DESIGN (and must stay bounded):
//   - quantizeRowsInv rounds via floor(f32(x*inv)+0.5) instead of
//     round(x_f64/scale): every int8 moves by AT MOST one step, only on
//     values that sit within float noise of a rounding boundary
// The GPU side of the same claims is enforced at init by gateMlp in webgpu.js
// (exact !== against the mirror chain, ragged shapes, pack-tail padding).
const fs = require("fs");
const path = require("path");
const p = (f) => path.join(__dirname, "public", f);
const V = require("./public/verified_core.js");
const T = require("./public/traincore.js");
const NEW = require("./public/transformer.js");
// The pre-chain transformer is a LOCAL regression baseline (not shipped —
// shipping a frozen copy of dead forward code would be the correctness
// illusion again). When absent, the old-vs-new convergence comparison is
// skipped and every self-contained assertion still runs.
let OLD = null;
try { OLD = require("./public/_transformer_prefusion.js"); } catch (e) {}
const L = { mul: new Int16Array(fs.readFileSync(p("mul_lut.bin")).buffer.slice(0)) };

const rnd = (len) => Float32Array.from({ length: len }, () => Math.random() * 4 - 2);
let pass = true;
const ok = (c, msg) => { console.log(`${c ? "  ok  " : "  FAIL"}  ${msg}`); if (!c) pass = false; };

(async () => {
  // 1) ex. 23 reduction: absmax + scales identical to quantizeRows' own
  {
    let bad = 0, n = 0;
    for (let t = 0; t < 200; t++) {
      const rows = 1 + (Math.random() * 40 | 0), cols = 1 + (Math.random() * 90 | 0);
      const X = rnd(rows * cols);
      if (Math.random() < 0.1) for (let c = 0; c < cols; c++) X[c] = 0;   // zero row -> 1e-8 floor
      const sOld = V.quantizeRows(X, rows, cols).s;
      const sNew = V.scalesFromAbsMax(V.rowAbsMax(X, rows, cols)).scale;
      for (let i = 0; i < rows; i++) { n++; if (sOld[i] !== sNew[i]) bad++; }
    }
    ok(bad === 0, `scales from the fused absmax are bit-identical to quantizeRows (${n} rows incl. zero rows)`);
  }
  // 2) the respec moves an int8 by at most ONE step — and must move at least
  //    one somewhere, or every "bit-identical to the mirror" claim downstream
  //    is untested. Boundary hits are ~1 in 10^5 values, so scan ADAPTIVELY:
  //    keep drawing until one is found (a fixed small sample made this test
  //    flip a coin — it failed 2 runs in 3, which is its own lesson about
  //    asserting the existence of rare events from bounded randomness).
  {
    let maxd = 0, diff = 0, n = 0;
    while (diff === 0 && n < 3_000_000) {
      const rows = 1 + (Math.random() * 30 | 0), cols = 1 + (Math.random() * 70 | 0);
      const X = rnd(rows * cols);
      const old = V.quantizeRows(X, rows, cols);
      const inv = V.scalesFromAbsMax(V.rowAbsMax(X, rows, cols)).inv;
      const q2 = V.quantizeRowsInv(X, rows, cols, inv);
      for (let i = 0; i < q2.length; i++) {
        n++;
        const d = Math.abs(q2[i] - old.q[i]);
        if (d) diff++;
        if (d > maxd) maxd = d;
      }
    }
    ok(maxd <= 1, `respecced quantize differs by at most 1 step (max ${maxd} over ${n} values)`);
    ok(diff > 0, `the respec is a real change (${diff} boundary value(s) in ${n} scanned) — the mirror equivalence is not vacuous`);
  }
  // 2b) fma-contraction immunity: WGSL permits contracting `x*inv + 0.5` into
  //     a hardware FMA (one rounding, e.g. RDNA2 V_FMA_F32), and no gate can
  //     forbid the compiler that choice. Prove it cannot matter: fused and
  //     stepped rounding differ only in the last ulp at binade crossings, and
  //     the RNE tie parity keeps both on the same side of every integer — so
  //     floor(), hence the int8, is identical. Assert (a) raw last-ulp
  //     differences DO occur (the claim is about floor, not vacuous absence
  //     of anomalies) and (b) not one of them survives floor.
  {
    const f32 = Math.fround;
    let raw = 0, fl = 0, n = 0;
    for (let k = 1; k <= 7; k++) {                 // binade edges 2..128, from below and above
      const u = Math.pow(2, k - 23);
      for (let t = 0; t < 400000; t++) {
        n++;
        const iv = f32(0.25 + Math.random() * 500);
        const x = f32((Math.pow(2, k) - 0.5 + (Math.random() * 8 - 6) * u) / iv);
        const p = x * iv;                           // f32×f32 is exact in f64 -> true fused input
        const two = f32(f32(p) + 0.5), fused = f32(p + 0.5);
        if (two !== fused) { raw++; if (Math.floor(two) !== Math.floor(fused)) fl++; }
      }
    }
    ok(raw > 0, `fused-vs-stepped last-ulp differences occur at binade edges (${raw} in ${n} draws) — the immunity claim is not vacuous`);
    ok(fl === 0, `none survives floor(): the +0.5 quantize is fma-contraction-immune (0/${raw} floor-visible)`);
  }
  // 3) chain == its parts, and gemm1 is byte-identical to the un-chained GEMM
  {
    const d = { m: 17, k: 32, h: 20, n: 9 };
    const X = rnd(d.m * d.k), W1 = rnd(d.k * d.h), W2 = rnd(d.h * d.n);
    const r = await V.vmlpBlock(X, W1, W2, d, L, null, null);
    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);
    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);
    let same = true;
    for (let i = 0; i < h1ref.length; i++) if (r.h1[i] !== h1ref[i]) { same = false; break; }
    ok(same, "chain gemm1 (hence h1 and the ReLU mask) is byte-identical to the un-chained GEMM");
    const sc = V.scalesFromAbsMax(V.rowAbsMax(r.h1, d.m, d.h));
    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);
    let same2 = true;
    for (let i = 0; i < outref.length; i++) if (r.out[i] !== outref[i]) { same2 = false; break; }
    ok(same2, "chain output equals the manual composition of its stages");
    const r2 = await V.vmlpBlock(X, W1, W2, d, L, null, null);
    ok(r.out.every((v, i) => v === r2.out[i]), "chain is deterministic (two runs bitwise equal)");
  }
  // 4) the transformer trains through the chain, and the spec change does not
  //    hurt convergence: same seeds, old forward vs chained forward
  {
    const cfg = { c: 32, t: 32, b: 8, layers: 2, heads: 2, steps: 40, lr: 0.01 };
    const run = async (X) => {
      const m = X.init(cfg, L, null);
      const opt = T.makeAdam(m.nParams, { lr: cfg.lr });
      let seed = 31337;
      const orig = Math.random;
      Math.random = () => { seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
      let first = 0, last = 0;
      for (let s = 0; s < cfg.steps; s++) {
        const r = await X.trainStep(m);
        if (s === 0) first = r.loss;
        last = r.loss;
        X.applyUpdate(m, opt.step(r.grad));
      }
      Math.random = orig;
      return { first, last };
    };
    const b = await run(NEW);
    ok(b.last < b.first * 0.75, `chained transformer converges (${b.first.toFixed(4)} -> ${b.last.toFixed(4)})`);
    if (OLD) {
      const a = await run(OLD);
      ok(a.first === b.first, `step-0 loss identical before any quantize divergence compounds (${a.first.toFixed(6)})`);
      const rel = Math.abs(a.last - b.last) / a.last;
      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)`);
      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");
    } else {
      console.log("  note   pre-chain baseline (_transformer_prefusion.js) not present — old-vs-new convergence comparison skipped (recorded result: old 2.0500 vs new 2.0512, 0.1% apart)");
    }
  }
  console.log(pass ? "\nB2B MLP CHAIN TEST PASSED" : "\nB2B MLP CHAIN TEST FAILED");
  process.exit(pass ? 0 : 1);
})();