Quazim0t0 commited on
Commit
56e2384
Β·
verified Β·
1 Parent(s): e8758e9

Web demo: leader failover + dataset picker + reconnect policy

Browse files
web/public/app.js CHANGED
@@ -37,6 +37,7 @@ const ui = {
37
  cfgB: document.getElementById("cfgB"),
38
  cfgSteps: document.getElementById("cfgSteps"),
39
  cfgLr: document.getElementById("cfgLr"),
 
40
  dataset: document.getElementById("dataset"),
41
  tokenizer: document.getElementById("tokenizer"),
42
  kit: document.getElementById("kit"),
@@ -173,7 +174,13 @@ function newPC(peerId) {
173
  const reconnectTimers = new Map(); // peerId -> attempt count
174
  function scheduleReconnect(peerId, attempt = 0) {
175
  if (!names.has(peerId) || chans.has(peerId) || reconnectTimers.has(peerId)) return;
176
- if (attempt >= 6) { log(`gave up reconnecting to ${nmeOf(peerId)}`); return; }
 
 
 
 
 
 
177
  const delay = 1500 * Math.pow(2, attempt);
178
  reconnectTimers.set(peerId, setTimeout(() => {
179
  reconnectTimers.delete(peerId);
@@ -353,26 +360,51 @@ function readCfgFromUI() {
353
  // lr crosses the wire as float32 β€” fround here so the leader trains with
354
  // the exact same value the followers decode (else Adam forks the weights)
355
  return { c: +ui.cfgC.value, t: +ui.cfgT.value, b: +ui.cfgB.value,
356
- steps: +ui.cfgSteps.value, lr: Math.fround(+ui.cfgLr.value / 1000) };
 
357
  }
358
  function showCfgInUI(cfg) {
359
  const set = (el, vid, val) => { el.value = val; document.getElementById(vid).textContent = val; };
360
  set(ui.cfgC, "vcfgC", cfg.c); set(ui.cfgT, "vcfgT", cfg.t); set(ui.cfgB, "vcfgB", cfg.b);
361
  set(ui.cfgSteps, "vcfgSteps", cfg.steps);
362
  set(ui.cfgLr, "vcfgLr", Math.round(cfg.lr * 1000));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  }
364
  function broadcastConfig(cfg) {
365
- const buf = new ArrayBuffer(28);
 
366
  new Int32Array(buf, 0, 5).set([CFG_SENTINEL, cfg.c, cfg.t, cfg.b, cfg.steps]);
367
  new Float32Array(buf, 20, 1)[0] = cfg.lr;
368
  new Int32Array(buf, 24, 1)[0] = Transformer.vocabSize(); // tokenizer must match
 
 
369
  broadcast(buf);
370
  }
371
- function onConfig(peerId, buf) {
372
  if (training) { log(`ignored settings from ${nmeOf(peerId)} (already training)`); return; }
373
  const [, c, t, b, steps] = new Int32Array(buf, 0, 5);
374
  const lr = new Float32Array(buf, 20, 1)[0];
375
  const vocab = buf.byteLength >= 28 ? new Int32Array(buf, 24, 1)[0] : -1;
 
 
 
 
 
 
376
  if (vocab !== Transformer.vocabSize()) {
377
  log(`NOT JOINING: ${nmeOf(peerId)} uses a ${vocab}-token tokenizer, this device has ` +
378
  `${Transformer.vocabSize()} (${Transformer.tokenizerName()}) β€” refresh so all devices match`);
@@ -382,10 +414,12 @@ function onConfig(peerId, buf) {
382
  steps >= 1 && steps <= 10000 && lr > 0 && lr <= 0.2)) {
383
  log(`rejected bad settings from ${nmeOf(peerId)}`); return;
384
  }
385
- const cfg = { c, t, b, steps, lr };
386
  leaderId = peerId; // the starter leads sync for this run
387
  showCfgInUI(cfg);
388
- log(`${nmeOf(peerId)} started the group: width=${c}, seq=${t}, batch=${b}, ${steps} steps, lr=${lr}`);
 
 
389
  buildModel(cfg);
390
  train(cfg); // follow automatically
391
  }
@@ -601,7 +635,10 @@ async function train(cfg, resume) {
601
  // resume runs keep `incoming` β€” grads that arrived while the bundle was
602
  // downloading are exactly the steps we need to catch up with
603
  if (!resume) { incoming.clear(); rosters.clear(); peerHashes.clear(); }
604
- const iLead = !resume && leaderId === null; // set by Start (null) or onConfig/onResume
 
 
 
605
  const cohort = [...chans.keys()]; // grows when a synced-in peer starts contributing
606
  const steps = cfg.steps;
607
  const opt = TrainCore.makeAdam(model.nParams, { lr: cfg.lr });
@@ -633,8 +670,16 @@ async function train(cfg, resume) {
633
  const skipMine = !iLead && known && !known.includes(myId);
634
  let loss = 0, grad = null;
635
  if (!skipMine) {
636
- ({ loss, grad } = await localStep());
637
- await broadcastGrad(s, whash, loss, grad);
 
 
 
 
 
 
 
 
638
  }
639
  let all;
640
  if (!cohort.length && iLead) {
@@ -659,9 +704,28 @@ async function train(cfg, resume) {
659
  // follower: apply the leader's roster verbatim, or stop
660
  await waitFor(() => rosters.has(s) || !chans.has(leaderId), 15000);
661
  if (!rosters.has(s)) {
662
- halted = chans.has(leaderId)
663
- ? `no roster from ${nmeOf(leaderId)} for step ${s + 1}`
664
- : `the sync leader (${nmeOf(leaderId)}) disconnected`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665
  break;
666
  }
667
  const roster = rosters.get(s);
@@ -678,6 +742,7 @@ async function train(cfg, resume) {
678
  // divergence check: every contributor's pre-step weight hash must match mine
679
  const hs = peerHashes.get(s);
680
  const roster = rosters.get(s) || [myId];
 
681
  if (hs) {
682
  const bad = roster.find(id => id !== myId && hs.has(id) && hs.get(id).hash !== whash);
683
  if (bad) { halted = `weights diverged from ${nmeOf(bad)} (detected at step ${s + 1})`; break; }
@@ -822,17 +887,13 @@ function buildModel(cfg) {
822
  }
823
  updatePeers();
824
  connectSignaling();
825
- // dataset: stream FineWeb-Edu from HuggingFace; built-in corpus if offline
826
- ui.dataset.textContent = "streaming FineWeb-Edu…";
827
- Transformer.streamFineWebEdu().then(({ name, chars }) => {
828
- ui.dataset.textContent = "FineWeb-Edu (streamed)";
829
- log(`dataset: ${name} β€” ${(chars / 1000).toFixed(0)}k chars loaded (each device streams its own slice)`);
830
- }).catch((e) => {
831
- ui.dataset.textContent = "built-in corpus (offline)";
832
- log(`dataset: FineWeb-Edu stream unavailable (${e.message}) β€” using the built-in corpus`);
833
- });
834
- ui.start.onclick = () => {
835
  const cfg = readCfgFromUI();
 
 
836
  leaderId = null; // I pressed Start: I lead sync
837
  buildModel(cfg);
838
  broadcastConfig(cfg); // everyone follows these settings
 
37
  cfgB: document.getElementById("cfgB"),
38
  cfgSteps: document.getElementById("cfgSteps"),
39
  cfgLr: document.getElementById("cfgLr"),
40
+ cfgData: document.getElementById("cfgData"),
41
  dataset: document.getElementById("dataset"),
42
  tokenizer: document.getElementById("tokenizer"),
43
  kit: document.getElementById("kit"),
 
174
  const reconnectTimers = new Map(); // peerId -> attempt count
175
  function scheduleReconnect(peerId, attempt = 0) {
176
  if (!names.has(peerId) || chans.has(peerId) || reconnectTimers.has(peerId)) return;
177
+ if (attempt >= 5) { // 5 tries, then they're out of the group
178
+ log(`${nmeOf(peerId)} unreachable after 5 reconnect attempts β€” removed from the group`);
179
+ cleanupPeer(peerId);
180
+ names.delete(peerId);
181
+ updatePeers();
182
+ return;
183
+ }
184
  const delay = 1500 * Math.pow(2, attempt);
185
  reconnectTimers.set(peerId, setTimeout(() => {
186
  reconnectTimers.delete(peerId);
 
360
  // lr crosses the wire as float32 β€” fround here so the leader trains with
361
  // the exact same value the followers decode (else Adam forks the weights)
362
  return { c: +ui.cfgC.value, t: +ui.cfgT.value, b: +ui.cfgB.value,
363
+ steps: +ui.cfgSteps.value, lr: Math.fround(+ui.cfgLr.value / 1000),
364
+ ds: (ui.cfgData.value || "").trim() };
365
  }
366
  function showCfgInUI(cfg) {
367
  const set = (el, vid, val) => { el.value = val; document.getElementById(vid).textContent = val; };
368
  set(ui.cfgC, "vcfgC", cfg.c); set(ui.cfgT, "vcfgT", cfg.t); set(ui.cfgB, "vcfgB", cfg.b);
369
  set(ui.cfgSteps, "vcfgSteps", cfg.steps);
370
  set(ui.cfgLr, "vcfgLr", Math.round(cfg.lr * 1000));
371
+ if (cfg.ds) ui.cfgData.value = cfg.ds;
372
+ }
373
+ // switch the training text to a chosen dataset (whole group uses the same one);
374
+ // on failure the built-in corpus stays and we log why
375
+ async function loadDataset(ds) {
376
+ if (!ds) return;
377
+ ui.dataset.textContent = `streaming ${ds}…`;
378
+ try {
379
+ const { name, chars } = await Transformer.streamDataset(ds);
380
+ ui.dataset.textContent = name;
381
+ log(`dataset: ${name} β€” ${(chars / 1000).toFixed(0)}k chars (this device's own slice)`);
382
+ } catch (e) {
383
+ ui.dataset.textContent = "built-in corpus";
384
+ log(`dataset "${ds}" unavailable (${e.message}) β€” using the built-in corpus`);
385
+ }
386
  }
387
  function broadcastConfig(cfg) {
388
+ const dsBytes = new TextEncoder().encode((cfg.ds || "").slice(0, 96));
389
+ const buf = new ArrayBuffer(32 + dsBytes.length);
390
  new Int32Array(buf, 0, 5).set([CFG_SENTINEL, cfg.c, cfg.t, cfg.b, cfg.steps]);
391
  new Float32Array(buf, 20, 1)[0] = cfg.lr;
392
  new Int32Array(buf, 24, 1)[0] = Transformer.vocabSize(); // tokenizer must match
393
+ new Int32Array(buf, 28, 1)[0] = dsBytes.length;
394
+ new Uint8Array(buf, 32).set(dsBytes);
395
  broadcast(buf);
396
  }
397
+ async function onConfig(peerId, buf) {
398
  if (training) { log(`ignored settings from ${nmeOf(peerId)} (already training)`); return; }
399
  const [, c, t, b, steps] = new Int32Array(buf, 0, 5);
400
  const lr = new Float32Array(buf, 20, 1)[0];
401
  const vocab = buf.byteLength >= 28 ? new Int32Array(buf, 24, 1)[0] : -1;
402
+ let ds = "";
403
+ if (buf.byteLength >= 32) {
404
+ const dsLen = new Int32Array(buf, 28, 1)[0];
405
+ if (dsLen > 0 && dsLen <= 96 && buf.byteLength === 32 + dsLen)
406
+ ds = new TextDecoder().decode(new Uint8Array(buf, 32, dsLen));
407
+ }
408
  if (vocab !== Transformer.vocabSize()) {
409
  log(`NOT JOINING: ${nmeOf(peerId)} uses a ${vocab}-token tokenizer, this device has ` +
410
  `${Transformer.vocabSize()} (${Transformer.tokenizerName()}) β€” refresh so all devices match`);
 
414
  steps >= 1 && steps <= 10000 && lr > 0 && lr <= 0.2)) {
415
  log(`rejected bad settings from ${nmeOf(peerId)}`); return;
416
  }
417
+ const cfg = { c, t, b, steps, lr: Math.fround(lr), ds };
418
  leaderId = peerId; // the starter leads sync for this run
419
  showCfgInUI(cfg);
420
+ log(`${nmeOf(peerId)} started the group: width=${c}, seq=${t}, batch=${b}, ${steps} steps, lr=${lr}` +
421
+ (ds ? `, dataset=${ds}` : ""));
422
+ if (ds) await loadDataset(ds); // same dataset as the starter
423
  buildModel(cfg);
424
  train(cfg); // follow automatically
425
  }
 
635
  // resume runs keep `incoming` β€” grads that arrived while the bundle was
636
  // downloading are exactly the steps we need to catch up with
637
  if (!resume) { incoming.clear(); rosters.clear(); peerHashes.clear(); }
638
+ let iLead = !resume && leaderId === null; // set by Start (null) or onConfig/onResume
639
+ let lastRoster = null; // the promotion electorate: last applied roster
640
+ let lastLocal = null; // this step's own grad (reused on failover redo)
641
+ const deadLeaders = new Set(); // leaders that left this run
642
  const cohort = [...chans.keys()]; // grows when a synced-in peer starts contributing
643
  const steps = cfg.steps;
644
  const opt = TrainCore.makeAdam(model.nParams, { lr: cfg.lr });
 
670
  const skipMine = !iLead && known && !known.includes(myId);
671
  let loss = 0, grad = null;
672
  if (!skipMine) {
673
+ // compute each step's gradient EXACTLY once: a leader-failover redo
674
+ // must reuse the grad already broadcast β€” recomputing would give peers
675
+ // a different gradient than the one they hold, forking the weights
676
+ if (lastLocal && lastLocal.s === s) {
677
+ ({ loss, grad } = lastLocal);
678
+ } else {
679
+ ({ loss, grad } = await localStep());
680
+ lastLocal = { s, loss, grad };
681
+ await broadcastGrad(s, whash, loss, grad);
682
+ }
683
  }
684
  let all;
685
  if (!cohort.length && iLead) {
 
704
  // follower: apply the leader's roster verbatim, or stop
705
  await waitFor(() => rosters.has(s) || !chans.has(leaderId), 15000);
706
  if (!rosters.has(s)) {
707
+ if (!chans.has(leaderId)) {
708
+ // leader failover: everyone promotes the same next peer β€” the
709
+ // lowest-numbered member of the LAST APPLIED roster (identical on
710
+ // every device) that hasn't already died as leader. All followers
711
+ // hold bit-identical weights + Adam state, so the new leader
712
+ // continues the run seamlessly; no state transfer needed.
713
+ deadLeaders.add(leaderId);
714
+ const electorate = (lastRoster || [myId, leaderId]);
715
+ const next = electorate.filter(id => !deadLeaders.has(id))
716
+ .sort((a, b) => +a.slice(1) - +b.slice(1))[0];
717
+ if (!next) { halted = "the sync leader left and no one remains to promote"; break; }
718
+ if (next === myId) {
719
+ iLead = true; leaderId = null;
720
+ cohort.length = 0; cohort.push(...chans.keys());
721
+ log(`the leader left β€” I take over sync leadership at step ${s + 1} (cohort ${cohort.length})`);
722
+ } else {
723
+ leaderId = next;
724
+ log(`the leader left β€” ${nmeOf(next)} takes over sync from step ${s + 1}`);
725
+ }
726
+ s--; continue; // redo this step under the new leader
727
+ }
728
+ halted = `no roster from ${nmeOf(leaderId)} for step ${s + 1}`;
729
  break;
730
  }
731
  const roster = rosters.get(s);
 
742
  // divergence check: every contributor's pre-step weight hash must match mine
743
  const hs = peerHashes.get(s);
744
  const roster = rosters.get(s) || [myId];
745
+ lastRoster = roster; // promotion electorate if the leader dies
746
  if (hs) {
747
  const bad = roster.find(id => id !== myId && hs.has(id) && hs.get(id).hash !== whash);
748
  if (bad) { halted = `weights diverged from ${nmeOf(bad)} (detected at step ${s + 1})`; break; }
 
887
  }
888
  updatePeers();
889
  connectSignaling();
890
+ // dataset: stream the chosen dataset from HuggingFace; built-in corpus if offline
891
+ loadDataset((ui.cfgData.value || "").trim());
892
+ ui.start.onclick = async () => {
893
+ if (training) return;
 
 
 
 
 
 
894
  const cfg = readCfgFromUI();
895
+ ui.start.disabled = true;
896
+ if (cfg.ds) await loadDataset(cfg.ds); // my pick becomes the group's dataset
897
  leaderId = null; // I pressed Start: I lead sync
898
  buildModel(cfg);
899
  broadcastConfig(cfg); // everyone follows these settings
web/public/index.html CHANGED
@@ -72,7 +72,7 @@
72
  </head>
73
  <body>
74
  <h1>🌼 DaisyChain-Web</h1>
75
- <p class="sub">Open this on your other devices <b>on the same network</b> and they train a shared model together β€” peer-to-peer, right in the browser, through the emulated GPU logic. Only devices on your network are grouped (like Snapdrop). To invite people across networks, everyone opens <code>?room=YOUR-CODE</code> β€” the person who created the room approves each device before it can join.</p>
76
 
77
  <div class="card" id="lobby" style="display:none;text-align:center">
78
  <div class="lbl">🏑 Get started</div>
@@ -118,6 +118,11 @@
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
 
 
72
  </head>
73
  <body>
74
  <h1>🌼 DaisyChain-Web</h1>
75
+ <p class="sub">Open this on your other devices <b>on the same network</b> and they <b>pretrain a language model from scratch</b> together β€” peer-to-peer, right in the browser, through the emulated GPU logic. This is pretraining, not fine-tuning: every run starts from random weights. Only devices on your network are grouped (like Snapdrop). To invite people across networks, everyone opens <code>?room=YOUR-CODE</code> β€” the person who created the room approves each device before it can join.</p>
76
 
77
  <div class="card" id="lobby" style="display:none;text-align:center">
78
  <div class="lbl">🏑 Get started</div>
 
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
+ <label class="slbl" for="cfgData">Dataset (HuggingFace)</label>
122
+ <input type="text" id="cfgData" value="HuggingFaceFW/fineweb-edu" spellcheck="false"
123
+ style="width:100%;padding:9px 11px;border-radius:8px;border:1px solid var(--card-border);background:transparent;color:inherit;font-family:'Courier New',monospace;font-size:.9rem">
124
+ <p class="note" style="margin:.4rem 0 0">Any public dataset with a <code style="background:rgba(74,124,46,.12);padding:1px 4px;border-radius:4px">text</code> column works β€” each device streams its own random slice. Whoever presses Start picks it for the whole group; if streaming fails, the built-in corpus is used.</p>
125
+ <p class="note" style="margin:.6rem 0 0"><b>This is pretraining, not fine-tuning</b> β€” every run trains a brand-new model from random weights. You are watching a language model learn from scratch.</p>
126
  <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>
127
  </div>
128
 
web/public/transformer.js CHANGED
@@ -87,24 +87,28 @@
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 -----
@@ -373,7 +377,7 @@
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; }
 
87
  let IDS = encode(CORPUS);
88
  let DATASET = "built-in corpus";
89
 
90
+ // Stream real training text from any public HuggingFace dataset with a
91
+ // `text` column, via the datasets-server rows API. Each device pulls its own
92
+ // random slice (that's data parallelism β€” batches were always per-device
93
+ // anyway). Offline or on API failure the built-in corpus stays in place.
94
+ const DEFAULT_DS = "HuggingFaceFW/fineweb-edu";
95
+ async function streamDataset(ds) {
96
+ ds = (ds || DEFAULT_DS).trim();
97
+ if (!/^[\w.-]+\/[\w.-]+$/.test(ds)) throw new Error(`invalid dataset id "${ds}"`);
98
  const offset = Math.floor(Math.random() * 9900);
99
+ const url = `https://datasets-server.huggingface.co/rows?dataset=${encodeURIComponent(ds)}` +
100
  `&config=default&split=train&offset=${offset}&length=100`;
101
  const r = await fetch(url);
102
  if (!r.ok) throw new Error(`datasets-server HTTP ${r.status}`);
103
  const j = await r.json();
104
+ const text = j.rows.map(x => String(x.row.text ?? "")).join("\n").replace(/[^\x20-\x7e\n]/g, " ");
105
+ if (text.length < 10000) throw new Error("too little text returned (does the dataset have a `text` column?)");
106
  CORPUS = text.slice(0, 500000);
107
  IDS = encode(CORPUS);
108
+ DATASET = `${ds} (streamed)`;
109
  return { name: DATASET, chars: CORPUS.length };
110
  }
111
+ const streamFineWebEdu = () => streamDataset(DEFAULT_DS);
112
  function datasetName() { return DATASET; }
113
 
114
  // ---- verified matmul: float in -> quantize -> LUT multiply -> dequant -----
 
377
  }
378
 
379
  const api = { init, trainStep, applyUpdate, getFlatParams, setFlatParams, generate,
380
+ streamFineWebEdu, streamDataset, datasetName, loadTokenizer, loadTokenizerData,
381
  vocabSize, tokenizerName, encode, decode };
382
  if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); V = require("./verified_core.js"); module.exports = api; }
383
  else { TC = root.TrainCore; V = root.Verified; root.Transformer = api; }