modelbeast/server/remote.py
MODELBEAST 5f46939321 runner: gpu lane becomes a node pool (local + remote workers)
The gpu lane is now a pool of nodes -- this Mac plus any remote workers in
nodes.json (gitignored, primary-only). A gpu job acquires the first free node
that supports its operator; local runs as before, a remote node has its inputs
rsynced out, runs the operator's run.py over ssh (keys sourced from the node's
.env.remote, kept off the process table), then its outputs are rsynced back and
registered locally exactly as for a local job. One job per node (one Metal
device each).

- server/remote.py (new): node loading, cached health checks, prepare/cmd/collect
- runner: _acquire_gpu_node + a remote branch in _run_job. lane_of now treats
  'gpu' as a real lane -- it was dropped from LANE_LIMITS (which holds only the
  semaphore lanes cpu/net), so gpu jobs were silently falling back to the cpu
  lane and the pool was never reached. A node is reserved before the health
  await so two concurrent jobs can't grab the same one.
- sysinfo: gpu lane capacity = pool size, plus per-node status for the dashboard

Verified: all 7 gpu operators route to the pool; remote_cmd/node_supports and
nodes.json parsing correct; acquisition is race-free; a scratch-DB server boots
clean and logs the pool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:38:34 +10:00

114 lines
4.6 KiB
Python

"""Remote GPU worker dispatch (the unified worker pool).
The gpu lane is a pool of NODES: the local Mac plus any remote workers listed in
nodes.json. A remote node runs the SAME operator code (a git-synced checkout) with
repo-relative paths. The primary never lets a remote touch its DB — it rsyncs the
inputs out, runs the operator's run.py over ssh, and rsyncs the outputs back, then
registers them locally exactly as for a local job.
nodes.json (repo root, gitignored — present only on the primary), e.g.:
[{"name": "m1", "ssh": "johnking@100.91.239.7", "root": "/Users/johnking/MODELBEAST",
"operators": ["flux_local","mflux_image_edit","seedvr2_upscale","bg_remove_local","sf3d"]}]
Omit "operators" to allow every gpu op. The implicit local node is always present.
"""
import asyncio
import json
import shlex
import time
from pathlib import Path
from . import db
SSH = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8"]
_health: dict[str, tuple[float, bool]] = {} # ssh -> (checked_at, ok)
_HEALTH_TTL = 30.0
def load_gpu_nodes() -> list[dict]:
"""Local node first, then remote workers from nodes.json."""
nodes = [{"name": "local", "local": True, "busy": False}]
f = db.ROOT / "nodes.json"
if f.exists():
try:
for n in json.loads(f.read_text()):
n["busy"] = False
n["local"] = False
nodes.append(n)
except (ValueError, KeyError):
pass
return nodes
def node_supports(node: dict, op_id: str) -> bool:
if node.get("local"):
return True
allow = node.get("operators")
return (not allow) or (op_id in allow)
async def _run(cmd: list[str], timeout: float = 300) -> tuple[int, str]:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
try:
out, _ = await asyncio.wait_for(proc.communicate(), timeout)
except asyncio.TimeoutError:
proc.kill()
return 124, "timeout"
return proc.returncode, (out or b"").decode(errors="replace")
async def healthy(node: dict) -> bool:
"""Reachable + checkout present. Cached for _HEALTH_TTL to avoid per-job ssh."""
if node.get("local"):
return True
key = node["ssh"]
hit = _health.get(key)
if hit and time.time() - hit[0] < _HEALTH_TTL:
return hit[1]
code, _ = await _run(SSH + [node["ssh"], f"test -d {shlex.quote(node['root'])}"], timeout=10)
ok = code == 0
_health[key] = (time.time(), ok)
return ok
async def prepare(node: dict, job_id: str, local_inputs: list[str]) -> list[str]:
"""Make remote dirs + rsync inputs. Returns the remote input paths."""
root = node["root"]
rjob = f"{root}/data/remote/{job_id}"
await _run(SSH + [node["ssh"], f"mkdir -p {shlex.quote(rjob)}/inputs {shlex.quote(rjob)}/out"])
remote_inputs = []
for lp in local_inputs:
name = Path(lp).name
dest = f"{node['ssh']}:{rjob}/inputs/{name}"
code, out = await _run(["rsync", "-a", "-e", "ssh -o BatchMode=yes",
lp.rstrip("/") + ("/" if Path(lp).is_dir() else ""), dest], timeout=600)
if code != 0:
raise RuntimeError(f"rsync input failed: {out[:200]}")
remote_inputs.append(f"{rjob}/inputs/{name}")
return remote_inputs
def remote_cmd(node: dict, op_id: str, op_python_rel: str, entry_rel: str,
job_id: str, params_json: str, remote_inputs: list[str]) -> list[str]:
"""ssh command that runs the operator on the remote node with repo-relative paths.
HF_TOKEN etc. are sourced from the node's .env.remote (kept off the process table)."""
root = node["root"]
rout = f"{root}/data/remote/{job_id}/out"
inner = (f"cd {shlex.quote(root)} && set -a; [ -f .env.remote ] && . ./.env.remote; set +a; "
f"exec {shlex.quote(op_python_rel)} {shlex.quote(entry_rel)} "
f"--outdir {shlex.quote(rout)} --params {shlex.quote(params_json)}")
for ri in remote_inputs:
inner += f" --input {shlex.quote(ri)}"
return SSH + ["-T", node["ssh"], f"bash -lc {shlex.quote(inner)}"]
async def collect(node: dict, job_id: str, local_outdir: Path) -> None:
"""rsync the remote outputs back into the local job outdir, then clean up remote."""
root = node["root"]
rjob = f"{root}/data/remote/{job_id}"
code, out = await _run(["rsync", "-a", "-e", "ssh -o BatchMode=yes",
f"{node['ssh']}:{rjob}/out/", f"{str(local_outdir).rstrip('/')}/"], timeout=600)
if code != 0:
raise RuntimeError(f"rsync output failed: {out[:200]}")
await _run(SSH + [node["ssh"], f"rm -rf {shlex.quote(rjob)}"], timeout=30)