Skip to content

Commit 1eb6f22

Browse files
authored
Publish the golden-record history of golden-change PRs on gh-pages (#285)
* Publish the golden-record history of golden-change PRs on gh-pages A golden-change PR's merge-queue run records run time, peak memory and the relative changes against its merge base. When the PR lands, the record joins gh-pages, and the site is redrawn: changes, paired run time and memory over the commit date, with the events listed below. * Describe the golden-record history in CONTRIBUTING
1 parent 95fa9d1 commit 1eb6f22

5 files changed

Lines changed: 330 additions & 0 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: Golden-record history
2+
3+
# A golden-change PR leaves a record of its merge-queue run (ci/golden.sh).
4+
# When the PR lands, the record joins the history site on gh-pages.
5+
on:
6+
push:
7+
branches: [main]
8+
9+
permissions:
10+
contents: write
11+
actions: read
12+
pull-requests: read
13+
concurrency:
14+
group: golden-history
15+
cancel-in-progress: false
16+
17+
jobs:
18+
append-record:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- name: Find the golden-change PR and its merge-queue record
22+
env:
23+
GH_TOKEN: ${{ github.token }}
24+
run: |
25+
gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
26+
--jq '[.[] | select(any(.labels[]; .name == "golden-change"))][0] // empty' > "$RUNNER_TEMP/pr.json"
27+
if [ ! -s "$RUNNER_TEMP/pr.json" ]; then
28+
echo "No golden-change PR behind $GITHUB_SHA"
29+
exit 0
30+
fi
31+
# The merge queue lands the commit it tested, so its run has this SHA.
32+
run=$(gh run list -R "$GITHUB_REPOSITORY" --workflow test-on-pr.yml --event merge_group \
33+
--commit "$GITHUB_SHA" --status success --json databaseId --jq '.[0].databaseId')
34+
gh run download "$run" -R "$GITHUB_REPOSITORY" -p 'golden-record-*' -D "$RUNNER_TEMP/record"
35+
record=$(find "$RUNNER_TEMP/record" -name history-record.json | head -n 1)
36+
if [ -z "$record" ]; then
37+
echo "No history record in run $run"
38+
exit 0
39+
fi
40+
echo "RECORD=$record" >> "$GITHUB_ENV"
41+
- name: Check out main
42+
if: env.RECORD
43+
uses: actions/checkout@v6
44+
with:
45+
path: main
46+
persist-credentials: false
47+
- name: Check out gh-pages
48+
if: env.RECORD
49+
run: |
50+
url="https://x-access-token:${{ github.token }}@github.com/$GITHUB_REPOSITORY.git"
51+
if git ls-remote --exit-code --heads "$url" gh-pages > /dev/null; then
52+
git clone --quiet --branch gh-pages --single-branch "$url" site
53+
else
54+
git init --quiet -b gh-pages site
55+
git -C site remote add origin "$url"
56+
fi
57+
- name: Add the record and render the site
58+
if: env.RECORD
59+
run: |
60+
python3 -m pip install --quiet numpy h5py matplotlib
61+
python3 -m pip install --quiet --no-deps ./main/python
62+
python3 - "$RECORD" "$RUNNER_TEMP/pr.json" <<'PY'
63+
import json, os, sys
64+
record = json.load(open(sys.argv[1]))
65+
pr = json.load(open(sys.argv[2]))
66+
record["event"] = {"source": "neo2", "pr": pr["number"], "description": pr["title"],
67+
"classification": "intended"}
68+
name = f"site/records/{record['date'][:10]}-{record['neo2'][:10]}.json"
69+
os.makedirs("site/records", exist_ok=True)
70+
json.dump(record, open(name, "w"), indent=1)
71+
PY
72+
python3 -m neo2_util.golden_history render site/records/*.json --out site
73+
cd site
74+
git config user.name github-actions
75+
git config user.email github-actions@github.com
76+
git add records index.html ./*.png
77+
git commit --quiet -m "Add the golden-change record of #$(jq -r .number "$RUNNER_TEMP/pr.json")"
78+
git push --quiet origin gh-pages

‎CONTRIBUTING.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,28 @@ How a PR that changes results is handled depends on how far they move:
5252
cut a release soon after, so that the stable comparison is green again for
5353
other PRs. In a stack, each layer that changes results brings its own data
5454
branch and records.
55+
- Every `golden-change` queue run measures run time, peak memory per MPI rank
56+
and the relative change of every output against the base; once the PR
57+
lands, this record joins the history site on the `gh-pages` branch.
5558

5659
Without the label every comparison stays strict, and the stored records come
5760
from the data branch named like the PR's head branch if it exists, otherwise
5861
from data `main`.
62+
63+
## Golden-record history
64+
65+
One command builds a NEO-2 revision with a libneo revision (by default its
66+
pin, or before the pin the libneo `main` of the commit date), runs the
67+
golden-record cases and writes one record:
68+
69+
python -m neo2_util.golden_replay run --neo2 REV --work WORK \
70+
--data DATA/TESTS/NEO-2/golden_record --out OUT \
71+
[--paired-build MAIN_TREE --concurrent]
72+
73+
`OUT/record.json` holds the run times, peak memory per MPI rank and, after
74+
`golden_replay compare OUT OTHER --label previous|main`, the relative changes
75+
of every compared output. It contains no absolute outputs and may be
76+
published. `OUT/<case>/summary.h5` holds the outputs and stays with the
77+
golden-record data. `python -m neo2_util.golden_changes events` lists the
78+
record refreshes and tolerance changes, and `golden_history render` writes
79+
the site.

‎ci/golden.sh‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ check par env TOL=1e-15 LEGACY_TOL=4e-8 make par
7676
differences par par TOL=1e-15 LEGACY_TOL=4e-8
7777
python -m neo2_util.golden_measure summary ci-out/runs/*.ranks > ci-out/performance.tsv ||
7878
echo 'No run-time summary' >&2
79+
# A golden-change PR leaves its record for the history site (golden-history.yml).
80+
if [ "${SCI_GOLDEN_CHANGE:-0}" = 1 ]; then
81+
python -m neo2_util.golden_history collect --data "$test_dir" --build "$src" \
82+
--reference-build "$root/base" --runs ci-out/runs --out ci-out/history-record.json ||
83+
echo 'No history record for this golden change' >&2
84+
fi
7985
if [ -f "$test_dir/performance_ql/performance_report.txt" ]; then
8086
cp "$test_dir/performance_ql/performance_report.txt" ci-out/
8187
fi
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Golden-record history site: the record of a CI golden change and the plots.
2+
3+
A golden-change PR leaves a record of its merge-queue run (collect); records
4+
of replayed commits come from golden_replay. render draws relative changes,
5+
paired run time and peak memory over the commit date and lists the events.
6+
"""
7+
8+
import argparse
9+
import datetime
10+
import json
11+
import statistics
12+
import sys
13+
from pathlib import Path
14+
15+
from .golden_changes import relative_changes
16+
from .golden_measure import measurements
17+
from .golden_replay import describe
18+
19+
20+
def collect(data, source, reference, runs, out):
21+
"""Public record of a golden-record run whose solvers ran through wrap().
22+
23+
The current runs log to runs/current.ranks, the runs of the merge base in
24+
the same job to runs/base.ranks, and the case directories in data hold
25+
the outputs of both.
26+
"""
27+
result = describe(source)
28+
result["reference"] = describe(reference)["neo2"]
29+
result["cases"] = {}
30+
result["changes"] = {"previous": {}, "main": {}}
31+
current = measurements(Path(runs) / "current.ranks")
32+
paired = measurements(Path(runs) / "base.ranks")
33+
for case, entry in sorted(current.items()):
34+
summary = Path(data) / case / "current" / "summary.h5"
35+
entry["status"] = "ok" if entry.pop("exit") == 0 and summary.exists() else "failed"
36+
if case in paired:
37+
entry["reference_wall_s"] = paired[case]["wall_s"]
38+
entry["reference_peak_rss_mib"] = paired[case]["peak_rss_mib"]
39+
base = Path(data) / case / "reference" / "summary.h5"
40+
if summary.exists() and base.exists():
41+
# The reference is main just before the change, so it is the
42+
# previous event and main at once.
43+
changes = relative_changes(base, summary)
44+
result["changes"]["previous"][case] = result["changes"]["main"][case] = changes
45+
result["cases"][case] = entry
46+
Path(out).write_text(json.dumps(result, indent=1))
47+
return result
48+
49+
50+
def render(records, out):
51+
"""Write the public site: relative changes, run time and memory over time."""
52+
import matplotlib
53+
matplotlib.use("Agg")
54+
import matplotlib.pyplot as plt
55+
56+
out = Path(out)
57+
out.mkdir(parents=True, exist_ok=True)
58+
rows = sorted((json.loads(Path(p).read_text()) for p in records), key=lambda r: r["date"])
59+
dates = {id(r): datetime.datetime.fromisoformat(r["date"]) for r in rows}
60+
cases = sorted({c for r in rows for c in r.get("cases", {})})
61+
# Paired times: ratio to the reference run on the same node, times the
62+
# median reference time of the case, which removes drifts in node speed.
63+
ref = {c: statistics.median([r["cases"][c]["reference_wall_s"] for r in rows
64+
if c in r.get("cases", {}) and "reference_wall_s" in r["cases"][c]]
65+
or [1.0]) for c in cases}
66+
67+
def change(case, entry, r, label):
68+
values = r.get("changes", {}).get(label, {}).get(case)
69+
numbers = [v for v in (values or {}).values() if v is not None]
70+
return max(max(numbers, default=0.0), 1e-17) if values else None
71+
72+
def wall(case, entry, r):
73+
if entry.get("reference_wall_s"):
74+
return entry["wall_s"] / entry["reference_wall_s"] * ref[case]
75+
return entry["wall_s"]
76+
77+
panels = [
78+
("changes.png", "Largest relative change against the previous event", "relative change", True,
79+
lambda c, e, r: change(c, e, r, "previous")),
80+
("changes_main.png", "Largest relative change against current main", "relative change", True,
81+
lambda c, e, r: change(c, e, r, "main")),
82+
("wall_time.png", "Wall time, paired against a reference on the same node", "wall time (s)",
83+
False, wall),
84+
("memory.png", "Peak resident memory of the largest MPI rank", "peak memory (MiB)", False,
85+
lambda c, e, r: max(e["peak_rss_mib"], default=None)),
86+
]
87+
for name, title, ylabel, log, value in panels:
88+
fig, ax = plt.subplots(figsize=(9, 4))
89+
for case in cases:
90+
points = [(dates[id(r)], value(case, r["cases"][case], r), r) for r in rows
91+
if r.get("cases", {}).get(case, {}).get("status") == "ok"]
92+
points = [p for p in points if p[1] is not None]
93+
if not points:
94+
continue
95+
line, = ax.plot([p[0] for p in points], [p[1] for p in points], "-", lw=0.8, label=case)
96+
for when, y, r in points:
97+
source = r.get("event", {}).get("source")
98+
if source == "libneo":
99+
ax.plot(when, y, "^", color=line.get_color(), mfc="none")
100+
elif source:
101+
ax.plot(when, y, "o", color=line.get_color())
102+
for r in rows:
103+
if r["build"] != "ok" or any(c.get("status") != "ok" for c in r["cases"].values()):
104+
ax.axvline(dates[id(r)], color="0.6", ls=":", lw=0.8)
105+
if log:
106+
ax.set_yscale("log")
107+
ax.set_title(title)
108+
ax.set_xlabel("commit date")
109+
ax.set_ylabel(ylabel)
110+
if ax.has_data():
111+
ax.legend(fontsize="small", ncol=len(cases))
112+
fig.autofmt_xdate()
113+
fig.tight_layout()
114+
fig.savefig(out / name, dpi=120)
115+
plt.close(fig)
116+
_write_html(rows, out)
117+
118+
119+
def _write_html(rows, out):
120+
import html
121+
repo = "https://github.com/itpplasma/NEO-2"
122+
body = []
123+
for r in reversed(rows):
124+
event = r.get("event", {})
125+
failed = [c for c, e in r["cases"].items() if e.get("status") != "ok"]
126+
status = "build failed" if r["build"] != "ok" else (
127+
"failed: " + ", ".join(failed) if failed else "ok")
128+
pr = event.get("pr")
129+
issue = event.get("issue")
130+
cells = [r["date"][:10], html.escape(event.get("source", "")),
131+
f'<a href="{repo}/commit/{r["neo2"]}">{r["neo2"][:8]}</a>'
132+
+ (f' (<a href="{repo}/pull/{pr}">#{pr}</a>)' if pr else ""),
133+
f'<a href="https://github.com/itpplasma/libneo/commit/{r["libneo"]}">{r["libneo"][:8]}</a>'
134+
if r.get("libneo") else "",
135+
html.escape(event.get("description", r.get("subject", ""))),
136+
html.escape(event.get("classification", ""))
137+
+ (f' (<a href="{repo}/issues/{issue}">#{issue}</a>)' if issue else ""),
138+
status]
139+
body.append("<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>")
140+
figures = "".join(f'<img src="{n}" alt="{n[:-4]}">' for n in
141+
("changes.png", "changes_main.png", "wall_time.png", "memory.png"))
142+
(out / "index.html").write_text(f"""<!doctype html>
143+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
144+
<title>NEO-2 golden-record history</title>
145+
<style>body{{font-family:sans-serif;max-width:60rem;margin:auto;padding:0 1rem}}
146+
img{{max-width:100%}}table{{border-collapse:collapse;font-size:90%}}
147+
td,th{{border-bottom:1px solid #ccc;padding:.2rem .4rem;text-align:left;vertical-align:top}}</style>
148+
</head><body><h1>NEO-2 golden-record history</h1>
149+
<p>Each row is a build of NEO-2 with the libneo it used, run on the golden-record test cases.
150+
Relative change is max|x &minus; x<sub>ref</sub>| / max|x<sub>ref</sub>| over each output dataset,
151+
largest over the datasets of a case. Filled circles mark NEO-2 events, open triangles libneo events,
152+
dotted lines builds or cases that failed.</p>
153+
{figures}
154+
<table><tr><th>date</th><th>event</th><th>NEO-2</th><th>libneo</th><th>change</th>
155+
<th>classification</th><th>status</th></tr>
156+
{''.join(body)}
157+
</table></body></html>
158+
""")
159+
160+
161+
def main(argv=None):
162+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
163+
sub = parser.add_subparsers(dest="command", required=True)
164+
p = sub.add_parser("collect", help="record of a golden-record CI run with measured solvers")
165+
p.add_argument("--data", required=True, help="golden_record directory the cases ran in")
166+
p.add_argument("--build", required=True, help="NEO-2 tree of the change")
167+
p.add_argument("--reference-build", required=True, help="NEO-2 tree of the merge base")
168+
p.add_argument("--runs", required=True, help="directory of current.ranks and base.ranks")
169+
p.add_argument("--out", required=True)
170+
p = sub.add_parser("render", help="write the site from record.json files")
171+
p.add_argument("records", nargs="+")
172+
p.add_argument("--out", required=True)
173+
a = parser.parse_args(argv)
174+
if a.command == "collect":
175+
collect(a.data, a.build, a.reference_build, a.runs, a.out)
176+
else:
177+
render(a.records, a.out)
178+
return 0
179+
180+
181+
if __name__ == "__main__":
182+
sys.exit(main())

‎python/test/test_golden_history.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import json
2+
import os
3+
import subprocess
4+
5+
from neo2_util import golden_history, golden_measure, golden_replay
6+
from test_golden_replay import fake_tree, toy_case
7+
8+
9+
def test_collect_measures_runs_of_the_golden_job(tmp_path):
10+
data = tmp_path / "golden"
11+
toy_case(data, ("current", "reference"))
12+
current = fake_tree(tmp_path / "current", 1)
13+
reference = fake_tree(tmp_path / "reference", 1)
14+
runs = tmp_path / "runs"
15+
runs.mkdir()
16+
for role, log, tree, result in (("current", "current", current, "2"),
17+
("reference", "base", reference, "4")):
18+
wrapper = golden_measure.wrap(runs / f"{log}.ranks", golden_replay.executable(tree, "ql"))
19+
env = dict(os.environ, CURRENT_NEO2_QL=str(wrapper), FAKE_RESULT=result)
20+
subprocess.run(["make", "-s", "-C", str(data / "toy" / role)], env=env, check=True)
21+
out = tmp_path / "record.json"
22+
golden_history.collect(data, current, reference, runs, out)
23+
record = json.loads(out.read_text())
24+
toy = record["cases"]["toy"]
25+
assert toy["status"] == "ok" and toy["reference_wall_s"] > 0
26+
changes = record["changes"]
27+
assert changes["previous"]["toy"]["/result"] == changes["main"]["toy"]["/result"] == 0.5
28+
29+
30+
def test_render_lists_events_and_draws_the_panels(tmp_path):
31+
record = {"neo2": "a" * 40, "libneo": "b" * 40, "date": "2026-11-01T00:00:00+00:00",
32+
"subject": "fake", "build": "ok",
33+
"cases": {"toy": {"status": "ok", "wall_s": 2.0, "reference_wall_s": 1.0,
34+
"peak_rss_mib": [300.0, 200.0]}},
35+
"changes": {"main": {"toy": {"/result": 1e-9}}},
36+
"event": {"source": "neo2", "pr": 1, "description": "toy change"}}
37+
path = tmp_path / "record.json"
38+
path.write_text(json.dumps(record))
39+
golden_history.render([path], tmp_path / "site")
40+
page = (tmp_path / "site" / "index.html").read_text()
41+
assert "toy change" in page and "pull/1" in page
42+
for name in ("changes.png", "changes_main.png", "wall_time.png", "memory.png"):
43+
assert (tmp_path / "site" / name).stat().st_size > 0

0 commit comments

Comments
 (0)