#!/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)