Web demo: metamorphic property tests (no-oracle correctness checks)
Browse files- web/test_metamorphic.js +136 -0
web/test_metamorphic.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Metamorphic tests: correctness properties that need NO reference implementation.
|
| 2 |
+
//
|
| 3 |
+
// Differential testing (kernel vs mirror) is only as strong as the independence
|
| 4 |
+
// of the two implementations, and every time you tune the oracle to agree with
|
| 5 |
+
// the thing it's checking, you spend some of that independence. It is also
|
| 6 |
+
// structurally blind to a bug in SHARED source: both replicas compile the same
|
| 7 |
+
// WGSL string, so a mutation there propagates to every device and every mirror
|
| 8 |
+
// tuned to match it.
|
| 9 |
+
//
|
| 10 |
+
// These properties come from the DEFINITION of a block-scaled batched GEMM, not
|
| 11 |
+
// from any implementation of one. When one fails, there is no referee question:
|
| 12 |
+
// the behaviour is wrong, regardless of which side is "right". They hold exactly
|
| 13 |
+
// (not approximately) because per-row/per-column quantization commutes with
|
| 14 |
+
// permutation, and because int32 accumulation is exactly associative.
|
| 15 |
+
const fs = require("fs");
|
| 16 |
+
const path = require("path");
|
| 17 |
+
const V = require("./public/verified_core.js");
|
| 18 |
+
const L = { mul: new Int16Array(fs.readFileSync(path.join(__dirname, "public", "mul_lut.bin")).buffer.slice(0)) };
|
| 19 |
+
|
| 20 |
+
const randf = (n, f) => Float32Array.from({ length: n }, f || (() => Math.random() * 2 - 1));
|
| 21 |
+
|
| 22 |
+
// The kernel under test: float in, float out, block-scaled through the units.
|
| 23 |
+
// Swap in a mutant to prove the properties actually bite.
|
| 24 |
+
function makeKernel(bug) {
|
| 25 |
+
return async (Xf, Wf, d) => {
|
| 26 |
+
const { m, k, n } = d, batch = d.batch || 1;
|
| 27 |
+
const x = V.quantizeRows(Xf, batch * m, k);
|
| 28 |
+
let wq, ws;
|
| 29 |
+
if (batch === 1) { const w = V.quantizeCols(Wf, k, n); wq = w.q; ws = w.s; }
|
| 30 |
+
else {
|
| 31 |
+
wq = new Int8Array(batch * k * n); ws = new Float32Array(batch * n);
|
| 32 |
+
for (let bz = 0; bz < batch; bz++) {
|
| 33 |
+
const w = V.quantizeCols(Wf.subarray(bz * k * n, (bz + 1) * k * n), k, n);
|
| 34 |
+
wq.set(w.q, bz * k * n); ws.set(w.s, bz * n);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
if (bug === "batchStride") { // ignores the batch offset on W
|
| 38 |
+
const d2 = { ...d };
|
| 39 |
+
const out = new Float32Array(batch * m * n);
|
| 40 |
+
for (let bz = 0; bz < batch; bz++) {
|
| 41 |
+
const o = V.bgemmJS(x.q.subarray(bz * m * k, (bz + 1) * m * k), wq.subarray(0, k * n),
|
| 42 |
+
x.s.subarray(bz * m, (bz + 1) * m), ws.subarray(0, n),
|
| 43 |
+
{ ...d2, batch: 1 }, L);
|
| 44 |
+
out.set(o, bz * m * n);
|
| 45 |
+
}
|
| 46 |
+
return out;
|
| 47 |
+
}
|
| 48 |
+
if (bug === "rowSwap") { // transposes two output rows
|
| 49 |
+
const out = V.bgemmJS(x.q, wq, x.s, ws, d, L);
|
| 50 |
+
if (m > 1) for (let j = 0; j < n; j++) { const t = out[0 * n + j]; out[0 * n + j] = out[1 * n + j]; out[1 * n + j] = t; }
|
| 51 |
+
return out;
|
| 52 |
+
}
|
| 53 |
+
return V.bgemmJS(x.q, wq, x.s, ws, d, L);
|
| 54 |
+
};
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const eq = (a, b) => { for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; };
|
| 58 |
+
|
| 59 |
+
// ---- the properties ---------------------------------------------------------
|
| 60 |
+
const PROPS = {
|
| 61 |
+
// a zero row of A must produce a zero row of output, whatever the scales are
|
| 62 |
+
async zeroRow(K) {
|
| 63 |
+
const d = { m: 6, k: 32, n: 5, batch: 1 };
|
| 64 |
+
const A = randf(d.m * d.k), B = randf(d.k * d.n);
|
| 65 |
+
for (let p = 0; p < d.k; p++) A[2 * d.k + p] = 0;
|
| 66 |
+
const out = await K(A, B, d);
|
| 67 |
+
for (let j = 0; j < d.n; j++) if (out[2 * d.n + j] !== 0) return `row 2 was zeroed but output[2,${j}] = ${out[2 * d.n + j]}`;
|
| 68 |
+
return null;
|
| 69 |
+
},
|
| 70 |
+
// permuting rows of A must permute rows of the output the same way
|
| 71 |
+
async rowPermutation(K) {
|
| 72 |
+
const d = { m: 6, k: 32, n: 5, batch: 1 };
|
| 73 |
+
const A = randf(d.m * d.k), B = randf(d.k * d.n);
|
| 74 |
+
const perm = [3, 1, 5, 0, 4, 2];
|
| 75 |
+
const Ap = new Float32Array(A.length);
|
| 76 |
+
perm.forEach((src, dst) => Ap.set(A.subarray(src * d.k, (src + 1) * d.k), dst * d.k));
|
| 77 |
+
const out = await K(A, B, d), outP = await K(Ap, B, d);
|
| 78 |
+
for (let r = 0; r < d.m; r++)
|
| 79 |
+
for (let j = 0; j < d.n; j++)
|
| 80 |
+
if (outP[r * d.n + j] !== out[perm[r] * d.n + j])
|
| 81 |
+
return `permuting rows of A did not permute the output at [${r},${j}]`;
|
| 82 |
+
return null;
|
| 83 |
+
},
|
| 84 |
+
// permuting columns of B must permute columns of the output the same way
|
| 85 |
+
async colPermutation(K) {
|
| 86 |
+
const d = { m: 4, k: 32, n: 5, batch: 1 };
|
| 87 |
+
const A = randf(d.m * d.k), B = randf(d.k * d.n);
|
| 88 |
+
const perm = [2, 0, 4, 1, 3];
|
| 89 |
+
const Bp = new Float32Array(B.length);
|
| 90 |
+
for (let p = 0; p < d.k; p++) perm.forEach((src, dst) => { Bp[p * d.n + dst] = B[p * d.n + src]; });
|
| 91 |
+
const out = await K(A, B, d), outP = await K(A, Bp, d);
|
| 92 |
+
for (let r = 0; r < d.m; r++)
|
| 93 |
+
for (let j = 0; j < d.n; j++)
|
| 94 |
+
if (outP[r * d.n + j] !== out[r * d.n + perm[j]])
|
| 95 |
+
return `permuting columns of B did not permute the output at [${r},${j}]`;
|
| 96 |
+
return null;
|
| 97 |
+
},
|
| 98 |
+
// a batched call must equal running each batch element on its own
|
| 99 |
+
async batchDecomposition(K) {
|
| 100 |
+
const d = { m: 4, k: 32, n: 5, batch: 3 };
|
| 101 |
+
const A = randf(d.batch * d.m * d.k), B = randf(d.batch * d.k * d.n);
|
| 102 |
+
const together = await K(A, B, d);
|
| 103 |
+
for (let bz = 0; bz < d.batch; bz++) {
|
| 104 |
+
const alone = await K(A.subarray(bz * d.m * d.k, (bz + 1) * d.m * d.k),
|
| 105 |
+
B.subarray(bz * d.k * d.n, (bz + 1) * d.k * d.n), { ...d, batch: 1 });
|
| 106 |
+
const slice = together.subarray(bz * d.m * d.n, (bz + 1) * d.m * d.n);
|
| 107 |
+
if (!eq(alone, slice)) return `batch element ${bz} differs when computed alone vs batched`;
|
| 108 |
+
}
|
| 109 |
+
return null;
|
| 110 |
+
},
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
(async () => {
|
| 114 |
+
let pass = true;
|
| 115 |
+
const ok = (c, msg) => { console.log(`${c ? " ok " : " FAIL"} ${msg}`); if (!c) pass = false; };
|
| 116 |
+
|
| 117 |
+
console.log("\nproperties hold for the real kernel (no reference implementation used):");
|
| 118 |
+
for (const [name, prop] of Object.entries(PROPS)) {
|
| 119 |
+
const bad = await prop(makeKernel(null));
|
| 120 |
+
ok(bad === null, `${name}${bad ? " -> " + bad : ""}`);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// The point: these catch bugs with NO oracle. A mutation living in source
|
| 124 |
+
// shared by every replica would sail past the probe and past a mirror tuned
|
| 125 |
+
// to agree with it. It cannot sail past arithmetic that must be true.
|
| 126 |
+
console.log("\nthe same properties catch bugs with no oracle to compare against:");
|
| 127 |
+
const strideBad = await PROPS.batchDecomposition(makeKernel("batchStride"));
|
| 128 |
+
ok(strideBad !== null, `batchDecomposition catches a dropped batch stride (${strideBad || "MISSED"})`);
|
| 129 |
+
const swapBad = await PROPS.rowPermutation(makeKernel("rowSwap"));
|
| 130 |
+
ok(swapBad !== null, `rowPermutation catches swapped output rows (${swapBad || "MISSED"})`);
|
| 131 |
+
const swapZero = await PROPS.zeroRow(makeKernel("rowSwap"));
|
| 132 |
+
console.log(` note zeroRow vs the same rowSwap bug: ${swapZero ? "caught" : "missed (properties are partial, not a proof)"}`);
|
| 133 |
+
|
| 134 |
+
console.log(pass ? "\nMETAMORPHIC TEST PASSED" : "\nMETAMORPHIC TEST FAILED");
|
| 135 |
+
process.exit(pass ? 0 : 1);
|
| 136 |
+
})();
|