Viewing files at commit 290f11640be1Commit detailsView latest

scripts/oakbench/perf_loop.py 569 lines · 20.2 KB · python
1
"""Overnight Oak perf-improvement loop primitives.
2
3
The loop is intentionally propose-only: scripts can select a target, verify a
4
candidate, and append the ledger, but they never edit Oak and never merge.
5
"""
6
7
from __future__ import annotations
8
9
import json
10
import os
11
import statistics
12
import subprocess
13
import time
14
from dataclasses import dataclass
15
from datetime import datetime, timezone
16
from pathlib import Path
17
from typing import Any, Iterable, Sequence
18
19
from oakbench.reporting import median
20
from oakbench.rows import row_returncode
21
22
SCORECARD_SCHEMA_VERSION = 1
23
LEDGER_MARKER_START = "<!-- oak-perf-loop-entry"
24
LEDGER_MARKER_END = "-->"
25
DIAGNOSTIC_SUFFIXES = (".determinism", ".inforecall")
26
SKIP_RETURNCODE = 77
27
28
SCORECARD_EXIT_BETTER = 0
29
SCORECARD_EXIT_NO_CHANGE = 10
30
SCORECARD_EXIT_REGRESSED = 20
31
32
33
def load_jsonl(paths: Sequence[Path]) -> list[dict[str, Any]]:
34
    rows: list[dict[str, Any]] = []
35
    for path in paths:
36
        with path.open() as fh:
37
            for line_number, raw in enumerate(fh, start=1):
38
                line = raw.strip()
39
                if not line:
40
                    continue
41
                try:
42
                    row = json.loads(line)
43
                except json.JSONDecodeError as exc:
44
                    raise SystemExit(f"{path}:{line_number}: invalid JSON: {exc}") from exc
45
                if not isinstance(row, dict):
46
                    raise SystemExit(f"{path}:{line_number}: expected JSON object")
47
                rows.append(row)
48
    return rows
49
50
51
def write_json(path: Path, payload: dict[str, Any]) -> None:
52
    path.parent.mkdir(parents=True, exist_ok=True)
53
    path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
54
55
56
def scorecard_exit_code(verdict: str) -> int:
57
    if verdict == "better":
58
        return SCORECARD_EXIT_BETTER
59
    if verdict == "no-change":
60
        return SCORECARD_EXIT_NO_CHANGE
61
    if verdict == "regressed":
62
        return SCORECARD_EXIT_REGRESSED
63
    raise ValueError(f"unknown scorecard verdict: {verdict}")
64
65
66
def row_lane(row: dict[str, Any]) -> str:
67
    for key in ("lane", "benchmark_lane", "measurement_lane"):
68
        if row.get(key):
69
            return str(row[key])
70
    if "workflow" in row or "phase" in row:
71
        return "workflow"
72
    if "contention_mode" in row or "workers" in row:
73
        return "contention"
74
    if all(key in row for key in ("profile", "scenario", "operation", "subject", "subject_kind")):
75
        return "core"
76
    return "unknown"
77
78
79
def scorecard_key(row: dict[str, Any]) -> tuple[str, str | None, str, str]:
80
    profile = row.get("profile")
81
    return (
82
        row_lane(row),
83
        str(profile) if profile is not None else None,
84
        str(row.get("scenario") or ""),
85
        str(row.get("operation") or ""),
86
    )
87
88
89
def _successful_latency_rows(rows: Iterable[dict[str, Any]], subject: str) -> list[dict[str, Any]]:
90
    return [
91
        row
92
        for row in rows
93
        if str(row.get("subject")) == subject
94
        and row_returncode(row) == 0
95
        and not bool(row.get("skipped"))
96
        and isinstance(row.get("elapsed_ms"), (int, float))
97
    ]
98
99
100
def _transport_signature(rows: list[dict[str, Any]]) -> tuple[str, ...]:
101
    values = {
102
        str(row.get("remote_transport") or row.get("transport") or "local")
103
        for row in rows
104
        if row.get("remote_transport") is not None or row.get("transport") is not None
105
    }
106
    if not values:
107
        return ("local",)
108
    return tuple(sorted(values))
109
110
111
def _entry_id(entry: dict[str, Any]) -> str:
112
    profile = entry.get("profile")
113
    profile_part = "" if profile is None else f"{profile}:"
114
    return f"{entry['lane']}:{profile_part}{entry['scenario']}/{entry['operation']}"
115
116
117
def _baseline_by_id(baseline: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
118
    if not baseline:
119
        return {}
120
    return {_entry_id(entry): entry for entry in baseline.get("entries", []) if isinstance(entry, dict)}
121
122
123
def build_scorecard(
124
    rows: list[dict[str, Any]],
125
    *,
126
    oak_subject: str,
127
    git_subject: str = "git",
128
    baseline: dict[str, Any] | None = None,
129
    change_threshold_pct: float = 3.0,
130
) -> dict[str, Any]:
131
    grouped: dict[tuple[str, str | None, str, str], list[dict[str, Any]]] = {}
132
    for row in rows:
133
        grouped.setdefault(scorecard_key(row), []).append(row)
134
135
    baseline_entries = _baseline_by_id(baseline)
136
    entries: list[dict[str, Any]] = []
137
    skipped: list[dict[str, Any]] = []
138
    any_better = False
139
    any_regressed = False
140
141
    for key, group in sorted(grouped.items()):
142
        lane, profile, scenario, operation = key
143
        if not scenario or not operation:
144
            continue
145
        oak_rows = _successful_latency_rows(group, oak_subject)
146
        git_rows = _successful_latency_rows(group, git_subject)
147
        if not oak_rows or not git_rows:
148
            continue
149
        oak_transport = _transport_signature(oak_rows)
150
        git_transport = _transport_signature(git_rows)
151
        if oak_transport != git_transport:
152
            skipped.append(
153
                {
154
                    "lane": lane,
155
                    "profile": profile,
156
                    "scenario": scenario,
157
                    "operation": operation,
158
                    "reason": "transport_mismatch",
159
                    "oak_transport": list(oak_transport),
160
                    "git_transport": list(git_transport),
161
                }
162
            )
163
            continue
164
        oak_values = [float(row["elapsed_ms"]) for row in oak_rows]
165
        git_values = [float(row["elapsed_ms"]) for row in git_rows]
166
        oak_median = median(oak_values)
167
        git_median = median(git_values)
168
        if oak_median is None or git_median is None or git_median <= 0:
169
            continue
170
        ratio = oak_median / git_median
171
        entry: dict[str, Any] = {
172
            "lane": lane,
173
            "profile": profile,
174
            "scenario": scenario,
175
            "operation": operation,
176
            "metric": "elapsed_ms",
177
            "oak_subject": oak_subject,
178
            "git_subject": git_subject,
179
            "oak_median_ms": round(oak_median, 3),
180
            "git_median_ms": round(git_median, 3),
181
            "ratio": ratio,
182
            "n_oak": len(oak_values),
183
            "n_git": len(git_values),
184
            "transport": list(oak_transport),
185
            "verdict": "oak_slower" if ratio > 1.0 else ("oak_faster" if ratio < 1.0 else "parity"),
186
        }
187
        previous = baseline_entries.get(_entry_id(entry))
188
        if previous and isinstance(previous.get("ratio"), (int, float)):
189
            baseline_ratio = float(previous["ratio"])
190
            ratio_delta_pct = ((ratio - baseline_ratio) / baseline_ratio) * 100.0 if baseline_ratio else 0.0
191
            entry["baseline"] = {
192
                "ratio": round(baseline_ratio, 6),
193
                "ratio_delta_pct": round(ratio_delta_pct, 3),
194
            }
195
            if ratio_delta_pct <= -change_threshold_pct:
196
                entry["baseline"]["verdict"] = "better"
197
                any_better = True
198
            elif ratio_delta_pct >= change_threshold_pct:
199
                entry["baseline"]["verdict"] = "regressed"
200
                any_regressed = True
201
            else:
202
                entry["baseline"]["verdict"] = "no-change"
203
        entries.append(entry)
204
205
    gate_verdict = "regressed" if any_regressed else ("better" if any_better else "no-change")
206
    worst = max(
207
        (entry for entry in entries if entry.get("ratio") is not None),
208
        key=lambda entry: float(entry["ratio"]),
209
        default=None,
210
    )
211
    return {
212
        "schema_version": SCORECARD_SCHEMA_VERSION,
213
        "generated_at": datetime.now(timezone.utc).isoformat(),
214
        "oak_subject": oak_subject,
215
        "git_subject": git_subject,
216
        "metric": "elapsed_ms",
217
        "change_threshold_pct": change_threshold_pct,
218
        "summary": {
219
            "gate_verdict": gate_verdict,
220
            "entry_count": len(entries),
221
            "skipped_comparison_count": len(skipped),
222
            "worst": worst,
223
        },
224
        "entries": entries,
225
        "skipped_comparisons": skipped,
226
    }
227
228
229
def load_scorecard(path: Path) -> dict[str, Any]:
230
    payload = json.loads(path.read_text())
231
    if not isinstance(payload, dict):
232
        raise SystemExit(f"{path}: expected scorecard JSON object")
233
    return payload
234
235
236
def target_key(target: dict[str, Any]) -> str:
237
    profile = target.get("profile")
238
    profile_part = "" if profile is None else f"{profile}:"
239
    return f"{target.get('lane', 'core')}:{profile_part}{target['scenario']}/{target['operation']}"
240
241
242
def is_diagnostic_operation(operation: str) -> bool:
243
    return operation.endswith(DIAGNOSTIC_SUFFIXES)
244
245
246
def parse_ledger(path: Path) -> list[dict[str, Any]]:
247
    if not path.exists():
248
        return []
249
    lines = path.read_text().splitlines()
250
    entries: list[dict[str, Any]] = []
251
    index = 0
252
    while index < len(lines):
253
        if lines[index].strip() != LEDGER_MARKER_START:
254
            index += 1
255
            continue
256
        index += 1
257
        payload_lines: list[str] = []
258
        while index < len(lines) and lines[index].strip() != LEDGER_MARKER_END:
259
            payload_lines.append(lines[index])
260
            index += 1
261
        if index >= len(lines):
262
            break
263
        index += 1
264
        try:
265
            payload = json.loads("\n".join(payload_lines))
266
        except json.JSONDecodeError:
267
            continue
268
        if isinstance(payload, dict):
269
            entries.append(payload)
270
    return entries
271
272
273
def no_payoff_targets(ledger_entries: list[dict[str, Any]]) -> set[str]:
274
    blocked: set[str] = set()
275
    for entry in ledger_entries:
276
        target = entry.get("target")
277
        if not isinstance(target, dict):
278
            continue
279
        verdict = str(entry.get("verdict") or entry.get("landed_or_parked") or "")
280
        reason = str(entry.get("reason") or "")
281
        if verdict in {"no-payoff", "parked_no_payoff"} or "did_not_pay" in reason or "no_payoff" in reason:
282
            blocked.add(target_key(target))
283
    return blocked
284
285
286
def select_target(scorecard: dict[str, Any], ledger_entries: list[dict[str, Any]]) -> dict[str, Any] | None:
287
    blocked = no_payoff_targets(ledger_entries)
288
    candidates = []
289
    for entry in scorecard.get("entries", []):
290
        if not isinstance(entry, dict):
291
            continue
292
        if entry.get("metric") != "elapsed_ms":
293
            continue
294
        if is_diagnostic_operation(str(entry.get("operation") or "")):
295
            continue
296
        if float(entry.get("ratio") or 0.0) <= 1.0:
297
            continue
298
        if target_key(entry) in blocked:
299
            continue
300
        candidates.append(entry)
301
    if not candidates:
302
        return None
303
    candidates.sort(key=lambda entry: (-float(entry["ratio"]), str(entry["scenario"]), str(entry["operation"])))
304
    return candidates[0]
305
306
307
def append_ledger(path: Path, entry: dict[str, Any]) -> None:
308
    path.parent.mkdir(parents=True, exist_ok=True)
309
    if not path.exists():
310
        path.write_text("# Oak Perf Loop Ledger\n\n")
311
    target = entry.get("target") if isinstance(entry.get("target"), dict) else {}
312
    title = target_key(target) if target else "unknown-target"
313
    verdict = entry.get("landed_or_parked") or entry.get("verdict") or "unknown"
314
    reason = entry.get("reason") or ""
315
    text = path.read_text()
316
    with path.open("a") as fh:
317
        if text and not text.endswith("\n"):
318
            fh.write("\n")
319
        fh.write(f"## {entry.get('timestamp', datetime.now(timezone.utc).isoformat())} — {title}\n\n")
320
        fh.write(f"- Verdict: {verdict}\n")
321
        if reason:
322
            fh.write(f"- Reason: {reason}\n")
323
        before = entry.get("before_after_medians") or {}
324
        if before.get("before_ms") is not None and before.get("after_ms") is not None:
325
            fh.write(
326
                "- Medians: "
327
                f"before {before.get('before_ms')} ms, after {before.get('after_ms')} ms"
328
                f", delta {before.get('delta_pct')}%\n"
329
            )
330
        elif before.get("before_ms") is not None:
331
            fh.write(f"- Current median: {before.get('before_ms')} ms\n")
332
        if entry.get("change_summary"):
333
            fh.write(f"- Change: {entry['change_summary']}\n")
334
        if entry.get("diagnosis"):
335
            fh.write(f"- Diagnosis: {entry['diagnosis']}\n")
336
        rejected = entry.get("levers_rejected") or []
337
        if rejected:
338
            fh.write("- Levers rejected:\n")
339
            for item in rejected:
340
                fh.write(f"  - {item}\n")
341
        fh.write("\n")
342
        fh.write(LEDGER_MARKER_START + "\n")
343
        fh.write(json.dumps(entry, indent=2, sort_keys=True) + "\n")
344
        fh.write(LEDGER_MARKER_END + "\n\n")
345
346
347
@dataclass(frozen=True)
348
class ABSample:
349
    label: str
350
    elapsed_ms: float
351
    returncode: int = 0
352
    stderr: str = ""
353
354
355
def run_interleaved_commands(
356
    before_command: Sequence[str],
357
    after_command: Sequence[str],
358
    *,
359
    cwd: Path,
360
    pairs: int,
361
    env: dict[str, str] | None = None,
362
) -> list[ABSample]:
363
    if pairs < 20:
364
        raise ValueError("interleaved A/B gate requires at least 20 before/after pairs")
365
    samples: list[ABSample] = []
366
    from oakbench.environment import host_home_environment
367
    run_env = os.environ.copy()
368
    if env:
369
        run_env.update(env)
370
    run_env = host_home_environment(run_env)
371
    for _ in range(pairs):
372
        for label, command in (("before", before_command), ("after", after_command)):
373
            start = time.perf_counter()
374
            proc = subprocess.run(
375
                list(command),
376
                cwd=cwd,
377
                env=run_env,
378
                stdout=subprocess.DEVNULL,
379
                stderr=subprocess.PIPE,
380
                text=True,
381
                check=False,
382
            )
383
            samples.append(
384
                ABSample(
385
                    label=label,
386
                    elapsed_ms=(time.perf_counter() - start) * 1000.0,
387
                    returncode=proc.returncode,
388
                    stderr=(proc.stderr or "")[-1000:],
389
                )
390
            )
391
    return samples
392
393
394
def evaluate_interleaved_ab(
395
    samples: Sequence[ABSample],
396
    *,
397
    min_pairs: int = 20,
398
    min_improvement_pct: float = 3.0,
399
) -> dict[str, Any]:
400
    if len(samples) < min_pairs * 2:
401
        return {
402
            "passed": False,
403
            "reason": f"need at least {min_pairs} before/after pairs",
404
            "sample_count": len(samples),
405
        }
406
    for index, sample in enumerate(samples):
407
        expected = "before" if index % 2 == 0 else "after"
408
        if sample.label != expected:
409
            return {"passed": False, "reason": "samples are not strictly alternating before/after"}
410
        if sample.returncode != 0:
411
            return {
412
                "passed": False,
413
                "reason": f"{sample.label} command failed at sample {index}: {sample.stderr}",
414
            }
415
    before_values = [sample.elapsed_ms for sample in samples if sample.label == "before"]
416
    after_values = [sample.elapsed_ms for sample in samples if sample.label == "after"]
417
    if len(before_values) < min_pairs or len(after_values) < min_pairs:
418
        return {"passed": False, "reason": f"need {min_pairs} samples per side"}
419
    before_median = float(statistics.median(before_values))
420
    after_median = float(statistics.median(after_values))
421
    before_p25 = percentile(before_values, 0.25)
422
    after_p25 = percentile(after_values, 0.25)
423
    delta_pct = ((after_median - before_median) / before_median) * 100.0 if before_median else 0.0
424
    median_improved = delta_pct <= -min_improvement_pct
425
    consistency_improved = after_p25 < before_p25
426
    if median_improved and consistency_improved:
427
        reason = "target_metric_improved"
428
    elif median_improved:
429
        reason = "target_metric_improvement_not_consistent"
430
    else:
431
        reason = "target_metric_did_not_improve"
432
    return {
433
        "passed": median_improved and consistency_improved,
434
        "reason": reason,
435
        "before_ms": round(before_median, 3),
436
        "after_ms": round(after_median, 3),
437
        "delta_pct": round(delta_pct, 3),
438
        "before_p25_ms": round(before_p25, 3),
439
        "after_p25_ms": round(after_p25, 3),
440
        "before_n": len(before_values),
441
        "after_n": len(after_values),
442
    }
443
444
445
def run_correctness_gate(oak_repo: Path, *, skip: bool = False) -> list[dict[str, Any]]:
446
    if skip:
447
        return [{"command": "skip", "returncode": 0, "skipped": True}]
448
    commands = [
449
        ["cargo", "build", "-p", "oakvcs-cli"],
450
        ["cargo", "test", "-p", "oakvcs-cli"],
451
        ["cargo", "clippy", "-p", "oakvcs-cli", "--all-targets", "--", "-D", "warnings"],
452
    ]
453
    results: list[dict[str, Any]] = []
454
    for command in commands:
455
        proc = subprocess.run(
456
            command,
457
            cwd=oak_repo,
458
            stdout=subprocess.PIPE,
459
            stderr=subprocess.STDOUT,
460
            text=True,
461
            check=False,
462
        )
463
        results.append(
464
            {
465
                "command": command,
466
                "returncode": proc.returncode,
467
                "output_tail": (proc.stdout or "")[-4000:],
468
            }
469
        )
470
        if proc.returncode != 0:
471
            break
472
    return results
473
474
475
def correctness_passed(results: Sequence[dict[str, Any]]) -> bool:
476
    return all(int(result.get("returncode", 1)) == 0 for result in results)
477
478
479
def percentile(values: Sequence[float], fraction: float) -> float:
480
    if not values:
481
        raise ValueError("percentile requires at least one value")
482
    if not 0.0 <= fraction <= 1.0:
483
        raise ValueError("percentile fraction must be between 0 and 1")
484
    ordered = sorted(values)
485
    if len(ordered) == 1:
486
        return float(ordered[0])
487
    position = (len(ordered) - 1) * fraction
488
    lower = int(position)
489
    upper = min(lower + 1, len(ordered) - 1)
490
    weight = position - lower
491
    return float((ordered[lower] * (1.0 - weight)) + (ordered[upper] * weight))
492
493
494
def verification_entry(
495
    *,
496
    target: dict[str, Any],
497
    diagnosis: str,
498
    change_summary: str,
499
    correctness_results: Sequence[dict[str, Any]],
500
    ab_result: dict[str, Any],
501
    oak_gate_verdict: str = "MISSING",
502
    fleet_gate_verdict: str = "MISSING",
503
    levers_rejected: Sequence[str] = (),
504
    propose_mode: bool = True,
505
) -> dict[str, Any]:
506
    if not diagnosis.strip():
507
        status = "parked"
508
        reason = "missing_diagnosis_measurements"
509
    elif not correctness_passed(correctness_results):
510
        status = "parked"
511
        reason = "correctness_gate_failed"
512
    elif not ab_result.get("passed"):
513
        status = "parked"
514
        reason = str(ab_result.get("reason") or "interleaved_ab_failed")
515
    elif oak_gate_verdict != "PASS":
516
        status = "parked"
517
        reason = "oak_efficiency_gate_regressed"
518
    elif fleet_gate_verdict != "PASS":
519
        status = "parked"
520
        reason = "fleet_reliability_gate_regressed"
521
    elif propose_mode:
522
        status = "parked"
523
        reason = "verified_propose_mode_human_review_required"
524
    else:
525
        status = "landed"
526
        reason = "verified"
527
    return {
528
        "schema_version": 1,
529
        "timestamp": datetime.now(timezone.utc).isoformat(),
530
        "target": {
531
            "lane": target.get("lane"),
532
            "profile": target.get("profile"),
533
            "scenario": target.get("scenario"),
534
            "operation": target.get("operation"),
535
            "ratio": target.get("ratio"),
536
            "oak_median_ms": target.get("oak_median_ms"),
537
            "git_median_ms": target.get("git_median_ms"),
538
        },
539
        "diagnosis": diagnosis,
540
        "change_summary": change_summary,
541
        "before_after_medians": {
542
            "before_ms": ab_result.get("before_ms"),
543
            "after_ms": ab_result.get("after_ms"),
544
            "delta_pct": ab_result.get("delta_pct"),
545
        },
546
        "correctness_gate": list(correctness_results),
547
        "oak_efficiency_gate": oak_gate_verdict,
548
        "fleet_reliability_gate": fleet_gate_verdict,
549
        "levers_rejected": list(levers_rejected),
550
        "landed_or_parked": status,
551
        "reason": reason,
552
    }
553
554
555
def dry_run_entry(target: dict[str, Any]) -> dict[str, Any]:
556
    return verification_entry(
557
        target=target,
558
        diagnosis="dry-run: target selection and ledger plumbing exercised; no Rust edit attempted",
559
        change_summary="dry-run parked candidate",
560
        correctness_results=[{"command": "dry-run", "returncode": 0}],
561
        ab_result={
562
            "passed": False,
563
            "reason": "dry_run_no_candidate_branch",
564
            "before_ms": target.get("oak_median_ms"),
565
            "after_ms": None,
566
            "delta_pct": None,
567
        },
568
        levers_rejected=[],
569
    )