Quazim0t0 commited on
Commit
e8758e9
·
verified ·
1 Parent(s): b9f2104

Web demo: mid-run joins + auto-reconnect

Browse files
Files changed (2) hide show
  1. web/public/app.js +134 -21
  2. web/public/traincore.js +5 -0
web/public/app.js CHANGED
@@ -93,7 +93,11 @@ function connectSignaling() {
93
  if (room()) params.set("room", room()); // no code -> group by network
94
  ws = new WebSocket(`${proto}://${location.host}/?${params}`);
95
  ws.onopen = () => setStatus(room() ? `connected — private room "${room()}"` : "connected — grouping with devices on your network");
96
- ws.onclose = () => { if (!wasDenied) setStatus("signaling disconnected"); };
 
 
 
 
97
  ws.onmessage = async (ev) => {
98
  const msg = JSON.parse(ev.data);
99
  if (msg.type === "welcome") {
@@ -101,8 +105,9 @@ function connectSignaling() {
101
  if (msg.room) log(`group: ${msg.room.startsWith("net:") ? "your network" : "private room " + msg.room.replace("room:", "")}`);
102
  if (msg.host) { log("you host this room — joiners wait for your approval"); setStatus(`hosting private room "${room()}"`); }
103
  else if (room()) setStatus(`accepted into private room "${room()}"`);
104
- // I'm newest: initiate to everyone already here
105
- for (const p of msg.peers) { names.set(p.id, p.name); initiatePeer(p.id); }
 
106
  updatePeers();
107
  } else if (msg.type === "waiting") {
108
  setStatus("knocking — waiting for the room's host to let you in…");
@@ -153,10 +158,34 @@ function addJoinRequest(id, name) {
153
  function newPC(peerId) {
154
  const pc = new RTCPeerConnection({ iceServers: STUN });
155
  pc.onicecandidate = (e) => { if (e.candidate) signal(peerId, { candidate: e.candidate }); };
156
- pc.onconnectionstatechange = () => { if (pc.connectionState === "failed") cleanupPeer(peerId); };
 
 
157
  pcs.set(peerId, pc);
158
  return pc;
159
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  function initiatePeer(peerId) {
161
  const pc = newPC(peerId);
162
  const dc = pc.createDataChannel("daisy");
@@ -191,11 +220,21 @@ async function onSignal(from, data) {
191
  }
192
  function setupChannel(peerId, dc) {
193
  dc.binaryType = "arraybuffer";
194
- dc.onopen = () => { chans.set(peerId, dc); updatePeers(); log(`connected to ${nmeOf(peerId)}`); ui.start.disabled = false; };
195
- dc.onclose = () => { chans.delete(peerId); updatePeers(); wake(); };
 
 
 
 
 
196
  dc.onmessage = (e) => onGrad(peerId, e.data);
197
  }
198
- function cleanupPeer(id) { const pc = pcs.get(id); if (pc) pc.close(); pcs.delete(id); chans.delete(id); pendingCand.delete(id); wake(); }
 
 
 
 
 
199
 
200
  // ---- checkpoints ------------------------------------------------------------
201
  // File layout (also the broadcast payload after the sentinel):
@@ -389,15 +428,67 @@ async function dcSend(dc, buf) {
389
  dc.send(msg);
390
  }
391
  }
392
- async function broadcast(buf) { for (const dc of chans.values()) if (dc.readyState === "open") await dcSend(dc, buf); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  function onFragment(peerId, buf) { // returns full message when complete
394
  const [, id, seq, total] = new Int32Array(buf, 0, 4);
395
- let st = fragIn.get(peerId);
396
- if (!st || st.id !== id) { st = { id, parts: [], got: 0, total }; fragIn.set(peerId, st); }
 
 
 
397
  st.parts[seq] = new Uint8Array(buf, 16).slice(0);
398
  st.got++;
399
  if (st.got < st.total) return null;
400
- fragIn.delete(peerId);
401
  let len = 0; for (const p of st.parts) len += p.length;
402
  const out = new Uint8Array(len);
403
  let off = 0; for (const p of st.parts) { out.set(p, off); off += p.length; }
@@ -444,6 +535,7 @@ function onGrad(peerId, buf) {
444
  return;
445
  }
446
  if (step === CFG_SENTINEL) { onConfig(peerId, buf); return; }
 
447
  if (step === CKPT_SENTINEL) { // a peer pushed a checkpoint
448
  if (training) { log(`ignored checkpoint from ${nmeOf(peerId)} (training in progress)`); return; }
449
  try { applyCheckpoint(parseCheckpoint(buf.slice(4)), nmeOf(peerId)); }
@@ -503,14 +595,18 @@ async function localStep() {
503
  }
504
 
505
  // ---- the training loop -----------------------------------------------------
506
- async function train(cfg) {
507
  if (training) return; training = true; ui.start.disabled = true;
508
  cfgSliders().forEach(el => el.disabled = true);
509
- incoming.clear(); rosters.clear(); peerHashes.clear(); // no stale grads from a previous run
510
- const iLead = leaderId === null; // set by Start (null) or onConfig (starter's id)
511
- const cohort = [...chans.keys()]; // lock the cohort (departed peers are pruned per-step)
 
 
512
  const steps = cfg.steps;
513
  const opt = TrainCore.makeAdam(model.nParams, { lr: cfg.lr });
 
 
514
  log(`training started — cohort ${cohort.length} peer(s), world ${cohort.length + 1}, ` +
515
  `width=${cfg.c} seq=${cfg.t} batch=${cfg.b}×${cohort.length + 1}, optimizer ${opt.name}` +
516
  (cohort.length ? `, sync ${iLead ? "led by me" : "led by " + nmeOf(leaderId)}` : "") +
@@ -523,18 +619,35 @@ async function train(cfg) {
523
  const contrib = new Map([[myId, 0]]); // peerId -> grads contributed
524
  for (const id of cohort) contrib.set(id, 0);
525
  try {
526
- for (let s = 0; s < steps; s++) {
 
 
 
 
 
527
  const whash = hashWeights(); // pre-step fingerprint, sent with the grad
528
- const { loss, grad } = await localStep();
529
- await broadcastGrad(s, whash, loss, grad);
 
 
 
 
 
 
 
 
530
  let all;
531
- if (!cohort.length) {
532
- all = [grad]; // solo run
533
  } else if (iLead) {
534
  const ids = await waitForGradIds(s, cohort);
 
 
 
 
535
  // sync guard: publish the exact contributor set so every device
536
  // averages the same gradients (or stops) — never a silent fork
537
- const roster = [myId, ...ids];
538
  broadcastRoster(s, roster);
539
  rosters.set(s, roster);
540
  // CRITICAL: average strictly in roster order. Float addition is
 
93
  if (room()) params.set("room", room()); // no code -> group by network
94
  ws = new WebSocket(`${proto}://${location.host}/?${params}`);
95
  ws.onopen = () => setStatus(room() ? `connected — private room "${room()}"` : "connected — grouping with devices on your network");
96
+ ws.onclose = () => {
97
+ if (wasDenied) return;
98
+ setStatus("signaling disconnected — reconnecting…");
99
+ setTimeout(connectSignaling, 3000); // data channels survive; rejoin the room
100
+ };
101
  ws.onmessage = async (ev) => {
102
  const msg = JSON.parse(ev.data);
103
  if (msg.type === "welcome") {
 
105
  if (msg.room) log(`group: ${msg.room.startsWith("net:") ? "your network" : "private room " + msg.room.replace("room:", "")}`);
106
  if (msg.host) { log("you host this room — joiners wait for your approval"); setStatus(`hosting private room "${room()}"`); }
107
  else if (room()) setStatus(`accepted into private room "${room()}"`);
108
+ // I'm newest: initiate to everyone already here (skip channels that
109
+ // survived a signaling blip no duplicate connections)
110
+ for (const p of msg.peers) { names.set(p.id, p.name); if (!chans.has(p.id)) initiatePeer(p.id); }
111
  updatePeers();
112
  } else if (msg.type === "waiting") {
113
  setStatus("knocking — waiting for the room's host to let you in…");
 
158
  function newPC(peerId) {
159
  const pc = new RTCPeerConnection({ iceServers: STUN });
160
  pc.onicecandidate = (e) => { if (e.candidate) signal(peerId, { candidate: e.candidate }); };
161
+ pc.onconnectionstatechange = () => {
162
+ if (pc.connectionState === "failed") { cleanupPeer(peerId); scheduleReconnect(peerId); }
163
+ };
164
  pcs.set(peerId, pc);
165
  return pc;
166
  }
167
+
168
+ // ---- auto-reconnect ----------------------------------------------------------
169
+ // If a data channel drops but the peer is still in the room (signaling knows
170
+ // them), redial with backoff. Only the higher-numbered peer initiates, so both
171
+ // sides don't collide (offer glare). If the peer truly left, names loses them
172
+ // and the retries stop on their own.
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);
180
+ if (!names.has(peerId) || chans.has(peerId)) return;
181
+ if (+myId.slice(1) > +peerId.slice(1)) { // deterministic initiator
182
+ log(`reconnecting to ${nmeOf(peerId)} (attempt ${attempt + 1})…`);
183
+ cleanupPeer(peerId);
184
+ initiatePeer(peerId);
185
+ }
186
+ setTimeout(() => scheduleReconnect(peerId, attempt + 1), 6000);
187
+ }, delay));
188
+ }
189
  function initiatePeer(peerId) {
190
  const pc = newPC(peerId);
191
  const dc = pc.createDataChannel("daisy");
 
220
  }
221
  function setupChannel(peerId, dc) {
222
  dc.binaryType = "arraybuffer";
223
+ dc.onopen = () => {
224
+ chans.set(peerId, dc); updatePeers(); log(`connected to ${nmeOf(peerId)}`); ui.start.disabled = false;
225
+ // I'm leading a live run (leaderId null while training) — sync them in at
226
+ // the next step boundary instead of making them wait for the next run
227
+ if (training && leaderId === null) { pendingJoins.add(peerId); log(`${nmeOf(peerId)} will be synced into the running training`); }
228
+ };
229
+ dc.onclose = () => { chans.delete(peerId); updatePeers(); wake(); scheduleReconnect(peerId); };
230
  dc.onmessage = (e) => onGrad(peerId, e.data);
231
  }
232
+ function cleanupPeer(id) {
233
+ const pc = pcs.get(id); if (pc) pc.close();
234
+ pcs.delete(id); chans.delete(id); pendingCand.delete(id); pendingJoins.delete(id);
235
+ for (const k of fragIn.keys()) if (k.startsWith(id + ":")) fragIn.delete(k);
236
+ wake();
237
+ }
238
 
239
  // ---- checkpoints ------------------------------------------------------------
240
  // File layout (also the broadcast payload after the sentinel):
 
428
  dc.send(msg);
429
  }
430
  }
431
+ // parallel: one slow channel (e.g. a joiner mid-download of a resume bundle)
432
+ // must not delay the gradient broadcast to everyone else
433
+ function broadcast(buf) {
434
+ return Promise.all([...chans.values()].filter(dc => dc.readyState === "open").map(dc => dcSend(dc, buf)));
435
+ }
436
+
437
+ // ---- mid-run join: the leader ships weights + Adam state + step -------------
438
+ // A device that connects (or reconnects) while training gets a resume bundle:
439
+ // [i32 -6][i32 startStep][i32 adamT][i32 c,t,b,steps][f32 lr]
440
+ // [f32 weights ×nP][f32 adam.m ×nP][f32 adam.v ×nP]
441
+ // From that snapshot it applies the exact same roster updates as everyone else
442
+ // (bit-identical), and its own gradients join the roster as soon as they
443
+ // arrive on time — no restart of the run.
444
+ const RESUME_SENTINEL = -6;
445
+ const pendingJoins = new Set(); // peers to sync in at the next step boundary
446
+ async function sendResume(peerId, startStep, opt, cfg) {
447
+ const dc = chans.get(peerId);
448
+ if (!dc || dc.readyState !== "open") return;
449
+ const st = opt.getState(); // snapshot NOW (top of step, post-update)
450
+ const w = Transformer.getFlatParams(model);
451
+ const nP = w.length;
452
+ const buf = new ArrayBuffer(32 + nP * 12);
453
+ new Int32Array(buf, 0, 7).set([RESUME_SENTINEL, startStep, st.t, cfg.c, cfg.t, cfg.b, cfg.steps]);
454
+ new Float32Array(buf, 28, 1)[0] = cfg.lr;
455
+ new Float32Array(buf, 32, nP).set(w);
456
+ new Float32Array(buf, 32 + nP * 4, nP).set(st.m);
457
+ new Float32Array(buf, 32 + nP * 8, nP).set(st.v);
458
+ log(`syncing ${nmeOf(peerId)} into the run at step ${startStep + 1} (${(buf.byteLength / 1048576).toFixed(1)} MB state transfer)`);
459
+ await dcSend(dc, buf);
460
+ }
461
+ function onResume(peerId, buf) {
462
+ if (training) { log(`ignored resume bundle from ${nmeOf(peerId)} (already training)`); return; }
463
+ const iv = new Int32Array(buf, 0, 7);
464
+ const lr = Math.fround(new Float32Array(buf, 28, 1)[0]);
465
+ const cfg = { c: iv[3], t: iv[4], b: iv[5], steps: iv[6], lr };
466
+ if (!(cfg.c >= 16 && cfg.c <= 128 && cfg.t >= 16 && cfg.t <= 128 && cfg.b >= 1 && cfg.b <= 32 &&
467
+ cfg.steps >= 1 && cfg.steps <= 10000 && lr > 0 && lr <= 0.2)) {
468
+ log(`rejected bad resume bundle from ${nmeOf(peerId)}`); return;
469
+ }
470
+ buildModel(cfg); showCfgInUI(cfg);
471
+ const nP = model.nParams;
472
+ if (buf.byteLength !== 32 + nP * 12) { log(`resume bundle size mismatch from ${nmeOf(peerId)}`); return; }
473
+ Transformer.setFlatParams(model, new Float32Array(buf.slice(32, 32 + nP * 4)));
474
+ const adam = { m: new Float32Array(buf.slice(32 + nP * 4, 32 + nP * 8)),
475
+ v: new Float32Array(buf.slice(32 + nP * 8)), t: iv[2] };
476
+ leaderId = peerId;
477
+ trainedSteps = iv[1];
478
+ log(`${nmeOf(peerId)} synced me into the running training at step ${iv[1] + 1} — joining the cohort`);
479
+ train(cfg, { startStep: iv[1], adam });
480
+ }
481
  function onFragment(peerId, buf) { // returns full message when complete
482
  const [, id, seq, total] = new Int32Array(buf, 0, 4);
483
+ // key by message id too: a resume bundle and step gradients can interleave
484
+ // on the same channel, and each must reassemble independently
485
+ const key = peerId + ":" + id;
486
+ let st = fragIn.get(key);
487
+ if (!st) { st = { id, parts: [], got: 0, total }; fragIn.set(key, st); }
488
  st.parts[seq] = new Uint8Array(buf, 16).slice(0);
489
  st.got++;
490
  if (st.got < st.total) return null;
491
+ fragIn.delete(key);
492
  let len = 0; for (const p of st.parts) len += p.length;
493
  const out = new Uint8Array(len);
494
  let off = 0; for (const p of st.parts) { out.set(p, off); off += p.length; }
 
535
  return;
536
  }
537
  if (step === CFG_SENTINEL) { onConfig(peerId, buf); return; }
538
+ if (step === RESUME_SENTINEL) { onResume(peerId, buf); return; }
539
  if (step === CKPT_SENTINEL) { // a peer pushed a checkpoint
540
  if (training) { log(`ignored checkpoint from ${nmeOf(peerId)} (training in progress)`); return; }
541
  try { applyCheckpoint(parseCheckpoint(buf.slice(4)), nmeOf(peerId)); }
 
595
  }
596
 
597
  // ---- the training loop -----------------------------------------------------
598
+ async function train(cfg, resume) {
599
  if (training) return; training = true; ui.start.disabled = true;
600
  cfgSliders().forEach(el => el.disabled = true);
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 });
608
+ if (resume) opt.setState(resume.adam); // bit-identical moments from the leader
609
+ pendingJoins.clear();
610
  log(`training started — cohort ${cohort.length} peer(s), world ${cohort.length + 1}, ` +
611
  `width=${cfg.c} seq=${cfg.t} batch=${cfg.b}×${cohort.length + 1}, optimizer ${opt.name}` +
612
  (cohort.length ? `, sync ${iLead ? "led by me" : "led by " + nmeOf(leaderId)}` : "") +
 
619
  const contrib = new Map([[myId, 0]]); // peerId -> grads contributed
620
  for (const id of cohort) contrib.set(id, 0);
621
  try {
622
+ for (let s = resume ? resume.startStep : 0; s < steps; s++) {
623
+ // leader: ship the current state to any device that connected mid-run —
624
+ // snapshot here (top of step, post-update) so their replay starts exact
625
+ if (iLead && pendingJoins.size) {
626
+ for (const pid of [...pendingJoins]) { pendingJoins.delete(pid); sendResume(pid, s, opt, cfg); }
627
+ }
628
  const whash = hashWeights(); // pre-step fingerprint, sent with the grad
629
+ // catching up after a mid-run join: if the leader's roster for this step
630
+ // already exists and doesn't include me, skip the local compute — just
631
+ // apply the roster updates and race forward to the live step
632
+ const known = rosters.get(s);
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) {
641
+ all = [grad]; // solo run (joiners can still sync in)
642
  } else if (iLead) {
643
  const ids = await waitForGradIds(s, cohort);
644
+ // synced-in peers join the cohort the first step their grad shows up
645
+ const extra = [...((incoming.get(s) || new Map()).keys())]
646
+ .filter(id => !cohort.includes(id) && !ids.includes(id) && chans.has(id));
647
+ for (const e of extra) { cohort.push(e); contrib.set(e, contrib.get(e) || 0); log(`${nmeOf(e)} joined the training cohort at step ${s + 1}`); }
648
  // sync guard: publish the exact contributor set so every device
649
  // averages the same gradients (or stops) — never a silent fork
650
+ const roster = [myId, ...ids, ...extra];
651
  broadcastRoster(s, roster);
652
  rosters.set(s, roster);
653
  // CRITICAL: average strictly in roster order. Float addition is
web/public/traincore.js CHANGED
@@ -85,6 +85,11 @@
85
  }
86
  return u;
87
  },
 
 
 
 
 
88
  };
89
  }
90
 
 
85
  }
86
  return u;
87
  },
88
+ // snapshot/restore the moments — this is what lets a device that joins
89
+ // mid-run become bit-identical with the group (weights alone are not
90
+ // enough; Adam's m/v/t must match too)
91
+ getState() { return { m: Float32Array.from(m), v: Float32Array.from(v), t }; },
92
+ setState(s) { m.set(s.m); v.set(s.v); t = s.t; },
93
  };
94
  }
95