Web demo: mid-run joins + auto-reconnect
Browse files- web/public/app.js +134 -21
- 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 = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 = () => {
|
|
|
|
|
|
|
| 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 = () => {
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
dc.onmessage = (e) => onGrad(peerId, e.data);
|
| 197 |
}
|
| 198 |
-
function cleanupPeer(id) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
function onFragment(peerId, buf) { // returns full message when complete
|
| 394 |
const [, id, seq, total] = new Int32Array(buf, 0, 4);
|
| 395 |
-
|
| 396 |
-
|
|
|
|
|
|
|
|
|
|
| 397 |
st.parts[seq] = new Uint8Array(buf, 16).slice(0);
|
| 398 |
st.got++;
|
| 399 |
if (st.got < st.total) return null;
|
| 400 |
-
fragIn.delete(
|
| 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 |
-
|
| 510 |
-
|
| 511 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 529 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|