noaa-goes-visualization/gpu_train.py

367 lines
17 KiB
Python
Raw Normal View History

#!/usr/bin/env python
"""Start a training run on the GPU host and hand the GPU back when it ends.
Detached by design: a training run is a day long, and tying it to an SSH channel means
it dies with the connection -- or worse, finishes while the client stays blocked on a
pipe that never closes. Both happened while this was being built. The run becomes a
transient systemd unit on the host; this process only starts it, waits, and restores the
LLM services afterwards.
gpu_train.py --epochs 48 --steps 1500 # start and watch
gpu_train.py --status # what is it doing now
gpu_train.py --stop # stop it and restore the LLMs
2026-10-14 00:27:18 -04:00
gpu_train.py --pool --steps 8923 ... # train from a rotating RAM pool
With ``--pool`` a second container runs ``pool_producer.py`` beside training, reading
the archive share read-only and keeping fresh slices in host RAM (:mod:`suvi.pool`);
``--stop`` stops both and frees the pool.
"""
import argparse
2026-10-14 00:27:18 -04:00
import json
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from suvi import gpubox
REMOTE = "/var/home/htpc/suvi"
LOG = f"{REMOTE}/train.log"
2026-10-14 00:27:18 -04:00
#: Free space below which a run is at risk of failing to write a checkpoint.
DISK_FLOOR_GB = 5
#: The archive share as the host mounts it (read-only), and the RAM pool directory.
#: Both containers use the host's IPC namespace, so they share its /dev/shm.
ARCHIVE = "/var/home/htpc/NOAA_GOES"
POOL_DIR = "/dev/shm/suvi_pool"
POOL_LOG = f"{REMOTE}/pool.log"
def command(args):
return (
"bash -c 'cd /work && python -u train.py "
2026-10-14 00:27:18 -04:00
f"--shards /work/{args.shards_dir} "
f"--manifest /work/{args.shards_dir}/manifest.json "
f"--out /work/runs/{args.name} "
f"--epochs {args.epochs} --batch {args.batch} --lr {args.lr} "
f"--base {args.base} --depth {args.depth} --workers {args.workers} "
f"--val-samples {args.val_samples} --eval-every {args.eval_every}"
+ (f" --steps-per-epoch {args.steps}" if args.steps else "")
2026-10-14 00:27:18 -04:00
+ (f" --lr-range {args.lr_range}" if args.lr_range else "")
+ (f" --span {args.span}" if args.span else "")
+ (f" --warmup {args.warmup}" if args.warmup is not None else "")
+ (" --relative" if args.relative else "")
+ (" --corr-flow" if args.corr_flow else "")
+ (" --attention" if args.attention else "")
+ (f" --max-steps {args.max_steps}" if args.max_steps else "")
+ (f" --overfit {args.overfit}" if args.overfit else "")
+ (f" --resume /work/runs/{args.name}/last.pt" if args.resume else "")
2026-10-14 00:27:18 -04:00
+ (f" --pool {POOL_DIR}" if args.pool else "")
+ (f" --val-slices /work/{args.val_dir}" if args.val_dir else "")
+ (f" --evaluate /work/{args.evaluate}" if args.evaluate else "")
+ "'"
)
2026-10-14 00:27:18 -04:00
def producer_command(args):
return (f"python -u /work/pool_producer.py --manifest /work/{args.pool_manifest} "
f"--archive /archive/Data --pool {POOL_DIR} "
f"--budget-gb {args.pool_budget_gb} --workers {args.pool_workers}")
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
2026-10-14 00:27:18 -04:00
parser.add_argument("--name", default="v6")
parser.add_argument("--shards-dir", default="shards",
help="dataset directory under the host's suvi root")
parser.add_argument("--epochs", type=int, default=20)
parser.add_argument("--steps", type=int, default=None,
help="steps per epoch (default: the whole train split)")
parser.add_argument("--val-samples", type=int, default=64)
parser.add_argument("--eval-every", type=int, default=250)
2026-10-14 00:27:18 -04:00
parser.add_argument("--lr-range", default=None,
help="'LO,HI': run the LR range test (needs --max-steps)")
parser.add_argument("--span", type=int, default=None,
help="model input span in slots (rung ladder prefix)")
parser.add_argument("--warmup", type=int, default=None)
parser.add_argument("--relative", action="store_true")
parser.add_argument("--corr-flow", action="store_true")
parser.add_argument("--attention", action="store_true")
parser.add_argument("--max-steps", type=int, default=None,
help="stop after this many steps (the 300-step probe gate)")
parser.add_argument("--overfit", type=int, default=None,
help="run the overfit gate on this many frozen samples")
parser.add_argument("--batch", type=int, default=8)
parser.add_argument("--lr", type=float, default=1e-4)
parser.add_argument("--base", type=int, default=32)
parser.add_argument("--depth", type=int, default=3)
parser.add_argument("--workers", type=int, default=6)
parser.add_argument("--resume", action="store_true")
2026-10-14 00:27:18 -04:00
parser.add_argument("--pool", action="store_true",
help="train from a rotating RAM pool generated from the archive "
"(needs --steps); validation stays on --shards-dir")
parser.add_argument("--pool-manifest", default="pool_manifest.json.zst",
help="under the host's suvi root, from pool_manifest.py")
parser.add_argument("--pool-budget-gb", type=float, default=48.0)
parser.add_argument("--pool-workers", type=int, default=16)
parser.add_argument("--val-dir", default=None,
help="validate on the fixed slices in this directory under the "
"host's suvi root (pool_producer.py --fixed)")
parser.add_argument("--evaluate", default=None,
help="score this checkpoint (under the suvi root) and exit")
parser.add_argument("--status", action="store_true")
2026-10-14 00:27:18 -04:00
parser.add_argument("--watchdog", action="store_true",
help="one-line answer to 'is the box still working'")
parser.add_argument("--since", type=int, default=None,
help="print history rows with step > SINCE, plus health")
parser.add_argument("--stop", action="store_true")
parser.add_argument("--watch", type=int, default=0,
help="seconds to wait for completion (0 = start and return)")
args = parser.parse_args(argv)
2026-10-14 00:27:18 -04:00
if args.watchdog:
return _watchdog(args)
if args.status:
print(gpubox.tail(LOG, 30))
print("containers:", gpubox.run("podman ps --format '{{.Names}}'", check=False).strip())
print(f"GTT {gpubox.gtt_used() / 1024**3:.1f} GiB used")
return 0
if args.since is not None:
return _report_since(args)
if args.stop:
stopped = gpubox.stop_containers()
2026-10-14 00:27:18 -04:00
gpubox.run(f"rm -rf {POOL_DIR}", check=False) # the pool is host RAM
print(f"stopped {stopped or 'nothing'}")
gpubox.run("systemctl --user start llama-swap.service", check=False)
return 0
2026-10-14 00:27:18 -04:00
if args.pool and not args.steps:
raise SystemExit("--pool needs --steps (steps per epoch): a pool has no length")
state = gpubox.stop_llms()
try:
2026-10-14 00:27:18 -04:00
if args.pool:
producer, _ = gpubox.launch(
producer_command(args), log=POOL_LOG,
name=f"{gpubox.CONTAINER_PREFIX}pool-{int(time.time())}",
mounts=[(REMOTE, "/work"), (ARCHIVE, "/archive:ro")])
print(f"launched producer {producer}; log at {POOL_LOG}", flush=True)
name, log = gpubox.launch(command(args), mounts=[(REMOTE, "/work")], log=LOG)
print(f"launched {name}; log at {log}", flush=True)
if not args.watch:
print("Running detached. gpu_train.py --status to check, --stop to end.")
print("NOTE: the LLM services stay stopped until --stop or the run finishes.")
_paused_by_us(state)
return 0
deadline = time.time() + args.watch
while time.time() < deadline and gpubox.running(name):
time.sleep(30)
print(gpubox.tail(log, 40))
finally:
if args.watch:
gpubox.stop_containers()
print("restore failures:", gpubox.start_llms(state) or "none")
return 0
2026-10-14 00:27:18 -04:00
#: A run that has not written an eval in this long is not merely slow.
STALL_MINUTES = 30
#: Host memory below which the box is thrashing rather than training. A run
#: that exhausts RAM keeps its process runnable at high CPU while it pages, so
#: it reads as healthy from every angle except the one that matters: it stops
#: producing output. Naming the cause turns a baffling stall into an obvious one.
LOW_MEMORY_GB = 4.0
def progress(row):
"""Epochs and steps, separately: epochs say how far training has really got
(passes through the corpus); steps are the fine-grained, short-term measure.
Rows from train.py carry steps_per_epoch and total_steps; older runs only the
epoch index, which is reported as the epoch in progress.
"""
step, per_epoch, total = row.get("step"), row.get("steps_per_epoch"), row.get("total_steps")
if per_epoch:
text = f"epoch {step / per_epoch:.2f}"
if total:
text += f" of {total / per_epoch:.2f}"
text += f", step {step}" + (f" of {total}" if total else "")
return text
if row.get("epoch") is not None:
return f"epoch {row['epoch'] + 1} (in progress), step {step}"
return f"step {step}"
def watchdog_verdict(containers, runs, now, stall_minutes=STALL_MINUTES,
busy_age=None, free_gb=None):
"""Decide what the box is doing, from one snapshot. Pure, so it can be tested.
`runs` maps run name to {"step", "psnr", "age"} -- plus, where the history
has them, "epoch", "steps_per_epoch" and "total_steps" -- where age is seconds since
its history last grew; `busy_age` is how long the training container has been
up. Returns (state, lines) with state one of ``training`` / ``stalled`` /
``idle`` / ``unknown``.
The distinction that matters is **stalled versus finished**: both look like
"no new rows", and telling them apart is the whole job of a watchdog. A
container still up with a frozen history is a wedged run burning hours; no
container at all is simply an idle box.
Staleness alone is not enough to call a stall, because a *freshly launched*
run has written nothing yet, so the newest history on the box still belongs
to the previous run and looks arbitrarily old. Pulling the image and
importing torch takes minutes, and the first eval is 250 steps after that.
So a stall needs both clocks: the history frozen *and* the container up
long enough that it should have produced a row.
"""
if containers is None:
return "unknown", ["host unreachable"]
busy = [name for name in containers if name.startswith("suvi-run")]
if not runs:
return ("training" if busy else "idle"), ["no run history yet"]
name = min(runs, key=lambda key: runs[key]["age"])
row = runs[name]
age = row["age"] / 60.0
where = f"{name} at {progress(row)}"
if row.get("psnr") is not None:
where += f", val {row['psnr']:.2f} dB"
if not busy:
return "idle", [f"GPU idle; last was {where} ({age:.0f} min ago)"]
starved = free_gb is not None and free_gb < LOW_MEMORY_GB
up = (busy_age / 60.0) if busy_age is not None else age
if age > stall_minutes and up > stall_minutes:
lines = [f"STALLED: {where}, no eval for {age:.0f} min "
f"(container up {up:.0f} min)"]
if starved:
lines.append(f"host has {free_gb:.1f} GB free -- it is swap-thrashing, "
f"not computing; stop it and lower the memory ceiling")
return "stalled", lines
if starved:
return "training", [f"{where} ({age:.0f} min ago)",
f"WARNING: only {free_gb:.1f} GB host memory free"]
if age > up:
return "training", [f"{busy[0]} starting up ({up:.0f} min); "
f"last finished run was {where}"]
return "training", [f"{where} ({age:.0f} min ago)"]
def _watchdog(args):
"""One round trip: what is running, how far along, and is it still moving."""
marker = "@@@"
command = (
"podman ps --format '{{.Names}}'; echo " + marker + "; "
"awk '/MemAvailable/{print \"MEM\", $2}' /proc/meminfo; "
f"for d in {REMOTE}/runs/*/; do n=$(basename $d); "
f"h=$d/history.jsonl; [ -f $h ] || continue; "
f"echo \"$n $(stat -c %Y $h) $(tail -1 $h)\"; done; true"
)
out = gpubox.run(command, check=False, timeout=60)
if marker not in out:
state, lines = watchdog_verdict(None, {}, time.time())
else:
head, _, tail = out.partition(marker)
containers = [line.strip() for line in head.splitlines() if line.strip()]
runs = {}
free_gb = None
now = time.time()
for line in tail.splitlines():
name, _, rest = line.strip().partition(" ")
if name == "MEM" and rest.strip().isdigit():
free_gb = int(rest.strip()) / 1024**2
continue
stamp, _, payload = rest.partition(" ")
if not payload.strip() or not stamp.isdigit():
continue
try:
row = json.loads(payload)
except ValueError:
continue
runs[name] = {"step": row.get("step", -1), "psnr": row.get("val_psnr"),
"age": max(now - int(stamp), 0), "epoch": row.get("epoch"),
"steps_per_epoch": row.get("steps_per_epoch"),
"total_steps": row.get("total_steps")}
# The launch timestamp is in the container's own name, which is the only
# clock that says how long *this* run has had to produce a row.
busy_age = None
for name in containers:
suffix = name.rsplit("-", 1)[-1]
if name.startswith("suvi-run") and suffix.isdigit():
busy_age = max(now - int(suffix), 0)
state, lines = watchdog_verdict(containers, runs, now, busy_age=busy_age,
free_gb=free_gb)
print(f"state: {state}")
for line in lines:
print(f" {line}")
return 0 if state in ("training", "idle") else 1
def _report_since(args):
"""New history rows past a step, plus the health a loss curve does not show.
A run can go non-finite, stall with the process still alive, or lose its
container entirely; each of those wastes hours if nobody looks. Returns 1 when
something is wrong, so a supervising loop can react without parsing prose.
"""
import json
raw = gpubox.run(f"cat {REMOTE}/runs/{args.name}/history.jsonl 2>/dev/null",
check=False)
rows = [json.loads(line) for line in raw.splitlines() if line.strip()]
fresh = [row for row in rows if row.get("step", 0) > args.since]
for row in fresh:
if row.get("untrained"):
print(f"step {row['step']:>6} val {row['val_loss']:.5f} "
f"({row['val_psnr']:.2f} dB) [untrained]")
else:
print(f"step {row['step']:>6} train {row['train_loss']:.5f} "
f"val {row['val_loss']:.5f} ({row['val_psnr']:.2f} dB) "
f"grad {row.get('grad_mean', 0):.3f}/{row.get('grad_max', 0):.2f}")
problems = []
for row in fresh:
for key in ("train_loss", "val_loss", "val_psnr"):
if key in row and not (row[key] == row[key] and abs(row[key]) < 1e30):
problems.append(f"step {row['step']}: {key} is not finite")
containers = gpubox.run("podman ps --format '{{.Names}}'", check=False).strip()
if "suvi" not in containers:
problems.append("no suvi container is running")
2026-10-14 00:27:18 -04:00
# Free bytes, not percent full. The packed corpus deliberately fills the host
# to within ~40 GB, so "96% used" is the intended state and a percentage
# threshold cries wolf on every poll until nobody reads the health line at all.
# What actually matters is whether the run can still write what it must:
# checkpoints (~30 MB each) and its log.
disk = gpubox.run(f"df --output=avail -BG {REMOTE} | tail -1", check=False)
free = disk.strip().rstrip("G")
if free.isdigit() and int(free) < DISK_FLOOR_GB:
problems.append(f"host disk has only {free} GB free")
for problem in problems:
print(f"PROBLEM: {problem}")
return 1 if problems else 0
def _paused_by_us(state):
"""Record what was stopped, so a later --stop can put it back.
Written to disk rather than held in memory because the run outlives this process by
a day, and a restore that only works while the launcher is alive is not a restore.
"""
import json
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".gpu_paused.json")
with open(path, "w") as handle:
json.dump(state, handle)
print(f" paused state recorded in {path}")
if __name__ == "__main__":
sys.exit(main())