Files
esh-pfi-infrastructure/services/lora-training-worker/worker/gpu.py
T
vh 888ba6a714 feat(lora-worker): stand up in-arbo LoRA training worker on irv-ml1 (arbo Phase 1 §4.1)
Host service (runs as llmuser, owns /opt/fluxgym + GPU access) that runs
sd-scripts SDXL LoRA training on demand for arbo — the infra-ops half of the
in-arbo LoRA training Phase 1 ownership split (vh/arbo
docs/contracts/in-arbo-lora-training-phase1.contract.md §4.1/§2).

- Fixed-invocation only (INV-T7): bounded params -> one sd-scripts command
  shape; every param range/allowlist/path-containment checked before spawn;
  bad request = 422, never a silent downgrade. 14 unit tests green.
- Thin supervisor: never imports torch; subprocesses the fluxgym venv's
  accelerate. 1-job-at-a-time (arbo lease is the serializer, 409 is backstop).
  Durable job records + boot reconciliation (§4.3).
- API: POST /train, GET /train/{id}[/log], POST /train/{id}/cancel,
  GET /gpu-status (per-device VRAM + tts_on_3090 co-OOM signal), GET /healthz.
- Wire-shape (§7 resolved with comfy-dev): shared /worktank/arbo/train handoff
  (group arbotrain, setgid 2770); worker binds 0.0.0.0:8203, arbo reaches via
  host.docker.internal:host-gateway (reachability proven on 172.20.0.1:8203);
  device-aware TTS steering via /gpu-status.

Deployed to irv-ml1 via playbooks/deploy-lora-training-worker.yaml (elway,
idempotent); systemd unit active; /healthz + /gpu-status verified live.
2026-07-06 18:28:04 -07:00

123 lines
4.9 KiB
Python

"""GPU status for arbo's device-aware scheduler (GET /gpu-status).
arbo steers a lean train OFF the 3090 when TTS is live there (the 3090/TTS co-OOM concern,
contract §7). The worker can't decide that — it just reports the raw per-device VRAM plus a
`tts_on_3090` boolean derived from nvidia-smi compute-apps. Everything is nvidia-smi CSV
parsing; no torch import.
Device indices are reported under CUDA_DEVICE_ORDER=PCI_BUS_ID (index 0 = RTX 3090,
index 1 = RTX A6000) — the same ordering the training recipe pins, so `device_index` in a
/train request and the indices here mean the same physical card.
"""
from __future__ import annotations
import subprocess
from . import config
_SMI_ENV = {"CUDA_DEVICE_ORDER": "PCI_BUS_ID"}
def _run(args: list[str]) -> str:
return subprocess.run(
["nvidia-smi", *args], capture_output=True, text=True, timeout=15, env={**_SMI_ENV}
).stdout
def _query_devices() -> list[dict]:
# index,name,memory.total,memory.used,memory.free (MiB) under PCI_BUS_ID ordering
out = _run(["--query-gpu=index,name,memory.total,memory.used,memory.free",
"--format=csv,noheader,nounits"])
devices = []
for line in out.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) != 5:
continue
idx, name, total, used, free = parts
devices.append({
"index": int(idx), "name": name,
"total_mb": int(total), "used_mb": int(used), "free_mb": int(free),
"procs": [],
})
return devices
def _query_compute_apps() -> list[dict]:
# pid,used_memory,gpu_bus_id — map each compute app to a device by bus id
out = _run(["--query-compute-apps=pid,used_memory,gpu_bus_id", "--format=csv,noheader,nounits"])
apps = []
for line in out.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) != 3:
continue
pid, mem, bus = parts
if not pid.isdigit():
continue
apps.append({"pid": int(pid), "used_mb": int(mem) if mem.isdigit() else 0, "bus_id": bus})
return apps
def _bus_id_by_index() -> dict[int, str]:
out = _run(["--query-gpu=index,gpu_bus_id", "--format=csv,noheader"])
mapping = {}
for line in out.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) == 2 and parts[0].isdigit():
mapping[int(parts[0])] = parts[1]
return mapping
def _cmdline(pid: int) -> str:
try:
with open(f"/proc/{pid}/cmdline", "rb") as fh:
return fh.read().replace(b"\x00", b" ").decode("utf-8", "replace").lower()
except (OSError, ValueError):
return ""
def gpu_status() -> dict:
"""Return `{devices: [...], tts_on_3090: bool}`. Degrades to an error field on nvidia-smi failure."""
try:
devices = _query_devices()
apps = _query_compute_apps()
bus_by_idx = _bus_id_by_index()
except (subprocess.SubprocessError, OSError, ValueError) as exc:
return {"devices": [], "tts_on_3090": False, "error": f"nvidia-smi failed: {exc}"}
idx_by_bus = {bus: idx for idx, bus in bus_by_idx.items()}
dev_by_idx = {d["index"]: d for d in devices}
# Attach compute apps (with a marker flag) to their device.
for app in apps:
idx = idx_by_bus.get(app["bus_id"])
cmd = _cmdline(app["pid"])
app["is_tts"] = any(m in cmd for m in config.TTS_CMDLINE_MARKERS)
if idx is not None and idx in dev_by_idx:
dev_by_idx[idx]["procs"].append(
{"pid": app["pid"], "used_mb": app["used_mb"], "is_tts": app["is_tts"]}
)
# tts_on_3090 — the co-OOM-risk signal for arbo's device-aware steering. Honest derivation
# (transparent via `reason`), because the worker can't reliably fingerprint TTS: the TTS
# containers run generic cmdlines (`uvicorn app:app`, `python app.py`) so only the container
# NAME identifies them, and the worker's llmuser isn't in the docker group. So:
# (a) proc_marker — a compute app on device 0 whose cmdline matches a TTS marker (best effort), else
# (b) used_mb_floor — device 0 used >= floor (a busy 3090 = lean-train co-OOM risk, TTS/STT/audio alike).
# The PRECISE lever arbo should key on is `devices[0].free_mb`; `tts_on_3090` is the convenience boolean.
dev0 = dev_by_idx.get(config.DEVICE_3090)
tts_on_3090, reason = False, "idle"
free_3090 = None
if dev0 is not None:
free_3090 = dev0["free_mb"]
if any(p["is_tts"] for p in dev0["procs"]):
tts_on_3090, reason = True, "proc_marker"
elif dev0["used_mb"] >= config.TTS_3090_USED_MB_FLOOR:
tts_on_3090, reason = True, "used_mb_floor"
return {
"devices": devices,
"tts_on_3090": tts_on_3090,
"tts_on_3090_reason": reason,
"gpu3090_free_mb": free_3090, # the precise co-OOM lever; steer lean off the 3090 when low
}