summaryrefslogtreecommitdiff
path: root/scripts/run_sweep.py
blob: 7b759213d8075d5370ccdf38ba13a4964664acce (plain)
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
#!/usr/bin/env python
"""Parallel experiment sweep runner.

Usage:
  .venv/bin/python -m scripts.run_sweep --exp seeds --jobs jobs.csv
  .venv/bin/python -m scripts.run_sweep --exp seeds --jobs jobs.csv --post-only   # eval/plot only

jobs.csv columns: job,model,seed,flags   (flags = extra train.py args, may be empty)

For each job: trains runs/<exp>/<job>/<model>/seed<seed>, then runs eval + plot.
Skips jobs whose results.json already exists (idempotent resume).
Concurrency default 2 (2 cores, one thread per child). Logs to runs/<exp>/logs/.
Appends a compact row per job to runs/<exp>/summary.csv.
"""
import argparse
import csv
import json
import os
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_venv_py = os.path.join(ROOT, ".venv", "bin", "python")
PY = _venv_py if os.path.exists(_venv_py) else sys.executable


def parse_jobs(path: str) -> list[dict]:
    jobs = []
    with open(path) as fh:
        for row in csv.DictReader(fh):
            jobs.append({
                "job": row["job"].strip(),
                "model": row["model"].strip(),
                "seed": row["seed"].strip(),
                "flags": (row.get("flags") or "").strip().split(),
            })
    return jobs


def out_dir(exp: str, job: dict) -> str:
    return os.path.join(ROOT, "runs", exp, job["job"], job["model"], f"seed{job['seed']}")


def run_one(exp: str, job: dict, post_only: bool) -> dict:
    od = out_dir(exp, job)
    logdir = os.path.join(ROOT, "runs", exp, "logs")
    os.makedirs(logdir, exist_ok=True)
    tag = f"{job['job']}__{job['model']}_s{job['seed']}"
    logpath = os.path.join(logdir, tag + ".log")
    results_path = os.path.join(od, "results.json")
    res = {"job": job["job"], "model": job["model"], "seed": job["seed"]}

    if os.path.exists(results_path):
        res["skipped"] = True
        return res

    t0 = time.time()
    if not post_only:
        cmd = [PY, "-m", "src.train", job["model"], job["seed"],
               "--out_dir", f"runs/{exp}/{job['job']}"] + job["flags"]
        # CPU-resident jobs get 2 threads (sequential tied cell benefits); GPU jobs
        # stay at 1 so the feeder process doesn't contend with concurrent CPU jobs.
        n_threads = "2" if "--device cpu" in job["flags"] else "1"
        env = dict(os.environ, OMP_NUM_THREADS=n_threads, MKL_NUM_THREADS=n_threads)
        with open(logpath, "w") as lf:
            p = subprocess.run(cmd, cwd=ROOT, env=env, stdout=lf, stderr=subprocess.STDOUT)
        if p.returncode != 0:
            res["error"] = f"train rc={p.returncode} (see {logpath})"
            return res

    for cmd in ([PY, "-m", "src.eval", job["model"], job["seed"], "--runs-dir", f"runs/{exp}/{job['job']}"],
                [PY, "-m", "scripts.plot", job["model"], job["seed"], "--runs-dir", f"runs/{exp}/{job['job']}"]):
        with open(logpath, "a") as lf:
            subprocess.run(cmd, cwd=ROOT, stdout=lf, stderr=subprocess.STDOUT)

    if not os.path.exists(results_path):
        res["error"] = f"eval produced no results.json (see {logpath})"
        return res

    with open(results_path) as fh:
        r = json.load(fh)
    sig = r.get("signature", {})
    final = r.get("final", {})
    probe = final.get("probe", {})
    hlt = final.get("halting") or {}
    res.update({
        "minutes": round((time.time() - t0) / 60, 1),
        "signature": sig.get("code"),
        "probe": probe.get("code"),
        "halting": hlt.get("code"),
        "val_em_last": final.get("val_exact_match"),
        "val_em_best": r.get("val_selected", {}).get("val_exact_match"),
        "probe_acc": probe.get("acc"),
        "halt_mean": hlt.get("mean_steps"),
    })
    return res


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--exp", required=True)
    ap.add_argument("--jobs", required=True)
    ap.add_argument("--post-only", action="store_true")
    ap.add_argument("--concurrency", type=int, default=2)
    a = ap.parse_args()
    jobs = parse_jobs(os.path.join(ROOT, a.jobs))
    print(f"{len(jobs)} jobs, concurrency {a.concurrency}, post_only={a.post_only}")

    results = []
    with ThreadPoolExecutor(max_workers=a.concurrency) as pool:
        futs = [pool.submit(run_one, a.exp, j, a.post_only) for j in jobs]
        for f in as_completed(futs):
            r = f.result()
            results.append(r)
            print(f"[{r['job']}/{r['model']}/s{r['seed']}] "
                  + (r.get("error") or r.get("skipped") and "skipped (exists)" or
                     f"{r.get('signature')}/{r.get('probe')}/{r.get('halting')} "
                     f"valEM {r.get('val_em_last')} probe {r.get('probe_acc')} "
                     f"({r.get('minutes')}min)"))

    summary_path = os.path.join(ROOT, "runs", a.exp, "summary.csv")
    with open(summary_path, "w", newline="") as fh:
        keys = ["job", "model", "seed", "minutes", "signature", "probe", "halting",
                "val_em_last", "val_em_best", "probe_acc", "halt_mean", "error", "skipped"]
        w = csv.DictWriter(fh, fieldnames=keys, extrasaction="ignore")
        w.writeheader()
        for r in sorted(results, key=lambda x: (x["job"], x["model"])):
            w.writerow(r)
    print(f"summary: {summary_path}")


if __name__ == "__main__":
    main()