Viewing files at commit a3ea065926c9Commit detailsView latest

Browse files
suites/scale/run_scale_suite.py 293 lines · 14.9 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 _registry() -> dict:
103
    return json.loads((ROOT / "config" / "fixtures.json").read_text()).get("fixtures", {})
104
105
106
def cmd_run(args: argparse.Namespace) -> int:
107
    from oakbench import runner as oak_runner
108
    from oakbench.environment import base_env
109
    from oakbench.envwatch import sample_environment
110
    from oakbench.results import ResultsStore
111
    from oakbench.runlock import measurement_lock
112
    from oakbench.subjects import load_subjects, subject_details
113
    from scalesuite import runner, workspace
114
115
    if args.runs < 5:
116
        print("[scale] warning: runs < 5 leaves every cell UNMEASURED (no A/A floor)", file=sys.stderr)
117
    git = _git_bin(args.git_bin)
118
    ns = argparse.Namespace(config=Path(args.config), subjects=args.subjects, git_bin=Path(git),
119
                            oak_installed_bin=Path(args.oak_bin) if args.oak_bin else None, oak_local_bin=None,
120
                            oak_aa_bin=Path(args.oak_bin) if args.oak_bin else None, allow_git_shim=False)
121
    subjects = load_subjects(ns)
122
    names = [s.name for s in subjects]
123
    kinds = tuple(sorted({s.kind for s in subjects}))
124
    oak_bin = next((str(s.bin) for s in subjects if s.kind == "oak"), args.oak_bin or "oak")
125
    details = subject_details(subjects)
126
    from oakbench import scale_calibration
127
    fields = {**oak_runner.runner_fields(), **scale_calibration.host_facts()}
128
    work = Path(args.work).resolve()
129
    work.mkdir(parents=True, exist_ok=True)
130
    # The volume the workspaces live on (RAM/disk-image volumes cannot certify flush costs).
131
    fields["work_volume"] = scale_calibration.volume_identity(work)
132
    home = work / "home"
133
    env = base_env(home=home)
134
    env["PATH"] = str(Path(git).parent) + os.pathsep + env.get("PATH", "")
135
    ops = ops_mod.ALL_OPS if args.ops == "all" else tuple(op.strip() for op in args.ops.split(",") if op.strip())
136
    unknown = sorted(set(ops) - set(ops_mod.ALL_OPS))
137
    if unknown:
138
        raise SystemExit(f"unknown ops: {', '.join(unknown)}")
139
    results = Path(args.results).resolve()
140
    status = 0
141
    calibration = None
142
    if getattr(args, "calibration_record", None):
143
        # Same verification publish_gate performs: recompute the campaign
144
        # from its own JSONL (matched by content sha256); never trust the record.
145
        record = json.loads(Path(args.calibration_record).read_text())
146
        cal_path = Path(str(record.get("calibration_rows_path") or ""))
147
        cal_rows = scale_calibration.load_rows(cal_path) if cal_path.is_file() else None
148
        cal_sha = scale_calibration.file_sha256(cal_path) if cal_path.is_file() else None
149
        facts, problems = scale_calibration.verify_campaign(record, cal_rows, cal_sha, _registry())
150
        if problems:
151
            print(f"[scale] calibration rejected (rows stay development-only): {problems}", file=sys.stderr)
152
        calibration = (record, facts, problems)
153
    for tier in _tiers(args):
154
        bench_id = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
155
        try:
156
            prepared = workspace.prepare(tier, work, git_bin=git, oak_bin=oak_bin, env=env, kinds=kinds,
157
                                         oak_branch_cap=args.oak_branch_cap)
158
        except Exception as exc:  # noqa: BLE001 - one tier's prep failure must not hide the others
159
            import traceback
160
            traceback.print_exc()
161
            print(f"[scale] {tier.tier_id}: preparation failed: {exc}", file=sys.stderr, flush=True)
162
            status = 1
163
            continue
164
        try:
165
            load_start = sample_environment().get("loadavg")
166
            probe_base = {"bench_id": bench_id, "profile": "scale", "scenario": tier.tier_id, **fields,
167
                          **prepared.fixture_fields()}
168
            with measurement_lock(f"scale:{tier.tier_id}") as lock:
169
                probes = scale_calibration.probe_rows(scale_calibration.disk_probe(prepared.root / "probe"),
170
                                                      probe_base, "start")
171
                rows = runner.run_schedule(prepared, subjects, runs=args.runs, ops=ops, bench_id=bench_id,
172
                                           seed=args.seed, scratch=prepared.root / "scratch", details=details,
173
                                           runner_fields=fields, timeout_s=args.timeout_s,
174
                                           lock_wait_ms=lock.wait_ms, calibration=calibration)
175
                probes += scale_calibration.probe_rows(scale_calibration.disk_probe(prepared.root / "probe"),
176
                                                       probe_base, "end")
177
            load_end = sample_environment().get("loadavg")
178
            disk_reasons, _ = scale_calibration.probe_verdict(probes)
179
            if disk_reasons and calibration is not None:
180
                # The published run's own disk probes must be quiet too.
181
                for row in rows:
182
                    if row.get("claim_eligible") is True:
183
                        scale_calibration.stamp(row, calibration[0], calibration[1], disk_reasons)
184
            rows = probes + rows
185
        finally:
186
            prepared.close()
187
        oak_subject = next((s for s in subjects if s.name == "oak_installed"), None)
188
        if oak_subject is not None:
189
            prepared.oak_identity = runner._subject_meta(oak_subject, details)
190
        rows = runner.ingest_rows(prepared, bench_id, fields) + rows
191
        report = runner.cell_report(rows, names)
192
        meta = {"bench_id": bench_id, "runs": args.runs, "subjects": names, "loadavg_start": load_start,
193
                "loadavg_end": load_end, "calibrated": any(r.get("claim_eligible") is True for r in rows)}
194
        summary = runner.summary_markdown(prepared, report, meta)
195
        store = ResultsStore(results, "scale", filename_suffix=tier.tier_id)
196
        raw_path, summary_path = store.write(bench_id, rows, summary)
197
        (results / f"{bench_id}.{tier.tier_id}.cells.json").write_text(
198
            json.dumps({"meta": meta, "cells": report, "prep_phases": prepared.phases,
199
                        "sizer": prepared.sizer, "manifest": prepared.manifest}, indent=2, default=str) + "\n")
200
        print(summary)
201
        print(f"[scale] rows: {raw_path}")
202
        if any(r.get("returncode") == 1 for r in rows):
203
            status = 1
204
        if args.delete_workspaces:
205
            import shutil
206
            shutil.rmtree(prepared.root / "git", ignore_errors=True)
207
            shutil.rmtree(prepared.root / "oak", ignore_errors=True)
208
            shutil.rmtree(prepared.root / "scratch", ignore_errors=True)
209
    return status
210
211
212
def cmd_calibrate(args: argparse.Namespace) -> int:
213
    """Identical-binary A/A campaign + host probes -> a sealed calibration record.
214
215
    The record names the campaign JSONL by content sha256; its numbers are
216
    recomputed from that JSONL wherever it is used (run, publish_gate). It is
217
    written even when the host is not quiet (reasons are printed).
218
    """
219
    from oakbench import scale_calibration
220
    if len(_tiers(args)) != 1:
221
        raise SystemExit("calibrate takes exactly one tier")
222
    args.subjects = DEFAULT_SUBJECTS
223
    args.calibration_record = None
224
    # Two independent halves of >= 5 runs (floors on one, held-out A/A claims on the other).
225
    args.runs = max(args.runs, scale_calibration.MIN_RUNS)
226
    results = Path(args.results).resolve()
227
    before = set(results.glob("*.jsonl")) if results.exists() else set()
228
    rc = cmd_run(args)
229
    produced = sorted(p for p in set(results.glob("*.jsonl")) - before if not p.name.startswith("latest"))
230
    if len(produced) != 1:
231
        print(f"[scale] calibration expected one JSONL, got {produced}", file=sys.stderr)
232
        return 2
233
    rows = scale_calibration.load_rows(produced[0])
234
    facts, problems = scale_calibration.recompute(rows, _registry())
235
    record = scale_calibration.seal({
236
        **{key: facts.get(key) for key in ("runner_id", "host_cpu_count", "host_mem_bytes", "oak_binary_sha256",
237
                                           "git_binary_sha256", "work_volume_key", "aa_per_arm",
238
                                           "aa_cells", "aa_floor_median_pct",
239
                                           "aa_false_claims", "loadavg_per_core_max", "fullfsync_median_ms",
240
                                           "create_files_per_s_min")},
241
        "created_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
242
        "calibration_bench_id": rows[0].get("bench_id"),
243
        "calibration_rows_path": str(produced[0]),
244
        "calibration_rows_sha256": scale_calibration.file_sha256(produced[0]),
245
    })
246
    Path(args.out).write_text(json.dumps(record, indent=2, sort_keys=True) + "\n")
247
    print(json.dumps({"record": args.out, "valid": not problems, "reasons": problems}, indent=2))
248
    return rc if rc else (0 if not problems else 1)
249
250
251
def parser() -> argparse.ArgumentParser:
252
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
253
    sub = ap.add_subparsers(dest="command", required=True)
254
    sub.add_parser("list")
255
    for name in ("generate", "run", "calibrate"):
256
        p = sub.add_parser(name)
257
        p.add_argument("--tier", action="append", required=True,
258
                       help="tier id (fresh, default, large, xl-*), shape:<core.yaml id>, or 'shapes'")
259
        p.add_argument("--shape-scale", type=float, default=1.0)
260
        p.add_argument("--allow-xl", action="store_true")
261
        p.add_argument("--allow-xl-combined", action="store_true")
262
        p.add_argument("--work", default=str(ROOT / "results" / "scale-work"),
263
                       help="fixture + workspace root (large; keep it on the benchmark volume)")
264
        p.add_argument("--git-bin")
265
        if name == "generate":
266
            p.add_argument("--delete-after", action="store_true")
267
        else:
268
            if name == "calibrate":
269
                p.add_argument("--out", required=True, help="where to write the sealed calibration record")
270
            else:
271
                p.add_argument("--calibration-record",
272
                               help="validated quiet-host record (from `calibrate`); without it rows are dev-only")
273
            p.add_argument("--subjects", default=DEFAULT_SUBJECTS)
274
            p.add_argument("--config", default=str(ROOT / "config" / "subjects.toml"))
275
            p.add_argument("--oak-bin")
276
            p.add_argument("--runs", type=int, default=5)
277
            p.add_argument("--ops", default="all")
278
            p.add_argument("--seed", type=int, default=20260928)
279
            p.add_argument("--timeout-s", type=float, default=1800.0)
280
            p.add_argument("--oak-branch-cap", type=int)
281
            p.add_argument("--results", default=str(ROOT / "results" / "scale"))
282
            p.add_argument("--delete-workspaces", action="store_true")
283
    return ap
284
285
286
def main(argv: list | None = None) -> int:
287
    args = parser().parse_args(argv)
288
    return {"list": cmd_list, "generate": cmd_generate, "run": cmd_run,
289
            "calibrate": cmd_calibrate}[args.command](args)
290
291
292
if __name__ == "__main__":
293
    raise SystemExit(main())