Skip to content

Commit 8562160

Browse files
Merge pull request #2632 from NullVoxPopuli-ai-agent/nvp/add-benchmark
Add mitata benchmark for recommended config linting
2 parents bf3a672 + 76cd8ad commit 8562160

23 files changed

Lines changed: 4548 additions & 1 deletion
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
name: Benchmark Comparison
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened]
6+
7+
concurrency:
8+
group: bench-${{ github.head_ref }}
9+
cancel-in-progress: true
10+
11+
jobs:
12+
bench-compare:
13+
name: 'Benchmark Comparison'
14+
runs-on: ubuntu-latest
15+
permissions:
16+
pull-requests: write
17+
contents: read
18+
19+
steps:
20+
- uses: actions/checkout@v6
21+
id: checkout
22+
with:
23+
# Full history so the script can git-archive the base branch.
24+
fetch-depth: 0
25+
# Use the PR head SHA so fork PRs resolve correctly
26+
# (github.head_ref is a branch name that only exists on the fork remote).
27+
ref: ${{ github.event.pull_request.head.sha }}
28+
29+
- uses: wyvox/action-setup-pnpm@v3
30+
31+
- name: Run benchmark comparison
32+
env:
33+
BENCH_JSON_OUTPUT: ${{ runner.temp }}/bench-results.json
34+
run: |
35+
set -o pipefail
36+
pnpm bench:compare | sed 's/\x1b\[[0-9;]*m//g' > "$RUNNER_TEMP/bench-output.txt"
37+
38+
- name: Format PR comment
39+
if: always() && steps.checkout.outcome == 'success'
40+
env:
41+
BENCH_OUTPUT_FILE: ${{ runner.temp }}/bench-output.txt
42+
BENCH_JSON_OUTPUT: ${{ runner.temp }}/bench-results.json
43+
BENCH_JOB_SUCCESS: ${{ job.status == 'success' }}
44+
run: node scripts/format-bench-comment.mjs > "$RUNNER_TEMP/bench-comment.md"
45+
46+
- name: Write job summary
47+
if: always() && steps.checkout.outcome == 'success'
48+
run: cat "$RUNNER_TEMP/bench-comment.md" >> "$GITHUB_STEP_SUMMARY"
49+
50+
- name: Post PR comment
51+
if: always() && steps.checkout.outcome == 'success'
52+
uses: actions/github-script@v7
53+
with:
54+
script: |
55+
const fs = require('fs');
56+
const marker = '<!-- bench-compare -->';
57+
58+
const body = fs.readFileSync(process.env.RUNNER_TEMP + '/bench-comment.md', 'utf8');
59+
60+
const headFullName = context.payload.pull_request?.head?.repo?.full_name;
61+
const isFork = !headFullName || headFullName !== context.repo.owner + '/' + context.repo.repo;
62+
63+
if (isFork) {
64+
core.info('PR is from a fork — skipping PR comment (results are in the job summary).');
65+
core.info('--- Comment body start ---');
66+
core.info(body);
67+
core.info('--- Comment body end ---');
68+
} else {
69+
const { data: comments } = await github.rest.issues.listComments({
70+
owner: context.repo.owner,
71+
repo: context.repo.repo,
72+
issue_number: context.issue.number,
73+
});
74+
75+
const existing = comments.find(c => c.body.includes(marker));
76+
77+
if (existing) {
78+
await github.rest.issues.updateComment({
79+
owner: context.repo.owner,
80+
repo: context.repo.repo,
81+
comment_id: existing.id,
82+
body,
83+
});
84+
} else {
85+
await github.rest.issues.createComment({
86+
owner: context.repo.owner,
87+
repo: context.repo.repo,
88+
issue_number: context.issue.number,
89+
body,
90+
});
91+
}
92+
}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,8 @@ npm-debug.log
1515
# eslint-remote-tester
1616
eslint-remote-tester-results
1717

18+
# Benchmark output
19+
bench-results.json
20+
1821
# Lock file generated by npm install (project uses pnpm)
1922
package-lock.json

eslint.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,8 @@ module.exports = [
239239
'lib/recommended-rules-gjs.js',
240240
'lib/recommended-rules-gts.js',
241241
'tests/__snapshots__/',
242+
'tests/bench/',
243+
'tests/lint.bench.mjs',
242244

243245
// # Contains <template> in js markdown
244246
'docs/rules/no-empty-glimmer-component-classes.md',

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
"lib"
3838
],
3939
"scripts": {
40+
"bench": "./scripts/run-bench.sh tests/lint.bench.mjs",
41+
"bench:compare": "node scripts/bench-compare.mjs",
42+
"bench:summary": "./scripts/local-bench-summary.sh",
4043
"format": "prettier . --write",
4144
"lint": "npm-run-all --continue-on-error --aggregate-output --parallel \"lint:!(fix)\"",
4245
"lint:docs": "markdownlint \"**/*.md\"",
@@ -99,6 +102,7 @@
99102
"jquery": "^3.7.1",
100103
"jsdom": "^24.0.0",
101104
"markdownlint-cli": "^0.48.0",
105+
"mitata": "^1.0.34",
102106
"npm-package-json-lint": "^7.0.0",
103107
"npm-run-all2": "^5.0.0",
104108
"prettier": "^3.0.3",

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/bench-compare.mjs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/* eslint-disable n/no-process-exit */
2+
/**
3+
* Benchmark comparison script using mitata.
4+
*
5+
* Copies the base branch's source to a temp directory, installs its
6+
* dependencies, then runs the mitata bench script with --control-dir so that
7+
* both control (base) and experiment (current) plugins are benchmarked in the
8+
* same process — giving mitata a fair, head-to-head comparison with built-in
9+
* summary tables and boxplots.
10+
*
11+
* Usage:
12+
* node scripts/bench-compare.mjs [--base <branch>]
13+
*
14+
* Options:
15+
* --base <branch> Branch to compare against (default: master)
16+
*/
17+
18+
import { execSync, spawnSync } from 'node:child_process';
19+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
20+
import { tmpdir } from 'node:os';
21+
import { join } from 'node:path';
22+
23+
// ---------------------------------------------------------------------------
24+
// CLI args
25+
// ---------------------------------------------------------------------------
26+
27+
const args = process.argv.slice(2);
28+
const baseIdx = args.indexOf('--base');
29+
const BASE_BRANCH = baseIdx !== -1 ? args[baseIdx + 1] : 'master';
30+
31+
// ---------------------------------------------------------------------------
32+
// Helpers
33+
// ---------------------------------------------------------------------------
34+
35+
function run(cmd, opts = {}) {
36+
return execSync(cmd, { stdio: 'inherit', ...opts });
37+
}
38+
39+
/**
40+
* Resolve a branch name to a commit SHA. Tries `origin/<branch>` first (for CI
41+
* where only the PR branch is checked out locally), then falls back to `<branch>`.
42+
*/
43+
function resolveRef(branch) {
44+
for (const candidate of [`origin/${branch}`, branch]) {
45+
const result = spawnSync('git', ['rev-parse', '--verify', candidate], {
46+
encoding: 'utf8',
47+
stdio: ['pipe', 'pipe', 'pipe'],
48+
});
49+
if (result.status === 0) return result.stdout.trim();
50+
}
51+
throw new Error(`Could not resolve ref for branch "${branch}". Is it fetched?`);
52+
}
53+
54+
// ---------------------------------------------------------------------------
55+
// Main
56+
// ---------------------------------------------------------------------------
57+
58+
const ROOT = process.cwd();
59+
const CONTROL_DIR = join(tmpdir(), `bench-control-${BASE_BRANCH}-${Date.now()}`);
60+
61+
console.error(`\n🔧 Setting up control (${BASE_BRANCH}) in ${CONTROL_DIR}\n`);
62+
63+
const BASE_REF = resolveRef(BASE_BRANCH);
64+
console.error(` Resolved ${BASE_BRANCH}${BASE_REF.slice(0, 10)}\n`);
65+
66+
// Clean up temp dir on exit
67+
function cleanup() {
68+
if (existsSync(CONTROL_DIR)) {
69+
try {
70+
rmSync(CONTROL_DIR, { recursive: true, force: true });
71+
} catch {
72+
// best-effort cleanup
73+
}
74+
}
75+
}
76+
process.on('exit', cleanup);
77+
process.on('SIGINT', () => process.exit(130));
78+
process.on('SIGTERM', () => process.exit(143));
79+
80+
try {
81+
// ── 1. Export base branch source to temp dir ─────────────────────────────
82+
mkdirSync(CONTROL_DIR, { recursive: true });
83+
84+
// Copy package manifests and source (use resolved SHA for reliability)
85+
run(
86+
`git archive ${BASE_REF} -- package.json pnpm-lock.yaml .npmrc lib/ | tar -x -C "${CONTROL_DIR}"`
87+
);
88+
89+
// ── 2. Install dependencies in control dir ───────────────────────────────
90+
console.error(`\n📦 Installing dependencies for control (${BASE_BRANCH})…\n`);
91+
try {
92+
run('pnpm install --frozen-lockfile', {
93+
cwd: CONTROL_DIR,
94+
stdio: ['inherit', 'pipe', 'inherit'],
95+
});
96+
} catch {
97+
console.error('⚠️ Frozen install failed, retrying without --frozen-lockfile…\n');
98+
run('pnpm install --no-frozen-lockfile', {
99+
cwd: CONTROL_DIR,
100+
stdio: ['inherit', 'pipe', 'inherit'],
101+
});
102+
}
103+
104+
// ── 3. Run mitata bench with --control-dir ───────────────────────────────
105+
console.error(`\n🏎️ Running benchmarks (experiment vs control)…\n`);
106+
107+
const benchScript = join(ROOT, 'tests/lint.bench.mjs');
108+
const benchArgs = [
109+
'--expose-gc',
110+
'--max-old-space-size=4096',
111+
benchScript,
112+
'--control-dir',
113+
CONTROL_DIR,
114+
];
115+
116+
// CPU pinning on Linux to reduce cross-core migration variance
117+
const IS_LINUX = process.platform === 'linux';
118+
const HAS_TASKSET = IS_LINUX && spawnSync('which', ['taskset'], { stdio: 'pipe' }).status === 0;
119+
120+
let cmd = 'node';
121+
let fullArgs = benchArgs;
122+
123+
if (HAS_TASKSET) {
124+
cmd = 'taskset';
125+
fullArgs = ['-c', '0', 'node', ...benchArgs];
126+
console.error('📌 CPU pinning enabled (taskset -c 0)\n');
127+
}
128+
129+
const result = spawnSync(cmd, fullArgs, {
130+
stdio: 'inherit',
131+
cwd: ROOT,
132+
env: { ...process.env },
133+
});
134+
135+
if (result.status !== 0) {
136+
console.error('\n❌ Benchmark run failed.');
137+
process.exit(1);
138+
}
139+
140+
console.error('\n✅ Benchmark comparison complete.\n');
141+
} catch (e) {
142+
console.error('❌ Error:', e.message);
143+
process.exit(1);
144+
}

scripts/bench-utils.mjs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Shared utilities for benchmark formatting scripts.
3+
*/
4+
5+
import { readFileSync } from 'node:fs';
6+
7+
export function formatTime(ns) {
8+
if (ns >= 1e6) return `${(ns / 1e6).toFixed(2)} ms`;
9+
if (ns >= 1e3) return `${(ns / 1e3).toFixed(2)} µs`;
10+
return `${ns.toFixed(2)} ns`;
11+
}
12+
13+
export function deltaEmoji(pct) {
14+
const abs = Math.abs(pct);
15+
if (abs < 2) return '⚪';
16+
if (pct <= -5) return '🟢';
17+
if (pct >= 5) return '🔴';
18+
if (pct < 0) return '🟢';
19+
return '🟠';
20+
}
21+
22+
/**
23+
* Parse benchmark JSON results into control/experiment pairs with deltas.
24+
* Uses p50 (median) which is more robust to outliers than avg.
25+
*/
26+
export function parsePairs(json) {
27+
const pairs = new Map();
28+
29+
for (const trial of json.benchmarks || []) {
30+
for (const r of trial.runs || []) {
31+
if (!r.stats) continue;
32+
const m = r.name.match(/^(.+)\s+\((control|experiment)\)$/);
33+
if (!m) continue;
34+
const [, key, role] = m;
35+
if (!pairs.has(key)) pairs.set(key, {});
36+
pairs.get(key)[role] = r.stats;
37+
}
38+
}
39+
40+
const rows = [];
41+
for (const [name, { control, experiment }] of pairs) {
42+
if (!control || !experiment) continue;
43+
const ctrlVal = control.p50 ?? control.avg;
44+
const expVal = experiment.p50 ?? experiment.avg;
45+
const delta = ((expVal - ctrlVal) / ctrlVal) * 100;
46+
rows.push({ name, control: ctrlVal, experiment: expVal, delta });
47+
}
48+
49+
return rows;
50+
}
51+
52+
/**
53+
* Read and parse the benchmark JSON results file.
54+
*/
55+
export function readBenchJSON(path) {
56+
return JSON.parse(readFileSync(path, 'utf8'));
57+
}

0 commit comments

Comments
 (0)