Viewing files at commit ef78687eef1aCommit detailsView latest

Browse files
suites/scale/run_scale_suite.py 275 lines · 13.7 KB · python
1
#!/usr/bin/env python3
2
"""Scale & coverage lane: every user-facing op, git vs oak, across repo tiers.
3
4
Subcommands:
5
  list                         tiers, core.yaml shapes and the op catalog
6
  generate --tier T [...]      build (or reuse) fixtures + git-sizer stats only
7
  run --tier T [...]           prepare workspaces, run the interleaved A/A
8
                               schedule, write rows + summary
9
10
Rows are DEVELOPMENT evidence (evidence_class development_only_loaded_host,
11
claim_eligible false) until the calibrated campaign runs on a quiet host.
12
Raw rows go to --results (default results/scale, gitignored; ADR-0003).
13
Exit: 0 when every executed op passed its oracle, 1 when any row failed
14
(subject failure, oracle failure or harness error), 2 on usage errors.
15
"""
16
17
from __future__ import annotations
18
19
import argparse
20
import json
21
import os
22
import sys
23
import time
24
from pathlib import Path
25
26
HERE = Path(__file__).resolve().parent
27
ROOT = HERE.parents[1]
28
sys.path.insert(0, str(HERE))
29
sys.path.insert(0, str(ROOT / "scripts"))
30
31
from scalesuite import generator as gen  # noqa: E402
32
from scalesuite import ops as ops_mod  # noqa: E402
33
from scalesuite import sizer as sz  # noqa: E402
34
from scalesuite import tiers as tiers_mod  # noqa: E402
35
36
DEFAULT_SUBJECTS = "git,git_aa,oak_installed,oak_installed_aa"
37
38
39
def _git_bin(explicit: str | None) -> str:
40
    if explicit:
41
        return explicit
42
    from oakbench.subjects import which_measured_git
43
    found = which_measured_git()
44
    if not found:
45
        raise SystemExit("git not found")
46
    return found
47
48
49
def _tiers(args: argparse.Namespace) -> list:
50
    names = []
51
    for item in args.tier:
52
        names.extend(part.strip() for part in item.split(",") if part.strip())
53
    if "shapes" in names:
54
        names.remove("shapes")
55
        names.extend(f"shape:{shape['id']}" for shape in tiers_mod.load_core_shapes())
56
    return [tiers_mod.resolve(name, shape_scale=args.shape_scale, allow_xl=args.allow_xl,
57
                              allow_xl_combined=args.allow_xl_combined) for name in names]
58
59
60
def cmd_list(_: argparse.Namespace) -> int:
61
    print("tiers:")
62
    for name, tier in tiers_mod.TIERS.items():
63
        spec = tier.spec
64
        print(f"  {name:12s} files={spec.files} depth={spec.depth} commits={spec.commits} branches={spec.branches} "
65
              f"tags={spec.tags} binaries={spec.binaries}x{spec.binary_bytes} xl={tier.xl} id={tier.fixture_id}")
66
    print("core.yaml shapes (shape:<id>, --shape-scale):")
67
    for shape in tiers_mod.load_core_shapes():
68
        print(f"  shape:{shape['id']}")
69
    print("ops:")
70
    for unit in ops_mod.UNIT_ORDER:
71
        for op in unit:
72
            info = ops_mod.OP_INFO[op]
73
            print(f"  {op:24s} t/perf: {', '.join(info.tperf) or '-'}")
74
    return 0
75
76
77
def cmd_generate(args: argparse.Namespace) -> int:
78
    git = _git_bin(args.git_bin)
79
    work = Path(args.work).resolve()
80
    out = []
81
    for tier in _tiers(args):
82
        started = time.perf_counter()
83
        manifest = gen.generate(tier.spec, work / tier.fixture_id / "fixture", git)
84
        pin = gen.check_pin(manifest)  # raises FixturePinMismatch
85
        stats = sz.measure(work / tier.fixture_id / "fixture" / "src.git", git)
86
        item = {"tier": tier.tier_id, "fixture_id": tier.fixture_id, "reused": manifest.get("reused"),
87
                "generation_elapsed_s": manifest["generation_elapsed_s"], "sizer_elapsed_s": stats["elapsed_s"],
88
                "store_bytes": manifest["store_bytes"], "refs_sha256": manifest["refs_sha256"],
89
                "head_commit": manifest["head_commit"], "fixture_pin_status": pin, "wall_s": round(time.perf_counter() - started, 3),
90
                "sizer": stats["values"], "checkout_scope": stats["checkout_scope"],
91
                "max_level_of_concern": stats["max_level_of_concern"]}
92
        (work / tier.fixture_id / "fixture" / "SIZER.json").write_text(
93
            json.dumps({"refs_sha256": manifest["refs_sha256"], "stats": stats}, indent=2) + "\n")
94
        print(json.dumps(item, sort_keys=True), flush=True)
95
        out.append(item)
96
        if args.delete_after:
97
            import shutil
98
            shutil.rmtree(work / tier.fixture_id, ignore_errors=True)
99
    return 0
100
101
102
def cmd_run(args: argparse.Namespace) -> int:
103
    from oakbench import runner as oak_runner
104
    from oakbench.environment import base_env
105
    from oakbench.envwatch import sample_environment
106
    from oakbench.results import ResultsStore
107
    from oakbench.runlock import measurement_lock
108
    from oakbench.subjects import load_subjects, subject_details
109
    from scalesuite import runner, workspace
110
111
    if args.runs < 5:
112
        print("[scale] warning: runs < 5 leaves every cell UNMEASURED (no A/A floor)", file=sys.stderr)
113
    git = _git_bin(args.git_bin)
114
    ns = argparse.Namespace(config=Path(args.config), subjects=args.subjects, git_bin=Path(git),
115
                            oak_installed_bin=Path(args.oak_bin) if args.oak_bin else None, oak_local_bin=None,
116
                            oak_aa_bin=Path(args.oak_bin) if args.oak_bin else None, allow_git_shim=False)
117
    subjects = load_subjects(ns)
118
    names = [s.name for s in subjects]
119
    kinds = tuple(sorted({s.kind for s in subjects}))
120
    oak_bin = next((str(s.bin) for s in subjects if s.kind == "oak"), args.oak_bin or "oak")
121
    details = subject_details(subjects)
122
    fields = oak_runner.runner_fields()
123
    work = Path(args.work).resolve()
124
    home = work / "home"
125
    env = base_env(home=home)
126
    env["PATH"] = str(Path(git).parent) + os.pathsep + env.get("PATH", "")
127
    ops = ops_mod.ALL_OPS if args.ops == "all" else tuple(op.strip() for op in args.ops.split(",") if op.strip())
128
    unknown = sorted(set(ops) - set(ops_mod.ALL_OPS))
129
    if unknown:
130
        raise SystemExit(f"unknown ops: {', '.join(unknown)}")
131
    results = Path(args.results).resolve()
132
    status = 0
133
    from oakbench import scale_calibration
134
    calibration = None
135
    if getattr(args, "calibration_record", None):
136
        calibration = json.loads(Path(args.calibration_record).read_text())
137
        problems = scale_calibration.validate(calibration, fields.get("runner_id"))
138
        if problems:
139
            print(f"[scale] calibration record rejected (rows stay development-only): {problems}", file=sys.stderr)
140
    for tier in _tiers(args):
141
        bench_id = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
142
        try:
143
            prepared = workspace.prepare(tier, work, git_bin=git, oak_bin=oak_bin, env=env, kinds=kinds,
144
                                         oak_branch_cap=args.oak_branch_cap)
145
        except Exception as exc:  # noqa: BLE001 - one tier's prep failure must not hide the others
146
            import traceback
147
            traceback.print_exc()
148
            print(f"[scale] {tier.tier_id}: preparation failed: {exc}", file=sys.stderr, flush=True)
149
            status = 1
150
            continue
151
        try:
152
            load_start = sample_environment().get("loadavg")
153
            with measurement_lock(f"scale:{tier.tier_id}") as lock:
154
                rows = runner.run_schedule(prepared, subjects, runs=args.runs, ops=ops, bench_id=bench_id,
155
                                           seed=args.seed, scratch=prepared.root / "scratch", details=details,
156
                                           runner_fields=fields, timeout_s=args.timeout_s,
157
                                           lock_wait_ms=lock.wait_ms, calibration=calibration)
158
            load_end = sample_environment().get("loadavg")
159
        finally:
160
            prepared.close()
161
        oak_subject = next((s for s in subjects if s.name == "oak_installed"), None)
162
        if oak_subject is not None:
163
            prepared.oak_identity = runner._subject_meta(oak_subject, details)
164
        rows = runner.ingest_rows(prepared, bench_id, fields) + rows
165
        report = runner.cell_report(rows, names)
166
        meta = {"bench_id": bench_id, "runs": args.runs, "subjects": names, "loadavg_start": load_start,
167
                "loadavg_end": load_end, "calibrated": any(r.get("claim_eligible") is True for r in rows)}
168
        summary = runner.summary_markdown(prepared, report, meta)
169
        store = ResultsStore(results, "scale", filename_suffix=tier.tier_id)
170
        raw_path, summary_path = store.write(bench_id, rows, summary)
171
        (results / f"{bench_id}.{tier.tier_id}.cells.json").write_text(
172
            json.dumps({"meta": meta, "cells": report, "prep_phases": prepared.phases,
173
                        "sizer": prepared.sizer, "manifest": prepared.manifest}, indent=2, default=str) + "\n")
174
        print(summary)
175
        print(f"[scale] rows: {raw_path}")
176
        if any(r.get("returncode") == 1 for r in rows):
177
            status = 1
178
        if args.delete_workspaces:
179
            import shutil
180
            shutil.rmtree(prepared.root / "git", ignore_errors=True)
181
            shutil.rmtree(prepared.root / "oak", ignore_errors=True)
182
            shutil.rmtree(prepared.root / "scratch", ignore_errors=True)
183
    return status
184
185
186
def cmd_calibrate(args: argparse.Namespace) -> int:
187
    """Identical-binary A/A campaign that produces a quiet-host calibration record.
188
189
    Runs the chosen tier with git, git_aa, oak_installed, oak_installed_aa and
190
    records host load, per-cell A/A floors and A/A false claims. The sealed
191
    record is written even when it does not validate (reasons are printed);
192
    only a valid one makes later `run --calibration-record` rows claimable.
193
    """
194
    import os as _os
195
    import statistics as _st
196
    from oakbench import cellstats, scale_calibration
197
    from oakbench import runner as oak_runner
198
    args.subjects = DEFAULT_SUBJECTS
199
    args.calibration_record = None
200
    results = Path(args.results).resolve()
201
    before = set(results.glob("*.jsonl")) if results.exists() else set()
202
    rc = cmd_run(args)
203
    produced = sorted(p for p in set(results.glob("*.jsonl")) - before if not p.name.startswith("latest"))
204
    if not produced:
205
        print("[scale] calibration produced no rows", file=sys.stderr)
206
        return 2
207
    rows = [json.loads(line) for path in produced for line in path.read_text().splitlines() if line.strip()]
208
    measured = [r for r in rows if r.get("returncode") == 0 and not r.get("diagnostic")]
209
    value = lambda r: r.get("elapsed_ms")  # noqa: E731
210
    floors, false_claims = [], 0
211
    for arm, control in (("git", "git_aa"), ("oak_installed", "oak_installed_aa")):
212
        cell_floors = cellstats.cell_floors(measured, arm, control, value)
213
        floors.extend(cell_floors.values())
214
        verdicts = cellstats.classify_cells(measured, control, arm, cell_floors, value_of=value)
215
        false_claims += sum(1 for item in verdicts.values() if item.get("verdict") in ("regression", "improvement"))
216
    cpus = _os.cpu_count() or 1
217
    loads = [r["environment_sample"]["loadavg"][0] for r in rows
218
             if isinstance((r.get("environment_sample") or {}).get("loadavg"), list)]
219
    record = scale_calibration.seal({
220
        "runner_id": oak_runner.runner_fields().get("runner_id"),
221
        "created_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
222
        "calibration_bench_id": rows[0].get("bench_id"), "cpu_count": cpus,
223
        "loadavg_per_core_max": round(max(loads) / cpus, 4) if loads else None,
224
        "aa_cells": len(floors), "aa_floor_median_pct": round(_st.median(floors), 3) if floors else None,
225
        "aa_false_claims": false_claims, "source_rows": [str(p) for p in produced],
226
    })
227
    Path(args.out).write_text(json.dumps(record, indent=2, sort_keys=True) + "\n")
228
    problems = scale_calibration.validate(record, record["runner_id"])
229
    print(json.dumps({"record": args.out, "valid": not problems, "reasons": problems}, indent=2))
230
    return rc if rc else (0 if not problems else 1)
231
232
233
def parser() -> argparse.ArgumentParser:
234
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
235
    sub = ap.add_subparsers(dest="command", required=True)
236
    sub.add_parser("list")
237
    for name in ("generate", "run", "calibrate"):
238
        p = sub.add_parser(name)
239
        p.add_argument("--tier", action="append", required=True,
240
                       help="tier id (fresh, default, large, xl-*), shape:<core.yaml id>, or 'shapes'")
241
        p.add_argument("--shape-scale", type=float, default=1.0)
242
        p.add_argument("--allow-xl", action="store_true")
243
        p.add_argument("--allow-xl-combined", action="store_true")
244
        p.add_argument("--work", default=str(ROOT / "results" / "scale-work"),
245
                       help="fixture + workspace root (large; keep it on the benchmark volume)")
246
        p.add_argument("--git-bin")
247
        if name == "generate":
248
            p.add_argument("--delete-after", action="store_true")
249
        else:
250
            if name == "calibrate":
251
                p.add_argument("--out", required=True, help="where to write the sealed calibration record")
252
            else:
253
                p.add_argument("--calibration-record",
254
                               help="validated quiet-host record (from `calibrate`); without it rows are dev-only")
255
            p.add_argument("--subjects", default=DEFAULT_SUBJECTS)
256
            p.add_argument("--config", default=str(ROOT / "config" / "subjects.toml"))
257
            p.add_argument("--oak-bin")
258
            p.add_argument("--runs", type=int, default=5)
259
            p.add_argument("--ops", default="all")
260
            p.add_argument("--seed", type=int, default=20260928)
261
            p.add_argument("--timeout-s", type=float, default=1800.0)
262
            p.add_argument("--oak-branch-cap", type=int)
263
            p.add_argument("--results", default=str(ROOT / "results" / "scale"))
264
            p.add_argument("--delete-workspaces", action="store_true")
265
    return ap
266
267
268
def main(argv: list | None = None) -> int:
269
    args = parser().parse_args(argv)
270
    return {"list": cmd_list, "generate": cmd_generate, "run": cmd_run,
271
            "calibrate": cmd_calibrate}[args.command](args)
272
273
274
if __name__ == "__main__":
275
    raise SystemExit(main())