Skip to content

Commit e4cf1ce

Browse files
committed
workers: hardware specs, local session console, and inference-follows-the-weights
- registration reports real compute (CUDA name+VRAM, Apple silicon chip+unified memory, cores/RAM floor) — shown in Machines, the Train picker, the dashboard - the worker terminal now prints the full training session (banner, [shadow] lines, per-step loss) instead of a single 'finished' line - playground chat/generate with a worker-trained shadow proxies to the machine that trained it — an mlx adapter can't load into the hub's torch stack, so the answer comes from where the weights live; offline machine → a clear error naming it instead of a state_dict traceback
1 parent 0c72e1c commit e4cf1ce

9 files changed

Lines changed: 249 additions & 17 deletions

File tree

frontend/src/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ export const clearVram = () =>
172172
export const getMethods = () => api<{ methods: MethodInfo[] }>("/v1/methods");
173173
export interface WorkerInfo {
174174
name: string; backend: string; device: string; gpus: number;
175+
gpu_name: string; vram_gb: number; ram_gb: number; cores: number;
175176
last_seen: number; online: boolean; queued: number;
176177
}
177178
export const getWorkers = () => api<{ workers: WorkerInfo[] }>("/v1/workers");

frontend/src/pages/Dashboard.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ export default function Dashboard() {
8989
w.online ? "bg-emerald-500" : "bg-muted-foreground/40"}`} />
9090
<span className="font-medium font-mono">{w.name}</span>
9191
<span className="text-xs text-muted-foreground font-mono">
92-
{w.backend} · {w.device}{w.gpus ? ` · ${w.gpus} gpu` : ""}
92+
{w.backend} · {w.gpu_name || w.device}
93+
{w.vram_gb ? ` · ${w.vram_gb} GB` : ""}
9394
</span>
9495
<span className="text-xs text-muted-foreground font-mono ml-auto">
9596
{w.online

frontend/src/pages/Machines.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,19 @@ function ConnectCmd() {
9292
);
9393
}
9494

95+
/** "NVIDIA L40S · 48 GB" / "Apple M3 Pro · 36 GB unified" / "12 cores · 32 GB RAM" */
96+
function compute(w: WorkerInfo): string {
97+
if (w.gpu_name) {
98+
const unified = w.backend === "mlx" ? " unified" : "";
99+
const count = w.gpus > 1 ? `${w.gpus}× ` : "";
100+
return `${count}${w.gpu_name}${w.vram_gb ? ` · ${w.vram_gb} GB${unified}` : ""}`;
101+
}
102+
const bits = [];
103+
if (w.cores) bits.push(`${w.cores} cores`);
104+
if (w.ram_gb) bits.push(`${w.ram_gb} GB RAM`);
105+
return bits.join(" · ") || "—";
106+
}
107+
95108
function ago(ts: number): string {
96109
const s = Math.max(0, Math.floor(Date.now() / 1000 - ts));
97110
if (s < 90) return `${s}s ago`;
@@ -144,7 +157,7 @@ export default function Machines() {
144157
<th className="text-left px-5 py-2.5 font-normal">Machine</th>
145158
<th className="text-left px-3 py-2.5 font-normal">Backend</th>
146159
<th className="text-left px-3 py-2.5 font-normal">Platform</th>
147-
<th className="text-right px-3 py-2.5 font-normal">GPUs</th>
160+
<th className="text-left px-3 py-2.5 font-normal">Compute</th>
148161
<th className="text-right px-3 py-2.5 font-normal">Queue</th>
149162
<th className="text-right px-5 py-2.5 font-normal">Status</th>
150163
</tr>
@@ -161,7 +174,7 @@ export default function Machines() {
161174
</td>
162175
<td className="px-3 py-3 font-mono text-xs uppercase">{w.backend}</td>
163176
<td className="px-3 py-3 font-mono text-xs">{w.device}</td>
164-
<td className="px-3 py-3 text-right font-mono">{w.gpus || "—"}</td>
177+
<td className="px-3 py-3 font-mono text-xs">{compute(w)}</td>
165178
<td className="px-3 py-3 text-right font-mono">{w.queued || "—"}</td>
166179
<td className="px-5 py-3 text-right text-xs text-muted-foreground font-mono">
167180
{w.online ? (w.queued ? "busy" : "idle") : `last seen ${ago(w.last_seen)}`}

frontend/src/pages/Train.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,8 @@ export default function Train({ methods }: { methods: MethodInfo[] }) {
415415
<option value="">this server</option>
416416
{workers.map((w) => (
417417
<option key={w.name} value={w.name} disabled={!w.online}>
418-
{w.name}{w.backend} · {w.device}{w.online ? "" : " (offline)"}
418+
{w.name}{w.backend} · {w.gpu_name || w.device}
419+
{w.vram_gb ? ` · ${w.vram_gb} GB` : ""}{w.online ? "" : " (offline)"}
419420
</option>
420421
))}
421422
</select>
Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shadowlm/_static/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<link rel="icon" type="image/png" href="/logo.png" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>ShadowLM · slm♥</title>
8-
<script type="module" crossorigin src="./assets/index-Bg6O8DuN.js"></script>
8+
<script type="module" crossorigin src="./assets/index-nUKwxbls.js"></script>
99
<link rel="stylesheet" crossorigin href="./assets/index-CTi3GkyQ.css">
1010
</head>
1111
<body>

shadowlm/serve.py

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ class _Job:
6464
checkpoint: str | None = None
6565
final_loss: float | None = None
6666
method: str | None = None
67+
worker: str | None = None # trained on this machine; inference routes there
6768
created: float = 0.0
6869
logs: list[str] = field(default_factory=list) # captured console lines
6970
live: str = "" # the in-progress (post-\r) line, e.g. the progress bar
@@ -74,7 +75,7 @@ class _Job:
7475
def record(self) -> dict:
7576
"""The serializable job record persisted to disk (survives restarts)."""
7677
return {"job_id": self.job_id, "base_model": self.base_model,
77-
"name": self.name,
78+
"name": self.name, "worker": self.worker,
7879
"status": self.status, "method": self.method,
7980
"error": self.error, "checkpoint": self.checkpoint,
8081
"final_loss": self.final_loss, "created": self.created,
@@ -84,7 +85,7 @@ def record(self) -> dict:
8485
@classmethod
8586
def from_record(cls, d: dict) -> "_Job":
8687
job = cls(job_id=d["job_id"], base_model=d.get("base_model", "?"),
87-
name=d.get("name", ""),
88+
name=d.get("name", ""), worker=d.get("worker"),
8889
status=d.get("status", "succeeded"), method=d.get("method"),
8990
error=d.get("error"), checkpoint=d.get("checkpoint"),
9091
final_loss=d.get("final_loss"), created=d.get("created", 0.0))
@@ -371,12 +372,18 @@ class _Worker:
371372
backend: str = "?"
372373
device: str = "?"
373374
gpus: int = 0
375+
gpu_name: str = ""
376+
vram_gb: float = 0.0
377+
ram_gb: float = 0.0
378+
cores: int = 0
374379
last_seen: float = 0.0
375380
inbox: "queue.Queue[str]" = field(default_factory=queue.Queue)
376381

377382
def info(self) -> dict:
378383
return {"name": self.name, "backend": self.backend, "device": self.device,
379-
"gpus": self.gpus, "last_seen": int(self.last_seen),
384+
"gpus": self.gpus, "gpu_name": self.gpu_name,
385+
"vram_gb": self.vram_gb, "ram_gb": self.ram_gb,
386+
"cores": self.cores, "last_seen": int(self.last_seen),
380387
"online": (time.time() - self.last_seen) < 90,
381388
"queued": self.inbox.qsize()}
382389

@@ -409,6 +416,7 @@ def __init__(self, *, backend: str, accelerator: str, device: str,
409416
self.jobs: dict[str, _Job] = {}
410417
self.workers: dict[str, _Worker] = {} # remote executors, keyed by name
411418
self.worker_socks: dict[str, object] = {} # name → live WSConn
419+
self._infer_waiters: dict[str, "queue.Queue[dict]"] = {} # req id → reply
412420
self.datasets = DatasetStore(work_root / "datasets")
413421
self.queue: "queue.Queue[str]" = queue.Queue()
414422
self._lock = threading.Lock() # job-store mutations
@@ -768,6 +776,7 @@ def submit(self, payload: dict) -> str:
768776
job = _Job(job_id=job_id, base_model=payload["base_model"],
769777
name=(payload.get("name") or "").strip(),
770778
method=(payload.get("config") or {}).get("method"),
779+
worker=(payload.get("worker") or None),
771780
created=int(time.time()))
772781
job._payload = payload
773782
with self._lock:
@@ -792,6 +801,10 @@ def register_worker(self, body: dict) -> _Worker:
792801
w.backend = body.get("backend", w.backend)
793802
w.device = body.get("device", w.device)
794803
w.gpus = int(body.get("gpus") or 0)
804+
w.gpu_name = str(body.get("gpu_name") or "")
805+
w.vram_gb = float(body.get("vram_gb") or 0)
806+
w.ram_gb = float(body.get("ram_gb") or 0)
807+
w.cores = int(body.get("cores") or 0)
795808
w.last_seen = time.time()
796809
return w
797810

@@ -875,6 +888,9 @@ def sender() -> None:
875888
if self.ingest_events(job, msg)["cancel"]:
876889
conn.send_json({"type": "cancel",
877890
"job_id": job.job_id})
891+
elif msg.get("type") == "infer_result":
892+
if (q := self._infer_waiters.pop(msg.get("id", ""), None)):
893+
q.put(msg)
878894
except (ConnectionError, OSError, json.JSONDecodeError):
879895
pass # a dropped worker is normal — the studio just shows offline
880896
finally:
@@ -885,6 +901,38 @@ def sender() -> None:
885901
conn.close()
886902
print(f"[hub] worker '{name}' disconnected", flush=True)
887903

904+
def worker_infer(self, job: _Job, req: dict, *, timeout: float = 240.0) -> str:
905+
"""Run generate/chat on the machine that trained `job`, over its socket.
906+
907+
An mlx-trained adapter can't load into this box's torch stack (and vice
908+
versa) — the weights live where they trained, so inference goes there.
909+
"""
910+
conn = self.worker_socks.get(job.worker)
911+
if conn is None:
912+
raise RuntimeError(
913+
f"this shadow was trained on machine '{job.worker}', which is "
914+
f"offline — start `shadowlm worker` there to chat with it")
915+
rid = uuid.uuid4().hex
916+
q: "queue.Queue[dict]" = queue.Queue()
917+
self._infer_waiters[rid] = q
918+
try:
919+
conn.send_json({**req, "id": rid, "job_id": job.job_id,
920+
"base_model": job.base_model})
921+
reply = q.get(timeout=timeout)
922+
except queue.Empty:
923+
raise RuntimeError(
924+
f"machine '{job.worker}' didn't answer within {timeout:.0f}s"
925+
) from None
926+
except OSError as e:
927+
raise RuntimeError(
928+
f"machine '{job.worker}' dropped its link mid-request ({e})"
929+
) from None
930+
finally:
931+
self._infer_waiters.pop(rid, None)
932+
if reply.get("error"):
933+
raise RuntimeError(f"machine '{job.worker}': {reply['error']}")
934+
return reply.get("text", "")
935+
888936
def push_cancel(self, job: _Job) -> None:
889937
"""Tell the executing worker to stop, right now, over its socket."""
890938
name = (getattr(job, "_payload", None) or {}).get("worker")
@@ -1280,6 +1328,16 @@ def do_POST(self): # noqa: N802
12801328
self._send(200, {"ok": True})
12811329
elif parts == ["v1", "generate"]:
12821330
b = self._body()
1331+
# a worker-trained shadow answers on its own machine — the
1332+
# adapter format matches that backend, not necessarily ours
1333+
wjob = server.jobs.get(b.get("adapter") or "")
1334+
if wjob is not None and wjob.worker:
1335+
self._send(200, {"text": server.worker_infer(wjob, {
1336+
"type": "generate", "prompt": b["prompt"],
1337+
"max_new_tokens": b.get("max_new_tokens", 256),
1338+
"temperature": b.get("temperature", 0.7),
1339+
"top_p": b.get("top_p", 0.95)})})
1340+
return
12831341
with server._model_lock:
12841342
m = server._infer_model(b["model"], b.get("adapter"), b.get("checkpoint"))
12851343
text = m.generate(
@@ -1290,6 +1348,14 @@ def do_POST(self): # noqa: N802
12901348
self._send(200, {"text": text})
12911349
elif parts == ["v1", "chat"]:
12921350
b = self._body()
1351+
wjob = server.jobs.get(b.get("adapter") or "")
1352+
if wjob is not None and wjob.worker:
1353+
self._send(200, {"text": server.worker_infer(wjob, {
1354+
"type": "chat", "messages": b["messages"],
1355+
"max_new_tokens": b.get("max_new_tokens", 512),
1356+
"temperature": b.get("temperature", 0.7),
1357+
"top_p": b.get("top_p", 0.95)})})
1358+
return
12931359
with server._model_lock:
12941360
m = server._infer_model(b["model"], b.get("adapter"), b.get("checkpoint"))
12951361
reply = m.chat(

0 commit comments

Comments
 (0)