#!/usr/bin/env python3 """ vllmctl — run vLLM (docker, ROCm) on fl WITHOUT freezing the box. The RX 7600 XT drives the display; VRAM overcommit spills to GTT and freezes the PC. vLLM grabs `gpu-memory-utilization` × total VRAM at startup and keeps it, so this script gates every start: 1. llama.service must be STOPPED (or --stop-router): a LAN client hitting :11343 would make the router load a model on top of vLLM = the freeze. --unsafe-keep-router skips this gate. 2. the card must be clean (VRAM used ≤ drain-gb; resident router models are unloaded via its API). 3. projected free VRAM = total − used − budget must be ≥ reserve-gb (else abort, --force overrides). Presets live in vllm.ini next to this file (same key = --flag convention as llamacpp/config.ini). ./vllmctl.py up [--stop-router] [--force] [--dry-run] start, wait for /health ./vllmctl.py down stop, wait for VRAM to drain ./vllmctl.py status | logs | pull | gpu-check | exec Stdlib only. Run ON fl. """ import argparse, grp, json, os, re, shlex, subprocess, sys, time, urllib.request, urllib.error HERE = os.path.dirname(os.path.abspath(__file__)) INI = os.path.join(HERE, "vllm.ini") DRM = "/sys/class/drm/renderD128/device" # ─── ini ──────────────────────────────────────────────────────────────────────────────────── def parse_ini(path): """{section: {key: value}} — inline `# comments` stripped, order kept.""" sections, sec = {}, None for ln in open(path, encoding="utf-8").read().splitlines(): s = ln.strip() if not s or s.startswith("#"): continue if s.startswith("[") and s.endswith("]"): sec = s[1:-1]; sections[sec] = {} elif sec and "=" in s: k, v = s.split("=", 1) sections[sec][k.strip()] = v.split(" #", 1)[0].split("\t#", 1)[0].strip() return sections def build_args(preset): """vllm serve args from a preset section (x-* and env.* are not flags).""" args = [] for k, v in preset.items(): if k.startswith("x-") or k.startswith("env."): continue if v.lower() == "false": continue if v.lower() == "true": args.append(f"--{k}") else: args += [f"--{k}", v] return args # ─── gpu / router ─────────────────────────────────────────────────────────────────────────── def sysfs(name): try: return int(open(os.path.join(DRM, name)).read().strip()) except (OSError, ValueError): return None def vram(): """(used, total, gtt_used) bytes from amdgpu sysfs; None where unreadable.""" return sysfs("mem_info_vram_used"), sysfs("mem_info_vram_total"), sysfs("mem_info_gtt_used") def gb(b): return f"{b / 1e9:.1f} GB" if b is not None else "-" def http(url, payload=None, timeout=10): data = json.dumps(payload).encode() if payload is not None else None hdrs = {"Content-Type": "application/json"} if data else {} return urllib.request.urlopen(urllib.request.Request(url, data=data, headers=hdrs), timeout=timeout) def _status(m): """Router /models status: newer builds send {'value': 'loaded'|'sleeping'|'unloaded'|…}, older a string.""" st = m.get("status", "loaded") return st.get("value", "loaded") if isinstance(st, dict) else st def router_loaded(root): """ids the llama.cpp router reports as loaded ([] if unreachable).""" try: d = json.load(http(root.rstrip("/") + "/models", timeout=5)) except Exception: return [] return [m["id"] for m in d.get("data", []) if _status(m) in ("loaded", "loading")] def router_unload(root): for m in router_loaded(root): try: http(root.rstrip("/") + "/models/unload", {"model": m}, timeout=30).read() except Exception: pass def router_active(): try: r = subprocess.run(["systemctl", "is-active", "llama.service"], capture_output=True, text=True, timeout=10) except (OSError, subprocess.TimeoutExpired): return False return r.stdout.strip() == "active" def router_hint(): return "" if router_active() else "\n (llama.service is STOPPED → sudo systemctl start llama.service to bring the router back)" def wait_drain(drain_gb, window_s, label): used, _, _ = vram() end = time.time() + window_s said = False while used is not None and used > drain_gb * 1e9 and time.time() < end: if not said: print(f" … waiting for VRAM to drain {label} ({gb(used)} used)"); said = True time.sleep(5) used, _, _ = vram() return used # ─── docker ───────────────────────────────────────────────────────────────────────────────── def docker(*args, check=True, capture=True): return subprocess.run(["docker", *args], check=check, capture_output=capture, text=True) def container_state(name): r = docker("inspect", "-f", "{{.State.Status}}", name, check=False) return r.stdout.strip() if r.returncode == 0 else None def gpu_flags(): flags = ["--device", "/dev/kfd", "--device", "/dev/dri", "--group-add", "video", "--cap-add", "SYS_PTRACE", "--security-opt", "seccomp=unconfined", "--ipc=host"] try: flags += ["--group-add", str(grp.getgrnam("render").gr_gid)] # Arch: /dev/dri/renderD128 is group render except KeyError: pass return flags def run_cmd(g, name, preset, envs): cmd = ["docker", "run", "-d", "--name", g["container"], *gpu_flags(), "-p", f'{g["port"]}:8000', "-v", f'{g["models-dir"]}:/models:ro', "-v", f'{g["hf-cache"]}:/root/.cache/huggingface'] for k, v in envs.items(): cmd += ["-e", f"{k}={v}"] cmd.append(g["image"]) cmd += ["--host", "0.0.0.0", "--port", "8000", "--served-model-name", preset.get("served-model-name", name)] cmd += build_args({k: v for k, v in preset.items() if k != "served-model-name"}) return cmd ROOT_CAUSE = re.compile(r"hipError|HSA_STATUS|code object|gfx\d+|no kernel image|EngineCore.*(ERROR|Error|error)" r"|^\S*(ERROR|CRITICAL)|RuntimeError|ValueError|NotImplementedError|OutOfMemory|Killed|Segmentation" r"|free memory|not supported|Unsupported|Traceback|memory profiling|kv cache memory|weights take|peak", re.I) def root_cause(lines): """The lines that explain a dead engine core: known ROCm/vLLM failure signatures, the FIRST traceback's error line, and the last 8 lines — not the parent's 40-line stack trace.""" out, seen = [], set() for l in lines: if ROOT_CAUSE.search(l) and "api_server" not in l and "core_client" not in l and "launch_core_engines" not in l: key = l.strip()[:120] if key not in seen: seen.add(key); out.append(l.rstrip()) if len(out) >= 30: break return out + ["…"] + [l.rstrip() for l in lines[-8:]] # ─── commands ─────────────────────────────────────────────────────────────────────────────── def cmd_up(a, cfg): g = cfg["global"] if a.preset not in cfg or a.preset == "global": sys.exit(f"unknown preset {a.preset!r}; have: {', '.join(k for k in cfg if k != 'global')}") preset = dict(cfg[a.preset]) preset.update(dict(kv.split("=", 1) for kv in a.set)) # --set gpu-memory-utilization=0.73 --set max-model-len=8192 preset.setdefault("gpu-memory-utilization", g.get("gpu-memory-utilization", "0.75")) envs = {k[4:]: v for k, v in g.items() if k.startswith("env.")} envs.update({k[4:]: v for k, v in preset.items() if k.startswith("env.")}) envs.update(dict(e.split("=", 1) for e in a.env)) util = float(preset["gpu-memory-utilization"]) reserve = float(a.reserve if a.reserve is not None else g.get("reserve-gb", 2.0)) drain = float(g.get("drain-gb", 4.0)) cmd = run_cmd(g, a.preset, preset, envs) print("docker command:\n " + " ".join(shlex.quote(c) for c in cmd) + "\n") if a.dry_run: return st = container_state(g["container"]) if st == "running": sys.exit(f"container {g['container']} is already running — `vllmctl.py down` first") if st: # leftover from a crash: its log is the evidence, show the tail, then clear the name r = docker("logs", "--tail", "15", g["container"], check=False) print(f"removing old container ({st}); its last lines were:\n" + "\n".join(" | " + l for l in (r.stdout + r.stderr).strip().splitlines()[-15:])) docker("rm", g["container"], check=False) # 1. clean card — unload resident router models through its API while it is still up, then drain if router_active(): router_unload(g["router"]) used = wait_drain(drain, 180, "before start") used, total, gtt = vram() if used is None or total is None: if not a.force: sys.exit(f"ABORT: cannot read {DRM}/mem_info_vram_* — no VRAM guard, not starting blind (--force overrides)") print("⚠ FORCED without a VRAM guard (sysfs unreadable)"); used, total = 0, 1 if used > drain * 1e9 and not a.force: sys.exit(f"ABORT: card not clean — {gb(used)} VRAM used (> drain-gb {drain}). Leftover model / GPU apps?") # 2. projection — abort here costs nothing, llama.service is still running budget = util * total free_after = total - used - budget weights = float(preset.get("x-weights-gb", 0) or 0) * 1e9 print(f"VRAM: total {gb(total)} · used now {gb(used)} (GTT {gb(gtt)}) · vLLM budget {util:.2f} × total = {gb(budget)}" f"\n → projected free {gb(free_after)} (reserve {reserve} GB)") if weights: room = budget - weights print(f" budget − weights {gb(weights)} = {gb(room)} for activations + KV cache" + (" ⚠ < 1 GB: expect 'no memory for KV cache' → raise util or lower max-model-len" if room < 1e9 else "")) if free_after < reserve * 1e9: suggest = max(0.0, (total - used - reserve * 1e9) / total) msg = (f"projected free {gb(free_after)} < reserve {reserve} GB. Max safe gpu-memory-utilization right now " f"= {suggest:.2f} (desktop is using {gb(used)}).") if not a.force: sys.exit("ABORT: " + msg + f"\n → free the desktop (vramx) or `--set gpu-memory-utilization={suggest:.2f}` " f"(+ `--set max-model-len=8192` if weights leave < 1 GB); --force to start anyway.") print("⚠ FORCED: " + msg) # 3. router gate — the only thing that stops a LAN client from loading a model on top of vLLM if router_active(): if a.stop_router: print("stopping llama.service (sudo) …") subprocess.run(["sudo", "systemctl", "stop", "llama.service"], check=True) elif a.unsafe_keep_router: print("⚠ llama.service stays ACTIVE: any client request to :11343 while vLLM runs = GTT freeze.") else: sys.exit("ABORT: llama.service is active. Any client request to :11343 would load a model on top " "of vLLM and freeze the PC.\n → re-run with --stop-router (sudo systemctl stop llama.service), " "or --unsafe-keep-router if you really know nothing will call the router.") used2, _, _ = vram() if used2 is not None and used2 > used + 1e9: # a client loaded something between projection and stop print(f"⚠ VRAM grew to {gb(used2)} meanwhile — vLLM's own free-memory check should refuse; watch the start") # 4. start + wait cid = subprocess.run(cmd, check=True, capture_output=True, text=True).stdout.strip() print(f"started {cid[:12]} — waiting for http://127.0.0.1:{g['port']}/health (timeout {g.get('health-timeout', 1800)} s; " f"first start compiles kernels)") end = time.time() + int(g.get("health-timeout", 1800)) last_log, shown = 0, set() while time.time() < end: st = container_state(g["container"]) if st != "running": r = docker("logs", g["container"], check=False) print("root cause candidates (full log: docker logs " + g["container"] + "):") for l in root_cause((r.stdout + r.stderr).splitlines()): print(" | " + l[:200]) sys.exit(f"ABORT: container is {st or 'gone'}. If a line names gfx1102 / 'code object' / hipErrorNoBinaryForGpu: " f"uncomment env.HSA_OVERRIDE_GFX_VERSION = 11.0.0 in [global] and retry." + router_hint()) try: if http(f"http://127.0.0.1:{g['port']}/health", timeout=3).status == 200: break except Exception: pass if time.time() - last_log > 30: r = docker("logs", "--tail", "3", g["container"], check=False) tail = (r.stdout + r.stderr).strip().splitlines()[-3:] for t in tail: if t not in shown: # only lines not printed before (compile phases are quiet for minutes) print(" | " + t[:160]); shown.add(t) last_log = time.time() time.sleep(3) else: sys.exit("ABORT: no /health within the timeout — `vllmctl.py logs`, then `vllmctl.py down`" + router_hint()) used, total, gtt = vram() print(f"UP. VRAM used {gb(used)} / free {gb(total - used)} · GTT {gb(gtt)}") try: ids = [m["id"] for m in json.load(http(f"http://127.0.0.1:{g['port']}/v1/models")).get("data", [])] print(f"models: {', '.join(ids)} → http://192.168.0.204:{g['port']}/v1") except Exception as e: print(f"(/v1/models not readable yet: {e})") print(f"next: ./bench-vs.py -t vllm:{preset.get('served-model-name', a.preset)} · when done: ./vllmctl.py down") def cmd_down(a, cfg): g = cfg["global"] st = container_state(g["container"]) if st: print(f"stopping {g['container']} ({st}) …") docker("stop", "-t", "60", g["container"], check=False) docker("rm", g["container"], check=False) else: print("container not running") used = wait_drain(float(g.get("drain-gb", 4.0)), 120, "after stop") used, total, gtt = vram() print(f"VRAM used {gb(used)} / free {gb(total - used) if total and used is not None else '-'} · GTT {gb(gtt)}") if not router_active(): print("llama.service is stopped → `sudo systemctl start llama.service` to bring the router back") def cmd_status(a, cfg): g = cfg["global"] used, total, gtt = vram() print(f"container {g['container']}: {container_state(g['container']) or 'not running'}") print(f"VRAM used {gb(used)} / total {gb(total)} · GTT {gb(gtt)}") print(f"llama.service: {'ACTIVE' if router_active() else 'stopped'} · loaded: {router_loaded(g['router']) or '-'}") try: ids = [m["id"] for m in json.load(http(f"http://127.0.0.1:{g['port']}/v1/models", timeout=3)).get("data", [])] print(f"vLLM :{g['port']} serving: {', '.join(ids)}") except Exception: print(f"vLLM :{g['port']}: not answering") def cmd_logs(a, cfg): os.execvp("docker", ["docker", "logs", "-f", "--tail", "100", cfg["global"]["container"]]) def cmd_pull(a, cfg): os.execvp("docker", ["docker", "pull", cfg["global"]["image"]]) GPU_CHECK = r""" import os, torch print('HSA_OVERRIDE_GFX_VERSION =', os.environ.get('HSA_OVERRIDE_GFX_VERSION', '(unset)')) print('cuda available:', torch.cuda.is_available()) p = torch.cuda.get_device_properties(0) print('device:', torch.cuda.get_device_name(0) or '(no name)', p.gcnArchName, f'{p.total_memory / 1e9:.1f} GB') print('torch kernel …', end=' ', flush=True) # a hard abort here (no code object for this arch) prints nothing more x = torch.ones(1024, device='cuda'); torch.cuda.synchronize(); print('ok', x.sum().item()) import vllm._C # noqa ops = sorted({s.name.split('::', 1)[1] for s in torch._C._jit_get_all_schemas() if s.name.startswith('_C::')}) print(f'vllm _C ops registered: {len(ops)}') f16 = dict(device='cuda', dtype=torch.float16) probes = {'silu_and_mul': lambda: torch.ops._C.silu_and_mul(torch.empty(4, 8, **f16), torch.randn(4, 16, **f16)), 'gelu_fast': lambda: torch.ops._C.gelu_fast(torch.empty(4, 8, **f16), torch.randn(4, 8, **f16)), 'rms_norm': lambda: torch.ops._C.rms_norm(torch.empty(4, 8, **f16), torch.randn(4, 8, **f16), torch.ones(8, **f16), 1e-6)} for name, fn in probes.items(): if name in ops: print(f'vllm custom op {name} …', end=' ', flush=True); fn(); torch.cuda.synchronize(); print('ok -> the image has kernels for this arch') break else: print('none of the probe ops exist in this build; first ops:', ops[:15]) """ def cmd_gpu_check(a, cfg): """Does the image see the card? Prints torch's device name + arch (needs no model).""" g = cfg["global"] envs = [x for k, v in g.items() if k.startswith("env.") for x in ("-e", f"{k[4:]}={v}")] envs += [x for e in a.env for x in ("-e", e)] code = GPU_CHECK cmd = ["docker", "run", "--rm", *gpu_flags(), *envs, "--entrypoint", "python3", g["image"], "-u", "-c", code] print(" ".join(shlex.quote(c) for c in cmd)) os.execvp("docker", cmd) def cmd_exec(a, cfg): g = cfg["global"] if container_state(g["container"]) == "running": os.execvp("docker", ["docker", "exec", "-it", g["container"], *a.cmd]) envs = [x for k, v in g.items() if k.startswith("env.") for x in ("-e", f"{k[4:]}={v}")] os.execvp("docker", ["docker", "run", "--rm", "-it", *gpu_flags(), *envs, "--entrypoint", a.cmd[0], g["image"], *a.cmd[1:]]) def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--ini", default=INI) sub = ap.add_subparsers(dest="cmd", required=True) up = sub.add_parser("up"); up.add_argument("preset") up.add_argument("--stop-router", action="store_true", help="sudo systemctl stop llama.service first") up.add_argument("--unsafe-keep-router", action="store_true", help="leave llama.service running (freeze risk)") up.add_argument("--force", action="store_true", help="ignore the VRAM projection / clean-card checks") up.add_argument("--reserve", type=float, help="override reserve-gb") up.add_argument("--dry-run", action="store_true", help="print the docker command only") up.add_argument("--env", action="append", default=[], metavar="KEY=VAL", help="extra container env (e.g. HSA_OVERRIDE_GFX_VERSION=11.0.0)") up.add_argument("--set", action="append", default=[], metavar="KEY=VAL", help="override a preset key for this start (e.g. gpu-memory-utilization=0.73)") sub.add_parser("down"); sub.add_parser("status"); sub.add_parser("logs"); sub.add_parser("pull") gc = sub.add_parser("gpu-check"); gc.add_argument("--env", action="append", default=[], metavar="KEY=VAL") ex = sub.add_parser("exec"); ex.add_argument("cmd", nargs=argparse.REMAINDER) a = ap.parse_args() cfg = parse_ini(a.ini) if "global" not in cfg: sys.exit(f"{a.ini}: no [global] section") {"up": cmd_up, "down": cmd_down, "status": cmd_status, "logs": cmd_logs, "pull": cmd_pull, "gpu-check": cmd_gpu_check, "exec": cmd_exec}[a.cmd](a, cfg) if __name__ == "__main__": main()