File size: 13,254 Bytes
93d9b2e
 
 
 
 
 
 
 
 
 
 
1bdb0bb
93d9b2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465d605
 
 
93d9b2e
 
 
 
 
 
 
 
465d605
93d9b2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465d605
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3c56c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1bdb0bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93d9b2e
 
 
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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
// Mutation test for the kernel admission gate.
//
// A gate that has never rejected anything is decoration. This injects deliberately
// buggy "kernels" and asserts the gate rejects each one β€” and, separately, shows
// two things the OLD gate (single tiny shape, allclose) let through.
//
// Runs in Node against the CPU mirrors, so the buggy kernels stand in for what a
// wrong GPU shader would produce.
const fs = require("fs");
const path = require("path");
const V = require("./public/verified_core.js");
const TC = require("./public/traincore.js");

const mul = new Int16Array(fs.readFileSync(path.join(__dirname, "public", "mul_lut.bin")).buffer.slice(0));
const L = { mul };

// ---- the gate under test (mirrors webgpu.js gateBgemm) ----------------------
const SWEEP = [
  { m: 5, k: 9, n: 6, batch: 3, relu: true },
  { m: 32, k: 64, n: 32, batch: 1, relu: false },
  { m: 7, k: 253, n: 5, batch: 2, relu: true },
  { m: 1, k: 4, n: 1, batch: 1, relu: false },
  { m: 17, k: 33, n: 9, batch: 1, relu: true },
];
const OLD_SHAPE = [{ m: 5, k: 9, n: 6, batch: 3, relu: true }];   // what we used to test
const rnd = (n, f) => { const a = new Int8Array(n); for (let i = 0; i < n; i++) a[i] = f(); return a; };

function inputs(d) {
  const Xq = rnd(d.batch * d.m * d.k, () => (Math.random() * 256 - 128) | 0);
  const Wq = rnd(d.batch * d.k * d.n, () => (Math.random() * 256 - 128) | 0);
  const rs = Float32Array.from({ length: d.batch * d.m }, () => Math.random() + 0.5);
  const cs = Float32Array.from({ length: d.batch * d.n }, () => Math.random() + 0.5);
  return { Xq, Wq, rs, cs };
}

// exact gate: int32 accumulator with !==, then f32 epilogue compared AT THE
// BIT LEVEL (V.bitDiff) β€” `!==` can't see -0 vs +0, and the fleet's replica
// checks hash raw bytes, so the gate must compare what the hash sees.
function gateExact(kernel, shapes) {
  for (const d of shapes) {
    const { Xq, Wq, rs, cs } = inputs(d);
    const accHw = kernel(Xq, Wq, rs, cs, { ...d, acc: true }, L);
    const accRef = V.bgemmJS(Xq, Wq, rs, cs, { ...d, acc: true }, L);
    for (let i = 0; i < accRef.length; i++) if (accHw[i] !== accRef[i]) return `acc @${i}`;
    const hw = kernel(Xq, Wq, rs, cs, d, L);
    const ref = V.bgemmJS(Xq, Wq, rs, cs, d, L);
    for (let i = 0; i < ref.length; i++) if (V.bitDiff(hw[i], ref[i])) return `epilogue @${i}`;
  }
  return null;
}
// the old gate: one shape, allclose on the fused f32 output only
function gateOld(kernel) {
  for (const d of OLD_SHAPE) {
    const { Xq, Wq, rs, cs } = inputs(d);
    const hw = kernel(Xq, Wq, rs, cs, d, L);
    const ref = V.bgemmJS(Xq, Wq, rs, cs, d, L);
    for (let i = 0; i < ref.length; i++)
      if (Math.abs(hw[i] - ref[i]) > Math.abs(ref[i]) * 1e-6 + 1e-6) return `allclose @${i}`;
  }
  return null;
}

// ---- buggy kernels ----------------------------------------------------------
// Each is bgemmJS with exactly one realistic defect injected.
function mutant(bug) {
  return function (Xq, Wq, rs, cs, d, LL) {
    const { m, k, n } = d, batch = d.batch || 1, relu = !!d.relu, mulT = LL.mul;
    const raw = !!d.acc;
    const out = raw ? new Int32Array(batch * m * n) : new Float32Array(batch * m * n);
    const acc = new Int32Array(n);
    for (let bz = 0; bz < batch; bz++) {
      const xo = bz * m * k, oo = bz * m * n, co = bz * n;
      // BUG: batch stride dropped on W β€” invisible unless batch > 1
      const wo = bug === "batchStride" ? 0 : bz * k * n;
      for (let i = 0; i < m; i++) {
        acc.fill(0);
        const xrow = xo + i * k;
        // BUG: last element of the dot product skipped
        const kEnd = bug === "shortK" ? k - 1 : k;
        for (let p = 0; p < kEnd; p++) {
          const au = (Xq[xrow + p] & 0xFF) * 256, wrow = wo + p * n;
          for (let j = 0; j < n; j++) acc[j] += mulT[au + (Wq[wrow + j] & 0xFF)];
        }
        const orow = oo + i * n;
        if (raw) { for (let j = 0; j < n; j++) out[orow + j] = acc[j]; continue; }
        const rscale = rs[bz * m + i];
        for (let j = 0; j < n; j++) {
          // BUG: column scale ignored / ReLU dropped / f64 rounding (the real one)
          let v;
          if (bug === "missingColScale") v = V.epi(acc[j], rscale, 1);
          else if (bug === "f64Rounding") v = acc[j] * rscale * cs[co + j];   // rounds once, not thrice
          else v = V.epi(acc[j], rscale, cs[co + j]);
          out[orow + j] = (relu && bug !== "noRelu" && v < 0) ? 0 : v;
        }
      }
    }
    return out;
  };
}

let pass = true;
const ok = (cond, msg) => { console.log(`${cond ? "  ok  " : "  FAIL"}  ${msg}`); if (!cond) pass = false; };

console.log("\nthe gate must reject every injected bug:");
for (const bug of ["batchStride", "shortK", "missingColScale", "noRelu", "f64Rounding"])
  ok(gateExact(mutant(bug), SWEEP) !== null, `rejects ${bug}  (${gateExact(mutant(bug), SWEEP) || "NOT CAUGHT"})`);

console.log("\nthe gate must accept the correct kernel:");
ok(gateExact(V.bgemmJS, SWEEP) === null, "accepts the real mirror across the shape sweep");

console.log("\nwhat the OLD gate (one tiny shape, allclose) let through:");
const oldMissedRounding = gateOld(mutant("f64Rounding")) === null;
const newCatchesRounding = gateExact(mutant("f64Rounding"), SWEEP) !== null;
ok(oldMissedRounding && newCatchesRounding,
   "f64-vs-f32 epilogue rounding: allclose PASSES it, exact gate catches it");
const oldMissedStride = gateOld(mutant("batchStride")) === null;
console.log(`  note   batchStride under the old gate: ${oldMissedStride ? "MISSED" : "caught (batch>1 was in the shape)"}`);

// ---- the sign of zero -------------------------------------------------------
// Real ISAs have non-IEEE modes that flush -0 to +0 (RDNA2 output modifiers /
// DX9-legacy multiplies). JS `!==` treats -0 === 0, so a value-level gate is
// BLIND to that flush β€” yet the sync guard hashes raw bits, so it would fork
// the fleet. The audit must therefore compare bit patterns.
console.log("\nthe audit must see the sign of zero (`!==` cannot):");
{
  const d = { m: 2, k: 4, n: 2 };
  const Xq = new Int8Array(d.m * d.k);                        // all zero -> acc = 0 -> epi = +0
  const Wq = rnd(d.k * d.n, () => (Math.random() * 256 - 128) | 0);
  const rs = Float32Array.from({ length: d.m }, () => Math.random() + 0.5);
  const cs = Float32Array.from({ length: d.n }, () => Math.random() + 0.5);
  const got = V.bgemmJS(Xq, Wq, rs, cs, { ...d, batch: 1 }, L);
  ok(got.every((v) => !V.bitDiff(v, 0)), "sanity: the units produce +0 here");
  got[1] = -0;                                                // what a -0-flushing device's INVERSE would look like; either direction diverges the hash
  ok(got.every((v) => v === 0), "sanity: `!==` cannot tell the corrupted output apart");
  const bad = V.auditTile(Xq, Wq, rs, cs, d, got, L, 400);    // 400 samples over 4 cells: hits the bad cell w.p. 1-(3/4)^400
  ok(bad !== null, `auditTile flags the -0 (${bad || "NOT CAUGHT"})`);
}

// ---- the audit's sampling strategy ------------------------------------------
// A bounds-guard off-by-one or a pack-tail padding bug corrupts the LAST
// row/column only. At the live logits shape (n = 16512) that is 1 cell in
// 16512, so uniformly random sampling essentially never lands on it. The audit
// now spends its first cells on the structural danger points deliberately.
// This measures both strategies against that named bug class.
console.log("\nthe audit must catch a LAST-COLUMN bug (uniform sampling cannot):");
{
  const d = { m: 64, k: 16, n: 512 };                 // n wide, like the real unembed
  const Xq = rnd(d.m * d.k, () => (Math.random() * 256 - 128) | 0);
  const Wq = rnd(d.k * d.n, () => (Math.random() * 256 - 128) | 0);
  const rs = Float32Array.from({ length: d.m }, () => Math.random() + 0.5);
  const cs = Float32Array.from({ length: d.n }, () => Math.random() + 0.5);
  const good = V.bgemmJS(Xq, Wq, rs, cs, { ...d, batch: 1 }, L);

  const corrupt = () => {                              // last column only
    const g = Float32Array.from(good);
    for (let i = 0; i < d.m; i++) g[i * d.n + (d.n - 1)] = 0;
    return g;
  };
  // the OLD strategy: every cell uniform-random
  const uniformAudit = (got, nCells) => {
    for (let t = 0; t < nCells; t++) {
      const i = (Math.random() * d.m) | 0, j = (Math.random() * d.n) | 0;
      let acc = 0;
      for (let p = 0; p < d.k; p++) acc += mul[(Xq[i * d.k + p] & 0xFF) * 256 + (Wq[p * d.n + j] & 0xFF)];
      if (V.bitDiff(got[i * d.n + j], V.epi(acc, rs[i], cs[j]))) return true;
    }
    return false;
  };
  const TRIALS = 300;
  let uniHits = 0, stratHits = 0;
  for (let t = 0; t < TRIALS; t++) {
    if (uniformAudit(corrupt(), 12)) uniHits++;
    if (V.auditTile(Xq, Wq, rs, cs, d, corrupt(), L, 12) !== null) stratHits++;
  }
  console.log(`  uniform 12 cells : caught ${uniHits}/${TRIALS} audits  (expected ~${(100*(1-Math.pow(1-1/d.n,12))).toFixed(1)}%)`);
  console.log(`  stratified 12    : caught ${stratHits}/${TRIALS} audits`);
  ok(stratHits === TRIALS, "stratified sampling catches the last-column bug on EVERY audit");
  ok(uniHits < TRIALS * 0.25, "uniform sampling misses it almost always β€” the strategy, not the cell count, is what changed");
  // and it must not cry wolf on a clean kernel
  let fp = 0;
  for (let t = 0; t < TRIALS; t++) if (V.auditTile(Xq, Wq, rs, cs, d, good, L, 12) !== null) fp++;
  ok(fp === 0, `no false positives on the clean kernel (${TRIALS} audits)`);
}

// ---- the attention kernels now get a LIVE audit too --------------------------
// They had exact init gates but nothing at live shapes β€” the same gap the GEMM
// audit exists to close. These must reject a corrupted attention output and
// accept a clean one, at a shape the init gate never sees.
console.log("\nthe attention audits must reject corrupted output:");
{
  const d = { B: 2, T: 24, heads: 3, hd: 8 };          // not one of the gate's shapes
  const C = d.heads * d.hd, BH = d.B * d.heads;
  const qq = rnd(d.B * d.T * C, () => (Math.random() * 256 - 128) | 0);
  const kq = rnd(d.B * d.T * C, () => (Math.random() * 256 - 128) | 0);
  const qs = Float32Array.from({ length: d.B * d.T * d.heads }, () => Math.random() + 0.5);
  const ks = Float32Array.from({ length: d.B * d.T * d.heads }, () => Math.random() + 0.5);
  const good = V.attScoresJS(qq, kq, qs, ks, d, L);
  ok(V.auditAttScores(qq, kq, qs, ks, d, good, L, 12) === null, "att.scores audit accepts the clean kernel");
  // corrupt the LAST head's last token pair β€” the classic head-stride bug
  const badS = Float32Array.from(good);
  badS[((d.B * d.heads - 1) * d.T + (d.T - 1)) * d.T + (d.T - 1)] = 0;
  ok(V.auditAttScores(qq, kq, qs, ks, d, badS, L, 12) !== null, "att.scores audit rejects a corrupted last head/token");

  const aq = rnd(BH * d.T * d.T, () => (Math.random() * 127) | 0);
  const vq = rnd(d.B * d.T * C, () => (Math.random() * 256 - 128) | 0);
  const as = Float32Array.from({ length: BH * d.T }, () => Math.random() + 0.5);
  const vs = Float32Array.from({ length: BH * d.hd }, () => Math.random() + 0.5);
  const goodC = V.attCtxJS(aq, vq, as, vs, d, L);
  ok(V.auditAttCtx(aq, vq, as, vs, d, goodC, L, 12) === null, "att.ctx audit accepts the clean kernel");
  const badC = Float32Array.from(goodC);               // last channel of the last head
  badC[((d.B - 1) * d.T + (d.T - 1)) * C + (d.heads - 1) * d.hd + (d.hd - 1)] = 0;
  ok(V.auditAttCtx(aq, vq, as, vs, d, badC, L, 12) !== null, "att.ctx audit rejects a corrupted last channel (scatter write-back)");
}

// ---- the f32 backward GEMM is no longer gated by a tolerance -----------------
// fgemmMirror reproduces split-K's partition and accumulation order, so the
// gate can compare with `!==`. This checks the mirror is self-consistent and
// that the two rounding schedules it offers are genuinely different (i.e. the
// gate is choosing between real alternatives, not two names for one thing).
console.log("\nthe split-K mirror is exact and discriminating:");
{
  const m0 = 6, k0 = 5000, n0 = 4;                     // k > 4096 exercises split-K
  const A = Float32Array.from({ length: m0 * k0 }, () => Math.random() - 0.5);
  const Bm = Float32Array.from({ length: k0 * n0 }, () => Math.random() - 0.5);
  const dG = { m: m0, k: k0, n: n0 };
  const stepped = V.fgemmMirror(A, Bm, dG, false), fused = V.fgemmMirror(A, Bm, dG, true);
  ok(V.fgemmMirror(A, Bm, dG, false).every((v, i) => !V.bitDiff(v, stepped[i])), "mirror is deterministic");
  let diff = 0;
  for (let i = 0; i < stepped.length; i++) if (V.bitDiff(stepped[i], fused[i])) diff++;
  ok(diff > 0, `the two rounding schedules really differ (${diff}/${stepped.length} cells) β€” the gate picks between real alternatives`);
  const naive = TC.matmul(A, Bm, m0, k0, n0);
  let vsNaive = 0;
  for (let i = 0; i < naive.length; i++) if (V.bitDiff(naive[i], stepped[i])) vsNaive++;
  console.log(`  note   split-K order differs from a naive matmul on ${vsNaive}/${naive.length} cells β€” which is why the old gate had to use a tolerance, and why the mirror had to be written`);
}

console.log(pass ? "\nGATE TEST PASSED β€” the gate rejects real bugs and accepts the real kernel."
                 : "\nGATE TEST FAILED");
process.exit(pass ? 0 : 1);