#!/usr/bin/env python3 import sys import os import json import shutil import tempfile import subprocess import time import signal import socket import threading from pathlib import Path # --- Configuration & Defaults --- CONFIG_FILE = Path.home() / ".config" / "distributed-llama.json" DEFAULT_MODELS_DIR = Path.home() / "models" # Binary paths: override via env vars or persisted config DEFAULT_LLAMA_SERVER_BIN = os.getenv("LLAMA_SERVER_BIN", "llama-server") DEFAULT_RPC_SERVER_BIN = os.getenv("LLAMA_RPC_SERVER_BIN", "rpc-server") REMOTE_SSH_PORT = os.getenv("REMOTE_PORT", "22") RPC_PORT = int(os.getenv("RPC_PORT", "50052")) LOCAL_HOST_PORT = os.getenv("LOCAL_HOST_PORT", "8080") MODES = ["llama-server", "llama-cli", "llama-bench"] DEFAULT_MODE = "llama-server" DEFAULT_HOSTS: list = [] KV_CACHE_QUANT_VALUES = ("q8_0", "q5_1", "q5_0", "q4_1", "q4_0", "iq4_nl") KV_CACHE_OPTIONS = { "off": "Disabled (full precision)", "q8_0": "Q8_0 (recommended)", "q5_1": "Q5_1", "q5_0": "Q5_0", "q4_1": "Q4_1", "q4_0": "Q4_0 (aggressive)", "iq4_nl": "IQ4_NL", } HEALTH_CHECK_INTERVAL = 10 # seconds between liveness polls RESTART_COOLDOWN = 5 # seconds to wait before attempting restart PORT_WAIT_TIMEOUT = 30 # seconds to wait for a (re)started node's port # --- Helpers --- def check_dependencies(): missing = [t for t in ("dialog", "ssh") if not shutil.which(t)] if missing: print(f"Error: missing required tools: {', '.join(missing)}") sys.exit(1) def run_dialog(args): with tempfile.NamedTemporaryFile(mode="w+") as tf: try: res = subprocess.run(["dialog"] + args, stderr=tf, check=False) tf.seek(0) return tf.read().strip(), res.returncode except Exception: return None, 1 def show_msg(title, msg): run_dialog(["--title", title, "--msgbox", msg, "10", "60"]) def _port_open(ip, port, timeout=2): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) s.connect((ip, port)) s.close() return True except Exception: return False def _wait_for_port(ip, port, stop_event, timeout=PORT_WAIT_TIMEOUT): for _ in range(timeout): if stop_event.is_set(): return False if _port_open(ip, port): return True time.sleep(1) return False # --- File Picker (GGUF only) --- def _dir_contents(path): try: entries = os.listdir(path) except PermissionError: return [], [] dirs = sorted(e for e in entries if os.path.isdir(os.path.join(path, e))) files = sorted(e for e in entries if e.endswith(".gguf")) return dirs, files def custom_file_picker(start_path): current = os.path.abspath(start_path if os.path.isdir(start_path) else os.path.dirname(start_path)) while True: dirs, files = _dir_contents(current) items = [] if current != "/": items += ["..", "Parent Directory"] for d in dirs: items += [d + "/", ""] for f in files: items += [f, ""] if not items: items += [".", "Empty Directory"] pretty = current if len(current) <= 50 else "..." + current[-47:] sel, code = run_dialog([ "--title", "Select GGUF File", "--backtitle", f"Current: {pretty}", "--menu", "Navigate and select a .gguf file:", "20", "70", "12", *items, ]) if code != 0: return None sel = sel.strip() if sel == "..": current = os.path.dirname(current) elif sel.endswith("/"): current = os.path.join(current, sel[:-1]) elif sel != ".": return os.path.join(current, sel) # --- Node Monitor (health check + auto-restart) --- class NodeMonitor(threading.Thread): """Watches one RPC node and restarts it via SSH if the port goes dark.""" def __init__(self, ip, rpc_port, ssh_port, remote_bin, stop_event): super().__init__(daemon=True, name=f"monitor-{ip}") self.ip = ip self.rpc_port = rpc_port self.ssh_port = ssh_port self.remote_bin = remote_bin # path to rpc-server on the remote machine self.stop_event = stop_event self.restart_count = 0 self.status = "ok" # "ok" | "down" | "restarting" | "unreachable" # --- internals --- def _ssh_start(self): """Kill any stale rpc-server, start a fresh one, return PID string or None.""" script = ( f"pkill -9 -f '{self.remote_bin}' 2>/dev/null || true\n" f"sleep 1\n" f"nohup {self.remote_bin} -H 0.0.0.0 -p {self.rpc_port} " f"> /tmp/rpc-server-{self.ip}.log 2>&1 < /dev/null &\n" f"echo $!\n" ) res = subprocess.run( [ "ssh", "-p", self.ssh_port, "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", self.ip, "bash -s", ], input=script, text=True, capture_output=True, ) if res.returncode != 0: return None for line in reversed(res.stdout.splitlines()): if line.strip().isdigit(): return line.strip() return None def _attempt_restart(self): self.status = "restarting" print(f"\n[MONITOR] {self.ip}: restarting RPC server...", flush=True) pid = self._ssh_start() if not pid: self.status = "unreachable" print(f"[MONITOR] {self.ip}: SSH failed — node unreachable.", flush=True) return print(f"[MONITOR] {self.ip}: started (PID {pid}), waiting for port...", flush=True) if _wait_for_port(self.ip, self.rpc_port, self.stop_event): self.restart_count += 1 self.status = "ok" print(f"[MONITOR] {self.ip}: back online (restart #{self.restart_count})", flush=True) else: self.status = "unreachable" print(f"[MONITOR] {self.ip}: port did not open after restart.", flush=True) # --- thread entry point --- def run(self): while not self.stop_event.wait(HEALTH_CHECK_INTERVAL): if not _port_open(self.ip, self.rpc_port): self.status = "down" print(f"\n[MONITOR] {self.ip}:{self.rpc_port} is DOWN.", flush=True) time.sleep(RESTART_COOLDOWN) if not self.stop_event.is_set(): self._attempt_restart() else: self.status = "ok" # --- AppState --- class AppState: def __init__(self): self.model_path = "" self.mode = DEFAULT_MODE self.hosts: list = list(DEFAULT_HOSTS) # [[ip, enabled], ...] self.context_size = None self.bench_prefill = "512,8192,16384,32768,65536" self.bench_gen = "128" self.kv_cache_quant = None self.extra_args = "" self.bench_extra_args = "" self.llama_server_bin = DEFAULT_LLAMA_SERVER_BIN self.rpc_server_bin = DEFAULT_RPC_SERVER_BIN self.load_config() @property def active_hosts(self): return [h[0] for h in self.hosts if h[1]] def save_config(self): data = { "model_path": self.model_path, "mode": self.mode, "hosts": self.hosts, "context_size": self.context_size, "bench_prefill": self.bench_prefill, "bench_gen": self.bench_gen, "kv_cache_quant": self.kv_cache_quant, "extra_args": self.extra_args, "bench_extra_args": self.bench_extra_args, "llama_server_bin": self.llama_server_bin, "rpc_server_bin": self.rpc_server_bin, } try: CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) CONFIG_FILE.write_text(json.dumps(data, indent=2)) except OSError: pass def load_config(self): if not CONFIG_FILE.is_file(): return try: data = json.loads(CONFIG_FILE.read_text()) except (json.JSONDecodeError, OSError): return if not isinstance(data, dict): return mp = data.get("model_path", "") if isinstance(mp, str) and mp and os.path.isfile(mp): self.model_path = mp md = data.get("mode") if isinstance(md, str) and md in MODES: self.mode = md hs = data.get("hosts") if isinstance(hs, list) and hs: valid = [ e for e in hs if isinstance(e, list) and len(e) == 2 and isinstance(e[0], str) and isinstance(e[1], bool) ] if valid: self.hosts = valid cs = data.get("context_size") if cs is None or (isinstance(cs, int) and cs > 0): self.context_size = cs kv = data.get("kv_cache_quant") if kv is None or (isinstance(kv, str) and kv in KV_CACHE_QUANT_VALUES): self.kv_cache_quant = kv for attr in ("bench_prefill", "bench_gen", "extra_args", "bench_extra_args"): v = data.get(attr) if isinstance(v, str): setattr(self, attr, v) for attr in ("llama_server_bin", "rpc_server_bin"): v = data.get(attr) if isinstance(v, str) and v: setattr(self, attr, v) # --- TUI actions --- def select_model(state): start = state.model_path if state.model_path else str(DEFAULT_MODELS_DIR) if os.path.isfile(start): start = os.path.dirname(start) sel = custom_file_picker(start) if sel: state.model_path = sel def select_mode(state): items = [] for m in MODES: items += [m, ""] sel, code = run_dialog([ "--title", "Select Execution Mode", "--menu", "Choose how to run the model:", "12", "50", "5", *items, ]) if code == 0 and sel: state.mode = sel def select_context(state): if state.mode == "llama-bench": sel_p, code = run_dialog([ "--title", "Bench Prefill Sizes (pp)", "--inputbox", "Comma-separated prompt sizes (e.g. 512,8192,16384).\nLeave empty to skip:", "10", "68", str(state.bench_prefill or ""), ]) if code == 0: state.bench_prefill = sel_p.strip() or None sel_n, code2 = run_dialog([ "--title", "Bench Token Generation (tg)", "--inputbox", "Comma-separated generation lengths (e.g. 128).\nLeave empty to skip:", "10", "68", str(state.bench_gen or ""), ]) if code2 == 0: state.bench_gen = sel_n.strip() or None else: sel, code = run_dialog([ "--title", "Context Size", "--inputbox", "Enter context size (e.g. 4096, 8192).\nLeave empty for model default:", "10", "60", str(state.context_size or ""), ]) if code == 0: state.context_size = int(sel.strip()) if sel.strip().isdigit() else None def select_kv_cache(state): items = [] for k, v in KV_CACHE_OPTIONS.items(): items += [k, v] sel, code = run_dialog([ "--title", "KV Cache Quantization", "--menu", "Quantize the KV cache to reduce memory usage:", "14", "55", "5", *items, ]) if code == 0 and sel: state.kv_cache_quant = None if sel == "off" else sel def edit_extra_args(state): is_bench = state.mode == "llama-bench" current = state.bench_extra_args if is_bench else state.extra_args label = "(Bench)" if is_bench else "(Server/CLI)" sel, code = run_dialog([ "--title", f"Extra Arguments {label}", "--inputbox", f"Extra CLI args for {state.mode} (appended as-is).\nLeave empty for none:", "12", "65", current, ]) if code == 0: if is_bench: state.bench_extra_args = sel.strip() else: state.extra_args = sel.strip() def edit_binaries(state): sel_s, code = run_dialog([ "--title", "Local llama-server Binary", "--inputbox", "Path or name of the local llama-server binary:", "10", "65", state.llama_server_bin, ]) if code == 0 and sel_s.strip(): state.llama_server_bin = sel_s.strip() sel_r, code = run_dialog([ "--title", "Remote rpc-server Binary", "--inputbox", "Path or name of rpc-server on the REMOTE machines.\n" "(e.g. /opt/llama/rpc-server or just rpc-server if in PATH):", "11", "65", state.rpc_server_bin, ]) if code == 0 and sel_r.strip(): state.rpc_server_bin = sel_r.strip() def add_server(state): sel, code = run_dialog(["--title", "Add Server", "--inputbox", "Enter new server IP address:", "10", "50"]) if code == 0 and sel.strip(): state.hosts.append([sel.strip(), True]) def remove_server(state): if not state.hosts: show_msg("Info", "No servers to remove.") return items = [] for i, (ip, _) in enumerate(state.hosts): items += [str(i), ip] sel, code = run_dialog(["--title", "Remove Server", "--menu", "Select server to remove:", "15", "50", "5", *items]) if code == 0 and sel: idx = int(sel) if 0 <= idx < len(state.hosts): del state.hosts[idx] def edit_server(state): if not state.hosts: show_msg("Info", "No servers to edit.") return items = [] for i, (ip, _) in enumerate(state.hosts): items += [str(i), ip] sel, code = run_dialog(["--title", "Edit Server", "--menu", "Select server to edit:", "15", "50", "5", *items]) if code == 0 and sel: idx = int(sel) if 0 <= idx < len(state.hosts): new_ip, code2 = run_dialog([ "--title", "Edit Server IP", "--inputbox", "Enter new IP address:", "10", "50", state.hosts[idx][0], ]) if code2 == 0 and new_ip.strip(): state.hosts[idx][0] = new_ip.strip() def toggle_servers(state): if not state.hosts: show_msg("Info", "No servers to configure. Add some first.") return items = [] for i, (ip, enabled) in enumerate(state.hosts): items += [str(i), ip, "on" if enabled else "off"] sel_str, code = run_dialog([ "--title", "Toggle Active Servers", "--checklist", "Space to toggle active nodes:", "15", "50", "5", *items, ]) if code == 0: for h in state.hosts: h[1] = False if sel_str: for x in sel_str.split(): idx = int(x.strip('"')) if 0 <= idx < len(state.hosts): state.hosts[idx][1] = True def configure_servers(state): while True: menu = ["1", "Toggle Active Servers", "2", "Add Server", "3", "Remove Server", "4", "Edit Server", "5", "Back"] sel, code = run_dialog(["--title", "Manage Remote Servers", "--menu", "Choose an action:", "15", "50", "5", *menu]) if code != 0 or sel == "5": break {"1": toggle_servers, "2": add_server, "3": remove_server, "4": edit_server}.get(sel, lambda s: None)(state) # --- Core: start one RPC node and return its PID --- def _ssh_start_rpc(ip, rpc_port, ssh_port, remote_bin): script = ( f"pkill -9 -f '{remote_bin}' 2>/dev/null || true\n" f"sleep 1\n" f"nohup {remote_bin} -H 0.0.0.0 -p {rpc_port} " f"> /tmp/rpc-server-{ip}.log 2>&1 < /dev/null &\n" f"echo $!\n" ) res = subprocess.run( [ "ssh", "-p", ssh_port, "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", ip, "bash -s", ], input=script, text=True, capture_output=True, ) if res.returncode != 0: return None, res.stderr.strip() for line in reversed(res.stdout.splitlines()): if line.strip().isdigit(): return line.strip(), None return None, f"unexpected output: {res.stdout!r}" def _kill_rpc(ip, ssh_port, remote_bin, pid=None): parts = [] if pid: parts.append(f"kill -9 {pid} 2>/dev/null || true") parts.append(f"pkill -9 -f '{remote_bin}' 2>/dev/null || true") subprocess.run( ["ssh", "-p", ssh_port, "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ip, "; ".join(parts)], stderr=subprocess.DEVNULL, ) # --- Run --- def run_distributed(state): if not state.model_path or not os.path.exists(state.model_path): show_msg("Error", f"Model file not found:\n{state.model_path}") return if not state.active_hosts: show_msg("Error", "No remote servers selected.") return subprocess.run(["clear"]) print("=== Distributed llama.cpp ===") print(f"Model: {state.model_path}") print(f"Mode: {state.mode}") print(f"Local bin: {state.llama_server_bin}") print(f"Remote bin: {state.rpc_server_bin}") if state.mode == "llama-bench": print(f"Prefill: {state.bench_prefill or '-'}") print(f"Gen: {state.bench_gen or '-'}") else: print(f"Context: {state.context_size or 'default'}") print(f"KV Cache: {state.kv_cache_quant or 'off'}") print(f"Hosts: {state.active_hosts}") print("---") active_ips = state.active_hosts remote_pids: dict[str, str | None] = {} stop_event = threading.Event() monitors: list[NodeMonitor] = [] def cleanup(): print("\nStopping monitors and cleaning up remote RPC servers...") stop_event.set() for ip in active_ips: pid = remote_pids.get(ip) print(f" Killing RPC on {ip} (PID: {pid or '?'})...") _kill_rpc(ip, REMOTE_SSH_PORT, state.rpc_server_bin, pid) def on_signal(sig, frame): cleanup() sys.exit(0) signal.signal(signal.SIGINT, on_signal) signal.signal(signal.SIGTERM, on_signal) try: rpc_endpoints = [] # 1. Start RPC servers on all remote nodes for ip in active_ips: print(f"-> Starting RPC server on {ip}...", end=" ", flush=True) pid, err = _ssh_start_rpc(ip, RPC_PORT, REMOTE_SSH_PORT, state.rpc_server_bin) if not pid: print(f"FAILED ({err})") cleanup() return remote_pids[ip] = pid print(f"PID {pid}", end=" ", flush=True) print(f"| waiting for :{RPC_PORT}...", end=" ", flush=True) if not _wait_for_port(ip, RPC_PORT, stop_event): print("TIMEOUT") cleanup() return print("OK") rpc_endpoints.append(f"{ip}:{RPC_PORT}") rpc_arg = ",".join(rpc_endpoints) print(f"All nodes ready. RPC: {rpc_arg}") # 2. Start health monitor threads for ip in active_ips: m = NodeMonitor(ip, RPC_PORT, REMOTE_SSH_PORT, state.rpc_server_bin, stop_event) m.start() monitors.append(m) print(f"[MONITOR] Watching {len(monitors)} node(s) every {HEALTH_CHECK_INTERVAL}s.") print("---") # 3. Build and run local command import shlex cmd = [state.llama_server_bin, "-m", state.model_path, "--rpc", rpc_arg] if state.mode == "llama-server": cmd += ["--host", "0.0.0.0", "--port", LOCAL_HOST_PORT] if state.context_size: cmd += ["-c", str(state.context_size)] elif state.mode == "llama-cli": cmd += ["-cnv", "-p", "You are a helpful assistant."] if state.context_size: cmd += ["-c", str(state.context_size)] elif state.mode == "llama-bench": if state.bench_prefill: cmd += ["-p", state.bench_prefill.strip()] if state.bench_gen: cmd += ["-n", state.bench_gen.strip()] if state.kv_cache_quant: cmd += ["--cache-type-k", state.kv_cache_quant, "--cache-type-v", state.kv_cache_quant] extra = state.bench_extra_args if state.mode == "llama-bench" else state.extra_args if extra: cmd += shlex.split(extra) print(f"CMD: {' '.join(cmd)}") print("---") proc = subprocess.Popen(cmd) proc.wait() except Exception as e: print(f"\n[ERROR] {e}") finally: cleanup() input("\nDone. Press Enter to return to menu...") # --- Main Menu --- def main_menu(): state = AppState() while True: model_display = Path(state.model_path).name if state.model_path else "(None)" servers_display = f"{len(state.active_hosts)} active" if state.mode == "llama-bench": ctx_display = f"pp=[{state.bench_prefill or '-'}] tg={state.bench_gen or '-'}" ctx_label = "Bench: " run_label = "RUN BENCHMARK" else: ctx_display = str(state.context_size) if state.context_size else "default" ctx_label = "Context: " run_label = "RUN DISTRIBUTED SERVER" kv_display = state.kv_cache_quant or "off" extra = (state.bench_extra_args if state.mode == "llama-bench" else state.extra_args) or "(none)" menu_args = [ "--clear", "--backtitle", "Distributed llama.cpp", "--title", "Main Menu", "--menu", "Configure and launch distributed inference:", "24", "70", "11", "1", f"Model: {model_display}", "2", f"Servers: {servers_display}", "3", f"Mode: {state.mode}", "4", f"{ctx_label}{ctx_display}", "5", f"KV Cache: {kv_display}", "6", f"Extra: {extra}", "7", f"Binaries: local={Path(state.llama_server_bin).name} remote={Path(state.rpc_server_bin).name}", "8", run_label, "9", "Exit", ] choice, code = run_dialog(menu_args) if code != 0: break { "1": select_model, "2": configure_servers, "3": select_mode, "4": select_context, "5": select_kv_cache, "6": edit_extra_args, "7": edit_binaries, "8": run_distributed, }.get(choice, lambda s: None)(state) if choice == "9": break state.save_config() state.save_config() subprocess.run(["clear"]) if __name__ == "__main__": check_dependencies() try: main_menu() except KeyboardInterrupt: subprocess.run(["clear"]) sys.exit(0)