File size: 9,404 Bytes
63f41a6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | """Generation and verification as ONE heavy job, overlapped.
THE IDLE-GPU DEFECT. The P2 queue ran `lean_gen.py` (GPU) and then `verify_generations.py`
(CPU, Lean) back to back. Verification of a 1,952-sample miniF2F run took 1.43 h and the pool
round's would take ~12 h; the four A10Gs sat at 0% for all of it. Running the verifier as a
second job beside the generator is not an option either: /apps/CLAUDE.md rule 2 forbids two heavy
jobs at once, and the repo's flock enforces it.
So this supervisor takes the heavy-job lock ONCE and runs both phases as children that inherit it
(`--inherit-lock`, the mechanism scripts/p2/launch_baseline.py already uses): the generator owns
the GPUs, and every `--every` seconds a verification pass checks the COMPLETE lines written so far.
A pass works on a snapshot that stops at the last newline, because the generator appends while we
read and the strict JSONL reader (p2_common.iter_jsonl) rightly refuses a half-written line.
Verification is keyed and resumable, so each pass only verifies what is new, and the final pass
after the generator exits covers the tail. One lock, one job, both resources busy.
Exit codes: 0 both phases complete; the generator's or verifier's non-zero code otherwise
(143 when stopped by a signal); 4 when the generator exited 0 but its manifest does not say
`complete` (a partial sample set must never read as a finished stage -- U12-2); 5 when GPU memory
did not drain after the generator (an orphan holds it -- U12-3). Re-running the same command
resumes both phases.
After EVERY generator exit (clean, failed or stopped) the generator's process group is reaped by its
exact pgid, orphan `psm_*` /dev/shm segments of this phase are swept, and GPU memory is waited on
until drained, using scripts/p2/launch_baseline.py's helpers (vLLM's EngineCore is documented to
outlive its parent: 19 GB of GPU memory, 0.19-1.4 GB of /dev/shm). The next GPU stage (sft.py,
export, eval generation) therefore starts on a clean host even though this supervisor releases the
lock on exit.
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))
PYR = str(REPO / ".venv-rollout" / "bin" / "python")
PY = str(REPO / ".venv" / "bin" / "python")
_LB = None
def launch_baseline():
"""scripts/p2/launch_baseline.py, loaded by path (the repo idiom): its kill_group / sweep_orphan_shm /
wait_gpu_drained are the reviewed implementation; a second copy would drift."""
global _LB
if _LB is None:
import importlib.util
spec = importlib.util.spec_from_file_location("_p2_launch_baseline", REPO / "scripts/p2/launch_baseline.py")
_LB = importlib.util.module_from_spec(spec)
spec.loader.exec_module(_LB)
return _LB
def generation_complete(gen_out: str) -> tuple[bool, str]:
"""Did the LAST lean_gen session on `gen_out` finish every planned sample? (its manifest says so)"""
mp = gen_out + ".manifest.json"
if not os.path.exists(mp):
return False, "no manifest"
with open(mp, encoding="utf-8") as f:
sessions = json.load(f).get("sessions", [])
if not sessions:
return False, "manifest has no sessions"
st = sessions[-1].get("status")
return st == "complete", f"last lean_gen session status {st!r}"
def reap_after_gen(proc, started_at: float, events, lb=None, log=None) -> None:
"""Kill survivors of the generator's process group, sweep its /dev/shm litter, wait for the GPUs to drain."""
lb = lb or launch_baseline()
log = log or (lambda **kw: log_event(events, **kw))
lb.Runner.kill_group(proc, 15.0)
gone, nbytes = lb.sweep_orphan_shm(started_at)
if gone:
log(event="shm_swept", segments=len(gone), bytes=nbytes)
lb.wait_gpu_drained()
log(event="gpu_drained")
def snapshot_complete_lines(src: Path, dst: Path) -> int:
"""Copy `src` up to and including its last newline into `dst` (atomically). Returns the
number of complete lines. A trailing partial line -- the generator mid-append -- is left out."""
if not src.exists():
return 0
data = src.read_bytes()
cut = data.rfind(b"\n") + 1
tmp = dst.with_suffix(dst.suffix + ".tmp")
tmp.write_bytes(data[:cut])
os.replace(tmp, dst)
return data[:cut].count(b"\n")
def log_event(path: Path, **kw) -> None:
kw["t"] = time.strftime("%Y-%m-%d %H:%M:%S")
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(kw) + "\n")
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--run-dir", required=True)
ap.add_argument("--gen-args", required=True, help="arguments for lean_gen.py, as ONE quoted string (must include --out)")
ap.add_argument("--problems", required=True)
ap.add_argument("--verify-out", required=True)
ap.add_argument("--workers", type=int, default=8)
ap.add_argument("--every", type=float, default=300.0, help="seconds between verification passes")
ap.add_argument("--feedback", action="store_true")
ap.add_argument("--label", default="p2-gen-verify")
a = ap.parse_args(argv)
from align.utils import guard
run = Path(a.run_dir)
run.mkdir(parents=True, exist_ok=True)
events = run / "pipeline.jsonl"
gen_args = a.gen_args.split()
gen_out = Path(gen_args[gen_args.index("--out") + 1])
snap = run / (gen_out.stem + ".snapshot.jsonl")
guard.acquire_heavy_job_lock(a.label, blocking=True)
guard.preflight_host_check() # AFTER the wait: a lock wait can last hours, a stale preflight proves nothing
me = os.getpid()
log_event(events, event="lock_acquired", pid=me)
stop = {"sig": None}
def on_signal(sig, _frm):
stop["sig"] = sig
signal.signal(signal.SIGTERM, on_signal)
signal.signal(signal.SIGINT, on_signal)
gen_log = open(run / "gen.log", "ab")
started_at = time.time() - 1.0
gen = subprocess.Popen(
[PYR, str(REPO / "extras/vllm_rollout/lean_gen.py"), *gen_args, "--inherit-lock", str(me)],
stdout=gen_log, stderr=subprocess.STDOUT, start_new_session=True,
)
log_event(events, event="gen_start", pid=gen.pid, args=gen_args)
def verify_pass(source: Path) -> int:
cmd = [PY, str(REPO / "scripts/p2/verify_generations.py"), "--generations", str(source),
"--problems", a.problems, "--out", a.verify_out, "--workers", str(a.workers),
"--inherit-lock", str(me)]
if a.feedback:
cmd.append("--feedback")
with open(run / "verify.log", "ab") as vl:
v = subprocess.Popen(cmd, stdout=vl, stderr=subprocess.STDOUT, start_new_session=True)
while v.poll() is None:
if stop["sig"] is not None:
os.killpg(v.pid, signal.SIGTERM)
v.wait()
break
time.sleep(2)
return v.returncode
rc_v = 0
last_lines = -1
while gen.poll() is None and stop["sig"] is None:
n = snapshot_complete_lines(gen_out, snap)
if n > last_lines and n > 0:
log_event(events, event="verify_pass", lines=n)
rc_v = verify_pass(snap)
log_event(events, event="verify_pass_end", lines=n, rc=rc_v)
if rc_v != 0 and stop["sig"] is None:
# a failing verifier must not cost the GPU work: keep generating, stop the
# overlapped passes, and let the final pass (or a re-run) surface the error
log_event(events, event="verify_overlap_disabled", rc=rc_v)
last_lines = float("inf")
rc_v = 0
continue
last_lines = n
t_end = time.monotonic() + a.every
while time.monotonic() < t_end and gen.poll() is None and stop["sig"] is None:
time.sleep(5)
if stop["sig"] is not None:
if gen.poll() is None:
os.killpg(gen.pid, signal.SIGTERM)
try:
gen.wait(timeout=120)
except subprocess.TimeoutExpired:
os.killpg(gen.pid, signal.SIGKILL)
gen.wait()
try:
reap_after_gen(gen, started_at, events)
except RuntimeError as e:
log_event(events, event="gpu_not_drained", error=str(e))
log_event(events, event="stopped", signal=stop["sig"], verify_rc=rc_v)
return 143
rc_g = gen.wait()
log_event(events, event="gen_end", rc=rc_g)
try:
reap_after_gen(gen, started_at, events)
except RuntimeError as e:
log_event(events, event="gpu_not_drained", error=str(e))
return rc_g or 5
if rc_g != 0:
return rc_g
complete, why = generation_complete(gen_out)
if not complete:
log_event(events, event="gen_incomplete", detail=why)
return 4
# final pass over the complete file (the generator has exited, so every line is whole)
rc_v = verify_pass(gen_out)
log_event(events, event="final_verify_end", rc=rc_v)
if snap.exists():
snap.unlink()
return 0 if stop["sig"] is None and rc_v == 0 else (143 if stop["sig"] is not None else rc_v)
if __name__ == "__main__":
sys.exit(main())
|