Web demo: weight tying + WebRTC resilience (relay fallback, keepalive)
Browse files- web/public/app.js +52 -6
- web/public/transformer.js +8 -5
- web/server.js +25 -2
web/public/app.js
CHANGED
|
@@ -13,6 +13,7 @@ const STUN = [
|
|
| 13 |
{ urls: "turn:openrelay.metered.ca:443", username: "openrelayproject", credential: "openrelayproject" },
|
| 14 |
{ urls: "turns:openrelay.metered.ca:443?transport=tcp", username: "openrelayproject", credential: "openrelayproject" },
|
| 15 |
];
|
|
|
|
| 16 |
|
| 17 |
const ui = {
|
| 18 |
status: document.getElementById("status"),
|
|
@@ -129,6 +130,10 @@ function connectSignaling() {
|
|
| 129 |
log(`${nmeOf(msg.id)} left`); cleanupPeer(msg.id); names.delete(msg.id); updatePeers();
|
| 130 |
} else if (msg.type === "signal") {
|
| 131 |
await onSignal(msg.from, msg.data);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
}
|
| 133 |
};
|
| 134 |
}
|
|
@@ -157,10 +162,18 @@ function addJoinRequest(id, name) {
|
|
| 157 |
}
|
| 158 |
|
| 159 |
function newPC(peerId) {
|
| 160 |
-
const pc = new RTCPeerConnection(
|
| 161 |
pc.onicecandidate = (e) => { if (e.candidate) signal(peerId, { candidate: e.candidate }); };
|
| 162 |
pc.onconnectionstatechange = () => {
|
| 163 |
if (pc.connectionState === "failed") { cleanupPeer(peerId); scheduleReconnect(peerId); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
};
|
| 165 |
pcs.set(peerId, pc);
|
| 166 |
return pc;
|
|
@@ -171,14 +184,37 @@ function newPC(peerId) {
|
|
| 171 |
// them), redial with backoff. Only the higher-numbered peer initiates, so both
|
| 172 |
// sides don't collide (offer glare). If the peer truly left, names loses them
|
| 173 |
// and the retries stop on their own.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) { //
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
| 182 |
return;
|
| 183 |
}
|
| 184 |
const delay = 1500 * Math.pow(2, attempt);
|
|
@@ -817,6 +853,7 @@ function buildModel(cfg) {
|
|
| 817 |
// to create their own room (they become its host) or join one by code.
|
| 818 |
try {
|
| 819 |
const mode = await (await fetch("mode")).json();
|
|
|
|
| 820 |
if (mode.forceRooms && !room()) {
|
| 821 |
const lobby = document.getElementById("lobby");
|
| 822 |
for (const c of document.querySelectorAll(".card")) if (c !== lobby) c.style.display = "none";
|
|
@@ -887,6 +924,15 @@ function buildModel(cfg) {
|
|
| 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 () => {
|
|
|
|
| 13 |
{ urls: "turn:openrelay.metered.ca:443", username: "openrelayproject", credential: "openrelayproject" },
|
| 14 |
{ urls: "turns:openrelay.metered.ca:443?transport=tcp", username: "openrelayproject", credential: "openrelayproject" },
|
| 15 |
];
|
| 16 |
+
let rtcConfig = { iceServers: STUN }; // /mode can override (own TURN etc.)
|
| 17 |
|
| 18 |
const ui = {
|
| 19 |
status: document.getElementById("status"),
|
|
|
|
| 130 |
log(`${nmeOf(msg.id)} left`); cleanupPeer(msg.id); names.delete(msg.id); updatePeers();
|
| 131 |
} else if (msg.type === "signal") {
|
| 132 |
await onSignal(msg.from, msg.data);
|
| 133 |
+
} else if (msg.type === "ping") { // keepalive (proxies close idle sockets)
|
| 134 |
+
ws.send(JSON.stringify({ type: "pong" }));
|
| 135 |
+
} else if (msg.type === "relay") { // payload relayed via the server (WS fallback)
|
| 136 |
+
onGrad(msg.from, bufFromB64(msg.data));
|
| 137 |
}
|
| 138 |
};
|
| 139 |
}
|
|
|
|
| 162 |
}
|
| 163 |
|
| 164 |
function newPC(peerId) {
|
| 165 |
+
const pc = new RTCPeerConnection(rtcConfig);
|
| 166 |
pc.onicecandidate = (e) => { if (e.candidate) signal(peerId, { candidate: e.candidate }); };
|
| 167 |
pc.onconnectionstatechange = () => {
|
| 168 |
if (pc.connectionState === "failed") { cleanupPeer(peerId); scheduleReconnect(peerId); }
|
| 169 |
+
// "disconnected" often self-heals; give it a grace period, then treat as failed
|
| 170 |
+
if (pc.connectionState === "disconnected")
|
| 171 |
+
setTimeout(() => {
|
| 172 |
+
if (pcs.get(peerId) === pc && pc.connectionState === "disconnected") {
|
| 173 |
+
log(`${nmeOf(peerId)} connection did not recover — redialing`);
|
| 174 |
+
cleanupPeer(peerId); scheduleReconnect(peerId);
|
| 175 |
+
}
|
| 176 |
+
}, 4000);
|
| 177 |
};
|
| 178 |
pcs.set(peerId, pc);
|
| 179 |
return pc;
|
|
|
|
| 184 |
// them), redial with backoff. Only the higher-numbered peer initiates, so both
|
| 185 |
// sides don't collide (offer glare). If the peer truly left, names loses them
|
| 186 |
// and the retries stop on their own.
|
| 187 |
+
// ---- WebSocket relay fallback (PairDrop's WSPeer pattern) ---------------------
|
| 188 |
+
// A peer still in the room after 5 failed WebRTC attempts is reachable through
|
| 189 |
+
// the signaling server even though no direct path exists (symmetric NATs, no
|
| 190 |
+
// TURN). Install a pseudo data channel that relays binary over the WS instead
|
| 191 |
+
// of dropping them. Devices that actually left are removed via peer-left.
|
| 192 |
+
function b64FromBuf(buf) {
|
| 193 |
+
const u = new Uint8Array(buf); let s = "";
|
| 194 |
+
for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode(...u.subarray(i, i + 0x8000));
|
| 195 |
+
return btoa(s);
|
| 196 |
+
}
|
| 197 |
+
function bufFromB64(s) { return Uint8Array.from(atob(s), c => c.charCodeAt(0)).buffer; }
|
| 198 |
+
function makeRelayChannel(peerId) {
|
| 199 |
+
return {
|
| 200 |
+
isRelay: true,
|
| 201 |
+
get readyState() { return ws && ws.readyState === 1 && names.has(peerId) ? "open" : "closed"; },
|
| 202 |
+
get bufferedAmount() { return ws ? ws.bufferedAmount : 0; },
|
| 203 |
+
send(buf) { ws.send(JSON.stringify({ type: "relay", to: peerId, data: b64FromBuf(buf) })); },
|
| 204 |
+
close() { chans.delete(peerId); },
|
| 205 |
+
};
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
const reconnectTimers = new Map(); // peerId -> attempt count
|
| 209 |
function scheduleReconnect(peerId, attempt = 0) {
|
| 210 |
if (!names.has(peerId) || chans.has(peerId) || reconnectTimers.has(peerId)) return;
|
| 211 |
+
if (attempt >= 5) { // no direct path — fall back to server relay
|
| 212 |
+
if (names.has(peerId)) {
|
| 213 |
+
log(`no direct WebRTC path to ${nmeOf(peerId)} after 5 attempts — relaying through the server instead`);
|
| 214 |
+
chans.set(peerId, makeRelayChannel(peerId));
|
| 215 |
+
updatePeers(); wake();
|
| 216 |
+
ui.start.disabled = false;
|
| 217 |
+
}
|
| 218 |
return;
|
| 219 |
}
|
| 220 |
const delay = 1500 * Math.pow(2, attempt);
|
|
|
|
| 853 |
// to create their own room (they become its host) or join one by code.
|
| 854 |
try {
|
| 855 |
const mode = await (await fetch("mode")).json();
|
| 856 |
+
if (mode.rtc) rtcConfig = mode.rtc; // operator-provided ICE config
|
| 857 |
if (mode.forceRooms && !room()) {
|
| 858 |
const lobby = document.getElementById("lobby");
|
| 859 |
for (const c of document.querySelectorAll(".card")) if (c !== lobby) c.style.display = "none";
|
|
|
|
| 924 |
}
|
| 925 |
updatePeers();
|
| 926 |
connectSignaling();
|
| 927 |
+
// phones suspend the page (screen off, app switch) — reconnect the moment we
|
| 928 |
+
// come back, and on network changes (wifi <-> cellular), PairDrop-style
|
| 929 |
+
document.addEventListener("visibilitychange", () => {
|
| 930 |
+
if (!document.hidden && (!ws || ws.readyState > 1)) { log("page woke — reconnecting"); connectSignaling(); }
|
| 931 |
+
});
|
| 932 |
+
if (navigator.connection && navigator.connection.addEventListener)
|
| 933 |
+
navigator.connection.addEventListener("change", () => {
|
| 934 |
+
if (!ws || ws.readyState > 1) { log("network changed — reconnecting"); connectSignaling(); }
|
| 935 |
+
});
|
| 936 |
// dataset: stream the chosen dataset from HuggingFace; built-in corpus if offline
|
| 937 |
loadDataset((ui.cfgData.value || "").trim());
|
| 938 |
ui.start.onclick = async () => {
|
web/public/transformer.js
CHANGED
|
@@ -169,7 +169,9 @@
|
|
| 169 |
Wv: add(`b${l}.Wv`, mk(c * c, 0.08)), Wo: add(`b${l}.Wo`, mk(c * c, 0.08)),
|
| 170 |
W1: add(`b${l}.W1`, mk(c * hidden, 0.08)), W2: add(`b${l}.W2`, mk(hidden * c, 0.08)),
|
| 171 |
});
|
| 172 |
-
|
|
|
|
|
|
|
| 173 |
m.nParams = params.reduce((a, p) => a + p.length, 0);
|
| 174 |
return m;
|
| 175 |
}
|
|
@@ -273,7 +275,7 @@
|
|
| 273 |
cache.blocks.push(cb);
|
| 274 |
}
|
| 275 |
const lf = lnFwd(x, BT, C); cache.lnf = lf; cache.xf = x;
|
| 276 |
-
const logits = await vmm(lf.y, m.
|
| 277 |
// cross-entropy + dlogits
|
| 278 |
let loss = 0;
|
| 279 |
const dlogits = new Float32Array(BT * vocab);
|
|
@@ -298,9 +300,10 @@
|
|
| 298 |
const BT = B * T, hd = C / heads, mm = TC.matmul, tr = TC.transpose;
|
| 299 |
const g = m.params.map(p => new Float32Array(p.length));
|
| 300 |
const gi = Object.fromEntries(m.names.map((n, i) => [n, i]));
|
| 301 |
-
// unembed
|
| 302 |
-
|
| 303 |
-
|
|
|
|
| 304 |
const scale = 1 / Math.sqrt(hd);
|
| 305 |
for (let l = layers - 1; l >= 0; l--) {
|
| 306 |
const bl = m.blocks[l], cb = cache.blocks[l];
|
|
|
|
| 169 |
Wv: add(`b${l}.Wv`, mk(c * c, 0.08)), Wo: add(`b${l}.Wo`, mk(c * c, 0.08)),
|
| 170 |
W1: add(`b${l}.W1`, mk(c * hidden, 0.08)), W2: add(`b${l}.W2`, mk(hidden * c, 0.08)),
|
| 171 |
});
|
| 172 |
+
// weight-tied unembedding: logits use embᵀ (no separate Wu). Halves the
|
| 173 |
+
// vocab-sized parameters — and with a 16k vocab that's ~half of ALL
|
| 174 |
+
// parameters, so gradients over the wire shrink ~2× too.
|
| 175 |
m.nParams = params.reduce((a, p) => a + p.length, 0);
|
| 176 |
return m;
|
| 177 |
}
|
|
|
|
| 275 |
cache.blocks.push(cb);
|
| 276 |
}
|
| 277 |
const lf = lnFwd(x, BT, C); cache.lnf = lf; cache.xf = x;
|
| 278 |
+
const logits = await vmm(lf.y, TC.transpose(m.emb, vocab, C), BT, C, vocab, ctx); // tied: embᵀ
|
| 279 |
// cross-entropy + dlogits
|
| 280 |
let loss = 0;
|
| 281 |
const dlogits = new Float32Array(BT * vocab);
|
|
|
|
| 300 |
const BT = B * T, hd = C / heads, mm = TC.matmul, tr = TC.transpose;
|
| 301 |
const g = m.params.map(p => new Float32Array(p.length));
|
| 302 |
const gi = Object.fromEntries(m.names.map((n, i) => [n, i]));
|
| 303 |
+
// tied unembed: logits = lnf @ embᵀ, so the unembedding gradient flows
|
| 304 |
+
// straight into emb — dlogitsᵀ @ lnf is V×C, emb's own shape
|
| 305 |
+
g[gi.emb] = mm(tr(cache.dlogits, BT, vocab), cache.lnf.y, vocab, BT, C);
|
| 306 |
+
let dx = lnBwd(mm(cache.dlogits, m.emb, BT, vocab, C), cache.lnf.y, cache.lnf.sig, BT, C);
|
| 307 |
const scale = 1 / Math.sqrt(hd);
|
| 308 |
for (let l = layers - 1; l >= 0; l--) {
|
| 309 |
const bl = m.blocks[l], cb = cache.blocks[l];
|
web/server.js
CHANGED
|
@@ -30,13 +30,28 @@ const PUB = path.join(__dirname, "public");
|
|
| 30 |
const TYPES = { ".html": "text/html", ".js": "text/javascript",
|
| 31 |
".css": "text/css", ".json": "application/json" };
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
const server = http.createServer((req, res) => {
|
| 34 |
let p = decodeURIComponent(req.url.split("?")[0]);
|
| 35 |
if (p === "/mode") { // deployment flags for the client
|
| 36 |
res.writeHead(200, { "Content-Type": "application/json" });
|
| 37 |
// DAISY_FORCE_ROOMS=1 (set on the HF Space): no LAN auto-grouping — every
|
| 38 |
-
// visitor creates their own private room and invites devices by link
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
if (p === "/") p = "/index.html";
|
| 42 |
const file = path.join(PUB, path.normalize(p));
|
|
@@ -89,6 +104,14 @@ wss.on("connection", (ws, req) => {
|
|
| 89 |
if (msg.type === "signal" && msg.to && room.peers.has(id)) { // relay WebRTC signaling
|
| 90 |
const target = room.peers.get(msg.to);
|
| 91 |
if (target) send(target.ws, { type: "signal", from: id, data: msg.data });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
} else if (msg.type === "admit" && id === room.host) { // host verdict on a joiner
|
| 93 |
const p = room.pending.get(msg.id);
|
| 94 |
if (!p) return;
|
|
|
|
| 30 |
const TYPES = { ".html": "text/html", ".js": "text/javascript",
|
| 31 |
".css": "text/css", ".json": "application/json" };
|
| 32 |
|
| 33 |
+
// keepalive: proxies (incl. HuggingFace's) close idle WebSockets — ping every
|
| 34 |
+
// 30s keeps the pipe warm and detects dead clients within a minute
|
| 35 |
+
setInterval(() => {
|
| 36 |
+
for (const room of rooms.values())
|
| 37 |
+
for (const [pid, v] of room.peers) {
|
| 38 |
+
if (v.ws.isAlive === false) { v.ws.terminate(); continue; }
|
| 39 |
+
v.ws.isAlive = false;
|
| 40 |
+
send(v.ws, { type: "ping" });
|
| 41 |
+
}
|
| 42 |
+
}, 30000);
|
| 43 |
+
|
| 44 |
const server = http.createServer((req, res) => {
|
| 45 |
let p = decodeURIComponent(req.url.split("?")[0]);
|
| 46 |
if (p === "/mode") { // deployment flags for the client
|
| 47 |
res.writeHead(200, { "Content-Type": "application/json" });
|
| 48 |
// DAISY_FORCE_ROOMS=1 (set on the HF Space): no LAN auto-grouping — every
|
| 49 |
+
// visitor creates their own private room and invites devices by link.
|
| 50 |
+
// DAISY_RTC_CONFIG: optional JSON RTCPeerConnection config (own TURN etc.,
|
| 51 |
+
// PairDrop-style server-provided rtcConfig).
|
| 52 |
+
let rtc = null;
|
| 53 |
+
try { if (process.env.DAISY_RTC_CONFIG) rtc = JSON.parse(process.env.DAISY_RTC_CONFIG); } catch (e) {}
|
| 54 |
+
return res.end(JSON.stringify({ forceRooms: !!process.env.DAISY_FORCE_ROOMS, rtc }));
|
| 55 |
}
|
| 56 |
if (p === "/") p = "/index.html";
|
| 57 |
const file = path.join(PUB, path.normalize(p));
|
|
|
|
| 104 |
if (msg.type === "signal" && msg.to && room.peers.has(id)) { // relay WebRTC signaling
|
| 105 |
const target = room.peers.get(msg.to);
|
| 106 |
if (target) send(target.ws, { type: "signal", from: id, data: msg.data });
|
| 107 |
+
} else if (msg.type === "pong") { // keepalive reply
|
| 108 |
+
ws.isAlive = true;
|
| 109 |
+
} else if (msg.type === "relay" && msg.to && room.peers.has(id)) {
|
| 110 |
+
// PairDrop-style WS fallback: when two devices can't form a direct
|
| 111 |
+
// WebRTC path (symmetric NATs, no TURN), the training payloads flow
|
| 112 |
+
// through here instead of dropping the peer
|
| 113 |
+
const target = room.peers.get(msg.to);
|
| 114 |
+
if (target) send(target.ws, { type: "relay", from: id, data: msg.data });
|
| 115 |
} else if (msg.type === "admit" && id === room.host) { // host verdict on a joiner
|
| 116 |
const p = room.pending.get(msg.id);
|
| 117 |
if (!p) return;
|