Quazim0t0 commited on
Commit
4cf5cdc
·
verified ·
1 Parent(s): b9cbc2e

Web trainer upgrade: sync guard (leader roster + weight-hash divergence check), 3xINT8 fast-accurate GEMM, DP4A hardware path (verified vs units), Spikewhale tokenizer (16.5k vocab), FineWeb-Edu streaming, gradient/checkpoint fragmentation, inference kit, generation tester, contribution logs

Browse files
web/public/app.js CHANGED
@@ -29,6 +29,12 @@ const ui = {
29
  cfgB: document.getElementById("cfgB"),
30
  cfgSteps: document.getElementById("cfgSteps"),
31
  cfgLr: document.getElementById("cfgLr"),
 
 
 
 
 
 
32
  };
33
  function log(m) { ui.log.textContent = `${new Date().toLocaleTimeString()} ${m}\n` + ui.log.textContent; }
34
  function setStatus(s) { ui.status.textContent = s; }
@@ -52,10 +58,24 @@ const names = new Map(); // peerId -> device name
52
  const incoming = new Map(); // step -> Map(peerId -> Float32Array)
53
  let model = null, training = false; // the mini transformer (transformer.js)
54
  let trainedSteps = 0; // steps baked into the current weights
 
 
 
 
 
 
 
 
55
  function nmeOf(id) { return names.get(id) || id; }
56
 
57
  function room() { return new URLSearchParams(location.search).get("room"); } // null -> group by network
58
- function updatePeers() { ui.peers.textContent = chans.size ? [...chans.keys()].map(nmeOf).join(", ") : "(none yet)"; }
 
 
 
 
 
 
59
 
60
  // ---- signaling + WebRTC ----------------------------------------------------
61
  function connectSignaling() {
@@ -75,6 +95,7 @@ function connectSignaling() {
75
  else if (room()) setStatus(`accepted into private room "${room()}"`);
76
  // I'm newest: initiate to everyone already here
77
  for (const p of msg.peers) { names.set(p.id, p.name); initiatePeer(p.id); }
 
78
  } else if (msg.type === "waiting") {
79
  setStatus("knocking — waiting for the room's host to let you in…");
80
  } else if (msg.type === "denied") {
@@ -88,6 +109,7 @@ function connectSignaling() {
88
  addJoinRequest(msg.id, msg.name);
89
  } else if (msg.type === "peer-joined") {
90
  names.set(msg.id, msg.name);
 
91
  log(`${msg.name} joined (they will connect to me)`);
92
  } else if (msg.type === "peer-left") {
93
  log(`${nmeOf(msg.id)} left`); cleanupPeer(msg.id); names.delete(msg.id); updatePeers();
@@ -173,8 +195,10 @@ function parseCheckpoint(buf) {
173
  const magic = String.fromCharCode(...new Uint8Array(buf, 0, 8));
174
  if (magic !== CKPT_MAGIC) throw new Error("not a DaisyChain v2 checkpoint");
175
  const [c, t, vocab, steps] = new Int32Array(buf, 8, 4);
176
- if (vocab !== Transformer.VOCAB) throw new Error(`vocab mismatch (file ${vocab}, this build ${Transformer.VOCAB})`);
177
- if (c < 16 || c > 64 || t < 16 || t > 64) throw new Error(`bad dims in checkpoint (width ${c}, seq ${t})`);
 
 
178
  return { c, t, steps, flat: new Float32Array(buf.slice(24)) };
179
  }
180
  function applyCheckpoint(ck, from) {
@@ -185,7 +209,7 @@ function applyCheckpoint(ck, from) {
185
  if (ck.flat.length !== model.nParams) throw new Error("truncated checkpoint");
186
  Transformer.setFlatParams(model, ck.flat);
187
  trainedSteps = ck.steps;
188
- ui.save.disabled = false;
189
  ui.step.textContent = `${ck.steps} baked in`;
190
  log(`checkpoint loaded (${ck.steps} steps) ${from ? "from " + from : "from file"} — all set to resume`);
191
  }
@@ -195,9 +219,65 @@ function broadcastCheckpoint() {
195
  new Int32Array(msg, 0, 1)[0] = CKPT_SENTINEL;
196
  new Uint8Array(msg, 4).set(new Uint8Array(ck));
197
  let n = 0;
198
- for (const dc of chans.values()) if (dc.readyState === "open") { dc.send(msg); n++; }
199
- log(`checkpoint pushed to ${n} device(s)`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  }
 
201
  function saveCheckpoint() {
202
  const blob = new Blob([packCheckpoint()], { type: "application/octet-stream" });
203
  const a = document.createElement("a");
@@ -210,8 +290,10 @@ function saveCheckpoint() {
210
  // ---- training config: whoever presses Start sets it for the whole group ----
211
  const CFG_SENTINEL = -3; // wire: [int32 -3][int32 c,t,b,steps][f32 lr]
212
  function readCfgFromUI() {
 
 
213
  return { c: +ui.cfgC.value, t: +ui.cfgT.value, b: +ui.cfgB.value,
214
- steps: +ui.cfgSteps.value, lr: +ui.cfgLr.value / 1000 };
215
  }
216
  function showCfgInUI(cfg) {
217
  const set = (el, vid, val) => { el.value = val; document.getElementById(vid).textContent = val; };
@@ -220,37 +302,109 @@ function showCfgInUI(cfg) {
220
  set(ui.cfgLr, "vcfgLr", Math.round(cfg.lr * 1000));
221
  }
222
  function broadcastConfig(cfg) {
223
- const buf = new ArrayBuffer(24);
224
  new Int32Array(buf, 0, 5).set([CFG_SENTINEL, cfg.c, cfg.t, cfg.b, cfg.steps]);
225
  new Float32Array(buf, 20, 1)[0] = cfg.lr;
226
- for (const dc of chans.values()) if (dc.readyState === "open") dc.send(buf);
 
227
  }
228
  function onConfig(peerId, buf) {
229
  if (training) { log(`ignored settings from ${nmeOf(peerId)} (already training)`); return; }
230
  const [, c, t, b, steps] = new Int32Array(buf, 0, 5);
231
  const lr = new Float32Array(buf, 20, 1)[0];
232
- if (!(c >= 16 && c <= 64 && c % 2 === 0 && t >= 16 && t <= 64 && b >= 1 && b <= 32 &&
 
 
 
 
 
 
233
  steps >= 1 && steps <= 10000 && lr > 0 && lr <= 0.2)) {
234
  log(`rejected bad settings from ${nmeOf(peerId)}`); return;
235
  }
236
  const cfg = { c, t, b, steps, lr };
 
237
  showCfgInUI(cfg);
238
  log(`${nmeOf(peerId)} started the group: width=${c}, seq=${t}, batch=${b}, ${steps} steps, lr=${lr}`);
239
  buildModel(cfg);
240
  train(cfg); // follow automatically
241
  }
242
 
243
- // ---- gradient wire format: [int32 step][float32 grad...] -----------------
244
- function packGrad(step, grad) {
245
- const buf = new ArrayBuffer(4 + grad.byteLength);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  new Int32Array(buf, 0, 1)[0] = step;
247
- new Float32Array(buf, 4).set(grad);
 
 
248
  return buf;
249
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  const waiters = new Set(); // pending waitForGrads checkers
251
  function wake() { for (const w of waiters) w(); }
252
  function onGrad(peerId, buf) {
253
  const step = new Int32Array(buf, 0, 1)[0];
 
 
 
 
 
254
  if (step === CFG_SENTINEL) { onConfig(peerId, buf); return; }
255
  if (step === CKPT_SENTINEL) { // a peer pushed a checkpoint
256
  if (training) { log(`ignored checkpoint from ${nmeOf(peerId)} (training in progress)`); return; }
@@ -258,27 +412,35 @@ function onGrad(peerId, buf) {
258
  catch (e) { log(`bad checkpoint from ${nmeOf(peerId)}: ${e.message}`); }
259
  return;
260
  }
261
- const grad = new Float32Array(buf.slice(4));
 
 
 
 
 
 
 
 
262
  if (!incoming.has(step)) incoming.set(step, new Map());
263
  incoming.get(step).set(peerId, grad);
 
 
264
  wake(); // resolve waits immediately (no polling)
265
  }
266
- function broadcastGrad(step, grad) { const b = packGrad(step, grad); for (const dc of chans.values()) if (dc.readyState === "open") dc.send(b); }
267
  // Event-driven: re-checked on every gradient arrival and peer departure, plus a
268
  // coarse fallback timer (background tabs throttle timers to ~1s, so the old
269
  // 15ms poll was the bottleneck there). Peers that left are dropped from the
270
  // wait — a device dying no longer costs the full timeout every step.
271
- function waitForGrads(step, cohort, timeoutMs = 8000) {
272
  return new Promise((resolve) => {
273
  const t0 = Date.now();
274
  let timer = null;
275
  const check = () => {
276
- const live = cohort.filter(id => chans.has(id)); // prune departed peers
277
- const got = incoming.get(step) || new Map();
278
- const have = live.filter(id => got.has(id));
279
- if (have.length === live.length || Date.now() - t0 > timeoutMs) {
280
  waiters.delete(check); clearInterval(timer);
281
- resolve(have.map(id => got.get(id)));
282
  }
283
  };
284
  waiters.add(check);
@@ -286,6 +448,15 @@ function waitForGrads(step, cohort, timeoutMs = 8000) {
286
  check();
287
  });
288
  }
 
 
 
 
 
 
 
 
 
289
 
290
  // ---- compute: one async training step THROUGH the verified units -----------
291
  async function localStep() {
@@ -297,40 +468,117 @@ async function localStep() {
297
  async function train(cfg) {
298
  if (training) return; training = true; ui.start.disabled = true;
299
  cfgSliders().forEach(el => el.disabled = true);
 
 
300
  const cohort = [...chans.keys()]; // lock the cohort (departed peers are pruned per-step)
301
  const steps = cfg.steps;
302
  const opt = TrainCore.makeAdam(model.nParams, { lr: cfg.lr });
303
  log(`training started — cohort ${cohort.length} peer(s), world ${cohort.length + 1}, ` +
304
- `width=${cfg.c} seq=${cfg.t} batch=${cfg.b}×${cohort.length + 1}, optimizer ${opt.name}`);
 
 
 
 
 
 
 
 
 
 
305
  for (let s = 0; s < steps; s++) {
 
306
  const { loss, grad } = await localStep();
307
- broadcastGrad(s, grad);
308
- const remote = cohort.length ? await waitForGrads(s, cohort) : [];
309
- const all = [grad, ...remote];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  const avg = TrainCore.averageGrads(all);
311
  const upd = opt.step(avg); // DaisyAdam on the cluster-avg grad
312
  Transformer.applyUpdate(model, upd); // W -= upd (lr folded into upd)
313
- incoming.delete(s);
314
  trainedSteps++;
315
  if (s % 10 === 0 || s === steps - 1) {
316
- ui.loss.textContent = loss.toFixed(5);
317
  ui.step.textContent = `${s + 1} / ${steps}`;
318
  ui.bar.style.width = `${Math.round(100 * (s + 1) / steps)}%`;
319
  await new Promise(r => setTimeout(r, 0)); // yield to UI
320
  }
 
 
 
321
  }
322
- log(`training done final loss ${ui.loss.textContent}`);
323
- try {
324
- const sample = await Transformer.generate(model, "the ", 70);
325
- ui.diff.textContent = `the model speaks: “${sample.trim()}”`;
326
- } catch (e) {
327
- ui.diff.textContent = "done trained through the verified units; all peers share one model.";
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  }
 
 
329
  training = false;
330
- ui.save.disabled = false;
331
  ui.start.disabled = false;
332
  cfgSliders().forEach(el => el.disabled = false);
333
  }
 
 
 
 
 
334
  function cfgSliders() { return [ui.cfgC, ui.cfgT, ui.cfgB, ui.cfgSteps, ui.cfgLr]; }
335
 
336
  // (re)build the mini transformer — deterministic shared init (same seeds on
@@ -386,12 +634,25 @@ function buildModel(cfg) {
386
  ui.start.disabled = true;
387
  return; // no signaling, no training
388
  }
389
- ui.backend.textContent = `${compute.backend.toUpperCase()} — ${compute.label} · through verified INT8 units`;
 
 
 
 
 
 
 
 
 
 
 
 
390
  // slider readouts
391
  for (const [el, v] of [[ui.cfgC, "vcfgC"], [ui.cfgT, "vcfgT"], [ui.cfgB, "vcfgB"],
392
  [ui.cfgSteps, "vcfgSteps"], [ui.cfgLr, "vcfgLr"]])
393
  el.oninput = () => document.getElementById(v).textContent = el.value;
394
  buildModel(readCfgFromUI());
 
395
  ui.me.textContent = deviceName;
396
  if (room()) {
397
  ui.roomInfo.style.display = "";
@@ -404,13 +665,32 @@ function buildModel(cfg) {
404
  }
405
  updatePeers();
406
  connectSignaling();
 
 
 
 
 
 
 
 
 
407
  ui.start.onclick = () => {
408
  const cfg = readCfgFromUI();
 
409
  buildModel(cfg);
410
  broadcastConfig(cfg); // everyone follows these settings
411
  train(cfg);
412
  };
413
  ui.save.onclick = saveCheckpoint;
 
 
 
 
 
 
 
 
 
414
  ui.loadBtn.onclick = () => { if (training) { log("can't load a checkpoint mid-training"); return; } ui.load.click(); };
415
  ui.load.onchange = async () => {
416
  const f = ui.load.files[0]; ui.load.value = "";
 
29
  cfgB: document.getElementById("cfgB"),
30
  cfgSteps: document.getElementById("cfgSteps"),
31
  cfgLr: document.getElementById("cfgLr"),
32
+ dataset: document.getElementById("dataset"),
33
+ tokenizer: document.getElementById("tokenizer"),
34
+ kit: document.getElementById("kit"),
35
+ genPrompt: document.getElementById("genPrompt"),
36
+ genBtn: document.getElementById("genBtn"),
37
+ genOut: document.getElementById("genOut"),
38
  };
39
  function log(m) { ui.log.textContent = `${new Date().toLocaleTimeString()} ${m}\n` + ui.log.textContent; }
40
  function setStatus(s) { ui.status.textContent = s; }
 
58
  const incoming = new Map(); // step -> Map(peerId -> Float32Array)
59
  let model = null, training = false; // the mini transformer (transformer.js)
60
  let trainedSteps = 0; // steps baked into the current weights
61
+ // ---- sync guard state -------------------------------------------------------
62
+ // The peer that presses Start is the round leader. Every step the leader
63
+ // publishes the exact set of contributors to average (the roster); followers
64
+ // apply that set verbatim or stop — no device may silently average a
65
+ // different set, which is what used to fork the weights.
66
+ let leaderId = null; // null => I lead (I pressed Start)
67
+ const rosters = new Map(); // step -> [peerId,...] from the leader
68
+ const peerHashes = new Map(); // step -> Map(peerId -> uint32 pre-step weight hash)
69
  function nmeOf(id) { return names.get(id) || id; }
70
 
71
  function room() { return new URLSearchParams(location.search).get("room"); } // null -> group by network
72
+ // show EVERY device the room knows about, not just open data channels —
73
+ // a peer whose WebRTC is still connecting (or failed) must stay visible
74
+ function updatePeers() {
75
+ if (!names.size) { ui.peers.textContent = "(none yet)"; return; }
76
+ ui.peers.textContent = [...names.entries()]
77
+ .map(([id, n]) => `${n} ${chans.has(id) ? "✓" : "(connecting…)"}`).join(", ");
78
+ }
79
 
80
  // ---- signaling + WebRTC ----------------------------------------------------
81
  function connectSignaling() {
 
95
  else if (room()) setStatus(`accepted into private room "${room()}"`);
96
  // I'm newest: initiate to everyone already here
97
  for (const p of msg.peers) { names.set(p.id, p.name); initiatePeer(p.id); }
98
+ updatePeers();
99
  } else if (msg.type === "waiting") {
100
  setStatus("knocking — waiting for the room's host to let you in…");
101
  } else if (msg.type === "denied") {
 
109
  addJoinRequest(msg.id, msg.name);
110
  } else if (msg.type === "peer-joined") {
111
  names.set(msg.id, msg.name);
112
+ updatePeers();
113
  log(`${msg.name} joined (they will connect to me)`);
114
  } else if (msg.type === "peer-left") {
115
  log(`${nmeOf(msg.id)} left`); cleanupPeer(msg.id); names.delete(msg.id); updatePeers();
 
195
  const magic = String.fromCharCode(...new Uint8Array(buf, 0, 8));
196
  if (magic !== CKPT_MAGIC) throw new Error("not a DaisyChain v2 checkpoint");
197
  const [c, t, vocab, steps] = new Int32Array(buf, 8, 4);
198
+ if (vocab !== Transformer.vocabSize())
199
+ throw new Error(`tokenizer mismatch checkpoint has a ${vocab}-token vocab, this device loaded ` +
200
+ `${Transformer.vocabSize()} (${Transformer.tokenizerName()}); use matching builds`);
201
+ if (c < 16 || c > 128 || t < 16 || t > 128) throw new Error(`bad dims in checkpoint (width ${c}, seq ${t})`);
202
  return { c, t, steps, flat: new Float32Array(buf.slice(24)) };
203
  }
204
  function applyCheckpoint(ck, from) {
 
209
  if (ck.flat.length !== model.nParams) throw new Error("truncated checkpoint");
210
  Transformer.setFlatParams(model, ck.flat);
211
  trainedSteps = ck.steps;
212
+ modelReady();
213
  ui.step.textContent = `${ck.steps} baked in`;
214
  log(`checkpoint loaded (${ck.steps} steps) ${from ? "from " + from : "from file"} — all set to resume`);
215
  }
 
219
  new Int32Array(msg, 0, 1)[0] = CKPT_SENTINEL;
220
  new Uint8Array(msg, 4).set(new Uint8Array(ck));
221
  let n = 0;
222
+ for (const dc of chans.values()) if (dc.readyState === "open") { dcSend(dc, msg); n++; }
223
+ log(`checkpoint pushed to ${n} device(s) (${(msg.byteLength / 1048576).toFixed(1)} MB)`);
224
+ }
225
+ // ---- inference kit: one self-contained HTML file with the trained weights --
226
+ // Bundles the model code + current checkpoint (base64) + a prompt box into a
227
+ // single file that runs generations offline — no server, nothing to install.
228
+ // The mul8 LUT is rebuilt in the file itself (it is exactly a×b for int8).
229
+ async function downloadInferenceKit() {
230
+ const srcs = await Promise.all(["traincore.js", "verified_core.js", "transformer.js"]
231
+ .map(f => fetch(f).then(r => r.text())));
232
+ let tokData = "null"; // embed the tokenizer (offline file)
233
+ try { tokData = await (await fetch("tokenizer.json")).text(); } catch (e) {}
234
+ const ck = new Uint8Array(packCheckpoint());
235
+ let b64 = ""; const CH = 0x8000;
236
+ for (let i = 0; i < ck.length; i += CH) b64 += String.fromCharCode(...ck.subarray(i, i + CH));
237
+ b64 = btoa(b64);
238
+ const esc = (s) => s.replace(/<\/script/gi, "<\\/script");
239
+ const html = `<!doctype html><html><head><meta charset="utf-8">
240
+ <meta name="viewport" content="width=device-width, initial-scale=1">
241
+ <title>DaisyChain inference — ${trainedSteps} steps</title>
242
+ <style>body{font-family:sans-serif;max-width:680px;margin:0 auto;padding:24px 16px;background:#efe4c9;color:#2a1d0a}
243
+ input{width:100%;padding:10px;border-radius:8px;border:1px solid #8b6f47;font-family:monospace;box-sizing:border-box}
244
+ button{margin-top:10px;padding:10px 24px;border:0;border-radius:8px;background:#4a7c2e;color:#f5ecd9;font-weight:700;cursor:pointer}
245
+ pre{background:#fbf6e8;border-radius:8px;padding:12px;white-space:pre-wrap;min-height:60px}</style></head><body>
246
+ <h2>🌼 DaisyChain model — inference</h2>
247
+ <p>Trained ${trainedSteps} steps · width ${model.cfg.c} · seq ${model.cfg.t} · dataset: ${Transformer.datasetName()}.
248
+ Runs entirely in this file through the verified INT8 units (CPU LUT).</p>
249
+ <input id="p" value="the "><button id="g">Generate</button><pre id="o"></pre>
250
+ <script>${esc(srcs[0])}<\/script><script>${esc(srcs[1])}<\/script><script>${esc(srcs[2])}<\/script>
251
+ <script>
252
+ const mul = new Int16Array(65536);
253
+ for (let a = 0; a < 256; a++) for (let b = 0; b < 256; b++)
254
+ mul[a*256+b] = ((a>127?a-256:a) * (b>127?b-256:b));
255
+ const L = { mul };
256
+ const TOKDATA = ${esc(tokData)};
257
+ if (TOKDATA) Transformer.loadTokenizerData(TOKDATA);
258
+ const bytes = Uint8Array.from(atob("${b64}"), c => c.charCodeAt(0));
259
+ const buf = bytes.buffer;
260
+ const dims = new Int32Array(buf, 8, 4); // c, t, vocab, steps
261
+ const m = Transformer.init({ c: dims[0], t: dims[1], b: 1, steps: 0, lr: 0 }, L,
262
+ (Xq, Wq, mm, kk, nn, LL) => Verified.lutMatmulJS(Xq, Wq, mm, kk, nn, LL));
263
+ Transformer.setFlatParams(m, new Float32Array(buf.slice(24)));
264
+ document.getElementById("g").onclick = async () => {
265
+ const b = document.getElementById("g"), o = document.getElementById("o");
266
+ b.disabled = true; o.textContent = "generating…";
267
+ try { o.textContent = await Transformer.generate(m, document.getElementById("p").value || "the ", 150); }
268
+ catch (e) { o.textContent = "error: " + e.message; }
269
+ b.disabled = false;
270
+ };
271
+ <\/script></body></html>`;
272
+ const blob = new Blob([html], { type: "text/html" });
273
+ const a = document.createElement("a");
274
+ a.href = URL.createObjectURL(blob);
275
+ a.download = `daisychain-inference-step${trainedSteps}.html`;
276
+ a.click();
277
+ URL.revokeObjectURL(a.href);
278
+ log("inference kit downloaded — a single HTML file: open it anywhere, type a prompt, generate");
279
  }
280
+
281
  function saveCheckpoint() {
282
  const blob = new Blob([packCheckpoint()], { type: "application/octet-stream" });
283
  const a = document.createElement("a");
 
290
  // ---- training config: whoever presses Start sets it for the whole group ----
291
  const CFG_SENTINEL = -3; // wire: [int32 -3][int32 c,t,b,steps][f32 lr]
292
  function readCfgFromUI() {
293
+ // lr crosses the wire as float32 — fround here so the leader trains with
294
+ // the exact same value the followers decode (else Adam forks the weights)
295
  return { c: +ui.cfgC.value, t: +ui.cfgT.value, b: +ui.cfgB.value,
296
+ steps: +ui.cfgSteps.value, lr: Math.fround(+ui.cfgLr.value / 1000) };
297
  }
298
  function showCfgInUI(cfg) {
299
  const set = (el, vid, val) => { el.value = val; document.getElementById(vid).textContent = val; };
 
302
  set(ui.cfgLr, "vcfgLr", Math.round(cfg.lr * 1000));
303
  }
304
  function broadcastConfig(cfg) {
305
+ const buf = new ArrayBuffer(28);
306
  new Int32Array(buf, 0, 5).set([CFG_SENTINEL, cfg.c, cfg.t, cfg.b, cfg.steps]);
307
  new Float32Array(buf, 20, 1)[0] = cfg.lr;
308
+ new Int32Array(buf, 24, 1)[0] = Transformer.vocabSize(); // tokenizer must match
309
+ broadcast(buf);
310
  }
311
  function onConfig(peerId, buf) {
312
  if (training) { log(`ignored settings from ${nmeOf(peerId)} (already training)`); return; }
313
  const [, c, t, b, steps] = new Int32Array(buf, 0, 5);
314
  const lr = new Float32Array(buf, 20, 1)[0];
315
+ const vocab = buf.byteLength >= 28 ? new Int32Array(buf, 24, 1)[0] : -1;
316
+ if (vocab !== Transformer.vocabSize()) {
317
+ log(`NOT JOINING: ${nmeOf(peerId)} uses a ${vocab}-token tokenizer, this device has ` +
318
+ `${Transformer.vocabSize()} (${Transformer.tokenizerName()}) — refresh so all devices match`);
319
+ return;
320
+ }
321
+ if (!(c >= 16 && c <= 128 && c % 2 === 0 && t >= 16 && t <= 128 && b >= 1 && b <= 32 &&
322
  steps >= 1 && steps <= 10000 && lr > 0 && lr <= 0.2)) {
323
  log(`rejected bad settings from ${nmeOf(peerId)}`); return;
324
  }
325
  const cfg = { c, t, b, steps, lr };
326
+ leaderId = peerId; // the starter leads sync for this run
327
  showCfgInUI(cfg);
328
  log(`${nmeOf(peerId)} started the group: width=${c}, seq=${t}, batch=${b}, ${steps} steps, lr=${lr}`);
329
  buildModel(cfg);
330
  train(cfg); // follow automatically
331
  }
332
 
333
+ // ---- big-message fragmentation ----------------------------------------------
334
+ // WebRTC data channels cap a single message (~256KB Chrome, 64KB elsewhere).
335
+ // Gradients with the Spikewhale vocab are multi-MB, and checkpoints always
336
+ // were at width ≥ 64 — so anything large goes out in 48KB chunks:
337
+ // [int32 -5][int32 msgId][int32 seq][int32 total][bytes...]
338
+ // Channels are ordered+reliable, so chunks arrive in order per peer.
339
+ const FRAG_SENTINEL = -5, FRAG_CHUNK = 48 * 1024;
340
+ let fragSeq = 1;
341
+ const fragIn = new Map(); // peerId -> {id, parts, got, total}
342
+ function dcSend(dc, buf) {
343
+ if (buf.byteLength <= FRAG_CHUNK) { dc.send(buf); return; }
344
+ const id = fragSeq++, src = new Uint8Array(buf);
345
+ const total = Math.ceil(src.length / FRAG_CHUNK);
346
+ for (let s = 0; s < total; s++) {
347
+ const part = src.subarray(s * FRAG_CHUNK, Math.min((s + 1) * FRAG_CHUNK, src.length));
348
+ const msg = new ArrayBuffer(16 + part.length);
349
+ new Int32Array(msg, 0, 4).set([FRAG_SENTINEL, id, s, total]);
350
+ new Uint8Array(msg, 16).set(part);
351
+ dc.send(msg);
352
+ }
353
+ }
354
+ function broadcast(buf) { for (const dc of chans.values()) if (dc.readyState === "open") dcSend(dc, buf); }
355
+ function onFragment(peerId, buf) { // returns full message when complete
356
+ const [, id, seq, total] = new Int32Array(buf, 0, 4);
357
+ let st = fragIn.get(peerId);
358
+ if (!st || st.id !== id) { st = { id, parts: [], got: 0, total }; fragIn.set(peerId, st); }
359
+ st.parts[seq] = new Uint8Array(buf, 16).slice(0);
360
+ st.got++;
361
+ if (st.got < st.total) return null;
362
+ fragIn.delete(peerId);
363
+ let len = 0; for (const p of st.parts) len += p.length;
364
+ const out = new Uint8Array(len);
365
+ let off = 0; for (const p of st.parts) { out.set(p, off); off += p.length; }
366
+ return out.buffer;
367
+ }
368
+
369
+ // ---- gradient wire format: [int32 step][uint32 whash][f32 loss][f32 grad...]
370
+ // whash = FNV-1a of the sender's weights BEFORE this step's update, so every
371
+ // peer can verify the whole group is still training the same model.
372
+ // loss = the sender's local batch loss, so everyone can show the true
373
+ // cluster-average loss and log what each device contributes.
374
+ function packGrad(step, whash, loss, grad) {
375
+ const buf = new ArrayBuffer(12 + grad.byteLength);
376
  new Int32Array(buf, 0, 1)[0] = step;
377
+ new Uint32Array(buf, 4, 1)[0] = whash;
378
+ new Float32Array(buf, 8, 1)[0] = loss;
379
+ new Float32Array(buf, 12).set(grad);
380
  return buf;
381
  }
382
+ function hashWeights() { // FNV-1a over the flat param bytes
383
+ const f = Transformer.getFlatParams(model);
384
+ const b = new Uint8Array(f.buffer, f.byteOffset, f.byteLength);
385
+ let h = 0x811c9dc5;
386
+ for (let i = 0; i < b.length; i++) { h ^= b[i]; h = Math.imul(h, 0x01000193); }
387
+ return h >>> 0;
388
+ }
389
+ // roster wire: [int32 -4][int32 step][int32 n][int32 peerNums...]
390
+ const ROSTER_SENTINEL = -4;
391
+ function broadcastRoster(step, ids) {
392
+ const nums = ids.map(id => +id.slice(1)); // "p12" -> 12
393
+ const buf = new ArrayBuffer(12 + 4 * nums.length);
394
+ const iv = new Int32Array(buf);
395
+ iv[0] = ROSTER_SENTINEL; iv[1] = step; iv[2] = nums.length;
396
+ iv.set(nums, 3);
397
+ broadcast(buf);
398
+ }
399
  const waiters = new Set(); // pending waitForGrads checkers
400
  function wake() { for (const w of waiters) w(); }
401
  function onGrad(peerId, buf) {
402
  const step = new Int32Array(buf, 0, 1)[0];
403
+ if (step === FRAG_SENTINEL) { // chunk of a large message
404
+ const whole = onFragment(peerId, buf);
405
+ if (whole) onGrad(peerId, whole);
406
+ return;
407
+ }
408
  if (step === CFG_SENTINEL) { onConfig(peerId, buf); return; }
409
  if (step === CKPT_SENTINEL) { // a peer pushed a checkpoint
410
  if (training) { log(`ignored checkpoint from ${nmeOf(peerId)} (training in progress)`); return; }
 
412
  catch (e) { log(`bad checkpoint from ${nmeOf(peerId)}: ${e.message}`); }
413
  return;
414
  }
415
+ if (step === ROSTER_SENTINEL) { // the leader's contributor set for a step
416
+ if (training && peerId !== leaderId) return; // only the run's leader may steer sync
417
+ const iv = new Int32Array(buf);
418
+ rosters.set(iv[1], [...iv.slice(3, 3 + iv[2])].map(n => "p" + n));
419
+ wake(); return;
420
+ }
421
+ const whash = new Uint32Array(buf, 4, 1)[0];
422
+ const loss = new Float32Array(buf, 8, 1)[0];
423
+ const grad = new Float32Array(buf.slice(12));
424
  if (!incoming.has(step)) incoming.set(step, new Map());
425
  incoming.get(step).set(peerId, grad);
426
+ if (!peerHashes.has(step)) peerHashes.set(step, new Map());
427
+ peerHashes.get(step).set(peerId, { hash: whash, loss });
428
  wake(); // resolve waits immediately (no polling)
429
  }
430
+ function broadcastGrad(step, whash, loss, grad) { broadcast(packGrad(step, whash, loss, grad)); }
431
  // Event-driven: re-checked on every gradient arrival and peer departure, plus a
432
  // coarse fallback timer (background tabs throttle timers to ~1s, so the old
433
  // 15ms poll was the bottleneck there). Peers that left are dropped from the
434
  // wait — a device dying no longer costs the full timeout every step.
435
+ function waitFor(pred, timeoutMs) { // resolves true if pred held, false on timeout
436
  return new Promise((resolve) => {
437
  const t0 = Date.now();
438
  let timer = null;
439
  const check = () => {
440
+ const ok = pred();
441
+ if (ok || Date.now() - t0 > timeoutMs) {
 
 
442
  waiters.delete(check); clearInterval(timer);
443
+ resolve(!!ok);
444
  }
445
  };
446
  waiters.add(check);
 
448
  check();
449
  });
450
  }
451
+ async function waitForGradIds(step, cohort, timeoutMs = 8000) {
452
+ await waitFor(() => {
453
+ const live = cohort.filter(id => chans.has(id)); // prune departed peers
454
+ const got = incoming.get(step) || new Map();
455
+ return live.every(id => got.has(id));
456
+ }, timeoutMs);
457
+ const got = incoming.get(step) || new Map();
458
+ return cohort.filter(id => chans.has(id) && got.has(id));
459
+ }
460
 
461
  // ---- compute: one async training step THROUGH the verified units -----------
462
  async function localStep() {
 
468
  async function train(cfg) {
469
  if (training) return; training = true; ui.start.disabled = true;
470
  cfgSliders().forEach(el => el.disabled = true);
471
+ incoming.clear(); rosters.clear(); peerHashes.clear(); // no stale grads from a previous run
472
+ const iLead = leaderId === null; // set by Start (null) or onConfig (starter's id)
473
  const cohort = [...chans.keys()]; // lock the cohort (departed peers are pruned per-step)
474
  const steps = cfg.steps;
475
  const opt = TrainCore.makeAdam(model.nParams, { lr: cfg.lr });
476
  log(`training started — cohort ${cohort.length} peer(s), world ${cohort.length + 1}, ` +
477
+ `width=${cfg.c} seq=${cfg.t} batch=${cfg.b}×${cohort.length + 1}, optimizer ${opt.name}` +
478
+ (cohort.length ? `, sync ${iLead ? "led by me" : "led by " + nmeOf(leaderId)}` : "") +
479
+ ` · data: ${Transformer.datasetName()}` +
480
+ ` · grad payload ${(model.nParams * 4 / 1048576).toFixed(1)} MB/step/peer`);
481
+ if ((cfg.c > 64 || cfg.t > 64) && cohort.length + 1 < 4)
482
+ log(`⚠ large model (width ${cfg.c}, seq ${cfg.t}) with only ${cohort.length + 1} device(s) — ` +
483
+ `steps will be slow and the effective batch small. 4+ devices recommended at this size.`);
484
+ let halted = null; // sync-guard stop reason
485
+ const contrib = new Map([[myId, 0]]); // peerId -> grads contributed
486
+ for (const id of cohort) contrib.set(id, 0);
487
+ try {
488
  for (let s = 0; s < steps; s++) {
489
+ const whash = hashWeights(); // pre-step fingerprint, sent with the grad
490
  const { loss, grad } = await localStep();
491
+ broadcastGrad(s, whash, loss, grad);
492
+ let all;
493
+ if (!cohort.length) {
494
+ all = [grad]; // solo run
495
+ } else if (iLead) {
496
+ const ids = await waitForGradIds(s, cohort);
497
+ // sync guard: publish the exact contributor set so every device
498
+ // averages the same gradients (or stops) — never a silent fork
499
+ broadcastRoster(s, [myId, ...ids]);
500
+ all = [grad, ...ids.map(id => incoming.get(s).get(id))];
501
+ rosters.set(s, [myId, ...ids]);
502
+ } else {
503
+ // follower: apply the leader's roster verbatim, or stop
504
+ await waitFor(() => rosters.has(s) || !chans.has(leaderId), 15000);
505
+ if (!rosters.has(s)) {
506
+ halted = chans.has(leaderId)
507
+ ? `no roster from ${nmeOf(leaderId)} for step ${s + 1}`
508
+ : `the sync leader (${nmeOf(leaderId)}) disconnected`;
509
+ break;
510
+ }
511
+ const roster = rosters.get(s);
512
+ const need = roster.filter(id => id !== myId);
513
+ const ok = await waitFor(() => need.every(id => (incoming.get(s) || new Map()).has(id)), 10000);
514
+ if (!ok) {
515
+ halted = `missing a roster gradient at step ${s + 1} — applying a partial average would fork the weights`;
516
+ break;
517
+ }
518
+ all = need.map(id => incoming.get(s).get(id));
519
+ if (roster.includes(myId)) all.unshift(grad); // leader may have dropped my late grad — then I skip it too
520
+ }
521
+ // divergence check: every contributor's pre-step weight hash must match mine
522
+ const hs = peerHashes.get(s);
523
+ const roster = rosters.get(s) || [myId];
524
+ if (hs) {
525
+ const bad = roster.find(id => id !== myId && hs.has(id) && hs.get(id).hash !== whash);
526
+ if (bad) { halted = `weights diverged from ${nmeOf(bad)} (detected at step ${s + 1})`; break; }
527
+ }
528
+ // cluster-average loss over this step's actual contributors
529
+ let lossSum = 0, lossN = 0;
530
+ for (const id of roster) {
531
+ if (id === myId) { lossSum += loss; lossN++; contrib.set(myId, (contrib.get(myId) || 0) + 1); }
532
+ else if (hs && hs.has(id)) { lossSum += hs.get(id).loss; lossN++; contrib.set(id, (contrib.get(id) || 0) + 1); }
533
+ }
534
+ const clusterLoss = lossSum / Math.max(1, lossN);
535
  const avg = TrainCore.averageGrads(all);
536
  const upd = opt.step(avg); // DaisyAdam on the cluster-avg grad
537
  Transformer.applyUpdate(model, upd); // W -= upd (lr folded into upd)
538
+ incoming.delete(s); rosters.delete(s); peerHashes.delete(s);
539
  trainedSteps++;
540
  if (s % 10 === 0 || s === steps - 1) {
541
+ ui.loss.textContent = clusterLoss.toFixed(5);
542
  ui.step.textContent = `${s + 1} / ${steps}`;
543
  ui.bar.style.width = `${Math.round(100 * (s + 1) / steps)}%`;
544
  await new Promise(r => setTimeout(r, 0)); // yield to UI
545
  }
546
+ if (cohort.length && (s + 1) % 50 === 0) // who is contributing what
547
+ log(`step ${s + 1}: contributions — ` +
548
+ [...contrib.entries()].map(([id, n]) => `${id === myId ? "me" : nmeOf(id)} ${n}/${s + 1}`).join(", "));
549
  }
550
+ } catch (e) { // a device that errors REPORTS it
551
+ halted = `error during training: ${e.message}`; // (instead of freezing at "—")
552
+ console.error(e);
553
+ }
554
+ if (cohort.length)
555
+ log(`contribution totals` +
556
+ [...contrib.entries()].map(([id, n]) => `${id === myId ? "me" : nmeOf(id)} ${n} grad(s)`).join(", "));
557
+ if (halted) {
558
+ log(`SYNC GUARD: stopped — ${halted}. The model here is intact (${trainedSteps} steps); ` +
559
+ `to re-sync the group, load/push a checkpoint and start again.`);
560
+ ui.diff.textContent = "stopped by the sync guard — see log";
561
+ } else {
562
+ log(`training done — final loss ${ui.loss.textContent}`);
563
+ try {
564
+ const sample = await Transformer.generate(model, "the ", 70);
565
+ ui.diff.textContent = `the model speaks: “${sample.trim()}”`;
566
+ } catch (e) {
567
+ ui.diff.textContent = "done — trained through the verified units; all peers share one model.";
568
+ }
569
  }
570
+ incoming.clear(); rosters.clear(); peerHashes.clear();
571
+ leaderId = null;
572
  training = false;
573
+ modelReady();
574
  ui.start.disabled = false;
575
  cfgSliders().forEach(el => el.disabled = false);
576
  }
577
+ function modelReady() { // trained weights exist: enable save/kit/generate
578
+ ui.save.disabled = false;
579
+ ui.kit.disabled = false;
580
+ ui.genBtn.disabled = false;
581
+ }
582
  function cfgSliders() { return [ui.cfgC, ui.cfgT, ui.cfgB, ui.cfgSteps, ui.cfgLr]; }
583
 
584
  // (re)build the mini transformer — deterministic shared init (same seeds on
 
634
  ui.start.disabled = true;
635
  return; // no signaling, no training
636
  }
637
+ ui.backend.textContent = `${compute.backend.toUpperCase()} — ${compute.label} · 3×INT8 fast-accurate through verified units`;
638
+ // tokenizer must load BEFORE the model is built (it sets the vocab size)
639
+ try {
640
+ const name = await Transformer.loadTokenizer();
641
+ ui.tokenizer.textContent = name;
642
+ log(`tokenizer: ${name}`);
643
+ } catch (e) {
644
+ ui.tokenizer.textContent = Transformer.tokenizerName();
645
+ log(`tokenizer.json unavailable (${e.message}) — using the ${Transformer.tokenizerName()} vocab`);
646
+ }
647
+ if (compute.backend === "cpu" && Transformer.vocabSize() > 1000)
648
+ log(`⚠ CPU backend with a ${Transformer.vocabSize()}-token vocab — steps will take seconds; ` +
649
+ `a WebGPU-capable browser will be much faster`);
650
  // slider readouts
651
  for (const [el, v] of [[ui.cfgC, "vcfgC"], [ui.cfgT, "vcfgT"], [ui.cfgB, "vcfgB"],
652
  [ui.cfgSteps, "vcfgSteps"], [ui.cfgLr, "vcfgLr"]])
653
  el.oninput = () => document.getElementById(v).textContent = el.value;
654
  buildModel(readCfgFromUI());
655
+ ui.start.disabled = false; // solo training works too
656
  ui.me.textContent = deviceName;
657
  if (room()) {
658
  ui.roomInfo.style.display = "";
 
665
  }
666
  updatePeers();
667
  connectSignaling();
668
+ // dataset: stream FineWeb-Edu from HuggingFace; built-in corpus if offline
669
+ ui.dataset.textContent = "streaming FineWeb-Edu…";
670
+ Transformer.streamFineWebEdu().then(({ name, chars }) => {
671
+ ui.dataset.textContent = "FineWeb-Edu (streamed)";
672
+ log(`dataset: ${name} — ${(chars / 1000).toFixed(0)}k chars loaded (each device streams its own slice)`);
673
+ }).catch((e) => {
674
+ ui.dataset.textContent = "built-in corpus (offline)";
675
+ log(`dataset: FineWeb-Edu stream unavailable (${e.message}) — using the built-in corpus`);
676
+ });
677
  ui.start.onclick = () => {
678
  const cfg = readCfgFromUI();
679
+ leaderId = null; // I pressed Start: I lead sync
680
  buildModel(cfg);
681
  broadcastConfig(cfg); // everyone follows these settings
682
  train(cfg);
683
  };
684
  ui.save.onclick = saveCheckpoint;
685
+ ui.kit.onclick = downloadInferenceKit;
686
+ ui.genBtn.onclick = async () => {
687
+ if (training) { log("can't generate mid-training"); return; }
688
+ ui.genBtn.disabled = true;
689
+ ui.genOut.textContent = "generating…";
690
+ try { ui.genOut.textContent = await Transformer.generate(model, ui.genPrompt.value || "the ", 150); }
691
+ catch (e) { ui.genOut.textContent = `error: ${e.message}`; }
692
+ ui.genBtn.disabled = false;
693
+ };
694
  ui.loadBtn.onclick = () => { if (training) { log("can't load a checkpoint mid-training"); return; } ui.load.click(); };
695
  ui.load.onchange = async () => {
696
  const f = ui.load.files[0]; ui.load.value = "";
web/public/index.html CHANGED
@@ -90,6 +90,8 @@
90
  <div class="device" id="me">—</div>
91
  <div class="row" style="margin-top:8px"><span class="k">Status</span><span class="v" id="status">starting…</span></div>
92
  <div class="row"><span class="k">Compute</span><span class="v" id="backend">detecting…</span></div>
 
 
93
  </div>
94
 
95
  <div class="card">
@@ -106,21 +108,22 @@
106
  <div class="card">
107
  <div class="lbl">🎛 Training settings</div>
108
  <label class="slbl">Model width <span class="sval" id="vcfgC">32</span></label>
109
- <input type="range" id="cfgC" min="16" max="64" step="16" value="32">
110
  <label class="slbl">Sequence length <span class="sval" id="vcfgT">32</span></label>
111
- <input type="range" id="cfgT" min="16" max="64" step="16" value="32">
 
112
  <label class="slbl">Batch per device <span class="sval" id="vcfgB">8</span></label>
113
  <input type="range" id="cfgB" min="2" max="32" step="2" value="8">
114
  <label class="slbl">Steps <span class="sval" id="vcfgSteps">300</span></label>
115
  <input type="range" id="cfgSteps" min="100" max="10000" step="100" value="300">
116
  <label class="slbl">Learning rate ×1000 <span class="sval" id="vcfgLr">20</span></label>
117
  <input type="range" id="cfgLr" min="5" max="50" step="5" value="20">
118
- <p class="note" style="margin:.5rem 0 0">A mini transformer language model — attention, MLP blocks, next-character prediction — every multiply through the verified INT8 units. Whoever presses Start sets the settings for the whole group; total batch scales with every device that joins.</p>
119
  </div>
120
 
121
  <div class="card" style="text-align:center">
122
  <button id="start" disabled>Start training</button>
123
- <p class="note" style="margin:.6rem 0 0">Enabled once another device joins. (Or open a second tab to try it.)</p>
124
  </div>
125
 
126
  <div class="card">
@@ -131,10 +134,22 @@
131
  <div class="row" style="margin-top:10px"><span class="diff" id="diff"></span></div>
132
  </div>
133
 
 
 
 
 
 
 
 
 
 
 
 
134
  <div class="card">
135
  <div class="lbl">💾 Model checkpoint</div>
136
  <div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center">
137
  <button id="save" disabled>Download model (.pt)</button>
 
138
  <button id="loadBtn">Load checkpoint…</button>
139
  <input type="file" id="load" accept=".pt" style="display:none">
140
  </div>
 
90
  <div class="device" id="me">—</div>
91
  <div class="row" style="margin-top:8px"><span class="k">Status</span><span class="v" id="status">starting…</span></div>
92
  <div class="row"><span class="k">Compute</span><span class="v" id="backend">detecting…</span></div>
93
+ <div class="row"><span class="k">Dataset</span><span class="v" id="dataset">—</span></div>
94
+ <div class="row"><span class="k">Tokenizer</span><span class="v" id="tokenizer">loading…</span></div>
95
  </div>
96
 
97
  <div class="card">
 
108
  <div class="card">
109
  <div class="lbl">🎛 Training settings</div>
110
  <label class="slbl">Model width <span class="sval" id="vcfgC">32</span></label>
111
+ <input type="range" id="cfgC" min="16" max="128" step="16" value="32">
112
  <label class="slbl">Sequence length <span class="sval" id="vcfgT">32</span></label>
113
+ <input type="range" id="cfgT" min="16" max="128" step="16" value="32">
114
+ <p class="note" style="margin:.4rem 0 0"><b>Width or sequence above 64?</b> Bring more devices — big settings on 1–3 devices mean slow steps and a small effective batch. 4+ devices recommended.</p>
115
  <label class="slbl">Batch per device <span class="sval" id="vcfgB">8</span></label>
116
  <input type="range" id="cfgB" min="2" max="32" step="2" value="8">
117
  <label class="slbl">Steps <span class="sval" id="vcfgSteps">300</span></label>
118
  <input type="range" id="cfgSteps" min="100" max="10000" step="100" value="300">
119
  <label class="slbl">Learning rate ×1000 <span class="sval" id="vcfgLr">20</span></label>
120
  <input type="range" id="cfgLr" min="5" max="50" step="5" value="20">
121
+ <p class="note" style="margin:.5rem 0 0">A mini transformer language model — attention, MLP blocks, next-character prediction — every multiply through the verified INT8 units. Training text is streamed from <b>FineWeb-Edu</b> (HuggingFace); offline devices fall back to a built-in corpus. Whoever presses Start sets the settings for the whole group; total batch scales with every device that joins.</p>
122
  </div>
123
 
124
  <div class="card" style="text-align:center">
125
  <button id="start" disabled>Start training</button>
126
+ <p class="note" style="margin:.6rem 0 0">Works solo every device that joins adds its batch to the group.</p>
127
  </div>
128
 
129
  <div class="card">
 
134
  <div class="row" style="margin-top:10px"><span class="diff" id="diff"></span></div>
135
  </div>
136
 
137
+ <div class="card">
138
+ <div class="lbl">🗣 Test the model</div>
139
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
140
+ <input type="text" id="genPrompt" value="the " spellcheck="false"
141
+ style="flex:1;min-width:160px;padding:10px 12px;border-radius:8px;border:1px solid var(--card-border);background:transparent;color:inherit;font-family:'Courier New',monospace">
142
+ <button id="genBtn" disabled style="padding:10px 18px">Generate</button>
143
+ </div>
144
+ <pre id="genOut" style="margin-top:10px;min-height:44px"></pre>
145
+ <p class="note" style="margin:.5rem 0 0">Enabled after training finishes or a checkpoint is loaded. The “inference kit” below downloads a single HTML file with these weights baked in — open it anywhere to run generations offline.</p>
146
+ </div>
147
+
148
  <div class="card">
149
  <div class="lbl">💾 Model checkpoint</div>
150
  <div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center">
151
  <button id="save" disabled>Download model (.pt)</button>
152
+ <button id="kit" disabled>Download inference kit</button>
153
  <button id="loadBtn">Load checkpoint…</button>
154
  <input type="file" id="load" accept=".pt" style="display:none">
155
  </div>
web/public/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
web/public/transformer.js CHANGED
@@ -28,29 +28,96 @@
28
  s += `the ${pick(W_ADJ)} ${pick(W_NOUN)} ${pick(W_VERB)} ${pick(W_PREP)} the ${pick(W_ADJ)} ${pick(W_NOUN)}. `;
29
  return s;
30
  }
31
- const CORPUS = buildCorpus();
32
- const CHARS = [...new Set(CORPUS)].sort();
33
- const VOCAB = CHARS.length;
34
- const STOI = Object.fromEntries(CHARS.map((c, i) => [c, i]));
35
- const IDS = new Int32Array(CORPUS.length);
36
- for (let i = 0; i < CORPUS.length; i++) IDS[i] = STOI[CORPUS[i]];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  // ---- verified matmul: float in -> quantize -> LUT multiply -> dequant -----
39
  // weights/projections may go through WebGPU; attention (per-head, many small
40
  // matmuls) uses the CPU LUT path — same verified units, no dispatch overhead.
 
 
 
41
  async function vmm(Xf, Wf, m, k, n, ctx) {
42
- const xq = V.quantize(Xf), wq = V.quantize(Wf);
43
- const acc = await ctx.matmulInt8(xq.q, wq.q, m, k, n, ctx.L);
44
- const dq = xq.scale * wq.scale, out = new Float32Array(m * n);
45
- for (let i = 0; i < out.length; i++) out[i] = acc[i] * dq;
46
- return out;
47
  }
48
  function vmmCPU(Xf, Wf, m, k, n, ctx) {
49
- const xq = V.quantize(Xf), wq = V.quantize(Wf);
50
- const acc = V.lutMatmulJS(xq.q, wq.q, m, k, n, ctx.L);
51
- const dq = xq.scale * wq.scale, out = new Float32Array(m * n);
52
- for (let i = 0; i < out.length; i++) out[i] = acc[i] * dq;
53
- return out;
54
  }
55
 
56
  // ---- layernorm (no affine) -------------------------------------------------
@@ -84,9 +151,9 @@
84
  const params = [], names = [];
85
  const add = (name, w) => { params.push(w); names.push(name); return w; };
86
  const m = {
87
- cfg: { ...cfg, layers, heads, hidden, vocab: VOCAB },
88
  ctx: { L, matmulInt8 },
89
- emb: add("emb", mk(VOCAB * c, 0.08)),
90
  pos: add("pos", mk(cfg.t * c, 0.02)),
91
  blocks: [], params, names,
92
  };
@@ -96,7 +163,7 @@
96
  Wv: add(`b${l}.Wv`, mk(c * c, 0.08)), Wo: add(`b${l}.Wo`, mk(c * c, 0.08)),
97
  W1: add(`b${l}.W1`, mk(c * hidden, 0.08)), W2: add(`b${l}.W2`, mk(hidden * c, 0.08)),
98
  });
99
- m.Wu = add("Wu", mk(c * VOCAB, 0.08));
100
  m.nParams = params.reduce((a, p) => a + p.length, 0);
101
  return m;
102
  }
@@ -289,7 +356,7 @@
289
  // greedy sampling — watch the model actually speak
290
  async function generate(m, prompt, nChars) {
291
  const { t: T } = m.cfg;
292
- let ids = [...prompt].map(ch => STOI[ch] ?? 0);
293
  for (let step = 0; step < nChars; step++) {
294
  const win = ids.slice(-T);
295
  const X = new Int32Array(T), Y = new Int32Array(T);
@@ -302,10 +369,12 @@
302
  for (let j = 0; j < m.cfg.vocab; j++) if (logits[row + j] > bv) { bv = logits[row + j]; best = j; }
303
  ids.push(best);
304
  }
305
- return ids.map(i => CHARS[i]).join("");
306
  }
307
 
308
- const api = { init, trainStep, applyUpdate, getFlatParams, setFlatParams, generate, VOCAB, CORPUS };
 
 
309
  if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); V = require("./verified_core.js"); module.exports = api; }
310
  else { TC = root.TrainCore; V = root.Verified; root.Transformer = api; }
311
  })(typeof self !== "undefined" ? self : this);
 
28
  s += `the ${pick(W_ADJ)} ${pick(W_NOUN)} ${pick(W_VERB)} ${pick(W_PREP)} the ${pick(W_ADJ)} ${pick(W_NOUN)}. `;
29
  return s;
30
  }
31
+ // ---- tokenizer -------------------------------------------------------------
32
+ // Spikewhale tokenizer (tokenizer.json): byte-level greedy longest-match
33
+ // ("length-max"), ~16.5k tokens. Until it loads (or if the file is missing)
34
+ // a 96-char byte-level vocab keeps the app working — but ALL devices in a
35
+ // group must use the same tokenizer (the config broadcast enforces it).
36
+ const FALLBACK_CHARS = [...Array(95)].map((_, i) => String.fromCharCode(32 + i)).concat(["\n"]);
37
+ let tok = {
38
+ name: "char-96 (fallback)",
39
+ vocab: Object.fromEntries(FALLBACK_CHARS.map((c, i) => [c, i])),
40
+ ids: FALLBACK_CHARS, maxLen: 1, size: FALLBACK_CHARS.length,
41
+ unk: 0, specials: new Set(),
42
+ };
43
+ tok.unk = tok.vocab[" "];
44
+ function vocabSize() { return tok.size; }
45
+ function tokenizerName() { return tok.name; }
46
+ function loadTokenizerData(d) { // plain {vocab, vocab_size, max_token_len}
47
+ const ids = new Array(d.vocab_size);
48
+ for (const [t, i] of Object.entries(d.vocab)) ids[i] = t;
49
+ tok = { name: `Spikewhale length-max (${d.vocab_size} tokens)`,
50
+ vocab: d.vocab, ids, maxLen: d.max_token_len || 24, size: d.vocab_size,
51
+ unk: d.vocab["<unk>"] ?? 1,
52
+ specials: new Set(["<pad>", "<unk>", "<bos>", "<eos>", ...(d.special_tokens || [])]) };
53
+ IDS = encode(CORPUS); // re-tokenize whatever corpus is loaded
54
+ return tok.name;
55
+ }
56
+ async function loadTokenizer(url) {
57
+ const r = await fetch(url || "tokenizer.json");
58
+ if (!r.ok) throw new Error(`tokenizer.json HTTP ${r.status}`);
59
+ return loadTokenizerData(await r.json());
60
+ }
61
+ function toLatin1(s) { const b = new TextEncoder().encode(s); let o = ""; for (const x of b) o += String.fromCharCode(x); return o; }
62
+ function encode(text) { // greedy longest match over bytes
63
+ const s = toLatin1(text), out = [];
64
+ let i = 0;
65
+ while (i < s.length) {
66
+ let m = null;
67
+ for (let L = Math.min(tok.maxLen, s.length - i); L > 0; L--) {
68
+ const sub = s.substr(i, L);
69
+ if (sub in tok.vocab) { m = sub; break; }
70
+ }
71
+ if (m === null) { out.push(tok.unk); i++; continue; }
72
+ out.push(tok.vocab[m]); i += m.length;
73
+ }
74
+ return Int32Array.from(out);
75
+ }
76
+ function decode(idArr) {
77
+ let s = "";
78
+ for (const id of idArr) {
79
+ const t = tok.ids[id];
80
+ if (t === undefined || tok.specials.has(t)) continue;
81
+ s += t;
82
+ }
83
+ const bytes = Uint8Array.from([...s].map(c => c.charCodeAt(0)));
84
+ return new TextDecoder().decode(bytes);
85
+ }
86
+ let CORPUS = buildCorpus();
87
+ let IDS = encode(CORPUS);
88
+ let DATASET = "built-in corpus";
89
+
90
+ // Stream real training text: FineWeb-Edu, via the public HuggingFace
91
+ // datasets-server rows API. Each device pulls its own random slice (that's
92
+ // data parallelism — batches were always per-device anyway). Offline or on
93
+ // API failure the built-in corpus stays in place.
94
+ async function streamFineWebEdu() {
95
+ const offset = Math.floor(Math.random() * 9900);
96
+ const url = "https://datasets-server.huggingface.co/rows?dataset=HuggingFaceFW%2Ffineweb-edu" +
97
+ `&config=default&split=train&offset=${offset}&length=100`;
98
+ const r = await fetch(url);
99
+ if (!r.ok) throw new Error(`datasets-server HTTP ${r.status}`);
100
+ const j = await r.json();
101
+ const text = j.rows.map(x => x.row.text).join("\n").replace(/[^\x20-\x7e\n]/g, " ");
102
+ if (text.length < 10000) throw new Error("too little text returned");
103
+ CORPUS = text.slice(0, 500000);
104
+ IDS = encode(CORPUS);
105
+ DATASET = "FineWeb-Edu (streamed from HuggingFace)";
106
+ return { name: DATASET, chars: CORPUS.length };
107
+ }
108
+ function datasetName() { return DATASET; }
109
 
110
  // ---- verified matmul: float in -> quantize -> LUT multiply -> dequant -----
111
  // weights/projections may go through WebGPU; attention (per-head, many small
112
  // matmuls) uses the CPU LUT path — same verified units, no dispatch overhead.
113
+ // 3xINT8 fast-accurate GEMM (CUTLASS 3xTF32 scheme through the verified
114
+ // units): three exact LUT GEMMs recover ~14-bit precision from the 8-bit
115
+ // units — see Verified.lutMatmul3.
116
  async function vmm(Xf, Wf, m, k, n, ctx) {
117
+ return V.lutMatmul3(Xf, Wf, m, k, n, ctx.L, ctx.matmulInt8);
 
 
 
 
118
  }
119
  function vmmCPU(Xf, Wf, m, k, n, ctx) {
120
+ return V.lutMatmul3JS(Xf, Wf, m, k, n, ctx.L);
 
 
 
 
121
  }
122
 
123
  // ---- layernorm (no affine) -------------------------------------------------
 
151
  const params = [], names = [];
152
  const add = (name, w) => { params.push(w); names.push(name); return w; };
153
  const m = {
154
+ cfg: { ...cfg, layers, heads, hidden, vocab: vocabSize() },
155
  ctx: { L, matmulInt8 },
156
+ emb: add("emb", mk(vocabSize() * c, 0.08)),
157
  pos: add("pos", mk(cfg.t * c, 0.02)),
158
  blocks: [], params, names,
159
  };
 
163
  Wv: add(`b${l}.Wv`, mk(c * c, 0.08)), Wo: add(`b${l}.Wo`, mk(c * c, 0.08)),
164
  W1: add(`b${l}.W1`, mk(c * hidden, 0.08)), W2: add(`b${l}.W2`, mk(hidden * c, 0.08)),
165
  });
166
+ m.Wu = add("Wu", mk(c * vocabSize(), 0.08));
167
  m.nParams = params.reduce((a, p) => a + p.length, 0);
168
  return m;
169
  }
 
356
  // greedy sampling — watch the model actually speak
357
  async function generate(m, prompt, nChars) {
358
  const { t: T } = m.cfg;
359
+ let ids = [...encode(prompt)];
360
  for (let step = 0; step < nChars; step++) {
361
  const win = ids.slice(-T);
362
  const X = new Int32Array(T), Y = new Int32Array(T);
 
369
  for (let j = 0; j < m.cfg.vocab; j++) if (logits[row + j] > bv) { bv = logits[row + j]; best = j; }
370
  ids.push(best);
371
  }
372
+ return decode(ids);
373
  }
374
 
375
+ const api = { init, trainStep, applyUpdate, getFlatParams, setFlatParams, generate,
376
+ streamFineWebEdu, datasetName, loadTokenizer, loadTokenizerData,
377
+ vocabSize, tokenizerName, encode, decode };
378
  if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); V = require("./verified_core.js"); module.exports = api; }
379
  else { TC = root.TrainCore; V = root.Verified; root.Transformer = api; }
380
  })(typeof self !== "undefined" ? self : this);
web/public/verified_core.js CHANGED
@@ -28,6 +28,44 @@
28
  return C;
29
  }
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  // one verified layer forward; returns float out (+ cache for STE backward).
32
  // Every product goes through the verified INT8 multiply (mul8 LUT) with exact
33
  // int32 accumulation — i.e. an emulated INT8 tensor-core GEMM — then dequant.
@@ -78,7 +116,7 @@
78
  for (let j = 0; j < W2.length; j++) W2[j] -= lr * gAvg[W1.length + j];
79
  }
80
 
81
- const api = { quantize, lutMatmulJS, linearFwd, forward, backward, splitApply };
82
  if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); module.exports = api; }
83
  else { TC = root.TrainCore; root.Verified = api; }
84
  })(typeof self !== "undefined" ? self : this);
 
28
  return C;
29
  }
30
 
31
+ // ---- 3xINT8 fast-accurate GEMM --------------------------------------------
32
+ // The CUTLASS example-27 "3xTF32" scheme, ported to the verified units:
33
+ // split each float into a coarse int8 part plus an int8-quantized residual,
34
+ // run three EXACT LUT GEMMs (hi·hi, hi·lo, lo·hi), drop the negligible
35
+ // lo·lo, and recombine. Same big/small decomposition NVIDIA uses to recover
36
+ // near-fp32 accuracy from TF32 tensor cores — here it recovers ~14-bit
37
+ // accuracy from the 8-bit units, at 3× the unit ops. Every product still
38
+ // goes through the verified mul8 LUT.
39
+ function quantize2(X) {
40
+ const hi = quantize(X);
41
+ const r = new Float32Array(X.length);
42
+ for (let i = 0; i < X.length; i++) r[i] = X[i] - hi.q[i] * hi.scale;
43
+ const lo = quantize(r);
44
+ return { hi, lo };
45
+ }
46
+ function combine3(hh, hl, lh, x, w, len) {
47
+ const out = new Float32Array(len);
48
+ const shh = x.hi.scale * w.hi.scale, shl = x.hi.scale * w.lo.scale, slh = x.lo.scale * w.hi.scale;
49
+ for (let i = 0; i < len; i++) out[i] = hh[i] * shh + hl[i] * shl + lh[i] * slh;
50
+ return out;
51
+ }
52
+ function lutMatmul3JS(Xf, Wf, m, k, n, L) { // sync, CPU LUT path
53
+ const x = quantize2(Xf), w = quantize2(Wf);
54
+ return combine3(lutMatmulJS(x.hi.q, w.hi.q, m, k, n, L),
55
+ lutMatmulJS(x.hi.q, w.lo.q, m, k, n, L),
56
+ lutMatmulJS(x.lo.q, w.hi.q, m, k, n, L), x, w, m * n);
57
+ }
58
+ async function lutMatmul3(Xf, Wf, m, k, n, L, matmulInt8) { // any backend
59
+ const x = quantize2(Xf), w = quantize2(Wf);
60
+ const mm = matmulInt8 || lutMatmulJS;
61
+ const [hh, hl, lh] = await Promise.all([
62
+ mm(x.hi.q, w.hi.q, m, k, n, L),
63
+ mm(x.hi.q, w.lo.q, m, k, n, L),
64
+ mm(x.lo.q, w.hi.q, m, k, n, L),
65
+ ]);
66
+ return combine3(hh, hl, lh, x, w, m * n);
67
+ }
68
+
69
  // one verified layer forward; returns float out (+ cache for STE backward).
70
  // Every product goes through the verified INT8 multiply (mul8 LUT) with exact
71
  // int32 accumulation — i.e. an emulated INT8 tensor-core GEMM — then dequant.
 
116
  for (let j = 0; j < W2.length; j++) W2[j] -= lr * gAvg[W1.length + j];
117
  }
118
 
119
+ const api = { quantize, quantize2, lutMatmulJS, lutMatmul3JS, lutMatmul3, linearFwd, forward, backward, splitApply };
120
  if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); module.exports = api; }
121
  else { TC = root.TrainCore; root.Verified = api; }
122
  })(typeof self !== "undefined" ? self : this);
web/public/webgpu.js CHANGED
@@ -3,10 +3,19 @@
3
  // without WebGPU (e.g. old PCs via Supermium). initCompute() returns
4
  // { backend, label, matmulInt8(Xq, Wq, m, k, n, L) -> Int32Array }
5
  // matching Verified.lutMatmulJS, so the trainer is device-blind.
 
 
 
 
 
 
 
 
 
6
  (function (root) {
7
  "use strict";
8
 
9
- const WGSL = `
10
  @group(0) @binding(0) var<storage, read> Xq : array<i32>; // int8 byte per elem
11
  @group(0) @binding(1) var<storage, read> Wq : array<i32>;
12
  @group(0) @binding(2) var<storage, read> lut : array<i32>; // 65536 signed products
@@ -26,6 +35,25 @@
26
  C[row * n + col] = s;
27
  }`;
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  async function loadLUTs(base) {
30
  base = base || "";
31
  const [mulB, reqB, reluB, meta] = await Promise.all([
@@ -38,6 +66,21 @@
38
  relu: new Int8Array(reluB), shift: meta.shift };
39
  }
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  async function initCompute(L) {
42
  const cpu = { backend: "cpu", label: "CPU (JS)",
43
  matmulInt8: (Xq, Wq, m, k, n, LL) => root.Verified.lutMatmulJS(Xq, Wq, m, k, n, LL) };
@@ -46,32 +89,45 @@
46
  const adapter = await navigator.gpu.requestAdapter();
47
  if (!adapter) return cpu;
48
  const device = await adapter.requestDevice();
49
- const module = device.createShaderModule({ code: WGSL });
50
- const pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } });
51
- // upload the multiply LUT once (as i32)
 
 
 
52
  const lut32 = new Int32Array(L.mul); // widen int16 -> int32
53
  const lutBuf = device.createBuffer({ size: lut32.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
54
  device.queue.writeBuffer(lutBuf, 0, lut32);
55
- const info = adapter.info || {};
56
- return { backend: "webgpu", label: info.description || info.vendor || "WebGPU",
57
- matmulInt8: (Xq, Wq, m, k, n) => gpuMatmul(device, pipeline, lutBuf, Xq, Wq, m, k, n) };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  } catch (e) { console.warn("WebGPU init failed, CPU fallback:", e); return cpu; }
59
  }
60
 
61
- async function gpuMatmul(device, pipeline, lutBuf, Xq, Wq, m, k, n) {
62
- const X32 = Int32Array.from(Xq), W32 = Int32Array.from(Wq); // byte -> i32
63
- const bufX = mk(device, X32.byteLength, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
64
- const bufW = mk(device, W32.byteLength, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
65
  const bytesC = m * n * 4;
66
  const bufC = mk(device, bytesC, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
67
- const bufD = mk(device, 16, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
68
- device.queue.writeBuffer(bufX, 0, X32);
69
- device.queue.writeBuffer(bufW, 0, W32);
70
- device.queue.writeBuffer(bufD, 0, new Uint32Array([m, k, n]));
71
- const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
72
- { binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
73
- { binding: 2, resource: { buffer: lutBuf } }, { binding: 3, resource: { buffer: bufC } },
74
- { binding: 4, resource: { buffer: bufD } } ] });
75
  const enc = device.createCommandEncoder();
76
  const pass = enc.beginComputePass();
77
  pass.setPipeline(pipeline); pass.setBindGroup(0, bind);
@@ -82,10 +138,42 @@
82
  await read.mapAsync(GPUMapMode.READ);
83
  const out = new Int32Array(read.getMappedRange().slice(0));
84
  read.unmap();
85
- [bufX, bufW, bufC, bufD, read].forEach(b => b.destroy());
86
- return out;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  }
 
88
  function mk(device, size, usage) { return device.createBuffer({ size, usage }); }
 
 
 
 
 
89
 
90
  root.Compute = { initCompute, loadLUTs };
91
  })(self);
 
3
  // without WebGPU (e.g. old PCs via Supermium). initCompute() returns
4
  // { backend, label, matmulInt8(Xq, Wq, m, k, n, L) -> Int32Array }
5
  // matching Verified.lutMatmulJS, so the trainer is device-blind.
6
+ //
7
+ // High-throughput path: when the browser exposes WGSL's
8
+ // packed_4x8_integer_dot_product feature, we use dot4I8Packed — it compiles to
9
+ // the GPU's DP4A/INT8 dot-product hardware (the same units tensor-core INT8
10
+ // paths are built on): 4 exact int8 MACs per instruction, int32 accumulation,
11
+ // and 4× less memory traffic from packing. Because int8×int8→int32 is exact,
12
+ // it is bit-identical to the verified mul8 LUT — and we PROVE that at init by
13
+ // cross-checking random matmuls against the LUT before trusting it. If the
14
+ // hardware ever disagrees with the units, we fall back to the LUT shader.
15
  (function (root) {
16
  "use strict";
17
 
18
+ const WGSL_LUT = `
19
  @group(0) @binding(0) var<storage, read> Xq : array<i32>; // int8 byte per elem
20
  @group(0) @binding(1) var<storage, read> Wq : array<i32>;
21
  @group(0) @binding(2) var<storage, read> lut : array<i32>; // 65536 signed products
 
35
  C[row * n + col] = s;
36
  }`;
37
 
38
+ // X packed 4×int8 along k; W transposed then packed the same way, so each
39
+ // thread streams two contiguous rows of u32 words through dot4I8Packed.
40
+ const WGSL_DP4 = `
41
+ @group(0) @binding(0) var<storage, read> Xp : array<u32>;
42
+ @group(0) @binding(1) var<storage, read> Wp : array<u32>; // Wᵀ, packed
43
+ @group(0) @binding(2) var<storage, read_write> C : array<i32>;
44
+ @group(0) @binding(3) var<uniform> dims : vec3<u32>; // m, kw=ceil(k/4), n
45
+ @compute @workgroup_size(8, 8)
46
+ fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
47
+ let m = dims.x; let kw = dims.y; let n = dims.z;
48
+ let row = gid.x; let col = gid.y;
49
+ if (row >= m || col >= n) { return; }
50
+ var s : i32 = 0;
51
+ for (var p = 0u; p < kw; p = p + 1u) {
52
+ s = s + dot4I8Packed(Xp[row * kw + p], Wp[col * kw + p]);
53
+ }
54
+ C[row * n + col] = s;
55
+ }`;
56
+
57
  async function loadLUTs(base) {
58
  base = base || "";
59
  const [mulB, reqB, reluB, meta] = await Promise.all([
 
66
  relu: new Int8Array(reluB), shift: meta.shift };
67
  }
68
 
69
+ // pack a row-major int8 matrix (rows×cols) into u32 words of 4 bytes along
70
+ // cols, zero-padded to kw words per row (zeros contribute 0 to the dot)
71
+ function packRows(Q, rows, cols, kw) {
72
+ const out = new Uint32Array(rows * kw);
73
+ const bytes = new Uint8Array(out.buffer);
74
+ for (let r = 0; r < rows; r++)
75
+ for (let c = 0; c < cols; c++) bytes[(r * kw * 4) + c] = Q[r * cols + c] & 0xFF;
76
+ return out;
77
+ }
78
+ function transposeI8(Q, rows, cols) {
79
+ const out = new Int8Array(rows * cols);
80
+ for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) out[c * rows + r] = Q[r * cols + c];
81
+ return out;
82
+ }
83
+
84
  async function initCompute(L) {
85
  const cpu = { backend: "cpu", label: "CPU (JS)",
86
  matmulInt8: (Xq, Wq, m, k, n, LL) => root.Verified.lutMatmulJS(Xq, Wq, m, k, n, LL) };
 
89
  const adapter = await navigator.gpu.requestAdapter();
90
  if (!adapter) return cpu;
91
  const device = await adapter.requestDevice();
92
+ const info = adapter.info || {};
93
+ const gpuName = info.description || info.vendor || "WebGPU";
94
+
95
+ // LUT pipeline (always built — the fallback and the verification oracle)
96
+ const lutModule = device.createShaderModule({ code: WGSL_LUT });
97
+ const lutPipe = device.createComputePipeline({ layout: "auto", compute: { module: lutModule, entryPoint: "main" } });
98
  const lut32 = new Int32Array(L.mul); // widen int16 -> int32
99
  const lutBuf = device.createBuffer({ size: lut32.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
100
  device.queue.writeBuffer(lutBuf, 0, lut32);
101
+ const viaLUT = { backend: "webgpu", label: `${gpuName} (LUT shader)`,
102
+ matmulInt8: (Xq, Wq, m, k, n) => gpuMatmulLUT(device, lutPipe, lutBuf, Xq, Wq, m, k, n) };
103
+
104
+ // DP4A pipeline — only if the WGSL feature exists AND it reproduces the
105
+ // verified units exactly on random self-tests
106
+ if (!(navigator.gpu.wgslLanguageFeatures && navigator.gpu.wgslLanguageFeatures.has("packed_4x8_integer_dot_product")))
107
+ return viaLUT;
108
+ const dp4Module = device.createShaderModule({ code: WGSL_DP4 });
109
+ const dp4Pipe = device.createComputePipeline({ layout: "auto", compute: { module: dp4Module, entryPoint: "main" } });
110
+ const dp4mm = (Xq, Wq, m, k, n) => gpuMatmulDP4(device, dp4Pipe, Xq, Wq, m, k, n);
111
+ for (let trial = 0; trial < 3; trial++) { // hardware vs verified units
112
+ const m0 = 5 + trial, k0 = 7 + 3 * trial, n0 = 6 + trial;
113
+ const Xq = new Int8Array(m0 * k0), Wq = new Int8Array(k0 * n0);
114
+ for (let i = 0; i < Xq.length; i++) Xq[i] = (Math.random() * 256 - 128) | 0;
115
+ for (let i = 0; i < Wq.length; i++) Wq[i] = (Math.random() * 256 - 128) | 0;
116
+ const hw = await dp4mm(Xq, Wq, m0, k0, n0);
117
+ const ref = root.Verified.lutMatmulJS(Xq, Wq, m0, k0, n0, L);
118
+ for (let i = 0; i < ref.length; i++)
119
+ if (hw[i] !== ref[i]) { console.warn("DP4A disagreed with the verified units — using LUT shader"); return viaLUT; }
120
+ }
121
+ return { backend: "webgpu", label: `${gpuName} (DP4A int8 dot — HW verified vs units)`, matmulInt8: dp4mm };
122
  } catch (e) { console.warn("WebGPU init failed, CPU fallback:", e); return cpu; }
123
  }
124
 
125
+ // shared dispatch/readback plumbing
126
+ async function runPass(device, pipeline, entries, m, n) {
 
 
127
  const bytesC = m * n * 4;
128
  const bufC = mk(device, bytesC, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
129
+ const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0),
130
+ entries: entries(bufC) });
 
 
 
 
 
 
131
  const enc = device.createCommandEncoder();
132
  const pass = enc.beginComputePass();
133
  pass.setPipeline(pipeline); pass.setBindGroup(0, bind);
 
138
  await read.mapAsync(GPUMapMode.READ);
139
  const out = new Int32Array(read.getMappedRange().slice(0));
140
  read.unmap();
141
+ return { out, bufC, read };
142
+ }
143
+
144
+ async function gpuMatmulLUT(device, pipeline, lutBuf, Xq, Wq, m, k, n) {
145
+ const X32 = Int32Array.from(Xq), W32 = Int32Array.from(Wq); // byte -> i32
146
+ const bufX = up(device, X32, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
147
+ const bufW = up(device, W32, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
148
+ const bufD = up(device, new Uint32Array([m, k, n, 0]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
149
+ const r = await runPass(device, pipeline, (bufC) => [
150
+ { binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
151
+ { binding: 2, resource: { buffer: lutBuf } }, { binding: 3, resource: { buffer: bufC } },
152
+ { binding: 4, resource: { buffer: bufD } } ], m, n);
153
+ [bufX, bufW, bufD, r.bufC, r.read].forEach(b => b.destroy());
154
+ return r.out;
155
+ }
156
+
157
+ async function gpuMatmulDP4(device, pipeline, Xq, Wq, m, k, n) {
158
+ const kw = Math.ceil(k / 4);
159
+ const Xp = packRows(Xq, m, k, kw);
160
+ const Wp = packRows(transposeI8(Wq, k, n), n, k, kw); // Wᵀ so col j is a row
161
+ const bufX = up(device, Xp, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
162
+ const bufW = up(device, Wp, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
163
+ const bufD = up(device, new Uint32Array([m, kw, n, 0]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
164
+ const r = await runPass(device, pipeline, (bufC) => [
165
+ { binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
166
+ { binding: 2, resource: { buffer: bufC } }, { binding: 3, resource: { buffer: bufD } } ], m, n);
167
+ [bufX, bufW, bufD, r.bufC, r.read].forEach(b => b.destroy());
168
+ return r.out;
169
  }
170
+
171
  function mk(device, size, usage) { return device.createBuffer({ size, usage }); }
172
+ function up(device, arr, usage) {
173
+ const b = mk(device, Math.max(16, arr.byteLength), usage);
174
+ device.queue.writeBuffer(b, 0, arr);
175
+ return b;
176
+ }
177
 
178
  root.Compute = { initCompute, loadLUTs };
179
  })(self);
web/test_transformer.js CHANGED
@@ -20,7 +20,7 @@ const cfg = { c: 32, t: 32, b: 8, layers: 2, heads: 2, steps: 120, lr: 0.02 };
20
  (async function () {
21
  const A = X.init(cfg, L, matmulInt8);
22
  const B = X.init(cfg, L, matmulInt8);
23
- console.log(`vocab=${X.VOCAB}, params=${A.nParams}, baseline loss=${Math.log(X.VOCAB).toFixed(3)}`);
24
  const oa = T.makeAdam(A.nParams, { lr: cfg.lr });
25
  const ob = T.makeAdam(B.nParams, { lr: cfg.lr });
26
  let first = 0, loss = 0;
@@ -40,7 +40,7 @@ const cfg = { c: 32, t: 32, b: 8, layers: 2, heads: 2, steps: 120, lr: 0.02 };
40
  const sample = await X.generate(A, "the ", 60);
41
  console.log(`sample: "${sample}"`);
42
  console.log(`replica max param diff: ${diff.toExponential(3)}`);
43
- const ok = loss < first * 0.75 && loss < Math.log(X.VOCAB) && diff === 0;
44
  console.log(ok ? "TRANSFORMER TEST PASSED" : "TRANSFORMER TEST FAILED");
45
  process.exit(ok ? 0 : 1);
46
  })();
 
20
  (async function () {
21
  const A = X.init(cfg, L, matmulInt8);
22
  const B = X.init(cfg, L, matmulInt8);
23
+ console.log(`vocab=${X.vocabSize()}, params=${A.nParams}, baseline loss=${Math.log(X.vocabSize()).toFixed(3)}`);
24
  const oa = T.makeAdam(A.nParams, { lr: cfg.lr });
25
  const ob = T.makeAdam(B.nParams, { lr: cfg.lr });
26
  let first = 0, loss = 0;
 
40
  const sample = await X.generate(A, "the ", 60);
41
  console.log(`sample: "${sample}"`);
42
  console.log(`replica max param diff: ${diff.toExponential(3)}`);
43
+ const ok = loss < first * 0.75 && loss < Math.log(X.vocabSize()) && diff === 0;
44
  console.log(ok ? "TRANSFORMER TEST PASSED" : "TRANSFORMER TEST FAILED");
45
  process.exit(ok ? 0 : 1);
46
  })();