367 lines
15 KiB
Python
367 lines
15 KiB
Python
"""Driving the training host over SSH, including getting the GPU back from the LLMs.
|
|
|
|
Training runs on a separate machine -- a Ryzen AI MAX+ 395 with a Radeon 8060S, whose
|
|
124 GB of memory is *unified*: the iGPU addresses it through GTT rather than owning
|
|
dedicated VRAM. That is why the box can train on frames this large at all, and also why
|
|
it can hold nothing else at the time: llama.cpp keeps 115.6 GB of the 124 GB resident, so
|
|
a training run does not get a smaller allocation, it gets ``Memory in use`` and dies.
|
|
|
|
So something has to give up the GPU for the duration. These services belong to the user,
|
|
not to us, which sets the bar for this module: stop as little as will do, put back
|
|
whatever it stopped -- including when the training run crashes -- and be able to say
|
|
afterwards whether that succeeded.
|
|
|
|
Stopping as little as will do is worth the extra code. Measured on this host, almost
|
|
all of that 115.6 GB is a 120B model that ``llama-swap`` had loaded on demand; with the
|
|
unit stopped, GTT falls to 24.8 GB and roughly 99 GB is free -- ample for training,
|
|
without touching anything else. So :func:`stop_llms` works in stages and escalates only
|
|
if the first stage leaves too little.
|
|
|
|
The two kinds of server are not alike:
|
|
|
|
* ``llama-swap.service``, a systemd --user unit. Stopping and starting it is exact, and
|
|
it is where the large on-demand models live. Almost always sufficient on its own.
|
|
* a **standalone** ``llama-server``, launched by hand and reparented to init. There is
|
|
no unit to restart, so the only way back is to record its argv and working directory
|
|
before killing it and re-exec them. This is the fragile one, only touched when the
|
|
first stage did not free enough, and the reason :func:`llms_paused` verifies the
|
|
restore rather than assuming it.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
import time
|
|
from contextlib import contextmanager
|
|
|
|
HOST = os.environ.get("SUVI_GPU_HOST", "htpc@192.168.1.66")
|
|
#: Read from a file rather than embedded, so the credential is not in the source tree.
|
|
PASSWORD_FILE = os.environ.get(
|
|
"SUVI_GPU_PASSWORD_FILE",
|
|
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
"gpu_system_ssh_info"),
|
|
)
|
|
#: systemd --user units that hold the GPU.
|
|
UNITS = ("llama-swap.service",)
|
|
#: Seconds to wait for GTT to drain / refill before giving up.
|
|
SETTLE_TIMEOUT = 120
|
|
#: Unified memory the iGPU can address, from mem_info_gtt_total on this host.
|
|
GTT_TOTAL = 115 * 1024**3
|
|
#: Free unified memory a training run needs before it will start.
|
|
GTT_FREE_BYTES = 48 * 1024**3
|
|
GTT_USED = "/sys/class/drm/card1/device/mem_info_gtt_used"
|
|
|
|
|
|
def _password():
|
|
with open(PASSWORD_FILE) as handle:
|
|
lines = [line.strip() for line in handle if line.strip()]
|
|
if len(lines) < 2:
|
|
raise RuntimeError(f"{PASSWORD_FILE}: expected an ssh line and a password line")
|
|
return lines[1]
|
|
|
|
|
|
def run(command, check=True, timeout=600, capture=True):
|
|
"""Run a shell command on the training host."""
|
|
argv = [
|
|
"sshpass", "-p", _password(), "ssh",
|
|
"-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
|
|
HOST, command,
|
|
]
|
|
result = subprocess.run(
|
|
argv, timeout=timeout,
|
|
stdout=subprocess.PIPE if capture else None,
|
|
stderr=subprocess.STDOUT if capture else None,
|
|
)
|
|
output = result.stdout.decode(errors="replace") if capture else ""
|
|
if check and result.returncode != 0:
|
|
raise RuntimeError(f"remote command failed ({result.returncode}): {command}\n{output}")
|
|
return output
|
|
|
|
|
|
def gtt_used():
|
|
"""Bytes of unified memory currently mapped to the GPU."""
|
|
try:
|
|
return int(run(f"cat {GTT_USED}", check=False).strip() or 0)
|
|
except ValueError:
|
|
return 0
|
|
|
|
|
|
# --------------------------------------------------------------- pausing the LLMs
|
|
|
|
|
|
def _standalone_servers():
|
|
"""Every llama-server not owned by one of our units, with enough to restart it.
|
|
|
|
A server spawned *by* llama-swap comes back when llama-swap does, so recording it
|
|
would restart it twice. Only the ones reparented to init are ours to restore.
|
|
"""
|
|
script = r"""
|
|
for p in $(pgrep -x llama-server); do
|
|
ppid=$(ps -o ppid= -p $p 2>/dev/null | tr -d ' ')
|
|
[ "$ppid" = "1" ] || continue
|
|
cwd=$(readlink /proc/$p/cwd)
|
|
argv=$(tr '\0' '\n' < /proc/$p/cmdline | sed 's/"/\\"/g' | awk '{printf "\"%s\",", $0}')
|
|
echo "{\"pid\": $p, \"cwd\": \"$cwd\", \"argv\": [${argv%,}]}"
|
|
done
|
|
"""
|
|
found = []
|
|
for line in run(script, check=False).splitlines():
|
|
line = line.strip()
|
|
if line.startswith("{"):
|
|
try:
|
|
found.append(json.loads(line))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
return found
|
|
|
|
|
|
def _wait_for_gtt(below, timeout=SETTLE_TIMEOUT):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if gtt_used() < below:
|
|
return True
|
|
time.sleep(2)
|
|
return False
|
|
|
|
|
|
def stop_llms(needed_bytes=None):
|
|
"""Free the GPU, stopping as little as will do.
|
|
|
|
Stage one stops the units, which is where the big on-demand models live and which
|
|
restarts exactly. Only if that leaves less than `needed_bytes` free does stage two
|
|
kill hand-launched servers, whose restoration is a re-exec rather than a restart.
|
|
|
|
Returns the state :func:`start_llms` needs to undo this.
|
|
"""
|
|
needed = GTT_TOTAL - (needed_bytes or GTT_FREE_BYTES)
|
|
state = {"units": [], "standalone": []}
|
|
|
|
for unit in UNITS:
|
|
active = run(f"systemctl --user is-active {unit}", check=False).strip()
|
|
state["units"].append({"unit": unit, "was_active": active == "active"})
|
|
if active == "active":
|
|
run(f"systemctl --user stop {unit}", check=False)
|
|
if _wait_for_gtt(needed, timeout=30):
|
|
return state
|
|
|
|
# Stage two: the units alone were not enough.
|
|
state["standalone"] = _standalone_servers()
|
|
for server in state["standalone"]:
|
|
run(f"kill {int(server['pid'])}", check=False)
|
|
if not _wait_for_gtt(needed):
|
|
for server in state["standalone"]:
|
|
run(f"kill -9 {int(server['pid'])}", check=False)
|
|
_wait_for_gtt(needed)
|
|
return state
|
|
|
|
|
|
def start_llms(state):
|
|
"""Put back exactly what :func:`stop_llms` took away.
|
|
|
|
Reports what it could not restore rather than raising: this runs in a `finally`,
|
|
and masking the training error with a restore error would lose the more useful of
|
|
the two.
|
|
"""
|
|
failures = []
|
|
for index, server in enumerate(state.get("standalone", [])):
|
|
argv = " ".join(shlex.quote(arg) for arg in server["argv"])
|
|
# systemd-run rather than nohup: a backgrounded process still holds the SSH
|
|
# channel open, so ssh blocks until it exits -- which for a server is never.
|
|
# A transient unit forks away cleanly and returns at once, and --collect means
|
|
# it leaves nothing behind when it stops.
|
|
started = run(
|
|
f"systemd-run --user --collect --unit=suvi-restored-llama-{index} "
|
|
f"--property=WorkingDirectory={shlex.quote(server['cwd'])} {argv}",
|
|
check=False, timeout=60,
|
|
)
|
|
if "Running as unit" not in started and "Failed" in started:
|
|
failures.append(f"standalone {os.path.basename(server['argv'][0])}")
|
|
|
|
for entry in state.get("units", []):
|
|
if not entry["was_active"]:
|
|
continue
|
|
run(f"systemctl --user start {entry['unit']}", check=False)
|
|
if run(f"systemctl --user is-active {entry['unit']}", check=False).strip() != "active":
|
|
failures.append(entry["unit"])
|
|
return failures
|
|
|
|
|
|
#: States stopped but not yet restored. Module level so the exit hooks can reach them.
|
|
_paused = []
|
|
_hooks_installed = False
|
|
|
|
|
|
def _restore_all(verbose=True):
|
|
"""Put back every outstanding pause. Safe to call more than once."""
|
|
while _paused:
|
|
state = _paused.pop()
|
|
# Our own containers first: restarting the LLMs while a training container still
|
|
# holds 60 GB of GTT just moves the memory exhaustion onto the user's services.
|
|
try:
|
|
stop_containers(verbose=verbose)
|
|
except (RuntimeError, OSError):
|
|
pass
|
|
failures = start_llms(state)
|
|
if failures:
|
|
print(f" WARNING: could not restart: {', '.join(failures)}")
|
|
print(" The GPU host is missing services it had before this run.")
|
|
elif verbose:
|
|
print(f" restored LLM services; GTT {gtt_used() / 1024**3:.1f} GiB used")
|
|
|
|
|
|
def _install_hooks():
|
|
"""Restore on the ways out that `finally` does not cover.
|
|
|
|
A `finally` block handles a return or an exception. It does not handle SIGTERM,
|
|
which is what a timeout, a `kill`, or a parent shell giving up actually sends -- the
|
|
default disposition terminates the interpreter without unwinding. That is not
|
|
hypothetical: it happened here, and left the user's LLM proxy stopped with nothing
|
|
scheduled to start it again. So the same belt-and-braces the archive traversals
|
|
use (see :class:`suvi.vfs.Reliever`): atexit for orderly exits, explicit handlers
|
|
for the signals.
|
|
"""
|
|
global _hooks_installed
|
|
if _hooks_installed:
|
|
return
|
|
import atexit
|
|
import signal
|
|
|
|
atexit.register(_restore_all, verbose=False)
|
|
for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
previous = signal.getsignal(signum)
|
|
|
|
def handler(number, frame, previous=previous):
|
|
_restore_all()
|
|
if callable(previous) and previous not in (signal.SIG_IGN, signal.SIG_DFL):
|
|
return previous(number, frame)
|
|
raise SystemExit(128 + number)
|
|
|
|
try:
|
|
signal.signal(signum, handler)
|
|
except (ValueError, OSError): # not the main thread, or no such signal
|
|
pass
|
|
_hooks_installed = True
|
|
|
|
|
|
@contextmanager
|
|
def llms_paused(needed_bytes=None, verbose=True):
|
|
"""Hold the GPU for the duration of the block, then give it back.
|
|
|
|
Restoration is registered before the block runs and happens on every exit path --
|
|
return, exception, or signal. What it cannot restore it names, because a
|
|
hand-launched server that quietly failed to come back would otherwise be discovered
|
|
by the user, later, as a service that is simply gone.
|
|
"""
|
|
_install_hooks()
|
|
state = stop_llms(needed_bytes)
|
|
_paused.append(state)
|
|
if verbose:
|
|
units = [e["unit"] for e in state["units"] if e["was_active"]]
|
|
print(f" paused: units {units or 'none'}"
|
|
+ (f", {len(state['standalone'])} standalone server(s)"
|
|
if state["standalone"] else " (no standalone servers touched)"))
|
|
print(f" GTT now {gtt_used() / 1024**3:.1f} GiB used")
|
|
try:
|
|
yield state
|
|
finally:
|
|
if state in _paused:
|
|
_paused.remove(state)
|
|
stop_containers(verbose=verbose)
|
|
failures = start_llms(state)
|
|
if failures:
|
|
print(f" WARNING: could not restart: {', '.join(failures)}")
|
|
print(" The GPU host is missing services it had before this run.")
|
|
elif verbose:
|
|
print(f" restored LLM services; GTT {gtt_used() / 1024**3:.1f} GiB used")
|
|
|
|
|
|
# ------------------------------------------------------------------ the container
|
|
|
|
|
|
#: ROCm+PyTorch plus this project's dependencies. Bazzite's root is read-only and its
|
|
#: Python is 3.14, which has no torch wheels, so the toolchain is containerised
|
|
#: regardless; the derived tag adds zstandard, opencv, scikit-image and astropy.
|
|
IMAGE = os.environ.get("SUVI_GPU_IMAGE", "localhost/suvi-train:latest")
|
|
#: Prefix for containers this module starts, so they can be found and stopped again.
|
|
CONTAINER_PREFIX = "suvi-run-"
|
|
|
|
|
|
def stop_containers(verbose=True):
|
|
"""Stop every container this module started.
|
|
|
|
Necessary because a container does *not* die with the SSH session that launched it.
|
|
A run that times out client-side leaves the container running, holding tens of GB of
|
|
unified memory; two such orphans were what put GTT at 89.6 GB with the GPU idle.
|
|
"""
|
|
names = [
|
|
line.strip()
|
|
for line in run(
|
|
f"podman ps --filter name={CONTAINER_PREFIX} --format '{{{{.Names}}}}'",
|
|
check=False,
|
|
).splitlines()
|
|
if line.strip().startswith(CONTAINER_PREFIX)
|
|
]
|
|
for name in names:
|
|
if verbose:
|
|
print(f" stopping orphaned container {name}")
|
|
run(f"podman stop -t 10 {shlex.quote(name)}", check=False, timeout=60)
|
|
return names
|
|
|
|
|
|
def launch(command, mounts=(), name=None, log="/var/home/htpc/suvi/run.log"):
|
|
"""Start a container detached on the host and return (name, log path).
|
|
|
|
Long jobs must not be tied to an SSH channel. Streaming a training run through
|
|
``ssh`` means the run dies with the connection, or -- worse, and observed here --
|
|
the container finishes while the local client stays blocked reading a pipe that
|
|
will never close. A transient systemd unit is owned by the host, survives the
|
|
client entirely, and can be polled with :func:`tail`.
|
|
"""
|
|
name = name or f"{CONTAINER_PREFIX}{int(time.time())}"
|
|
binds = " ".join(f"-v {shlex.quote(src)}:{shlex.quote(dst)}" for src, dst in mounts)
|
|
inner = (
|
|
f"podman run --rm --name {shlex.quote(name)} "
|
|
f"--device /dev/kfd --device /dev/dri "
|
|
f"--security-opt seccomp=unconfined --ipc=host {binds} {IMAGE} {command}"
|
|
)
|
|
run(f"rm -f {shlex.quote(log)}", check=False)
|
|
run(
|
|
f"systemd-run --user --collect --unit={shlex.quote(name)} "
|
|
f"--property=StandardOutput=append:{shlex.quote(log)} "
|
|
f"--property=StandardError=append:{shlex.quote(log)} "
|
|
f"/bin/sh -c {shlex.quote(inner)}",
|
|
timeout=60,
|
|
)
|
|
return name, log
|
|
|
|
|
|
def tail(log, lines=40):
|
|
return run(f"tail -n {int(lines)} {shlex.quote(log)} 2>/dev/null", check=False)
|
|
|
|
|
|
def running(name):
|
|
return run(f"podman ps --filter name={shlex.quote(name)} --format '{{{{.Names}}}}'",
|
|
check=False).strip() != ""
|
|
|
|
|
|
def podman(command, mounts=(), timeout=None, capture=True, name=None):
|
|
"""Run `command` inside the training container on the GPU host.
|
|
|
|
``/dev/kfd`` and ``/dev/dri`` are both world-accessible on this host, so no group
|
|
mapping is needed; ``seccomp=unconfined`` is what ROCm needs to issue its ioctls.
|
|
|
|
The container is named and stopped in a `finally`, so a client-side timeout cannot
|
|
leave it running -- ``--rm`` only covers containers that actually exit.
|
|
"""
|
|
name = name or f"{CONTAINER_PREFIX}{os.getpid()}"
|
|
binds = " ".join(f"-v {shlex.quote(src)}:{shlex.quote(dst)}" for src, dst in mounts)
|
|
try:
|
|
return run(
|
|
f"podman run --rm --name {shlex.quote(name)} "
|
|
f"--device /dev/kfd --device /dev/dri "
|
|
f"--security-opt seccomp=unconfined --ipc=host {binds} {IMAGE} {command}",
|
|
timeout=timeout, capture=capture,
|
|
)
|
|
finally:
|
|
run(f"podman stop -t 10 {shlex.quote(name)}", check=False, timeout=60)
|