diff --git a/.github/workflows/performance.yaml b/.github/workflows/performance.yaml index 9244de9708..e61264f2b1 100644 --- a/.github/workflows/performance.yaml +++ b/.github/workflows/performance.yaml @@ -8,6 +8,10 @@ on: pull_request: branches: - main + # `labeled` lets applying the `performance-benchmark` label trigger a rerun + # without needing an empty commit; the job-level `if` filters out unrelated + # label changes so we don't burn CI on every label add. + types: [opened, synchronize, reopened, labeled] env: CI: true @@ -16,6 +20,9 @@ env: jobs: historical-versions: runs-on: ubuntu-latest + # Skip the job when a `labeled` event fires for an unrelated label — + # opens/pushes/reopens always run; only the label firehose is filtered. + if: github.event.action != 'labeled' || github.event.label.name == 'performance-benchmark' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install pnpm for Rebilly/api-definitions @@ -33,8 +40,9 @@ jobs: run: npm i -g hyperfine - name: Add more versions to test - # Run only on the release branch (changeset-release/main): - if: github.head_ref == 'changeset-release/main' + # Runs on the release PR (changeset-release/main) automatically, or on + # any PR carrying the `performance-benchmark` label for opt-in deep-dive. + if: github.head_ref == 'changeset-release/main' || contains(github.event.pull_request.labels.*.name, 'performance-benchmark') run: | cd tests/performance/ cat package.json | jq ".dependencies = $(cat package.json | jq ._enhancedDependencies)" > package.json @@ -52,12 +60,16 @@ jobs: run: | cd tests/performance/ npm run test # This command is generated and injected into package.json in the previous step. - cat benchmark_check.md + cat benchmark_bundle.md benchmark_lint.md benchmark_check-config.md npm run chart # Creates benchmark_chart.md with the performance bar chart. - name: Comment PR + # If the PR carries the `performance-benchmark` label, each push posts a + # fresh comment (so variance across reruns can be compared in-thread); + # otherwise the comment is overwritten in place so normal PRs keep a + # single result. if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 with: file-path: tests/performance/benchmark_chart.md - comment-tag: historical-versions-comparison + comment-tag: ${{ contains(github.event.pull_request.labels.*.name, 'performance-benchmark') && format('historical-versions-rerun-{0}', github.run_id) || 'historical-versions-comparison' }} diff --git a/tests/performance/.gitignore b/tests/performance/.gitignore index 7d90fe0bc1..e59ba9b4a2 100644 --- a/tests/performance/.gitignore +++ b/tests/performance/.gitignore @@ -2,6 +2,10 @@ api-definitions node_modules package-lock.json test-command.txt -benchmark_check.md -benchmark_check.json +benchmark_bundle.md +benchmark_bundle.json +benchmark_lint.md +benchmark_lint.json +benchmark_check-config.md +benchmark_check-config.json benchmark_chart.md diff --git a/tests/performance/chart.js b/tests/performance/chart.js index 9cfcb8fb0c..f8daade056 100644 --- a/tests/performance/chart.js +++ b/tests/performance/chart.js @@ -1,39 +1,65 @@ import fs from 'node:fs'; -const content = fs.readFileSync('benchmark_check.json', 'utf8'); -const json = JSON.parse(content); -const arr = json.results.map((r) => [ - r.command.replace(/^node node_modules\/([^/]+)\/.*/, (_, cliVersion) => cliVersion), - r.mean, - r.stddev, -]); -const minMean = Math.min(...arr.map(([_, mean]) => mean)); - -const constructBarForChart = (mean, min) => { +const median = (xs) => { + const sorted = [...xs].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +}; + +const medianAbsoluteDeviation = (xs, centre) => median(xs.map((x) => Math.abs(x - centre))); + +const constructBarForChart = (value, min) => { if (min <= 0) return 'N/A'; - const slownessRatio = mean / min; - const slownessFactor = slownessRatio - 1; + const slownessFactor = value / min - 1; const maxBarLength = 30; - const visualFactor = Math.min(1, slownessFactor); - const length = Math.floor(visualFactor * maxBarLength); + const length = Math.floor(Math.min(1, slownessFactor) * maxBarLength); return '▓' + '▓'.repeat(length); }; +const loadResults = (jsonPath) => { + const json = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); + return new Map( + json.results.map((r) => { + const cliVersion = r.command.replace(/^node node_modules\/([^/]+)\/.*/, (_, v) => v); + return [cliVersion, { median: r.median, mad: medianAbsoluteDeviation(r.times, r.median) }]; + }) + ); +}; + +const findFastest = (results) => + [...results.values()].reduce((best, r) => (r.median < best.median ? r : best)); + +const renderCell = (entry, fastest) => { + const bar = constructBarForChart(entry.median, fastest.median); + const factor = entry.median / fastest.median; + if (entry === fastest) { + return `${bar} ${factor.toFixed(2)}x (Fastest)`; + } + const relativeUnc = + factor * Math.sqrt((entry.mad / entry.median) ** 2 + (fastest.mad / fastest.median) ** 2); + return `${bar} ${factor.toFixed(2)}x ± ${relativeUnc.toFixed(2)}`; +}; + +const operations = [ + { name: 'Bundle', file: 'benchmark_bundle.json' }, + { name: 'Lint', file: 'benchmark_lint.json' }, + { name: 'Check Config', file: 'benchmark_check-config.json' }, +]; + +const columns = operations.map(({ name, file }) => { + const data = loadResults(file); + return { name, data, fastest: findFastest(data) }; +}); +const versions = [...columns[0].data.keys()]; + const output = [ - '| CLI Version | Mean Time ± Std Dev (s) | Relative Performance (Lower is Faster) |', - '|---|---|---|', - ...arr.map(([cliVersion, mean, stddev]) => { - const bar = constructBarForChart(mean, minMean); - const meanFormatted = mean.toFixed(3); - const stddevFormatted = stddev.toFixed(3); - const relativeSpeedFactor = (mean / minMean).toFixed(2); - const factorSuffix = mean === minMean ? 'x (Fastest)' : 'x'; - - const timeWithStddev = `${meanFormatted}s ± ${stddevFormatted}s`; - const performanceDisplay = `${bar} ${relativeSpeedFactor}${factorSuffix}`; - - return `| ${cliVersion} | ${timeWithStddev} | ${performanceDisplay} |`; - }), + '## Performance Benchmark', + '', + `| CLI Version | ${columns.map((c) => c.name).join(' | ')} |`, + `|---|${columns.map(() => '---').join('|')}|`, + ...versions.map( + (v) => `| ${v} | ${columns.map((c) => renderCell(c.data.get(v), c.fastest)).join(' | ')} |` + ), ].join('\n'); process.stdout.write(output); diff --git a/tests/performance/make-test-command.sh b/tests/performance/make-test-command.sh index 4b1d96cfd5..2b5a42c34b 100644 --- a/tests/performance/make-test-command.sh +++ b/tests/performance/make-test-command.sh @@ -7,7 +7,11 @@ git clone https://github.com/Rebilly/api-definitions.git cd api-definitions && pnpm install && cd .. # Store the command into a text file: -echo REDOCLY_SUPPRESS_UPDATE_NOTICE=true hyperfine --warmup 1 $(cat package.json | jq '.dependencies' | jq 'keys' | jq 'map("'\''node node_modules/" + . + "/bin/cli.js bundle all@latest --config=api-definitions/redocly.yaml'\''")' | jq 'join(" ")' | xargs) --export-markdown benchmark_check.md --export-json benchmark_check.json > test-command.txt +build_cmds() { + jq -r --arg suffix "$1" '.dependencies | keys | map("'\''node node_modules/" + . + "/bin/cli.js " + $suffix + "'\''") | join(" ")' package.json +} + +echo "REDOCLY_SUPPRESS_UPDATE_NOTICE=true hyperfine --warmup 2 $(build_cmds 'bundle all@latest --config=api-definitions/redocly.yaml') --export-markdown benchmark_bundle.md --export-json benchmark_bundle.json && REDOCLY_SUPPRESS_UPDATE_NOTICE=true hyperfine --warmup 2 $(build_cmds 'lint all@latest --config=api-definitions/redocly.yaml --generate-ignore-file') --export-markdown benchmark_lint.md --export-json benchmark_lint.json && REDOCLY_SUPPRESS_UPDATE_NOTICE=true hyperfine --warmup 2 $(build_cmds 'check-config --config=api-definitions/redocly.yaml --lint-config=warn') --export-markdown benchmark_check-config.md --export-json benchmark_check-config.json" > test-command.txt # Put the command in the test section of the package.json: cat package.json | jq ".scripts.test = \"$(cat test-command.txt)\"" > package.json diff --git a/tests/performance/package.json b/tests/performance/package.json index 0f5d76d56e..c22bb87bdd 100644 --- a/tests/performance/package.json +++ b/tests/performance/package.json @@ -13,20 +13,12 @@ }, "_enhancedDependencies": { "cli-2.0.0": "npm:@redocly/cli@2.0.0", - "cli-2.03.1": "npm:@redocly/cli@2.3.1", - "cli-2.08.0": "npm:@redocly/cli@2.8.0", "cli-2.11.1": "npm:@redocly/cli@2.11.1", - "cli-2.12.0": "npm:@redocly/cli@2.12.0", - "cli-2.12.2": "npm:@redocly/cli@2.12.2", "cli-2.13.0": "npm:@redocly/cli@2.13.0", - "cli-2.14.1": "npm:@redocly/cli@2.14.1", "cli-2.14.2": "npm:@redocly/cli@2.14.2", - "cli-2.19.2": "npm:@redocly/cli@2.19.2", - "cli-2.24.1": "npm:@redocly/cli@2.24.1", - "cli-2.25.0": "npm:@redocly/cli@2.25.0", - "cli-2.25.4": "npm:@redocly/cli@2.25.4", "cli-2.27.0": "npm:@redocly/cli@2.27.0", "cli-2.30.2": "npm:@redocly/cli@2.30.2", + "cli-2.31.0": "npm:@redocly/cli@2.31.0", "cli-latest": "npm:@redocly/cli@latest", "cli-next": "file:../../redocly-cli.tgz" },