Skip to content

Commit 8977666

Browse files
[ci] Benchmark comment: show avg-latency deltas vs main (#2842)
1 parent 712ed61 commit 8977666

4 files changed

Lines changed: 182 additions & 2 deletions

File tree

.changeset/wise-planes-tell.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

.github/scripts/render-benchmark-comment.mjs

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* node render-benchmark-comment.mjs \
1414
* --status running|completed|failed \
1515
* [--results-dir <dir>] # dir with bench-results-*.json files
16+
* [--baseline-dir <dir>] # main-branch results to diff averages against
1617
* [--previous-body <file>] # previous comment body to carry history from
1718
* [--commit <sha>] [--run-url <url>] \
1819
* [--output <file>] # defaults to stdout
@@ -48,6 +49,7 @@ export function parseArgs(argv) {
4849
const args = {
4950
status: 'completed',
5051
resultsDir: undefined,
52+
baselineDir: undefined,
5153
previousBody: undefined,
5254
commit: undefined,
5355
runUrl: undefined,
@@ -66,6 +68,9 @@ export function parseArgs(argv) {
6668
case '--results-dir':
6769
args.resultsDir = next();
6870
break;
71+
case '--baseline-dir':
72+
args.baselineDir = next();
73+
break;
6974
case '--previous-body':
7075
args.previousBody = next();
7176
break;
@@ -149,6 +154,50 @@ function formatMs(value) {
149154
return `${Math.abs(value) >= 100 ? Math.round(value) : value}`;
150155
}
151156

157+
/**
158+
* Annotates each metric row with the matching baseline average (from the most
159+
* recent main-branch run), keyed by backend/app/metric/scenario. The
160+
* annotation is stored on the entry so history re-renders keep showing the
161+
* delta each run was originally compared against.
162+
*/
163+
export function annotateWithBaseline(results, baseline) {
164+
if (!baseline || baseline.length === 0) return results;
165+
const baselineAvgs = new Map();
166+
for (const result of baseline) {
167+
for (const row of result.metrics ?? []) {
168+
baselineAvgs.set(
169+
`${result.backend}/${result.app}/${row.metric}/${row.scenario}`,
170+
row.avg
171+
);
172+
}
173+
}
174+
return results.map((result) => ({
175+
...result,
176+
metrics: (result.metrics ?? []).map((row) => {
177+
const baselineAvg = baselineAvgs.get(
178+
`${result.backend}/${result.app}/${row.metric}/${row.scenario}`
179+
);
180+
return typeof baselineAvg === 'number' ? { ...row, baselineAvg } : row;
181+
}),
182+
}));
183+
}
184+
185+
/** Formats the avg-vs-main delta, e.g. " (+4.2%)"; empty without a baseline. */
186+
function formatDelta(current, baselineAvg) {
187+
if (
188+
typeof current !== 'number' ||
189+
typeof baselineAvg !== 'number' ||
190+
baselineAvg <= 0 ||
191+
!Number.isFinite(current / baselineAvg)
192+
) {
193+
return '';
194+
}
195+
const pct = ((current - baselineAvg) / baselineAvg) * 100;
196+
if (Math.abs(pct) < 0.5) return ' (±0%)';
197+
const digits = Math.abs(pct) >= 10 ? 0 : 1;
198+
return ` (${pct > 0 ? '+' : ''}${pct.toFixed(digits)}%)`;
199+
}
200+
152201
/**
153202
* Formats a percentile cell, marking it 🟢/🔴 against its target when one is
154203
* defined for this row.
@@ -182,7 +231,7 @@ function renderResultTable(result) {
182231
const name = label ? `**${label.name}**` : row.metric;
183232
const targets = row.targets ?? {};
184233
lines.push(
185-
`| ${name} | ${row.scenario} | ${formatMs(row.avg)} | ${formatCell(row.p75, targets.p75)} | ${formatCell(row.p90, targets.p90)} | ${formatCell(row.p99, targets.p99)} | ${row.samples} |`
234+
`| ${name} | ${row.scenario} | ${formatMs(row.avg)}${formatDelta(row.avg, row.baselineAvg)} | ${formatCell(row.p75, targets.p75)} | ${formatCell(row.p90, targets.p90)} | ${formatCell(row.p99, targets.p99)} | ${row.samples} |`
186235
);
187236
}
188237
return lines.join('\n');
@@ -249,8 +298,17 @@ function renderFooter(entries) {
249298
).join(' · ');
250299
const scenarioLegend = buildScenarioLegend(results);
251300
const targetsLegend = buildTargetsLegend(results);
301+
const hasBaseline = results.some((result) =>
302+
(result.metrics ?? []).some((row) => typeof row.baselineAvg === 'number')
303+
);
252304

253305
return [
306+
...(hasBaseline
307+
? [
308+
'<sub>Avg deltas compare against the most recent benchmark run on `main` at the time of this run.</sub>',
309+
'',
310+
]
311+
: []),
254312
`<sub>Metrics — ${definitions}</sub>`,
255313
...(scenarioLegend ? ['', `<sub>Scenarios — ${scenarioLegend}</sub>`] : []),
256314
...(targetsLegend
@@ -312,6 +370,7 @@ function renderHistorySection(shownPrevious) {
312370
export function renderComment({
313371
status,
314372
results,
373+
baseline = [],
315374
history,
316375
commit,
317376
runUrl,
@@ -324,7 +383,7 @@ export function renderComment({
324383
commit,
325384
runUrl,
326385
generatedAt: now.toISOString(),
327-
results,
386+
results: annotateWithBaseline(results, baseline),
328387
},
329388
...entries,
330389
].slice(0, MAX_HISTORY_ENTRIES);
@@ -367,6 +426,10 @@ function main() {
367426
: '';
368427
const history = extractHistory(previousBody);
369428
const results = loadResults(args.resultsDir);
429+
const baseline = loadResults(args.baselineDir);
430+
if (args.baselineDir && baseline.length === 0) {
431+
console.error(`No baseline results found in ${args.baselineDir}`);
432+
}
370433

371434
if (args.status === 'completed' && results.length === 0) {
372435
console.error('No benchmark results found for status=completed');
@@ -376,6 +439,7 @@ function main() {
376439
const body = renderComment({
377440
status: args.status,
378441
results,
442+
baseline,
379443
history,
380444
commit: args.commit,
381445
runUrl: args.runUrl,

.github/scripts/render-benchmark-comment.test.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,59 @@ test('renders a completed run with a table and embedded history', async () => {
136136
assert.strictEqual(history[0].results[0].metrics.length, 4);
137137
});
138138

139+
test('renders avg deltas against a baseline and embeds them in history', async () => {
140+
const { renderComment, extractHistory } = await loadModule();
141+
const baseline = sampleResult({
142+
metrics: sampleResult()
143+
.metrics.filter((row) => row.metric !== 'wo') // no baseline for WO
144+
.map((row) => ({
145+
...row,
146+
// ttfs 412.3 vs 400 → +3.1%; sl 55.1 vs 50 → +10%; stso 91 vs 91 → ±0%
147+
avg: { ttfs: 400, sl: 50, stso: 91 }[row.metric],
148+
})),
149+
});
150+
const body = renderComment({
151+
status: 'completed',
152+
results: [sampleResult()],
153+
baseline: [baseline],
154+
history: [],
155+
commit: 'abcdef1234567890',
156+
});
157+
158+
// 412.3 renders rounded (>= 100), 55.1 keeps its decimal
159+
assert.match(body, /\| 412 \(\+3\.1%\) \|/);
160+
assert.match(body, /\| 55\.1 \(\+10%\) \|/);
161+
assert.match(body, /\| 91 \(±0%\) \|/);
162+
// WO has no baseline row → no delta
163+
assert.match(body, /\| 1200 \|/);
164+
assert.match(
165+
body,
166+
/Avg deltas compare against the most recent benchmark run on `main`/
167+
);
168+
// The annotation is embedded so history re-renders keep the delta
169+
const history = extractHistory(body);
170+
assert.strictEqual(history[0].results[0].metrics[0].baselineAvg, 400);
171+
const rerendered = renderComment({
172+
status: 'running',
173+
results: [],
174+
history,
175+
commit: 'ffffff1234567890',
176+
});
177+
assert.match(rerendered, /\| 412 \(\+3\.1%\) \|/);
178+
});
179+
180+
test('renders no deltas without a baseline', async () => {
181+
const { renderComment } = await loadModule();
182+
const body = renderComment({
183+
status: 'completed',
184+
results: [sampleResult()],
185+
history: [],
186+
commit: 'abcdef1234567890',
187+
});
188+
assert.doesNotMatch(body, /%\)/);
189+
assert.doesNotMatch(body, /Avg deltas/);
190+
});
191+
139192
test('collapses previous results on re-runs', async () => {
140193
const { renderComment, extractHistory } = await loadModule();
141194
const first = renderComment({
@@ -259,13 +312,35 @@ test('CLI renders results from a directory and previous body file', async () =>
259312
const first = fs.readFileSync(firstOut, 'utf8');
260313
assert.match(first, /398 🔴/);
261314

315+
// Baseline files arrive nested in per-artifact subdirectories (that's how
316+
// the download action extracts them); loadResults must find them anyway.
317+
const baselineDir = path.join(dir, 'baseline');
318+
fs.mkdirSync(
319+
path.join(baselineDir, 'bench-results-nextjs-turbopack-vercel'),
320+
{
321+
recursive: true,
322+
}
323+
);
324+
const baseline = sampleResult();
325+
baseline.metrics = baseline.metrics.map((row) => ({ ...row, avg: 400 }));
326+
fs.writeFileSync(
327+
path.join(
328+
baselineDir,
329+
'bench-results-nextjs-turbopack-vercel',
330+
'bench-results-nextjs-turbopack-vercel.json'
331+
),
332+
JSON.stringify(baseline)
333+
);
334+
262335
const secondOut = path.join(dir, 'comment2.md');
263336
execFileSync(process.execPath, [
264337
SCRIPT,
265338
'--status',
266339
'completed',
267340
'--results-dir',
268341
resultsDir,
342+
'--baseline-dir',
343+
baselineDir,
269344
'--previous-body',
270345
firstOut,
271346
'--commit',
@@ -276,6 +351,8 @@ test('CLI renders results from a directory and previous body file', async () =>
276351
const second = fs.readFileSync(secondOut, 'utf8');
277352
assert.match(second, /Previous results \(1\)/);
278353
assert.match(second, /#### 1111111/);
354+
// ttfs avg 412.3 (rendered rounded) vs baseline 400 → +3.1%
355+
assert.match(second, /\| 412 \(\+3\.1%\) \|/);
279356
});
280357

281358
test('CLI fails when completed with no results', async () => {

.github/workflows/benchmarks.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@ name: Performance Benchmarks
55
# workbench app and posts the results as a sticky PR comment. Re-runs update
66
# the same comment; previous results stay available in a collapsed history
77
# section (state is embedded in the comment body itself).
8+
#
9+
# Runs on pushes to main (against the production deployment) purely to produce
10+
# baseline artifacts; PR runs download the most recent main baseline and show
11+
# avg-latency deltas against it in the comment.
812

913
on:
1014
pull_request:
1115
branches: [main]
16+
push:
17+
branches: [main]
1218
workflow_dispatch: # Allow manual triggers
1319

1420
concurrency:
@@ -193,6 +199,36 @@ jobs:
193199
path: benchmark-results
194200
merge-multiple: true
195201

202+
# Baseline for the avg-delta column: results from the most recent
203+
# successful run of this workflow on main. Uses gh (first-party) rather
204+
# than a third-party cross-run artifact action. Filtering to push /
205+
# workflow_dispatch events matters for more than tidiness: it excludes
206+
# artifacts uploaded by fork PR runs, whose head branch can also be
207+
# named "main" (artifact poisoning).
208+
- name: Download baseline results from main
209+
continue-on-error: true
210+
env:
211+
GH_TOKEN: ${{ github.token }}
212+
run: |
213+
run_id=$(gh run list \
214+
--repo "$GITHUB_REPOSITORY" \
215+
--workflow benchmarks.yml \
216+
--branch main \
217+
--status success \
218+
--limit 20 \
219+
--json databaseId,event \
220+
--jq '[.[] | select(.event == "push" or .event == "workflow_dispatch")][0].databaseId // empty')
221+
if [ -z "$run_id" ]; then
222+
echo "No successful benchmark run found on main; skipping baseline"
223+
exit 0
224+
fi
225+
echo "Using baseline artifacts from run $run_id"
226+
gh run download "$run_id" \
227+
--repo "$GITHUB_REPOSITORY" \
228+
--pattern 'bench-results-*' \
229+
--dir baseline-results \
230+
|| echo "Run $run_id has no benchmark artifacts; skipping baseline"
231+
196232
- name: Find existing benchmark comment
197233
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
198234
id: find-comment
@@ -222,6 +258,7 @@ jobs:
222258
node .github/scripts/render-benchmark-comment.mjs \
223259
--status "$BENCH_STATUS" \
224260
--results-dir benchmark-results \
261+
--baseline-dir baseline-results \
225262
--previous-body "$RUNNER_TEMP/previous-comment.md" \
226263
--commit "$HEAD_SHA" \
227264
--run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \

0 commit comments

Comments
 (0)