项目文件夹

文件
wehub-resource-sync a8262fc01e
docs / build (push) Has been cancelled
docs / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:22 +08:00

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