fareedkhan-dev--train-llm-from-scratch
162 行
4.8 KiB
Python
162 行
4.8 KiB
Python
"""
|
|
Background job manager for the control panel.
|
|
|
|
Launches the real training / data-prep scripts as detached subprocesses, captures their
|
|
output to a logfile, and records a tiny JSON registry under /ephemeral/ui_jobs/ so jobs
|
|
survive Streamlit reruns and page navigation. Includes a GPU-busy guard so we never start a
|
|
second multi-GPU job on top of a running one (which would OOM the H100s).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
from ui.stages import REPO_ROOT
|
|
|
|
JOB_DIR = "/ephemeral/ui_jobs"
|
|
_TORCHRUN = os.path.join(os.path.dirname(sys.executable), "torchrun")
|
|
|
|
|
|
def _ensure_job_dir() -> None:
|
|
os.makedirs(JOB_DIR, exist_ok=True)
|
|
|
|
|
|
def _reg(job_id: str) -> str:
|
|
return os.path.join(JOB_DIR, f"{job_id}.json")
|
|
|
|
|
|
def _log(job_id: str) -> str:
|
|
return os.path.join(JOB_DIR, f"{job_id}.log")
|
|
|
|
|
|
def _write_registry(job_id: str, data: dict) -> None:
|
|
with open(_reg(job_id), "w") as f:
|
|
json.dump(data, f)
|
|
|
|
|
|
def read_registry(job_id: str) -> dict | None:
|
|
p = _reg(job_id)
|
|
if not os.path.exists(p):
|
|
return None
|
|
try:
|
|
with open(p) as f:
|
|
return json.load(f)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
def _alive(pid: int) -> bool:
|
|
try:
|
|
os.kill(pid, 0)
|
|
except OSError:
|
|
return False
|
|
# A finished detached child lingers as a zombie (parent = this process) and still
|
|
# passes kill(pid, 0). Treat zombies as dead and best-effort reap them.
|
|
try:
|
|
with open(f"/proc/{pid}/stat") as f:
|
|
state = f.read().rsplit(") ", 1)[1].split(" ", 1)[0]
|
|
if state == "Z":
|
|
try:
|
|
os.waitpid(pid, os.WNOHANG)
|
|
except OSError:
|
|
pass
|
|
return False
|
|
except (FileNotFoundError, IndexError):
|
|
return False
|
|
return True
|
|
|
|
|
|
def build_argv(script: str, config_json: str, nproc: int, multi_gpu: bool, extra: list[str] | None = None) -> list[str]:
|
|
"""Construct the exact command (torchrun for multi-GPU, else python)."""
|
|
base = [script, "--config", config_json] + (extra or [])
|
|
if multi_gpu and nproc > 1:
|
|
return [_TORCHRUN, "--standalone", f"--nproc_per_node={nproc}", *base]
|
|
return [sys.executable, *base]
|
|
|
|
|
|
def launch(job_id: str, argv: list[str], *, kind: str = "cpu") -> dict:
|
|
"""Start ``argv`` as a detached background job. ``kind`` is 'gpu' or 'cpu' (for the guard)."""
|
|
_ensure_job_dir()
|
|
log_path = _log(job_id)
|
|
env = {**os.environ, "PYTHONPATH": REPO_ROOT}
|
|
env.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
|
env.setdefault("HF_HOME", "/ephemeral/hf_cache")
|
|
logf = open(log_path, "wb")
|
|
proc = subprocess.Popen(
|
|
argv, cwd=REPO_ROOT, env=env, stdout=logf, stderr=subprocess.STDOUT,
|
|
start_new_session=True, # own process group -> killpg reaches all torchrun ranks
|
|
)
|
|
rec = dict(job_id=job_id, pid=proc.pid, cmd=argv, log=log_path, kind=kind,
|
|
started=time.time(), status="running")
|
|
_write_registry(job_id, rec)
|
|
return rec
|
|
|
|
|
|
def status(job_id: str) -> str:
|
|
"""Return 'running' | 'finished' | 'failed' | 'none'."""
|
|
rec = read_registry(job_id)
|
|
if not rec:
|
|
return "none"
|
|
if _alive(rec["pid"]):
|
|
return "running"
|
|
tail = tail_log(job_id, 4000).lower()
|
|
if "traceback" in tail or "error:" in tail or "aborted" in tail:
|
|
return "failed"
|
|
return "finished"
|
|
|
|
|
|
def stop(job_id: str) -> bool:
|
|
"""Terminate a running job and all its workers (process group)."""
|
|
rec = read_registry(job_id)
|
|
if not rec:
|
|
return False
|
|
try:
|
|
os.killpg(os.getpgid(rec["pid"]), signal.SIGTERM)
|
|
except OSError:
|
|
return False
|
|
rec["status"] = "stopped"
|
|
_write_registry(job_id, rec)
|
|
return True
|
|
|
|
|
|
def tail_log(job_id: str, max_bytes: int = 16000) -> str:
|
|
p = _log(job_id)
|
|
if not os.path.exists(p):
|
|
return ""
|
|
with open(p, "rb") as f:
|
|
f.seek(0, os.SEEK_END)
|
|
size = f.tell()
|
|
f.seek(max(0, size - max_bytes))
|
|
return f.read().decode("utf-8", errors="replace")
|
|
|
|
|
|
def gpu_busy() -> str | None:
|
|
"""Return the job_id of a *running* GPU job, if any (for the launch guard)."""
|
|
if not os.path.isdir(JOB_DIR):
|
|
return None
|
|
for fn in os.listdir(JOB_DIR):
|
|
if not fn.endswith(".json"):
|
|
continue
|
|
rec = read_registry(fn[:-5])
|
|
if rec and rec.get("kind") == "gpu" and _alive(rec["pid"]):
|
|
return rec["job_id"]
|
|
return None
|
|
|
|
|
|
def active_jobs() -> list[dict]:
|
|
out = []
|
|
if not os.path.isdir(JOB_DIR):
|
|
return out
|
|
for fn in sorted(os.listdir(JOB_DIR)):
|
|
if fn.endswith(".json"):
|
|
rec = read_registry(fn[:-5])
|
|
if rec:
|
|
rec["live"] = _alive(rec["pid"])
|
|
out.append(rec)
|
|
return out
|