File size: 13,250 Bytes
cad0868 54e1054 cad0868 9ae1216 cad0868 54e1054 cad0868 9ae1216 54e1054 cad0868 9ae1216 54e1054 cad0868 | 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 241 242 243 244 245 246 247 248 249 | // Metamorphic tests: correctness properties that need NO reference implementation.
//
// Differential testing (kernel vs mirror) is only as strong as the independence
// of the two implementations, and every time you tune the oracle to agree with
// the thing it's checking, you spend some of that independence. It is also
// structurally blind to a bug in SHARED source: both replicas compile the same
// WGSL string, so a mutation there propagates to every device and every mirror
// tuned to match it.
//
// These properties come from the DEFINITION of a block-scaled batched GEMM, not
// from any implementation of one. When one fails, there is no referee question:
// the behaviour is wrong, regardless of which side is "right". They hold exactly
// (not approximately) because per-row/per-column quantization commutes with
// permutation, and because int32 accumulation is exactly associative.
//
// The suite is TWO species of check, and the distinction matters:
// RELATIONS (permutation, zero-row, batch, sensitivity) — compare calls to
// each other. Provably blind to value bugs: if out satisfies every
// relation, so does c·out. An external bug corpus scored exactly this
// hole (2/2 on loop bugs, 0/2 on math bugs).
// DEFINITIONAL ABSOLUTES (reluRange, unitScaleAnchor) — points where the
// spec pins the value itself: ReLU output cannot be negative, and at unit
// scales the output IS the integer dot product. Still no reference
// implementation anywhere — the expected values are plain integer
// arithmetic — but they close the value-bug hole the relations cannot.
const fs = require("fs");
const path = require("path");
const V = require("./public/verified_core.js");
const L = { mul: new Int16Array(fs.readFileSync(path.join(__dirname, "public", "mul_lut.bin")).buffer.slice(0)) };
const randf = (n, f) => Float32Array.from({ length: n }, f || (() => Math.random() * 2 - 1));
// The kernel under test: float in, float out, block-scaled through the units.
// Swap in a mutant to prove the properties actually bite.
function makeKernel(bug) {
return async (Xf, Wf, d) => {
const { m, k, n } = d, batch = d.batch || 1;
if (bug === "accOverwrite") { // corpus: matmul_triton_buggy
const q = V.quantizeRows(Xf, batch * m, k), w = V.quantizeCols(Wf, k, n);
const out = d.acc ? new Int32Array(batch * m * n) : new Float32Array(batch * m * n);
for (let i = 0; i < m; i++) for (let j = 0; j < n; j++) {
let a = 0;
for (let p = 0; p < k; p++) a = L.mul[(q.q[i * k + p] & 0xFF) * 256 + (w.q[p * n + j] & 0xFF)];
out[i * n + j] = d.acc ? a : V.epi(a, q.s[i], w.s[j]);
}
return out;
}
const x = V.quantizeRows(Xf, batch * m, k);
let wq, ws;
if (batch === 1) { const w = V.quantizeCols(Wf, k, n); wq = w.q; ws = w.s; }
else {
wq = new Int8Array(batch * k * n); ws = new Float32Array(batch * n);
for (let bz = 0; bz < batch; bz++) {
const w = V.quantizeCols(Wf.subarray(bz * k * n, (bz + 1) * k * n), k, n);
wq.set(w.q, bz * k * n); ws.set(w.s, bz * n);
}
}
if (bug === "batchStride") { // ignores the batch offset on W
const d2 = { ...d };
const out = new Float32Array(batch * m * n);
for (let bz = 0; bz < batch; bz++) {
const o = V.bgemmJS(x.q.subarray(bz * m * k, (bz + 1) * m * k), wq.subarray(0, k * n),
x.s.subarray(bz * m, (bz + 1) * m), ws.subarray(0, n),
{ ...d2, batch: 1 }, L);
out.set(o, bz * m * n);
}
return out;
}
if (bug === "rowSwap") { // transposes two output rows
const out = V.bgemmJS(x.q, wq, x.s, ws, d, L);
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; }
return out;
}
if (bug === "factor2") { // corpus: gelu_triton_buggy — uniform 2x
const out = V.bgemmJS(x.q, wq, x.s, ws, d, L);
for (let i = 0; i < out.length; i++) out[i] = Math.fround(out[i] * 2);
return out;
}
if (bug === "leakyAlpha") { // corpus: leaky_relu_buggy — leaks instead of clamping
const out = V.bgemmJS(x.q, wq, x.s, ws, { ...d, relu: false }, L);
if (d.relu) for (let i = 0; i < out.length; i++) if (out[i] < 0) out[i] = Math.fround(out[i] * 0.1);
return out;
}
return V.bgemmJS(x.q, wq, x.s, ws, d, L);
};
}
const eq = (a, b) => { for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; };
// ---- the properties ---------------------------------------------------------
const PROPS = {
// NON-TRIVIALITY. Added after an external bug corpus scored the suite below
// 0/4: the zero function satisfies every relation here, because zero is
// permutation-equivariant, zero-row-preserving and batch-decomposable. Without
// this, a kernel can pass the whole suite by doing nothing at all.
async nonTriviality(K) {
const d = { m: 6, k: 32, n: 5, batch: 1 };
const out = await K(randf(d.m * d.k), randf(d.k * d.n), d);
let nz = 0;
for (let i = 0; i < out.length; i++) if (out[i] !== 0) nz++;
if (nz < out.length / 4) return `output is ${out.length - nz}/${out.length} zeros`;
return null;
},
// SENSITIVITY. Every element of A must be able to move its output row. Catches
// an accumulator that overwrites instead of accumulating (acc= for acc+=):
// every structural relation still holds, but only the last k contributes.
// Measured on the raw accumulator, perturbed by a sign flip that leaves the
// row absmax alone — otherwise the perturbation moves the output through the
// quantization SCALE and the property proves nothing.
async sensitivity(K) {
const d = { m: 6, k: 32, n: 5, batch: 1, acc: true };
const A = randf(d.m * d.k), B = randf(d.k * d.n);
for (let p = 0; p < d.k; p++) A[1 * d.k + p] = 0.3;
A[1 * d.k + 0] = 1.0; // pin the absmax at p=0
const s0 = V.quantizeRows(A, d.m, d.k).s[1];
const base = await K(A, B, d);
for (let p = 1; p < d.k; p++) {
const A2 = Float32Array.from(A);
A2[1 * d.k + p] = -0.3;
if (V.quantizeRows(A2, d.m, d.k).s[1] !== s0) continue; // inconclusive
const o2 = await K(A2, B, d);
let moved = false;
for (let j = 0; j < d.n; j++) if (o2[1 * d.n + j] !== base[1 * d.n + j]) { moved = true; break; }
if (!moved) return `A[.,${p}] does not affect its output row`;
}
return null;
},
// relu(x) >= 0 is part of the DEFINITION when the fused ReLU is on — a range
// constraint, not a relation. Catches a wrong negative slope, which every
// relation survives (the structure of a leak is fine; its sign is not).
async reluRange(K) {
const d = { m: 6, k: 32, n: 5, batch: 1, relu: true };
const out = await K(randf(d.m * d.k), randf(d.k * d.n), d);
for (let i = 0; i < out.length; i++)
if (out[i] < 0) return `negative output ${out[i]} at [${(i / d.n) | 0},${i % d.n}] under fused ReLU`;
return null;
},
// With unit scales the dequant is the identity, so the definition pins
// ABSOLUTE values: out must equal the exact integer dot product. No RELATION
// can catch a uniform c× — if out satisfies every relation, c·out does too —
// so the suite needs one point where the spec fixes the scale. Inputs are
// floats that quantize exactly (row/col absmax = 127 ⇒ scale 1), and the
// expected values are plain integer arithmetic: no reference implementation.
async unitScaleAnchor(K) {
const d = { m: 2, k: 4, n: 2, batch: 1 };
const A = Float32Array.from([127, 1, -2, 3,
0, 5, -127, 2]);
const B = Float32Array.from([127, 3, // k×n, each COLUMN has absmax 127
-1, 127,
2, -5,
0, 1]);
const out = await K(A, B, d);
for (let i = 0; i < d.m; i++)
for (let j = 0; j < d.n; j++) {
let dot = 0;
for (let p = 0; p < d.k; p++) dot += A[i * d.k + p] * B[p * d.n + j];
if (out[i * d.n + j] !== dot) return `unit-scale output [${i},${j}] = ${out[i * d.n + j]}, definition says ${dot}`;
}
return null;
},
// a zero row of A must produce a zero row of output, whatever the scales are
async zeroRow(K) {
const d = { m: 6, k: 32, n: 5, batch: 1 };
const A = randf(d.m * d.k), B = randf(d.k * d.n);
for (let p = 0; p < d.k; p++) A[2 * d.k + p] = 0;
const out = await K(A, B, d);
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]}`;
return null;
},
// permuting rows of A must permute rows of the output the same way
async rowPermutation(K) {
const d = { m: 6, k: 32, n: 5, batch: 1 };
const A = randf(d.m * d.k), B = randf(d.k * d.n);
const perm = [3, 1, 5, 0, 4, 2];
const Ap = new Float32Array(A.length);
perm.forEach((src, dst) => Ap.set(A.subarray(src * d.k, (src + 1) * d.k), dst * d.k));
const out = await K(A, B, d), outP = await K(Ap, B, d);
for (let r = 0; r < d.m; r++)
for (let j = 0; j < d.n; j++)
if (outP[r * d.n + j] !== out[perm[r] * d.n + j])
return `permuting rows of A did not permute the output at [${r},${j}]`;
return null;
},
// permuting columns of B must permute columns of the output the same way
async colPermutation(K) {
const d = { m: 4, k: 32, n: 5, batch: 1 };
const A = randf(d.m * d.k), B = randf(d.k * d.n);
const perm = [2, 0, 4, 1, 3];
const Bp = new Float32Array(B.length);
for (let p = 0; p < d.k; p++) perm.forEach((src, dst) => { Bp[p * d.n + dst] = B[p * d.n + src]; });
const out = await K(A, B, d), outP = await K(A, Bp, d);
for (let r = 0; r < d.m; r++)
for (let j = 0; j < d.n; j++)
if (outP[r * d.n + j] !== out[r * d.n + perm[j]])
return `permuting columns of B did not permute the output at [${r},${j}]`;
return null;
},
// a batched call must equal running each batch element on its own
async batchDecomposition(K) {
const d = { m: 4, k: 32, n: 5, batch: 3 };
const A = randf(d.batch * d.m * d.k), B = randf(d.batch * d.k * d.n);
const together = await K(A, B, d);
for (let bz = 0; bz < d.batch; bz++) {
const alone = await K(A.subarray(bz * d.m * d.k, (bz + 1) * d.m * d.k),
B.subarray(bz * d.k * d.n, (bz + 1) * d.k * d.n), { ...d, batch: 1 });
const slice = together.subarray(bz * d.m * d.n, (bz + 1) * d.m * d.n);
if (!eq(alone, slice)) return `batch element ${bz} differs when computed alone vs batched`;
}
return null;
},
};
(async () => {
let pass = true;
const ok = (c, msg) => { console.log(`${c ? " ok " : " FAIL"} ${msg}`); if (!c) pass = false; };
console.log("\nproperties hold for the real kernel (no reference implementation used):");
for (const [name, prop] of Object.entries(PROPS)) {
const bad = await prop(makeKernel(null));
ok(bad === null, `${name}${bad ? " -> " + bad : ""}`);
}
// The point: these catch bugs with NO oracle. A mutation living in source
// shared by every replica would sail past the probe and past a mirror tuned
// to agree with it. It cannot sail past arithmetic that must be true.
console.log("\nthe same properties catch bugs with no oracle to compare against:");
const strideBad = await PROPS.batchDecomposition(makeKernel("batchStride"));
ok(strideBad !== null, `batchDecomposition catches a dropped batch stride (${strideBad || "MISSED"})`);
const swapBad = await PROPS.rowPermutation(makeKernel("rowSwap"));
ok(swapBad !== null, `rowPermutation catches swapped output rows (${swapBad || "MISSED"})`);
const swapZero = await PROPS.zeroRow(makeKernel("rowSwap"));
console.log(` note zeroRow vs the same rowSwap bug: ${swapZero ? "caught" : "missed (properties are partial, not a proof)"}`);
// a bug from someone else's taxonomy, not mine: acc= instead of acc+=. Every
// structural relation survives it, which is why sensitivity had to exist.
const accSens = await PROPS.sensitivity(makeKernel("accOverwrite"));
const accPerm = await PROPS.rowPermutation(makeKernel("accOverwrite"));
ok(accSens !== null, `sensitivity catches acc= instead of acc+= (${accSens || "MISSED"})`);
console.log(` note rowPermutation vs that same acc= bug: ${accPerm ? "caught" : "missed — it is structure-preserving, which is the whole trap"}`);
// value bugs: invisible to every RELATION (c·out satisfies whatever out
// does), caught by the definitional absolutes — range and unit-scale anchor
const leakBad = await PROPS.reluRange(makeKernel("leakyAlpha"));
ok(leakBad !== null, `reluRange catches a wrong leaky slope (${leakBad || "MISSED"})`);
const facBad = await PROPS.unitScaleAnchor(makeKernel("factor2"));
ok(facBad !== null, `unitScaleAnchor catches a uniform 2x (${facBad || "MISSED"})`);
console.log(pass ? "\nMETAMORPHIC TEST PASSED" : "\nMETAMORPHIC TEST FAILED");
process.exit(pass ? 0 : 1);
})();
|