align / code /scripts /p2 /pipeline_gen_verify.py
viai957's picture
Add files using upload-large-folder tool
63f41a6 verified
Raw History Blame Contribute Delete
9.4 kB
"""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())