|
| 1 | +# Perf Smoke Gate — Architecture |
| 2 | + |
| 3 | +This document explains *how* the gate is put together: the data flow, the |
| 4 | +baseline/history model, and the verdict logic. For a plain-English file-by-file |
| 5 | +tour, see [`README.md`](README.md). |
| 6 | + |
| 7 | +## Design goals |
| 8 | + |
| 9 | +1. **Cheap and per-PR.** A handful of short, stable tasks on a fixed GPU — fast |
| 10 | + enough to run on every pull request. |
| 11 | +2. **Robust to noise.** Small run-to-run wobble must not flake the gate; only a |
| 12 | + real, sustained drop blocks. |
| 13 | +3. **In-tree and reviewed.** Baselines and history live in the repo and only |
| 14 | + change through a normal, reviewed PR — never a silent side-write from CI. This |
| 15 | + keeps the gate auditable and lets `git blame` answer "why did the bar move?". |
| 16 | +4. **No new dependencies.** Everything is standard-library Python so the |
| 17 | + comparator and its tests run on any runner without Isaac Sim or a GPU. |
| 18 | + |
| 19 | +## Components |
| 20 | + |
| 21 | +| Layer | Module | Responsibility | |
| 22 | +|---|---|---| |
| 23 | +| Orchestration | `run_perf_gate.py` | Per task: resolve launch config from `baseline.json`, launch the benchmark as its own Isaac Sim subprocess (retry once), hand the result to the comparator, aggregate verdicts (worst wins). | |
| 24 | +| Decision (pure logic) | `check_perf_regression.py` | Read one benchmark result + baseline + rolling window, compute the KPI, and return PASS / WARN / BLOCK. No GPU, no Isaac Sim. | |
| 25 | +| Stored state | `baseline.json`, `perf_history/`, `baseline_overrides.json` | The launch config + static fallback, the rolling window of recent samples, and manual threshold overrides. | |
| 26 | +| Maintenance | `rebaseline.py`, `seed_history.py` | Produce/refresh the stored state from fresh or existing runs (always via a reviewed PR). | |
| 27 | + |
| 28 | +## Data flow (one PR) |
| 29 | + |
| 30 | +``` |
| 31 | +baseline.json ─┐ |
| 32 | + ├─► run_perf_gate ─► benchmark_non_rl.py (subprocess) ─► result.json |
| 33 | +perf_history/ ─┤ │ |
| 34 | +overrides ─────┘ ▼ |
| 35 | + check_perf_regression ◄─────────────────┘ |
| 36 | + │ |
| 37 | + RESULT=PASS|WARN|BLOCK + $GITHUB_STEP_SUMMARY table |
| 38 | +``` |
| 39 | + |
| 40 | +The orchestrator only builds commands and aggregates; **all** of the regression |
| 41 | +judgement lives in the comparator, which is why the comparator is independently |
| 42 | +unit-testable without hardware. |
| 43 | + |
| 44 | +## The KPI |
| 45 | + |
| 46 | +The gating metric is the **post-warm-up steady FPS** (`steady_fps`): the |
| 47 | +benchmark's per-frame effective-FPS array with the first `warmup_frames` dropped. |
| 48 | +Using the same statistic the backend already reports — just windowed — keeps the |
| 49 | +measured value directly comparable to the stored history. Wall-clock seconds are |
| 50 | +carried as a secondary, advisory signal only. |
| 51 | + |
| 52 | +## Baseline & history model |
| 53 | + |
| 54 | +There are two stores, deliberately layered: |
| 55 | + |
| 56 | +- **Rolling window (`perf_history/`, primary).** Per `(task, GPU)`, the last |
| 57 | + *N* known-good samples. The comparator computes its threshold *at test time* |
| 58 | + from this window with a robust **median + MAD** estimator: |
| 59 | + |
| 60 | + ``` |
| 61 | + center = median(window) |
| 62 | + spread = max(1.4826 * MAD(window), min_spread_pct/100 * center) |
| 63 | + WARN when measured < center - k_warn * spread |
| 64 | + BLOCK when measured < center - k_block * spread |
| 65 | + ``` |
| 66 | + |
| 67 | + A `min_spread_pct` floor stops a very low-variance task from blocking on |
| 68 | + trivial dips. |
| 69 | + |
| 70 | +- **Static fallback (`baseline.json`, secondary).** When the window is too small |
| 71 | + to trust (`< MIN_WINDOW` samples), the comparator falls back to a static |
| 72 | + `baseline_fps` + percentage bands calibrated for that task/GPU. This keeps a |
| 73 | + fresh store from silently passing everything before it has accumulated history. |
| 74 | + |
| 75 | +**Overrides** (`baseline_overrides.json`) are a manual escape hatch keyed by |
| 76 | +*stable* test identity (`task` + GPU), applied on top of either source — used for |
| 77 | +one-off threshold relaxations or `skip` that ride along in the PR. |
| 78 | + |
| 79 | +### Environment fingerprint buckets |
| 80 | + |
| 81 | +Performance is only comparable within the same software stack: a Warp bump or an |
| 82 | +Isaac Sim upgrade can legitimately shift FPS, and mixing those samples into one |
| 83 | +window would corrupt the baseline. So history is **bucketed by an environment |
| 84 | +fingerprint**: |
| 85 | + |
| 86 | +``` |
| 87 | +perf_history/ |
| 88 | + <task>__<gpu>.json # flat "default" bucket (legacy / no provenance) |
| 89 | + env-<hash>/<task>__<gpu>.json # one bucket per (warp, isaaclab, cuda) stack |
| 90 | +``` |
| 91 | + |
| 92 | +- `env_fingerprint(result)` hashes the environment-defining provenance |
| 93 | + (`warp`, `isaaclab`, `cuda`) into a short, stable `env-<hash>` key. GPU is |
| 94 | + *not* in the hash because it is already in the file name. |
| 95 | +- The comparator derives the fingerprint from the run under test and reads the |
| 96 | + matching bucket, **falling back to the flat file** when no bucket exists yet — |
| 97 | + so the change is backward-compatible with already-seeded flat history. |
| 98 | +- A consistent filename (`history_basename`) is shared by the reader and every |
| 99 | + writer so a written bucket is always found again. |
| 100 | + |
| 101 | +### Per-sample provenance |
| 102 | + |
| 103 | +Every stored sample carries the context needed to audit or re-bucket it without |
| 104 | +re-running the benchmark: `commit`, `warp`, `isaaclab`, `cuda`, plus the |
| 105 | +`fingerprint` recorded at the window level. This makes the in-tree history |
| 106 | +self-describing — a reviewer reading a `perf_history/` diff can see exactly which |
| 107 | +commit and stack produced each number. |
| 108 | + |
| 109 | +## Re-baselining lifecycle |
| 110 | + |
| 111 | +`rebaseline.py` is the only writer of the stored state and serves two jobs from |
| 112 | +one measurement path: |
| 113 | + |
| 114 | +1. **Variance study (default).** Run each task `--repeat` times and report robust |
| 115 | + stats (median / CV / MAD / min / max) so thresholds can be justified to |
| 116 | + reviewers. |
| 117 | +2. **Rolling re-baseline (`--apply`).** Append the new samples to the window |
| 118 | + (pruned to a cap, stamped with provenance, written into the env bucket) and |
| 119 | + refresh the static fallback in `baseline.json`. |
| 120 | + |
| 121 | +A **boiling-frog guard** keeps a rolling baseline from quietly absorbing a real |
| 122 | +regression: a task whose new median drops the baseline by more than |
| 123 | +`--soft-drop-pct` is *flagged for review*; a drop beyond `--hard-drop-pct` is |
| 124 | +*refused* (old value kept) unless `--force`. Both stores then change only through |
| 125 | +the PR that `perf-rebaseline.yml` opens. |
| 126 | + |
| 127 | +## CI wiring |
| 128 | + |
| 129 | +- `perf-gate.yml` — on a PR it runs a fast, GPU-free **unit-test job** (comparator, |
| 130 | + orchestrator, rebaseline, fingerprint helpers) for early signal, then a |
| 131 | + per-task GPU matrix on the L40S fleet that posts the verdict back. Advisory |
| 132 | + (`continue-on-error`) until cross-runner variance is confirmed. |
| 133 | +- `perf-rebaseline.yml` — manual workflow that runs `rebaseline.py --apply` on the |
| 134 | + fleet and opens a PR with the `baseline.json` + `perf_history/` diff. |
| 135 | + |
| 136 | +## Why these boundaries |
| 137 | + |
| 138 | +- **Pure-logic comparator** ⇒ the regression rules are fully testable on any |
| 139 | + runner; the GPU job only *produces* numbers, it never *decides*. |
| 140 | +- **In-tree, reviewed state** ⇒ no opaque external baseline service; every change |
| 141 | + to the bar is a diff someone approved. |
| 142 | +- **Layered window → static → override** ⇒ robust thresholds when history exists, |
| 143 | + a safe floor when it doesn't, and a human escape hatch when neither fits. |
0 commit comments