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>
This commit is contained in:
MODELBEAST 2026-07-14 13:38:34 +10:00
parent d8d44bad45
commit 5f46939321
3 changed files with 209 additions and 22 deletions

113
server/remote.py Normal file
View File

@ -0,0 +1,113 @@
"""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)

View File

@ -18,10 +18,12 @@ import signal
import sys
from pathlib import Path
from . import db, settings as settings_mod, store
from . import db, remote, settings as settings_mod, store
JOBS_DIR = db.DATA / "jobs"
LANE_LIMITS = {"gpu": 1, "cpu": 3, "net": 6}
# gpu is a NODE POOL (this Mac + remote workers), not a plain semaphore; cpu/net
# are semaphores. Each Mac has ONE Metal device, so each gpu node runs one job.
LANE_LIMITS = {"cpu": 3, "net": 6}
class Runner:
@ -30,6 +32,7 @@ class Runner:
self.subscribers: dict = {} # websocket -> user dict (for per-user scoping)
self.operators: dict[str, dict] = {}
self.lanes: dict[str, asyncio.Semaphore] = {}
self.gpu_nodes: list[dict] = [] # [{local}, {ssh,root,operators}, ...]
self.procs: dict = {} # job_id -> subprocess
self.cancelled: set[str] = set()
self._tasks: set = set()
@ -55,7 +58,9 @@ class Runner:
def lane_of(self, operator: str) -> str:
op = self.operators.get(operator, {})
lane = op.get("resources", "cpu")
return lane if lane in LANE_LIMITS else "cpu"
# gpu is a valid lane (the node pool) even though it isn't in LANE_LIMITS,
# which holds only the semaphore lanes (cpu/net).
return lane if lane in LANE_LIMITS or lane == "gpu" else "cpu"
# -- job lifecycle --------------------------------------------------------
def create_job(self, con, operator: str, asset_ids: list[str], params: dict,
@ -135,6 +140,9 @@ class Runner:
con = db.connect()
for key in LANE_LIMITS:
self.lanes[key] = asyncio.Semaphore(LANE_LIMITS[key])
self.gpu_nodes = remote.load_gpu_nodes()
names = ", ".join(n.get("name", n.get("ssh", "?")) for n in self.gpu_nodes)
print(f"[runner] gpu pool: {names}", flush=True)
# re-queue jobs left running/queued by a previous process
for row in con.execute("SELECT id FROM jobs WHERE status IN ('queued','running')"):
con.execute("UPDATE jobs SET status='queued' WHERE id = ?", (row["id"],))
@ -156,6 +164,19 @@ class Runner:
await self._update(con, job_id, status="cancelled", finished_at=db.now())
return
lane = self.lane_of(job["operator"])
if lane == "gpu":
node = await self._acquire_gpu_node(job["operator"])
try:
if job_id in self.cancelled:
self.cancelled.discard(job_id)
await self._update(con, job_id, status="cancelled", finished_at=db.now())
return
await self._run_job(con, job_id, node=node)
except Exception as e:
await self._update(con, job_id, status="error", error=str(e), finished_at=db.now())
finally:
node["busy"] = False
return
async with self.lanes[lane]:
if job_id in self.cancelled:
self.cancelled.discard(job_id)
@ -166,7 +187,22 @@ class Runner:
except Exception as e:
await self._update(con, job_id, status="error", error=str(e), finished_at=db.now())
async def _run_job(self, con, job_id: str):
async def _acquire_gpu_node(self, op_id: str) -> dict:
"""Return the first free gpu node that supports this operator (local always
does; a remote must list it + be reachable). Polls gpu jobs are coarse."""
while True:
for n in self.gpu_nodes:
if n["busy"] or not remote.node_supports(n, op_id):
continue
n["busy"] = True # reserve synchronously — the health check below
# awaits, and without reserving first two jobs could grab one node
if not n.get("local") and not await remote.healthy(n):
n["busy"] = False # unreachable right now; release and keep looking
continue
return n
await asyncio.sleep(0.25)
async def _run_job(self, con, job_id: str, node: dict | None = None):
job = self.get_job(con, job_id)
if not job:
return
@ -192,28 +228,48 @@ class Runner:
return
outdir = Path(job["outdir"])
params_json = json.dumps(job["params"])
local_inputs = self.input_paths(con, job)
entry = Path(op["dir"]) / op.get("entry", "run.py")
# a manifest "python" may be relative to the repo root (portable across
# machines) or absolute (legacy); resolve relative ones against db.ROOT
op_py = op.get("python")
if op_py and not Path(op_py).is_absolute():
op_py = str(db.ROOT / op_py)
python = op_py or sys.executable
if op_py and not Path(python).exists():
await self._update(con, job_id, status="error", finished_at=db.now(),
error=f"operator not installed — {python} missing. "
f"Run its install script under scripts/.")
return
cmd = [python, str(entry), "--outdir", str(outdir), "--params", json.dumps(job["params"])]
for p in self.input_paths(con, job):
cmd += ["--input", p]
# manifest "python" may be repo-relative (portable) or absolute (legacy)
op_py_rel = op.get("python")
op_py_abs = (str(db.ROOT / op_py_rel)
if op_py_rel and not Path(op_py_rel).is_absolute() else op_py_rel)
node = node or {"local": True, "name": "local"}
if node.get("local"):
python = op_py_abs or sys.executable
if op_py_abs and not Path(python).exists():
await self._update(con, job_id, status="error", finished_at=db.now(),
error=f"operator not installed — {python} missing. "
f"Run its install script under scripts/.")
return
cmd = [python, str(entry), "--outdir", str(outdir), "--params", params_json]
for p in local_inputs:
cmd += ["--input", p]
run_env = env
else: # remote node: rsync inputs out, run over ssh, rsync outputs back
if not op_py_rel or Path(op_py_rel).is_absolute():
await self._update(con, job_id, status="error", finished_at=db.now(),
error="operator has no repo-relative python — can't run remotely")
return
try:
remote_inputs = await remote.prepare(node, job_id, local_inputs)
except Exception as e:
await self._update(con, job_id, status="error", finished_at=db.now(),
error=f"remote prepare failed: {e}")
return
entry_rel = os.path.relpath(str(entry), str(db.ROOT))
cmd = remote.remote_cmd(node, op["id"], op_py_rel, entry_rel,
job_id, params_json, remote_inputs)
run_env = os.environ.copy()
await self._update(con, job_id, status="running", started_at=db.now())
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
start_new_session=True, env=env)
start_new_session=True, env=run_env)
self.procs[job_id] = proc
log_lines: list[str] = []
log_lines: list[str] = [f"[node: {node.get('name', 'local')}]\n"]
assert proc.stdout
try:
async for raw in proc.stdout:
@ -235,6 +291,13 @@ class Runner:
error=f"exit code {code}", finished_at=db.now())
return
if not node.get("local"): # pull the remote outputs into the local job dir
try:
await remote.collect(node, job_id, outdir)
except Exception as e:
await self._update(con, job_id, status="error", log=log,
error=f"remote collect failed: {e}", finished_at=db.now())
return
registered = self._register_outputs(con, outdir, job_id)
await self._update(con, job_id, status="done", log=log, finished_at=db.now())
await self.broadcast({"type": "assets_changed", "job_id": job_id, "assets": registered})

View File

@ -53,8 +53,11 @@ def _du_gb(path: Path):
def _lanes(con, runner):
limits = getattr(runner, "LANE_LIMITS", None) or {"gpu": 1, "cpu": 3, "net": 6}
lanes = {k: {"limit": v, "running": 0, "queued": 0} for k, v in limits.items()}
# gpu lane capacity = number of gpu nodes in the pool (this Mac + remote workers)
gpu_limit = max(1, len(getattr(runner, "gpu_nodes", []) or []))
lanes = {"gpu": {"limit": gpu_limit, "running": 0, "queued": 0},
"cpu": {"limit": 3, "running": 0, "queued": 0},
"net": {"limit": 6, "running": 0, "queued": 0}}
rows = con.execute("SELECT operator, status FROM jobs WHERE status IN ('queued','running')").fetchall()
for r in rows:
lane = runner.lane_of(r["operator"])
@ -64,6 +67,13 @@ def _lanes(con, runner):
return lanes
def gpu_pool(runner) -> list[dict]:
"""Per-node status for the dashboard: name, remote?, currently busy."""
return [{"name": n.get("name", n.get("ssh", "?")),
"remote": not n.get("local", False),
"busy": n.get("busy", False)} for n in getattr(runner, "gpu_nodes", [])]
def _jobs_24h(con):
cutoff = time.time() - 86400
rows = con.execute(
@ -99,6 +109,7 @@ def snapshot(con, runner) -> dict:
**base,
"gpu": _macmon_gpu(),
"lanes": _lanes(con, runner),
"nodes": gpu_pool(runner),
"jobs_24h": _jobs_24h(con),
"data_dir_gb": _cached("data_du", 300.0, lambda: _du_gb(db.DATA)),
"hf_cache_gb": _cached("hf_du", 300.0, lambda: _du_gb(hf) if hf.exists() else None),