ci(perf): add the weekly baseline tightening workflow (#1760)

This commit is contained in:
4gray authored and GitHub committed 2026-09-30 18:50:30 +02:00
1 parent 97f56e219c
commit ec8b931dbf
9 files changed
+1142 -58

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@
{ "externalDependencies": ["parse5"] }
],
"options": {
"command": "node --test tools/performance/measure-initial-bytes.test.mjs tools/performance/check-journey-ratchet.test.mjs tools/performance/check-baseline-direction.test.mjs",
"command": "node --test tools/performance/measure-initial-bytes.test.mjs tools/performance/check-journey-ratchet.test.mjs tools/performance/check-baseline-direction.test.mjs tools/performance/tighten-baselines.test.mjs",
"cwd": "{workspaceRoot}"
}
},
+367
View File
@@ -0,0 +1,367 @@
/**
* Lowers the baselines in tools/performance/journey-baselines.json that every
* measured run beat. The weekly .github/workflows/performance-ratchet.yml job
* measures `master` three times on separate runners and runs this script on
* the summaries; the contract is the Ratchet section of
* docs/architecture/performance-journeys.md.
*
* Rules:
* - At least three runs. A run is one summary file, or several (the initial
* bytes summary and the journeys summary of the same runner) merged.
* - An entry is tightened only when every run measured it and every
* measurement is strictly below `value`. The new value is the largest
* measurement: for a counter the largest count, for a wall-clock entry (one
* with `toleranceRatio`) the largest per-run summary value, which for a
* `.p50` entry is the largest P50.
* - A counter marked `counterStability.<name>.stable === false` in any run is
* skipped: its summary value hides iterations that disagreed.
* - `value` never goes up, `slack` and `toleranceRatio` never change, and no
* entry is added or removed; check-baseline-direction.mjs must accept the
* result without --allow-increase, and this script checks that itself.
* - A tightened entry gets `updatedAt`, `measuredWith` and `evidenceRun` (the
* workflow run URL). `evidencePr` is left alone; once the PR exists,
* `--fill-evidence-pr` sets it on the entries carrying that run URL.
*
* Usage:
* node tools/performance/tighten-baselines.mjs \
* --run <summary.json|run-dir> --run … --run … \
* --evidence-run <workflow run URL> \
* [--baselines tools/performance/journey-baselines.json] \
* [--report <report.md>] [--measured-with <text>] [--date YYYY-MM-DD]
* node tools/performance/tighten-baselines.mjs \
* --fill-evidence-pr <number> --evidence-run <workflow run URL> \
* [--baselines tools/performance/journey-baselines.json]
*
* A run directory contributes every `*.json` file directly inside it.
*/
import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { compareBaselineDirection } from './check-baseline-direction.mjs';
import {
DEFAULT_BASELINES_PATH,
validateBaselines,
} from './check-journey-ratchet.mjs';
export const MIN_RUNS = 3;
export const DEFAULT_MEASURED_WITH =
'.github/workflows/performance-ratchet.yml';
const SECTIONS = ['counters', 'wallClock', 'counterStability'];
function isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function formatNumber(value) {
return value.toLocaleString('en-US', { maximumFractionDigits: 2 });
}
/**
* Merges the summaries of one run into one `journeys` map. The same
* measurement in two files of one run is ambiguous and therefore an error.
*/
export function mergeRunSummaries(summaries, runLabel = 'run') {
const journeys = {};
for (const summary of summaries) {
if (!isPlainObject(summary?.journeys)) {
throw new Error(`${runLabel}: a summary has no "journeys" map.`);
}
for (const [journey, measured] of Object.entries(summary.journeys)) {
const target = (journeys[journey] ??= {
counters: {},
wallClock: {},
counterStability: {},
});
for (const section of SECTIONS) {
for (const [name, value] of Object.entries(
measured?.[section] ?? {}
)) {
if (Object.hasOwn(target[section], name)) {
throw new Error(
`${runLabel}: journeys.${journey}.${section}.${name} appears in two summaries of the same run.`
);
}
target[section][name] = value;
}
}
}
}
return { journeys };
}
function decide(entry, name, journey, runs) {
const counter = entry.toleranceRatio === undefined;
const section = counter ? 'counters' : 'wallClock';
const measured = runs.map(
(run) => run.journeys[journey]?.[section]?.[name]
);
for (const [index, value] of measured.entries()) {
if (value === undefined) {
return {
measured,
reason: `not measured in run ${index + 1} (journeys.${journey}.${section}).`,
};
}
if (typeof value !== 'number' || !Number.isFinite(value)) {
return {
measured,
reason: `run ${index + 1} measured ${JSON.stringify(value)}, not a finite number.`,
};
}
}
if (counter) {
for (const [index, run] of runs.entries()) {
const stability = run.journeys[journey]?.counterStability?.[name];
if (stability?.stable === false) {
const values = Array.isArray(stability.values)
? ` (iterations ${stability.values.join(', ')})`
: '';
return {
measured,
reason: `unstable in run ${index + 1}${values}; the summary value hides iterations that disagreed.`,
};
}
}
}
const highest = Math.max(...measured);
if (highest >= entry.value) {
return {
measured,
reason: `not below ${formatNumber(entry.value)} in every run (highest ${formatNumber(highest)}).`,
};
}
return { measured, newValue: highest };
}
/**
* Pure tightening. Returns the new baselines object and one row per entry so
* the CLI and the PR body can show the per-run numbers.
*/
export function tightenBaselines({
baselines,
runs,
evidenceRun,
measuredWith = DEFAULT_MEASURED_WITH,
date = new Date().toISOString().slice(0, 10),
}) {
validateBaselines(baselines);
if (runs.length < MIN_RUNS) {
throw new Error(
`Tightening needs at least ${MIN_RUNS} runs; received ${runs.length}.`
);
}
if (!evidenceRun) {
throw new Error('--evidence-run <workflow run URL> is required.');
}
const next = structuredClone(baselines);
const rows = [];
for (const [journey, entries] of Object.entries(baselines.journeys)) {
for (const [name, entry] of Object.entries(entries)) {
const decision = decide(entry, name, journey, runs);
rows.push({
label: `${journey}/${name}`,
unit: entry.unit,
value: entry.value,
...decision,
});
if (decision.newValue === undefined) continue;
next.journeys[journey][name] = {
...entry,
value: decision.newValue,
updatedAt: date,
measuredWith: `${measuredWith}, max of ${runs.length} runs`,
evidenceRun,
};
}
}
const direction = compareBaselineDirection({ base: baselines, head: next });
if (direction.failures.length > 0) {
throw new Error(
`Refusing to write a weakened baselines file: ${direction.failures.join(' ')}`
);
}
const tightened = rows.filter((row) => row.newValue !== undefined);
return { baselines: next, rows, tightened };
}
/** Sets `evidencePr` on the entries a tightening run wrote. */
export function fillEvidencePr({ baselines, evidenceRun, pr }) {
validateBaselines(baselines);
if (!Number.isInteger(pr) || pr <= 0) {
throw new Error(`--fill-evidence-pr expects a PR number, got ${pr}.`);
}
const next = structuredClone(baselines);
let filled = 0;
for (const entries of Object.values(next.journeys)) {
for (const entry of Object.values(entries)) {
if (entry.evidenceRun !== evidenceRun) continue;
entry.evidencePr = pr;
filled += 1;
}
}
if (filled === 0) {
throw new Error(`No baseline carries evidenceRun ${evidenceRun}.`);
}
return { baselines: next, filled };
}
/** Markdown: readable in a terminal, and pasted as is into the PR body. */
export function formatReport({ rows, tightened }, runNames = []) {
const runCount = Math.max(0, ...rows.map((row) => row.measured.length));
const runHeaders = Array.from(
{ length: runCount },
(_, index) => `Run ${index + 1}`
);
const lines = [
`| Baseline | Value | ${runHeaders.join(' | ')} | Result |`,
`| --- | ---: | ${runHeaders.map(() => '---:').join(' | ')} | --- |`,
];
for (const row of rows) {
const unit = row.unit ? ` ${row.unit}` : '';
const cells = row.measured.map((value) =>
typeof value === 'number' ? formatNumber(value) : '—'
);
const result =
row.newValue !== undefined
? `lowered to ${formatNumber(row.newValue)}${unit}`
: `kept: ${row.reason}`;
lines.push(
`| \`${row.label}\` | ${formatNumber(row.value)}${unit} | ${cells.join(' | ')} | ${result} |`
);
}
if (runNames.length > 0) {
lines.push('');
for (const [index, name] of runNames.entries()) {
lines.push(`- Run ${index + 1}: ${name}`);
}
}
lines.push(
'',
tightened.length > 0
? `Tightened ${tightened.length} of ${rows.length} baselines.`
: `No baseline was below its value in every run; nothing to tighten (${rows.length} checked).`
);
return lines.join('\n');
}
export function parseArgs(argv) {
const options = {
runs: [],
baselines: DEFAULT_BASELINES_PATH,
evidenceRun: null,
report: null,
measuredWith: DEFAULT_MEASURED_WITH,
date: undefined,
fillEvidencePr: null,
};
const valued = {
'--run': (value) => options.runs.push(value),
'--baselines': (value) => (options.baselines = value),
'--evidence-run': (value) => (options.evidenceRun = value),
'--report': (value) => (options.report = value),
'--measured-with': (value) => (options.measuredWith = value),
'--date': (value) => (options.date = value),
'--fill-evidence-pr': (value) =>
(options.fillEvidencePr = Number(value)),
};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--') continue;
const [flag, inline] = argument.split(/=(.*)/s, 2);
if (!(flag in valued)) throw new Error(`Unknown argument: ${argument}`);
const value = inline ?? argv[++index];
if (value === undefined || value === '') {
throw new Error(`Missing value for ${flag}`);
}
valued[flag](value);
}
if (
options.date !== undefined &&
!/^\d{4}-\d{2}-\d{2}$/.test(options.date)
) {
throw new Error(`--date expects YYYY-MM-DD, got "${options.date}".`);
}
if (!options.evidenceRun) {
throw new Error('--evidence-run <workflow run URL> is required.');
}
return options;
}
async function readJson(filePath) {
try {
return JSON.parse(await readFile(filePath, 'utf8'));
} catch (error) {
throw new Error(`Cannot read ${filePath}: ${error.message}`);
}
}
/** Reads one run: a summary file, or every `*.json` directly in a directory. */
export async function readRun(runPath, runLabel) {
let files = [runPath];
if ((await stat(runPath)).isDirectory()) {
files = (await readdir(runPath))
.filter((name) => name.endsWith('.json'))
.sort()
.map((name) => path.join(runPath, name));
if (files.length === 0) {
throw new Error(`${runLabel}: no summary files in ${runPath}.`);
}
}
const summaries = await Promise.all(files.map((file) => readJson(file)));
return mergeRunSummaries(summaries, runLabel);
}
async function writeJson(filePath, value) {
await writeFile(filePath, `${JSON.stringify(value, null, 4)}\n`);
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) ===
path.resolve(fileURLToPath(import.meta.url));
if (isMain) {
try {
const options = parseArgs(process.argv.slice(2));
const baselinesPath = path.resolve(options.baselines);
const baselines = await readJson(baselinesPath);
if (options.fillEvidencePr !== null) {
const { baselines: next, filled } = fillEvidencePr({
baselines,
evidenceRun: options.evidenceRun,
pr: options.fillEvidencePr,
});
await writeJson(baselinesPath, next);
console.log(
`Set evidencePr ${options.fillEvidencePr} on ${filled} baselines.`
);
} else {
const runs = await Promise.all(
options.runs.map((run, index) =>
readRun(path.resolve(run), `run ${index + 1}`)
)
);
const result = tightenBaselines({
baselines,
runs,
evidenceRun: options.evidenceRun,
measuredWith: options.measuredWith,
date: options.date,
});
const report = formatReport(
result,
options.runs.map((run) => path.basename(path.resolve(run)))
);
console.log(report);
if (options.report) await writeFile(options.report, `${report}\n`);
if (result.tightened.length > 0) {
await writeJson(baselinesPath, result.baselines);
}
}
} catch (error) {
console.error(`tighten-baselines: ${error.message}`);
process.exitCode = 1;
}
}
@@ -0,0 +1,396 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { after, before, test } from 'node:test';
import { compareBaselineDirection } from './check-baseline-direction.mjs';
import {
fillEvidencePr,
formatReport,
mergeRunSummaries,
parseArgs,
tightenBaselines,
} from './tighten-baselines.mjs';
const scriptPath = fileURLToPath(
new URL('./tighten-baselines.mjs', import.meta.url)
);
const RUN_URL = 'https://github.com/4gray/iptvnator/actions/runs/1';
const baselinesFile = (entries) => ({
version: 1,
journeys: {
launch: {
'renderer.initialBytes': {
value: 1000,
unit: 'bytes',
slack: 64,
updatedAt: '2026-09-27',
evidencePr: 1734,
measuredWith:
'pnpm nx build web && pnpm run perf:initial-bytes',
},
...entries,
},
},
});
const run = (launch) => ({ journeys: { launch } });
const bytesRun = (initialBytes) =>
run({ counters: { 'renderer.initialBytes': initialBytes } });
const tighten = (baselines, runs) =>
tightenBaselines({
baselines,
runs,
evidenceRun: RUN_URL,
date: '2026-10-05',
});
let workDir;
before(async () => {
workDir = await mkdtemp(path.join(os.tmpdir(), 'tighten-baselines-'));
});
after(async () => {
await rm(workDir, { recursive: true, force: true });
});
test('a counter below its value in every run drops to the largest run', () => {
const baselines = baselinesFile();
const result = tighten(baselines, [
bytesRun(990),
bytesRun(995),
bytesRun(980),
]);
assert.deepEqual(
result.baselines.journeys.launch['renderer.initialBytes'],
{
value: 995,
unit: 'bytes',
slack: 64,
updatedAt: '2026-10-05',
evidencePr: 1734,
measuredWith:
'.github/workflows/performance-ratchet.yml, max of 3 runs',
evidenceRun: RUN_URL,
}
);
assert.equal(result.tightened.length, 1);
assert.equal(
baselines.journeys.launch['renderer.initialBytes'].value,
1000,
'the input is not mutated'
);
const direction = compareBaselineDirection({
base: baselines,
head: result.baselines,
});
assert.deepEqual(direction.failures, []);
assert.deepEqual(direction.allowed, []);
assert.match(direction.lowered[0], /1,064 -> 1,059 bytes/);
});
test('one run at or above the value keeps the baseline untouched', () => {
for (const measured of [1000, 1010]) {
const baselines = baselinesFile();
const result = tighten(baselines, [
bytesRun(990),
bytesRun(measured),
bytesRun(980),
]);
assert.deepEqual(result.baselines, baselines);
assert.deepEqual(result.tightened, []);
assert.match(result.rows[0].reason, /not below 1,000 in every run/);
}
});
test('an entry missing or non-numeric in any run is kept', () => {
const missing = tighten(baselinesFile(), [
bytesRun(990),
run({ counters: {} }),
bytesRun(980),
]);
assert.deepEqual(missing.tightened, []);
assert.match(missing.rows[0].reason, /not measured in run 2/);
const invalid = tighten(baselinesFile(), [
bytesRun(990),
bytesRun(980),
bytesRun('980'),
]);
assert.deepEqual(invalid.tightened, []);
assert.match(invalid.rows[0].reason, /run 3 measured "980"/);
});
test('a counter marked unstable in any run is skipped with the iterations', () => {
const baselines = baselinesFile({
'renderer.ipcCallsToFirstCard': { value: 20 },
});
const ipcRun = (value, stable = true) =>
run({
counters: {
'renderer.initialBytes': 990,
'renderer.ipcCallsToFirstCard': value,
},
counterStability: {
'renderer.ipcCallsToFirstCard': {
stable,
values: stable ? [value, value] : [13, value],
},
},
});
const result = tighten(baselines, [
ipcRun(16),
ipcRun(16, false),
ipcRun(16),
]);
const row = result.rows.find(
(entry) => entry.label === 'launch/renderer.ipcCallsToFirstCard'
);
assert.match(row.reason, /unstable in run 2 \(iterations 13, 16\)/);
assert.equal(
result.baselines.journeys.launch['renderer.ipcCallsToFirstCard'].value,
20
);
assert.equal(
result.baselines.journeys.launch['renderer.initialBytes'].value,
990,
'other entries are still tightened'
);
});
test('a wall-clock entry drops to the largest P50 and keeps its tolerance', () => {
const baselines = baselinesFile({
'spawnToFirstCardMs.p50': {
value: 1600,
unit: 'ms',
toleranceRatio: 1.25,
},
});
const wallRun = (p50) =>
run({
counters: { 'renderer.initialBytes': 1000 },
wallClock: {
'spawnToFirstCardMs.p50': p50,
'spawnToFirstCardMs.p90': 5000,
},
// counterStability does not apply to wall-clock entries.
counterStability: {
'spawnToFirstCardMs.p50': { stable: false },
},
});
const result = tighten(baselines, [
wallRun(1401.5),
wallRun(1550.25),
wallRun(1480),
]);
assert.deepEqual(
result.baselines.journeys.launch['spawnToFirstCardMs.p50'],
{
value: 1550.25,
unit: 'ms',
toleranceRatio: 1.25,
updatedAt: '2026-10-05',
measuredWith:
'.github/workflows/performance-ratchet.yml, max of 3 runs',
evidenceRun: RUN_URL,
}
);
assert.deepEqual(
compareBaselineDirection({ base: baselines, head: result.baselines })
.failures,
[]
);
});
test('fewer than three runs or no run URL is refused', () => {
assert.throws(
() => tighten(baselinesFile(), [bytesRun(990), bytesRun(990)]),
/at least 3 runs; received 2/
);
assert.throws(
() =>
tightenBaselines({
baselines: baselinesFile(),
runs: [bytesRun(1), bytesRun(1), bytesRun(1)],
}),
/--evidence-run/
);
});
test('entries are never added and values are never raised', () => {
const baselines = baselinesFile();
const result = tighten(baselines, [
run({ counters: { 'renderer.initialBytes': 1200, newCounter: 1 } }),
bytesRun(1200),
bytesRun(1200),
]);
assert.deepEqual(result.baselines, baselines);
});
test('summaries of one run merge; a duplicate measurement is an error', () => {
const merged = mergeRunSummaries([
bytesRun(990),
run({
counters: { 'renderer.ipcCallsToFirstCard': 16 },
wallClock: { 'spawnToFirstCardMs.p50': 1400 },
}),
]);
assert.deepEqual(merged.journeys.launch.counters, {
'renderer.initialBytes': 990,
'renderer.ipcCallsToFirstCard': 16,
});
assert.throws(
() => mergeRunSummaries([bytesRun(990), bytesRun(991)], 'run 2'),
/run 2: journeys\.launch\.counters\.renderer\.initialBytes appears in two summaries/
);
});
test('fillEvidencePr sets the PR only on entries from that run', () => {
const baselines = baselinesFile({
other: { value: 5, evidenceRun: 'https://example.invalid/runs/0' },
});
baselines.journeys.launch['renderer.initialBytes'].evidenceRun = RUN_URL;
const { baselines: next, filled } = fillEvidencePr({
baselines,
evidenceRun: RUN_URL,
pr: 1800,
});
assert.equal(filled, 1);
assert.equal(
next.journeys.launch['renderer.initialBytes'].evidencePr,
1800
);
assert.equal(next.journeys.launch.other.evidencePr, undefined);
assert.throws(
() => fillEvidencePr({ baselines, evidenceRun: 'x', pr: 1 }),
/No baseline carries evidenceRun x/
);
});
test('the report lists every run value and the decision', () => {
const result = tighten(baselinesFile({ gone: { value: 3 } }), [
bytesRun(990),
bytesRun(995),
bytesRun(980),
]);
const report = formatReport(result, ['a', 'b', 'c']);
assert.match(
report,
/\| `launch\/renderer.initialBytes` \| 1,000 bytes \| 990 \| 995 \| 980 \| lowered to 995 bytes \|/
);
assert.match(
report,
/\| `launch\/gone` \| 3 \| — \| — \| — \| kept: not measured in run 1/
);
assert.match(report, /- Run 2: b/);
assert.match(report, /Tightened 1 of 2 baselines\./);
});
test('parseArgs reads repeated runs and requires the run URL', () => {
assert.deepEqual(
parseArgs([
'--run',
'a.json',
'--run=b',
'--evidence-run',
RUN_URL,
'--date=2026-10-05',
]).runs,
['a.json', 'b']
);
assert.throws(() => parseArgs(['--run', 'a.json']), /--evidence-run/);
assert.throws(
() => parseArgs(['--evidence-run', RUN_URL, '--date', '5.10.2026']),
/YYYY-MM-DD/
);
assert.throws(() => parseArgs(['--bogus']), /Unknown argument/);
assert.throws(() => parseArgs(['--run']), /Missing value for --run/);
});
test('CLI rewrites the file only when a baseline was tightened', async () => {
const baselinesPath = path.join(workDir, 'baselines.json');
const original = `${JSON.stringify(baselinesFile(), null, 4)}\n`;
await writeFile(baselinesPath, original);
const runArgs = [];
for (const [index, bytes] of [990, 1000, 985].entries()) {
const dir = path.join(workDir, `run-${index + 1}`);
await mkdir(dir, { recursive: true });
await writeFile(
path.join(dir, 'initial-bytes.summary.json'),
JSON.stringify(bytesRun(bytes))
);
await writeFile(
path.join(dir, 'journeys.summary.json'),
JSON.stringify(run({ counters: { 'renderer.longTasks': 2 } }))
);
runArgs.push('--run', dir);
}
const cli = (...extra) =>
spawnSync(
process.execPath,
[
scriptPath,
...runArgs,
'--baselines',
baselinesPath,
'--evidence-run',
RUN_URL,
...extra,
],
{ encoding: 'utf8' }
);
const unchanged = cli();
assert.equal(unchanged.status, 0, unchanged.stderr);
assert.match(unchanged.stdout, /nothing to tighten/);
assert.equal(await readFile(baselinesPath, 'utf8'), original);
await writeFile(
path.join(workDir, 'run-2', 'initial-bytes.summary.json'),
JSON.stringify(bytesRun(970))
);
const reportPath = path.join(workDir, 'report.md');
const tightened = cli('--report', reportPath, '--date', '2026-10-05');
assert.equal(tightened.status, 0, tightened.stderr);
const written = JSON.parse(await readFile(baselinesPath, 'utf8'));
assert.equal(written.journeys.launch['renderer.initialBytes'].value, 990);
assert.match(await readFile(reportPath, 'utf8'), /lowered to 990 bytes/);
const fill = spawnSync(
process.execPath,
[
scriptPath,
'--baselines',
baselinesPath,
'--evidence-run',
RUN_URL,
'--fill-evidence-pr',
'1800',
],
{ encoding: 'utf8' }
);
assert.equal(fill.status, 0, fill.stderr);
const filled = JSON.parse(await readFile(baselinesPath, 'utf8'));
assert.equal(
filled.journeys.launch['renderer.initialBytes'].evidencePr,
1800
);
const tooFew = spawnSync(
process.execPath,
[
scriptPath,
'--run',
path.join(workDir, 'run-1'),
'--evidence-run',
RUN_URL,
],
{ encoding: 'utf8' }
);
assert.equal(tooFew.status, 1);
assert.match(tooFew.stderr, /at least 3 runs; received 1/);
});