Viewing files at commit b4b51d4daa88Commit detailsView latest

scripts/oakbench/perf_loop.py 567 lines · 20.1 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
    run_env = os.environ.copy()
367
    if env:
368
        run_env.update(env)
369
    for _ in range(pairs):
370
        for label, command in (("before", before_command), ("after", after_command)):
371
            start = time.perf_counter()
372
            proc = subprocess.run(
373
                list(command),
374
                cwd=cwd,
375
                env=run_env,
376
                stdout=subprocess.DEVNULL,
377
                stderr=subprocess.PIPE,
378
                text=True,
379
                check=False,
380
            )
381
            samples.append(
382
                ABSample(
383
                    label=label,
384
                    elapsed_ms=(time.perf_counter() - start) * 1000.0,
385
                    returncode=proc.returncode,
386
                    stderr=(proc.stderr or "")[-1000:],
387
                )
388
            )
389
    return samples
390
391
392
def evaluate_interleaved_ab(
393
    samples: Sequence[ABSample],
394
    *,
395
    min_pairs: int = 20,
396
    min_improvement_pct: float = 3.0,
397
) -> dict[str, Any]:
398
    if len(samples) < min_pairs * 2:
399
        return {
400
            "passed": False,
401
            "reason": f"need at least {min_pairs} before/after pairs",
402
            "sample_count": len(samples),
403
        }
404
    for index, sample in enumerate(samples):
405
        expected = "before" if index % 2 == 0 else "after"
406
        if sample.label != expected:
407
            return {"passed": False, "reason": "samples are not strictly alternating before/after"}
408
        if sample.returncode != 0:
409
            return {
410
                "passed": False,
411
                "reason": f"{sample.label} command failed at sample {index}: {sample.stderr}",
412
            }
413
    before_values = [sample.elapsed_ms for sample in samples if sample.label == "before"]
414
    after_values = [sample.elapsed_ms for sample in samples if sample.label == "after"]
415
    if len(before_values) < min_pairs or len(after_values) < min_pairs:
416
        return {"passed": False, "reason": f"need {min_pairs} samples per side"}
417
    before_median = float(statistics.median(before_values))
418
    after_median = float(statistics.median(after_values))
419
    before_p25 = percentile(before_values, 0.25)
420
    after_p25 = percentile(after_values, 0.25)
421
    delta_pct = ((after_median - before_median) / before_median) * 100.0 if before_median else 0.0
422
    median_improved = delta_pct <= -min_improvement_pct
423
    consistency_improved = after_p25 < before_p25
424
    if median_improved and consistency_improved:
425
        reason = "target_metric_improved"
426
    elif median_improved:
427
        reason = "target_metric_improvement_not_consistent"
428
    else:
429
        reason = "target_metric_did_not_improve"
430
    return {
431
        "passed": median_improved and consistency_improved,
432
        "reason": reason,
433
        "before_ms": round(before_median, 3),
434
        "after_ms": round(after_median, 3),
435
        "delta_pct": round(delta_pct, 3),
436
        "before_p25_ms": round(before_p25, 3),
437
        "after_p25_ms": round(after_p25, 3),
438
        "before_n": len(before_values),
439
        "after_n": len(after_values),
440
    }
441
442
443
def run_correctness_gate(oak_repo: Path, *, skip: bool = False) -> list[dict[str, Any]]:
444
    if skip:
445
        return [{"command": "skip", "returncode": 0, "skipped": True}]
446
    commands = [
447
        ["cargo", "build", "-p", "oakvcs-cli"],
448
        ["cargo", "test", "-p", "oakvcs-cli"],
449
        ["cargo", "clippy", "-p", "oakvcs-cli", "--all-targets", "--", "-D", "warnings"],
450
    ]
451
    results: list[dict[str, Any]] = []
452
    for command in commands:
453
        proc = subprocess.run(
454
            command,
455
            cwd=oak_repo,
456
            stdout=subprocess.PIPE,
457
            stderr=subprocess.STDOUT,
458
            text=True,
459
            check=False,
460
        )
461
        results.append(
462
            {
463
                "command": command,
464
                "returncode": proc.returncode,
465
                "output_tail": (proc.stdout or "")[-4000:],
466
            }
467
        )
468
        if proc.returncode != 0:
469
            break
470
    return results
471
472
473
def correctness_passed(results: Sequence[dict[str, Any]]) -> bool:
474
    return all(int(result.get("returncode", 1)) == 0 for result in results)
475
476
477
def percentile(values: Sequence[float], fraction: float) -> float:
478
    if not values:
479
        raise ValueError("percentile requires at least one value")
480
    if not 0.0 <= fraction <= 1.0:
481
        raise ValueError("percentile fraction must be between 0 and 1")
482
    ordered = sorted(values)
483
    if len(ordered) == 1:
484
        return float(ordered[0])
485
    position = (len(ordered) - 1) * fraction
486
    lower = int(position)
487
    upper = min(lower + 1, len(ordered) - 1)
488
    weight = position - lower
489
    return float((ordered[lower] * (1.0 - weight)) + (ordered[upper] * weight))
490
491
492
def verification_entry(
493
    *,
494
    target: dict[str, Any],
495
    diagnosis: str,
496
    change_summary: str,
497
    correctness_results: Sequence[dict[str, Any]],
498
    ab_result: dict[str, Any],
499
    oak_gate_verdict: str = "MISSING",
500
    fleet_gate_verdict: str = "MISSING",
501
    levers_rejected: Sequence[str] = (),
502
    propose_mode: bool = True,
503
) -> dict[str, Any]:
504
    if not diagnosis.strip():
505
        status = "parked"
506
        reason = "missing_diagnosis_measurements"
507
    elif not correctness_passed(correctness_results):
508
        status = "parked"
509
        reason = "correctness_gate_failed"
510
    elif not ab_result.get("passed"):
511
        status = "parked"
512
        reason = str(ab_result.get("reason") or "interleaved_ab_failed")
513
    elif oak_gate_verdict != "PASS":
514
        status = "parked"
515
        reason = "oak_efficiency_gate_regressed"
516
    elif fleet_gate_verdict != "PASS":
517
        status = "parked"
518
        reason = "fleet_reliability_gate_regressed"
519
    elif propose_mode:
520
        status = "parked"
521
        reason = "verified_propose_mode_human_review_required"
522
    else:
523
        status = "landed"
524
        reason = "verified"
525
    return {
526
        "schema_version": 1,
527
        "timestamp": datetime.now(timezone.utc).isoformat(),
528
        "target": {
529
            "lane": target.get("lane"),
530
            "profile": target.get("profile"),
531
            "scenario": target.get("scenario"),
532
            "operation": target.get("operation"),
533
            "ratio": target.get("ratio"),
534
            "oak_median_ms": target.get("oak_median_ms"),
535
            "git_median_ms": target.get("git_median_ms"),
536
        },
537
        "diagnosis": diagnosis,
538
        "change_summary": change_summary,
539
        "before_after_medians": {
540
            "before_ms": ab_result.get("before_ms"),
541
            "after_ms": ab_result.get("after_ms"),
542
            "delta_pct": ab_result.get("delta_pct"),
543
        },
544
        "correctness_gate": list(correctness_results),
545
        "oak_efficiency_gate": oak_gate_verdict,
546
        "fleet_reliability_gate": fleet_gate_verdict,
547
        "levers_rejected": list(levers_rejected),
548
        "landed_or_parked": status,
549
        "reason": reason,
550
    }
551
552
553
def dry_run_entry(target: dict[str, Any]) -> dict[str, Any]:
554
    return verification_entry(
555
        target=target,
556
        diagnosis="dry-run: target selection and ledger plumbing exercised; no Rust edit attempted",
557
        change_summary="dry-run parked candidate",
558
        correctness_results=[{"command": "dry-run", "returncode": 0}],
559
        ab_result={
560
            "passed": False,
561
            "reason": "dry_run_no_candidate_branch",
562
            "before_ms": target.get("oak_median_ms"),
563
            "after_ms": None,
564
            "delta_pct": None,
565
        },
566
        levers_rejected=[],
567
    )